Projektziel: Ein Tower-Defense-Spiel mit der Godot-Engine. Eine kleine Stellung der Bundeswehr hält einen Bunker gegen Zombie-Wellen: Türme auf Bauplätze setzen, aufwerten und ab Stufe 3 spezialisieren. Es gibt vier Einsatzräume, sieben Turmtypen und acht Gegnerarten. Das Spiel läuft im Browser und als Download für Windows und Linux.

Einsatzraum 1: Türme an der Straße, rechts die Bauliste und die Karte des ausgewählten Turms
Bild 1 - Einsatzraum 1: Türme an der Straße, rechts die Bauliste und die Karte des ausgewählten Turms

Projektlink: Der letzte Korridor spielen

Das Spiel

  • Einsatzraum 1 · Korridor: eine lange Straße mit Außenbogen zum Bunker.
  • Einsatzraum 2 · Schleife: schnelle Sprinter und Brandläufer, dazu der Flammenwerfer.
  • Einsatzraum 3 · Luftabwehr: keine Straße. Drohnen greifen aus allen Richtungen an, verteidigt wird mit Gepard, Roland und Abwehrdrohnen.
  • Einsatzraum 4 · Insel: Küstennebel senkt die Reichweite aller Türme, die Wellen sind die größten.

Jeder Turm hat sechs Stufen und ab Stufe 3 zwei mögliche Spezialisierungen, dazu fünf Zielmodi (zum Beispiel „Stärkster“ oder „Gruppe“). Besiegte Gegner lassen manchmal etwas fallen: Vorräte, eine Sanitätskiste für den Bunker oder Boni für mehr Reichweite und kritische Treffer. Pro Karte gibt es eine Bestenliste. Bedient wird mit der Maus und Tastenkürzeln (1–7 Turmtyp, Leertaste nächste Welle, U aufwerten).

Hauptmenü mit Einsatzräumen, Taktiktipps und Einstellungen
Bild 2 - Hauptmenü mit Einsatzräumen, Taktiktipps und Einstellungen

Aufbau des Codes

Das Spiel ist in GDScript geschrieben, der Skriptsprache von Godot. Jede Datei hat eine Aufgabe:

  • spielfeld.gd: die Spiellogik (Bauen, Wellen, Drops, Bunker, Pause).
  • hud.gd: die Oberfläche. Sie zeigt nur Werte an und meldet Eingaben per Signal an das Spielfeld.
  • spiel_daten.gd: alle Spielwerte, also Türme, Gegner und die Zusammensetzung der Wellen.
  • klang_bibliothek.gd: Schüsse, Explosionen und Musik. Alle Klänge werden im Programm aus Sinuswellen und Rauschen berechnet, das Spiel lädt keine Audiodateien.
  • turm.gd, zombie.gd, projektil.gd: die Einheiten auf dem Spielfeld.

Das Aussehen aller Menüs kommt aus einem einzigen Godot-Theme (dunkle Flächen, Bernstein als Akzentfarbe). Die Oberflächen-Knoten werden über eindeutige Namen gefunden (%AufwertenKnopf). Deshalb darf man sie in der Szene verschieben, ohne dass der Code bricht.

Die Wellen sind als Tabelle beschrieben, nicht als Code. Jede Zeile gilt ab einer Mindestwelle; ein Zufallswert zwischen 0 und 1 wird mit aufsteigenden Schwellen verglichen (Ausschnitt für Einsatzraum 1):


   "standard": [
      [12, [["Koloss", 0.18], ["Panzer", 0.48], ["Sprinter", 0.75]], "Laeufer"],
      [8, [["Koloss", 0.08], ["Panzer", 0.36], ["Sprinter", 0.60]], "Laeufer"],
      [4, [["Panzer", 0.18], ["Sprinter", 0.40]], "Laeufer"],
      [0, [], "Laeufer"]
   ],

static func waehle_zombie_typ(wellenstufe: int, profil: String, wurf: float) -> Dictionary:
   var stufen: Array = WELLEN_MIX.get(profil, WELLEN_MIX["standard"])
   for stufe in stufen:
      if wellenstufe < int(stufe[0]):
         continue
      for eintrag in stufe[1]:
         if wurf < float(eintrag[1]):
            return ZOMBIE_TYPEN[eintrag[0]]
      return ZOMBIE_TYPEN[stufe[2]]
   return ZOMBIE_TYPEN["Laeufer"]

Ab Welle 12 heißt das zum Beispiel: 18 % Kolosse, 30 % Panzer, 27 % Sprinter und der Rest Läufer.

Optimierungen: Wo die Zeit hinging

Ein automatischer Test (Rauchtest) startet jedes Level ohne Fenster, baut auf allen Bauplätzen Türme und lässt Wellen laufen. Er rechnet mit fester Bildrate (60 Bilder pro Sekunde Spielzeit) und misst die echte Rechenzeit pro Bild. Bei 60 Bildern pro Sekunde stehen pro Bild 16,7 ms zur Verfügung; alles darüber ist ein sichtbarer Ruckler. Drei Stellen haben fast die ganze Zeit gekostet:

Messung (7 Türme)vor der Optimierung Ø / Spitzedanach Ø / Spitze
Einsatzraum 1, ab Welle 121,46 ms / 57 ms0,54 ms / 13 ms
Einsatzraum 4, ab Welle 121,44 ms / 34 ms0,59 ms / 12 ms
Einsatzraum 1, ab Welle 404,24 ms / 40 ms1,27 ms / 15 ms
Einsatzraum 4, ab Welle 404,17 ms / 26 ms1,30 ms / 20 ms

1. Die Einstellungsdatei bei jedem Schuss gelesen.

Vor jedem Schuss und Treffer fragt das Spiel, ob Effekte eingeschaltet sind und wie laut sie sein sollen. Die Funktion dafür las jedes Mal die Einstellungsdatei von der Festplatte. Bei sieben Türmen sind das hunderte Dateizugriffe pro Sekunde. Jetzt wird die Datei einmal gelesen und im Speicher gehalten:


static func _wert(schluessel: String):
   if _cache.is_empty():
      _cache = _lade_von_platte()
   return _cache.get(schluessel, STANDARD.get(schluessel))

2. Klänge bei jedem Ereignis neu berechnet.

Ein Todesklang besteht aus rund 7000 Einzelwerten, die aus Sinuswellen und etwas Rauschen berechnet werden. Das geschah bei jedem besiegten Zombie neu. Jetzt entsteht jeder Klang genau einmal und wird dann wiederverwendet; außerdem laufen höchstens 14 Effekte gleichzeitig:


static func schuss(typ_name: String, klangprofil: String) -> AudioStreamWAV:
   var schluessel := "schuss|%s|%s" % [typ_name, klangprofil]
   if not _cache.has(schluessel):
      _cache[schluessel] = _erstelle_schuss_stream(typ_name, klangprofil)
   return _cache[schluessel]

3. Die Zielsuche in jedem Bild.

Jeder Turm bewertete 60-mal pro Sekunde alle Zombies in Reichweite. Granat- und Flammenwerfer zählen dabei für jedes Ziel, wie viele andere Zombies in der Nähe stehen. Bei doppelt so vielen Zombies ist das viermal so viel Arbeit. Jetzt sucht ein Turm alle 0,1 Sekunden und direkt vor jedem Schuss neu. Die Schussentscheidung bleibt dieselbe, nur das Nachdrehen des Rohrs zwischen zwei Schüssen nutzt das zuletzt gefundene Ziel:


func _process(delta: float) -> void:
   _abklingzeit = max(_abklingzeit - delta, 0.0)
   _zielsuche_timer -= delta
   var schussbereit := _abklingzeit <= 0.0
   if schussbereit or _zielsuche_timer <= 0.0 or not _ziel_gueltig(_ziel):
      _zielsuche_timer = ZIELSUCHE_INTERVALL
      _ziel = _naechsten_zombie_finden()
   if _ziel == null:
      return
   $LaufDrehpunkt.rotation = global_position.angle_to_point(_ziel.global_position) + PI / 2.0
   if not schussbereit:
      return
   _abklingzeit = feuerrate
   _schiesse_auf(_ziel)

Drei Stolperfallen in Godot

Tween mit absoluter statt relativer Bewegung.

Schadenszahlen sollen ein Stück nach oben fliegen. tween_property(self, "position", Vector2(0, -70), 1.2) bewegt sie aber zur Weltposition (0, −70), also in die linke obere Ecke. Richtig ist ein Versatz:


tween.parallel().tween_property(self, "position", _ziel_versatz, _flug_dauer).as_relative()

Einrichten, bevor der Knoten im Szenenbaum ist.

Türme werden mit instantiate() erzeugt, dann eingerichtet und erst danach mit add_child() eingefügt. Beim Einrichten gibt es noch keinen get_tree(), also auch kein Level, dessen Waffenklang der Turm übernehmen könnte. Alles, was den Szenenbaum braucht, gehört deshalb in _ready(), das Godot nach add_child() aufruft:


func _ready() -> void:
   # richte_ein() läuft vor add_child(); das Klangprofil des Levels ist erst hier erreichbar.
   _waffenklangprofil = _hole_waffenklangprofil()
   _schussprofil_anwenden()

Der Hintergrund schluckt die Klicks.

Klicks auf die Karte werden in _unhandled_input verarbeitet, also erst nachdem die Oberfläche sie nicht gebraucht hat. So landet ein Klick auf die Seitenleiste nicht versehentlich auf einem Bauplatz. Der Kartenhintergrund und die Rasterlinien sind aber ColorRect, also selbst Oberflächen-Elemente, und fingen jeden Klick ab. Die Lösung: alle Steuerelemente der Karte auf „Maus ignorieren“ stellen.


# Hintergrund und Rasterlinien der Karte sind ColorRects, also Steuerelemente.
# Ohne diese Einstellung schlucken sie jeden Mausklick, bevor er das Spielfeld erreicht.
func _welt_steuerelemente_durchlaessig_machen(knoten: Node) -> void:
   for kind in knoten.get_children():
      if kind == hud:
         continue
      if kind is Control:
         kind.mouse_filter = Control.MOUSE_FILTER_IGNORE
      _welt_steuerelemente_durchlaessig_machen(kind)

Nach der Verarbeitung markiert set_input_as_handled() den Klick als erledigt. Sonst kommt derselbe Klick über die Physik-Abfrage ein zweites Mal an, und ein Drop würde doppelt gezählt:


func _unhandled_input(event: InputEvent) -> void:
   if not (event is InputEventMouseButton) or not event.pressed:
      return
   if event.button_index == MOUSE_BUTTON_LEFT:
      _weltklick_verarbeiten(to_global(make_input_local(event).position))
   elif event.button_index == MOUSE_BUTTON_RIGHT:
      _auswahl_aufheben()
   else:
      return
   # Verhindert, dass derselbe Klick zusätzlich über Physik-Picking ankommt.
   get_viewport().set_input_as_handled()

Balance-Test mit dem echten Spiel

Ob ein Turm zu stark oder zu schwach ist, lässt sich schlecht schätzen. Deshalb gibt es einen Balance-Test, der das echte Spiel ohne Fenster durchspielt, vier Partien gleichzeitig. Ein Testspieler baut nach einem festen Bauplan (zum Beispiel „ausgewogen“ oder „nur MG-Nester“) und folgt drei einfachen Regeln: Spezialisierung sofort wählen, für den nächsten Turm aus dem Plan sparen und ihn auf den Platz setzen, der am meisten Straße abdeckt, danach immer das billigste Upgrade kaufen. Das ist keine KI; gerade weil der Spieler immer gleich entscheidet, liegen Unterschiede an den Spielwerten.


# Freier Bauplatz, von dem aus der Turm die meiste Strecke abdeckt.
# Auf der Luftkarte gibt es keine Strecke; dort in fester Reihenfolge.
func _bestes_feld(typ: String):
   var reichweite := float(_spielfeld._hole_turmdaten(typ)["reichweite"])
   var bestes = null
   var beste_abdeckung := -1
   for feld in _spielfeld.bau_felder.get_children():
      if _spielfeld._ist_feld_belegt(String(feld.name)):
         continue
      var abdeckung := 0
      if _spielfeld._wellenprofil() != "luftabwehr":
         for punkt in _streckenpunkte:
            if feld.global_position.distance_to(punkt) <= reichweite:
               abdeckung += 1
      if abdeckung > beste_abdeckung:
         beste_abdeckung = abdeckung
         bestes = feld
   return bestes

Ein kompletter Durchlauf (42 Partien bis höchstens Welle 80) dauert etwa eine Stunde. Er zeigte drei Probleme: Der Scharfschütze war dem MG-Nest in allem überlegen (MG-Nester machten nur 2 bis 5 % des Schadens), die Flammenwerfer-Aufstellung erreichte auf jeder Karte das Limit, und voll ausgebaute Stellungen hielten auf zwei Karten beliebig lange, weil die Gegner nur linear stärker wurden. Angepasst wurde in zwei Runden, jeweils mit neuem Testlauf: MG-Nest stärker, Scharfschütze teurer und etwas schwächer, Flammenwerfer schwächer, Abwehrdrohne stärker, Gegner ab Welle 25 zusätzlich 1,2 % mehr Leben pro Welle; in der zweiten Runde noch Roland und Flammenwerfer etwas schwächer, weil sie danach immer noch vorne lagen.

Karte · Planvorher Ø Wellennachher Ø Wellen
Einsatzraum 1 · ausgewogen80+64,0
Einsatzraum 1 · nur Scharfschützen80+60,5
Einsatzraum 1 · nur MG-Nester26,531,5
Einsatzraum 2 · Branddruck80+65,0
Einsatzraum 2 · ausgewogen74,553,0
Einsatzraum 2 · nur MG-Nester27,532,0
Einsatzraum 3 · Luft ausgewogen80+69,0
Einsatzraum 3 · nur Roland80+76,5
Einsatzraum 3 · nur Abwehrdrohnen65,559,5
Einsatzraum 4 · Branddruck80+56,0
Einsatzraum 4 · ausgewogen66,547,5
Einsatzraum 4 · nur MG-Nester18,024,5

Jede Stellung fällt jetzt irgendwann, damit bedeutet die Bestenliste etwas. Auf Einsatzraum 1 liegt die gemischte Aufstellung vorn, reine MG-Nester halten länger als zuvor. Noch vorne liegen der Flammenwerfer-Plan auf den Karten 2 und 4 und reine Roland-Stellungen auf der Luftkarte, allerdings mit kleinerem Abstand als vorher. Das ist der Ansatzpunkt für die nächste Runde.

Beim ersten Versuch hatte der Test eine Notbremse von 45 Minuten Spielzeit, die früher griff als das Wellenlimit; fast alle Partien endeten dort. Gemessen hatte er also nur, wie schnell die Wellen durchlaufen. Seitdem gilt: nach jedem Lauf nachsehen, warum die Partien geendet haben. Auch der Zeitraffer (doppelte Spielgeschwindigkeit) wurde erst gegen Echtzeit geprüft (je drei Läufe: Ø 26,7 Wellen in beiden Fällen).

Fazit

Die meiste Zeit ging nicht in große Algorithmen, sondern in kleine Stellen, die sehr oft laufen. Messen hat sich jedes Mal gelohnt: bei der Rechenzeit, beim Klicken und beim Balancing. Die automatischen Tests (Rauchtest, Klick- und Tastaturtest, Balance-Test) laufen nach jeder Änderung und zeigen sofort, ob noch alles stimmt.