Files
Grovewright/docs/superpowers/plans/2026-10-02-m1d-stilproben.md
Steven Wroblewski 5e9836c64a Plan M1d auf Pixelgrafik umgestellt
Nach der ersten Vorschau soll das Spielfeld Pixelgrafik im Stil
klassischer Farmspiele zeigen, aus Code gezeichnet. Konzept 7.4 und 11
sagen das jetzt. Der Plan behält Kennungen, Kartenblöcke, Vitrine und
Stilwechsel und zeichnet statt der drei Vektorstile Pixelgrafik in drei
Abwandlungen: Warm, Pastell und Glimmen. Warm war nach der Probe der
Favorit. Jede Task-Grenze wurde erneut vorab in einer Kopie von main
geprüft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:00:41 +02:00

229 KiB
Raw Permalink Blame History

M1d Stilproben Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Das Spielfeld bekommt Pixelgrafik aus Code im Stil klassischer Farmspiele, in drei Abwandlungen: Warm, Pastell und Glimmen. Eine Vitrine zeigt jeden Stil zusammen mit Dingen späterer Meilensteine, F2 wechselt den Stil im Spiel, und das Spiel merkt sich die Wahl. Damit lässt sich die Prüffrage von Meilenstein 1 beantworten: „Welche der drei Stilproben soll es sein?“

Architecture: Alles auf dem Spielfeld zeichnet Art. Jede Grafik hat eine Kennung wie terrain/soft_rock; der gewählte Stil zeichnet sie, und ein Bild game/art/<Kennung>.png ersetzt die Zeichnung, ohne dass sich Code ändert. PixelStyle zeichnet jede Kennung Pixel für Pixel in ein kleines Bild, 16 × 16 Pixel pro Kachel, und hebt es auf; die drei Stile wählen nur Palette, Umrisse und Leuchten. TileShape gibt jeder Kachel ihre acht Nachbarn, damit Felsmassen außen rund und innen geschlossen sind und zum offenen Boden hin ihre Vorderkante zeigen. MapView zeichnet die Karte in Blöcken und nach einem Treffer nur die Blöcke um die Kachel neu. Kern und Brücke bleiben unverändert, denn Grafik ändert nichts an der gemeinsamen Welt.

Tech Stack: Godot 4.7.2 (Standard-Ausgabe, Renderer „GL Compatibility“) mit Texturfilterung „Nearest“ und Einrasten auf ganze Pixel, GDScript mit Typangaben, Image und ImageTexture für die Pixelbilder. Rust 1.98.1 und godot-rust 0.5.5 bleiben unverändert.

Spec: docs/KONZEPT.md, Abschnitt 7.4 („Spielfeld aus Code“ als Pixelgrafik, „Austauschbar“), Abschnitt 8 (Meilenstein 1 mit der Prüffrage „Welche der drei Stilproben soll es sein?“) und Abschnitt 11 („Grafikstil“ und das Risiko „Grafik aus Code kann nüchtern wirken“). Abschnitt 7.4 und 11 wurden mit diesem Plan geändert: Nach der ersten Vorschau hast du dir Pixelgrafik im Stil von Stardew Valley gewünscht, nur abgewandelt, aus Code und in drei Abwandlungen; nach der Pixel-Probe war Warm dein Favorit. Dazu kommen die Antworten aus dem Probelauf von M1c im Kommentar zu Gitea #8.

Einordnung

Plan Inhalt Stand
M1a Rust-Kern Deterministischer Kern mit Karte, Spielern, Befehlen, Speicherstand, Aufzeichnung fertig, in main
M1b Brücke und Hülle GDExtension, Godot-Hülle, Takt, Figur, Speichern und Laden fertig, in main
M1c Gleichschritt im Netz Koop bis vier Spieler, Host-Welt, Prüfsummen, zwei Instanzen im Gleichschritt fertig, in main
M1d Stilproben Dieser Plan offen

Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prüffrage.

Was dieser Plan erledigt

Punkt Task
Konzept 7.4: Jede Grafik hängt an einer Kennung; ein Artist kann sie ersetzen, ohne dass sich Code ändert 1: Art mit 16 Kennungen und Bildern unter game/art/
Konzept 7.4: Spielfeld aus Code als Pixelgrafik, Bewegung durch Programmierung, alle Kachelvarianten passen aneinander 1 und 2: Nachbarmasken und Blöcke; 5 und 6: Pixelgrafik
Konzept 8 und 11: drei Stilproben zur Wahl 3: Vitrine; 4: Wechsel mit F2 und über die Befehlszeile; 5 bis 7: Warm, Pastell und Glimmen; 8: Fragebogen mit Bildern
Konzept 11, Risiko „Grafik aus Code kann nüchtern wirken“ 3 und 6: Die Vitrine zeigt lebendigen Boden, Moos, Ranke, Pflanze, Tier und Ruine in Bewegung
Probelauf M1c, Frage 2: Schläge als Gast sind sichtbar verzögert 2: Der eigene Schlag zeigt seine Splitter beim Klick
Probelauf M1c, Frage 4: Namen sind unscharf 2: Namen in Bildschirmgröße
#8: „nach höchstens NetLink.TIMEOUT_MSEC“ in CLAUDE.md 8: Wortlaut „nach etwa“. Der Test über get_statistic bleibt in #8 offen.

Nicht Teil dieses Plans

  • Regeln für lebendigen Boden, Moos, Ranken, Pflanzen, Tiere und Ruinen. Sie kommen ab Meilenstein 2. Die Vitrine bewegt ihre Dinge nach festen Formeln der Hülle; der Kern weiß nichts von ihnen.
  • Bilder von einem Artist, Porträts, Titelbild und Ton (Konzept 7.4, Meilenstein 6)
  • Bilder unter game/art/ mit mehreren Einzelbildern für Kachelränder oder Bewegung. Ein Bild ersetzt bisher nur eine feste Zeichnung.
  • Das Entfernen der Stile, die du nicht wählst, und das Feinjustieren des gewählten Stils. Beides folgt nach deinem Probelauf.
  • Die übrigen Punkte aus #5 und #8

Technische Festlegungen

Thema Festlegung Grund
Ort Nur die Hülle ändert sich, dazu CLAUDE.md und docs/. Grafik ändert nichts an der gemeinsamen Welt.
Richtung Pixelgrafik aus Code im Stil klassischer Farmspiele wie Stardew Valley, aber eigenständig. Die Grafiken von Stardew Valley werden nicht nachgebaut, nur der Charakter übernommen. Deine Wahl nach der ersten Vorschau; Konzept 7.4
Abwandlungen Warm: warme, satte Farben und dunkle Umrisse; der Standard. Pastell: weiche, helle Farben, Umrisse in einem dunkleren Ton der Fläche. Glimmen: eine gedämpfte, kühle tote Welt, in der alles Lebendige kräftig ist und leuchtet. Deine Wahl; nach der Probe war Warm dein Favorit
Szene Die Testkarte im Spiel und eine Vitrine mit Dingen späterer Meilensteine Deine Wahl vor diesem Plan. Auf der Testkarte allein ließe sich nicht beurteilen, wie Lebendiges aussieht.
Kennungen 16 Kennungen in Art.IDS. Für das Spiel: Boden, weicher Fels, harter Fels, Grundgestein, Risse, Splitter, Figur, Zielmarke. Für die Vitrine: toter und lebendiger Boden, Moos, Ranke, Faserpflanze, Stein, Weidetier, Ruine. Konzept 7.4: Jede Grafik hängt an einer Kennung.
Austausch Art.load_overrides lädt beim Start jedes Bild game/art/<Kennung>.png; es ersetzt die Zeichnung seiner Kennung. Ein Artist kann Grafik austauschen, ohne dass sich Code ändert.
Stile ArtStyle zeichnet schlichte Platzhalter. PixelStyle überschreibt jede Zeichnung mit Pixelgrafik; WarmStyle, PastelStyle und GlowStyle setzen nur Palette, Spielerfarben, Art der Umrisse und Stärke des Leuchtens. Eine neue Grafik wird einmal gezeichnet und gilt in allen Abwandlungen. Fehlt einer Kennung die Zeichnung, fehlt nur ihr Aussehen; das Spiel läuft weiter.
Pixel 16 × 16 Pixel pro Kachel, dreifach vergrößert. project.godot stellt die Texturfilterung auf „Nearest“ und rastet 2D-Transformationen auf ganze Bildschirmpixel ein; die Stile setzen jedes Bild auf ganze Pixel. Sonst würden Pixel unscharf oder verschieden breit.
Bilder PixelStyle macht jede Grafik einmal als kleines Bild (Image, daraus ImageTexture) und hebt es unter einem Schlüssel aus Kennung, Nachbarn, Abwandlung und Bewegungsschritt auf. Bewegung besteht aus wenigen Bildern: vier Laufbilder, sechs Wachstumsstufen, sechs Bilder für die Splitter, vier für das Funkeln des Mooses. Konzept 7.4: Bewegung durch Programmierung. Gemessen: Das erste Zeichnen eines Stils dauert einmalig rund 0,1 Sekunden, danach braucht ein Bild etwa 4 ms.
Sicht Schräg von vorn: Fels zeigt zum offenen Boden hin eine 6 Pixel hohe Vorderkante und wirft einen Schatten auf den Boden darunter. Figuren sind 16 × 24 Pixel groß und stehen mit den Füßen knapp unter ihrem Körper. Wie in klassischen Farmspielen. Die Zielmarke deckt weiter genau die Kachel.
Zustand Was eine Grafik zeigt, steht im Wörterbuch state, etwa die Nachbarn einer Kachel, die Zahl der Treffer, das Alter der Splitter oder wie weit eine Pflanze gewachsen ist. Die Einträge stehen im Kopf von art_style.gd. Stile und Aufrufer kennen nur Kennung und Zustand, nicht einander.
Kachelformen TileShape.mask beschreibt die acht Nachbarn einer Kachel als Bitmaske, im Uhrzeigersinn ab Norden; jenseits des Kartenrands zählt alles als Fels. PixelStyle beachtet eine Ecke nur, wenn beide Seiten daneben gesetzt sind, und kommt so mit höchstens 47 Formen je Felsart aus. Konzept 7.4: Alle Kachelvarianten passen aneinander.
Zeichnen der Karte Blöcke von 16 × 16 Kacheln. Ein Treffer zeichnet nur die Blöcke um seine Kachel neu. Die ganze Karte neu zu zeichnen dauerte 18 bis 40 ms, zu lang für jeden Treffer. Gemessen nach dem Umbau: rund 9 ms für das ganze Bild mit einem Treffer, 35 bis 40 ms für das Bild nach einem Stilwechsel.
Eigener Schlag Die Splitter erscheinen beim Klick. Risse und Abbau kommen weiter mit dem Takt, die Splitter anderer Spieler auch. Probelauf M1c: Als Gast kam der Schlag erst nach der Rundreise zum Host zurück.
Namen Die Namen über fremden Figuren werden in der Größe gezeichnet, die sie auf dem Bildschirm haben, und dann verkleinert. Probelauf M1c: In 8 Pixeln gezeichnet und von der Kamera dreifach vergrößert, wirkten sie unscharf.
Wechsel und Wahl F2 wechselt im Spiel. In der Vitrine wechseln die Tasten 1 bis 3, F2 und Knöpfe. --style=NAME wählt beim Start. Das Spiel merkt sich die Wahl in den Einstellungen, Abschnitt display, Schlüssel style. Du vergleichst schnell und spielst im gewählten Stil weiter.
Esc Esc pausiert im Spiel (toggle_pause) und führt in der Vitrine zurück (back). Die Vitrine öffnet sich nur aus dem Menü, also nie während eines laufenden Spiels.
Vitrine Eine eigene Ansicht aus dem Startmenü: 20 × 10 Kacheln, dreifach vergrößert, mit Beschriftungen. Die Pflanze wächst in 6 Sekunden, das Tier hüpft alle 1,2 Sekunden, drei Steine wandern die Ranke entlang, die Ruine erwacht und döst. Die Prüffrage braucht Dinge, die es erst später gibt.
Spielerfarben Jeder Stil hat acht Spielerfarben. Spieler n trägt Farbe n mod 8, ein Spieler, der noch nicht in der Welt ist, die erste. Bis zu vier Spieler gleichzeitig unterscheiden sich sicher.

Global Constraints

  • Arbeitsverzeichnis ist die Wurzel des Archivs: F:/Development/_kiZeug/Grovewright. Alle Befehle laufen dort in der Bash.
  • Gearbeitet wird auf dem Branch m1d-stilproben, nicht auf main.
  • Kern (core/) und Brücke (bridge/) bleiben unverändert. Dieser Plan ändert nur game/, CLAUDE.md und docs/playtests/.
  • Spielregeln gehören in den Kern, nie in die Hülle. Die Dinge der Vitrine bewegen sich nach Formeln der Vitrine und ändern keine Welt.
  • Auf dem Spielfeld zeichnet nur Art. Andere Skripte zeichnen dort keine eigenen Formen oder Farben; ausgenommen sind Texte wie die Namen über den Figuren.
  • GDScript mit Typangaben (var x := …, Rückgabetypen an jeder Funktion).
  • Texte, die Spieler sehen, kommen über tr() aus game/i18n/texts.csv, auf Deutsch und Englisch. Eingabeaktionen legt game/scripts/input_setup.gd an, nicht project.godot.
  • Tests, die Dateien schreiben, nutzen TestFiles.temporary_dir(), nie die echten Dateien des Spielers.
  • Code, Bezeichner und Kommentare im Code sind Englisch. Dokumente und Commit-Nachrichten sind Deutsch.
  • Jede Commit-Nachricht endet mit einer Zeile Co-Authored-By: Claude <Modell> <noreply@anthropic.com>, die das Claude-Modell nennt, das den Commit erstellt hat. Die Commit-Nachrichten in diesem Plan zeigen Claude Opus 5.5; wer auf einem anderen Modell läuft, setzt dessen Namen ein.
  • Nach jedem Task wird committet und hochgeladen. Das Hochladen ist vom Nutzer freigegeben.
  • Vor jedem Commit laufen fehlerfrei: cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, cargo build -p grovewright-bridge, godot --headless --path game --import und godot --headless --path game -s res://tests/run_tests.gd.
  • Die GDScript-Tests geben drei bekannte Warnzeilen aus: zwei mit „save not usable“ und eine mit „settings could not be read“. Sie stammen aus Tests, die absichtlich unbrauchbare oder gesperrte Dateien lesen. Andere Warnungen oder Fehler gehören nicht in die Ausgabe eines grünen Laufs.
  • Godot erzeugt beim Import zu jedem Skript eine .uid-Datei. Sie gehört in den Commit. Der Ordner game/.godot/ gehört nicht hinein.
  • Werkzeugstand: Rust 1.98.1, Godot 4.7.2 (Standard-Ausgabe, im Pfad als godot), godot-rust 0.5.5, Git 2.54.0.

Hinweis zur Vorabprüfung

Der gesamte Code dieses Plans wurde am 2. Oktober 2026 vorab gebaut. Du hast die drei Abwandlungen als Pixel-Probe gesehen und Warm gewählt. Danach wurde jede der acht Task-Grenzen einzeln in einer Kopie von main nachgestellt, jeweils mit dem roten und dem grünen Lauf, die der Plan beschreibt:

  • GDScript-Tests: 96 in main, nach den Tasks 1 bis 8: 110, 119, 126, 130, 138, 143, 147 und 147. Jeder grüne Lauf gab genau die drei bekannten Warnzeilen aus.
  • Rust: 151 Tests wie in main; rustfmt und Clippy meldeten nichts.
  • Das Bildschirmfoto-Werkzeug lieferte nach den Tasks 2 und 4 bis 8 die Bilder, die der Plan beschreibt, ohne Warnung.
  • Die Anweisungen dieses Plans, der Reihe nach auf main angewandt, ergeben nach jedem Task genau die geprüften Dateien.

Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten.

Dateiübersicht

Datei Aufgabe
game/scripts/art/art.gd Kennungen, Wahl des Stils, Bilder statt Zeichnungen
game/scripts/art/art_style.gd Grundklasse der Stile: Platzhalter und drei kleine Helfer
game/scripts/art/tile_shape.gd Nachbarmasken und eine feste Zahl je Kachel für kleine Abwandlungen
game/scripts/art/pixel_style.gd Pixelgrafik aus Code für jede Kennung
game/scripts/art/warm_style.gd, pastel_style.gd, glow_style.gd Die drei Abwandlungen: Palette, Spielerfarben, Umrisse, Leuchten
game/project.godot Scharfe Pixel: Texturfilterung „Nearest“, Einrasten auf ganze Pixel
game/scripts/map_view.gd Karte in Blöcken, Risse und Splitter
game/scripts/avatar.gd, figures.gd, tile_marker.gd Eigene und fremde Figuren, Zielmarke
game/scripts/showcase.gd Vitrine der Stilproben
game/scripts/menu.gd, input_setup.gd Dazu: Knopf zur Vitrine; F2, Esc und die Tasten 1 bis 3
game/scripts/launch_options.gd, save_store.gd Dazu: --style=NAME und der gemerkte Stil
game/scripts/main.gd Verbindet alles und zeigt den eigenen Schlag sofort
game/i18n/texts.csv Namen der Stile, Texte der Vitrine, Hilfezeile
game/tools/screenshot.gd Bildschirmfoto in einem Stil, auch von der Vitrine
game/tests/test_*.gd Tests der Hülle
CLAUDE.md, docs/playtests/m1d.md, docs/playtests/m1d/*.png Regeln für die Grafik, Fragebogen mit Bildern der drei Stile

Task 1: Kennungen, Kachelformen und Platzhalter

Files:

  • Create: game/scripts/art/tile_shape.gd, game/scripts/art/art_style.gd, game/scripts/art/art.gd
  • Create: game/scripts/art/warm_style.gd, game/scripts/art/pastel_style.gd, game/scripts/art/glow_style.gd (vorläufig; die Tasks 5 und 7 ersetzen sie)
  • Modify: game/i18n/texts.csv (ganze Datei ersetzen)
  • Test: game/tests/test_tile_shape.gd, game/tests/test_art_style.gd, game/tests/test_art.gd

Interfaces:

  • Consumes: TestCase mit check(condition, message), check_eq(actual, expected, message) und tree; TestFiles.temporary_dir(name) und TestFiles.remove_dir(path); I18n.load_translations(I18n.TEXTS_FILE)

  • Produces:

    • TileShape: die Bits N, NE, E, SE, S, SW, W, NW (1 bis 128, im Uhrzeigersinn ab Norden), OFFSETS: Array[Vector2i], static flags(codes: PackedByteArray, members: Array[int]) -> PackedByteArray, static mask(tile_flags: PackedByteArray, width: int, height: int, tile: Vector2i, outside_set: bool) -> int, static variant(tile: Vector2i) -> int (0 bis 2147483647, für jede Kachel immer gleich)
    • ArtStyle: key() -> StringName, title_key() -> String, draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary) -> void, player_color(player: int) -> Color und je Kennung eine Zeichenmethode (canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: _ground, _soft_rock, _hard_rock, _bedrock, _cracks, _hit, _figure, _marker, _dead_soil, _living_soil, _moss, _vine, _fiber_plant, _stone, _grazer, _ruin. Dazu die statischen Helfer share(variant: int, salt: int) -> float, sway(time: float, variant: int, speed := 1.3) -> float und along(points: PackedVector2Array, part: float) -> Vector2.
    • Art: Signal changed; die Kennungen GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, CRACKS, HIT, FIGURE, MARKER, DEAD_SOIL, LIVING_SOIL, MOSS, VINE, FIBER_PLANT, STONE, GRAZER, RUIN und IDS: Array[StringName]; OVERRIDE_DIR := "res://art"; styles: Array[ArtStyle] und style: ArtStyle; static style_keys() -> Array[StringName]; select(key: StringName) -> bool; select_next() -> void; load_overrides(directory: String) -> void; has_override(id: StringName) -> bool; draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary = {}) -> void
    • WarmStyle, PastelStyle und GlowStyle mit KEY (&"warm", &"pastel", &"glow") und den Texten STYLE_WARM, STYLE_PASTEL, STYLE_GLOW, vorerst ohne eigene Zeichnungen
  • Step 1: Auf den Branch wechseln

Der Branch existiert bereits. Er wurde zusammen mit diesem Plan angelegt und hochgeladen.

git switch m1d-stilproben

Expected: Already on 'm1d-stilproben' oder Switched to branch 'm1d-stilproben'

  • Step 2: Fehlschlagende Tests schreiben

Create game/tests/test_tile_shape.gd:

extends TestCase

## A map of three by three tiles, row by row, with rock (1) in the middle and
## in the top right corner.
const CODES := [0, 0, 1, 0, 1, 0, 0, 0, 0]


func _flags() -> PackedByteArray:
	return TileShape.flags(PackedByteArray(CODES), [1])


func test_flags_mark_the_tiles_of_the_given_codes() -> void:
	check_eq(_flags(), PackedByteArray([0, 0, 1, 0, 1, 0, 0, 0, 0]), "rock is marked")
	check_eq(TileShape.flags(PackedByteArray([0, 1, 2]), [1, 2]), PackedByteArray([0, 1, 1]), "several codes")


func test_a_mask_holds_the_marked_neighbours_clockwise_from_north() -> void:
	check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(1, 1), false), TileShape.NE, "the middle sees the corner")
	check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(1, 2), false), TileShape.N, "below the middle")
	check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(2, 1), false), TileShape.N | TileShape.W, "right of the middle")
	check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(0, 0), false), TileShape.SE, "the top left corner")


func test_beyond_the_edge_neighbours_count_as_marked_if_asked() -> void:
	var corner := TileShape.mask(_flags(), 3, 3, Vector2i(0, 0), true)
	var outside := TileShape.N | TileShape.NE | TileShape.NW | TileShape.W | TileShape.SW
	check_eq(corner, outside | TileShape.SE, "five neighbours lie outside")


func test_variants_stay_the_same_and_differ_between_neighbours() -> void:
	check_eq(TileShape.variant(Vector2i(4, 7)), TileShape.variant(Vector2i(4, 7)), "the same every time")
	var seen := {}
	for y in 4:
		for x in 4:
			var variant := TileShape.variant(Vector2i(x, y))
			check(variant >= 0, "never negative: %d" % variant)
			seen[variant] = true
	check_eq(seen.size(), 16, "sixteen tiles, sixteen variants")

Create game/tests/test_art_style.gd:

extends TestCase
## The helpers that every style draws with.


func test_a_point_can_be_found_along_a_path() -> void:
	var path := PackedVector2Array([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10)])
	check_eq(ArtStyle.along(path, 0.0), Vector2(0, 0), "the start")
	check_eq(ArtStyle.along(path, 0.5), Vector2(10, 0), "halfway is the bend")
	check_eq(ArtStyle.along(path, 0.75), Vector2(10, 5), "three quarters")
	check_eq(ArtStyle.along(path, 1.0), Vector2(10, 10), "the end")
	check_eq(ArtStyle.along(path, 2.0), Vector2(10, 10), "never beyond the end")


func test_shares_lie_between_zero_and_one_and_stay_the_same() -> void:
	for variant in [0, 1, 99, 123456, 2147483647]:
		for salt in 4:
			var share := ArtStyle.share(variant, salt)
			check(share >= 0.0 and share < 1.0, "share of %d and %d: %f" % [variant, salt, share])
			check_eq(ArtStyle.share(variant, salt), share, "the same again")


func test_a_sway_swings_gently_and_differs_between_things() -> void:
	for tenth in 100:
		var swing := ArtStyle.sway(tenth * 0.1, 7)
		check(swing >= -1.0 and swing <= 1.0, "between left and right at %.1f s: %f" % [tenth * 0.1, swing])
	check(ArtStyle.sway(0.0, 1) != ArtStyle.sway(0.0, 2), "two things do not swing alike")

Create game/tests/test_art.gd:

extends TestCase

## Draws every graphic of `art` twice, with `state` and with nothing, while
## it draws.
class Probe extends Node2D:
	var art: Art
	var state: Dictionary
	var drawn := 0

	func _draw() -> void:
		for id in Art.IDS:
			var rect := Rect2(0, 0, 48, 48) if id == Art.RUIN else Rect2(16, 16, 16, 16)
			art.draw(self, id, rect, state)
			art.draw(self, id, rect, {})
			drawn += 1


## Everything a graphic can be asked to show, so that each drawing runs
## through all its parts.
static func _full_state() -> Dictionary:
	return {
		"solid": TileShape.N | TileShape.W | TileShape.NE, "living": TileShape.E | TileShape.S,
		"variant": 123456, "hits": 2, "age": 0.5, "player": 1, "facing": -1.0, "step": 1.0,
		"moving": true, "reachable": true, "time": 2.5, "growth": 0.9, "hop": 0.5, "awake": 0.7,
		"points": PackedVector2Array([Vector2(0, 8), Vector2(10, 4), Vector2(24, 9)]),
	}


## Lets `art` draw every graphic once and returns how many were drawn.
func _draw_everything(art: Art) -> int:
	var probe := Probe.new()
	probe.art = art
	probe.state = _full_state()
	tree.root.add_child(probe)
	await tree.process_frame
	await tree.process_frame
	var drawn := probe.drawn
	tree.root.remove_child(probe)
	probe.free()
	return drawn


func test_every_style_draws_every_graphic() -> void:
	var art := Art.new()
	for style in art.styles:
		art.select(style.key())
		check_eq(await _draw_everything(art), Art.IDS.size(), "%s draws them all" % style.key())
	art.style = ArtStyle.new()
	check_eq(await _draw_everything(art), Art.IDS.size(), "the placeholders too")


func test_the_styles_are_offered_in_a_fixed_order() -> void:
	var art := Art.new()
	var keys: Array[StringName] = []
	for style in art.styles:
		keys.append(style.key())
	check_eq(keys, Art.style_keys(), "the keys in the order of the styles")
	check_eq(keys, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY], "warm, pastel, glow")
	check_eq(art.style, art.styles[0], "the first one is chosen at first")


func test_a_style_is_chosen_by_its_key() -> void:
	var art := Art.new()
	var changes := [0]
	art.changed.connect(func() -> void: changes[0] += 1)
	check(art.select(PastelStyle.KEY), "the pastel style exists")
	check_eq(art.style.key(), PastelStyle.KEY, "and is chosen")
	check(art.select(PastelStyle.KEY), "choosing it again works")
	check_eq(changes[0], 1, "only a real change is announced")
	check(not art.select(&"watercolour"), "an unknown key is refused")
	check_eq(art.style.key(), PastelStyle.KEY, "and changes nothing")


func test_the_next_style_follows_and_the_first_comes_after_the_last() -> void:
	var art := Art.new()
	var seen: Array[StringName] = []
	for i in art.styles.size() + 1:
		seen.append(art.style.key())
		art.select_next()
	check_eq(seen, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY, WarmStyle.KEY], "round and round")


func test_every_style_has_a_name_in_every_language() -> void:
	for translation in I18n.load_translations(I18n.TEXTS_FILE):
		for style in Art.new().styles:
			var title := String(translation.get_message(style.title_key()))
			check(not title.is_empty(), "%s in %s" % [style.title_key(), translation.locale])


func test_a_picture_named_after_an_id_replaces_its_drawing() -> void:
	var directory := TestFiles.temporary_dir("art")
	DirAccess.make_dir_recursive_absolute(directory.path_join("terrain"))
	var image := Image.create(16, 16, false, Image.FORMAT_RGBA8)
	image.fill(Color.RED)
	image.save_png(directory.path_join("terrain/ground.png"))
	var art := Art.new()
	art.load_overrides(directory)
	check(art.has_override(Art.GROUND), "the ground has a picture")
	check(not art.has_override(Art.SOFT_ROCK), "soft rock has none")
	check_eq(await _draw_everything(art), Art.IDS.size(), "the picture is drawn like the rest")
	art.load_overrides(directory.path_join("missing"))
	check(not art.has_override(Art.GROUND), "loading again drops the picture")
	TestFiles.remove_dir(directory)


func test_without_the_folder_of_pictures_everything_is_drawn() -> void:
	var art := Art.new()
	art.load_overrides(Art.OVERRIDE_DIR)
	for id in Art.IDS:
		check(not art.has_override(id), "no picture for %s" % id)
  • Step 3: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 96 passed, 3 failed. test_art.gd, test_art_style.gd und test_tile_shape.gd laden nicht; davor stehen Zeilen wie SCRIPT ERROR: Parse Error: Identifier "TileShape" not declared in the current scope.

  • Step 4: Kachelformen, Stile und Kennungen schreiben

Lege den Ordner game/scripts/art/ an.

Create game/scripts/art/tile_shape.gd:

class_name TileShape
extends RefCounted
## Neighbourhoods on the tile grid, so that a style can draw each tile to fit
## the tiles around it, and a stable number per tile for small variations.

## Bits of a neighbour mask, clockwise from north.
const N := 1
const NE := 2
const E := 4
const SE := 8
const S := 16
const SW := 32
const W := 64
const NW := 128
## Where the neighbour of each bit lies, in the order of the bits.
const OFFSETS: Array[Vector2i] = [
	Vector2i(0, -1), Vector2i(1, -1), Vector2i(1, 0), Vector2i(1, 1),
	Vector2i(0, 1), Vector2i(-1, 1), Vector2i(-1, 0), Vector2i(-1, -1),
]


## One byte per tile of `codes`: 1 if `members` holds its code, else 0.
static func flags(codes: PackedByteArray, members: Array[int]) -> PackedByteArray:
	var result := PackedByteArray()
	result.resize(codes.size())
	for index in codes.size():
		result[index] = 1 if members.has(codes[index]) else 0
	return result


## The neighbours of `tile` whose flag is 1, as a mask of the bits above, on
## a map of `width` by `height` tiles. Beyond the edge of the map, every
## neighbour counts as set if `outside_set` is true.
static func mask(tile_flags: PackedByteArray, width: int, height: int, tile: Vector2i, outside_set: bool) -> int:
	var result := 0
	for bit in 8:
		var at := tile + OFFSETS[bit]
		var inside := at.x >= 0 and at.y >= 0 and at.x < width and at.y < height
		if (inside and tile_flags[at.y * width + at.x] == 1) or (not inside and outside_set):
			result |= 1 << bit
	return result


## A number from 0 to 2147483647 that is the same for `tile` every time and
## looks random from one tile to the next.
static func variant(tile: Vector2i) -> int:
	var h := (tile.x * 374761393 + tile.y * 668265263) & 0x7fffffff
	h = ((h ^ (h >> 13)) * 1274126177) & 0x7fffffff
	return h ^ (h >> 16)

Create game/scripts/art/art_style.gd:

class_name ArtStyle
extends RefCounted
## One look of the playing field. Draws every graphic that Art names, with
## code. This base class draws plain placeholders; each style overrides the
## drawing of every id.
##
## The entries of `state`, by id:
## - GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, DEAD_SOIL and LIVING_SOIL:
##   `solid`, a TileShape mask of the neighbours that are rock or the edge of
##   the map; `living`, for LIVING_SOIL, the mask of living neighbours;
##   `variant`, from TileShape.variant
## - CRACKS: `hits`, 1 or more; `variant`
## - HIT: `age`, from 0 when the pickaxe lands to 1 when the burst is over;
##   `variant`
## - FIGURE: `player`, the player's number or -1; `facing`, 1.0 to the
##   right or -1.0 to the left; `step`, an angle that grows while the figure
##   walks; `moving`
## - MARKER: `reachable`
## - MOSS: `variant`; `time`, in seconds
## - VINE: `points`, the path of the vine; `time`
## - FIBER_PLANT: `growth`, from 0 (a seed) to 1 (grown); `time`
## - GRAZER: `hop`, from 0 to 1 during one hop; `facing`
## - RUIN: `awake`, from 0 (asleep) to 1 (awake); `time`
## - STONE: nothing


## The name of the style on the command line and in the settings. The base
## class is no style to choose and has none.
func key() -> StringName:
	return &""


## The key of the style's name in the texts, or an empty text for the base
## class.
func title_key() -> String:
	return ""


## Draws the graphic `id` into `rect` on `canvas`, while `canvas` draws.
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary) -> void:
	match id:
		Art.GROUND:
			_ground(canvas, rect, state)
		Art.SOFT_ROCK:
			_soft_rock(canvas, rect, state)
		Art.HARD_ROCK:
			_hard_rock(canvas, rect, state)
		Art.BEDROCK:
			_bedrock(canvas, rect, state)
		Art.CRACKS:
			_cracks(canvas, rect, state)
		Art.HIT:
			_hit(canvas, rect, state)
		Art.FIGURE:
			_figure(canvas, rect, state)
		Art.MARKER:
			_marker(canvas, rect, state)
		Art.DEAD_SOIL:
			_dead_soil(canvas, rect, state)
		Art.LIVING_SOIL:
			_living_soil(canvas, rect, state)
		Art.MOSS:
			_moss(canvas, rect, state)
		Art.VINE:
			_vine(canvas, rect, state)
		Art.FIBER_PLANT:
			_fiber_plant(canvas, rect, state)
		Art.STONE:
			_stone(canvas, rect, state)
		Art.GRAZER:
			_grazer(canvas, rect, state)
		Art.RUIN:
			_ruin(canvas, rect, state)
		_:
			push_error("no drawing for the graphic %s" % id)


## The colour of the figure of `player`. Players keep their colour; -1, a
## player not in the world yet, gets the first.
func player_color(player: int) -> Color:
	return Color(0.95, 0.85, 0.6) if player <= 0 else Color(0.55, 0.8, 0.95)


func _ground(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.42, 0.36, 0.28))


func _soft_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.56, 0.55, 0.52))


func _hard_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.33, 0.33, 0.37))


func _bedrock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.12, 0.12, 0.14))


func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var hits: int = state.get("hits", 1)
	var inset := rect.grow(-3.0)
	for line in hits:
		var part := (line + 1.0) / (hits + 1.0)
		var from := Vector2(inset.position.x, inset.position.y + inset.size.y * part)
		var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - part))
		canvas.draw_line(from, to, Color(0.1, 0.08, 0.06, 0.8), 1.0)


func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var age: float = state.get("age", 0.0)
	for chip in 4:
		var direction := Vector2.RIGHT.rotated(TAU * chip / 4.0 + 0.4)
		var at := rect.get_center() + direction * rect.size.x * 0.6 * age
		canvas.draw_rect(Rect2(at - Vector2(1, 1), Vector2(2, 2)), Color(0.8, 0.8, 0.75, 1.0 - age))


func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var radius := rect.size.x * 0.3
	canvas.draw_circle(rect.get_center(), radius + 1.0, Color(0.15, 0.1, 0.05))
	canvas.draw_circle(rect.get_center(), radius, player_color(state.get("player", -1)))


func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var color := Color(0.4, 1.0, 0.4) if state.get("reachable", false) else Color(1.0, 0.4, 0.4)
	canvas.draw_rect(rect, color, false, 1.0)


func _dead_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.45, 0.42, 0.38))


func _living_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.35, 0.5, 0.25))


func _moss(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_circle(rect.get_center(), rect.size.x * 0.3, Color(0.3, 0.65, 0.3))


func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void:
	var points: PackedVector2Array = state.get("points", PackedVector2Array())
	if points.size() >= 2:
		canvas.draw_polyline(points, Color(0.25, 0.6, 0.25), 2.0)


func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var growth: float = state.get("growth", 1.0)
	var foot := Vector2(rect.get_center().x, rect.end.y)
	canvas.draw_line(foot, foot - Vector2(0, rect.size.y * growth), Color(0.4, 0.7, 0.3), 2.0)


func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	canvas.draw_circle(rect.get_center(), rect.size.x * 0.4, Color(0.6, 0.6, 0.58))


func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var lift := sin(PI * float(state.get("hop", 0.0))) * rect.size.y * 0.3
	canvas.draw_circle(rect.get_center() - Vector2(0, lift), rect.size.x * 0.35, Color(0.92, 0.9, 0.85))


func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	canvas.draw_rect(rect, Color(0.5, 0.5, 0.48))
	var awake: float = state.get("awake", 0.0)
	canvas.draw_circle(rect.get_center(), rect.size.x * 0.2, Color(1.0, 0.85, 0.3, awake))


# Helpers for the styles.


## A share from 0 to 1 that is the same for the same `variant` and `salt`.
static func share(variant: int, salt: int) -> float:
	return float(TileShape.variant(Vector2i(variant & 0xffff, salt)) % 10007) / 10007.0


## A gentle swing from -1 to 1, slow and different for every `variant`.
static func sway(time: float, variant: int, speed := 1.3) -> float:
	return sin(time * speed + share(variant, 7) * TAU)


## The point at `part` of the way along `points`, from 0 to 1.
static func along(points: PackedVector2Array, part: float) -> Vector2:
	var total := 0.0
	for i in points.size() - 1:
		total += points[i].distance_to(points[i + 1])
	var left := clampf(part, 0.0, 1.0) * total
	for i in points.size() - 1:
		var length := points[i].distance_to(points[i + 1])
		if left <= length:
			return points[i].lerp(points[i + 1], left / length if length > 0.0 else 0.0)
		left -= length
	return points[points.size() - 1]

Create game/scripts/art/art.gd:

class_name Art
extends RefCounted
## Every graphic on the playing field has an id. Art draws it in the chosen
## style, or from a picture whose file name is the id, so that an artist can
## replace a graphic without changing code: res://art/terrain/ground.png
## replaces the drawing of GROUND.

## The style changed, or pictures were loaded. Everything drawn by Art has to
## be drawn again.
signal changed

const GROUND := &"terrain/ground"
const SOFT_ROCK := &"terrain/soft_rock"
const HARD_ROCK := &"terrain/hard_rock"
const BEDROCK := &"terrain/bedrock"
## The cracks of a tile that took hits, drawn over the tile.
const CRACKS := &"terrain/cracks"
## The burst where a pickaxe lands.
const HIT := &"effect/hit"
const FIGURE := &"figure"
## The outline of the tile under the mouse.
const MARKER := &"marker"
## Graphics of later milestones, shown in the showcase of the style samples.
const DEAD_SOIL := &"soil/dead"
const LIVING_SOIL := &"soil/living"
const MOSS := &"plant/moss"
const VINE := &"plant/vine"
const FIBER_PLANT := &"plant/fiber"
const STONE := &"item/stone"
const GRAZER := &"animal/grazer"
const RUIN := &"ruin"

## Every id, in the order the showcase and the checks go through them.
const IDS: Array[StringName] = [
	GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, CRACKS, HIT, FIGURE, MARKER,
	DEAD_SOIL, LIVING_SOIL, MOSS, VINE, FIBER_PLANT, STONE, GRAZER, RUIN,
]
## The folder where pictures that replace drawings are looked for.
const OVERRIDE_DIR := "res://art"

## The styles to choose from, in the order the game offers them.
var styles: Array[ArtStyle] = [WarmStyle.new(), PastelStyle.new(), GlowStyle.new()]
## The style everything is drawn in.
var style: ArtStyle = styles[0]
var _overrides: Dictionary[StringName, Texture2D] = {}


## The keys of the styles, as LaunchOptions and the settings name them.
static func style_keys() -> Array[StringName]:
	return [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY]


## Draws everything in the style named `key` from now on. Returns false, and
## changes nothing, if no style has that key.
func select(key: StringName) -> bool:
	for candidate in styles:
		if candidate.key() == key:
			if candidate != style:
				style = candidate
				changed.emit()
			return true
	return false


## Switches to the style after the current one, and from the last to the
## first.
func select_next() -> void:
	style = styles[(styles.find(style) + 1) % styles.size()]
	changed.emit()


## Uses the picture `<directory>/<id>.png` instead of the drawing, for every
## id that has one. Pictures loaded earlier are dropped.
func load_overrides(directory: String) -> void:
	_overrides.clear()
	for id in IDS:
		var texture := _load_texture(directory.path_join(String(id) + ".png"))
		if texture != null:
			_overrides[id] = texture
	changed.emit()


## True if a picture replaces the drawing of `id`.
func has_override(id: StringName) -> bool:
	return _overrides.has(id)


## Draws the graphic `id` into `rect` on `canvas`. Call it only while
## `canvas` draws, from its _draw(). `state` carries what the graphic shows,
## for example how far a plant has grown; see the styles for the entries.
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary = {}) -> void:
	var texture: Texture2D = _overrides.get(id)
	if texture != null:
		canvas.draw_texture_rect(texture, rect, false)
	else:
		style.draw(canvas, id, rect, state)


static func _load_texture(path: String) -> Texture2D:
	if path.begins_with("res://"):
		return load(path) as Texture2D if ResourceLoader.exists(path) else null
	if not FileAccess.file_exists(path):
		return null
	var image := Image.load_from_file(path)
	return ImageTexture.create_from_image(image) if image != null else null

Die drei Stile sind vorerst dünn: Sie nennen ihren Schlüssel und ihren Namen und zeichnen die Platzhalter von ArtStyle. Die Tasks 5 und 7 ersetzen sie durch die fertigen Abwandlungen.

Create game/scripts/art/warm_style.gd:

class_name WarmStyle
extends ArtStyle
## Warm, full colours and dark outlines, close to the classic farming games.
## Until it draws pixel art, it draws the placeholders of ArtStyle.

const KEY := &"warm"


func key() -> StringName:
	return KEY


func title_key() -> String:
	return "STYLE_WARM"

Create game/scripts/art/pastel_style.gd:

class_name PastelStyle
extends ArtStyle
## Soft, light colours with outlines in a darker shade of what they surround.
## Until it draws pixel art, it draws the placeholders of ArtStyle.

const KEY := &"pastel"


func key() -> StringName:
	return KEY


func title_key() -> String:
	return "STYLE_PASTEL"

Create game/scripts/art/glow_style.gd:

class_name GlowStyle
extends ArtStyle
## A dim, cool world in which everything living shines and glows.
## Until it draws pixel art, it draws the placeholders of ArtStyle.

const KEY := &"glow"


func key() -> StringName:
	return KEY


func title_key() -> String:
	return "STYLE_GLOW"

Ersetze den gesamten Inhalt von game/i18n/texts.csv durch die folgende Fassung. Neu sind die letzten drei Zeilen mit den Namen der Stile.

keys,de,en
STONE_COUNT,Stein: %d,Stone: %d
SAVED,Gespeichert,Saved
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
LOADED,Spielstand geladen,Save loaded
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
PAUSED,Pause,Paused
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause
DEFAULT_NAME,Spieler,Player
MENU_NAME,Dein Name,Your name
MENU_SOLO,Allein spielen,Play alone
MENU_HOST,Welt öffnen,Open your world
MENU_ADDRESS,Adresse des Hosts,Address of the host
MENU_JOIN,Beitreten,Join
CONNECTING,Verbinde mit %s …,Connecting to %s …
CONNECT_FAILED,Keine Verbindung zu %s,Could not reach %s
ADDRESS_INVALID,Das ist keine Adresse: %s,That is not an address: %s
HOST_LEFT,Der Host hat die Welt geschlossen,The host closed the world
PROTOCOL_ERROR,Die Verbindung zum Host ist gestört,The connection to the host is broken
REFUSED_OTHER_PROTOCOL,Der Host hat eine andere Version des Spiels,The host has another version of the game
REFUSED_OTHER_RULES,Der Host hat andere Spielregeln. Ihr braucht denselben Stand des Spiels.,The host has other rules. You need the same version of the game.
REFUSED_WORLD_FULL,Die Welt ist voll: Vier Spieler sind schon da,The world is full: four players are in it
REFUSED_TOO_MANY_PLAYERS,Diese Welt kennt schon 16 Spieler und nimmt keine neuen mehr auf,This world knows 16 players already and takes no new ones
REFUSED_ALREADY_PLAYING,Ein Spieler mit deinem Schlüssel ist schon in der Welt,A player with your key is in the world already
STATUS_HOST,Welt offen auf Port %d · Spieler: %d,World open on port %d · players: %d
STATUS_GUEST,Gast bei %s · Spieler: %d,Guest of %s · players: %d
OPEN_FAILED,Port %d lässt sich nicht öffnen,Port %d cannot be opened
PLAYER_JOINED,%s ist da,%s joined
PLAYER_LEFT,%s ist gegangen,%s left
RESYNCED,Welt mit dem Host abgeglichen,World matched with the host again
PAUSE_ONLY_ALONE,Pause gibt es nur ohne Gäste,Pausing only works without guests
LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when playing alone
HOST_SAVES,Nur der Host speichert,Only the host saves
KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now
SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now
STYLE_WARM,Warm,Warm
STYLE_PASTEL,Pastell,Pastel
STYLE_GLOW,Glimmen,Glow
  • Step 5: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 110 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Kennungen, Kachelformen und Platzhalter für die Grafik

Jede Grafik auf dem Spielfeld bekommt eine Kennung. Art zeichnet sie im
gewählten Stil oder aus einem Bild gleichen Namens unter game/art.
ArtStyle zeichnet vorerst schlichte Platzhalter. TileShape beschreibt
die Nachbarn einer Kachel, damit Felsmassen außen rund und innen
geschlossen gezeichnet werden können. Die drei Stile Warm, Pastell und
Glimmen gibt es schon mit Namen, noch ohne eigene Zeichnungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push -u origin m1d-stilproben

Task 2: Spielfeld über Art

Files:

  • Modify: game/scripts/map_view.gd, game/scripts/avatar.gd, game/scripts/figures.gd, game/scripts/tile_marker.gd, game/scripts/main.gd (jeweils ganze Datei ersetzen)
  • Test: game/tests/test_map_view.gd (neu); game/tests/test_avatar.gd (zwei Tests am Ende); game/tests/test_figures.gd, game/tests/test_main.gd (je ein Test am Ende)

Interfaces:

  • Consumes: Art und TileShape aus Task 1. Aus GwSession: is_running(), map_width(), map_height(), terrain_codes(), damage_codes(), terrain_at(x, y), spawn_point(), local_player(), player_name(player), dig(x, y), step() und die Konstanten TERRAIN_GROUND, TERRAIN_SOFT_ROCK, TERRAIN_HARD_ROCK, TERRAIN_BEDROCK, DIG_COOLDOWN_TICKS. Aus GridMotion: TILE_SIZE, move, tile_center.

  • Produces:

    • MapView: art: Art; SOLID: Array[int] (weicher Fels, harter Fels, Grundgestein), BLOCK := 16, HIT_SECONDS := 0.35; static id_of(terrain: int) -> StringName; reload() nimmt die ganze Karte neu aus der Sitzung, redraw() zeichnet alles neu, refresh_tile(tile: Vector2i) nimmt eine Kachel neu und zeichnet die Blöcke um sie, hit(tile: Vector2i) zeigt Splitter; für Tests block_count() -> int, burst_count() -> int und mask_at(tile: Vector2i) -> int. Wer die Karte neu zeigen will, ruft reload(); queue_redraw() auf MapView selbst zeichnet die Karte nicht mehr.
    • Avatar: art: Art, player := -1, facing := 1.0, step := 0.0, moving := false, STEP_PER_PIXEL := 0.16, static figure_rect(at: Vector2) -> Rect2: eine Kachel breit, anderthalb hoch, die Unterkante bei at.y + RADIUS + 1
    • Figures: art: Art, facing_of(player: int) -> float; die Namen stehen über Avatar.figure_rect
    • TileMarker: art: Art
    • Hauptszene: art: Art. dig_at(tile) zeigt die Splitter sofort, wenn die Kachel kein Boden ist.
  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/test_map_view.gd:

extends TestCase
## The map view over a real world of the core.

var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])


func _session() -> GwSession:
	var session := GwSession.new()
	session.start_new(7)
	session.join(key, "Ada")
	session.step()
	return session


func _view(session: GwSession) -> MapView:
	var view := MapView.new()
	view.art = Art.new()
	view.session = session
	tree.root.add_child(view)
	view.reload()
	return view


func _free(view: MapView) -> void:
	tree.root.remove_child(view)
	view.free()


## The first tile of the soft rock beside the spawn clearing.
func _rock(session: GwSession) -> Vector2i:
	return session.spawn_point() + Vector2i(7, -2)


func test_the_map_is_drawn_in_blocks_that_cover_it() -> void:
	var session := _session()
	var view := _view(session)
	var across := ceili(session.map_width() / float(MapView.BLOCK))
	var down := ceili(session.map_height() / float(MapView.BLOCK))
	check_eq(view.block_count(), across * down, "blocks over the whole map")
	for style in Art.style_keys():
		view.art.select(style)
		view.redraw()
		await tree.process_frame
	_free(view)


func test_each_tile_is_drawn_to_fit_the_rock_around_it() -> void:
	var session := _session()
	var view := _view(session)
	var solid := TileShape.flags(session.terrain_codes(), MapView.SOLID)
	for tile: Vector2i in [Vector2i(0, 0), session.spawn_point(), _rock(session)]:
		var expected := TileShape.mask(solid, session.map_width(), session.map_height(), tile, true)
		check_eq(view.mask_at(tile), expected, "the neighbours of %s" % tile)
	_free(view)


func test_a_broken_rock_no_longer_shapes_its_neighbours() -> void:
	var session := _session()
	var view := _view(session)
	var rock := _rock(session)
	var west := rock + Vector2i(-1, 0)
	check(view.mask_at(west) & TileShape.E != 0, "the rock lies east of its neighbour")
	for hit in 3:
		session.dig(rock.x, rock.y)
		session.step()
		for wait in GwSession.DIG_COOLDOWN_TICKS:
			session.step()
	check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three hits break it")
	view.refresh_tile(rock)
	check(view.mask_at(west) & TileShape.E == 0, "now there is ground east of the neighbour")
	await tree.process_frame
	_free(view)


func test_a_burst_of_chips_shows_for_a_moment() -> void:
	var view := _view(_session())
	view.hit(Vector2i(3, 3))
	check_eq(view.burst_count(), 1, "a burst")
	view._process(MapView.HIT_SECONDS * 0.5)
	check_eq(view.burst_count(), 1, "still in the air")
	await tree.process_frame
	view._process(MapView.HIT_SECONDS * 0.6)
	check_eq(view.burst_count(), 0, "gone after its time")
	_free(view)


func test_without_a_world_nothing_is_drawn() -> void:
	var view := _view(_session())
	view.session = null
	view.reload()
	check_eq(view.block_count(), 0, "no blocks")
	view.refresh_tile(Vector2i(1, 1))
	await tree.process_frame
	_free(view)

Füge am Ende von game/tests/test_avatar.gd diese beiden Tests an. Wie zwischen allen Funktionen stehen davor zwei Leerzeilen:

func test_a_walking_figure_steps_and_faces_the_way_it_goes() -> void:
	var avatar := Avatar.new()
	avatar.is_walkable = _open_field
	avatar.position = GridMotion.tile_center(Vector2i(50, 50))
	check(not avatar.moving, "standing at first")
	avatar.walk(Vector2.LEFT, 0.1)
	check(avatar.moving, "walking")
	check_eq(avatar.facing, -1.0, "facing left")
	check(avatar.step > 0.0, "the step turns")
	avatar.walk(Vector2.UP, 0.1)
	check_eq(avatar.facing, -1.0, "walking up keeps the way it faces")
	avatar.walk(Vector2.ZERO, 0.1)
	check(not avatar.moving, "standing again")
	avatar.free()


func test_a_figure_stands_upright_in_its_tile() -> void:
	var rect := Avatar.figure_rect(Vector2(100, 100))
	check_eq(rect.size, Vector2(GridMotion.TILE_SIZE, GridMotion.TILE_SIZE * 1.5), "a tile wide, one and a half tall")
	check_eq(rect.get_center().x, 100.0, "centred on the figure")
	check_eq(rect.end.y, 100.0 + Avatar.RADIUS + 1.0, "its feet just below its body")

Füge am Ende von game/tests/test_figures.gd diesen Test an, wieder nach zwei Leerzeilen:

func test_a_figure_faces_the_way_it_glides() -> void:
	var figures := Figures.new()
	figures.place(1, Vector2(100.0, 0.0))
	check_eq(figures.facing_of(1), 1.0, "facing right at first")
	figures.place(1, Vector2.ZERO)
	figures._process(0.02)
	check_eq(figures.facing_of(1), -1.0, "facing left while it glides to the left")
	figures.free()

Füge am Ende von game/tests/test_main.gd diesen Test an, nach zwei Leerzeilen:

func test_an_own_hit_shows_its_burst_at_once() -> void:
	var directory := TestFiles.temporary_dir("burst")
	var main := _start_main(directory)
	main.play(false)
	var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
	check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_SOFT_ROCK, "rock beside the clearing")
	main.dig_at(rock)
	check_eq(main.map_view.burst_count(), 1, "the burst shows before the hit lands in a tick")
	_clean_up([directory])
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 107 passed, 4 failed. test_avatar.gd lädt nicht (SCRIPT ERROR: Parse Error: Static function "figure_rect()" not found in base "Avatar".), test_map_view.gd auch nicht (Parse Error: Cannot find member "BLOCK" in base "MapView".). Die angehängten Tests in test_figures.gd und test_main.gd scheitern mit Invalid call. Nonexistent function 'facing_of' in base 'Node2D (Figures)'. und Invalid call. Nonexistent function 'burst_count' in base 'Node2D (MapView)'. Am Ende meldet Godot liegen gebliebene Objekte (… were leaked at exit), weil die abgebrochenen Tests nicht aufräumen.

  • Step 3: Karte, Figuren und Zielmarke über Art zeichnen

Ersetze den gesamten Inhalt von game/scripts/map_view.gd durch:

class_name MapView
extends Node2D
## Draws the terrain of the shared world in the style of `art`: every tile to
## fit its neighbours, the cracks of the hits it took, and a short burst of
## chips where a pickaxe lands. The map is drawn in square blocks, so that a
## hit draws only the blocks around its tile again.

## The terrain that rock masses and the edge of the map are made of. Tiles
## beside it are drawn to fit it.
const SOLID: Array[int] = [GwSession.TERRAIN_SOFT_ROCK, GwSession.TERRAIN_HARD_ROCK, GwSession.TERRAIN_BEDROCK]
## Tiles along each side of a block.
const BLOCK := 16
## Seconds a burst of chips lasts.
const HIT_SECONDS := 0.35

var session: GwSession
var art: Art
var _width := 0
var _height := 0
var _terrain := PackedByteArray()
var _damage := PackedByteArray()
var _solid := PackedByteArray()
var _masks := PackedInt32Array()
var _variants := PackedInt32Array()
var _blocks: Array[MapBlock] = []
var _bursts: Array[Dictionary] = []
var _burst_layer := Node2D.new()


## A square part of the map that draws itself.
class MapBlock extends Node2D:
	var view: MapView
	var tiles: Rect2i

	func _init(owner_view: MapView, block_tiles: Rect2i) -> void:
		view = owner_view
		tiles = block_tiles

	func _draw() -> void:
		view.draw_block(self, tiles)


func _ready() -> void:
	_burst_layer.draw.connect(_draw_bursts)
	add_child(_burst_layer)
	set_process(false)


## The graphic of a terrain code.
static func id_of(terrain: int) -> StringName:
	match terrain:
		GwSession.TERRAIN_SOFT_ROCK:
			return Art.SOFT_ROCK
		GwSession.TERRAIN_HARD_ROCK:
			return Art.HARD_ROCK
		GwSession.TERRAIN_BEDROCK:
			return Art.BEDROCK
	return Art.GROUND


## Takes the whole map from `session` anew and draws it, or shows nothing if
## no world runs. Call it when a world starts or was replaced.
func reload() -> void:
	for block in _blocks:
		remove_child(block)
		block.free()
	_blocks.clear()
	_bursts.clear()
	_burst_layer.queue_redraw()
	if session == null or not session.is_running():
		_width = 0
		_height = 0
		return
	_width = session.map_width()
	_height = session.map_height()
	_terrain = session.terrain_codes()
	_damage = session.damage_codes()
	_solid = TileShape.flags(_terrain, SOLID)
	_masks.resize(_width * _height)
	_variants.resize(_width * _height)
	for y in _height:
		for x in _width:
			var tile := Vector2i(x, y)
			_masks[y * _width + x] = TileShape.mask(_solid, _width, _height, tile, true)
			_variants[y * _width + x] = TileShape.variant(tile)
	for top in range(0, _height, BLOCK):
		for left in range(0, _width, BLOCK):
			var block := MapBlock.new(self, Rect2i(left, top, mini(BLOCK, _width - left), mini(BLOCK, _height - top)))
			_blocks.append(block)
			add_child(block)
	move_child(_burst_layer, -1)


## Draws everything again, for example in another style.
func redraw() -> void:
	for block in _blocks:
		block.queue_redraw()


## Takes `tile` anew from the session after it changed, and draws it and the
## tiles around it again, which may have to fit it differently now.
func refresh_tile(tile: Vector2i) -> void:
	if _blocks.is_empty() or not Rect2i(0, 0, _width, _height).has_point(tile):
		return
	_terrain = session.terrain_codes()
	_damage = session.damage_codes()
	var index := tile.y * _width + tile.x
	_solid[index] = 1 if SOLID.has(_terrain[index]) else 0
	var touched: Array[MapBlock] = []
	for y in range(tile.y - 1, tile.y + 2):
		for x in range(tile.x - 1, tile.x + 2):
			var around := Vector2i(x, y)
			if not Rect2i(0, 0, _width, _height).has_point(around):
				continue
			_masks[y * _width + x] = TileShape.mask(_solid, _width, _height, around, true)
			var block := _block_of(around)
			if not touched.has(block):
				touched.append(block)
	for block in touched:
		block.queue_redraw()


## The block that draws `tile`.
func _block_of(tile: Vector2i) -> MapBlock:
	var across := ceili(_width / float(BLOCK))
	@warning_ignore("integer_division")
	return _blocks[(tile.y / BLOCK) * across + tile.x / BLOCK]


## Shows a burst of chips on `tile`, where a pickaxe just landed.
func hit(tile: Vector2i) -> void:
	_bursts.append({"tile": tile, "age": 0.0})
	set_process(true)


## The number of blocks the map is drawn in. Tests read it.
func block_count() -> int:
	return _blocks.size()


## The number of bursts still shown. Tests read it.
func burst_count() -> int:
	return _bursts.size()


## The neighbour mask a tile is drawn with. Tests read it.
func mask_at(tile: Vector2i) -> int:
	return _masks[tile.y * _width + tile.x]


## Draws the tiles `tiles` onto `block`, while the block draws.
func draw_block(block: MapBlock, tiles: Rect2i) -> void:
	if art == null:
		return
	var size := GridMotion.TILE_SIZE
	for y in range(tiles.position.y, tiles.end.y):
		for x in range(tiles.position.x, tiles.end.x):
			var index := y * _width + x
			var rect := Rect2(x * size, y * size, size, size)
			var variant := _variants[index]
			art.draw(block, id_of(_terrain[index]), rect, {"solid": _masks[index], "variant": variant})
			if _damage[index] > 0:
				art.draw(block, Art.CRACKS, rect, {"hits": _damage[index], "variant": variant})


func _process(delta: float) -> void:
	for burst in _bursts:
		burst["age"] += delta / HIT_SECONDS
	_bursts = _bursts.filter(func(burst: Dictionary) -> bool: return burst["age"] < 1.0)
	if _bursts.is_empty():
		set_process(false)
	_burst_layer.queue_redraw()


func _draw_bursts() -> void:
	if art == null:
		return
	var size := GridMotion.TILE_SIZE
	for burst in _bursts:
		var tile: Vector2i = burst["tile"]
		var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
		art.draw(_burst_layer, Art.HIT, rect, {"age": burst["age"], "variant": TileShape.variant(tile)})

Ersetze den gesamten Inhalt von game/scripts/avatar.gd durch:

class_name Avatar
extends Node2D
## The local player's figure. It moves outside the lockstep simulation; only
## its pickaxe hits become commands. `art` draws it, stepping while it walks
## and facing the way it went last.

## Walking speed: five tiles per second.
const SPEED := 5.0 * GridMotion.TILE_SIZE
const RADIUS := 5.0
## Longest frame that moves the figure in one go. Longer frames are cut, so
## that the figure cannot pass through a wall after a stall.
const MAX_STEP_SECONDS := 0.1
## How far the step of a walking figure turns per pixel, in radians: about
## two steps per second at walking speed.
const STEP_PER_PIXEL := 0.16

## Callable(x: int, y: int) -> bool that says whether a tile can be walked on.
var is_walkable: Callable
var art: Art
## The number of the player the figure belongs to, for its colour, or -1
## before the player is in the world.
var player := -1
## 1.0 while the figure faces right, -1.0 while it faces left.
var facing := 1.0
## Grows while the figure walks; the styles swing the steps by it.
var step := 0.0
## True while the figure walked in the last frame.
var moving := false


## Where a figure standing at `at` is drawn: a tile wide and one and a half
## tiles tall, with its feet just below its body, so that it stands upright
## in its tile.
static func figure_rect(at: Vector2) -> Rect2:
	var size := GridMotion.TILE_SIZE
	return Rect2(at.x - size / 2.0, at.y + RADIUS + 1.0 - size * 1.5, size, size * 1.5)


func _process(delta: float) -> void:
	walk(Input.get_vector("move_left", "move_right", "move_up", "move_down"), delta)


## Moves the figure in `direction` for `delta` seconds, sliding along walls.
func walk(direction: Vector2, delta: float) -> void:
	var was_moving := moving
	moving = false
	if direction == Vector2.ZERO or not is_walkable.is_valid():
		if was_moving:
			queue_redraw()
		return
	var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS)
	var before := position
	position = GridMotion.move(position, motion, RADIUS, is_walkable)
	var walked := position.distance_to(before)
	moving = walked > 0.0
	step += walked * STEP_PER_PIXEL
	if direction.x != 0.0:
		facing = signf(direction.x)
	queue_redraw()


func _draw() -> void:
	if art != null:
		art.draw(self, Art.FIGURE, figure_rect(Vector2.ZERO), {"player": player, "facing": facing, "step": step, "moving": moving})

Ersetze den gesamten Inhalt von game/scripts/figures.gd durch:

class_name Figures
extends Node2D
## The figures of the other players, with their names, drawn by `art`. A new
## position comes over the network about ten times a second; in between,
## each figure glides toward the latest one, stepping and facing the way it
## goes.

## How quickly a figure closes the gap to its latest position, per second.
const GLIDE := 15.0
## Pixels per frame below which a gliding figure counts as standing.
const STANDING := 0.05
## Size and width of the names, in pixels of the playing field. The names
## are drawn at the size they have on screen, so that they stay sharp.
const NAME_SIZE := 8
const NAME_WIDTH := 80.0
const NAME_OUTLINE := Color(0.05, 0.05, 0.06)

var session: GwSession
var art: Art
var _shown: Dictionary[int, Vector2] = {}
var _latest: Dictionary[int, Vector2] = {}
var _facing: Dictionary[int, float] = {}
var _step: Dictionary[int, float] = {}
var _moving: Dictionary[int, bool] = {}


## Moves the figure of `player` toward `position`. A figure that was not
## shown yet appears there at once.
func place(player: int, position: Vector2) -> void:
	if not _shown.has(player):
		_shown[player] = position
	_latest[player] = position
	queue_redraw()


## Removes the figure of `player`.
func forget(player: int) -> void:
	for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]:
		figures.erase(player)
	queue_redraw()


func clear() -> void:
	for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]:
		figures.clear()
	queue_redraw()


## Where the figure of `player` is shown, or Vector2.INF if it is not.
func shown_at(player: int) -> Vector2:
	return _shown.get(player, Vector2.INF)


## 1.0 if the figure of `player` faces right, -1.0 if it faces left.
func facing_of(player: int) -> float:
	return _facing.get(player, 1.0)


func _process(delta: float) -> void:
	if _shown.is_empty():
		return
	var weight := minf(1.0, GLIDE * delta)
	for player in _shown:
		var before := _shown[player]
		_shown[player] = before.lerp(_latest[player], weight)
		var moved := _shown[player] - before
		if absf(moved.x) > STANDING:
			_facing[player] = signf(moved.x)
		_step[player] = _step.get(player, 0.0) + moved.length() * Avatar.STEP_PER_PIXEL
		_moving[player] = moved.length() > STANDING
	queue_redraw()


func _draw() -> void:
	if art == null:
		return
	var font := ThemeDB.fallback_font
	# The camera enlarges the field. Text drawn at its size on screen and then
	# shrunk stays sharp; text drawn small and enlarged would blur.
	var zoom := maxf(1.0, get_global_transform_with_canvas().get_scale().x)
	for player in _shown:
		var at := _shown[player]
		var state := {"player": player, "facing": facing_of(player), "step": _step.get(player, 0.0), "moving": _moving.get(player, false)}
		art.draw(self, Art.FIGURE, Avatar.figure_rect(at), state)
		if session != null:
			var label := Vector2(at.x - NAME_WIDTH / 2.0, Avatar.figure_rect(at).position.y - 2.0)
			var text := session.player_name(player)
			var size := roundi(NAME_SIZE * zoom)
			draw_set_transform(label, 0.0, Vector2.ONE / zoom)
			draw_string_outline(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size, maxi(2, roundi(zoom * 1.5)), NAME_OUTLINE)
			draw_string(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size)
			draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)

Ersetze den gesamten Inhalt von game/scripts/tile_marker.gd durch:

class_name TileMarker
extends Node2D
## Marks the tile under the mouse, drawn by `art`: one way when the figure
## can reach it, another when it is too far away.

var tile := Vector2i(-1, -1)
var in_reach := false
var art: Art


## Moves the mark to `new_tile`. Redraws only when something changed.
func show_tile(new_tile: Vector2i, reachable: bool) -> void:
	if new_tile == tile and reachable == in_reach:
		return
	tile = new_tile
	in_reach = reachable
	queue_redraw()


func _draw() -> void:
	if art == null:
		return
	var size := GridMotion.TILE_SIZE
	art.draw(self, Art.MARKER, Rect2(Vector2(tile) * size, Vector2(size, size)), {"reachable": in_reach})

In der Hauptszene ändert sich gegenüber main:

  • Sie legt art an, lädt die Bilder aus Art.OVERRIDE_DIR und gibt art an Karte, Zielmarke, Figuren und eigene Figur.
  • Wo sie die Karte bisher mit map_view.queue_redraw() neu zeichnete, ruft sie map_view.reload().
  • Die Ereignisse tile_hit und tile_changed nehmen nur ihre Kachel neu (refresh_tile) und zeigen Splitter, eigene Schläge aber nicht noch einmal.
  • dig_at zeigt die Splitter des eigenen Schlags sofort.
  • Die eigene Figur kennt die Nummer ihres Spielers, damit sie dessen Farbe trägt.

Ersetze den gesamten Inhalt von game/scripts/main.gd durch:

extends Node2D
## Composition root. Shows the start menu, then runs a session as host or as
## guest with the map view, the figures, the HUD and the files on disk.

## Seed of a new world until the slice ships its chosen seed.
const NEW_WORLD_SEED := 2026
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
const REACH_TILES := 2.5
## Ticks between two automatic saves: two minutes.
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
const CAMERA_ZOOM := Vector2(3.0, 3.0)
## Most ticks a guest runs in one frame. A guest that fell behind catches up
## within a few frames without freezing.
const MAX_GUEST_TICKS_PER_FRAME := 20
## Seconds between two position updates of the own figure.
const FIGURE_SECONDS := 0.1
## The text for every reason a host gives for turning a guest away.
const REFUSAL_TEXTS := {
	GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL",
	GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES",
	GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL",
	GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS",
	GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING",
}

## Folder for the player key, the save and the recordings. If it is empty,
## the command line decides; tests set a folder of their own.
@export var storage_dir := ""
## Network interface an open world listens on. Tests use the loopback one.
var listen_address := "*"
## The command line arguments after "--". Tools and tests may set their own
## before the scene enters the tree.
var command_line := OS.get_cmdline_user_args()

var options: LaunchOptions
var store: SaveStore
## The running session, or null in the menu. A guest's session has no world
## until the host sent it.
var session: GwSession
## The network connection of an open world or a guest, or null.
var link: SessionLink
var clock: TickClock
## The style the playing field is drawn in.
var art: Art
var menu: Menu
var map_view: MapView
var marker: TileMarker
var avatar: Avatar
var figures: Figures
var hud: Hud
## How often a guest's world diverged and was replaced by the host's.
var resyncs := 0

var _player_key: PackedByteArray
var _next_dig_tick := 0
## True while an unusable save could not be set aside. Saving is refused
## then, so that the unusable save is never overwritten.
var _save_blocked := false
var _figure_wait := 0.0
## The address a guest is joining, for messages.
var _joining := ""
## The host's accepted guests when _process last looked. A new one ends a
## pause.
var _known_guests := 0


func _ready() -> void:
	I18n.install()
	InputSetup.install()
	options = LaunchOptions.parse(command_line)
	for argument in options.ignored:
		push_warning("ignored command line argument: %s" % argument)
	if storage_dir.is_empty():
		storage_dir = options.storage_dir()
	store = SaveStore.new(storage_dir)
	_player_key = store.player_key()
	if _player_key.is_empty():
		push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
	clock = TickClock.new(GwSession.TICKS_PER_SECOND)
	art = Art.new()
	art.load_overrides(Art.OVERRIDE_DIR)
	map_view = MapView.new()
	map_view.art = art
	add_child(map_view)
	marker = TileMarker.new()
	marker.art = art
	add_child(marker)
	figures = Figures.new()
	figures.art = art
	add_child(figures)
	avatar = Avatar.new()
	avatar.art = art
	add_child(avatar)
	var camera := Camera2D.new()
	camera.zoom = CAMERA_ZOOM
	avatar.add_child(camera)
	hud = Hud.new()
	add_child(hud)
	menu = Menu.new()
	add_child(menu)
	menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
	menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
	menu.play_chosen.connect(play)
	menu.join_chosen.connect(join)
	_show_menu("")
	match options.start:
		"solo":
			play(false)
		"host":
			play(true)
		"join":
			join(options.address)


## Plays the saved world, or a new one, as its host. With `open`, guests may
## join. Returns false if it could not start; the menu then says why.
func play(open: bool) -> bool:
	if not _can_start():
		return false
	var next := GwSession.new()
	var set_aside := false
	if store.has_save():
		var save := store.read_save()
		if store.last_read_error != OK:
			_show_menu(tr("SAVE_UNREADABLE"))
			return false
		set_aside = not _load(next, save)
	if not next.is_running():
		next.start_new(NEW_WORLD_SEED)
	if open:
		var net := NetLink.new()
		if net.host(options.port, listen_address) != OK:
			_show_menu(tr("OPEN_FAILED") % options.port)
			return false
		link = SessionLink.new(next, net)
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	if set_aside:
		hud.flash("LOAD_FAILED")
	return true


## Starts joining the host at `address`, "host" or "host:port". The world
## arrives later; until then the menu says that it is connecting.
func join(address: String) -> void:
	if not _can_start():
		return
	var target := NetLink.split_address(address)
	if target.is_empty():
		_show_menu(tr("ADDRESS_INVALID") % address)
		return
	var net := NetLink.new()
	if net.join(target[0], target[1]) != OK:
		_show_menu(tr("CONNECT_FAILED") % address)
		return
	session = GwSession.new()
	session.start_guest(_player_key, _player_name())
	link = SessionLink.new(session, net)
	_joining = address
	store.set_last_address(address)
	_show_menu(tr("CONNECTING") % address)


func _can_start() -> bool:
	if session != null:
		return false
	if _player_key.is_empty():
		_show_menu(tr("KEY_UNREADABLE"))
		return false
	store.set_player_name(menu.player_name())
	return true


func _player_name() -> String:
	var typed := menu.player_name()
	return typed if not typed.is_empty() else tr("DEFAULT_NAME")


## Starts `next` from `save`. If the save is unusable, it is set aside so
## that the next save does not overwrite it, and false is returned.
func _load(next: GwSession, save: PackedByteArray) -> bool:
	if next.start_loaded(save):
		_save_blocked = false
		return true
	var aside := store.set_aside_save()
	if aside.is_empty():
		_save_blocked = true
		push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error())
	else:
		push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside])
	return false


func _show_menu(status: String) -> void:
	menu.show_status(status)
	menu.show()
	hud.hide()
	for node: Node2D in [avatar, marker, figures]:
		node.hide()


## Shows the world of the session and puts the figure on the spawn.
func _show_world() -> void:
	menu.hide()
	hud.show()
	for node: Node2D in [avatar, marker, figures]:
		node.show()
	map_view.session = session
	map_view.reload()
	figures.session = session
	avatar.player = session.local_player()
	figures.clear()
	avatar.is_walkable = session.is_walkable
	avatar.position = GridMotion.tile_center(session.spawn_point())
	hud.show_stone(session.item_count(GwSession.ITEM_STONE))
	_update_status()
	_next_dig_tick = 0


## Says in the status line how the game is shared, and with how many.
func _update_status() -> void:
	if link == null:
		hud.show_status("")
	elif session.is_host():
		hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()])
	else:
		hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()])


## Ends a guest's session and goes back to the menu with `message`.
func _leave(message: String) -> void:
	if session == null:
		return
	_write_recording()
	if link != null:
		link.link.close()
	link = null
	session = null
	map_view.session = null
	map_view.reload()
	avatar.is_walkable = Callable()
	figures.clear()
	_show_menu(message)


## A broken session runs no more ticks. Instead of leaving the others
## waiting, a host closes its world to guests and a guest leaves. The host's
## status line says why its world stands still.
func _stop_sharing() -> void:
	if not session.is_host():
		_leave(tr("SIMULATION_STOPPED"))
		return
	if link != null:
		link.link.close()
		link = null
		figures.clear()
	hud.show_status(tr("SIMULATION_STOPPED"))


## Ends a pause once the host accepted a guest: the guest joins in the next
## tick, and a paused world would keep it waiting. A connection that has not
## greeted yet is no guest.
func _end_pause_for_new_guests() -> void:
	var guests := session.guest_count()
	if guests > _known_guests and clock.paused:
		_set_paused(false)
	_known_guests = guests


func _process(delta: float) -> void:
	if link != null:
		for notice in link.pump(delta):
			_on_notice(notice)
	if session != null and session.is_broken():
		_stop_sharing()
	if session == null or not session.is_running() or session.is_broken():
		return
	_end_pause_for_new_guests()
	_update_marker()
	if Input.is_action_pressed("dig") and not clock.paused:
		_try_dig()
	if session.is_host():
		for i in clock.advance(delta):
			_run_tick()
	else:
		for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME):
			_apply(session.step())
	if link != null:
		link.flush()
		_figure_wait -= delta
		if _figure_wait <= 0.0:
			_figure_wait = FIGURE_SECONDS
			link.send_figure(avatar.position)


func _on_notice(notice: Dictionary) -> void:
	# An earlier notice of the same batch may have ended the session.
	if session == null:
		return
	match notice["type"]:
		"world_received":
			if notice["resync"]:
				resyncs += 1
				_keep_diverged_recording()
				map_view.reload()
				hud.show_stone(session.item_count(GwSession.ITEM_STONE))
				hud.flash("RESYNCED")
			else:
				_show_world()
		"refused":
			_leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR")))
		"protocol_error":
			push_warning("broken message from the host: %s" % session.last_error())
			_leave(tr("PROTOCOL_ERROR"))
		"host_lost":
			_leave(tr("HOST_LEFT"))
		"failed":
			_leave(tr("CONNECT_FAILED") % _joining)
		"divergence":
			push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]])
		"figure":
			figures.place(notice["player"], notice["position"])


## Keeps the recording of a guest's world that diverged from the host's, so
## that the divergence can be replayed.
func _keep_diverged_recording() -> void:
	var path := store.write_recording(session.take_diverged_recording())
	push_warning("the world diverged from the host's; its recording is %s" % path)


func _unhandled_input(event: InputEvent) -> void:
	if session == null or not session.is_running():
		return
	if event.is_action_pressed("save_game"):
		if session.is_host():
			hud.flash("SAVED" if save_game() else "SAVE_FAILED")
		else:
			hud.flash("HOST_SAVES")
	elif event.is_action_pressed("load_game"):
		_reload()
	elif event.is_action_pressed("toggle_pause"):
		# Ending a pause is always allowed, starting one only alone.
		if clock.paused or (session.is_host() and session.guest_count() == 0):
			_set_paused(not clock.paused)
		else:
			hud.flash("PAUSE_ONLY_ALONE")


## Goes back to the saved world. Only when playing alone: guests would no
## longer have the host's world.
func _reload() -> void:
	if link != null:
		hud.flash("LOAD_ONLY_ALONE")
		return
	var save := store.read_save()
	if store.last_read_error != OK:
		hud.flash("LOAD_FAILED")
		return
	var next := GwSession.new()
	if not _load(next, save):
		hud.flash("LOAD_FAILED")
		return
	_write_recording()
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	hud.flash("LOADED")


func _set_paused(paused: bool) -> void:
	clock.paused = paused
	avatar.set_process(not paused)
	hud.show_paused(paused)


func _notification(what: int) -> void:
	if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
		return
	if session.is_running():
		if session.is_host() and not save_game():
			push_warning("the game could not be saved on close")
		_write_recording()
	if link != null:
		link.link.close()


func _run_tick() -> void:
	_apply(session.step())
	if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
		hud.flash("SAVE_FAILED")
		push_warning("the automatic save failed")


func _apply(events: Array[Dictionary]) -> void:
	var local := session.local_player()
	for event in events:
		match event["type"]:
			"tile_hit":
				var tile := Vector2i(event["x"], event["y"])
				map_view.refresh_tile(tile)
				# The own hits showed their burst when they were made.
				if event["player"] != local:
					map_view.hit(tile)
			"tile_changed":
				var tile := Vector2i(event["x"], event["y"])
				map_view.refresh_tile(tile)
				map_view.hit(tile)
			"item_gained":
				if event["player"] == local:
					hud.show_stone(session.item_count(GwSession.ITEM_STONE))
			"inventory_full":
				if event["player"] == local:
					hud.flash("INVENTORY_FULL")
			"player_joined":
				if event["player"] != local:
					hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
				else:
					avatar.player = local
					avatar.queue_redraw()
				_update_status()
			"player_left":
				figures.forget(event["player"])
				hud.flash("PLAYER_LEFT", [session.player_name(event["player"])])
				_update_status()
	if session.is_broken():
		hud.flash("SIMULATION_STOPPED")
		push_error(session.last_error())


func _update_marker() -> void:
	var tile := GridMotion.tile_of(get_global_mouse_position())
	var distance := avatar.position.distance_to(GridMotion.tile_center(tile))
	marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE)


func _try_dig() -> void:
	if marker.in_reach:
		dig_at(marker.tile)


## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its
## burst of chips at once: the hit itself lands with a later tick, which for a
## guest comes back from the host only after the round trip. The caller must
## check the reach: figures move outside the lockstep, so the core does not
## know where the figure is. The core checks the cooldown, the tile, its
## terrain, the tool tier and the free space in the inventory.
func dig_at(tile: Vector2i) -> void:
	if session.tick() < _next_dig_tick:
		return
	session.dig(tile.x, tile.y)
	_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS
	if session.terrain_at(tile.x, tile.y) != GwSession.TERRAIN_GROUND:
		map_view.hit(tile)


## Writes the running world as the save. Returns false if nothing was
## written: for a guest, whose world belongs to the host, and while an
## unusable save could not be set aside.
func save_game() -> bool:
	if _save_blocked or not session.is_host():
		return false
	return store.write_save(session.save())


func _write_recording() -> void:
	store.write_recording(session.recording_bytes())
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 119 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 5: Bildschirmfoto prüfen

Run: godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-task2.png"

Expected: Ein Fenster öffnet sich kurz. Die Ausgabe endet mit screenshot saved: …m1d-task2.png und enthält weder Warnung noch Fehler. Das Bild zeigt die Karte in den Platzhaltern von ArtStyle, so flach wie vor diesem Plan: brauner Boden, graue Felsen mit eckigen Kanten, die Figur als heller Kreis mit dunklem Rand. Im getroffenen Fels rechts neben der Figur steht ein kurzer dunkler Strich als Riss. Oben links steht Stein: 0, unten die Hilfezeile.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Spielfeld über Art zeichnen

Karte, Figuren und Zielmarke zeichnet jetzt Art. Die Karte zeichnet in
Blöcken von 16 mal 16 Kacheln und nach einem Treffer nur die Blöcke um
die Kachel neu; jede Kachel kennt ihre Nachbarn. Figuren stehen
aufrecht in ihrer Kachel, schreiten und schauen in die Richtung, in die
sie gehen. Der eigene Schlag zeigt seine Splitter sofort, auch als Gast,
und die Namen über fremden Figuren werden in Bildschirmgröße gezeichnet
und bleiben scharf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 3: Vitrine der Stilproben

Files:

  • Create: game/scripts/showcase.gd
  • Modify: game/scripts/menu.gd, game/scripts/input_setup.gd, game/i18n/texts.csv, game/scripts/main.gd (jeweils ganze Datei ersetzen)
  • Test: game/tests/test_showcase.gd (neu); game/tests/test_input_setup.gd (ganze Datei ersetzen); game/tests/test_menu.gd, game/tests/test_main.gd (je ein Test am Ende)

Interfaces:

  • Consumes: aus Task 1 Art (styles, style, select, select_next, changed, draw), ArtStyle.along, ArtStyle.title_key() und TileShape; aus Task 2 Avatar.figure_rect und MapView.redraw(); GridMotion.TILE_SIZE; die Hilfen _menu() und _free(menu) in game/tests/test_menu.gd

  • Produces:

    • Showcase (CanvasLayer): Signal closed; art: Art, time: float (Sekunden seit dem Öffnen), style_buttons: Array[Button], back_button: Button; die Konstanten GRID, ZOOM, GROW_SECONDS, GROWN_SECONDS, HOP_EVERY, HOP_SECONDS, STONE_SPEED, STONES, VINE, MOSS, PLANT, GRAZER_HOME, RUIN, FIGURES; die Bewegungen static growth_at(seconds: float) -> float, static hop_at(seconds: float) -> float, static grazer_at(seconds: float) -> Vector2, static stones_at(seconds: float) -> Array[float] und static awake_at(seconds: float) -> float; caption_keys() -> PackedStringArray; draw_scene(canvas: CanvasItem)
    • Menu: Signal showcase_chosen und showcase_button
    • Eingabeaktionen next_style (F2), back (Esc), style_1, style_2 und style_3 (Tasten 1 bis 3)
    • Hauptszene: showcase: Showcase, open_showcase() und close_showcase(). Nach jedem Wechsel des Stils zeichnet _redraw_art() das Spielfeld neu.
    • Texte MENU_SHOWCASE, SHOWCASE_TITLE, SHOWCASE_BACK und die Beschriftungen SHOWCASE_ROCK, SHOWCASE_DEAD_SOIL, SHOWCASE_LIVING_SOIL, SHOWCASE_MOSS, SHOWCASE_PLANT, SHOWCASE_GRAZER, SHOWCASE_VINE, SHOWCASE_RUIN, SHOWCASE_FIGURES
  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/test_showcase.gd:

extends TestCase
## The showcase of the style samples: how its things move, and that it shows
## every style.


func test_the_plant_grows_from_a_seed_and_starts_again() -> void:
	check_eq(Showcase.growth_at(0.0), 0.0, "a seed at first")
	check(is_equal_approx(Showcase.growth_at(Showcase.GROW_SECONDS / 2.0), 0.5), "half grown halfway")
	check_eq(Showcase.growth_at(Showcase.GROW_SECONDS), 1.0, "grown")
	check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS - 0.01), 1.0, "it stays grown for a while")
	check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS), 0.0, "then a seed again")


func test_the_animal_hops_then_rests_near_its_home() -> void:
	check_eq(Showcase.hop_at(0.0), 0.0, "taking off")
	check(is_equal_approx(Showcase.hop_at(Showcase.HOP_SECONDS / 2.0), 0.5), "in the air")
	check_eq(Showcase.hop_at(Showcase.HOP_SECONDS), 1.0, "landed")
	check_eq(Showcase.hop_at(Showcase.HOP_EVERY - 0.01), 1.0, "resting until the next hop")
	for tenth in 100:
		var at := Showcase.grazer_at(tenth * 0.1)
		check(at.distance_to(Showcase.GRAZER_HOME) <= 1.01, "near home at %.1f s: %s" % [tenth * 0.1, at])


func test_stones_travel_along_the_vine_one_after_another() -> void:
	var start := Showcase.stones_at(0.0)
	check_eq(start.size(), Showcase.STONES, "every stone")
	check_eq(start[0], 0.0, "the first one starts")
	for stone in start:
		check(stone >= 0.0 and stone < 1.0, "on the vine: %f" % stone)
	var later := Showcase.stones_at(1.0)
	check(is_equal_approx(later[0], Showcase.STONE_SPEED), "it moves on")
	var round_trip := Showcase.stones_at(1.0 / Showcase.STONE_SPEED)
	check(is_equal_approx(round_trip[0], 0.0) or is_equal_approx(round_trip[0], 1.0), "and comes round again")


func test_the_ruin_wakes_and_dozes() -> void:
	for tenth in 100:
		var awake := Showcase.awake_at(tenth * 0.1)
		check(awake >= 0.29 and awake <= 1.0, "between dozing and awake: %f" % awake)


func test_the_showcase_shows_every_style_and_names_its_things() -> void:
	I18n.install()
	InputSetup.install()
	var art := Art.new()
	var showcase := Showcase.new()
	showcase.art = art
	tree.root.add_child(showcase)
	for index in art.styles.size():
		showcase.style_buttons[index].pressed.emit()
		check_eq(art.style, art.styles[index], "button %d chooses its style" % (index + 1))
		check(showcase.style_buttons[index].disabled, "the chosen one cannot be pressed again")
		await tree.process_frame
	for key in showcase.caption_keys():
		check(tr(key) != key, "a text for %s" % key)
	var closed := [0]
	showcase.closed.connect(func() -> void: closed[0] += 1)
	showcase.back_button.pressed.emit()
	check_eq(closed[0], 1, "back leads away")
	tree.root.remove_child(showcase)
	showcase.free()

Ersetze den gesamten Inhalt von game/tests/test_input_setup.gd durch die folgende Fassung. Neu sind die fünf Aktionen am Ende der Liste.

extends TestCase


func test_every_action_of_the_game_exists() -> void:
	InputSetup.install()
	for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause", "next_style", "back", "style_1", "style_2", "style_3"]:
		check(InputMap.has_action(action), "action %s" % action)


func test_installing_twice_adds_no_second_set_of_keys() -> void:
	InputSetup.install()
	var events := InputMap.action_get_events("move_up").size()
	InputSetup.install()
	check_eq(InputMap.action_get_events("move_up").size(), events, "same number of keys")
	check_eq(events, 2, "W and the arrow key")

Füge am Ende von game/tests/test_menu.gd diesen Test an, nach zwei Leerzeilen:

func test_the_way_to_the_style_samples() -> void:
	var menu := _menu()
	var chosen := [0]
	menu.showcase_chosen.connect(func() -> void: chosen[0] += 1)
	check_eq(menu.showcase_button.text, tr("MENU_SHOWCASE"), "the button says where it leads")
	menu.showcase_button.pressed.emit()
	check_eq(chosen[0], 1, "it asks for the samples")
	_free(menu)

Füge am Ende von game/tests/test_main.gd diesen Test an, nach zwei Leerzeilen:

func test_the_style_samples_open_from_the_menu_and_lead_back_to_it() -> void:
	var directory := TestFiles.temporary_dir("showcase")
	var main := _start_main(directory)
	main.menu.showcase_button.pressed.emit()
	check(main.showcase.visible, "the samples show")
	check(not main.menu.visible, "instead of the menu")
	main.showcase.style_buttons[2].pressed.emit()
	check_eq(main.art.style.key(), GlowStyle.KEY, "a button there chooses the style")
	await tree.process_frame
	main.showcase.back_button.pressed.emit()
	check(main.menu.visible, "the menu is back")
	check(not main.showcase.visible, "the samples are gone")
	_clean_up([directory])
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 118 passed, 4 failed. test_showcase.gd lädt nicht (SCRIPT ERROR: Parse Error: Identifier "Showcase" not declared in the current scope.). test_every_action_of_the_game_exists vermisst action next_style, action back und action style_1 bis action style_3. Die neuen Tests in test_menu.gd und test_main.gd scheitern mit Invalid access to property or key 'showcase_chosen' on a base object of type 'CanvasLayer (Menu)'. beziehungsweise … key 'showcase_button' ….

  • Step 3: Vitrine, Menü, Tasten und Texte schreiben

Die Vitrine baut ihre Szene aus einem Raster von Buchstaben. Pflanze, Tier, Steine und Ruine bewegen sich nach Formeln der Zeit seit dem Öffnen; die Formeln sind statisch, damit die Tests sie ohne Szene prüfen.

Create game/scripts/showcase.gd:

class_name Showcase
extends CanvasLayer
## The showcase of the style samples: one small scene in the chosen style,
## with the terrain of the test map and things of later milestones. Dead
## soil turns living, moss glows, a vine carries stones into a waking ruin, a
## plant grows and an animal hops. Buttons switch the style.

## The player wants to go back to the menu.
signal closed

## The scene, one letter per tile: B edge of the map, S soft rock, H hard
## rock, . dead soil, L living soil.
const GRID: Array[String] = [
	"BBSSSHHH............",
	"BSSSHHH.............",
	"SS...H..............",
	"..........LLLLLL....",
	".......LLLLLLLLLLLL.",
	".....LLLLLLLLLLLLLL.",
	".....LLLLLLLLLLLLLL.",
	".......LLLLLLLLLLL..",
	".........LLLLLL.....",
	"....................",
]
## Pixels of the scene per pixel on screen.
const ZOOM := 3.0
## Seconds from seed to grown plant, and the pause before it starts again.
const GROW_SECONDS := 6.0
const GROWN_SECONDS := 2.0
## Seconds between two hops, and how long a hop lasts.
const HOP_EVERY := 1.2
const HOP_SECONDS := 0.5
## How far stones travel along the vine per second, as a share of its length.
const STONE_SPEED := 0.1
const STONES := 3
## The path of the vine, in tiles: along the living soil into the ruin.
const VINE: Array[Vector2] = [
	Vector2(6.8, 6.9), Vector2(9.0, 7.3), Vector2(11.0, 6.9),
	Vector2(13.0, 7.1), Vector2(15.0, 6.4), Vector2(17.3, 5.7),
]
## Where things stand, in tiles: the middle of their foot; for the figures
## the middle of the tile they stand on.
const MOSS: Array[Vector2] = [Vector2(8.0, 4.6), Vector2(9.8, 3.3), Vector2(14.2, 3.4), Vector2(16.8, 6.8)]
const PLANT := Vector2(10.9, 4.4)
const GRAZER_HOME := Vector2(12.8, 5.2)
## The ruin covers three by three tiles from here.
const RUIN := Vector2i(16, 3)
## The figures of two players: the own one and one of another colour.
const FIGURES: Array[Vector2] = [Vector2(3.0, 5.0), Vector2(1.6, 6.6)]

var art: Art
## Seconds since the showcase opened.
var time := 0.0
var style_buttons: Array[Button] = []
var back_button := Button.new()
var _view := ShowcaseView.new()
var _title := Label.new()
var _captions: Array[Label] = []
var _grid_codes := PackedByteArray()
var _solid := PackedByteArray()
var _living := PackedByteArray()


## The node that draws the scene.
class ShowcaseView extends Node2D:
	var showcase: Showcase

	func _draw() -> void:
		showcase.draw_scene(self)


func _ready() -> void:
	_read_grid()
	var background := ColorRect.new()
	background.color = Color(0.08, 0.08, 0.09)
	background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	add_child(background)
	_view.showcase = self
	_view.scale = Vector2(ZOOM, ZOOM)
	add_child(_view)
	_title.add_theme_font_size_override("font_size", 28)
	_title.position = Vector2(32, 20)
	add_child(_title)
	var row := HBoxContainer.new()
	row.position = Vector2(32, 660)
	row.add_theme_constant_override("separation", 12)
	add_child(row)
	for index in art.styles.size():
		var button := Button.new()
		button.text = "%d  %s" % [index + 1, tr(art.styles[index].title_key())]
		button.pressed.connect(func() -> void: art.select(art.styles[index].key()))
		style_buttons.append(button)
		row.add_child(button)
	back_button.text = tr("SHOWCASE_BACK")
	back_button.pressed.connect(func() -> void: closed.emit())
	row.add_child(back_button)
	_add_captions()
	art.changed.connect(_on_style_changed)
	_on_style_changed()
	get_viewport().size_changed.connect(_layout)
	_layout()


func _process(delta: float) -> void:
	time += delta
	_view.queue_redraw()


func _unhandled_input(event: InputEvent) -> void:
	if not visible:
		return
	for index in mini(3, art.styles.size()):
		if event.is_action_pressed("style_%d" % (index + 1)):
			art.select(art.styles[index].key())
	if event.is_action_pressed("next_style"):
		art.select_next()
	elif event.is_action_pressed("back"):
		closed.emit()


## How far the plant has grown at `seconds`, from 0 (a seed) to 1, over and
## over again.
static func growth_at(seconds: float) -> float:
	return clampf(fposmod(seconds, GROW_SECONDS + GROWN_SECONDS) / GROW_SECONDS, 0.0, 1.0)


## How far the animal is through its hop at `seconds`: from 0 when it takes
## off to 1 when it lands, then 1 while it rests.
static func hop_at(seconds: float) -> float:
	return clampf(fposmod(seconds, HOP_EVERY) / HOP_SECONDS, 0.0, 1.0)


## Where the animal is at `seconds`, in tiles: it hops around its home.
static func grazer_at(seconds: float) -> Vector2:
	var hops := floorf(seconds / HOP_EVERY) + hop_at(seconds)
	return GRAZER_HOME + Vector2(cos(hops * 0.9), sin(hops * 0.9) * 0.4)


## How far each stone is along the vine at `seconds`, from 0 to 1.
static func stones_at(seconds: float) -> Array[float]:
	var shares: Array[float] = []
	for stone in STONES:
		shares.append(fposmod(seconds * STONE_SPEED + float(stone) / STONES, 1.0))
	return shares


## How awake the ruin is at `seconds`: it wakes and dozes in slow breaths.
static func awake_at(seconds: float) -> float:
	return 0.65 + 0.35 * sin(seconds * 1.2)


## The texts under the things in the scene, as keys. Tests read them.
func caption_keys() -> PackedStringArray:
	var keys := PackedStringArray()
	for label in _captions:
		keys.append(label.get_meta("key"))
	return keys


## Draws the whole scene onto `canvas`, while it draws.
func draw_scene(canvas: CanvasItem) -> void:
	if art == null:
		return
	var size := GridMotion.TILE_SIZE
	var width := GRID[0].length()
	var height := GRID.size()
	for y in height:
		for x in width:
			var tile := Vector2i(x, y)
			var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
			var code := _grid_codes[y * width + x]
			var state := {
				"solid": TileShape.mask(_solid, width, height, tile, false),
				"living": TileShape.mask(_living, width, height, tile, false),
				"variant": TileShape.variant(tile),
			}
			art.draw(canvas, _id_of(code), rect, state)
	for at in MOSS:
		art.draw(canvas, Art.MOSS, _tile_rect(at), {"variant": TileShape.variant(Vector2i(at)), "time": time})
	var path := PackedVector2Array()
	for point in VINE:
		path.append(point * size)
	art.draw(canvas, Art.VINE, _bounds(path), {"points": path, "time": time})
	for share in stones_at(time):
		var at := ArtStyle.along(path, share)
		art.draw(canvas, Art.STONE, Rect2(at - Vector2(4, 5), Vector2(8, 8)))
	art.draw(canvas, Art.FIBER_PLANT, _tile_rect(PLANT, Vector2(1.0, 1.8)), {"growth": growth_at(time), "time": time})
	var grazer := grazer_at(time)
	var heading := grazer_at(time + 0.1) - grazer
	art.draw(canvas, Art.GRAZER, _tile_rect(grazer), {"hop": hop_at(time), "facing": 1.0 if heading.x >= 0.0 else -1.0})
	var ruin := Rect2(Vector2(RUIN) * size, Vector2(3, 3) * size)
	art.draw(canvas, Art.RUIN, ruin, {"awake": awake_at(time), "time": time})
	for player in FIGURES.size():
		var at := (FIGURES[player] + Vector2(0.5, 0.5)) * size
		art.draw(canvas, Art.FIGURE, Avatar.figure_rect(at), {"player": player, "facing": 1.0, "step": time * 4.0 + player, "moving": true})


func _read_grid() -> void:
	var codes := {".": 0, "L": 1, "S": 2, "H": 3, "B": 4}
	_grid_codes.clear()
	for line in GRID:
		for letter in line:
			_grid_codes.append(codes[letter])
	_solid = TileShape.flags(_grid_codes, [2, 3, 4])
	_living = TileShape.flags(_grid_codes, [1])


static func _id_of(code: int) -> StringName:
	return [Art.DEAD_SOIL, Art.LIVING_SOIL, Art.SOFT_ROCK, Art.HARD_ROCK, Art.BEDROCK][code]


## A square of one tile, or of `size` tiles, whose bottom middle stands at
## `at` (in tiles) plus half a tile.
static func _tile_rect(at: Vector2, size := Vector2.ONE) -> Rect2:
	var tile := GridMotion.TILE_SIZE
	var foot := (at + Vector2(0.5, 1.0)) * tile
	return Rect2(foot - Vector2(size.x * 0.5, size.y) * tile, size * tile)


static func _bounds(points: PackedVector2Array) -> Rect2:
	var rect := Rect2(points[0], Vector2.ZERO)
	for point in points:
		rect = rect.expand(point)
	return rect


func _add_captions() -> void:
	var entries := [
		["SHOWCASE_ROCK", Vector2(4.0, 3.1)],
		["SHOWCASE_DEAD_SOIL", Vector2(2.6, 8.8)],
		["SHOWCASE_LIVING_SOIL", Vector2(12.5, 9.15)],
		["SHOWCASE_MOSS", Vector2(8.4, 5.6)],
		["SHOWCASE_PLANT", Vector2(11.4, 2.3)],
		["SHOWCASE_GRAZER", Vector2(13.3, 6.3)],
		["SHOWCASE_VINE", Vector2(10.6, 7.7)],
		["SHOWCASE_RUIN", Vector2(17.5, 1.9)],
		["SHOWCASE_FIGURES", Vector2(2.6, 7.7)],
	]
	for entry: Array in entries:
		var label := Label.new()
		label.text = tr(entry[0])
		label.set_meta("key", entry[0])
		label.set_meta("at", entry[1])
		label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		label.add_theme_font_size_override("font_size", 13)
		label.add_theme_color_override("font_outline_color", Color(0.05, 0.05, 0.06))
		label.add_theme_constant_override("outline_size", 5)
		_captions.append(label)
		add_child(label)


## Puts the scene in the middle of the screen and the texts under their
## things.
func _layout() -> void:
	var scene_size := Vector2(GRID[0].length(), GRID.size()) * GridMotion.TILE_SIZE * ZOOM
	var screen := get_viewport().get_visible_rect().size
	_view.position = ((screen - scene_size) / 2.0).floor()
	for label in _captions:
		var at: Vector2 = label.get_meta("at")
		label.size = Vector2(160, 20)
		label.position = _view.position + at * GridMotion.TILE_SIZE * ZOOM - Vector2(80, 0)


func _on_style_changed() -> void:
	_title.text = tr("SHOWCASE_TITLE") % tr(art.style.title_key())
	for index in style_buttons.size():
		style_buttons[index].disabled = art.styles[index] == art.style
	_view.queue_redraw()

Ersetze den gesamten Inhalt von game/scripts/menu.gd durch die folgende Fassung. Neu sind das Signal showcase_chosen und der Knopf showcase_button unter einer zweiten Trennlinie.

class_name Menu
extends CanvasLayer
## The start screen: the player's name, then playing alone, opening the world
## for guests, or joining a host. Below, the way to the style samples.

signal play_chosen(open: bool)
signal join_chosen(address: String)
signal showcase_chosen

var name_field := LineEdit.new()
var address_field := LineEdit.new()
var solo_button := Button.new()
var host_button := Button.new()
var join_button := Button.new()
var showcase_button := Button.new()
var _status := Label.new()


func _ready() -> void:
	var center := CenterContainer.new()
	center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	add_child(center)
	var panel := PanelContainer.new()
	center.add_child(panel)
	var box := VBoxContainer.new()
	box.custom_minimum_size = Vector2(380, 0)
	box.add_theme_constant_override("separation", 10)
	panel.add_child(box)

	var title := Label.new()
	title.text = "Grovewright"
	title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	title.add_theme_font_size_override("font_size", 32)
	name_field.placeholder_text = tr("MENU_NAME")
	name_field.max_length = 32
	solo_button.text = tr("MENU_SOLO")
	host_button.text = tr("MENU_HOST")
	var row := HBoxContainer.new()
	for button in [solo_button, host_button]:
		button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		row.add_child(button)
	address_field.placeholder_text = tr("MENU_ADDRESS")
	join_button.text = tr("MENU_JOIN")
	showcase_button.text = tr("MENU_SHOWCASE")
	_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	_status.custom_minimum_size = Vector2(380, 0)
	for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, HSeparator.new(), showcase_button, _status]:
		box.add_child(control)

	solo_button.pressed.connect(func() -> void: play_chosen.emit(false))
	host_button.pressed.connect(func() -> void: play_chosen.emit(true))
	join_button.pressed.connect(func() -> void: join_chosen.emit(address_field.text.strip_edges()))
	address_field.text_submitted.connect(func(_text: String) -> void: join_button.pressed.emit())
	showcase_button.pressed.connect(func() -> void: showcase_chosen.emit())


## Shows `text` below the buttons.
func show_status(text: String) -> void:
	_status.text = text


## The text shown below the buttons. Tests read it.
func status() -> String:
	return _status.text


## The name the player typed, without spaces around it.
func player_name() -> String:
	return name_field.text.strip_edges()

Ersetze den gesamten Inhalt von game/scripts/input_setup.gd durch die folgende Fassung. Neu sind die Aktionen next_style, back und style_1 bis style_3.

class_name InputSetup
extends RefCounted
## Registers the game's input actions in code, so that project.godot holds no
## serialized input events. Keys are physical positions, so WASD stays in
## place on German keyboards too.


static func install() -> void:
	_add_keys("move_up", [KEY_W, KEY_UP])
	_add_keys("move_down", [KEY_S, KEY_DOWN])
	_add_keys("move_left", [KEY_A, KEY_LEFT])
	_add_keys("move_right", [KEY_D, KEY_RIGHT])
	_add_keys("save_game", [KEY_F5])
	_add_keys("load_game", [KEY_F9])
	_add_keys("toggle_pause", [KEY_ESCAPE])
	_add_keys("next_style", [KEY_F2])
	_add_keys("back", [KEY_ESCAPE])
	_add_keys("style_1", [KEY_1])
	_add_keys("style_2", [KEY_2])
	_add_keys("style_3", [KEY_3])
	_add_mouse_button("dig", MOUSE_BUTTON_LEFT)


static func _add_keys(action: StringName, keys: Array[Key]) -> void:
	if InputMap.has_action(action):
		return
	InputMap.add_action(action)
	for key in keys:
		var event := InputEventKey.new()
		event.physical_keycode = key
		InputMap.action_add_event(action, event)


static func _add_mouse_button(action: StringName, button: MouseButton) -> void:
	if InputMap.has_action(action):
		return
	InputMap.add_action(action)
	var event := InputEventMouseButton.new()
	event.button_index = button
	InputMap.action_add_event(action, event)

Ersetze den gesamten Inhalt von game/i18n/texts.csv durch die folgende Fassung. Neu sind die zwölf Zeilen von MENU_SHOWCASE bis SHOWCASE_FIGURES am Ende.

keys,de,en
STONE_COUNT,Stein: %d,Stone: %d
SAVED,Gespeichert,Saved
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
LOADED,Spielstand geladen,Save loaded
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
PAUSED,Pause,Paused
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause
DEFAULT_NAME,Spieler,Player
MENU_NAME,Dein Name,Your name
MENU_SOLO,Allein spielen,Play alone
MENU_HOST,Welt öffnen,Open your world
MENU_ADDRESS,Adresse des Hosts,Address of the host
MENU_JOIN,Beitreten,Join
CONNECTING,Verbinde mit %s …,Connecting to %s …
CONNECT_FAILED,Keine Verbindung zu %s,Could not reach %s
ADDRESS_INVALID,Das ist keine Adresse: %s,That is not an address: %s
HOST_LEFT,Der Host hat die Welt geschlossen,The host closed the world
PROTOCOL_ERROR,Die Verbindung zum Host ist gestört,The connection to the host is broken
REFUSED_OTHER_PROTOCOL,Der Host hat eine andere Version des Spiels,The host has another version of the game
REFUSED_OTHER_RULES,Der Host hat andere Spielregeln. Ihr braucht denselben Stand des Spiels.,The host has other rules. You need the same version of the game.
REFUSED_WORLD_FULL,Die Welt ist voll: Vier Spieler sind schon da,The world is full: four players are in it
REFUSED_TOO_MANY_PLAYERS,Diese Welt kennt schon 16 Spieler und nimmt keine neuen mehr auf,This world knows 16 players already and takes no new ones
REFUSED_ALREADY_PLAYING,Ein Spieler mit deinem Schlüssel ist schon in der Welt,A player with your key is in the world already
STATUS_HOST,Welt offen auf Port %d · Spieler: %d,World open on port %d · players: %d
STATUS_GUEST,Gast bei %s · Spieler: %d,Guest of %s · players: %d
OPEN_FAILED,Port %d lässt sich nicht öffnen,Port %d cannot be opened
PLAYER_JOINED,%s ist da,%s joined
PLAYER_LEFT,%s ist gegangen,%s left
RESYNCED,Welt mit dem Host abgeglichen,World matched with the host again
PAUSE_ONLY_ALONE,Pause gibt es nur ohne Gäste,Pausing only works without guests
LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when playing alone
HOST_SAVES,Nur der Host speichert,Only the host saves
KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now
SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now
STYLE_WARM,Warm,Warm
STYLE_PASTEL,Pastell,Pastel
STYLE_GLOW,Glimmen,Glow
MENU_SHOWCASE,Stilproben ansehen,See the style samples
SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s
SHOWCASE_BACK,Zurück,Back
SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock
SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil
SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil
SHOWCASE_MOSS,Lebensmoos,Life moss
SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows
SHOWCASE_GRAZER,Weidetier,Grazer
SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones
SHOWCASE_RUIN,Ruine erwacht,A ruin wakes
SHOWCASE_FIGURES,Spielfiguren,Player figures

In der Hauptszene kommt gegenüber Task 2 die Vitrine hinzu: Sie wird beim Start versteckt und angehalten angelegt, open_showcase() zeigt sie statt des Menüs, und ihr Signal closed führt über close_showcase() zurück. Weil die Knöpfe der Vitrine den Stil wechseln, zeichnet _redraw_art() bei jedem Signal changed von art das Spielfeld neu.

Ersetze den gesamten Inhalt von game/scripts/main.gd durch:

extends Node2D
## Composition root. Shows the start menu, then runs a session as host or as
## guest with the map view, the figures, the HUD and the files on disk.

## Seed of a new world until the slice ships its chosen seed.
const NEW_WORLD_SEED := 2026
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
const REACH_TILES := 2.5
## Ticks between two automatic saves: two minutes.
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
const CAMERA_ZOOM := Vector2(3.0, 3.0)
## Most ticks a guest runs in one frame. A guest that fell behind catches up
## within a few frames without freezing.
const MAX_GUEST_TICKS_PER_FRAME := 20
## Seconds between two position updates of the own figure.
const FIGURE_SECONDS := 0.1
## The text for every reason a host gives for turning a guest away.
const REFUSAL_TEXTS := {
	GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL",
	GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES",
	GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL",
	GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS",
	GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING",
}

## Folder for the player key, the save and the recordings. If it is empty,
## the command line decides; tests set a folder of their own.
@export var storage_dir := ""
## Network interface an open world listens on. Tests use the loopback one.
var listen_address := "*"
## The command line arguments after "--". Tools and tests may set their own
## before the scene enters the tree.
var command_line := OS.get_cmdline_user_args()

var options: LaunchOptions
var store: SaveStore
## The running session, or null in the menu. A guest's session has no world
## until the host sent it.
var session: GwSession
## The network connection of an open world or a guest, or null.
var link: SessionLink
var clock: TickClock
## The style the playing field is drawn in.
var art: Art
var menu: Menu
var showcase: Showcase
var map_view: MapView
var marker: TileMarker
var avatar: Avatar
var figures: Figures
var hud: Hud
## How often a guest's world diverged and was replaced by the host's.
var resyncs := 0

var _player_key: PackedByteArray
var _next_dig_tick := 0
## True while an unusable save could not be set aside. Saving is refused
## then, so that the unusable save is never overwritten.
var _save_blocked := false
var _figure_wait := 0.0
## The address a guest is joining, for messages.
var _joining := ""
## The host's accepted guests when _process last looked. A new one ends a
## pause.
var _known_guests := 0


func _ready() -> void:
	I18n.install()
	InputSetup.install()
	options = LaunchOptions.parse(command_line)
	for argument in options.ignored:
		push_warning("ignored command line argument: %s" % argument)
	if storage_dir.is_empty():
		storage_dir = options.storage_dir()
	store = SaveStore.new(storage_dir)
	_player_key = store.player_key()
	if _player_key.is_empty():
		push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
	clock = TickClock.new(GwSession.TICKS_PER_SECOND)
	art = Art.new()
	art.load_overrides(Art.OVERRIDE_DIR)
	map_view = MapView.new()
	map_view.art = art
	add_child(map_view)
	marker = TileMarker.new()
	marker.art = art
	add_child(marker)
	figures = Figures.new()
	figures.art = art
	add_child(figures)
	avatar = Avatar.new()
	avatar.art = art
	add_child(avatar)
	var camera := Camera2D.new()
	camera.zoom = CAMERA_ZOOM
	avatar.add_child(camera)
	hud = Hud.new()
	add_child(hud)
	menu = Menu.new()
	add_child(menu)
	menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
	menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
	art.changed.connect(_redraw_art)
	menu.play_chosen.connect(play)
	menu.join_chosen.connect(join)
	menu.showcase_chosen.connect(open_showcase)
	showcase = Showcase.new()
	showcase.art = art
	showcase.closed.connect(close_showcase)
	showcase.hide()
	showcase.process_mode = Node.PROCESS_MODE_DISABLED
	add_child(showcase)
	_show_menu("")
	match options.start:
		"solo":
			play(false)
		"host":
			play(true)
		"join":
			join(options.address)


## Plays the saved world, or a new one, as its host. With `open`, guests may
## join. Returns false if it could not start; the menu then says why.
func play(open: bool) -> bool:
	if not _can_start():
		return false
	var next := GwSession.new()
	var set_aside := false
	if store.has_save():
		var save := store.read_save()
		if store.last_read_error != OK:
			_show_menu(tr("SAVE_UNREADABLE"))
			return false
		set_aside = not _load(next, save)
	if not next.is_running():
		next.start_new(NEW_WORLD_SEED)
	if open:
		var net := NetLink.new()
		if net.host(options.port, listen_address) != OK:
			_show_menu(tr("OPEN_FAILED") % options.port)
			return false
		link = SessionLink.new(next, net)
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	if set_aside:
		hud.flash("LOAD_FAILED")
	return true


## Starts joining the host at `address`, "host" or "host:port". The world
## arrives later; until then the menu says that it is connecting.
func join(address: String) -> void:
	if not _can_start():
		return
	var target := NetLink.split_address(address)
	if target.is_empty():
		_show_menu(tr("ADDRESS_INVALID") % address)
		return
	var net := NetLink.new()
	if net.join(target[0], target[1]) != OK:
		_show_menu(tr("CONNECT_FAILED") % address)
		return
	session = GwSession.new()
	session.start_guest(_player_key, _player_name())
	link = SessionLink.new(session, net)
	_joining = address
	store.set_last_address(address)
	_show_menu(tr("CONNECTING") % address)


func _can_start() -> bool:
	if session != null:
		return false
	if _player_key.is_empty():
		_show_menu(tr("KEY_UNREADABLE"))
		return false
	store.set_player_name(menu.player_name())
	return true


func _player_name() -> String:
	var typed := menu.player_name()
	return typed if not typed.is_empty() else tr("DEFAULT_NAME")


## Starts `next` from `save`. If the save is unusable, it is set aside so
## that the next save does not overwrite it, and false is returned.
func _load(next: GwSession, save: PackedByteArray) -> bool:
	if next.start_loaded(save):
		_save_blocked = false
		return true
	var aside := store.set_aside_save()
	if aside.is_empty():
		_save_blocked = true
		push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error())
	else:
		push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside])
	return false


func _show_menu(status: String) -> void:
	menu.show_status(status)
	menu.show()
	hud.hide()
	for node: Node2D in [avatar, marker, figures]:
		node.hide()


## Shows the world of the session and puts the figure on the spawn.
func _show_world() -> void:
	menu.hide()
	hud.show()
	for node: Node2D in [avatar, marker, figures]:
		node.show()
	map_view.session = session
	map_view.reload()
	figures.session = session
	avatar.player = session.local_player()
	figures.clear()
	avatar.is_walkable = session.is_walkable
	avatar.position = GridMotion.tile_center(session.spawn_point())
	hud.show_stone(session.item_count(GwSession.ITEM_STONE))
	_update_status()
	_next_dig_tick = 0


## Shows the style samples instead of the menu.
func open_showcase() -> void:
	menu.hide()
	showcase.time = 0.0
	showcase.process_mode = Node.PROCESS_MODE_INHERIT
	showcase.show()


## Goes back from the style samples to the menu.
func close_showcase() -> void:
	showcase.hide()
	showcase.process_mode = Node.PROCESS_MODE_DISABLED
	menu.show()


## Draws the playing field again after the style changed.
func _redraw_art() -> void:
	map_view.redraw()
	for node: Node2D in [marker, figures, avatar]:
		node.queue_redraw()


## Says in the status line how the game is shared, and with how many.
func _update_status() -> void:
	if link == null:
		hud.show_status("")
	elif session.is_host():
		hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()])
	else:
		hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()])


## Ends a guest's session and goes back to the menu with `message`.
func _leave(message: String) -> void:
	if session == null:
		return
	_write_recording()
	if link != null:
		link.link.close()
	link = null
	session = null
	map_view.session = null
	map_view.reload()
	avatar.is_walkable = Callable()
	figures.clear()
	_show_menu(message)


## A broken session runs no more ticks. Instead of leaving the others
## waiting, a host closes its world to guests and a guest leaves. The host's
## status line says why its world stands still.
func _stop_sharing() -> void:
	if not session.is_host():
		_leave(tr("SIMULATION_STOPPED"))
		return
	if link != null:
		link.link.close()
		link = null
		figures.clear()
	hud.show_status(tr("SIMULATION_STOPPED"))


## Ends a pause once the host accepted a guest: the guest joins in the next
## tick, and a paused world would keep it waiting. A connection that has not
## greeted yet is no guest.
func _end_pause_for_new_guests() -> void:
	var guests := session.guest_count()
	if guests > _known_guests and clock.paused:
		_set_paused(false)
	_known_guests = guests


func _process(delta: float) -> void:
	if link != null:
		for notice in link.pump(delta):
			_on_notice(notice)
	if session != null and session.is_broken():
		_stop_sharing()
	if session == null or not session.is_running() or session.is_broken():
		return
	_end_pause_for_new_guests()
	_update_marker()
	if Input.is_action_pressed("dig") and not clock.paused:
		_try_dig()
	if session.is_host():
		for i in clock.advance(delta):
			_run_tick()
	else:
		for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME):
			_apply(session.step())
	if link != null:
		link.flush()
		_figure_wait -= delta
		if _figure_wait <= 0.0:
			_figure_wait = FIGURE_SECONDS
			link.send_figure(avatar.position)


func _on_notice(notice: Dictionary) -> void:
	# An earlier notice of the same batch may have ended the session.
	if session == null:
		return
	match notice["type"]:
		"world_received":
			if notice["resync"]:
				resyncs += 1
				_keep_diverged_recording()
				map_view.reload()
				hud.show_stone(session.item_count(GwSession.ITEM_STONE))
				hud.flash("RESYNCED")
			else:
				_show_world()
		"refused":
			_leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR")))
		"protocol_error":
			push_warning("broken message from the host: %s" % session.last_error())
			_leave(tr("PROTOCOL_ERROR"))
		"host_lost":
			_leave(tr("HOST_LEFT"))
		"failed":
			_leave(tr("CONNECT_FAILED") % _joining)
		"divergence":
			push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]])
		"figure":
			figures.place(notice["player"], notice["position"])


## Keeps the recording of a guest's world that diverged from the host's, so
## that the divergence can be replayed.
func _keep_diverged_recording() -> void:
	var path := store.write_recording(session.take_diverged_recording())
	push_warning("the world diverged from the host's; its recording is %s" % path)


func _unhandled_input(event: InputEvent) -> void:
	if session == null or not session.is_running():
		return
	if event.is_action_pressed("save_game"):
		if session.is_host():
			hud.flash("SAVED" if save_game() else "SAVE_FAILED")
		else:
			hud.flash("HOST_SAVES")
	elif event.is_action_pressed("load_game"):
		_reload()
	elif event.is_action_pressed("toggle_pause"):
		# Ending a pause is always allowed, starting one only alone.
		if clock.paused or (session.is_host() and session.guest_count() == 0):
			_set_paused(not clock.paused)
		else:
			hud.flash("PAUSE_ONLY_ALONE")


## Goes back to the saved world. Only when playing alone: guests would no
## longer have the host's world.
func _reload() -> void:
	if link != null:
		hud.flash("LOAD_ONLY_ALONE")
		return
	var save := store.read_save()
	if store.last_read_error != OK:
		hud.flash("LOAD_FAILED")
		return
	var next := GwSession.new()
	if not _load(next, save):
		hud.flash("LOAD_FAILED")
		return
	_write_recording()
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	hud.flash("LOADED")


func _set_paused(paused: bool) -> void:
	clock.paused = paused
	avatar.set_process(not paused)
	hud.show_paused(paused)


func _notification(what: int) -> void:
	if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
		return
	if session.is_running():
		if session.is_host() and not save_game():
			push_warning("the game could not be saved on close")
		_write_recording()
	if link != null:
		link.link.close()


func _run_tick() -> void:
	_apply(session.step())
	if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
		hud.flash("SAVE_FAILED")
		push_warning("the automatic save failed")


func _apply(events: Array[Dictionary]) -> void:
	var local := session.local_player()
	for event in events:
		match event["type"]:
			"tile_hit":
				var tile := Vector2i(event["x"], event["y"])
				map_view.refresh_tile(tile)
				# The own hits showed their burst when they were made.
				if event["player"] != local:
					map_view.hit(tile)
			"tile_changed":
				var tile := Vector2i(event["x"], event["y"])
				map_view.refresh_tile(tile)
				map_view.hit(tile)
			"item_gained":
				if event["player"] == local:
					hud.show_stone(session.item_count(GwSession.ITEM_STONE))
			"inventory_full":
				if event["player"] == local:
					hud.flash("INVENTORY_FULL")
			"player_joined":
				if event["player"] != local:
					hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
				else:
					avatar.player = local
					avatar.queue_redraw()
				_update_status()
			"player_left":
				figures.forget(event["player"])
				hud.flash("PLAYER_LEFT", [session.player_name(event["player"])])
				_update_status()
	if session.is_broken():
		hud.flash("SIMULATION_STOPPED")
		push_error(session.last_error())


func _update_marker() -> void:
	var tile := GridMotion.tile_of(get_global_mouse_position())
	var distance := avatar.position.distance_to(GridMotion.tile_center(tile))
	marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE)


func _try_dig() -> void:
	if marker.in_reach:
		dig_at(marker.tile)


## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its
## burst of chips at once: the hit itself lands with a later tick, which for a
## guest comes back from the host only after the round trip. The caller must
## check the reach: figures move outside the lockstep, so the core does not
## know where the figure is. The core checks the cooldown, the tile, its
## terrain, the tool tier and the free space in the inventory.
func dig_at(tile: Vector2i) -> void:
	if session.tick() < _next_dig_tick:
		return
	session.dig(tile.x, tile.y)
	_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS
	if session.terrain_at(tile.x, tile.y) != GwSession.TERRAIN_GROUND:
		map_view.hit(tile)


## Writes the running world as the save. Returns false if nothing was
## written: for a guest, whose world belongs to the host, and while an
## unusable save could not be set aside.
func save_game() -> bool:
	if _save_blocked or not session.is_host():
		return false
	return store.write_save(session.save())


func _write_recording() -> void:
	store.write_recording(session.recording_bytes())
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 126 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 6: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Vitrine der Stilproben

Das Startmenü führt zu einer Vitrine, die den gewählten Stil an einer
kleinen Szene zeigt: Fels, toter und lebendiger Boden, Lebensmoos, eine
Ranke, die Steine in eine erwachende Ruine trägt, eine wachsende
Faserpflanze, ein hüpfendes Weidetier und zwei Spielfiguren. Knöpfe,
die Tasten 1 bis 3 und F2 wechseln den Stil, Esc führt zurück ins Menü.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 4: Stil wählen, merken und fotografieren

Files:

  • Modify: game/scripts/launch_options.gd (ganze Datei ersetzen), game/scripts/save_store.gd (zwei Funktionen einfügen), game/scripts/main.gd (ganze Datei ersetzen), game/i18n/texts.csv (ganze Datei ersetzen), game/tools/screenshot.gd (ganze Datei ersetzen)
  • Test: game/tests/test_launch_options.gd, game/tests/test_save_store.gd (je ein Test am Ende), game/tests/test_main.gd (zwei Tests am Ende)

Interfaces:

  • Consumes: Art.style_keys(), Art.select, Art.select_next, ArtStyle.key() und ArtStyle.title_key() aus Task 1; die Aktion next_style und _redraw_art() aus Task 3; SaveStore._setting(section, key) und SaveStore._set_setting(section, key, value); Hud.flash(key: String, values: Array = []) und Hud.message(); die Hilfen _start_main, _stop_main, _clean_up, _press und _started in game/tests/test_main.gd

  • Produces:

    • LaunchOptions.style: StringName: der Stil aus --style=NAME, leer ohne Angabe. Ein unbekannter Name landet in ignored.
    • SaveStore.style() -> String und SaveStore.set_style(key: String) -> void, in den Einstellungen unter Abschnitt display, Schlüssel style
    • Hauptszene: Beim Start gilt der Stil der Befehlszeile, sonst der gemerkte, sonst Warm. F2 wechselt im Spiel und meldet STYLE_SWITCHED. Jeder Wechsel wird gemerkt.
    • res://tools/screenshot.gd, aufgerufen mit -- <Pfad der PNG-Datei> [--style=NAME] [--showcase]
    • Texte: STYLE_SWITCHED; die Hilfezeile HELP nennt F2
  • Step 1: Fehlschlagende Tests schreiben

Füge am Ende von game/tests/test_launch_options.gd diesen Test an, nach zwei Leerzeilen:

func test_a_style_can_be_chosen() -> void:
	check_eq(LaunchOptions.parse(PackedStringArray(["--style=glow"])).style, GlowStyle.KEY, "a known style")
	check_eq(LaunchOptions.parse(PackedStringArray()).style, &"", "none without the argument")
	var unknown := LaunchOptions.parse(PackedStringArray(["--style=watercolour"]))
	check_eq(unknown.style, &"", "an unknown style is not taken")
	check_eq(unknown.ignored, PackedStringArray(["--style=watercolour"]), "but listed as ignored")

Füge am Ende von game/tests/test_save_store.gd diesen Test an, nach zwei Leerzeilen:

func test_the_chosen_style_is_kept() -> void:
	var directory := TestFiles.temporary_dir("style")
	var store := SaveStore.new(directory)
	check_eq(store.style(), "", "no style at first")
	store.set_style("pastel")
	check_eq(SaveStore.new(directory).style(), "pastel", "kept for the next start")
	TestFiles.remove_dir(directory)

Füge am Ende von game/tests/test_main.gd diese beiden Tests an, nach zwei Leerzeilen:

func test_f2_switches_the_style_and_the_choice_is_kept() -> void:
	var directory := TestFiles.temporary_dir("style")
	var main := _start_main(directory)
	main.play(false)
	var first: StringName = main.art.style.key()
	_press(main, "next_style")
	var second: StringName = main.art.style.key()
	check(second != first, "another style")
	check_eq(main.hud.message(), tr("STYLE_SWITCHED") % tr(main.art.style.title_key()), "the player is told")
	_stop_main(main)

	var later := _start_main(directory)
	check_eq(later.art.style.key(), second, "the next start draws in it again")
	_clean_up([directory])


func test_the_command_line_chooses_the_style() -> void:
	var directory := TestFiles.temporary_dir("style-option")
	var main: Node = load("res://main.tscn").instantiate()
	main.storage_dir = directory
	main.command_line = PackedStringArray(["--style=pastel"])
	tree.root.add_child(main)
	_started.append(main)
	check_eq(main.art.style.key(), PastelStyle.KEY, "the style of the command line")
	_clean_up([directory])
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 126 passed, 4 failed:

  • test_a_style_can_be_chosen mit Invalid access to property or key 'style' on a base object of type 'RefCounted (LaunchOptions)'.
  • test_the_chosen_style_is_kept mit Invalid call. Nonexistent function 'style' in base 'RefCounted (SaveStore)'.
  • test_f2_switches_the_style_and_the_choice_is_kept mit another style
  • test_the_command_line_chooses_the_style mit the style of the command line: expected pastel, got warm

Dazu kommen WARNING: ignored command line argument: --style=pastel und ERROR: String formatting error: not all arguments converted during string formatting., weil es den Text STYLE_SWITCHED noch nicht gibt.

  • Step 3: Befehlszeile, Einstellung, Hauptszene, Texte und Werkzeug schreiben

Ersetze den gesamten Inhalt von game/scripts/launch_options.gd durch die folgende Fassung. Neu sind die Zeilen zu --style in der Beschreibung, das Feld style und der Zweig "--style" in parse.

class_name LaunchOptions
extends RefCounted
## What the command line asks for. Godot passes the arguments after "--":
##   --profile=NAME   keeps key, save and recordings in a folder of their own,
##                    so that two windows on one machine are two players
##   --name=NAME      the player's name; like a name typed in the menu, it is
##                    remembered for the next start
##   --solo           starts playing alone at once, without the menu
##   --host           opens the world for guests at once
##   --port=PORT      the port an open world listens on
##   --join=ADDRESS   joins a host at once; ADDRESS may end in ":PORT"
##   --style=NAME     draws the playing field in the style NAME: warm,
##                    pastel or glow

## Longest profile name.
const MAX_PROFILE_LENGTH := 32
## Characters a profile name may contain.
const PROFILE_CHARACTERS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"

var profile := ""
var player_name := ""
## How to start without the menu: "", "solo", "host" or "join".
var start := ""
var port := NetLink.DEFAULT_PORT
var address := ""
## The style to draw in, or empty for the one in the settings.
var style := &""
## Arguments that were not understood and are ignored.
var ignored := PackedStringArray()


## Reads `arguments`. Unknown arguments and bad values end up in `ignored`.
static func parse(arguments: PackedStringArray) -> LaunchOptions:
	var options := LaunchOptions.new()
	for argument in arguments:
		var value := argument.get_slice("=", 1) if argument.contains("=") else ""
		match argument.get_slice("=", 0):
			"--profile":
				if is_valid_profile(value):
					options.profile = value
				else:
					options.ignored.append(argument)
			"--name":
				options.player_name = value
			"--solo":
				options.start = "solo"
			"--host":
				options.start = "host"
			"--port":
				if value.is_valid_int() and value.to_int() >= 0 and value.to_int() <= 65535:
					options.port = value.to_int()
				else:
					options.ignored.append(argument)
			"--join":
				options.start = "join"
				options.address = value
			"--style":
				if Art.style_keys().has(StringName(value)):
					options.style = StringName(value)
				else:
					options.ignored.append(argument)
			_:
				options.ignored.append(argument)
	return options


## Whether `name` can name a profile folder: up to 32 of the letters A to Z,
## digits, "-" and "_".
static func is_valid_profile(name: String) -> bool:
	if name.is_empty() or name.length() > MAX_PROFILE_LENGTH:
		return false
	for character in name:
		if not PROFILE_CHARACTERS.contains(character):
			return false
	return true


## The folder for the player's files. The default profile uses the user
## folder itself, so that saves of earlier versions stay where they are.
func storage_dir() -> String:
	if profile.is_empty():
		return "user://"
	return "user://profiles".path_join(profile)

Füge in game/scripts/save_store.gd direkt vor der Zeile func _setting(section: String, key: String) -> String: diese beiden Funktionen ein. Danach folgen zwei Leerzeilen, wie zwischen allen Funktionen:

## The key of the style the player chose last time, or an empty text.
func style() -> String:
	return _setting("display", "style")


func set_style(key: String) -> void:
	_set_setting("display", "style", key)

In der Hauptszene kommt gegenüber Task 3 hinzu: Beim Start wählt art den Stil der Befehlszeile und sonst den gemerkten. _redraw_art() merkt sich jeden Wechsel mit store.set_style. F2 ruft im Spiel art.select_next() und meldet den neuen Stil.

Ersetze den gesamten Inhalt von game/scripts/main.gd durch:

extends Node2D
## Composition root. Shows the start menu, then runs a session as host or as
## guest with the map view, the figures, the HUD and the files on disk.

## Seed of a new world until the slice ships its chosen seed.
const NEW_WORLD_SEED := 2026
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
const REACH_TILES := 2.5
## Ticks between two automatic saves: two minutes.
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
const CAMERA_ZOOM := Vector2(3.0, 3.0)
## Most ticks a guest runs in one frame. A guest that fell behind catches up
## within a few frames without freezing.
const MAX_GUEST_TICKS_PER_FRAME := 20
## Seconds between two position updates of the own figure.
const FIGURE_SECONDS := 0.1
## The text for every reason a host gives for turning a guest away.
const REFUSAL_TEXTS := {
	GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL",
	GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES",
	GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL",
	GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS",
	GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING",
}

## Folder for the player key, the save and the recordings. If it is empty,
## the command line decides; tests set a folder of their own.
@export var storage_dir := ""
## Network interface an open world listens on. Tests use the loopback one.
var listen_address := "*"
## The command line arguments after "--". Tools and tests may set their own
## before the scene enters the tree.
var command_line := OS.get_cmdline_user_args()

var options: LaunchOptions
var store: SaveStore
## The running session, or null in the menu. A guest's session has no world
## until the host sent it.
var session: GwSession
## The network connection of an open world or a guest, or null.
var link: SessionLink
var clock: TickClock
## The style the playing field is drawn in.
var art: Art
var menu: Menu
var showcase: Showcase
var map_view: MapView
var marker: TileMarker
var avatar: Avatar
var figures: Figures
var hud: Hud
## How often a guest's world diverged and was replaced by the host's.
var resyncs := 0

var _player_key: PackedByteArray
var _next_dig_tick := 0
## True while an unusable save could not be set aside. Saving is refused
## then, so that the unusable save is never overwritten.
var _save_blocked := false
var _figure_wait := 0.0
## The address a guest is joining, for messages.
var _joining := ""
## The host's accepted guests when _process last looked. A new one ends a
## pause.
var _known_guests := 0


func _ready() -> void:
	I18n.install()
	InputSetup.install()
	options = LaunchOptions.parse(command_line)
	for argument in options.ignored:
		push_warning("ignored command line argument: %s" % argument)
	if storage_dir.is_empty():
		storage_dir = options.storage_dir()
	store = SaveStore.new(storage_dir)
	_player_key = store.player_key()
	if _player_key.is_empty():
		push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
	clock = TickClock.new(GwSession.TICKS_PER_SECOND)
	art = Art.new()
	art.load_overrides(Art.OVERRIDE_DIR)
	if not art.select(options.style):
		art.select(StringName(store.style()))
	map_view = MapView.new()
	map_view.art = art
	add_child(map_view)
	marker = TileMarker.new()
	marker.art = art
	add_child(marker)
	figures = Figures.new()
	figures.art = art
	add_child(figures)
	avatar = Avatar.new()
	avatar.art = art
	add_child(avatar)
	var camera := Camera2D.new()
	camera.zoom = CAMERA_ZOOM
	avatar.add_child(camera)
	hud = Hud.new()
	add_child(hud)
	menu = Menu.new()
	add_child(menu)
	menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
	menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
	art.changed.connect(_redraw_art)
	menu.play_chosen.connect(play)
	menu.join_chosen.connect(join)
	menu.showcase_chosen.connect(open_showcase)
	showcase = Showcase.new()
	showcase.art = art
	showcase.closed.connect(close_showcase)
	showcase.hide()
	showcase.process_mode = Node.PROCESS_MODE_DISABLED
	add_child(showcase)
	_show_menu("")
	match options.start:
		"solo":
			play(false)
		"host":
			play(true)
		"join":
			join(options.address)


## Plays the saved world, or a new one, as its host. With `open`, guests may
## join. Returns false if it could not start; the menu then says why.
func play(open: bool) -> bool:
	if not _can_start():
		return false
	var next := GwSession.new()
	var set_aside := false
	if store.has_save():
		var save := store.read_save()
		if store.last_read_error != OK:
			_show_menu(tr("SAVE_UNREADABLE"))
			return false
		set_aside = not _load(next, save)
	if not next.is_running():
		next.start_new(NEW_WORLD_SEED)
	if open:
		var net := NetLink.new()
		if net.host(options.port, listen_address) != OK:
			_show_menu(tr("OPEN_FAILED") % options.port)
			return false
		link = SessionLink.new(next, net)
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	if set_aside:
		hud.flash("LOAD_FAILED")
	return true


## Starts joining the host at `address`, "host" or "host:port". The world
## arrives later; until then the menu says that it is connecting.
func join(address: String) -> void:
	if not _can_start():
		return
	var target := NetLink.split_address(address)
	if target.is_empty():
		_show_menu(tr("ADDRESS_INVALID") % address)
		return
	var net := NetLink.new()
	if net.join(target[0], target[1]) != OK:
		_show_menu(tr("CONNECT_FAILED") % address)
		return
	session = GwSession.new()
	session.start_guest(_player_key, _player_name())
	link = SessionLink.new(session, net)
	_joining = address
	store.set_last_address(address)
	_show_menu(tr("CONNECTING") % address)


func _can_start() -> bool:
	if session != null:
		return false
	if _player_key.is_empty():
		_show_menu(tr("KEY_UNREADABLE"))
		return false
	store.set_player_name(menu.player_name())
	return true


func _player_name() -> String:
	var typed := menu.player_name()
	return typed if not typed.is_empty() else tr("DEFAULT_NAME")


## Starts `next` from `save`. If the save is unusable, it is set aside so
## that the next save does not overwrite it, and false is returned.
func _load(next: GwSession, save: PackedByteArray) -> bool:
	if next.start_loaded(save):
		_save_blocked = false
		return true
	var aside := store.set_aside_save()
	if aside.is_empty():
		_save_blocked = true
		push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error())
	else:
		push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside])
	return false


func _show_menu(status: String) -> void:
	menu.show_status(status)
	menu.show()
	hud.hide()
	for node: Node2D in [avatar, marker, figures]:
		node.hide()


## Shows the world of the session and puts the figure on the spawn.
func _show_world() -> void:
	menu.hide()
	hud.show()
	for node: Node2D in [avatar, marker, figures]:
		node.show()
	map_view.session = session
	map_view.reload()
	figures.session = session
	avatar.player = session.local_player()
	figures.clear()
	avatar.is_walkable = session.is_walkable
	avatar.position = GridMotion.tile_center(session.spawn_point())
	hud.show_stone(session.item_count(GwSession.ITEM_STONE))
	_update_status()
	_next_dig_tick = 0


## Shows the style samples instead of the menu.
func open_showcase() -> void:
	menu.hide()
	showcase.time = 0.0
	showcase.process_mode = Node.PROCESS_MODE_INHERIT
	showcase.show()


## Goes back from the style samples to the menu.
func close_showcase() -> void:
	showcase.hide()
	showcase.process_mode = Node.PROCESS_MODE_DISABLED
	menu.show()


## Draws the playing field again after the style changed, and remembers the
## style for the next start.
func _redraw_art() -> void:
	map_view.redraw()
	for node: Node2D in [marker, figures, avatar]:
		node.queue_redraw()
	store.set_style(String(art.style.key()))


## Says in the status line how the game is shared, and with how many.
func _update_status() -> void:
	if link == null:
		hud.show_status("")
	elif session.is_host():
		hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()])
	else:
		hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()])


## Ends a guest's session and goes back to the menu with `message`.
func _leave(message: String) -> void:
	if session == null:
		return
	_write_recording()
	if link != null:
		link.link.close()
	link = null
	session = null
	map_view.session = null
	map_view.reload()
	avatar.is_walkable = Callable()
	figures.clear()
	_show_menu(message)


## A broken session runs no more ticks. Instead of leaving the others
## waiting, a host closes its world to guests and a guest leaves. The host's
## status line says why its world stands still.
func _stop_sharing() -> void:
	if not session.is_host():
		_leave(tr("SIMULATION_STOPPED"))
		return
	if link != null:
		link.link.close()
		link = null
		figures.clear()
	hud.show_status(tr("SIMULATION_STOPPED"))


## Ends a pause once the host accepted a guest: the guest joins in the next
## tick, and a paused world would keep it waiting. A connection that has not
## greeted yet is no guest.
func _end_pause_for_new_guests() -> void:
	var guests := session.guest_count()
	if guests > _known_guests and clock.paused:
		_set_paused(false)
	_known_guests = guests


func _process(delta: float) -> void:
	if link != null:
		for notice in link.pump(delta):
			_on_notice(notice)
	if session != null and session.is_broken():
		_stop_sharing()
	if session == null or not session.is_running() or session.is_broken():
		return
	_end_pause_for_new_guests()
	_update_marker()
	if Input.is_action_pressed("dig") and not clock.paused:
		_try_dig()
	if session.is_host():
		for i in clock.advance(delta):
			_run_tick()
	else:
		for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME):
			_apply(session.step())
	if link != null:
		link.flush()
		_figure_wait -= delta
		if _figure_wait <= 0.0:
			_figure_wait = FIGURE_SECONDS
			link.send_figure(avatar.position)


func _on_notice(notice: Dictionary) -> void:
	# An earlier notice of the same batch may have ended the session.
	if session == null:
		return
	match notice["type"]:
		"world_received":
			if notice["resync"]:
				resyncs += 1
				_keep_diverged_recording()
				map_view.reload()
				hud.show_stone(session.item_count(GwSession.ITEM_STONE))
				hud.flash("RESYNCED")
			else:
				_show_world()
		"refused":
			_leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR")))
		"protocol_error":
			push_warning("broken message from the host: %s" % session.last_error())
			_leave(tr("PROTOCOL_ERROR"))
		"host_lost":
			_leave(tr("HOST_LEFT"))
		"failed":
			_leave(tr("CONNECT_FAILED") % _joining)
		"divergence":
			push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]])
		"figure":
			figures.place(notice["player"], notice["position"])


## Keeps the recording of a guest's world that diverged from the host's, so
## that the divergence can be replayed.
func _keep_diverged_recording() -> void:
	var path := store.write_recording(session.take_diverged_recording())
	push_warning("the world diverged from the host's; its recording is %s" % path)


func _unhandled_input(event: InputEvent) -> void:
	if session == null or not session.is_running():
		return
	if event.is_action_pressed("save_game"):
		if session.is_host():
			hud.flash("SAVED" if save_game() else "SAVE_FAILED")
		else:
			hud.flash("HOST_SAVES")
	elif event.is_action_pressed("load_game"):
		_reload()
	elif event.is_action_pressed("toggle_pause"):
		# Ending a pause is always allowed, starting one only alone.
		if clock.paused or (session.is_host() and session.guest_count() == 0):
			_set_paused(not clock.paused)
		else:
			hud.flash("PAUSE_ONLY_ALONE")
	elif event.is_action_pressed("next_style"):
		art.select_next()
		hud.flash("STYLE_SWITCHED", [tr(art.style.title_key())])


## Goes back to the saved world. Only when playing alone: guests would no
## longer have the host's world.
func _reload() -> void:
	if link != null:
		hud.flash("LOAD_ONLY_ALONE")
		return
	var save := store.read_save()
	if store.last_read_error != OK:
		hud.flash("LOAD_FAILED")
		return
	var next := GwSession.new()
	if not _load(next, save):
		hud.flash("LOAD_FAILED")
		return
	_write_recording()
	session = next
	session.join(_player_key, _player_name())
	_run_tick()
	_show_world()
	hud.flash("LOADED")


func _set_paused(paused: bool) -> void:
	clock.paused = paused
	avatar.set_process(not paused)
	hud.show_paused(paused)


func _notification(what: int) -> void:
	if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
		return
	if session.is_running():
		if session.is_host() and not save_game():
			push_warning("the game could not be saved on close")
		_write_recording()
	if link != null:
		link.link.close()


func _run_tick() -> void:
	_apply(session.step())
	if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
		hud.flash("SAVE_FAILED")
		push_warning("the automatic save failed")


func _apply(events: Array[Dictionary]) -> void:
	var local := session.local_player()
	for event in events:
		match event["type"]:
			"tile_hit":
				var tile := Vector2i(event["x"], event["y"])
				map_view.refresh_tile(tile)
				# The own hits showed their burst when they were made.
				if event["player"] != local:
					map_view.hit(tile)
			"tile_changed":
				var tile := Vector2i(event["x"], event["y"])
				map_view.refresh_tile(tile)
				map_view.hit(tile)
			"item_gained":
				if event["player"] == local:
					hud.show_stone(session.item_count(GwSession.ITEM_STONE))
			"inventory_full":
				if event["player"] == local:
					hud.flash("INVENTORY_FULL")
			"player_joined":
				if event["player"] != local:
					hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
				else:
					avatar.player = local
					avatar.queue_redraw()
				_update_status()
			"player_left":
				figures.forget(event["player"])
				hud.flash("PLAYER_LEFT", [session.player_name(event["player"])])
				_update_status()
	if session.is_broken():
		hud.flash("SIMULATION_STOPPED")
		push_error(session.last_error())


func _update_marker() -> void:
	var tile := GridMotion.tile_of(get_global_mouse_position())
	var distance := avatar.position.distance_to(GridMotion.tile_center(tile))
	marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE)


func _try_dig() -> void:
	if marker.in_reach:
		dig_at(marker.tile)


## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its
## burst of chips at once: the hit itself lands with a later tick, which for a
## guest comes back from the host only after the round trip. The caller must
## check the reach: figures move outside the lockstep, so the core does not
## know where the figure is. The core checks the cooldown, the tile, its
## terrain, the tool tier and the free space in the inventory.
func dig_at(tile: Vector2i) -> void:
	if session.tick() < _next_dig_tick:
		return
	session.dig(tile.x, tile.y)
	_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS
	if session.terrain_at(tile.x, tile.y) != GwSession.TERRAIN_GROUND:
		map_view.hit(tile)


## Writes the running world as the save. Returns false if nothing was
## written: for a guest, whose world belongs to the host, and while an
## unusable save could not be set aside.
func save_game() -> bool:
	if _save_blocked or not session.is_host():
		return false
	return store.write_save(session.save())


func _write_recording() -> void:
	store.write_recording(session.recording_bytes())

Ersetze den gesamten Inhalt von game/i18n/texts.csv durch die folgende Fassung. Neu sind „F2: Stil wechseln“ in der Hilfezeile HELP und die Zeile STYLE_SWITCHED.

keys,de,en
STONE_COUNT,Stein: %d,Stone: %d
SAVED,Gespeichert,Saved
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
LOADED,Spielstand geladen,Save loaded
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
PAUSED,Pause,Paused
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F2: Stil wechseln · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F2: change the style · F5: save · F9: load · Esc: pause
DEFAULT_NAME,Spieler,Player
MENU_NAME,Dein Name,Your name
MENU_SOLO,Allein spielen,Play alone
MENU_HOST,Welt öffnen,Open your world
MENU_ADDRESS,Adresse des Hosts,Address of the host
MENU_JOIN,Beitreten,Join
CONNECTING,Verbinde mit %s …,Connecting to %s …
CONNECT_FAILED,Keine Verbindung zu %s,Could not reach %s
ADDRESS_INVALID,Das ist keine Adresse: %s,That is not an address: %s
HOST_LEFT,Der Host hat die Welt geschlossen,The host closed the world
PROTOCOL_ERROR,Die Verbindung zum Host ist gestört,The connection to the host is broken
REFUSED_OTHER_PROTOCOL,Der Host hat eine andere Version des Spiels,The host has another version of the game
REFUSED_OTHER_RULES,Der Host hat andere Spielregeln. Ihr braucht denselben Stand des Spiels.,The host has other rules. You need the same version of the game.
REFUSED_WORLD_FULL,Die Welt ist voll: Vier Spieler sind schon da,The world is full: four players are in it
REFUSED_TOO_MANY_PLAYERS,Diese Welt kennt schon 16 Spieler und nimmt keine neuen mehr auf,This world knows 16 players already and takes no new ones
REFUSED_ALREADY_PLAYING,Ein Spieler mit deinem Schlüssel ist schon in der Welt,A player with your key is in the world already
STATUS_HOST,Welt offen auf Port %d · Spieler: %d,World open on port %d · players: %d
STATUS_GUEST,Gast bei %s · Spieler: %d,Guest of %s · players: %d
OPEN_FAILED,Port %d lässt sich nicht öffnen,Port %d cannot be opened
PLAYER_JOINED,%s ist da,%s joined
PLAYER_LEFT,%s ist gegangen,%s left
RESYNCED,Welt mit dem Host abgeglichen,World matched with the host again
PAUSE_ONLY_ALONE,Pause gibt es nur ohne Gäste,Pausing only works without guests
LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when playing alone
HOST_SAVES,Nur der Host speichert,Only the host saves
KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now
SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now
STYLE_WARM,Warm,Warm
STYLE_PASTEL,Pastell,Pastel
STYLE_GLOW,Glimmen,Glow
STYLE_SWITCHED,Stil: %s,Style: %s
MENU_SHOWCASE,Stilproben ansehen,See the style samples
SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s
SHOWCASE_BACK,Zurück,Back
SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock
SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil
SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil
SHOWCASE_MOSS,Lebensmoos,Life moss
SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows
SHOWCASE_GRAZER,Weidetier,Grazer
SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones
SHOWCASE_RUIN,Ruine erwacht,A ruin wakes
SHOWCASE_FIGURES,Spielfiguren,Player figures

Das Bildschirmfoto-Werkzeug lernt --style=NAME und --showcase und stellt eine zweite Figur mit Namen neben die eigene, damit die Namen auf dem Bild zu sehen sind. Das Foto entsteht jetzt acht Einzelbilder nach dem Schlag, solange die Splitter noch fliegen.

Ersetze den gesamten Inhalt von game/tools/screenshot.gd durch:

extends SceneTree
## Starts the game in a temporary storage folder, walks the figure toward the
## rock beside the clearing, hits it once and saves a screenshot. A second
## figure with a name stands beside it. Lets a change to the look be checked
## without playing by hand. The player's real key and save stay untouched.
##
## From the repository root (opens a window for a moment):
##   godot --path game -s res://tools/screenshot.gd -- <path of the PNG>
##     [--style=NAME] [--showcase]
## --style=NAME draws in that style; --showcase shows the style samples
## instead of the game.

const STORAGE := "user://screenshot-run"
## Frames between the hit and the picture: the burst of chips is still in
## the air, and the crack is there.
const FRAMES_AFTER_HIT := 8
## Seconds into the showcase at which the picture is taken: the plant is
## nearly grown and the animal in the air.
const SHOWCASE_SECONDS := 5.2


func _initialize() -> void:
	_run.call_deferred()


func _run() -> void:
	var output := "user://screenshot.png"
	var style := ""
	var showcase := false
	for argument in OS.get_cmdline_user_args():
		if argument.begins_with("--style="):
			style = argument
		elif argument == "--showcase":
			showcase = true
		else:
			output = argument
	DirAccess.make_dir_recursive_absolute(STORAGE)
	var main: Node = load("res://main.tscn").instantiate()
	main.storage_dir = STORAGE
	main.command_line = PackedStringArray([style] if not style.is_empty() else [])
	root.add_child(main)
	if showcase:
		main.open_showcase()
		await process_frame
		main.showcase.time = SHOWCASE_SECONDS
		await process_frame
		await process_frame
	else:
		await _play(main)

	var image := root.get_texture().get_image()
	var error := image.save_png(output)
	print("screenshot %s: %s" % ["saved" if error == OK else "failed", ProjectSettings.globalize_path(output)])
	root.remove_child(main)
	main.free()
	_remove(STORAGE)
	quit(0 if error == OK else 1)


## Plays alone: walks four tiles to the right, toward the rock outcrop
## beside the clearing, and hits it once.
func _play(main: Node) -> void:
	main.play(false)
	await process_frame
	for frame in 48:
		main.avatar.walk(Vector2.RIGHT, 1.0 / 60.0)
		await process_frame
	var session: GwSession = main.session
	main.figures.place(session.local_player(), main.avatar.position + Vector2(-40, 8))
	var rock := session.spawn_point() + Vector2i(7, 0)
	main.dig_at(rock)
	for frame in FRAMES_AFTER_HIT:
		await process_frame


func _remove(path: String) -> void:
	for file in DirAccess.get_files_at(path):
		DirAccess.remove_absolute(path.path_join(file))
	for directory in DirAccess.get_directories_at(path):
		_remove(path.path_join(directory))
	DirAccess.remove_absolute(path)
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 130 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 5: Bildschirmfotos prüfen

Run:

godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-task4-spiel.png" --style=pastel
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-task4-vitrine.png" --showcase

Expected: Beide Aufrufe enden mit screenshot saved: … und geben weder Warnung noch Fehler aus. Beide Bilder zeigen noch die Platzhalter, denn die Pixelgrafik folgt erst ab Task 5. Das erste Bild zeigt das Spiel wie nach Task 2, dazu links eine zweite Figur, über der scharf „Spieler“ steht, und kleine helle Splitter am getroffenen Fels. Das zweite Bild zeigt die Vitrine: oben „Stilprobe: Warm“, in der Mitte die Szene aus Rechtecken, Kreisen und Linien mit neun weißen Beschriftungen, unten die Knöpfe „1 Warm“ (ausgegraut), „2 Pastell“, „3 Glimmen“ und „Zurück“.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Stil wählen, merken und fotografieren

F2 wechselt im Spiel den Stil und meldet ihn, --style=NAME wählt ihn
beim Start, und das Spiel merkt sich die Wahl in den Einstellungen.
Das Bildschirmfoto-Werkzeug nimmt Bilder in einem bestimmten Stil und
von der Vitrine auf und zeigt eine zweite Figur mit Namen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 5: Pixelgrafik für Boden, Fels und Figuren

PixelStyle zeichnet jede Grafik Pixel für Pixel in ein kleines Bild und hebt es auf. Dieser Task bringt den Boden, die drei Felsarten mit Vorderkante und Schatten, Risse, Splitter, die Zielmarke und die Figuren mit Laufbildern; Task 6 bringt die Dinge der Vitrine. WarmStyle wird die erste fertige Abwandlung und bekommt schon die ganze Palette, auch die Farben, die erst Task 6 braucht.

Files:

  • Create: game/scripts/art/pixel_style.gd
  • Modify: game/scripts/art/warm_style.gd, game/project.godot (jeweils ganze Datei ersetzen)
  • Test: game/tests/test_pixel_style.gd, game/tests/test_warm_style.gd

Interfaces:

  • Consumes: ArtStyle mit seinen Zeichenmethoden und ArtStyle.along; TileShape mit seinen Bits und TileShape.variant; die Einträge von state laut Kopf von art_style.gd; Avatar.figure_rect aus Task 2, in das eine Figur gezeichnet wird

  • Produces:

    • PixelStyle (erbt von ArtStyle): die Konstanten TILE := 16, FACE := 6, HIT_FRAMES := 6, FIGURE_SIZE := Vector2i(16, 24) und CLEAR; die Felder palette: Dictionary[String, Color], players: Array[Color] und tinted_outlines: bool, die eine Unterklasse füllt
    • die innere Klasse PixelStyle.Pixels mit image: Image, size: Vector2i, has, put, at, filled, shade, rect, ellipse, line und outline(color: Color, tinted: bool)
    • _image(key: String, make: Callable) -> Texture2D: macht das Bild unter key nur beim ersten Mal; static _shape(mask: int) -> int; static _spot(index: int, variant: int, low: int, span: int) -> Vector2i; static _round(pixels, corner, inward, ink); _shadow_image(width: int) -> Pixels
    • die Bilder _earth(variant: int, shadow: int) -> Pixels, _rock_image(kind: String, mask: int, variant: int) -> Pixels (kind ist "soft", "hard" oder "bed") und _figure_image(colour: int, frame: int, left: bool) -> Pixels (Bild 0 steht, 1 bis 4 laufen)
    • die Farben der Palette mit den Namen, die warm_style.gd setzt; PastelStyle und GlowStyle in Task 7 setzen dieselben Namen
  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/test_pixel_style.gd:

extends TestCase
## The pixel art of the ground, the rock and the figures, which every pixel
## style draws.


## How many pixels of `image` are not transparent.
static func _opaque(image: Image) -> int:
	var count := 0
	for y in image.get_height():
		for x in image.get_width():
			if image.get_pixel(x, y).a > 0.0:
				count += 1
	return count


func test_the_field_is_drawn_in_sharp_pixels() -> void:
	check_eq(ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_filter"), 0, "each pixel a square, not blurred")
	check(ProjectSettings.get_setting("rendering/2d/snap/snap_2d_transforms_to_pixel"), "on whole pixels of the screen")


func test_ground_fills_its_tile_and_lies_in_the_shadow_of_rock() -> void:
	var style := WarmStyle.new()
	var open := style._earth(3, 0).image
	check_eq(open.get_size(), Vector2i(PixelStyle.TILE, PixelStyle.TILE), "a tile")
	check_eq(_opaque(open), PixelStyle.TILE * PixelStyle.TILE, "no gaps")
	var shaded := style._earth(3, TileShape.N).image
	check(shaded.get_pixel(5, 0).get_luminance() < open.get_pixel(5, 0).get_luminance(), "darker below rock")
	check_eq(shaded.get_pixel(5, 8), open.get_pixel(5, 8), "but only along the rock")


func test_a_lone_rock_is_round_and_shows_its_face() -> void:
	var style := WarmStyle.new()
	var lone := style._rock_image("soft", 0, 0).image
	check_eq(lone.get_pixel(0, 0).a, 0.0, "a round corner, the ground shows there")
	check_eq(lone.get_pixel(PixelStyle.TILE - 1, PixelStyle.TILE - 1).a, 0.0, "round at the bottom too")
	check_eq(lone.get_pixel(8, 0), style.palette["outline"], "outlined at the top")
	check_eq(lone.get_pixel(8, PixelStyle.TILE - 1), style.palette["outline"], "and where it meets the ground")
	check_eq(lone.get_pixel(5, PixelStyle.TILE - 3), style.palette["soft_face"], "its face toward the open ground")


func test_rock_inside_a_mass_has_neither_edge_nor_face() -> void:
	var style := WarmStyle.new()
	var inside := style._rock_image("soft", 255, 0).image
	check_eq(_opaque(inside), PixelStyle.TILE * PixelStyle.TILE, "no gaps")
	for y in PixelStyle.TILE:
		for x in PixelStyle.TILE:
			var colour := inside.get_pixel(x, y)
			check(colour != style.palette["outline"] and colour != style.palette["soft_face"], "inside at %d, %d" % [x, y])


func test_only_the_corners_that_matter_tell_tiles_apart() -> void:
	check_eq(PixelStyle._shape(TileShape.NE), 0, "a corner alone does not count")
	check_eq(PixelStyle._shape(TileShape.N | TileShape.E | TileShape.NE), TileShape.N | TileShape.E | TileShape.NE, "between two sides it does")
	check_eq(PixelStyle._shape(255), 255, "all of them inside a mass")


func test_each_picture_is_made_only_once() -> void:
	var style := WarmStyle.new()
	var made := [0]
	var make := func() -> PixelStyle.Pixels:
		made[0] += 1
		return PixelStyle.Pixels.new(1, 1)
	var first := style._image("test/once", make)
	var again := style._image("test/once", make)
	check_eq(again, first, "the same picture")
	check_eq(made[0], 1, "made once")


func test_a_figure_walks_in_four_steps_and_faces_both_ways() -> void:
	var style := WarmStyle.new()
	var frames: Array[PackedByteArray] = []
	for frame in 5:
		var image := style._figure_image(0, frame, false).image
		check_eq(image.get_size(), PixelStyle.FIGURE_SIZE, "a tile wide, one and a half tall")
		check(not frames.has(image.get_data()), "frame %d differs from the others" % frame)
		frames.append(image.get_data())
	var left := style._figure_image(0, 1, true).image
	left.flip_x()
	check_eq(left.get_data(), frames[1], "facing left mirrors facing right")
	var other := style._figure_image(1, 0, false).image
	check(other.get_data() != frames[0], "another player has another colour")

Create game/tests/test_warm_style.gd:

extends TestCase


func test_four_players_have_four_colours() -> void:
	var style := WarmStyle.new()
	var colours := {}
	for player in 4:
		colours[style.player_color(player)] = true
	check_eq(colours.size(), 4, "four players, four colours")
	check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 130 passed, 2 failed. test_pixel_style.gd lädt nicht; davor stehen Zeilen wie SCRIPT ERROR: Parse Error: Identifier "PixelStyle" not declared in the current scope. Unter FAIL test_warm_style.gd test_four_players_have_four_colours steht four players, four colours: expected 4, got 2, denn der Platzhalter kennt nur zwei Farben.

  • Step 3: Pixelgrafik, Palette und scharfe Pixel einbauen

Create game/scripts/art/pixel_style.gd:

class_name PixelStyle
extends ArtStyle
## Pixel art in the manner of classic farming games, drawn in code. A tile
## is 16 by 16 pixels, seen a little from the front: rock shows its face
## toward open ground, and figures, animals and plants stand upright. Every
## graphic is made once as a small image, pixel by pixel from the palette,
## and kept; the camera enlarges each pixel to a crisp square.
##
## The subclasses choose the palette and the outlines.

## Pixels along the edge of a tile.
const TILE := 16
## Rows of the face that rock shows toward open ground below it.
const FACE := 6
## Images of a burst of chips, from the hit to its end.
const HIT_FRAMES := 6
## Size of a figure: a tile wide and one and a half tiles tall.
const FIGURE_SIZE := Vector2i(16, 24)
const CLEAR := Color(0, 0, 0, 0)

## Colours by name; the subclass fills them.
var palette: Dictionary[String, Color] = {}
## The colours of the players, in the order of their numbers.
var players: Array[Color] = []
## True for outlines in a darker shade of what they surround, false for one
## dark outline colour.
var tinted_outlines := false
var _textures: Dictionary[String, Texture2D] = {}


## A small image that the drawings put their pixels on.
class Pixels:
	const SIDES: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
	const CLEAR := Color(0, 0, 0, 0)

	var image: Image
	var size: Vector2i

	func _init(width: int, height: int) -> void:
		size = Vector2i(width, height)
		image = Image.create_empty(width, height, false, Image.FORMAT_RGBA8)

	func has(x: int, y: int) -> bool:
		return x >= 0 and y >= 0 and x < size.x and y < size.y

	func put(x: int, y: int, color: Color) -> void:
		if has(x, y):
			image.set_pixel(x, y, color)

	## The colour at x, y, transparent outside the image.
	func at(x: int, y: int) -> Color:
		return image.get_pixel(x, y) if has(x, y) else CLEAR

	func filled(x: int, y: int) -> bool:
		return at(x, y).a > 0.0

	## Lays the translucent `color` over the pixel at x, y.
	func shade(x: int, y: int, color: Color) -> void:
		if has(x, y):
			image.set_pixel(x, y, image.get_pixel(x, y).blend(color))

	func rect(x: int, y: int, width: int, height: int, color: Color) -> void:
		for row in range(y, y + height):
			for column in range(x, x + width):
				put(column, row, color)

	## Fills the ellipse around cx, cy with the half axes rx and ry.
	func ellipse(cx: float, cy: float, rx: float, ry: float, color: Color) -> void:
		for row in range(floori(cy - ry), ceili(cy + ry) + 1):
			for column in range(floori(cx - rx), ceili(cx + rx) + 1):
				var dx := (column + 0.5 - cx) / rx
				var dy := (row + 0.5 - cy) / ry
				if dx * dx + dy * dy <= 1.0:
					put(column, row, color)

	## A line of single pixels from x0, y0 to x1, y1.
	func line(x0: int, y0: int, x1: int, y1: int, color: Color) -> void:
		var dx := absi(x1 - x0)
		var dy := -absi(y1 - y0)
		var sx := 1 if x0 < x1 else -1
		var sy := 1 if y0 < y1 else -1
		var error := dx + dy
		var x := x0
		var y := y0
		while true:
			put(x, y, color)
			if x == x1 and y == y1:
				return
			var twice := 2 * error
			if twice >= dy:
				error += dy
				x += sx
			if twice <= dx:
				error += dx
				y += sy

	## Surrounds everything solid drawn so far with a line one pixel wide: in
	## `color`, or, with `tinted`, in a darker shade of the pixel beside it.
	func outline(color: Color, tinted: bool) -> void:
		var found: Array[Vector3i] = []
		var colors: Array[Color] = []
		for y in size.y:
			for x in size.x:
				if filled(x, y):
					continue
				for side in SIDES:
					var beside := at(x + side.x, y + side.y)
					if beside.a >= 1.0:
						found.append(Vector3i(x, y, 0))
						colors.append(beside.darkened(0.5) if tinted else color)
						break
		for index in found.size():
			put(found[index].x, found[index].y, colors[index])


func player_color(player: int) -> Color:
	return players[maxi(0, player) % players.size()]


func _c(name: String) -> Color:
	return palette[name]


## The dark line around things, or around a thing of colour `fill` when
## outlines are tinted.
func _ink(fill: Color) -> Color:
	return fill.darkened(0.5) if tinted_outlines else _c("outline")


## A number from 0 to 1 that looks random and is the same for the same
## arguments.
static func _noise(x: int, y: int, salt: int) -> float:
	return float(TileShape.variant(Vector2i(x * 31 + salt * 977, y * 17 - salt * 131)) % 1000) / 1000.0


## The texture made by `make` under `key`, made only the first time.
func _image(key: String, make: Callable) -> Texture2D:
	var texture: Texture2D = _textures.get(key)
	if texture == null:
		var pixels: Pixels = make.call()
		texture = ImageTexture.create_from_image(pixels.image)
		_textures[key] = texture
	return texture


## Draws `texture` with its top left corner at `at`, on whole pixels.
static func _put(canvas: CanvasItem, texture: Texture2D, at: Vector2, modulate := Color.WHITE) -> void:
	canvas.draw_texture(texture, at.round(), modulate)


## The neighbours of `mask` that change how a tile looks: the four sides,
## and a corner only where both sides beside it are set.
static func _shape(mask: int) -> int:
	var result := mask & (TileShape.N | TileShape.E | TileShape.S | TileShape.W)
	var corners := [
		[TileShape.NE, TileShape.N | TileShape.E], [TileShape.SE, TileShape.S | TileShape.E],
		[TileShape.SW, TileShape.S | TileShape.W], [TileShape.NW, TileShape.N | TileShape.W],
	]
	for corner: Array in corners:
		var sides: int = corner[1]
		if (mask & corner[0]) != 0 and (mask & sides) == sides:
			result |= corner[0]
	return result


# Terrain.


func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
	var variant: int = state.get("variant", 0) % 8
	_put(canvas, _image("ground/%d/%d" % [shadow, variant], func() -> Pixels: return _earth(variant, shadow)), rect.position)


## The bare earth of the dead world: up to two soft patches of darker earth,
## a light grain or two, now and then a pebble.
func _earth(variant: int, shadow: int) -> Pixels:
	var pixels := Pixels.new(TILE, TILE)
	pixels.rect(0, 0, TILE, TILE, _c("ground"))
	var patches: int = [0, 1, 2, 1, 0, 1, 1, 2][variant]
	for patch in patches:
		var at := _spot(patch, variant, 2, 11)
		for dy in 2:
			for dx in range(-1, 2):
				if _noise(at.x + dx, at.y + dy, variant + 40) < 0.6:
					pixels.put(at.x + dx, at.y + dy, _c("ground_dark"))
	for grain in 1 + variant % 2:
		var at := _spot(grain + 4, variant, 1, 14)
		pixels.put(at.x, at.y, _c("ground_light"))
	if variant % 4 == 1:
		var at := _spot(9, variant, 3, 9)
		_pebble(pixels, at.x, at.y)
	_cast_shadow(pixels, shadow)
	return pixels


## A point in the tile that is the same for the same `index` and `variant`,
## from `low` to `low + span - 1` on both axes.
static func _spot(index: int, variant: int, low: int, span: int) -> Vector2i:
	return Vector2i(low + int(_noise(index, 1, variant) * span), low + int(_noise(index, 2, variant) * span))


func _pebble(pixels: Pixels, x: int, y: int) -> void:
	pixels.put(x, y, _c("pebble_light"))
	pixels.put(x + 1, y, _c("pebble"))
	pixels.put(x, y + 1, _c("pebble"))
	pixels.put(x + 1, y + 1, _c("pebble_dark"))


## Darkens the ground where rock to the north or west casts its shadow; the
## light falls from the top left.
func _cast_shadow(pixels: Pixels, shadow: int) -> void:
	var deep := Color(0, 0, 0, 0.32)
	var faint := Color(0, 0, 0, 0.16)
	if (shadow & TileShape.N) != 0:
		for x in TILE:
			pixels.shade(x, 0, deep)
			pixels.shade(x, 1, deep if x % 2 == 0 else faint)
			pixels.shade(x, 2, faint if x % 2 == 1 else CLEAR)
	if (shadow & TileShape.W) != 0:
		for y in range(1 if (shadow & TileShape.N) != 0 else 0, TILE):
			pixels.shade(0, y, faint)
	elif (shadow & (TileShape.N | TileShape.NW)) == TileShape.NW:
		pixels.shade(0, 0, deep)


func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	_rock(canvas, rect, state, "soft")


func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	_rock(canvas, rect, state, "hard")


func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	_rock(canvas, rect, state, "bed")


## Rock of `kind`: its top, and its face where the ground below is open.
## The ground shows where the outer corners are round.
func _rock(canvas: CanvasItem, rect: Rect2, state: Dictionary, kind: String) -> void:
	var mask := _shape(state.get("solid", 0))
	var variant: int = state.get("variant", 0) % 4
	_ground(canvas, rect, {"variant": state.get("variant", 0)})
	var key := "%s/%d/%d" % [kind, mask, variant]
	_put(canvas, _image(key, func() -> Pixels: return _rock_image(kind, mask, variant)), rect.position)


func _rock_image(kind: String, mask: int, variant: int) -> Pixels:
	var open_n := (mask & TileShape.N) == 0
	var open_e := (mask & TileShape.E) == 0
	var open_s := (mask & TileShape.S) == 0
	var open_w := (mask & TileShape.W) == 0
	var top_rows := TILE - FACE if open_s else TILE
	var ink := _c("outline")
	var pixels := Pixels.new(TILE, TILE)
	for y in TILE:
		for x in TILE:
			var color := _c(kind + "_top")
			if y >= top_rows:
				color = _c(kind + "_face")
				if y == top_rows or ((x + variant) % 4 == 0 and y < TILE - 2):
					color = _c(kind + "_face_dark")
			else:
				if (open_n and y == 1) or (open_w and x == 1):
					color = _c(kind + "_top_light")
				elif open_e and x == TILE - 2:
					color = _c(kind + "_top_dark")
				if open_s and y == top_rows - 1:
					color = _c(kind + "_lip")
			pixels.put(x, y, color)
	_rock_marks(pixels, kind, variant, top_rows)
	if open_n:
		pixels.rect(0, 0, TILE, 1, ink)
	if open_w:
		pixels.rect(0, 0, 1, TILE, ink)
	if open_e:
		pixels.rect(TILE - 1, 0, 1, TILE, ink)
	if open_s:
		pixels.rect(0, TILE - 1, TILE, 1, ink)
	# Inner corners, where the mass turns around open ground.
	if not open_n and not open_w and (mask & TileShape.NW) == 0:
		pixels.put(0, 0, ink)
	if not open_n and not open_e and (mask & TileShape.NE) == 0:
		pixels.put(TILE - 1, 0, ink)
	if not open_s and not open_w and (mask & TileShape.SW) == 0:
		pixels.rect(0, TILE - FACE, 1, FACE, ink)
	if not open_s and not open_e and (mask & TileShape.SE) == 0:
		pixels.rect(TILE - 1, TILE - FACE, 1, FACE, ink)
	# Round outer corners.
	if open_n and open_w:
		_round(pixels, Vector2i(0, 0), Vector2i(1, 1), ink)
	if open_n and open_e:
		_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), ink)
	if open_s and open_w:
		_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), ink)
	if open_s and open_e:
		_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), ink)
	return pixels


## Cuts the corner at `corner` and closes the outline one pixel further in,
## in the direction `inward`.
static func _round(pixels: Pixels, corner: Vector2i, inward: Vector2i, ink: Color) -> void:
	pixels.put(corner.x, corner.y, CLEAR)
	pixels.put(corner.x + inward.x, corner.y, CLEAR)
	pixels.put(corner.x, corner.y + inward.y, CLEAR)
	pixels.put(corner.x + inward.x, corner.y + inward.y, ink)


## Small details on the top of rock: pores and chips on soft rock, glinting
## crystals in hard rock, layers in bedrock.
func _rock_marks(pixels: Pixels, kind: String, variant: int, top_rows: int) -> void:
	var room := maxi(1, top_rows - 6)
	for pore in 2:
		var spot := Vector2i(3 + int(_noise(pore, 8, variant) * 10.0), 3 + int(_noise(pore, 9, variant) * room))
		pixels.put(spot.x, spot.y, _c(kind + "_top_dark"))
	var at := Vector2i(4 + int(_noise(7, 8, variant) * 7.0), 3 + int(_noise(7, 9, variant) * room))
	match kind:
		"soft":
			if variant % 2 == 0:
				pixels.put(at.x, at.y, _c("soft_top_dark"))
				pixels.put(at.x + 1, at.y, _c("soft_top_dark"))
				pixels.put(at.x + 1, at.y - 1, _c("soft_top_light"))
				pixels.put(at.x + 2, at.y + 1, _c("soft_top_dark"))
		"hard":
			if variant % 2 == 1:
				pixels.put(at.x, at.y, _c("hard_glint"))
				pixels.put(at.x + 1, at.y, _c("hard_top_light"))
				pixels.put(at.x, at.y + 1, _c("hard_top_light"))
		"bed":
			for row in range(4, top_rows - 1, 5):
				for column in range(3, TILE - 3):
					if _noise(column, row, variant) < 0.5:
						pixels.put(column, row, _c("bed_top_dark"))


func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var hits := clampi(state.get("hits", 1), 1, 3)
	var variant: int = state.get("variant", 0) % 4
	_put(canvas, _image("cracks/%d/%d" % [hits, variant], func() -> Pixels: return _crack_image(hits, variant)), rect.position)


func _crack_image(hits: int, variant: int) -> Pixels:
	var pixels := Pixels.new(TILE, TILE)
	var path: Array[Vector2i] = []
	var at := Vector2i(5 + variant, 3)
	for step in 2 + hits * 3:
		path.append(at)
		var turn := _noise(step, hits, variant)
		at += Vector2i(-1 if turn < 0.3 else (1 if turn > 0.65 else 0), 1)
	if hits >= 2:
		var branch: Vector2i = path[path.size() >> 1]
		for step in 3:
			branch += Vector2i(1, 1 if step % 2 == 0 else 0)
			path.append(branch)
	for point in path:
		pixels.put(point.x + 1, point.y, _c("soft_top_light"))
	for point in path:
		pixels.put(point.x, point.y, _c("outline"))
	return pixels


func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var frame := clampi(int(float(state.get("age", 0.0)) * HIT_FRAMES), 0, HIT_FRAMES - 1)
	var variant: int = state.get("variant", 0) % 4
	_put(canvas, _image("hit/%d/%d" % [frame, variant], func() -> Pixels: return _hit_image(frame, variant)), rect.position - Vector2(8, 8))


## Chips that fly up and fall, and a puff of dust, in a square of two tiles
## around the tile.
func _hit_image(frame: int, variant: int) -> Pixels:
	var pixels := Pixels.new(TILE * 2, TILE * 2)
	var t := (frame + 1.0) / HIT_FRAMES
	for chip in 5:
		var angle := -PI / 2.0 + (chip - 2) * 0.6 + (variant - 1.5) * 0.15
		var distance := 3.0 + t * 10.0
		var x := roundi(TILE + cos(angle) * distance)
		var y := roundi(TILE - 1 + sin(angle) * distance + t * t * 10.0)
		pixels.put(x, y, _c("soft_top_light"))
		if frame < 3 and chip % 2 == 0:
			pixels.put(x + 1, y, _c("soft_top"))
			pixels.put(x, y + 1, _c("soft_face"))
			pixels.put(x + 1, y + 1, _c("soft_face_dark"))
	if frame < 3:
		pixels.outline(_c("outline"), tinted_outlines)
		var radius := 3.0 + frame * 2.0
		for puff in 8:
			var angle := TAU * puff / 8.0 + variant
			var dust := _c("dust")
			dust.a = 0.75 - frame * 0.2
			pixels.put(roundi(TILE + cos(angle) * radius), roundi(TILE - 1 + sin(angle) * radius * 0.6), dust)
	return pixels


func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var reachable: bool = state.get("reachable", false)
	_put(canvas, _image("marker/%s" % reachable, func() -> Pixels: return _marker_image(reachable)), rect.position - Vector2(1, 1))


## Four corners around the tile, with a dark rim that shows on any ground.
func _marker_image(reachable: bool) -> Pixels:
	var pixels := Pixels.new(TILE + 2, TILE + 2)
	var color := _c("marker_near") if reachable else _c("marker_far")
	var last := TILE
	for corner: Vector2i in [Vector2i(1, 1), Vector2i(last, 1), Vector2i(1, last), Vector2i(last, last)]:
		var dx := 1 if corner.x == 1 else -1
		var dy := 1 if corner.y == 1 else -1
		for i in 4:
			pixels.put(corner.x + dx * i, corner.y, color)
			pixels.put(corner.x, corner.y + dy * i, color)
	pixels.outline(_c("outline"), false)
	return pixels


# Figures.


func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var frame := 0
	if state.get("moving", false):
		frame = 1 + int(fposmod(float(state.get("step", 0.0)), TAU) / TAU * 4.0) % 4
	var colour := maxi(0, state.get("player", -1)) % players.size()
	var left: bool = float(state.get("facing", 1.0)) < 0.0
	var foot := Vector2(rect.get_center().x, rect.end.y)
	_put(canvas, _image("shadow/12", func() -> Pixels: return _shadow_image(12)), foot - Vector2(6, 2))
	var key := "figure/%d/%d/%s" % [colour, frame, left]
	_put(canvas, _image(key, func() -> Pixels: return _figure_image(colour, frame, left)), foot - Vector2(FIGURE_SIZE.x / 2.0, FIGURE_SIZE.y))


## A soft shadow on the ground, `width` pixels wide.
func _shadow_image(width: int) -> Pixels:
	var pixels := Pixels.new(width, 3)
	pixels.ellipse(width / 2.0, 1.5, width / 2.0, 1.5, Color(0, 0, 0, 0.28))
	return pixels


## A figure in walking frame `frame` (0 standing, 1 to 4 walking), facing
## right or, with `left`, left. The shirt has the colour of the player.
func _figure_image(colour: int, frame: int, left: bool) -> Pixels:
	var pixels := Pixels.new(FIGURE_SIZE.x, FIGURE_SIZE.y)
	var shirt := players[colour]
	var bob := -1 if frame == 2 or frame == 4 else 0
	# The far leg and the near leg, by frame.
	var legs: Array[Vector2i] = [Vector2i(6, 9), Vector2i(5, 10), Vector2i(7, 8), Vector2i(10, 5), Vector2i(8, 7)]
	for leg in 2:
		var x := legs[frame].x if leg == 0 else legs[frame].y
		var lifted := 1 if frame > 0 and frame % 2 == 1 and leg == 0 else 0
		pixels.rect(x, 18, 2, 4 - lifted, _c("pants_dark") if leg == 0 else _c("pants"))
		pixels.rect(x, 22 - lifted, 3, 2, _c("boots"))
	pixels.rect(5, 11 + bob, 6, 8, shirt)
	pixels.rect(5, 11 + bob, 1, 8, shirt.darkened(0.2))
	pixels.rect(5, 18 + bob, 6, 1, shirt.darkened(0.35))
	var swing: int = [0, 1, 0, -1, 0][frame]
	pixels.rect(8 + swing, 12 + bob, 2, 5, shirt.darkened(0.15))
	pixels.rect(8 + swing, 17 + bob, 2, 1, _c("skin"))
	# The head: hair behind, the face in front, bangs above the eye.
	pixels.ellipse(7.8, 5.8 + bob, 5.2, 5.0, _c("hair"))
	pixels.ellipse(9.6, 7.4 + bob, 3.7, 3.9, _c("skin"))
	pixels.rect(9, 3 + bob, 4, 2, _c("hair"))
	pixels.put(6, 2 + bob, _c("hair_light"))
	pixels.put(7, 2 + bob, _c("hair_light"))
	pixels.put(8, 1 + bob, _c("hair_light"))
	pixels.put(11, 6 + bob, _c("eye"))
	pixels.put(11, 7 + bob, _c("eye"))
	pixels.put(8, 8 + bob, _c("skin_shade"))
	pixels.put(12, 9 + bob, _c("skin_shade"))
	pixels.outline(_ink(shirt), tinted_outlines)
	if left:
		pixels.image.flip_x()
	return pixels

Ersetze den gesamten Inhalt von game/scripts/art/warm_style.gd durch:

class_name WarmStyle
extends PixelStyle
## Warm, full colours and dark outlines around figures and things, close to
## the classic farming games.

const KEY := &"warm"


func _init() -> void:
	palette = {
		"outline": Color("3a2820"), "dust": Color("e9dcc6"),
		"ground": Color("b98a5e"), "ground_dark": Color("a07650"), "ground_light": Color("cba077"),
		"pebble": Color("8e8072"), "pebble_light": Color("b8ab9a"), "pebble_dark": Color("6b5f54"),
		"dead_crack": Color("8a6646"),
		"soft_top": Color("bcb0a0"), "soft_top_dark": Color("a69a8a"), "soft_top_light": Color("d6ccbd"),
		"soft_face": Color("8f8170"), "soft_face_dark": Color("776a5b"), "soft_lip": Color("e4dccf"),
		"hard_top": Color("7c88a2"), "hard_top_dark": Color("68738c"), "hard_top_light": Color("9aa6c0"),
		"hard_face": Color("596380"), "hard_face_dark": Color("454d66"), "hard_lip": Color("b6c2da"),
		"hard_glint": Color("dff4ff"),
		"bed_top": Color("51453e"), "bed_top_dark": Color("433832"), "bed_top_light": Color("62554c"),
		"bed_face": Color("382e29"), "bed_face_dark": Color("2b231f"), "bed_lip": Color("6e6157"),
		"grass": Color("64a83f"), "grass_dark": Color("4e8f33"), "grass_light": Color("8acb52"),
		"grass_edge": Color("3f7a2c"),
		"flower_a": Color("f7e25c"), "flower_b": Color("fbf6ea"), "flower_c": Color("ef7fa6"),
		"moss": Color("4e9a3a"), "moss_dark": Color("3b7b2e"), "moss_light": Color("7ec552"), "spore": Color("ffe57e"),
		"vine": Color("4f9439"), "vine_dark": Color("38702a"), "leaf": Color("68b545"), "leaf_dark": Color("4c8d35"),
		"stem": Color("5c9c3c"), "bud": Color("acd97a"), "fibre": Color("f8f3e6"), "fibre_shade": Color("dcd3c0"),
		"seed": Color("7b5433"),
		"stone": Color("a29a8f"), "stone_light": Color("c8c1b6"), "stone_dark": Color("7c756d"),
		"wool": Color("f5f0e6"), "wool_shade": Color("dbd2c2"), "wool_light": Color("ffffff"),
		"face": Color("4b3a31"), "hoof": Color("35281f"),
		"ruin": Color("aaa395"), "ruin_dark": Color("8a8376"), "ruin_light": Color("c6bfb2"),
		"ruin_moss": Color("5f9f43"), "door": Color("2b231f"), "glow": Color("ffd560"), "glow_light": Color("fff4c6"),
		"skin": Color("f4cba5"), "skin_shade": Color("dba67f"), "hair": Color("6e4128"), "hair_light": Color("8f5a37"),
		"eye": Color("2a1b15"), "pants": Color("4b5b8d"), "pants_dark": Color("3a4672"), "boots": Color("5b3a26"),
		"marker_near": Color("fff6c2"), "marker_far": Color("ff705c"),
	}
	players = [
		Color("d9573f"), Color("3f7fd1"), Color("e9b23c"), Color("4ea84a"),
		Color("9a62c9"), Color("39a9a0"), Color("e271a1"), Color("e8873b"),
	]


func key() -> StringName:
	return KEY


func title_key() -> String:
	return "STYLE_WARM"

Ersetze den gesamten Inhalt von game/project.godot durch die folgende Fassung. Neu sind die letzten beiden Zeilen im Abschnitt [rendering]: Texturfilterung „Nearest“ und Einrasten auf ganze Pixel.

; Engine configuration file.
; Input actions are registered in code (scripts/input_setup.gd), so this file
; holds no serialized input events.

config_version=5

[application]

config/name="Grovewright"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility")

[display]

window/size/viewport_width=1280
window/size/viewport_height=720

[rendering]

renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
textures/canvas_textures/default_texture_filter=0
2d/snap/snap_2d_transforms_to_pixel=true
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 138 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 5: Bildschirmfotos ansehen

Run:

godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-karte.png" --style=warm
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-vitrine.png" --style=warm --showcase

Expected: Beide Aufrufe enden mit screenshot saved: … ohne Warnung. Sieh dir beide Bilder an. Alle Pixel sind scharfe Quadrate, nichts ist verschwommen.

  • Spiel: Warmer, brauner Erdboden mit wenigen dunkleren Flecken, hellen Körnern und kleinen Kieseln. Weicher Fels ist ein hellgraues Plateau mit dunklem Umriss, außen abgerundet und innen geschlossen. Zum offenen Boden hin zeigt er unten eine dunklere Vorderkante mit senkrechten Streifen und wirft einen Schatten auf den Boden darunter. Im getroffenen Fels steht ein dunkler Riss mit Splittern. Die beiden Figuren stehen auf einem kleinen Schatten: braune Haare, helles Gesicht mit Auge, rotes Hemd, blaue Hose und Stiefel. Über der linken steht scharf „Spieler“.

  • Vitrine: Oben steht „Stilprobe: Warm“. Oben links liegen Grundgestein (fast schwarzbraun mit Schichten), weicher Fels (hell) und harter Fels (blaugrau mit hellen Glanzpunkten) als Pixelgrafik mit Vorderkanten. Die beiden Figuren in Rot und Blau sind Pixelfiguren. Alles andere zeigt noch die Platzhalter von ArtStyle: graubraunen und grünen Boden, Kreise für Moos, Tier und Stein, einen Strich als Pflanze, eine Linie als Ranke, ein graues Feld mit gelbem Kreis als Ruine. An den runden Ecken des Felses blitzt der braune Boden der Karte durch; das gleicht Task 6 aus, weil toter Boden dann dieselbe Erde ist.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Pixelgrafik für Boden, Fels und Figuren

PixelStyle zeichnet jede Grafik Pixel für Pixel in ein kleines Bild,
16 mal 16 Pixel pro Kachel, und hebt es auf. Fels ist außen rund, innen
geschlossen und zeigt zum offenen Boden hin seine Vorderkante; Figuren
stehen aufrecht und haben vier Laufbilder. Warm bekommt als erster Stil
seine Palette und acht Spielerfarben. Die Texturfilterung steht auf
Nearest, damit die Pixel scharf bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 6: Pixelgrafik für die Dinge der Vitrine

Der letzte Abschnitt von PixelStyle zeichnet, was die Vitrine aus späteren Meilensteinen zeigt: toten Boden mit Rissen, lebendigen Boden mit Gras, Blumen und unregelmäßigem Rand, Moos mit funkelnden Sporen, die Ranke, die wachsende Faserpflanze, den Stein, das Weidetier und die Ruine mit ihrem Licht.

Files:

  • Modify: game/scripts/art/pixel_style.gd (zwei Stellen einfügen, einen Abschnitt anhängen)
  • Test: game/tests/test_pixel_things.gd

Interfaces:

  • Consumes: alles aus Task 5, dazu ArtStyle.sway und ArtStyle.along

  • Produces:

    • PixelStyle.GROWTH_STAGES := 6 und das Feld glow: float, das eine Unterklasse setzt
    • die Zeichnungen für DEAD_SOIL, LIVING_SOIL, MOSS, VINE, FIBER_PLANT, STONE, GRAZER und RUIN
    • die Bilder _crack_soil(pixels: Pixels, variant: int) -> Pixels, _grass_image(living: int, variant: int) -> Pixels, _moss_image(variant: int, twinkle: int) -> Pixels, _vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels, _plant_image(stage: int, lean: int) -> Pixels, _stone_image() -> Pixels, _grazer_image(tucked: bool, left: bool) -> Pixels, _ruin_image() -> Pixels und _ruin_glow() -> Pixels
  • Step 1: Fehlschlagenden Test schreiben

Create game/tests/test_pixel_things.gd:

extends TestCase
## The pixel art of the things of later milestones in the showcase.


## The highest row of `image` with a pixel that is not transparent.
static func _top(image: Image) -> int:
	for y in image.get_height():
		for x in image.get_width():
			if image.get_pixel(x, y).a > 0.0:
				return y
	return image.get_height()


func test_a_plant_grows_taller_from_stage_to_stage() -> void:
	var style := WarmStyle.new()
	var tops: Array[int] = []
	for stage in PixelStyle.GROWTH_STAGES:
		tops.append(_top(style._plant_image(stage, 0).image))
	for stage in range(1, tops.size()):
		check(tops[stage] < tops[stage - 1], "stage %d is taller than stage %d: %s" % [stage, stage - 1, tops])


func test_grass_has_a_ragged_edge_only_toward_bare_ground() -> void:
	var style := WarmStyle.new()
	var inside := style._grass_image(255, 0).image
	var lone := style._grass_image(0, 0).image
	var tile := PixelStyle.TILE
	var inside_gaps := 0
	var lone_gaps := 0
	for y in tile:
		for x in tile:
			inside_gaps += 1 if inside.get_pixel(x, y).a == 0.0 else 0
			lone_gaps += 1 if lone.get_pixel(x, y).a == 0.0 else 0
	check_eq(inside_gaps, 0, "living all around: no gaps")
	check(lone_gaps > 4, "bare ground all around: a ragged, round edge (%d gaps)" % lone_gaps)
	check_eq(lone.get_pixel(0, 0).a, 0.0, "round at the corner")


func test_dead_soil_is_the_bare_earth_with_cracks() -> void:
	var style := WarmStyle.new()
	var earth := style._earth(0, 0).image
	var cracked := style._crack_soil(style._earth(0, 0), 0).image
	check(cracked.get_data() != earth.get_data(), "cracks in every other tile")
	check_eq(style._crack_soil(style._earth(1, 0), 1).image.get_data(), style._earth(1, 0).image.get_data(), "none in the others")


func test_an_animal_in_the_air_tucks_its_legs() -> void:
	var style := WarmStyle.new()
	var standing := style._grazer_image(false, false).image
	var tucked := style._grazer_image(true, false).image
	check(standing.get_pixel(5, 14).a > 0.0, "standing on its legs")
	check_eq(tucked.get_pixel(5, 14).a, 0.0, "legs tucked in the air")


func test_the_ruin_glows_in_its_doorway() -> void:
	var style := WarmStyle.new()
	var light := style._ruin_glow().image
	check(light.get_pixel(24, 31).a > 0.5, "bright in the middle of the doorway")
	check_eq(light.get_pixel(3, 30).a, 0.0, "dark on the walls")
  • Step 2: Test ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 138 passed, 1 failed. test_pixel_things.gd lädt nicht; davor steht SCRIPT ERROR: Parse Error: Cannot find member "GROWTH_STAGES" in base "PixelStyle".

  • Step 3: Die Dinge der Vitrine zeichnen

Füge in game/scripts/art/pixel_style.gd direkt vor der Zeile ## Size of a figure: a tile wide and one and a half tiles tall. diese Zeilen ein:

## Stages of a growing plant, from the seed to the fibres.
const GROWTH_STAGES := 6

Füge in game/scripts/art/pixel_style.gd direkt vor der Zeile var _textures: Dictionary[String, Texture2D] = {} diese Zeilen ein:

## How strongly living things glow, from 0 (not at all) to 1; the subclass
## chooses it.
var glow := 0.0

Füge am Ende von game/scripts/art/pixel_style.gd diesen Abschnitt an. Wie zwischen allen Funktionen stehen davor zwei Leerzeilen:

# Things of later milestones, shown in the showcase.


func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
	var variant: int = state.get("variant", 0) % 8
	var key := "dead/%d/%d" % [shadow, variant]
	_put(canvas, _image(key, func() -> Pixels: return _crack_soil(_earth(variant, shadow), variant)), rect.position)


## Dry cracks in every other tile of dead soil. Returns `pixels`.
func _crack_soil(pixels: Pixels, variant: int) -> Pixels:
	if variant % 2 == 0:
		var at := _spot(12, variant, 3, 7)
		for step in 5:
			pixels.put(at.x, at.y, _c("dead_crack"))
			if step == 2:
				pixels.put(at.x, at.y + 1, _c("dead_crack"))
				pixels.put(at.x - 1, at.y + 2, _c("dead_crack"))
			at += Vector2i(1, -1 if _noise(step, 5, variant) < 0.4 else 0)
	return pixels


func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var living := _shape(state.get("living", 0))
	var variant: int = state.get("variant", 0) % 6
	_ground(canvas, rect, {"variant": state.get("variant", 0)})
	_put(canvas, _image("living/%d/%d" % [living, variant], func() -> Pixels: return _grass_image(living, variant)), rect.position)


## Grass with blades and a few flowers, with a soft edge toward the sides
## that are not living.
func _grass_image(living: int, variant: int) -> Pixels:
	var open_n := (living & TileShape.N) == 0
	var open_e := (living & TileShape.E) == 0
	var open_s := (living & TileShape.S) == 0
	var open_w := (living & TileShape.W) == 0
	var pixels := Pixels.new(TILE, TILE)
	pixels.rect(0, 0, TILE, TILE, _c("grass"))
	for tuft in 6:
		var at := _spot(tuft + 10, variant, 2, 12)
		pixels.put(at.x, at.y, _c("grass_dark"))
		pixels.put(at.x - 1, at.y - 1, _c("grass_dark"))
		pixels.put(at.x + 1, at.y - 1, _c("grass_dark"))
	for blade in 4:
		var at := _spot(blade + 20, variant, 1, 14)
		pixels.put(at.x, at.y, _c("grass_light"))
	if variant % 3 == 0:
		var x := 4 + variant % 7
		var y := 5 + variant % 5
		var petal := _c("flower_b") if variant % 2 == 0 else _c("flower_c")
		for side in Pixels.SIDES:
			pixels.put(x + side.x, y + side.y, petal)
		pixels.put(x, y, _c("flower_a"))
	if glow > 0.0:
		for spark in 2:
			var light := _c("spore")
			light.a = glow * 0.8
			pixels.put(2 + int(_noise(spark, 21, variant) * 12.0), 2 + int(_noise(spark, 22, variant) * 12.0), light)
	# A ragged edge toward bare ground.
	for i in TILE:
		var ragged := 1 if _noise(i, 23, variant) < 0.45 else 0
		if open_n:
			_edge(pixels, Vector2i(i, 0), Vector2i(0, 1), ragged)
		if open_s:
			_edge(pixels, Vector2i(i, TILE - 1), Vector2i(0, -1), ragged)
		if open_w:
			_edge(pixels, Vector2i(0, i), Vector2i(1, 0), ragged)
		if open_e:
			_edge(pixels, Vector2i(TILE - 1, i), Vector2i(-1, 0), ragged)
	if open_n and open_w:
		_round(pixels, Vector2i(0, 0), Vector2i(1, 1), _c("grass_edge"))
	if open_n and open_e:
		_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), _c("grass_edge"))
	if open_s and open_w:
		_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), _c("grass_edge"))
	if open_s and open_e:
		_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), _c("grass_edge"))
	return pixels


## Clears `cut` pixels at `at` and draws the darker edge of the grass behind
## them, one step further in the direction `inward`.
func _edge(pixels: Pixels, at: Vector2i, inward: Vector2i, cut: int) -> void:
	for depth in cut:
		var gone := at + inward * depth
		pixels.put(gone.x, gone.y, CLEAR)
	var edge := at + inward * cut
	pixels.put(edge.x, edge.y, _c("grass_edge"))


func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var variant: int = state.get("variant", 0) % 3
	var twinkle := int(float(state.get("time", 0.0)) * 2.5) % 4
	var foot := Vector2(rect.get_center().x, rect.end.y)
	_put(canvas, _image("moss/%d/%d" % [variant, twinkle], func() -> Pixels: return _moss_image(variant, twinkle)), foot - Vector2(8, 13))


## A cushion of moss whose spores twinkle one after another.
func _moss_image(variant: int, twinkle: int) -> Pixels:
	var pixels := Pixels.new(TILE, 13)
	var shift := variant - 1
	for bump: Vector4 in [Vector4(4.5 + shift, 9.5, 4, 3.2), Vector4(11, 9.2, 4.3, 3.5), Vector4(7.8 - shift, 6.8, 3.8, 3.4)]:
		pixels.ellipse(bump.x, bump.y, bump.z, bump.w, _c("moss"))
	for y in pixels.size.y:
		for x in pixels.size.x:
			if not pixels.filled(x, y):
				continue
			if y >= 11:
				pixels.put(x, y, _c("moss_dark"))
			elif not pixels.filled(x, y - 1) or not pixels.filled(x, y - 2):
				pixels.put(x, y, _c("moss_light"))
	pixels.outline(_ink(_c("moss")), tinted_outlines)
	var spores: Array[Vector2i] = [Vector2i(4 + shift, 8), Vector2i(8 - shift, 5), Vector2i(12, 8)]
	for index in spores.size():
		var at := spores[index]
		var bright := index == twinkle
		var spore := _c("spore") if bright else _c("spore").darkened(0.2)
		pixels.put(at.x, at.y, spore)
		pixels.put(at.x + 1, at.y, spore)
		if bright:
			pixels.put(at.x, at.y - 1, _c("spore"))
			if glow > 0.0:
				var halo := _c("spore")
				halo.a = 0.45 * glow
				for side in Pixels.SIDES:
					pixels.shade(at.x + side.x, at.y + side.y + 1, halo)
	return pixels


func _vine(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var points: PackedVector2Array = state.get("points", PackedVector2Array())
	if points.size() < 2:
		return
	var origin := (rect.position - Vector2(6, 6)).floor()
	var size := Vector2i(ceili(rect.size.x) + 13, ceili(rect.size.y) + 13)
	var key := "vine/%d/%d/%d" % [hash(points), size.x, size.y]
	_put(canvas, _image(key, func() -> Pixels: return _vine_image(points, origin, size)), origin)


## A vine two pixels thick along `points`, with leaves on alternate sides.
func _vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels:
	var pixels := Pixels.new(size.x, size.y)
	for i in points.size() - 1:
		var from := Vector2i((points[i] - origin).round())
		var to := Vector2i((points[i + 1] - origin).round())
		pixels.line(from.x, from.y - 1, to.x, to.y - 1, _c("leaf"))
		pixels.line(from.x, from.y, to.x, to.y, _c("vine"))
		pixels.line(from.x, from.y + 1, to.x, to.y + 1, _c("vine_dark"))
	var length := 0.0
	for i in points.size() - 1:
		length += points[i].distance_to(points[i + 1])
	var leaves := int(length / 9.0)
	for leaf in leaves:
		var at := Vector2i((along(points, (leaf + 0.5) / leaves) - origin).round())
		var up := leaf % 2 == 0
		var dy := -2 if up else 2
		var tip := -1 if up else 1
		pixels.put(at.x, at.y + dy, _c("leaf"))
		pixels.put(at.x + 1, at.y + dy, _c("leaf"))
		pixels.put(at.x + 1, at.y + dy + tip, _c("leaf"))
		pixels.put(at.x + 2, at.y + dy + tip, _c("leaf_dark"))
	pixels.outline(_ink(_c("vine")), tinted_outlines)
	return pixels


func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var stage := clampi(int(float(state.get("growth", 1.0)) * GROWTH_STAGES), 0, GROWTH_STAGES - 1)
	var lean := clampi(roundi(sway(state.get("time", 0.0), 3) * 1.4), -1, 1)
	var foot := Vector2(rect.get_center().x, rect.end.y)
	_put(canvas, _image("plant/%d/%d" % [stage, lean], func() -> Pixels: return _plant_image(stage, lean)), foot - Vector2(8, 28))


## A fibre plant at growth stage `stage`, its top leaning by `lean` pixels:
## a seed in a small mound, a sprout, a stalk with leaves, buds and at last
## white tufts of fibre.
func _plant_image(stage: int, lean: int) -> Pixels:
	var pixels := Pixels.new(TILE, 28)
	pixels.rect(5, 26, 6, 1, _c("ground_dark"))
	pixels.rect(4, 27, 8, 1, _c("ground_dark"))
	if stage == 0:
		pixels.put(7, 25, _c("seed"))
		pixels.put(8, 25, _c("seed"))
		pixels.outline(_ink(_c("seed")), tinted_outlines)
		return pixels
	var heights: Array[int] = [0, 4, 8, 13, 17, 19]
	var height := heights[stage]
	var top := Vector2i(8, 25)
	for i in height:
		var x := 8 + roundi(lean * 2.0 * i / 19.0)
		top = Vector2i(x, 25 - i)
		pixels.put(top.x, top.y, _c("stem"))
	@warning_ignore("integer_division")
	for pair in range(1, height / 3 + 1):
		var y := 26 - pair * 3
		var x := 8 + roundi(lean * 2.0 * (25 - y) / 19.0)
		var side := 1 if pair % 2 == 0 else -1
		pixels.put(x + side, y, _c("leaf"))
		pixels.put(x + side * 2, y, _c("leaf"))
		pixels.put(x + side * 2, y - 1, _c("leaf"))
		pixels.put(x + side * 3, y - 1, _c("leaf_dark"))
		pixels.put(x - side, y - 1, _c("leaf_dark"))
	if stage == 1:
		pixels.put(top.x - 1, top.y, _c("leaf"))
		pixels.put(top.x + 1, top.y, _c("leaf"))
	if stage == 4:
		pixels.ellipse(top.x + 0.5, top.y - 0.5, 1.6, 1.6, _c("bud"))
	if stage == 5:
		for tuft: Vector3 in [Vector3(0, -2, 2.3), Vector3(-3, 1, 1.8), Vector3(3, 2, 1.8)]:
			pixels.ellipse(top.x + 0.5 + tuft.x, top.y + tuft.y, tuft.z, tuft.z, _c("fibre"))
			pixels.put(top.x + int(tuft.x), top.y + int(tuft.y) + 1, _c("fibre_shade"))
	pixels.outline(_ink(_c("stem")), tinted_outlines)
	return pixels


func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
	_put(canvas, _image("stone", func() -> Pixels: return _stone_image()), Vector2(rect.get_center().x - 4, rect.end.y - 8))


func _stone_image() -> Pixels:
	var pixels := Pixels.new(8, 8)
	pixels.ellipse(4, 4.2, 3.2, 2.7, _c("stone"))
	pixels.rect(2, 6, 4, 1, _c("stone_dark"))
	pixels.put(2, 3, _c("stone_light"))
	pixels.put(3, 2, _c("stone_light"))
	pixels.outline(_ink(_c("stone")), tinted_outlines)
	return pixels


func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var height := roundi(sin(PI * clampf(state.get("hop", 0.0), 0.0, 1.0)) * 5.0)
	var left: bool = float(state.get("facing", 1.0)) < 0.0
	var foot := Vector2(rect.get_center().x, rect.end.y)
	var width := 14 - height
	_put(canvas, _image("shadow/%d" % width, func() -> Pixels: return _shadow_image(width)), foot - Vector2(width / 2.0, 2))
	var tucked := height > 1
	_put(canvas, _image("grazer/%s/%s" % [tucked, left], func() -> Pixels: return _grazer_image(tucked, left)), foot - Vector2(10, 16 + height))


## A woolly animal, facing right or, with `left`, left; in the air its legs
## are `tucked`.
func _grazer_image(tucked: bool, left: bool) -> Pixels:
	var pixels := Pixels.new(20, 16)
	for leg in [5, 7, 11, 13]:
		pixels.rect(leg, 12, 1, 1 if tucked else 3, _c("hoof"))
	for puff: Vector4 in [Vector4(9, 8, 6.5, 4.5), Vector4(5.5, 9.5, 4, 3.5), Vector4(12.5, 9.5, 4.5, 3.5), Vector4(8, 5.8, 4, 3), Vector4(11.5, 6.3, 3.5, 3)]:
		pixels.ellipse(puff.x, puff.y, puff.z, puff.w, _c("wool"))
	for y in pixels.size.y:
		for x in pixels.size.x:
			if pixels.at(x, y) == _c("wool"):
				if y >= 11:
					pixels.put(x, y, _c("wool_shade"))
				elif not pixels.filled(x, y - 1) and _noise(x, y, 4) < 0.6:
					pixels.put(x, y, _c("wool_light"))
	pixels.ellipse(16.2, 8.0, 2.6, 3.2, _c("face"))
	pixels.put(14, 5, _c("face"))
	pixels.put(15, 5, _c("face"))
	pixels.put(17, 7, _c("wool_light"))
	pixels.outline(_ink(_c("wool")), tinted_outlines)
	if left:
		pixels.image.flip_x()
	return pixels


func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
	var awake := clampf(state.get("awake", 0.0), 0.0, 1.0)
	_put(canvas, _image("ruin", func() -> Pixels: return _ruin_image()), rect.position)
	_put(canvas, _image("ruin_glow", func() -> Pixels: return _ruin_glow()), rect.position, Color(1, 1, 1, awake))


## A gate of old stone blocks, three tiles wide, with moss on top and a dark
## doorway.
func _ruin_image() -> Pixels:
	var pixels := Pixels.new(TILE * 3, TILE * 3)
	var stone := _c("ruin")
	pixels.rect(3, 12, 12, 34, stone)
	pixels.rect(33, 12, 12, 34, stone)
	pixels.rect(1, 6, 46, 9, stone)
	pixels.rect(1, 3, 46, 3, _c("ruin_light"))
	pixels.rect(15, 15, 18, 31, _c("door"))
	pixels.rect(9, 44, 30, 2, _c("ruin_dark"))
	pixels.rect(7, 46, 34, 2, stone)
	# Blocks: a joint every five rows, and between the blocks of each row.
	for course in 8:
		var y := 8 + course * 5
		for x in range(1, 47):
			if pixels.at(x, y) == stone:
				pixels.put(x, y, _c("ruin_dark"))
		var offset := 0 if course % 2 == 0 else 4
		for x in range(3 + offset, 47, 8):
			for row in range(y + 1, y + 5):
				if pixels.at(x, row) == stone:
					pixels.put(x, row, _c("ruin_dark"))
	for x in range(1, 47):
		if _noise(x, 40, 1) < 0.45:
			pixels.put(x, 3, _c("ruin_moss"))
		if _noise(x, 41, 1) < 0.25:
			pixels.put(x, 4, _c("ruin_moss"))
	for strand: Vector2i in [Vector2i(5, 4), Vector2i(12, 3), Vector2i(36, 5), Vector2i(43, 2)]:
		pixels.rect(strand.x, 15, 1, strand.y, _c("ruin_moss"))
	pixels.outline(_ink(stone), tinted_outlines)
	return pixels


## The light in the doorway and the runes above it, shown as the ruin wakes.
func _ruin_glow() -> Pixels:
	var pixels := Pixels.new(TILE * 3, TILE * 3)
	var center := Vector2(24, 31)
	for y in range(15, 46):
		for x in range(15, 33):
			var distance := Vector2(x + 0.5, y + 0.5).distance_to(center)
			var light := _c("glow_light") if distance < 4.0 else _c("glow")
			light.a = clampf(1.0 - distance / 13.0, 0.0, 1.0) * (0.8 + 0.2 * glow)
			if light.a > 0.05:
				pixels.put(x, y, light)
	for rune in 3:
		var x := 16 + rune * 7
		for dot: Vector2i in [Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(0, 2), Vector2i(1, 2)]:
			pixels.put(x + dot.x + (rune % 2), 8 + dot.y, _c("glow"))
	return pixels
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 143 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 5: Bildschirmfoto der Vitrine ansehen

Run: godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-vitrine.png" --style=warm --showcase

Expected: Der Aufruf endet mit screenshot saved: … ohne Warnung. Sieh dir das Bild an. Jetzt ist die ganze Vitrine Pixelgrafik, nirgends steht mehr ein Platzhalter. Toter Boden ist dieselbe braune Erde wie im Spiel, in jeder zweiten Kachel mit einem kleinen Riss. Lebendiger Boden ist sattgrünes Gras mit dunklen Grasbüscheln, hellen Halmen und einzelnen Blumen in Gelb, Weiß und Rosa; sein Rand ist unregelmäßig und dunkler. Dazu kommen Moospolster mit gelben Sporen, die Ranke als grünes Band mit Blättern und drei grauen Steinen, die Faserpflanze mit weißen Faserbüscheln, ein Schaf mit dunklem Gesicht in der Luft und die Ruine als Steintor aus Blöcken mit Moos, Runen und goldenem Licht im Tor.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Pixelgrafik für die Dinge der Vitrine

Toter Boden hat Risse, lebendiger Boden Gras, Blumen und einen
unregelmäßigen Rand. Moos funkelt, die Ranke trägt Blätter, die
Faserpflanze wächst in sechs Stufen bis zu den Fasern und wiegt sich,
das Weidetier zieht im Sprung die Beine an, und die Ruine leuchtet,
wenn sie erwacht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 7: Abwandlungen Pastell und Glimmen

Pastell: weiche, helle Farben und Umrisse in einem dunkleren Ton der Fläche. Glimmen: eine gedämpfte, kühle tote Welt in Grau und Schiefer, in der alles Lebendige kräftig ist und leuchtet. Beide setzen dieselben Farbnamen wie Warm.

Files:

  • Modify: game/scripts/art/pastel_style.gd, game/scripts/art/glow_style.gd (jeweils ganze Datei ersetzen)
  • Test: game/tests/test_pastel_style.gd, game/tests/test_glow_style.gd

Interfaces:

  • Consumes: PixelStyle aus den Tasks 5 und 6 mit palette, players, tinted_outlines und glow; die Farbnamen von WarmStyle

  • Produces: PastelStyle (tinted_outlines = true) und GlowStyle (glow = 1.0) als fertige Abwandlungen von PixelStyle, je mit acht Spielerfarben

  • Step 1: Fehlschlagende Tests schreiben

Create game/tests/test_pastel_style.gd:

extends TestCase


func test_four_players_have_four_colours() -> void:
	var style := PastelStyle.new()
	var colours := {}
	for player in 4:
		colours[style.player_color(player)] = true
	check_eq(colours.size(), 4, "four players, four colours")
	check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")


func test_it_has_every_colour_the_warm_style_has() -> void:
	var names := PastelStyle.new().palette.keys()
	var warm := WarmStyle.new().palette.keys()
	names.sort()
	warm.sort()
	check_eq(names, warm, "the same colours by name")

Create game/tests/test_glow_style.gd:

extends TestCase


func test_four_players_have_four_colours() -> void:
	var style := GlowStyle.new()
	var colours := {}
	for player in 4:
		colours[style.player_color(player)] = true
	check_eq(colours.size(), 4, "four players, four colours")
	check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")


func test_it_has_every_colour_the_warm_style_has() -> void:
	var names := GlowStyle.new().palette.keys()
	var warm := WarmStyle.new().palette.keys()
	names.sort()
	warm.sort()
	check_eq(names, warm, "the same colours by name")
  • Step 2: Tests ausführen und Fehlschlag sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Der Testlauf endet mit Exit-Code 1 und 143 passed, 2 failed. test_pastel_style.gd und test_glow_style.gd laden nicht; davor steht je SCRIPT ERROR: Parse Error: Cannot infer the type of "names" variable because the value doesn't have a set type., denn die vorläufigen Stile haben noch keine Palette.

  • Step 3: Die beiden Abwandlungen schreiben

Ersetze den gesamten Inhalt von game/scripts/art/pastel_style.gd durch:

class_name PastelStyle
extends PixelStyle
## Soft, light colours, and outlines in a darker shade of what they
## surround instead of one dark line.

const KEY := &"pastel"


func _init() -> void:
	tinted_outlines = true
	palette = {
		"outline": Color("7a6670"), "dust": Color("fff6ea"),
		"ground": Color("e2c7a6"), "ground_dark": Color("d2b591"), "ground_light": Color("eedac0"),
		"pebble": Color("c2b6aa"), "pebble_light": Color("ddd4ca"), "pebble_dark": Color("a3978c"),
		"dead_crack": Color("c3a585"),
		"soft_top": Color("dcd5cf"), "soft_top_dark": Color("cbc3bc"), "soft_top_light": Color("eeeae6"),
		"soft_face": Color("bab1ab"), "soft_face_dark": Color("a89f99"), "soft_lip": Color("f6f3f1"),
		"hard_top": Color("abb6da"), "hard_top_dark": Color("99a4c9"), "hard_top_light": Color("c5ceea"),
		"hard_face": Color("8e98bf"), "hard_face_dark": Color("7c86ad"), "hard_lip": Color("dde4f6"),
		"hard_glint": Color("ffffff"),
		"bed_top": Color("8d8292"), "bed_top_dark": Color("7e7383"), "bed_top_light": Color("9e93a3"),
		"bed_face": Color("6f6574"), "bed_face_dark": Color("615866"), "bed_lip": Color("b0a6b5"),
		"grass": Color("9dd28b"), "grass_dark": Color("86c177"), "grass_light": Color("bae4a7"),
		"grass_edge": Color("74ae67"),
		"flower_a": Color("fff2a8"), "flower_b": Color("ffffff"), "flower_c": Color("ffb6cf"),
		"moss": Color("88c57b"), "moss_dark": Color("72ae67"), "moss_light": Color("abde9a"), "spore": Color("fff0a8"),
		"vine": Color("80c077"), "vine_dark": Color("67a460"), "leaf": Color("94d086"), "leaf_dark": Color("76b16a"),
		"stem": Color("88c37b"), "bud": Color("cae9b2"), "fibre": Color("fffdf6"), "fibre_shade": Color("ede7db"),
		"seed": Color("b38d6c"),
		"stone": Color("c6bfb8"), "stone_light": Color("ded9d4"), "stone_dark": Color("aaa39c"),
		"wool": Color("fffaf2"), "wool_shade": Color("ede5d9"), "wool_light": Color("ffffff"),
		"face": Color("8c7c76"), "hoof": Color("71635d"),
		"ruin": Color("cfc8d2"), "ruin_dark": Color("b7b0bd"), "ruin_light": Color("e2dde5"),
		"ruin_moss": Color("94c888"), "door": Color("62596d"), "glow": Color("ffe7a2"), "glow_light": Color("fffbe9"),
		"skin": Color("fcddc3"), "skin_shade": Color("efc3a4"), "hair": Color("b27c5c"), "hair_light": Color("cb987c"),
		"eye": Color("5c4852"), "pants": Color("91a2d2"), "pants_dark": Color("7c8dbf"), "boots": Color("a28068"),
		"marker_near": Color("ffffff"), "marker_far": Color("ff9c8c"),
	}
	players = [
		Color("f08c7c"), Color("7cb0ea"), Color("f2ce6c"), Color("88cb7c"),
		Color("ba98e2"), Color("76d1c8"), Color("f2a2c6"), Color("f4af7a"),
	]


func key() -> StringName:
	return KEY


func title_key() -> String:
	return "STYLE_PASTEL"

Ersetze den gesamten Inhalt von game/scripts/art/glow_style.gd durch:

class_name GlowStyle
extends PixelStyle
## A dim, cool world in grey and slate, in which everything living shines in
## strong colours and glows: the dead and the living apart at a glance.

const KEY := &"glow"


func _init() -> void:
	glow = 1.0
	palette = {
		"outline": Color("15171f"), "dust": Color("b9b5af"),
		"ground": Color("6f6a68"), "ground_dark": Color("5f5a59"), "ground_light": Color("7f7a77"),
		"pebble": Color("57534f"), "pebble_light": Color("8a8580"), "pebble_dark": Color("45413e"),
		"dead_crack": Color("4c4846"),
		"soft_top": Color("8d8a87"), "soft_top_dark": Color("7c7976"), "soft_top_light": Color("a3a09c"),
		"soft_face": Color("636160"), "soft_face_dark": Color("514f4f"), "soft_lip": Color("b5b2ae"),
		"hard_top": Color("4f5a75"), "hard_top_dark": Color("434d66"), "hard_top_light": Color("62708f"),
		"hard_face": Color("39425a"), "hard_face_dark": Color("2e354a"), "hard_lip": Color("7a89aa"),
		"hard_glint": Color("8ff0ff"),
		"bed_top": Color("2e2b30"), "bed_top_dark": Color("262328"), "bed_top_light": Color("3a363c"),
		"bed_face": Color("1e1b20"), "bed_face_dark": Color("171519"), "bed_lip": Color("48434a"),
		"grass": Color("3fbf5a"), "grass_dark": Color("2e9c48"), "grass_light": Color("6ee887"),
		"grass_edge": Color("1f7a3a"),
		"flower_a": Color("fff27a"), "flower_b": Color("e8fff0"), "flower_c": Color("ff8ad0"),
		"moss": Color("35c46a"), "moss_dark": Color("25a052"), "moss_light": Color("7cf5a5"), "spore": Color("fff59a"),
		"vine": Color("3dbb63"), "vine_dark": Color("2a8f4a"), "leaf": Color("52d47a"), "leaf_dark": Color("34a85c"),
		"stem": Color("45c068"), "bud": Color("a0f5b8"), "fibre": Color("f0fff6"), "fibre_shade": Color("c8e8d6"),
		"seed": Color("8a6a4a"),
		"stone": Color("8e8b88"), "stone_light": Color("aeaaa6"), "stone_dark": Color("6c6967"),
		"wool": Color("eef2f0"), "wool_shade": Color("cfd6d3"), "wool_light": Color("ffffff"),
		"face": Color("3a3438"), "hoof": Color("2a2629"),
		"ruin": Color("7d7a80"), "ruin_dark": Color("68656b"), "ruin_light": Color("94919a"),
		"ruin_moss": Color("3fbf6a"), "door": Color("121014"), "glow": Color("ffe066"), "glow_light": Color("fffbd0"),
		"skin": Color("f0c4a0"), "skin_shade": Color("d39e7a"), "hair": Color("3d2a24"), "hair_light": Color("5a3e34"),
		"eye": Color("1a1416"), "pants": Color("3b4466"), "pants_dark": Color("2e3552"), "boots": Color("3a2a22"),
		"marker_near": Color("c8fff0"), "marker_far": Color("ff6a5a"),
	}
	players = [
		Color("ff5f4f"), Color("4f9fff"), Color("ffc847"), Color("4fd06a"),
		Color("b77aff"), Color("3fd8c8"), Color("ff7ac0"), Color("ff9a4a"),
	]


func key() -> StringName:
	return KEY


func title_key() -> String:
	return "STYLE_GLOW"
  • Step 4: Tests ausführen und Erfolg sehen

Run:

godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: Exit-Code 0 und 147 passed, 0 failed. Die einzigen Warnungen sind die drei bekannten Zeilen.

  • Step 5: Bildschirmfotos ansehen

Run:

for key in pastel glow; do
  godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-$key-karte.png" --style=$key
  godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-$key-vitrine.png" --style=$key --showcase
done

Expected: Alle vier Aufrufe enden mit screenshot saved: … ohne Warnung. Sieh dir die Bilder an. Nirgends steht ein Platzhalter.

  • Pastell: dieselben Formen wie Warm, aber in weichen, hellen Farben: heller Sandboden, hellgrauer Fels, lavendelblauer harter Fels, zartgrünes Gras. Die Umrisse sind kein Schwarzbraun, sondern ein dunklerer Ton der Fläche. Die Vitrine hat oben „Stilprobe: Pastell“, unten ist „2 Pastell“ ausgegraut.

  • Glimmen: eine graue, gedämpfte tote Welt mit grauem Boden, grauem Fels, dunklem schieferblauem harten Fels, fast schwarzem Grundgestein und dunklen Umrissen. Das Lebendige ist kräftig: leuchtend grünes Gras mit hellen Funken, Moos mit leuchtenden Sporen, die Ruine mit hellem Licht. Die Vitrine hat oben „Stilprobe: Glimmen“, unten ist „3 Glimmen“ ausgegraut.

  • Step 6: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests

  • Step 7: Commit und Hochladen
git add game
git commit -F - <<'EOF'
Abwandlungen Pastell und Glimmen

Pastell zeichnet mit weichen, hellen Farben und Umrissen im dunkleren
Ton der Fläche. Glimmen zeigt eine gedämpfte, kühle tote Welt, in der
alles Lebendige kräftig ist und leuchtet. Beide haben dieselben
Farbnamen wie Warm und je acht Spielerfarben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push

Task 8: Regeln, Fragebogen und Bilder für den Probelauf

Files:

  • Modify: CLAUDE.md (ganze Datei ersetzen)
  • Create: docs/playtests/m1d.md
  • Create: docs/playtests/m1d/warm-karte.png, warm-vitrine.png, pastell-karte.png, pastell-vitrine.png, glimmen-karte.png, glimmen-vitrine.png (vom Bildschirmfoto-Werkzeug erzeugt)

Interfaces:

  • Consumes: das Bildschirmfoto-Werkzeug aus Task 4 und die drei Stile aus den Tasks 5 bis 7

  • Produces: der Abschnitt „Grafik“ in CLAUDE.md; der Fragebogen docs/playtests/m1d.md mit sechs Bildern

  • Step 1: Regeln schreiben

Gegenüber main bekommt CLAUDE.md den Abschnitt „Grafik“. Unter „Prüfen“ stehen die Optionen des Bildschirmfoto-Werkzeugs, unter „Spielen“ das Argument --style=NAME. Bei der Verbindung, die als verloren gilt, heißt es jetzt „nach etwa“ statt „nach höchstens“, wie in #8 vermerkt.

Ersetze den gesamten Inhalt von CLAUDE.md durch:

# Grovewright

Aufbauspiel für ein bis vier Spieler, in dem die Fabrik lebt. Gebaut wird zuerst eine Vertical Slice mit 2–3 Stunden Spielzeit.

Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, bevor du Spielregeln, Architektur oder Umfang änderst. Was dort nicht steht, ist nicht beschlossen.

## Aufbau

| Ordner | Inhalt |
|---|---|
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
| `bridge/` | Rust-Brücke (GDExtension): macht den Kern für Godot aufrufbar und enthält keine Spielregeln. |
| `game/` | Godot-Projekt mit der GDScript-Hülle: Darstellung, Eingabe, Oberfläche, Dateien. |
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne |
| `docs/playtests/` | Fragebögen für Playtests |

Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. Die Hülle darf Gleitkommazahlen nutzen, weil sie nichts an der gemeinsamen Welt ändert; sie schickt nur Befehle.

## Regeln für den Kern

Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gelten für den Quelltext des Kerns in `core/src/` diese Regeln (die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen):

- Nur ganze Zahlen, keine Gleitkommazahlen.
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand.
- Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Ausnahmen sind das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet, und die Übernahme der Welt des Hosts mit `World::from_snapshot`, die ein Gast beim Beitritt und nach einer Abweichung macht.
- Wer eine Sitzung aufzeichnet, ruft `Recording::step` statt `World::step` auf. So enthält die Aufzeichnung genau die ausgeführten Befehle.
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es im Kodierer und im Dekodierer seiner Struktur (`snapshot.rs` für die Welt, `player.rs` für Spieler, `inventory.rs` für das Inventar, `map.rs` für die Karte), prüft es beim Dekodieren oder in `check_restorable` und erhöht `SNAPSHOT_VERSION`.
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler laufen praktisch nie über und werden normal addiert: Die Welt zählt in `u64`, eine Aufzeichnung in `u32`, was bei 20 Takten pro Sekunde für über sechs Jahre reicht. Takte aus geladenen Dateien begrenzt `MAX_RESTORABLE_TICK`.
- Keine fremden Crates im Kern.

Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections::HashMap` oder `std::fs::read`, auch über Aliase und gruppierte Importe. Der Test `core/tests/forbidden_apis.rs` durchsucht zusätzlich den Text nach verbotenen Begriffen; sie dürfen dort auch in Kommentaren nicht vorkommen. Gruppierte Importe aus `std` (`use std::{…}`) sind verboten, weil sie dem Text die Modulnamen verbergen: Jeder Pfad steht in einer eigenen `use`-Zeile. `core/tests/no_dependencies.rs` hält die Abhängigkeiten leer.

## Gemeinsame Welt im Netz

- Ein Spieler ist Host, die anderen sind Gäste. Der Host (`core/src/host.rs`) legt fest, welche Befehle in welchem Takt laufen, und schickt jeden Takt an seine Gäste. Jeder Gast (`core/src/guest.rs`) rechnet jeden Takt selbst nach. Allein spielen heißt: Host ohne Gäste.
- Ein Gast bekommt beim Beitritt die Welt des Hosts als Snapshot. Einmal pro Sekunde liegt einem Takt die Prüfsumme des Hosts bei. Weicht die Welt des Gastes ab, holt er sich die Welt des Hosts neu, und die Hülle speichert die Aufzeichnung der abgewichenen Welt.
- Die Nachrichten stehen in `core/src/protocol.rs`. Wer eine Nachricht ändert, erhöht `PROTOCOL_VERSION`. Der Anfang des Grußes und die ganze Ablehnung ändern sich dabei nie, damit Spiele jeder Version einander noch sagen können, dass sie nicht zusammenpassen; `core/tests/golden.rs` hält beides fest.
- Die Regel-Kennung (`core/src/rules.rs`) leitet sich aus den Endzuständen eingefrorener Skripte ab. Rechner mit verschiedener Regel-Kennung spielen nicht zusammen, und Aufzeichnungen anderer Regeln werden abgelehnt. Eine neue Regel braucht deshalb ein neues Skript in `SCRIPTS`, das sie benutzt; bestehende Skripte ändert niemand.
- Gäste schicken nur Aktionen, ohne Spielernummer. Der Host macht daraus Befehle für den Spieler der Verbindung und nimmt höchstens `MAX_ACTIONS_PER_TICK` pro Spieler und Takt an.
- Ein Gast zählt die Abklingzeit der Spitzhacke in seinen eigenen Takten, die denen des Hosts um die Laufzeit nachlaufen. Kommt sein Schlag an, während die Abklingzeit seines Spielers noch läuft, hält ihn der Host bis zum ersten Takt danach; kommen mehrere, gilt der neueste. Sonst ginge übers Internet etwa jeder fünfte Schlag eines Gastes verloren.
- Die Reichweite der Spitzhacke prüft nur die Hülle des eigenen Spielers. Figuren laufen außerhalb des Gleichschritts, also kennt der Kern ihre Position nicht. Ein veränderter Client könnte deshalb aus der Ferne graben. Außerdem stehen die Schlüssel aller Spieler in jedem Snapshot, den ein Gast bekommt; ein veränderter Client könnte sich damit als ein Spieler ausgeben, der gerade nicht online ist. Für ein Koop-Spiel unter Freunden nehmen wir beides hin.

## Regeln für Brücke und Hülle

- Die Brücke übersetzt nur. Neue Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
- GDScript mit Typangaben schreiben (`var x := …`, Rückgabetypen an jeder Funktion).
- Eingabeaktionen legt `game/scripts/input_setup.gd` an, nicht `project.godot`.
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt.
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()` (Ordner unter `user://tests`), nie die echten Dateien des Spielers.
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig, aber geordnet über Kanal 1; ENet drosselt sie nie. Eine Verbindung, deren Pakete unbeantwortet bleiben, gilt nach etwa `NetLink.TIMEOUT_MSEC` als verloren; ENet prüft die Grenze nur, wenn eine Wiederholung fällig ist. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
- Nur der Host speichert. Laden mit F9 geht nur ohne geöffnete Welt („Allein spielen“).
- Eine Pause beginnt nur ohne Gäste und lässt sich immer beenden. Nimmt der Host einen Gast an, endet sie; eine bloße Verbindung ohne Gruß beendet sie nicht.
- Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück. Eine angehaltene Sitzung hinterlässt keine Aufzeichnung.
- Tests mit Netzwerk lauschen nur auf `127.0.0.1` und lassen das System den Port wählen (Port 0), damit sie nie an einem belegten Port scheitern.

## Grafik

- Alles auf dem Spielfeld zeichnet `Art` (`game/scripts/art/art.gd`), jedes Ding unter einer Kennung wie `terrain/soft_rock`. Andere Skripte zeichnen keine eigenen Formen oder Farben aufs Spielfeld; ausgenommen sind Texte wie die Namen über den Figuren.
- Die Grafik ist Pixelgrafik aus Code (Konzept 7.4): 16 × 16 Pixel pro Kachel, schräg von vorn gesehen. `PixelStyle` (`game/scripts/art/pixel_style.gd`) zeichnet jede Kennung Pixel für Pixel in ein kleines Bild und hebt es auf. Die Stile `WarmStyle`, `PastelStyle` und `GlowStyle` wählen nur Palette, Umrisse und Leuchten; Warm ist der Standard. F2 wechselt im Spiel, „Stilproben ansehen“ im Menü öffnet die Vitrine, und das Spiel merkt sich die Wahl.
- Eine neue Kennung braucht eine Zeichnung in `PixelStyle` und ihre Farben in jeder Palette; sonst zeichnet `ArtStyle` nur einen Platzhalter. `game/tests/test_art.gd` lässt jeden Stil jede Kennung zeichnen.
- Damit die Pixel scharf bleiben, stellt `project.godot` die Texturfilterung auf „Nearest“ und rastet 2D-Transformationen auf ganze Bildschirmpixel ein. Die Kamera vergrößert ganzzahlig (`CAMERA_ZOOM` in `main.gd`), und die Stile setzen ihre Bilder auf ganze Pixel.
- Ein Bild `game/art/<Kennung>.png` ersetzt die Zeichnung seiner Kennung, ohne dass sich Code ändert. So kann ein Artist Grafik austauschen. Ein solches Bild ist ein einzelnes festes Bild; Kachelränder und Bewegung zeichnet bisher nur der Code.
- Die Karte zeichnet in Blöcken von 16 × 16 Kacheln (`MapView`); nach einem Treffer werden nur die Blöcke um die Kachel neu gezeichnet. Jede Kachel kennt ihre Nachbarn (`TileShape`), damit Felsmassen außen rund und innen geschlossen sind und zum offenen Boden hin ihre Vorderkante zeigen.

## Prüfen

Vor jedem Commit müssen alle Befehle fehlerfrei laufen:

```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```

Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust-Erweiterung beim Beenden ab (Exit-Code 139 in der Bash). Der Import ist dann trotzdem erfolgt; ein zweiter Aufruf läuft fehlerfrei durch. `play.bat` wiederholt den Import deshalb einmal.

Der Test `game/tests/test_two_instances.gd` startet einen zweiten Godot-Prozess als Gast (`game/tools/guest_bot.gd`) und prüft, dass beide Welten gleich bleiben. Er braucht deshalb die gebaute Brücke und den Import wie alle anderen Tests der Hülle.

Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände, Aufzeichnungen oder Nachrichten betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt (`SNAPSHOT_VERSION`, `RECORDING_VERSION` oder `PROTOCOL_VERSION`). Erst danach passt du den festgeschriebenen Wert bewusst an.

Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert. `--style=NAME` wählt einen Stil, `--showcase` zeigt die Vitrine statt des Spiels:

```bash
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei> [--style=NAME] [--showcase]
```

## Spielen

`play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`.

Ein laufendes Spiel hält `target\debug\grovewright_bridge.dll` offen. Ändern sich danach Brücke oder Kern, scheitert `cargo build` mit „Zugriff verweigert (os error 5)“, bis alle Spielfenster geschlossen sind.

Argumente hinter `play.bat` gehen an das Spiel:

| Argument | Wirkung |
|---|---|
| `--profile=NAME` | Eigener Ordner unter `profiles\NAME` für Schlüssel, Spielstand und Aufzeichnungen. Zwei Fenster auf einem Rechner brauchen zwei Profile, sonst sind sie derselbe Spieler. Erlaubt sind bis zu 32 Zeichen aus A bis Z, a bis z, Ziffern, `-` und `_`; einen anderen Namen übergeht das Spiel mit einer Warnung im Log und nimmt das Standardprofil. |
| `--name=NAME` | Name des Spielers |
| `--solo`, `--host` | Startet ohne Menü allein beziehungsweise mit offener Welt |
| `--port=PORT` | Port einer offenen Welt |
| `--join=ADRESSE` | Tritt ohne Menü einem Host bei; die Adresse darf auf `:PORT` enden |
| `--style=NAME` | Zeichnet im Stil `warm`, `pastel` oder `glow` statt im zuletzt gewählten |

`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Die Datei reicht weitere Argumente ebenfalls an das Spiel weiter, etwa `--name=Zweiter`.

## Sprache

- Code, Bezeichner und Kommentare im Code: Englisch.
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
- Texte im Spiel: Deutsch und Englisch über `game/i18n/texts.csv`.

## Git

- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). Issues und Pull Requests sind freigegeben.
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
- Commit-Nachrichten enden mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat.
  • Step 2: Bilder für den Fragebogen aufnehmen

Der Pfad muss absolut sein, denn Godot legt relative Pfade unter game/ an. pwd -W gibt in der Git-Bash den Windows-Pfad aus.

mkdir -p docs/playtests/m1d
OUT="$(pwd -W)/docs/playtests/m1d"
for pair in warm:warm pastel:pastell glow:glimmen; do
  godot --path game -s res://tools/screenshot.gd -- "$OUT/${pair#*:}-karte.png" --style=${pair%%:*}
  godot --path game -s res://tools/screenshot.gd -- "$OUT/${pair#*:}-vitrine.png" --style=${pair%%:*} --showcase
done
ls -l docs/playtests/m1d

Expected: Jeder der sechs Aufrufe endet mit screenshot saved: … ohne Warnung. ls zeigt die sechs Dateien glimmen-karte.png, glimmen-vitrine.png, pastell-karte.png, pastell-vitrine.png, warm-karte.png und warm-vitrine.png, jede zwischen 35 und 75 KB groß. Die genauen Größen schwanken von Lauf zu Lauf um einige Bytes.

  • Step 3: Bilder ansehen

Sieh dir alle sechs Bilder an. Jedes muss zu seiner Beschreibung aus den Tasks 5 bis 7 passen: das Spiel mit zwei Figuren und dem Namen „Spieler“, die Vitrine mit allen neun Beschriftungen und dem Titel ihres Stils.

  • Step 4: Fragebogen schreiben

Create docs/playtests/m1d.md:

# Probelauf M1d: Stilproben

M1d zeichnet das Spielfeld als Pixelgrafik aus Code, im Stil klassischer Farmspiele, aber eigenständig. Es gibt drei Abwandlungen: Warm, Pastell und Glimmen. Nach der ersten Probe war Warm dein Favorit; jetzt siehst du die Stile im Spiel und in Bewegung. Das beantwortet die Prüffrage von Meilenstein 1: „Welche der drei Stilproben soll es sein?“ Die Vitrine zeigt dazu Dinge aus späteren Meilensteinen, die es zum Spielen noch nicht gibt.

## So siehst du die Proben

1. Doppelklick auf `play.bat`.
2. Im Menü auf „Stilproben ansehen“. Die Vitrine zeigt
   - toten und lebendigen Boden,
   - Lebensmoos,
   - eine Ranke, die Steine trägt,
   - eine wachsende Faserpflanze,
   - ein hüpfendes Weidetier,
   - eine erwachende Ruine,
   - zwei Spielfiguren.

   Mit 1, 2 und 3 oder den Knöpfen unten wechselst du den Stil, mit Esc oder „Zurück“ geht es ins Menü.
3. Im Spiel wechselt F2 den Stil. Lauf herum, schlag Stein und sieh dir Fels, Figur, Zielmarke und Splitter an. Das Spiel merkt sich den zuletzt gewählten Stil.
4. Zu zweit wie in M1c: Die Namen über den Figuren sind jetzt scharf, und dein eigener Schlag zeigt seine Splitter sofort, auch als Gast.

So sehen die drei Proben aus:

| Warm | Pastell | Glimmen |
|---|---|---|
| ![Warm, Vitrine](m1d/warm-vitrine.png) | ![Pastell, Vitrine](m1d/pastell-vitrine.png) | ![Glimmen, Vitrine](m1d/glimmen-vitrine.png) |
| ![Warm, Spiel](m1d/warm-karte.png) | ![Pastell, Spiel](m1d/pastell-karte.png) | ![Glimmen, Spiel](m1d/glimmen-karte.png) |

## Fragen

1. Bleibt es bei Warm, oder gefällt dir im Spiel ein anderer Stil besser?
2. Was gefällt dir an deinem Favoriten am besten, und was stört dich daran?
3. Gibt es an den anderen beiden etwas, das du übernehmen möchtest, etwa das Leuchten des Lebendigen aus Glimmen?
4. Erkennst du auf einen Blick, was Boden, weicher Fels und harter Fels ist? Und was lebendiger Boden ist?
5. Wirkt das Lebendige lebendig: das Moos, die Ranke, die wachsende Pflanze, das Tier, die erwachende Ruine? Oder wirkt es nüchtern?
6. Ist die Bewegung angenehm: Laufen, Wiegen, Wachsen und Hüpfen? Oder ist es zu viel oder zu wenig?
7. Findest du deine Figur sofort, auch neben anderen? Sind Figuren und Dinge groß genug?
8. Wirkt ein Schlag als Gast jetzt sofort, und sind die Namen über den Figuren scharf?

Antworte formlos im Chat oder als Kommentar in Gitea.
  • Step 5: Alles prüfen

Run:

cargo fmt --all -- --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
cargo build -p grovewright-bridge
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd

Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests; der GDScript-Lauf endet mit Exit-Code 0 und 147 passed, 0 failed

  • Step 6: Commit und Hochladen
git add CLAUDE.md docs/playtests/m1d.md docs/playtests/m1d
git commit -F - <<'EOF'
Regeln, Fragebogen und Bilder für den Probelauf M1d

CLAUDE.md beschreibt, wie die Grafik entsteht: Kennungen, Pixelgrafik
aus Code, scharfe Pixel, Bilder, die Zeichnungen ersetzen, Blöcke und
Kachelformen. Das Bildschirmfoto-Werkzeug und --style=NAME stehen bei
ihren Befehlen. Eine stille Verbindung gilt nach etwa
NetLink.TIMEOUT_MSEC als verloren. Ein Fragebogen mit Bildern der drei
Stile begleitet den Probelauf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push