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 --xmasWir werden uns auf “simple” konzentrieren.
- Mit
--ref: Alles funktioniert (Referenz-Implementierung) - Ohne
--ref: Fehler! Diesimple/live/Dateien sind leer - das ist unser Startpunkt.
Dateien die wir live coden:
simple/live/entities.py- Vererbungsimple/live/events.py- EventBussimple/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_rangeWas 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: intund-> 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 Nonetarget: 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 abbautcooldown: 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.0Regie: Zeige die Hierarchie:
Entity
└── Unit
├── Worker
└── SoldierSchritt 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 Noneproduction_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.01.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-rendererFehler! 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. …
- Explosionspartikel spawnen
- Sound abspielen
- Logging
- 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: intWas 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: tupleDie 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-rendererDas 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 istDas 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-rendererSchaut euch das an:
- Wir haben keine Zeile in CombatSystem geändert
- Wir haben keine Zeile in MovementSystem geändert
- Wir haben nur einen neuen Handler registriert
- 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 # SpritesBeide 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
| Konzept | Datei | Kernidee |
|---|---|---|
| Vererbung | entities.py | super().__init__() - Code-Wiederverwendung |
| IoC | events.py | EventBus - Lose Kopplung |
| Duck Typing | frontends/ | Gleiches Interface, keine Basisklasse |
Take-Aways:
- Vererbung für “ist-ein” Beziehungen (Worker ist eine Unit)
- IoC für lose Kopplung (CombatSystem weiß nichts von Explosionen)
- Duck Typing statt Interface-Vererbung (ist pythonischer)