MicroCraft

Wir bauen ein kleines StarCraft-artigs Spiel.

Link zum Source-Code-Repository auf GitHub.

Vorbereitung

# Starte mit leeren live/ Dateien
./run.sh simple --simple-renderer
# -> Fehler! Das ist der Ausgangspunkt.

# Zum Testen der fertigen Version:
./run.sh simple --ref

# Zum Testen der aufwändigeren Version:
./run.sh full

# Aufwändigere Version ohne Fog of War und mit KI-Logging 
./run.sh full --debug

# Zum Testen der fertigen Version mit festlichen Explosionen
./run.sh full --xmas

Wir werden uns auf “simple” konzentrieren.

  • Mit --ref: Alles funktioniert (Referenz-Implementierung)
  • Ohne --ref: Fehler! Die simple/live/ Dateien sind leer - das ist unser Startpunkt.

Dateien die wir live coden:

  1. simple/live/entities.py - Vererbung
  2. simple/live/events.py - EventBus
  3. simple/live/effects_festive.py - IoC Payoff

Referenzimplementierung: simple/ref/ (zum Nachschlagen)


Teil 1: Vererbung

1.1 Das Problem

Wir brauchen verschiedene Einheiten im Spiel:

  • Worker - sammeln Ressourcen
  • Soldiers - kämpfen
  • Base - produziert Worker
  • Barracks - produziert Soldiers

Alle haben gemeinsame Eigenschaften: ID, Team, Position, HP. Aber auch spezifische: Worker tragen Ressourcen, Soldiers haben Schaden."

1.2 Der naive Ansatz (der ins Verderben führt)

# simple/live/entities.py - NAIVE VERSION

class Worker:
    def __init__(self, id, team, x, y, hp, speed, carrying):
        self.id = id
        self.team = team
        self.x = x
        self.y = y
        self.hp = hp
        self.max_hp = hp
        self.alive = True
        self.speed = speed
        self.carrying = carrying

class Soldier:
    def __init__(self, id, team, x, y, hp, speed, damage, attack_range):
        self.id = id          # Wieder das gleiche!
        self.team = team      # Copy-Paste...
        self.x = x
        self.y = y
        self.hp = hp
        self.max_hp = hp
        self.alive = True
        self.speed = speed
        self.damage = damage
        self.attack_range = attack_range

Was ist das Problem hier?"

  • DRY-Verletzung (Don’t Repeat Yourself)
  • Wenn wir alive ändern wollen, müssen wir es überall ändern
  • Fehleranfällig
  • Schwer erweiterbar

1.3 Die elegante Lösung: Vererbung

Schritt 1: Entity Basisklasse

class Entity:
    """Basisklasse für alle Spielobjekte."""

    def __init__(self, entity_id: int, team: int, pos: tuple, hp: int):
        self.id = entity_id
        self.team = team
        self.x, self.y = pos
        self.hp = hp
        self.max_hp = hp
        self.alive = True

    def take_damage(self, amount: int) -> None:
        """Reduziere HP um amount."""
        self.hp -= amount
        if self.hp <= 0:
            self.hp = 0
            self.alive = False
  • “Entity hat alles was ALLE Spielobjekte brauchen”
  • “Die Methode take_damage() ist auch nur einmal definiert”

Type Hints:

  • Was bedeutet amount: int und -> None? Das sind Type Hints:
# Type Hints sind Hinweise für Entwickler - Python ignoriert sie zur Laufzeit!
e = Entity(1, 1, (5, 5), 100)
e.take_damage("zehn")  # Läuft trotzdem! Aber kracht dann bei -=

# Der Vorteil: IDEs und Tools wie MyPy/Pyright finden Fehler VOR dem Ausführen:
# > error: Argument 1 to "take_damage" has incompatible type "str"; expected "int"
  • In VS Code: Hover über eine Variable zeigt den erkannten Typ.

Schritt 2: Unit - bewegliche Einheiten

class Unit(Entity):
    """Bewegliche Einheiten mit Geschwindigkeit."""

    def __init__(self, entity_id: int, team: int, pos: tuple, hp: int, speed: float):
        super().__init__(entity_id, team, pos, hp)  # <- Die Magie!
        self.speed = speed
        self.destination = None
        self.target = None
  • “super().init() ruft den Konstruktor der Elternklasse auf”
  • “Wir müssen nicht mehr self.id, self.team, etc. setzen”
  • “Unit ERBT alles von Entity und fügt speed hinzu”

Die neuen Attribute:

  • destination: Wohin die Einheit läuft - ein (x, y) Tupel oder None
  • target: Die entity_id eines Gegners den wir angreifen - eine Zahl oder None

destination ist WO wir hingehen, target ist WEN wir angreifen.

Schritt 3: Worker und Soldier

Regie: Zeige zuerst woher die Stats kommen

Woher kommen die Werte wie HP und Speed?

# In simple/shared/config.py:
def load_unit_stats() -> dict:
    with open(DATA_DIR / "units.json", "r") as f:
        return json.load(f)

UNIT_STATS = load_unit_stats()  # Beim Import geladen
// data/units.json (Auszug):
{
  "Worker": {"hp": 40, "speed": 2.5, "vision": 5, "cost": 50},
  "Soldier": {"hp": 60, "speed": 2.0, "damage": 8, "range": 4, "cooldown": 1.0}
}

“Die Stats stehen in einer JSON-Datei - so können Designer Werte ändern ohne Code anzufassen!”

Jetzt programmieren wir Worker und Soldier.

Attribute erklärt:

  • vision: Sichtweite in Tiles (wie weit die Einheit “sehen” kann)
  • state: Zustandsmaschine für Worker (“idle”, “gathering”, “returning”)
  • gather_target: Das MineralPatch das der Worker gerade abbaut
  • cooldown: Wartezeit zwischen Angriffen (Soldier muss warten bevor er wieder schlägt)
class Worker(Unit):
    """Sammelt Ressourcen."""

    def __init__(self, entity_id: int, team: int, pos: tuple):
        stats = UNIT_STATS["Worker"]  # Stats aus JSON laden
        super().__init__(
            entity_id, team, pos,
            hp=stats["hp"],
            speed=stats["speed"]
        )
        self.carrying = 0
        self.gather_target = None
        self.vision = stats["vision"]
        self.state = "idle"

class Soldier(Unit):
    """Kampfeinheit."""

    def __init__(self, entity_id: int, team: int, pos: tuple):
        stats = UNIT_STATS["Soldier"]
        super().__init__(
            entity_id, team, pos,
            hp=stats["hp"],
            speed=stats["speed"]
        )
        self.damage = stats["damage"]
        self.attack_range = stats["range"]
        self.cooldown = 0.0

Regie: Zeige die Hierarchie:

Entity
└── Unit
    ├── Worker
    └── Soldier

Schritt 4: Buildings

“Gebäude erben auch von Entity, aber NICHT von Unit - sie bewegen sich nicht!”

Building-Attribute:

  • current_production: Was gerade gebaut wird (“Worker” oder “Soldier”) oder None
  • production_progress: Fortschritt von 0.0 bis 1.0 (0% bis 100%)

Wenn start_production() aufgerufen wird, setzt es current_production auf den Einheitentyp. Das ProductionSystem erhöht dann jeden Frame production_progress bis es 1.0 erreicht.

class Building(Entity):
    """Statische Gebäude die Einheiten produzieren."""

    def __init__(self, entity_id: int, team: int, pos: tuple, hp: int):
        super().__init__(entity_id, team, pos, hp)
        self.current_production = None
        self.production_progress = 0.0

class Base(Building):
    """Hauptgebäude - produziert Worker."""

    def __init__(self, entity_id: int, team: int, pos: tuple):
        stats = BUILDING_STATS["Base"]
        super().__init__(entity_id, team, pos, stats["hp"])

    def start_production(self) -> None:
        if self.current_production is None:
            self.current_production = "Worker"
            self.production_progress = 0.0

class Barracks(Building):
    """Kaserne - produziert Soldiers."""

    def __init__(self, entity_id: int, team: int, pos: tuple):
        stats = BUILDING_STATS["Barracks"]
        super().__init__(entity_id, team, pos, stats["hp"])

    def start_production(self) -> None:
        if self.current_production is None:
            self.current_production = "Soldier"
            self.production_progress = 0.0

1.4 Test

Was funktioniert jetzt?

  • entities.py ist fertig!
  • Aber: Der Import schlägt fehl, weil events.py noch fehlt
  • Das ist OK - weiter zu Teil 2!

Versuchen wir, das Spiel zu starten:

./run.sh simple --simple-renderer

Fehler! Wir brauchen noch events.py…


Teil 2: IoC mit EventBus

2.1 Das Problem

Wir öffnen simple/shared/systems.py und sehen uns das CombatSystem an..

Was passiert, wenn eine Einheit stirbt?

# In CombatSystem:
if target.hp <= 0:
    target.alive = False
    # Publish death event
    event_bus.publish(DeathEvent(...))

Wir wollen z.B. …

  1. Explosionspartikel spawnen
  2. Sound abspielen
  3. Logging
  4. Später: Weihnachts-Effekte?

Wo schreiben wir den Code dafür?"

2.2 Der naive Ansatz

# SCHLECHT - Tight Coupling!
if target.hp <= 0:
    target.alive = False
    particle_system.spawn_explosion(x, y)  # CombatSystem kennt ParticleSystem?!
    sound_manager.play("explosion")         # Und SoundManager?!
    logger.log("Unit died")                 # Und Logger?!
    # Was wenn wir Weihnachts-Effekte wollen?
    # Müssen wir CombatSystem ändern? NEIN!

Das Problem:

  • CombatSystem muss ALLES kennen
  • Jedes neue Feature = CombatSystem ändern
  • Schwer testbar
  • Gegen Single Responsibility Principle

2.3 Die elegante Lösung: EventBus

Was ist ein “Bus”?

Nicht der Linienbus! In Computern ist ein Bus eine gemeinsame Verbindung:

┌──────────────────────────────────────────────────────────┐
│                       EVENT BUS                          │
│  ┌─────────┐    ┌─────────┐    ┌─────────┐               │
│  │ Event 1 │    │ Event 2 │    │ Event 3 │   ...         │
│  └────┬────┘    └────┬────┘    └────┬────┘               │
└───────┼──────────────┼──────────────┼────────────────────┘
        │              │              │
   ┌────┴────┐    ┌────┴────┐    ┌────┴────┐
   │Handler A│    │Handler B│    │Handler C│
   └─────────┘    └─────────┘    └─────────┘

Wie ein Hardware-Bus im Computer:

  • Alle Komponenten hängen am gleichen Bus
  • Eine Komponente sendet eine Nachricht → alle anderen können zuhören
  • Der Sender weiß nicht, wer zuhört! (Entkopplung)

Das nennt man Publish-Subscribe Pattern:

  • Publish: Nachricht auf den Bus legen
  • Subscribe: Sich als Empfänger registrieren"

Schauen wir uns simple/live/events.py an.

Schritt 1: Event Dataclasses

@dataclass
class SpawnEvent:
    """Gefeuert wenn eine neue Einheit erstellt wird."""
    kind: str          # "Worker", "Soldier", etc.
    entity_id: int
    team: int
    pos: tuple

@dataclass
class DeathEvent:
    """Gefeuert wenn eine Einheit stirbt."""
    entity_id: int
    kind: str
    team: int
    pos: tuple

@dataclass
class ResourceCollectedEvent:
    """Gefeuert wenn Ressourcen abgeliefert werden."""
    worker_id: int
    team: int
    amount: int
    team_total: int

Was ist @dataclass? Ein Python-Decorator der automatisch __init__ generiert!

# Ohne @dataclass müssten wir schreiben:
class DeathEvent:
    def __init__(self, entity_id: int, kind: str, team: int, pos: tuple):
        self.entity_id = entity_id
        self.kind = kind
        self.team = team
        self.pos = pos

# Mit @dataclass: Python generiert das automatisch aus den Annotations!
@dataclass
class DeathEvent:
    entity_id: int  # ← Diese Syntax definiert Felder
    kind: str
    team: int
    pos: tuple

Die Syntax entity_id: int sieht aus wie ein Dict, ist aber eine Typ-Annotation. Bei @dataclass werden daraus automatisch Konstruktor-Parameter!

Schritt 2: Der EventBus

Einfaches Beispiel vorweg:

# So funktioniert subscribe/publish:
event_bus.subscribe(DeathEvent, print)  # print wird Handler!
event_bus.publish(DeathEvent(1, "Worker", 1, (5, 5)))
# Ausgabe: DeathEvent(entity_id=1, kind='Worker', team=1, pos=(5, 5))

Jetzt der vollständige EventBus:

class EventBus:
    """Zentraler Event-Dispatcher - das Herz von IoC."""

    def __init__(self):
        self._subscribers: Dict[type, List[Callable]] = {}

    def subscribe(self, event_type: type, handler: Callable) -> None:
        """Registriere einen Handler für einen Event-Typ."""
        if event_type not in self._subscribers:
            self._subscribers[event_type] = []
        self._subscribers[event_type].append(handler)

    def publish(self, event: Any) -> None:
        """Benachrichtige alle Handler für diesen Event-Typ."""
        event_type = type(event)
        for handler in self._subscribers.get(event_type, []):
            handler(event)

# Globale Instanz
event_bus = EventBus()

Was ist ein Callable?

Alles was man ‘aufrufen’ kann - mit Klammern ():

# Funktionen sind Callable:
def meine_funktion(x):
    print(x)
meine_funktion("Hallo")  # ← Aufruf mit ()

# Methoden sind auch Callable:
class MeineKlasse:
    def methode(self, x):
        print(x)
obj = MeineKlasse()
obj.methode("Welt")  # ← Aufruf mit ()

# Sogar print ist ein Callable!
print("Test")  # ← Aufruf mit ()

Was bedeutet type?

Die Klasse selbst (nicht eine Instanz):

type(42)                    # → int (die Klasse!)
type("Hallo")               # → str (die Klasse!)
type(DeathEvent(1, "W", 1, (0,0)))  # → DeathEvent (die Klasse!)

Also: Dict[type, List[Callable]] bedeutet:

Ein Wörterbuch das Klassen auf Listen von aufrufbaren Dingen abbildet

{
    DeathEvent: [handler1, handler2, print],  # Wenn DeathEvent → rufe diese auf
    SpawnEvent: [logger_func],                 # Wenn SpawnEvent → rufe diese auf
}

Als Diagramm:

CombatSystem ──publish()──> EventBus ──ruft auf──> handler1(event)
                                      ──ruft auf──> handler2(event)
                                      ──ruft auf──> handler3(event)

Der Clou: CombatSystem weiß NICHTS von den Handlern!

Wir können beliebig viele Handler hinzufügen ohne CombatSystem zu ändern.

Im CombatSystem (simple/shared/systems.py) sieht es immer so aus:

# Im CombatSystem - wenn eine Einheit stirbt:
if target.hp <= 0:
    target.alive = False

    # Publish death event - WER zuhört ist uns egal!
    event_bus.publish(DeathEvent(
        entity_id=target.id,
        kind=type(target).__name__,  # "Worker" oder "Soldier"
        team=target.team,
        pos=(target.x, target.y)
    ))

Das CombatSystem published nur. Es weiß nicht, dass es Explosionen, Sounds, oder Logs gibt!

2.4 Test

./run.sh simple --simple-renderer

Das Spiel läuft!

  • Einheiten bewegen sich
  • Combat funktioniert
  • Produktion funktioniert
  • Nur: Wenn Einheiten sterben, passiert nichts Besonderes (noch keine Explosionen)

Wir fügen jetzt noch bunte Explosionen hinzu - OHNE eine Zeile im CombatSystem zu ändern!


Teil 3: Nutzen von IoC: Festliche Explosionen einbauen

3.1 CombatSystem anpassen

Jetzt fügen wir dem Spiel weihnachtliche Explosionen hinzu. Was müssen wir dazu im CombatSystem ändern?

Keine einzige Zeile!

3.2 Der FestiveExplosionHandler

Zur Erinnerung: So sieht ein DeathEvent aus.

@dataclass
class DeathEvent:
    entity_id: int
    kind: str       # "Worker", "Soldier", etc.
    team: int
    pos: tuple      # (x, y) - wo die Einheit gestorben ist

Das ist alles was wir wissen müssen: WO ist etwas gestorben (pos)!

Für die festlichen Explosionen nutzen wir das ParticleSystem, das schon eingebaut ist:

# Das ParticleSystem verwaltet visuelle Effekte. Wir nutzen:
# - spawn_burst(x, y, count, colors, ...): Viele Partikel in alle Richtungen
# - spawn(x, y, angle, speed, color, ...): Ein einzelner Partikel

# Wir müssen nicht verstehen WIE es intern funktioniert -
# nur WAS wir aufrufen können!

Wir öffnen nun simple/live/effects_festive.py.

Die Klassen-Struktur ist vorbereitet, wir füllen die Details:

class FestiveExplosionHandler:
    """Spawnt Weihnachts-Partikel wenn Einheiten sterben."""

    COLORS = [
        "#FF0000",  # Rot
        "#00FF00",  # Grün
        "#FFD700",  # Gold
        "#FFFFFF",  # Weiß (Schnee)
        "#FF69B4",  # Pink
        "#00CED1",  # Türkis
    ]

    def __init__(self, particle_system):
        self.particles = particle_system
        # DAS IST DIE IOC MAGIE:
        event_bus.subscribe(DeathEvent, self.on_death)

    def on_death(self, event: DeathEvent) -> None:
        """Wird automatisch aufgerufen wenn IRGENDWAS stirbt."""
        x, y = event.pos

        # Bunte Partikel-Explosion
        self.particles.spawn_burst(
            x=x, y=y,
            count=15,
            min_speed=50,
            max_speed=200,
            colors=self.COLORS,
            min_lifetime=0.5,
            max_lifetime=1.5,
            min_size=2,
            max_size=6
        )

        # Extra Sparkles
        for _ in range(8):
            self.particles.spawn(
                x=x + random.uniform(-0.5, 0.5),
                y=y + random.uniform(-0.5, 0.5),
                angle=random.uniform(0, 360),
                speed=random.uniform(100, 250),
                color="#FFFFFF",
                lifetime=random.uniform(0.3, 0.8),
                size=1
            )

3.3 Finale Demo

./run.sh simple --simple-renderer

Schaut euch das an:

  1. Wir haben keine Zeile in CombatSystem geändert
  2. Wir haben keine Zeile in MovementSystem geändert
  3. Wir haben nur einen neuen Handler registriert
  4. Wenn Einheiten sterben → Weihnachts-Konfetti!

Das ist Inversion of Control – lose Kopplung.

Kontext: Publish-Subscribe als IoC-Muster

“Was wir gerade gebaut haben nennt sich Publish-Subscribe Pattern. Es ist eine Möglichkeit, Inversion of Control umzusetzen.

Andere Muster für IoC:

  • Dependency Injection: Abhängigkeiten werden von außen übergeben
  • Service Locator: Zentrale Registry für Services
  • Strategy Pattern: Austauschbare Algorithmen

Allen gemeinsam: Der “Kontrollfluss” wird umgekehrt. Statt dass Code andere Module direkt aufruft, registriert sich Code um aufgerufen zu werden.


Teil 4: Duck Typing

4.1 Wenn es quakt wie eine Ente…

Wir schauen uns simple/ref/effects_festive.py und simple/ref/audio.py an.

# effects_festive.py
class FestiveExplosionHandler:
    def __init__(self, particle_system):
        event_bus.subscribe(DeathEvent, self.on_death)

    def on_death(self, event: DeathEvent) -> None:
        # Spawnt bunte Partikel
        self.particles.spawn_burst(x=event.pos[0], y=event.pos[1], ...)

# effects_festive.py
class LoggerHandler:
    def __init__(self):
        event_bus.subscribe(DeathEvent, self.on_death)

    def on_death(self, event: DeathEvent) -> None:
        # Loggt auf Konsole
        print(f"[EVENT] {event.kind} died at {event.pos}")

# audio.py
class SoundHandler:
    def __init__(self, sounds_dir):
        event_bus.subscribe(DeathEvent, self.on_death)

    def on_death(self, event: DeathEvent) -> None:
        # Spielt Sound ab
        self.play("explosion")

Was haben diese Klassen gemeinsam?

Sie haben alle eine on_death(event) Methode. Aber:

  • Es gibt keine gemeinsame Basisklasse!
  • Es gibt auch keine Interface-Definition wie in Java!
  • Der EventBus ruft einfach handler(event) auf.

Das ist Duck Typing: Wenn es eine on_death-Methode hat, kann es DeathEvents behandeln.

4.2 Unterschied zu anderen Programmiersprachen

In der objektorientierten Programmiersprache Java würde man das z.B. so implementieren:

interface DeathHandler {
    void onDeath(DeathEvent event);
}

class FestiveHandler implements DeathHandler { ... }
class LoggerHandler implements DeathHandler { ... }
class SoundHandler implements DeathHandler { ... }

In Python ist es einfacher! Der EventBus erwartet nur irgendetwas Aufrufbares.

def subscribe(self, event_type: type, handler: Callable) -> None:
    # handler muss nur aufrufbar sein - das wars!

Sogar eine einfache Funktion funktioniert:

def log_death(event):
    print(f"Died: {event.kind}")

event_bus.subscribe(DeathEvent, log_death)  # Funktioniert!

Wir fragen also nicht “Bist du ein DeathHandler?”” sondern “Kannst du aufgerufen werden mit einem Event?”

4.3 Bonus: Renderer

./run.sh simple --ref     # Kreise und Rechtecke
./run.sh full             # Sprites

Beide Renderer haben das gleiche Interface:

  • render_frame(world, camera, particles)
  • handle_input()
  • cleanup()

Keine gemeinsame Basisklasse - aber austauschbar!

Bei Renderern könnte man eine Basisklasse verwenden - dort wäre es grundsätzlich sinnvoll. Bei Event Handlern ist es oft weniger sinnvoll - jeder Handler kann ja andere Events abonnieren und andere Abhängigkeiten haben.


Zusammenfassung

KonzeptDateiKernidee
Vererbungentities.pysuper().__init__() - Code-Wiederverwendung
IoCevents.pyEventBus - Lose Kopplung
Duck Typingfrontends/Gleiches Interface, keine Basisklasse

Take-Aways:

  1. Vererbung für “ist-ein” Beziehungen (Worker ist eine Unit)
  2. IoC für lose Kopplung (CombatSystem weiß nichts von Explosionen)
  3. Duck Typing statt Interface-Vererbung (ist pythonischer)