Files
Grovewright/docs/superpowers/plans/2026-10-02-m1d-stilproben.md
T
Steven Wroblewski 298bdf8c37 Plan M1d: Stilproben
Acht Tasks bringen das Spielfeld in drei Stilen auf den Bildschirm, alle
aus Code: Scherenschnitt, Kontur und Geometrisch. Jede Grafik hängt an
einer Kennung, eine Vitrine zeigt jeden Stil mit Dingen späterer
Meilensteine, F2 wechselt im Spiel, und das Spiel merkt sich die Wahl.
Dazu kommen die Antworten aus dem Probelauf von M1c: Der eigene Schlag
zeigt seine Splitter sofort, und die Namen über den Figuren bleiben
scharf. Jede Task-Grenze wurde vorab in einer Kopie von main geprüft.

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

5605 lines
226 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 lässt sich in drei Stilen zeichnen, alle aus Code: Scherenschnitt, Kontur und Geometrisch. 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, eine Unterklasse von `ArtStyle`, zeichnet sie mit den Zeichenbefehlen von Godot; ein Bild `game/art/<Kennung>.png` ersetzt die Zeichnung, ohne dass sich Code ändert. `TileShape` gibt jeder Kachel ihre acht Nachbarn, damit Felsmassen außen rund und innen geschlossen sind. `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“), GDScript mit Typangaben, die Zeichenbefehle von `CanvasItem` mit Kantenglättung. Rust 1.98.1 und godot-rust 0.5.5 bleiben unverändert.
**Spec:** `docs/KONZEPT.md`, Abschnitt 7.4 („Spielfeld aus Code“, „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“). Dazu kommen die Antworten aus dem Probelauf von M1c im Kommentar zu Gitea #8 und deine Wahl der drei Richtungen und der Szene vor diesem Plan.
## 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, flach und klar, Bewegung durch Programmierung, alle Kachelvarianten passen aneinander | 1 und 2: Nachbarmasken und Blöcke; 5 bis 7: die drei Stile |
| Konzept 8 und 11: drei Stilproben zur Wahl | 3: Vitrine; 4: Wechsel mit F2 und über die Befehlszeile; 8: Fragebogen mit Bildern |
| Konzept 11, Risiko „Grafik aus Code kann nüchtern wirken“ | 3: 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)
- Das Entfernen der Stile, die du nicht wählst. Das entscheidet sich 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. |
| Richtungen | Scherenschnitt: Papierlagen ohne Umriss mit weichen Schatten, gedeckte Erdfarben, Lebendiges leuchtet grün und golden. Kontur: kräftige dunkle Umrisse um flache, starke Farben, gut lesbar wie ein Comic. Geometrisch: ruhige Pastellformen ohne Umriss und Schatten, Muster nach dem Raster. | Deine Wahl vor diesem Plan |
| 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 | Ein Stil ist eine Unterklasse von `ArtStyle` und überschreibt je Kennung eine Zeichenmethode. `ArtStyle` selbst zeichnet schlichte Platzhalter. | Ein Stil steckt in einer Datei. Fehlt einer neuen Kennung die Zeichnung, fehlt nur ihr Aussehen; das Spiel läuft weiter. |
| 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. |
| Kanten | Glatte Kanten kommen aus den Zeichenbefehlen mit `antialiased = true`. | Der Renderer „GL Compatibility“ kennt kein MSAA in 2D; eingeschaltet meldet es einen Engine-Fehler. |
| Kachelformen | `TileShape.mask` beschreibt die acht Nachbarn einer Kachel als Bitmaske, im Uhrzeigersinn ab Norden. Fels ist außen rund und innen geschlossen; jenseits des Kartenrands zählt alles als Fels. | 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. Nach dem Umbau gemessen: rund 8 ms, um die Karte beim Start aufzubauen, rund 7 ms für das ganze Bild nach einem Treffer, höchstens 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/`.
- 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. Alle drei Stile wurden auf der Karte und in der Vitrine angesehen und nachgebessert. 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: 113, 121, 128, 132, 133, 134, 135 und 135. 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 Helfer für Formen |
| `game/scripts/art/tile_shape.gd` | Nachbarmasken und eine feste Zahl je Kachel für kleine Abwandlungen |
| `game/scripts/art/papercut_style.gd`, `outline_style.gd`, `geometric_style.gd` | Die drei Stile |
| `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/papercut_style.gd`, `game/scripts/art/outline_style.gd`, `game/scripts/art/geometric_style.gd` (vorläufig; die Tasks 5 bis 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, salt) -> float`, `rounded_rect(rect, mask, radius, segments := 4) -> PackedVector2Array`, `inset_open_sides(rect, mask, inset) -> Rect2`, `sway(time, variant, speed := 1.3) -> float`, `regular_polygon(center, radius, corners, rotation := 0.0) -> PackedVector2Array`, `closed(points) -> PackedVector2Array`, `along(points, part) -> Vector2`, `ellipse(center, radii, segments := 14) -> PackedVector2Array`, `slit(from, to, width) -> PackedVector2Array` und `leaf(base, angle, length, width) -> PackedVector2Array`
- `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`
- `PapercutStyle`, `OutlineStyle` und `GeometricStyle` mit `KEY` (`&"papercut"`, `&"outline"`, `&"geometric"`) und den Texten `STYLE_PAPERCUT`, `STYLE_OUTLINE`, `STYLE_GEOMETRIC`, vorerst ohne eigene Zeichnungen
- [ ] **Step 1: Auf den Branch wechseln**
Der Branch existiert bereits. Er wurde zusammen mit diesem Plan angelegt und hochgeladen.
```bash
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`:
```gdscript
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`:
```gdscript
extends TestCase
## The helpers that every style draws with.
const TILE := Rect2(0, 0, 16, 16)
func test_a_lone_tile_is_round_at_every_corner() -> void:
var points := ArtStyle.rounded_rect(TILE, 0, 4.0, 4)
check_eq(points.size(), 20, "five points for each of the four corners")
for point in points:
check(TILE.grow(0.001).has_point(point), "inside the tile: %s" % point)
check(not points.has(Vector2.ZERO), "the corner itself is cut off")
func test_a_tile_inside_a_mass_keeps_its_square_corners() -> void:
var all := 255
check_eq(ArtStyle.rounded_rect(TILE, all, 4.0), PackedVector2Array([Vector2(0, 0), Vector2(16, 0), Vector2(16, 16), Vector2(0, 16)]), "four corners")
var top_left_open := ArtStyle.rounded_rect(TILE, TileShape.E | TileShape.S, 4.0, 4)
check_eq(top_left_open.size(), 5 + 1 + 1 + 1, "only the corner between the open sides is round")
func test_inner_layers_shrink_only_toward_open_sides() -> void:
var inner := ArtStyle.inset_open_sides(TILE, TileShape.N | TileShape.W, 2.0)
check_eq(inner, Rect2(0, 0, 14, 14), "kept toward the neighbours above and to the left")
check_eq(ArtStyle.inset_open_sides(TILE, 0, 2.0), Rect2(2, 2, 12, 12), "smaller on every open side")
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_shapes_have_their_corners() -> void:
check_eq(ArtStyle.regular_polygon(Vector2.ZERO, 2.0, 6).size(), 6, "a hexagon")
check_eq(ArtStyle.ellipse(Vector2.ZERO, Vector2(3, 1), 12).size(), 12, "an ellipse")
check_eq(ArtStyle.slit(Vector2.ZERO, Vector2(10, 0), 2.0), PackedVector2Array([Vector2(0, 0), Vector2(5, -1), Vector2(10, 0), Vector2(5, 1)]), "a cut")
check_eq(ArtStyle.leaf(Vector2.ZERO, 0.0, 10.0, 4.0)[3], Vector2(10, 0), "a leaf ends at its tip")
check_eq(ArtStyle.closed(PackedVector2Array([Vector2.ZERO, Vector2.ONE])), PackedVector2Array([Vector2.ZERO, Vector2.ONE, Vector2.ZERO]), "closed")
```
Create `game/tests/test_art.gd`:
```gdscript
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, [PapercutStyle.KEY, OutlineStyle.KEY, GeometricStyle.KEY], "papercut, outline, geometric")
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(OutlineStyle.KEY), "the outline style exists")
check_eq(art.style.key(), OutlineStyle.KEY, "and is chosen")
check(art.select(OutlineStyle.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(), OutlineStyle.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, [PapercutStyle.KEY, OutlineStyle.KEY, GeometricStyle.KEY, PapercutStyle.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:
```bash
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`:
```gdscript
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`:
```gdscript
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
## The outline of `rect` as points clockwise, with each corner rounded by
## `radius` where neither neighbour beside the corner is set in `mask`, so
## that a mass of tiles is round only on its outside.
static func rounded_rect(rect: Rect2, mask: int, radius: float, segments := 4) -> PackedVector2Array:
var points := PackedVector2Array()
var corners := [
[rect.position, TileShape.W | TileShape.N, PI],
[Vector2(rect.end.x, rect.position.y), TileShape.N | TileShape.E, 1.5 * PI],
[rect.end, TileShape.E | TileShape.S, 0.0],
[Vector2(rect.position.x, rect.end.y), TileShape.S | TileShape.W, 0.5 * PI],
]
for corner: Array in corners:
var at: Vector2 = corner[0]
var sides: int = corner[1]
if mask & sides != 0 or radius <= 0.0:
points.append(at)
continue
var inward := Vector2(radius if at.x == rect.position.x else -radius, radius if at.y == rect.position.y else -radius)
var center := at + inward
var start: float = corner[2]
for i in segments + 1:
var angle := start + 0.5 * PI * i / segments
points.append(center + Vector2(cos(angle), sin(angle)) * radius)
return points
## `rect` made smaller by `inset` on each side that does not touch a
## neighbour set in `mask`, so that the inner layers of a mass of tiles join.
static func inset_open_sides(rect: Rect2, mask: int, inset: float) -> Rect2:
var left := 0.0 if mask & TileShape.W else inset
var top := 0.0 if mask & TileShape.N else inset
var right := 0.0 if mask & TileShape.E else inset
var bottom := 0.0 if mask & TileShape.S else inset
return Rect2(rect.position + Vector2(left, top), rect.size - Vector2(left + right, top + bottom))
## 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 points of a regular polygon with `corners` corners around `center`.
static func regular_polygon(center: Vector2, radius: float, corners: int, rotation := 0.0) -> PackedVector2Array:
var points := PackedVector2Array()
for i in corners:
var angle := rotation + TAU * i / corners
points.append(center + Vector2(cos(angle), sin(angle)) * radius)
return points
## A closed copy of `points`, for drawing the outline of a polygon.
static func closed(points: PackedVector2Array) -> PackedVector2Array:
var result := points.duplicate()
if not points.is_empty():
result.append(points[0])
return result
## 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]
## The points of an ellipse with the half axes `radii` around `center`.
static func ellipse(center: Vector2, radii: Vector2, segments := 14) -> PackedVector2Array:
var points := PackedVector2Array()
for i in segments:
var angle := TAU * i / segments
points.append(center + Vector2(cos(angle) * radii.x, sin(angle) * radii.y))
return points
## A narrow cut from `from` to `to`, `width` wide in the middle and pointed
## at both ends.
static func slit(from: Vector2, to: Vector2, width: float) -> PackedVector2Array:
var middle := (from + to) / 2.0
var side := (to - from).orthogonal().normalized() * width / 2.0
return PackedVector2Array([from, middle + side, to, middle - side])
## A leaf `length` long and `width` wide that grows from `base` in the
## direction `angle`.
static func leaf(base: Vector2, angle: float, length: float, width: float) -> PackedVector2Array:
var along := Vector2.from_angle(angle)
var side := along.orthogonal() * width / 2.0
return PackedVector2Array([
base,
base + along * length * 0.35 + side,
base + along * length * 0.7 + side * 0.7,
base + along * length,
base + along * length * 0.7 - side * 0.7,
base + along * length * 0.35 - side,
])
```
Create `game/scripts/art/art.gd`:
```gdscript
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] = [PapercutStyle.new(), OutlineStyle.new(), GeometricStyle.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 [PapercutStyle.KEY, OutlineStyle.KEY, GeometricStyle.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 bis 7 ersetzen sie durch die fertigen Stile.
Create `game/scripts/art/papercut_style.gd`:
```gdscript
class_name PapercutStyle
extends ArtStyle
## Layers of cut paper without outlines, with soft shadows.
## Until it has drawings of its own, it draws the placeholders of ArtStyle.
const KEY := &"papercut"
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_PAPERCUT"
```
Create `game/scripts/art/outline_style.gd`:
```gdscript
class_name OutlineStyle
extends ArtStyle
## Bold dark outlines around flat colours, easy to read like a comic.
## Until it has drawings of its own, it draws the placeholders of ArtStyle.
const KEY := &"outline"
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_OUTLINE"
```
Create `game/scripts/art/geometric_style.gd`:
```gdscript
class_name GeometricStyle
extends ArtStyle
## Plain geometric forms in calm pastel colours.
## Until it has drawings of its own, it draws the placeholders of ArtStyle.
const KEY := &"geometric"
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_GEOMETRIC"
```
Ersetze den gesamten Inhalt von `game/i18n/texts.csv` durch die folgende Fassung. Neu sind die letzten drei Zeilen mit den Namen der Stile.
```text
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_PAPERCUT,Scherenschnitt,Papercut
STYLE_OUTLINE,Kontur,Outline
STYLE_GEOMETRIC,Geometrisch,Geometric
```
- [ ] **Step 5: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `113 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
- [ ] **Step 6: Alles prüfen**
Run:
```bash
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**
```bash
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 und bringt Helfer für
Formen mit. TileShape beschreibt die Nachbarn einer Kachel, damit
Felsmassen außen rund und innen geschlossen gezeichnet werden können.
Die drei Stile 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`, `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`
- `Figures`: `art: Art`, `facing_of(player: int) -> float`
- `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`:
```gdscript
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` diesen Test an. Wie zwischen allen Funktionen stehen davor zwei Leerzeilen:
```gdscript
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()
```
Füge am Ende von `game/tests/test_figures.gd` diesen Test an, wieder nach zwei Leerzeilen:
```gdscript
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:
```gdscript
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:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Der Testlauf endet mit Exit-Code 1 und `113 passed, 4 failed`. `test_map_view.gd` lädt nicht (`SCRIPT ERROR: Parse Error: Cannot find member "BLOCK" in base "MapView".`). Die drei angehängten Tests scheitern mit `Invalid access to property or key 'moving' on a base object of type 'Node2D (Avatar)'.`, `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:
```gdscript
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:
```gdscript
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
## The square a figure standing at `at` is drawn in.
static func figure_rect(at: Vector2) -> Rect2:
var size := GridMotion.TILE_SIZE
return Rect2(at - Vector2(size, size) / 2.0, Vector2(size, size))
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:
```gdscript
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, at.y - GridMotion.TILE_SIZE / 2.0 - 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:
```gdscript
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:
```gdscript
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:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `121 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:
```bash
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**
```bash
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 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`:
```gdscript
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.
```gdscript
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:
```gdscript
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:
```gdscript
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(), GeometricStyle.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:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Der Testlauf endet mit Exit-Code 1 und `120 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`:
```gdscript
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.
```gdscript
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`.
```gdscript
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.
```text
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_PAPERCUT,Scherenschnitt,Papercut
STYLE_OUTLINE,Kontur,Outline
STYLE_GEOMETRIC,Geometrisch,Geometric
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:
```gdscript
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:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `128 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
- [ ] **Step 5: Alles prüfen**
Run:
```bash
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**
```bash
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. 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:
```gdscript
func test_a_style_can_be_chosen() -> void:
check_eq(LaunchOptions.parse(PackedStringArray(["--style=geometric"])).style, GeometricStyle.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:
```gdscript
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("outline")
check_eq(SaveStore.new(directory).style(), "outline", "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:
```gdscript
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=outline"])
tree.root.add_child(main)
_started.append(main)
check_eq(main.art.style.key(), OutlineStyle.KEY, "the style of the command line")
_clean_up([directory])
```
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Der Testlauf endet mit Exit-Code 1 und `128 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 outline, got papercut`
Dazu kommen `WARNING: ignored command line argument: --style=outline` 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`.
```gdscript
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: papercut,
## outline or geometric
## 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:
```gdscript
## 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:
```gdscript
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`.
```text
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_PAPERCUT,Scherenschnitt,Papercut
STYLE_OUTLINE,Kontur,Outline
STYLE_GEOMETRIC,Geometrisch,Geometric
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:
```gdscript
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:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `132 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
- [ ] **Step 5: Bildschirmfotos prüfen**
Run:
```bash
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-task4-spiel.png" --style=outline
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 Stile zeichnen erst ab Task 5 selbst. 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: Scherenschnitt“, in der Mitte die Szene aus Rechtecken, Kreisen und Linien mit neun weißen Beschriftungen, unten die Knöpfe „1 Scherenschnitt“ (ausgegraut), „2 Kontur“, „3 Geometrisch“ und „Zurück“.
- [ ] **Step 6: Alles prüfen**
Run:
```bash
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**
```bash
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: Stil Scherenschnitt
Lagen aus geschnittenem Papier ohne Umriss, mit weichen Schatten. Gedeckte Erdfarben; Lebendiges leuchtet grün und golden. Das Licht fällt von oben links, also wirft Fels seinen Schatten nach rechts unten auf den Boden.
**Files:**
- Modify: `game/scripts/art/papercut_style.gd` (ganze Datei ersetzen)
- Test: `game/tests/test_papercut_style.gd` (neu)
**Interfaces:**
- Consumes: `ArtStyle` mit seinen Zeichenmethoden und Helfern, `TileShape`, `Art.IDS`, die Einträge von `state` laut Kopf von `art_style.gd`
- Produces: `PapercutStyle` zeichnet jede Kennung selbst; `PLAYER_COLORS: Array[Color]` mit acht Farben und `player_color(player: int) -> Color`
- [ ] **Step 1: Fehlschlagenden Test schreiben**
Create `game/tests/test_papercut_style.gd`:
```gdscript
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := PapercutStyle.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: Test ausführen und Fehlschlag sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 1 und `132 passed, 1 failed`; unter `FAIL test_papercut_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: Stil schreiben**
Ersetze den gesamten Inhalt von `game/scripts/art/papercut_style.gd` durch:
```gdscript
class_name PapercutStyle
extends ArtStyle
## Layers of cut paper without outlines, with soft shadows. Muted earth
## colours; living things glow in green and gold. Light falls from the top
## left, so rock casts its shadow onto the ground below and to the right.
const KEY := &"papercut"
const GROUND := Color("9a8468")
const GROUND_FLECK := Color("8d7860")
const GROUND_FIBRE := Color("a8926f")
const SHADOW := Color(0.16, 0.11, 0.07, 0.38)
## The outer, fainter part of a shadow on the ground.
const SHADOW_FAINT := Color(0.16, 0.11, 0.07, 0.17)
const SOFT_BASE := Color("8b8275")
const SOFT_TOP := Color("bab1a2")
const SOFT_SCRAP := Color("a69d8e")
const HARD_BASE := Color("454c5b")
const HARD_TOP := Color("646c7e")
const HARD_FACET := Color("7f8899")
const BEDROCK_BASE := Color("241e1a")
const BEDROCK_TOP := Color("342b25")
const CUT := Color("2a2119")
const DEAD_CUT := Color("6f5e4a")
const LIVING_BASE := Color("587f3e")
const LIVING_TOP := Color("72a04c")
const GOLD := Color("ebc75e")
const MOSS_DARK := Color("4b7633")
const MOSS_LIGHT := Color("89bd59")
const VINE_DARK := Color("3d672b")
const VINE_LIGHT := Color("6e9d44")
const LEAF := Color("88ba57")
const STONE_BASE := Color("8f877a")
const STONE_TOP := Color("b1a999")
const STEM := Color("5a8739")
const FIBRE := Color("f1e8d2")
const SEED := Color("7a5b3a")
const WOOL := Color("eee6d4")
const WOOL_SHADE := Color("d3c7b0")
const FACE := Color("4a3a30")
const RUIN_BASE := Color("767067")
const RUIN_TOP := Color("a39c90")
const RUIN_DARK := Color("2d2722")
const SKIN := Color("e9cba4")
const HAIR := Color("4b3528")
const LEGS := Color("3a2f28")
const MARKER_NEAR := Color("f0cf6a")
const MARKER_FAR := Color("c25b4c")
const PLAYER_COLORS: Array[Color] = [
Color("c5563f"), Color("3f7cad"), Color("d99a3d"), Color("6a9a4c"),
Color("8b5a7c"), Color("4a9a96"), Color("b07a52"), Color("6b6fa8"),
]
## How far each of the two bands of a rock shadow reaches onto the ground,
## in pixels: a darker band along the rock and a fainter one beyond it.
const SHADOW_DEPTH := 1.8
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_PAPERCUT"
func player_color(player: int) -> Color:
return PLAYER_COLORS[maxi(0, player) % PLAYER_COLORS.size()]
func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
canvas.draw_rect(rect, GROUND)
_paper_bits(canvas, rect, state.get("variant", 0), GROUND_FLECK, GROUND_FIBRE)
_shadow(canvas, rect, state.get("solid", 0))
func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var solid: int = state.get("solid", 0)
var variant: int = state.get("variant", 0)
_raised(canvas, rect, solid, SOFT_BASE, SOFT_TOP)
if share(variant, 3) < 0.4:
var at := rect.position + Vector2(4, 4) + Vector2(share(variant, 4), share(variant, 5)) * 6.0
canvas.draw_colored_polygon(regular_polygon(at, 2.0, 5, share(variant, 6) * TAU), SOFT_SCRAP)
func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var solid: int = state.get("solid", 0)
var variant: int = state.get("variant", 0)
_raised(canvas, rect, solid, HARD_BASE, HARD_TOP)
# An angled facet, as if the hard stone had split along a plane.
var c := rect.get_center()
var flip := 1.0 if share(variant, 3) < 0.5 else -1.0
var facet := PackedVector2Array([
c + Vector2(-5.0 * flip, -4.0), c + Vector2(4.0 * flip, -2.0), c + Vector2(-1.0 * flip, 5.0),
])
canvas.draw_colored_polygon(facet, HARD_FACET)
func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
_raised(canvas, rect, state.get("solid", 0), BEDROCK_BASE, BEDROCK_TOP)
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hits: int = state.get("hits", 1)
var variant: int = state.get("variant", 0)
var c := rect.get_center()
var start := c + Vector2(-5.0, -3.0 + share(variant, 8) * 2.0)
canvas.draw_colored_polygon(slit(start, c + Vector2(4.0, 3.0), 1.4), CUT)
if hits >= 2:
canvas.draw_colored_polygon(slit(c + Vector2(3.5, -5.0), c + Vector2(-1.0, 4.5), 1.2), CUT)
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var age: float = state.get("age", 0.0)
var variant: int = state.get("variant", 0)
var c := rect.get_center()
for flake in 5:
var direction := Vector2.from_angle(TAU * flake / 5.0 + share(variant, flake) * 0.8)
var at := c + direction * (2.0 + 9.0 * age) + Vector2(0, 6.0 * age * age)
var turn := age * 5.0 + flake
var color := SOFT_TOP if flake % 2 == 0 else SOFT_BASE
color.a = 1.0 - age
canvas.draw_colored_polygon(regular_polygon(at, 1.6, 3, turn), color)
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var facing: float = state.get("facing", 1.0)
var step: float = state.get("step", 0.0)
var moving: bool = state.get("moving", false)
var swing := sin(step) if moving else 0.0
var lift := absf(swing) * 1.0
var c := rect.get_center() + Vector2(0, -lift)
var feet := rect.get_center() + Vector2(0, 6.0)
canvas.draw_colored_polygon(ellipse(feet, Vector2(4.5, 1.6)), SHADOW)
for side in [-1.0, 1.0]:
var leg := Vector2(side * 1.6 + side * swing * 1.2, 4.5)
canvas.draw_rect(Rect2(c + leg - Vector2(0.9, 1.5), Vector2(1.8, 3.0)), LEGS)
var body := PackedVector2Array([
c + Vector2(-3.6, 4.0), c + Vector2(-2.6, -1.8), c + Vector2(2.6, -1.8), c + Vector2(3.6, 4.0),
])
canvas.draw_colored_polygon(body, player_color(state.get("player", -1)))
var head := c + Vector2(0, -4.6)
canvas.draw_circle(head, 3.1, SKIN, true, -1.0, true)
# Hair on the back of the head, so that the figure looks the way it goes.
canvas.draw_colored_polygon(ellipse(head + Vector2(-facing * 1.2, -1.2), Vector2(2.4, 1.9)), HAIR)
canvas.draw_circle(head + Vector2(facing * 1.3, -0.2), 0.55, LEGS, true, -1.0, true)
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var color := MARKER_NEAR if state.get("reachable", false) else MARKER_FAR
var inner := rect.grow(-1.0)
var arm := 4.0
var thick := 1.6
for corner in 4:
var x := inner.position.x if corner % 3 == 0 else inner.end.x
var y := inner.position.y if corner < 2 else inner.end.y
var sx := 1.0 if corner % 3 == 0 else -1.0
var sy := 1.0 if corner < 2 else -1.0
var bracket := PackedVector2Array([
Vector2(x, y), Vector2(x + sx * arm, y), Vector2(x + sx * arm, y + sy * thick),
Vector2(x + sx * thick, y + sy * thick), Vector2(x + sx * thick, y + sy * arm), Vector2(x, y + sy * arm),
])
var shade := bracket.duplicate()
for i in shade.size():
shade[i] += Vector2(0.6, 0.6)
canvas.draw_colored_polygon(shade, SHADOW)
canvas.draw_colored_polygon(bracket, color)
func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0)
canvas.draw_rect(rect, GROUND)
if share(variant, 1) < 0.55:
var a := rect.position + Vector2(share(variant, 2) * 6.0 + 2.0, share(variant, 3) * 4.0 + 3.0)
var b := a + Vector2(6.0, 4.0 - share(variant, 4) * 8.0)
canvas.draw_colored_polygon(slit(a, b, 1.1), DEAD_CUT)
canvas.draw_colored_polygon(slit(b, b + Vector2(3.0, 3.0), 0.8), DEAD_CUT)
_shadow(canvas, rect, state.get("solid", 0))
func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var living: int = state.get("living", 0)
var variant: int = state.get("variant", 0)
canvas.draw_rect(rect, GROUND)
canvas.draw_colored_polygon(rounded_rect(rect, living, 5.0), LIVING_BASE)
canvas.draw_colored_polygon(rounded_rect(inset_open_sides(rect, living, 1.4), living, 3.6), LIVING_TOP)
if share(variant, 1) < 0.5:
var at := rect.position + Vector2(3, 3) + Vector2(share(variant, 2), share(variant, 3)) * 10.0
canvas.draw_circle(at, 0.8, GOLD, true, -1.0, true)
_shadow(canvas, rect, state.get("solid", 0))
func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0)
var time: float = state.get("time", 0.0)
var foot := Vector2(rect.get_center().x, rect.end.y - 3.0)
var breathe := 1.0 + 0.06 * sway(time, variant, 1.6)
canvas.draw_colored_polygon(ellipse(foot + Vector2(1.0, 1.2), Vector2(6.0, 2.0)), SHADOW)
var blobs: Array[Vector3] = [Vector3(-3.5, -1.5, 3.0), Vector3(0.5, -3.0, 3.6), Vector3(3.8, -1.2, 2.7)]
for blob in blobs:
canvas.draw_circle(foot + Vector2(blob.x, blob.y) * breathe, blob.z * breathe, MOSS_DARK, true, -1.0, true)
for blob in blobs:
canvas.draw_circle(foot + Vector2(blob.x - 0.6, blob.y - 0.8) * breathe, blob.z * 0.7 * breathe, MOSS_LIGHT, true, -1.0, true)
for spark in 3:
var glow := 0.55 + 0.45 * sin(time * 2.0 + spark * 2.1 + share(variant, spark) * TAU)
var gold := GOLD
gold.a = glow
canvas.draw_circle(foot + Vector2(-3.0 + spark * 3.0, -4.0 + (spark % 2) * 2.0), 0.7, gold, true, -1.0, true)
func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
var time: float = state.get("time", 0.0)
if points.size() < 2:
return
var shade := points.duplicate()
for i in shade.size():
shade[i] += Vector2(1.0, 1.4)
canvas.draw_polyline(shade, SHADOW, 3.0, true)
canvas.draw_polyline(points, VINE_DARK, 3.2, true)
var top := points.duplicate()
for i in top.size():
top[i] -= Vector2(0.3, 0.4)
canvas.draw_polyline(top, VINE_LIGHT, 1.6, true)
var count := 9
for i in count:
var share_along := (i + 0.5) / count
var at := along(points, share_along)
var ahead := along(points, minf(1.0, share_along + 0.02))
var side := 1.0 if i % 2 == 0 else -1.0
var angle := (ahead - at).angle() + side * 0.9 + 0.25 * sway(time, i, 1.4)
canvas.draw_colored_polygon(leaf(at, angle, 5.0, 2.6), LEAF)
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var growth: float = state.get("growth", 1.0)
var time: float = state.get("time", 0.0)
var foot := Vector2(rect.get_center().x, rect.end.y - 2.0)
canvas.draw_colored_polygon(ellipse(foot + Vector2(1.0, 0.8), Vector2(4.0, 1.4)), SHADOW)
if growth < 0.15:
canvas.draw_colored_polygon(ellipse(foot + Vector2(0, -1.2), Vector2(2.2, 1.6)), SEED)
if growth > 0.08:
canvas.draw_colored_polygon(leaf(foot + Vector2(0, -2.2), -PI / 2.0, 2.5, 1.4), LEAF)
return
var grown := (growth - 0.15) / 0.85
var height := lerpf(4.0, 20.0, grown)
canvas.draw_set_transform(foot, 0.07 * sway(time, 3, 1.1), Vector2.ONE)
canvas.draw_colored_polygon(slit(Vector2.ZERO, Vector2(0, -height), 1.6), STEM)
var leaves := 1 + int(grown * 5.0)
for i in leaves:
var at := Vector2(0, -height * (0.2 + 0.7 * i / maxf(1.0, leaves)))
var side := 1.0 if i % 2 == 0 else -1.0
canvas.draw_colored_polygon(leaf(at, -PI / 2.0 + side * 1.0, lerpf(3.0, 6.0, grown), 2.6), LEAF)
if grown > 0.75:
var tuft := (grown - 0.75) / 0.25
for puff in 4:
var at := Vector2(-2.0 + puff * 1.4, -height - 1.0 - (puff % 2) * 1.2)
canvas.draw_circle(at, 1.4 * tuft + 0.3, FIBRE, true, -1.0, true)
canvas.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
var c := rect.get_center()
canvas.draw_colored_polygon(ellipse(c + Vector2(0.8, 2.6), Vector2(3.0, 1.0)), SHADOW)
canvas.draw_colored_polygon(regular_polygon(c, 3.0, 6, 0.3), STONE_BASE)
canvas.draw_colored_polygon(regular_polygon(c + Vector2(-0.4, -0.5), 2.2, 6, 0.3), STONE_TOP)
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hop: float = state.get("hop", 1.0)
var facing: float = state.get("facing", 1.0)
var lift := sin(PI * hop) * 4.0
var foot := Vector2(rect.get_center().x, rect.end.y - 2.0)
canvas.draw_colored_polygon(ellipse(foot + Vector2(0.8, 0.6), Vector2(5.5 - lift * 0.5, 1.6)), SHADOW)
var body := foot + Vector2(0, -6.0 - lift)
for leg in [-2.5, 2.5]:
canvas.draw_rect(Rect2(body + Vector2(leg - 0.7, 3.0), Vector2(1.4, 3.0 - lift * 0.3)), FACE)
var puffs: Array[Vector3] = [
Vector3(-3.5, 0.5, 3.0), Vector3(0.0, -1.5, 3.4), Vector3(3.2, 0.3, 3.0), Vector3(0.0, 1.8, 3.0),
]
for puff in puffs:
canvas.draw_circle(body + Vector2(puff.x, puff.y), puff.z, WOOL_SHADE, true, -1.0, true)
for puff in puffs:
canvas.draw_circle(body + Vector2(puff.x - 0.5, puff.y - 0.6), puff.z * 0.78, WOOL, true, -1.0, true)
var head := body + Vector2(facing * 5.0, -1.2)
canvas.draw_colored_polygon(ellipse(head, Vector2(2.4, 2.0)), FACE)
canvas.draw_colored_polygon(ellipse(head + Vector2(-facing * 1.6, -1.6), Vector2(1.2, 0.7)), FACE)
canvas.draw_circle(head + Vector2(facing * 0.9, -0.4), 0.45, WOOL, true, -1.0, true)
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var awake: float = state.get("awake", 0.0)
var time: float = state.get("time", 0.0)
var tile := rect.size.x / 3.0
var floor_y := rect.end.y - 3.0
var center := Vector2(rect.get_center().x, floor_y - tile * 1.2)
# The shadow of the ruin and the dark space under its arch.
canvas.draw_colored_polygon(ellipse(Vector2(center.x + 3.0, floor_y + 1.0), Vector2(rect.size.x * 0.48, 3.5)), SHADOW)
var opening := Rect2(rect.position.x + tile * 0.75, rect.position.y + tile * 0.9, tile * 1.5, floor_y - rect.position.y - tile * 0.9)
canvas.draw_rect(opening, RUIN_DARK)
# The glow of the sleeping grove, brighter the more it is awake.
for ring in 4:
var gold := GOLD
gold.a = awake * (0.18 + 0.12 * ring)
canvas.draw_circle(center + Vector2(0, 4), 9.0 - ring * 2.0, gold, true, -1.0, true)
var crown := center + Vector2(0, 0.5 * sin(time * 1.2))
canvas.draw_colored_polygon(slit(Vector2(center.x, floor_y), crown + Vector2(0, 4), 2.6), SEED)
for blob in 3:
var at := crown + Vector2(-3.5 + blob * 3.5, -1.0 + (blob % 2) * -2.0)
canvas.draw_circle(at, 3.4, MOSS_DARK.lerp(GOLD, awake * 0.35), true, -1.0, true)
# Two pillars and the lintel, each of two layers of paper.
for x in [rect.position.x, rect.end.x - tile * 0.75]:
var pillar := Rect2(x, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6)
canvas.draw_colored_polygon(rounded_rect(pillar, 0, 2.0), RUIN_BASE)
canvas.draw_colored_polygon(rounded_rect(pillar.grow(-1.4), 0, 1.4), RUIN_TOP)
var vein := GOLD
vein.a = awake * 0.8
canvas.draw_line(pillar.get_center() + Vector2(0, -6), pillar.get_center() + Vector2(1.5, 6), vein, 1.0, true)
var lintel := Rect2(rect.position.x - 1.0, rect.position.y + tile * 0.25, rect.size.x + 2.0, tile * 0.6)
canvas.draw_colored_polygon(rounded_rect(lintel, 0, 2.0), RUIN_BASE)
canvas.draw_colored_polygon(rounded_rect(lintel.grow(-1.4), 0, 1.4), RUIN_TOP)
var step := Rect2(rect.position.x + tile * 0.4, floor_y - 2.0, rect.size.x - tile * 0.8, 3.0)
canvas.draw_rect(step, RUIN_BASE)
## A mass of rock: ground where the corners are cut round, the darker lower
## layer of paper and the lighter upper one, inset on the open sides.
func _raised(canvas: CanvasItem, rect: Rect2, solid: int, base: Color, top: Color) -> void:
canvas.draw_rect(rect, GROUND)
_shadow(canvas, rect, solid)
canvas.draw_colored_polygon(rounded_rect(rect, solid, 4.0), base)
canvas.draw_colored_polygon(rounded_rect(inset_open_sides(rect, solid, 1.6), solid, 2.6), top)
## The shadow that rock above and to the left casts onto this tile: a darker
## band along the rock and a fainter one beyond, so that it fades.
func _shadow(canvas: CanvasItem, rect: Rect2, solid: int) -> void:
var above := solid & TileShape.N != 0
var left := solid & TileShape.W != 0
for band: Array in [[0.0, SHADOW], [SHADOW_DEPTH, SHADOW_FAINT]]:
var offset: float = band[0]
var color: Color = band[1]
if above:
canvas.draw_rect(Rect2(rect.position + Vector2(0, offset), Vector2(rect.size.x, SHADOW_DEPTH)), color)
if left:
var top := offset + SHADOW_DEPTH if above else 0.0
canvas.draw_rect(Rect2(rect.position + Vector2(offset, top), Vector2(SHADOW_DEPTH, rect.size.y - top)), color)
if not above and not left and solid & TileShape.NW != 0:
canvas.draw_rect(Rect2(rect.position, Vector2(SHADOW_DEPTH + offset, SHADOW_DEPTH + offset)), color)
## Small flecks and fibres in the paper, on some tiles.
func _paper_bits(canvas: CanvasItem, rect: Rect2, variant: int, fleck: Color, fibre: Color) -> void:
if share(variant, 11) < 0.25:
var at := rect.position + Vector2(3, 3) + Vector2(share(variant, 12), share(variant, 13)) * 10.0
canvas.draw_colored_polygon(regular_polygon(at, 1.1 + share(variant, 14), 5, share(variant, 15) * TAU), fleck)
if share(variant, 16) < 0.2:
var from := rect.position + Vector2(2, 2) + Vector2(share(variant, 17), share(variant, 18)) * 9.0
canvas.draw_line(from, from + Vector2.from_angle(share(variant, 19) * TAU) * 3.5, fibre, 0.7, true)
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `133 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
- [ ] **Step 5: Bildschirmfotos ansehen**
Run:
```bash
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-scherenschnitt-karte.png" --style=papercut
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-scherenschnitt-vitrine.png" --style=papercut --showcase
```
Expected: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Nirgends steht mehr ein Platzhalter, also kein einfarbiges Rechteck und kein schlichter Kreis.
- Spiel: Der Boden ist braunes Packpapier mit feinen Flecken und Fasern. Fels besteht aus zwei hellgrauen Papierlagen mit wenigen Flecken, außen abgerundet und innen geschlossen, und wirft nach rechts unten einen weichen Schatten. Im getroffenen Fels steht ein dunkler Schnitt, darum fliegen Papierschnipsel. Die beiden Figuren stehen auf einem Schatten: Körper in Rotbraun, heller Kopf, dunkles Haar, Beine. Über der linken steht scharf „Spieler“.
- Vitrine: Grundgestein fast schwarz, weicher Fels hell, harter Fels blaugrau mit hellen Dreiecksfacetten, alles mit Schatten. Toter Boden mit eingeschnittenen Rissen, lebendiger Boden als grüne Papierlage mit goldenen Pünktchen. Moospolster, eine Ranke als Band mit Blättern und drei Steinen, eine fast ausgewachsene Faserpflanze mit weißen Fasern, ein Wollschaf in der Luft, die Ruine als Steintor mit goldenem Leuchten, zwei Figuren in Rot und Blau. Oben „Stilprobe: Scherenschnitt“, unten ist „1 Scherenschnitt“ ausgegraut.
- [ ] **Step 6: Alles prüfen**
Run:
```bash
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**
```bash
git add game
git commit -F - <<'EOF'
Stil Scherenschnitt
Lagen aus geschnittenem Papier ohne Umriss, mit weichen Schatten nach
rechts unten. Gedeckte Erdfarben; Moos, lebendiger Boden und die Ruine
leuchten grün und golden. Pflanzen wiegen sich, die Faserpflanze wächst
vom Samen bis zu den Fasern, und jeder Spieler hat eine eigene Farbe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 6: Stil Kontur
Kräftige dunkle Umrisse um flache, starke Farben, gut lesbar wie ein Comic. Felsmassen haben ihren Umriss nur dort, wo sie an offenen Boden grenzen, und jedes Ding hat einen dunklen Umriss um seine ganze Form.
**Files:**
- Modify: `game/scripts/art/outline_style.gd` (ganze Datei ersetzen)
- Test: `game/tests/test_outline_style.gd` (neu)
**Interfaces:**
- Consumes: `ArtStyle` mit seinen Zeichenmethoden und Helfern, `TileShape`, `Art.IDS`, die Einträge von `state` laut Kopf von `art_style.gd`
- Produces: `OutlineStyle` zeichnet jede Kennung selbst; `PLAYER_COLORS: Array[Color]` mit acht Farben und `player_color(player: int) -> Color`
- [ ] **Step 1: Fehlschlagenden Test schreiben**
Create `game/tests/test_outline_style.gd`:
```gdscript
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := OutlineStyle.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: Test ausführen und Fehlschlag sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 1 und `133 passed, 1 failed`; unter `FAIL test_outline_style.gd test_four_players_have_four_colours` steht `four players, four colours: expected 4, got 2`.
- [ ] **Step 3: Stil schreiben**
Ersetze den gesamten Inhalt von `game/scripts/art/outline_style.gd` durch:
```gdscript
class_name OutlineStyle
extends ArtStyle
## Bold dark outlines around flat colours, few and strong colours, easy to
## read like a comic. Masses of rock are outlined only where they meet open
## ground, and each thing gets one dark outline around its whole shape.
const KEY := &"outline"
const INK := Color("2b1d14")
## Width of the outlines, in pixels of the playing field.
const LINE := 1.1
const GROUND := Color("d6a35a")
const GROUND_DOT := Color("bd8a43")
const SOFT_FILL := Color("c8c1d6")
const SOFT_SHADE := Color("a79fbd")
const SOFT_LIGHT := Color("eeeaf5")
const HARD_FILL := Color("4a63a6")
const HARD_SHADE := Color("374c88")
const HARD_GLINT := Color("a9c3ff")
const DEAD_CRACK := Color("9a6b31")
const LIVING := Color("5cbf48")
const LIVING_SHADE := Color("3f9a33")
const SPARK := Color("ffe14d")
const MOSS := Color("49b13a")
const MOSS_LIGHT := Color("8de06a")
const VINE := Color("3fa34a")
const LEAF := Color("6fd259")
const STONE := Color("b3aba0")
const STEM := Color("3f9a33")
const FIBRE := Color("fffaf0")
const SEED := Color("8a5a35")
const WOOL := Color("fbf8f0")
const FACE := Color("3a2a22")
const RUIN := Color("a8a49b")
const RUIN_SHADE := Color("8a857b")
const RUIN_DARK := Color("3b322b")
const GLOW := Color("ffd84a")
const SKIN := Color("f5c79a")
const MARKER_NEAR := Color("7ddc4f")
const MARKER_FAR := Color("ee5a3c")
const PLAYER_COLORS: Array[Color] = [
Color("e8483b"), Color("2d8fe0"), Color("f5a623"), Color("33b864"),
Color("a35bd6"), Color("1fb5b5"), Color("f06aa8"), Color("6d7ce8"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_OUTLINE"
func player_color(player: int) -> Color:
return PLAYER_COLORS[maxi(0, player) % PLAYER_COLORS.size()]
func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0)
canvas.draw_rect(rect, GROUND)
if share(variant, 1) < 0.3:
var at := rect.position + Vector2(3, 3) + Vector2(share(variant, 2), share(variant, 3)) * 10.0
canvas.draw_circle(at, 0.8, GROUND_DOT, true, -1.0, true)
canvas.draw_circle(at + Vector2(2.2, 1.0), 0.55, GROUND_DOT, true, -1.0, true)
func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var solid: int = state.get("solid", 0)
var variant: int = state.get("variant", 0)
_mass(canvas, rect, solid, SOFT_FILL, SOFT_SHADE)
if share(variant, 3) < 0.3:
var at := rect.position + Vector2(4, 4) + Vector2(share(variant, 4), share(variant, 5)) * 5.0
canvas.draw_line(at, at + Vector2(3.0, -1.2), SOFT_LIGHT, 0.9, true)
func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var solid: int = state.get("solid", 0)
var variant: int = state.get("variant", 0)
_mass(canvas, rect, solid, HARD_FILL, HARD_SHADE)
if share(variant, 3) < 0.45:
var at := rect.position + Vector2(5, 5) + Vector2(share(variant, 4), share(variant, 5)) * 5.0
var glint := PackedVector2Array([at + Vector2(0, -2.4), at + Vector2(1.4, 0), at + Vector2(0, 2.4), at + Vector2(-1.4, 0)])
canvas.draw_colored_polygon(glint, HARD_GLINT)
canvas.draw_polyline(closed(glint), INK, 0.6, true)
func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
canvas.draw_rect(rect, GROUND)
canvas.draw_colored_polygon(rounded_rect(rect, state.get("solid", 0), 4.0), INK)
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hits: int = state.get("hits", 1)
var c := rect.get_center()
canvas.draw_polyline(PackedVector2Array([c + Vector2(-5, -4), c + Vector2(-1, -1), c + Vector2(-2, 2), c + Vector2(4, 5)]), INK, 1.0, true)
if hits >= 2:
canvas.draw_polyline(PackedVector2Array([c + Vector2(5, -5), c + Vector2(2, -1), c + Vector2(3, 2)]), INK, 1.0, true)
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var age: float = state.get("age", 0.0)
var variant: int = state.get("variant", 0)
var c := rect.get_center()
for shard in 5:
var direction := Vector2.from_angle(TAU * shard / 5.0 + share(variant, shard) * 0.8)
var at := c + direction * (2.0 + 9.0 * age) + Vector2(0, 6.0 * age * age)
var points := regular_polygon(at, 1.7, 3, age * 6.0 + shard)
var fill := SOFT_FILL
var ink := INK
fill.a = 1.0 - age
ink.a = 1.0 - age
canvas.draw_colored_polygon(points, fill)
canvas.draw_polyline(closed(points), ink, 0.6, true)
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var facing: float = state.get("facing", 1.0)
var step: float = state.get("step", 0.0)
var moving: bool = state.get("moving", false)
var swing := sin(step) if moving else 0.0
var c := rect.get_center() + Vector2(0, -absf(swing))
var head := c + Vector2(0, -4.2)
var feet: Array[Vector2] = [c + Vector2(-1.8 + swing * 1.2, 5.0), c + Vector2(1.8 - swing * 1.2, 5.0)]
var body := ellipse(c + Vector2(0, 1.0), Vector2(3.6, 3.6))
# One outline around the whole figure: dark shapes first, colours on top.
for foot in feet:
canvas.draw_colored_polygon(ellipse(foot, Vector2(1.7, 1.2) + Vector2(LINE, LINE)), INK)
canvas.draw_colored_polygon(ellipse(c + Vector2(0, 1.0), Vector2(3.6 + LINE, 3.6 + LINE)), INK)
canvas.draw_circle(head, 3.0 + LINE, INK, true, -1.0, true)
for foot in feet:
canvas.draw_colored_polygon(ellipse(foot, Vector2(1.7, 1.2)), INK.lightened(0.25))
canvas.draw_colored_polygon(body, player_color(state.get("player", -1)))
canvas.draw_circle(head, 3.0, SKIN, true, -1.0, true)
for eye in [-0.6, 0.9]:
canvas.draw_circle(head + Vector2(facing * (0.9 + eye), -0.3), 0.5, INK, true, -1.0, true)
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var color := MARKER_NEAR if state.get("reachable", false) else MARKER_FAR
var inner := rect.grow(-1.2)
canvas.draw_rect(inner, INK, false, 2.4, true)
canvas.draw_rect(inner, color, false, 1.2, true)
func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0)
canvas.draw_rect(rect, GROUND)
if share(variant, 1) < 0.5:
var a := rect.position + Vector2(2, 3) + Vector2(share(variant, 2), share(variant, 3)) * 6.0
canvas.draw_polyline(PackedVector2Array([a, a + Vector2(3, 2), a + Vector2(5, 1), a + Vector2(7, 4)]), DEAD_CRACK, 0.8, true)
func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var living: int = state.get("living", 0)
var variant: int = state.get("variant", 0)
canvas.draw_rect(rect, GROUND)
canvas.draw_colored_polygon(rounded_rect(rect, living, 5.0), LIVING)
_edges(canvas, rect, living, 5.0, LIVING_SHADE, LINE)
if share(variant, 1) < 0.4:
var at := rect.position + Vector2(4, 6) + Vector2(share(variant, 2), share(variant, 3)) * 7.0
canvas.draw_polyline(PackedVector2Array([at + Vector2(-1.2, -1.8), at, at + Vector2(1.2, -2.0)]), LIVING_SHADE, 0.8, true)
func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0)
var time: float = state.get("time", 0.0)
var foot := Vector2(rect.get_center().x, rect.end.y - 3.0)
var breathe := 1.0 + 0.06 * sway(time, variant, 1.6)
var blobs: Array[Vector3] = [Vector3(-3.5, -1.5, 3.0), Vector3(0.5, -3.0, 3.6), Vector3(3.8, -1.2, 2.7)]
for blob in blobs:
canvas.draw_circle(foot + Vector2(blob.x, blob.y) * breathe, blob.z * breathe + LINE, INK, true, -1.0, true)
for blob in blobs:
canvas.draw_circle(foot + Vector2(blob.x, blob.y) * breathe, blob.z * breathe, MOSS, true, -1.0, true)
canvas.draw_circle(foot + Vector2(blob.x - 0.8, blob.y - 0.9) * breathe, blob.z * 0.35, MOSS_LIGHT, true, -1.0, true)
for spark in 2:
var glow := 0.5 + 0.5 * sin(time * 2.4 + spark * 2.3 + share(variant, spark) * TAU)
_sparkle(canvas, foot + Vector2(-2.5 + spark * 5.0, -6.5 + spark), 1.8 * glow)
func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
var time: float = state.get("time", 0.0)
if points.size() < 2:
return
var leaves: Array[PackedVector2Array] = []
var count := 9
for i in count:
var part := (i + 0.5) / count
var at := along(points, part)
var ahead := along(points, minf(1.0, part + 0.02))
var side := 1.0 if i % 2 == 0 else -1.0
leaves.append(leaf(at, (ahead - at).angle() + side * 0.9 + 0.25 * sway(time, i, 1.4), 5.0, 2.8))
for blade in leaves:
canvas.draw_polyline(closed(blade), INK, LINE * 1.6, true)
canvas.draw_polyline(points, INK, 2.2 + LINE * 2.0, true)
canvas.draw_polyline(points, VINE, 2.2, true)
for blade in leaves:
canvas.draw_colored_polygon(blade, LEAF)
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var growth: float = state.get("growth", 1.0)
var time: float = state.get("time", 0.0)
var foot := Vector2(rect.get_center().x, rect.end.y - 2.0)
if growth < 0.15:
var seed := ellipse(foot + Vector2(0, -1.2), Vector2(2.2, 1.6))
canvas.draw_colored_polygon(ellipse(foot + Vector2(0, -1.2), Vector2(2.2 + LINE, 1.6 + LINE)), INK)
canvas.draw_colored_polygon(seed, SEED)
return
var grown := (growth - 0.15) / 0.85
var height := lerpf(4.0, 20.0, grown)
canvas.draw_set_transform(foot, 0.07 * sway(time, 3, 1.1), Vector2.ONE)
var leaves: Array[PackedVector2Array] = []
var count := 1 + int(grown * 5.0)
for i in count:
var at := Vector2(0, -height * (0.2 + 0.7 * i / maxf(1.0, count)))
var side := 1.0 if i % 2 == 0 else -1.0
leaves.append(leaf(at, -PI / 2.0 + side * 1.0, lerpf(3.0, 6.0, grown), 2.8))
var tuft := clampf((grown - 0.75) / 0.25, 0.0, 1.0)
var puffs: Array[Vector2] = []
if tuft > 0.0:
for puff in 4:
puffs.append(Vector2(-2.0 + puff * 1.4, -height - 1.0 - (puff % 2) * 1.2))
for blade in leaves:
canvas.draw_polyline(closed(blade), INK, LINE * 1.6, true)
canvas.draw_line(Vector2.ZERO, Vector2(0, -height), INK, 1.6 + LINE * 2.0, true)
for puff in puffs:
canvas.draw_circle(puff, 1.4 * tuft + 0.3 + LINE, INK, true, -1.0, true)
canvas.draw_line(Vector2.ZERO, Vector2(0, -height), STEM, 1.6, true)
for blade in leaves:
canvas.draw_colored_polygon(blade, LEAF)
for puff in puffs:
canvas.draw_circle(puff, 1.4 * tuft + 0.3, FIBRE, true, -1.0, true)
canvas.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
var c := rect.get_center()
var pebble := regular_polygon(c, 2.8, 6, 0.3)
canvas.draw_colored_polygon(regular_polygon(c, 2.8 + LINE, 6, 0.3), INK)
canvas.draw_colored_polygon(pebble, STONE)
canvas.draw_line(c + Vector2(-1.2, -1.0), c + Vector2(0.4, -1.6), Color.WHITE, 0.6, true)
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hop: float = state.get("hop", 1.0)
var facing: float = state.get("facing", 1.0)
var lift := sin(PI * hop) * 4.0
var foot := Vector2(rect.get_center().x, rect.end.y - 2.0)
var body := foot + Vector2(0, -6.0 - lift)
var puffs: Array[Vector3] = [
Vector3(-3.5, 0.5, 3.0), Vector3(0.0, -1.5, 3.4), Vector3(3.2, 0.3, 3.0), Vector3(0.0, 1.8, 3.0),
]
var head := body + Vector2(facing * 5.0, -1.2)
for leg in [-2.5, 2.5]:
canvas.draw_line(body + Vector2(leg, 3.0), body + Vector2(leg, 6.0 - lift * 0.3), INK, 1.6, true)
for puff in puffs:
canvas.draw_circle(body + Vector2(puff.x, puff.y), puff.z + LINE, INK, true, -1.0, true)
canvas.draw_colored_polygon(ellipse(head, Vector2(2.4 + LINE, 2.0 + LINE)), INK)
for puff in puffs:
canvas.draw_circle(body + Vector2(puff.x, puff.y), puff.z, WOOL, true, -1.0, true)
canvas.draw_colored_polygon(ellipse(head, Vector2(2.4, 2.0)), FACE)
canvas.draw_circle(head + Vector2(facing * 0.9, -0.4), 0.6, WOOL, true, -1.0, true)
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var awake: float = state.get("awake", 0.0)
var time: float = state.get("time", 0.0)
var tile := rect.size.x / 3.0
var floor_y := rect.end.y - 3.0
var center := Vector2(rect.get_center().x, floor_y - tile * 1.2)
var opening := Rect2(rect.position.x + tile * 0.75, rect.position.y + tile * 0.9, tile * 1.5, floor_y - rect.position.y - tile * 0.9)
canvas.draw_rect(opening, RUIN_DARK)
# Rays of the waking grove.
for ray in 8:
var direction := Vector2.from_angle(TAU * ray / 8.0 + time * 0.3)
var glow := GLOW
glow.a = awake * 0.7
canvas.draw_line(center + direction * 4.0, center + direction * (6.0 + 3.0 * awake), glow, 1.0, true)
canvas.draw_circle(center, 3.5 + LINE, INK, true, -1.0, true)
var core := GLOW.lerp(Color.WHITE, 0.4 * awake)
canvas.draw_circle(center, 3.5, core, true, -1.0, true)
var blocks: Array[Rect2] = [
Rect2(rect.position.x, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6),
Rect2(rect.end.x - tile * 0.75, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6),
Rect2(rect.position.x - 1.0, rect.position.y + tile * 0.25, rect.size.x + 2.0, tile * 0.6),
]
for block in blocks:
canvas.draw_rect(block, RUIN)
canvas.draw_rect(Rect2(block.position.x, block.end.y - 2.0, block.size.x, 2.0), RUIN_SHADE)
canvas.draw_rect(block, INK, false, LINE, true)
canvas.draw_rect(Rect2(rect.position.x + tile * 0.4, floor_y - 2.0, rect.size.x - tile * 0.8, 3.0), RUIN_SHADE)
canvas.draw_rect(Rect2(rect.position.x + tile * 0.4, floor_y - 2.0, rect.size.x - tile * 0.8, 3.0), INK, false, LINE, true)
## A mass of rock: ground where corners are cut round, the fill, a darker
## band along its lower open edge and the outline where it meets the ground.
func _mass(canvas: CanvasItem, rect: Rect2, solid: int, fill: Color, shade: Color) -> void:
canvas.draw_rect(rect, GROUND)
canvas.draw_colored_polygon(rounded_rect(rect, solid, 4.0), fill)
if solid & TileShape.S == 0:
# The band is as high as the corners are round, so that it can take
# the round lower corners of the tile.
var band := Rect2(rect.position.x, rect.end.y - 4.0, rect.size.x, 4.0)
canvas.draw_colored_polygon(rounded_rect(band, solid | TileShape.N, 4.0), shade)
_edges(canvas, rect, solid, 4.0, INK, LINE)
## The outline of a mass of tiles where it meets open ground: along every
## open side, round around open corners and filled into inner corners, all
## inside `rect`, so that tiles drawn later cannot cover half of it.
func _edges(canvas: CanvasItem, rect: Rect2, mask: int, radius: float, color: Color, width: float) -> void:
var half := width / 2.0
var inner := inset_open_sides(rect, mask, half)
var r := maxf(0.0, radius - half)
var round_tl := mask & (TileShape.N | TileShape.W) == 0
var round_tr := mask & (TileShape.N | TileShape.E) == 0
var round_br := mask & (TileShape.S | TileShape.E) == 0
var round_bl := mask & (TileShape.S | TileShape.W) == 0
if mask & TileShape.N == 0:
canvas.draw_line(Vector2(inner.position.x + (r if round_tl else 0.0), inner.position.y), Vector2(inner.end.x - (r if round_tr else 0.0), inner.position.y), color, width, true)
if mask & TileShape.S == 0:
canvas.draw_line(Vector2(inner.position.x + (r if round_bl else 0.0), inner.end.y), Vector2(inner.end.x - (r if round_br else 0.0), inner.end.y), color, width, true)
if mask & TileShape.W == 0:
canvas.draw_line(Vector2(inner.position.x, inner.position.y + (r if round_tl else 0.0)), Vector2(inner.position.x, inner.end.y - (r if round_bl else 0.0)), color, width, true)
if mask & TileShape.E == 0:
canvas.draw_line(Vector2(inner.end.x, inner.position.y + (r if round_tr else 0.0)), Vector2(inner.end.x, inner.end.y - (r if round_br else 0.0)), color, width, true)
if round_tl:
canvas.draw_arc(inner.position + Vector2(r, r), r, PI, 1.5 * PI, 6, color, width, true)
if round_tr:
canvas.draw_arc(Vector2(inner.end.x - r, inner.position.y + r), r, 1.5 * PI, TAU, 6, color, width, true)
if round_br:
canvas.draw_arc(inner.end - Vector2(r, r), r, 0.0, 0.5 * PI, 6, color, width, true)
if round_bl:
canvas.draw_arc(Vector2(inner.position.x + r, inner.end.y - r), r, 0.5 * PI, PI, 6, color, width, true)
# Inner corners: both sides join neighbours, but the tile across the
# corner is open, so the outlines of the neighbours meet here.
var corners: Array[Array] = [
[TileShape.N | TileShape.W, TileShape.NW, rect.position],
[TileShape.N | TileShape.E, TileShape.NE, Vector2(rect.end.x - width, rect.position.y)],
[TileShape.S | TileShape.E, TileShape.SE, rect.end - Vector2(width, width)],
[TileShape.S | TileShape.W, TileShape.SW, Vector2(rect.position.x, rect.end.y - width)],
]
for corner in corners:
var sides: int = corner[0]
var across: int = corner[1]
if mask & sides == sides and mask & across == 0:
canvas.draw_rect(Rect2(corner[2], Vector2(width, width)), color)
## A four-pointed sparkle of `size` around `at`.
func _sparkle(canvas: CanvasItem, at: Vector2, size: float) -> void:
if size <= 0.1:
return
var star := PackedVector2Array([
at + Vector2(0, -size), at + Vector2(size * 0.3, -size * 0.3), at + Vector2(size, 0),
at + Vector2(size * 0.3, size * 0.3), at + Vector2(0, size), at + Vector2(-size * 0.3, size * 0.3),
at + Vector2(-size, 0), at + Vector2(-size * 0.3, -size * 0.3),
])
canvas.draw_colored_polygon(star, SPARK)
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `134 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
- [ ] **Step 5: Bildschirmfotos ansehen**
Run:
```bash
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-kontur-karte.png" --style=outline
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-kontur-vitrine.png" --style=outline --showcase
```
Expected: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Nirgends steht mehr ein Platzhalter.
- Spiel: Ockerfarbener Boden mit feinen Punkten. Fels ist hell lila, außen abgerundet, mit dunklem Umriss nur zum Boden hin, einem dunkleren Band an der Unterkante und kleinen hellen Strichen. Im getroffenen Fels steht ein gezackter dunkler Riss mit Splittern. Die Figuren haben einen dunklen Umriss, einen roten Körper und einen hellen Kopf. Über der linken steht scharf „Spieler“.
- Vitrine: Harter Fels kräftig blau, weicher Fels hell lila, Grundgestein dunkelbraun, alles mit dunklem Umriss. Toter Boden mit kleinen Rissen, lebendiger Boden kräftig grün mit hellen Häkchen. Moos mit gelben Funken, eine Ranke mit Blättern und Steinen, die Faserpflanze, ein Schaf und die Ruine als Tor mit gelber Sonne, jedes Ding mit dunklem Umriss. Zwei Figuren in Rot und Blau. Oben „Stilprobe: Kontur“, unten ist „2 Kontur“ ausgegraut.
- [ ] **Step 6: Alles prüfen**
Run:
```bash
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**
```bash
git add game
git commit -F - <<'EOF'
Stil Kontur
Kräftige dunkle Umrisse um flache, starke Farben, gut lesbar wie ein
Comic. Felsmassen haben ihren Umriss nur zum offenen Boden hin, jedes
Ding hat einen Umriss um seine ganze Form, und Lebendiges funkelt gelb.
Jeder Spieler hat eine eigene Farbe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push
```
---
### Task 7: Stil Geometrisch
Schlichte geometrische Formen in ruhigen Pastellfarben, ordentlich und still: Quadrate, Dreiecke, Kreise und Sechsecke, ohne Umriss und ohne Schatten. Muster folgen dem Raster, das Feld wirkt geordnet.
**Files:**
- Modify: `game/scripts/art/geometric_style.gd` (ganze Datei ersetzen)
- Test: `game/tests/test_geometric_style.gd` (neu)
**Interfaces:**
- Consumes: `ArtStyle` mit seinen Zeichenmethoden und Helfern, `TileShape`, `Art.IDS`, die Einträge von `state` laut Kopf von `art_style.gd`
- Produces: `GeometricStyle` zeichnet jede Kennung selbst; `PLAYER_COLORS: Array[Color]` mit acht Farben und `player_color(player: int) -> Color`
- [ ] **Step 1: Fehlschlagenden Test schreiben**
Create `game/tests/test_geometric_style.gd`:
```gdscript
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := GeometricStyle.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: Test ausführen und Fehlschlag sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 1 und `134 passed, 1 failed`; unter `FAIL test_geometric_style.gd test_four_players_have_four_colours` steht `four players, four colours: expected 4, got 2`.
- [ ] **Step 3: Stil schreiben**
Ersetze den gesamten Inhalt von `game/scripts/art/geometric_style.gd` durch:
```gdscript
class_name GeometricStyle
extends ArtStyle
## Plain geometric forms in calm pastel colours, tidy and quiet: squares,
## triangles, circles and hexagons, without outlines or shadows. Patterns
## follow the grid, so the field looks ordered.
const KEY := &"geometric"
const GROUND := Color("ead9b3")
const GROUND_DOT := Color("dcc699")
const SOFT_LIGHT := Color("c6d0e2")
const SOFT_DARK := Color("aebad0")
const HARD := Color("8d94c7")
const HARD_HEX := Color("a8aedc")
const BEDROCK := Color("4a4e69")
const LINE := Color("5b6181")
const DEAD_MARK := Color("cdb78c")
const LIVING := Color("b9e4c7")
const LIVING_DOT := Color("9bd4ae")
const MOSS_DARK := Color("4f9e6c")
const MOSS_LIGHT := Color("7cc493")
const VINE := Color("4c946a")
const LEAF := Color("6fbb88")
const STONE := Color("aab6cd")
const FIBRE := Color("fffaf0")
const SEED := Color("c49b74")
const BODY := Color("f6f2e9")
const FACE := Color("6d6875")
const RUIN := Color("bab5ca")
const RUIN_DARK := Color("5f5b6f")
const GLOW := Color("f6d77e")
const SKIN := Color("f2d4bf")
const MARKER_NEAR := Color("7fca97")
const MARKER_FAR := Color("ef8b7e")
const PLAYER_COLORS: Array[Color] = [
Color("e9665f"), Color("4f8fcc"), Color("ef9f2f"), Color("4fa870"),
Color("9a7bd8"), Color("2fa9a3"), Color("e27aae"), Color("6f7fd6"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_GEOMETRIC"
func player_color(player: int) -> Color:
return PLAYER_COLORS[maxi(0, player) % PLAYER_COLORS.size()]
func _ground(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, GROUND)
if _lattice(rect, 3):
canvas.draw_circle(rect.get_center(), 1.1, GROUND_DOT, true, -1.0, true)
func _soft_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, SOFT_LIGHT)
var tile := _tile(rect)
var corner := rect.position if (tile.x + tile.y) % 2 == 0 else Vector2(rect.end.x, rect.position.y)
var opposite := rect.end if (tile.x + tile.y) % 2 == 0 else Vector2(rect.position.x, rect.end.y)
canvas.draw_colored_polygon(PackedVector2Array([corner, opposite, Vector2(corner.x, opposite.y)]), SOFT_DARK)
func _hard_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, HARD)
canvas.draw_colored_polygon(regular_polygon(rect.get_center(), rect.size.x * 0.38, 6, PI / 6.0), HARD_HEX)
func _bedrock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, BEDROCK)
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hits: int = state.get("hits", 1)
var inner := rect.grow(-3.5)
canvas.draw_line(inner.position, inner.end, LINE, 1.0, true)
if hits >= 2:
canvas.draw_line(Vector2(inner.end.x, inner.position.y), Vector2(inner.position.x, inner.end.y), LINE, 1.0, true)
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var age: float = state.get("age", 0.0)
var c := rect.get_center()
for chip in 4:
var direction := Vector2.from_angle(TAU * chip / 4.0 + PI / 4.0)
var at := c + direction * (2.0 + 8.0 * age)
var color := SOFT_DARK
color.a = 1.0 - age
canvas.draw_colored_polygon(regular_polygon(at, 1.8 * (1.0 - age * 0.5), 4, age * 4.0), color)
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var facing: float = state.get("facing", 1.0)
var step: float = state.get("step", 0.0)
var moving: bool = state.get("moving", false)
var swing := sin(step) if moving else 0.0
var c := rect.get_center() + Vector2(0, -absf(swing))
for side in [-1.0, 1.0]:
var foot := c + Vector2(side * 1.8 + side * swing * 1.0, 5.2)
canvas.draw_rect(Rect2(foot - Vector2(1.0, 1.0), Vector2(2.0, 2.0)), FACE)
var body := PackedVector2Array([c + Vector2(0, -3.0), c + Vector2(4.3, 4.8), c + Vector2(-4.3, 4.8)])
canvas.draw_colored_polygon(body, player_color(state.get("player", -1)))
var head := c + Vector2(0, -5.0)
canvas.draw_circle(head, 3.0, SKIN, true, -1.0, true)
canvas.draw_circle(head + Vector2(facing * 1.2, -0.2), 0.55, FACE, true, -1.0, true)
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var color := MARKER_NEAR if state.get("reachable", false) else MARKER_FAR
var size := 4.0
var corners: Array[Array] = [
[rect.position, Vector2(1, 1)],
[Vector2(rect.end.x, rect.position.y), Vector2(-1, 1)],
[rect.end, Vector2(-1, -1)],
[Vector2(rect.position.x, rect.end.y), Vector2(1, -1)],
]
for corner in corners:
var at: Vector2 = corner[0]
var inward: Vector2 = corner[1]
canvas.draw_colored_polygon(PackedVector2Array([at, at + Vector2(inward.x * size, 0), at + Vector2(0, inward.y * size)]), color)
func _dead_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, GROUND)
if _lattice(rect, 4):
var c := rect.get_center()
canvas.draw_line(c - Vector2(1.6, 1.6), c + Vector2(1.6, 1.6), DEAD_MARK, 0.8, true)
canvas.draw_line(c + Vector2(1.6, -1.6), c + Vector2(-1.6, 1.6), DEAD_MARK, 0.8, true)
func _living_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, LIVING)
if _lattice(rect, 2):
canvas.draw_circle(rect.get_center(), 1.2, LIVING_DOT, true, -1.0, true)
func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0)
var time: float = state.get("time", 0.0)
var foot := Vector2(rect.get_center().x, rect.end.y - 4.0)
var breathe := 1.0 + 0.08 * sway(time, variant, 1.6)
canvas.draw_circle(foot + Vector2(-3.0, 0.0), 2.6 * breathe, MOSS_DARK, true, -1.0, true)
canvas.draw_circle(foot + Vector2(2.6, 0.4), 2.2 * breathe, MOSS_DARK, true, -1.0, true)
canvas.draw_circle(foot + Vector2(0.0, -2.6), 3.0 * breathe, MOSS_LIGHT, true, -1.0, true)
func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
var time: float = state.get("time", 0.0)
if points.size() < 2:
return
canvas.draw_polyline(points, VINE, 1.0, true)
var beads := 24
for i in beads + 1:
var part := float(i) / beads
var at := along(points, part)
if i % 3 == 1:
var ahead := along(points, minf(1.0, part + 0.02))
var side := 1.0 if i % 2 == 0 else -1.0
var angle := (ahead - at).angle() + side * (PI / 2.0) + 0.2 * sway(time, i, 1.4)
var tip := at + Vector2.from_angle(angle) * 4.0
var base := Vector2.from_angle(angle).orthogonal() * 1.3
canvas.draw_colored_polygon(PackedVector2Array([at + base, tip, at - base]), LEAF)
canvas.draw_circle(at, 1.4, VINE, true, -1.0, true)
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var growth: float = state.get("growth", 1.0)
var time: float = state.get("time", 0.0)
var foot := Vector2(rect.get_center().x, rect.end.y - 2.0)
if growth < 0.15:
canvas.draw_circle(foot + Vector2(0, -1.5), 1.8, SEED, true, -1.0, true)
return
var grown := (growth - 0.15) / 0.85
var height := lerpf(4.0, 20.0, grown)
canvas.draw_set_transform(foot, 0.06 * sway(time, 3, 1.1), Vector2.ONE)
canvas.draw_line(Vector2.ZERO, Vector2(0, -height), VINE, 1.2, true)
var leaves := 1 + int(grown * 5.0)
for i in leaves:
var at := Vector2(0, -height * (0.2 + 0.7 * i / maxf(1.0, leaves)))
var side := 1.0 if i % 2 == 0 else -1.0
var size := lerpf(2.5, 4.5, grown)
canvas.draw_colored_polygon(PackedVector2Array([at, at + Vector2(side * size, -size * 0.6), at + Vector2(0, -size * 0.9)]), LEAF)
if grown > 0.75:
var tuft := (grown - 0.75) / 0.25
for puff in 3:
canvas.draw_circle(Vector2(-1.6 + puff * 1.6, -height - 1.0), 1.3 * tuft + 0.2, FIBRE, true, -1.0, true)
canvas.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_colored_polygon(regular_polygon(rect.get_center(), 2.8, 4, PI / 4.0), STONE)
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hop: float = state.get("hop", 1.0)
var facing: float = state.get("facing", 1.0)
var lift := sin(PI * hop) * 4.0
var foot := Vector2(rect.get_center().x, rect.end.y - 2.0)
var body := foot + Vector2(0, -5.5 - lift)
for leg in [-2.0, 2.0]:
canvas.draw_line(body + Vector2(leg, 2.5), body + Vector2(leg, 5.5 - lift * 0.3), FACE, 1.2, true)
canvas.draw_circle(body, 4.4, BODY, true, -1.0, true)
canvas.draw_circle(body + Vector2(facing * 4.6, -1.6), 2.2, FACE, true, -1.0, true)
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var awake: float = state.get("awake", 0.0)
var tile := rect.size.x / 3.0
var floor_y := rect.end.y - 3.0
var center := Vector2(rect.get_center().x, floor_y - tile * 1.2)
canvas.draw_rect(Rect2(rect.position.x + tile * 0.75, rect.position.y + tile * 0.9, tile * 1.5, floor_y - rect.position.y - tile * 0.9), RUIN_DARK)
for ring in 3:
var glow := GLOW
glow.a = awake * (0.35 + 0.3 * ring)
canvas.draw_circle(center, 8.5 - ring * 2.4, glow, true, -1.0, true)
canvas.draw_rect(Rect2(rect.position.x, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6), RUIN)
canvas.draw_rect(Rect2(rect.end.x - tile * 0.75, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6), RUIN)
canvas.draw_rect(Rect2(rect.position.x - 1.0, rect.position.y + tile * 0.25, rect.size.x + 2.0, tile * 0.6), RUIN)
canvas.draw_colored_polygon(PackedVector2Array([
Vector2(rect.position.x - 1.0, rect.position.y + tile * 0.25), Vector2(rect.get_center().x, rect.position.y - tile * 0.3),
Vector2(rect.end.x + 1.0, rect.position.y + tile * 0.25),
]), RUIN)
## The tile that `rect` covers.
static func _tile(rect: Rect2) -> Vector2i:
return Vector2i(rect.position / GridMotion.TILE_SIZE)
## True for every `every`th tile along both diagonals, which makes an even,
## open pattern across the field.
static func _lattice(rect: Rect2, every: int) -> bool:
var tile := _tile(rect)
return posmod(tile.x + tile.y * 2, every) == 0 and posmod(tile.x - tile.y, 2) == 0
```
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
Run:
```bash
godot --headless --path game --import
godot --headless --path game -s res://tests/run_tests.gd
```
Expected: Exit-Code 0 und `135 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
- [ ] **Step 5: Bildschirmfotos ansehen**
Run:
```bash
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-geometrisch-karte.png" --style=geometric
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-geometrisch-vitrine.png" --style=geometric --showcase
```
Expected: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Nirgends steht mehr ein Platzhalter.
- Spiel: Heller Sandboden mit kleinen Rauten in einem regelmäßigen Raster. Weicher Fels besteht aus hellblauen und graublauen Dreiecken, ohne Umriss und ohne Schatten. Im getroffenen Fels steht ein dünner dunkler Strich als Riss. Die Figuren sind rote Dreiecke mit rundem Kopf. Über der linken steht scharf „Spieler“.
- Vitrine: Grundgestein dunkel, weicher Fels aus Dreiecken, harter Fels lila mit hellen Sechsecken. Toter Boden mit kleinen Kreuzen im Raster, lebendiger Boden mintgrün mit Punkten. Moos aus Kreisen, die Ranke als Perlenschnur mit eckigen Steinen, die Faserpflanze, ein Schaf, die Ruine als Haus mit Giebel und goldenem Leuchten. Zwei Figuren als rotes und blaues Dreieck. Oben „Stilprobe: Geometrisch“, unten ist „3 Geometrisch“ ausgegraut.
- [ ] **Step 6: Alles prüfen**
Run:
```bash
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**
```bash
git add game
git commit -F - <<'EOF'
Stil Geometrisch
Schlichte Formen in ruhigen Pastellfarben, ohne Umriss und ohne
Schatten: Dreiecke für weichen Fels, Sechsecke für harten, Kreise für
Moos und eine Perlenschnur als Ranke. Muster folgen dem Raster, und
jeder Spieler hat eine eigene Farbe.
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/scherenschnitt-karte.png`, `scherenschnitt-vitrine.png`, `kontur-karte.png`, `kontur-vitrine.png`, `geometrisch-karte.png`, `geometrisch-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:
````markdown
# 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.
- Ein Stil ist eine Unterklasse von `ArtStyle` und zeichnet jede Kennung aus Code: flach, klar, Bewegung aus Programmierung (Konzept 7.4). Zur Wahl stehen Scherenschnitt, Kontur und Geometrisch. 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 jedem Stil; sonst zeichnet `ArtStyle` nur einen Platzhalter. `game/tests/test_art.gd` lässt jeden Stil jede Kennung zeichnen.
- Ein Bild `game/art/<Kennung>.png` ersetzt die Zeichnung seiner Kennung, ohne dass sich Code ändert. So kann ein Artist Grafik austauschen.
- 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.
- Der Renderer der Hülle kennt kein MSAA in 2D. Glatte Kanten entstehen über die Zeichenbefehle mit `antialiased`, etwa bei Kreisen und Linien.
## 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 `papercut`, `outline` oder `geometric` 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.
```bash
mkdir -p docs/playtests/m1d
OUT="$(pwd -W)/docs/playtests/m1d"
for pair in papercut:scherenschnitt outline:kontur geometric:geometrisch; 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 `geometrisch-karte.png`, `geometrisch-vitrine.png`, `kontur-karte.png`, `kontur-vitrine.png`, `scherenschnitt-karte.png` und `scherenschnitt-vitrine.png`, jede zwischen 40 und 120 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 in Step 5 der Tasks 5, 6 und 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`:
```markdown
# Probelauf M1d: Stilproben
M1d zeigt das Spielfeld in drei Stilen, alle aus Code gezeichnet: Scherenschnitt, Kontur und Geometrisch. Du wählst einen davon für das ganze Spiel. Das ist 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:
| Scherenschnitt | Kontur | Geometrisch |
|---|---|---|
| ![Scherenschnitt, Vitrine](m1d/scherenschnitt-vitrine.png) | ![Kontur, Vitrine](m1d/kontur-vitrine.png) | ![Geometrisch, Vitrine](m1d/geometrisch-vitrine.png) |
| ![Scherenschnitt, Spiel](m1d/scherenschnitt-karte.png) | ![Kontur, Spiel](m1d/kontur-karte.png) | ![Geometrisch, Spiel](m1d/geometrisch-karte.png) |
## Fragen
1. Welcher Stil soll es sein: Scherenschnitt, Kontur oder Geometrisch?
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?
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: Wiegen, Wachsen, Hüpfen und die Schritte der Figur? Oder ist es zu viel oder zu wenig?
7. Findest du deine Figur sofort, auch neben anderen?
8. Wirkt ein Schlag als Gast jetzt sofort? Stört die Verzögerung noch?
Antworte formlos im Chat oder als Kommentar in Gitea.
```
- [ ] **Step 5: Alles prüfen**
Run:
```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
```
Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests; der GDScript-Lauf endet mit Exit-Code 0 und `135 passed, 0 failed`
- [ ] **Step 6: Commit und Hochladen**
```bash
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 Grafik entsteht: Kennungen, Stile, 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
```