5e9836c64a
Nach der ersten Vorschau soll das Spielfeld Pixelgrafik im Stil klassischer Farmspiele zeigen, aus Code gezeichnet. Konzept 7.4 und 11 sagen das jetzt. Der Plan behält Kennungen, Kartenblöcke, Vitrine und Stilwechsel und zeichnet statt der drei Vektorstile Pixelgrafik in drei Abwandlungen: Warm, Pastell und Glimmen. Warm war nach der Probe der Favorit. Jede Task-Grenze wurde erneut vorab in einer Kopie von main geprüft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5721 lines
229 KiB
Markdown
5721 lines
229 KiB
Markdown
# M1d Stilproben Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Das Spielfeld bekommt Pixelgrafik aus Code im Stil klassischer Farmspiele, in drei Abwandlungen: Warm, Pastell und Glimmen. Eine Vitrine zeigt jeden Stil zusammen mit Dingen späterer Meilensteine, F2 wechselt den Stil im Spiel, und das Spiel merkt sich die Wahl. Damit lässt sich die Prüffrage von Meilenstein 1 beantworten: „Welche der drei Stilproben soll es sein?“
|
||
|
||
**Architecture:** Alles auf dem Spielfeld zeichnet `Art`. Jede Grafik hat eine Kennung wie `terrain/soft_rock`; der gewählte Stil zeichnet sie, und ein Bild `game/art/<Kennung>.png` ersetzt die Zeichnung, ohne dass sich Code ändert. `PixelStyle` zeichnet jede Kennung Pixel für Pixel in ein kleines Bild, 16 × 16 Pixel pro Kachel, und hebt es auf; die drei Stile wählen nur Palette, Umrisse und Leuchten. `TileShape` gibt jeder Kachel ihre acht Nachbarn, damit Felsmassen außen rund und innen geschlossen sind und zum offenen Boden hin ihre Vorderkante zeigen. `MapView` zeichnet die Karte in Blöcken und nach einem Treffer nur die Blöcke um die Kachel neu. Kern und Brücke bleiben unverändert, denn Grafik ändert nichts an der gemeinsamen Welt.
|
||
|
||
**Tech Stack:** Godot 4.7.2 (Standard-Ausgabe, Renderer „GL Compatibility“) mit Texturfilterung „Nearest“ und Einrasten auf ganze Pixel, GDScript mit Typangaben, `Image` und `ImageTexture` für die Pixelbilder. Rust 1.98.1 und godot-rust 0.5.5 bleiben unverändert.
|
||
|
||
**Spec:** `docs/KONZEPT.md`, Abschnitt 7.4 („Spielfeld aus Code“ als Pixelgrafik, „Austauschbar“), Abschnitt 8 (Meilenstein 1 mit der Prüffrage „Welche der drei Stilproben soll es sein?“) und Abschnitt 11 („Grafikstil“ und das Risiko „Grafik aus Code kann nüchtern wirken“). Abschnitt 7.4 und 11 wurden mit diesem Plan geändert: Nach der ersten Vorschau hast du dir Pixelgrafik im Stil von Stardew Valley gewünscht, nur abgewandelt, aus Code und in drei Abwandlungen; nach der Pixel-Probe war Warm dein Favorit. Dazu kommen die Antworten aus dem Probelauf von M1c im Kommentar zu Gitea #8.
|
||
|
||
## Einordnung
|
||
|
||
| Plan | Inhalt | Stand |
|
||
|---|---|---|
|
||
| M1a Rust-Kern | Deterministischer Kern mit Karte, Spielern, Befehlen, Speicherstand, Aufzeichnung | fertig, in `main` |
|
||
| M1b Brücke und Hülle | GDExtension, Godot-Hülle, Takt, Figur, Speichern und Laden | fertig, in `main` |
|
||
| M1c Gleichschritt im Netz | Koop bis vier Spieler, Host-Welt, Prüfsummen, zwei Instanzen im Gleichschritt | fertig, in `main` |
|
||
| **M1d Stilproben** | Dieser Plan | offen |
|
||
|
||
Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prüffrage.
|
||
|
||
## Was dieser Plan erledigt
|
||
|
||
| Punkt | Task |
|
||
|---|---|
|
||
| Konzept 7.4: Jede Grafik hängt an einer Kennung; ein Artist kann sie ersetzen, ohne dass sich Code ändert | 1: `Art` mit 16 Kennungen und Bildern unter `game/art/` |
|
||
| Konzept 7.4: Spielfeld aus Code als Pixelgrafik, Bewegung durch Programmierung, alle Kachelvarianten passen aneinander | 1 und 2: Nachbarmasken und Blöcke; 5 und 6: Pixelgrafik |
|
||
| Konzept 8 und 11: drei Stilproben zur Wahl | 3: Vitrine; 4: Wechsel mit F2 und über die Befehlszeile; 5 bis 7: Warm, Pastell und Glimmen; 8: Fragebogen mit Bildern |
|
||
| Konzept 11, Risiko „Grafik aus Code kann nüchtern wirken“ | 3 und 6: Die Vitrine zeigt lebendigen Boden, Moos, Ranke, Pflanze, Tier und Ruine in Bewegung |
|
||
| Probelauf M1c, Frage 2: Schläge als Gast sind sichtbar verzögert | 2: Der eigene Schlag zeigt seine Splitter beim Klick |
|
||
| Probelauf M1c, Frage 4: Namen sind unscharf | 2: Namen in Bildschirmgröße |
|
||
| #8: „nach höchstens `NetLink.TIMEOUT_MSEC`“ in `CLAUDE.md` | 8: Wortlaut „nach etwa“. Der Test über `get_statistic` bleibt in #8 offen. |
|
||
|
||
## Nicht Teil dieses Plans
|
||
|
||
- Regeln für lebendigen Boden, Moos, Ranken, Pflanzen, Tiere und Ruinen. Sie kommen ab Meilenstein 2. Die Vitrine bewegt ihre Dinge nach festen Formeln der Hülle; der Kern weiß nichts von ihnen.
|
||
- Bilder von einem Artist, Porträts, Titelbild und Ton (Konzept 7.4, Meilenstein 6)
|
||
- Bilder unter `game/art/` mit mehreren Einzelbildern für Kachelränder oder Bewegung. Ein Bild ersetzt bisher nur eine feste Zeichnung.
|
||
- Das Entfernen der Stile, die du nicht wählst, und das Feinjustieren des gewählten Stils. Beides folgt nach deinem Probelauf.
|
||
- Die übrigen Punkte aus #5 und #8
|
||
|
||
## Technische Festlegungen
|
||
|
||
| Thema | Festlegung | Grund |
|
||
|---|---|---|
|
||
| Ort | Nur die Hülle ändert sich, dazu `CLAUDE.md` und `docs/`. | Grafik ändert nichts an der gemeinsamen Welt. |
|
||
| Richtung | Pixelgrafik aus Code im Stil klassischer Farmspiele wie Stardew Valley, aber eigenständig. Die Grafiken von Stardew Valley werden nicht nachgebaut, nur der Charakter übernommen. | Deine Wahl nach der ersten Vorschau; Konzept 7.4 |
|
||
| Abwandlungen | Warm: warme, satte Farben und dunkle Umrisse; der Standard. Pastell: weiche, helle Farben, Umrisse in einem dunkleren Ton der Fläche. Glimmen: eine gedämpfte, kühle tote Welt, in der alles Lebendige kräftig ist und leuchtet. | Deine Wahl; nach der Probe war Warm dein Favorit |
|
||
| Szene | Die Testkarte im Spiel und eine Vitrine mit Dingen späterer Meilensteine | Deine Wahl vor diesem Plan. Auf der Testkarte allein ließe sich nicht beurteilen, wie Lebendiges aussieht. |
|
||
| Kennungen | 16 Kennungen in `Art.IDS`. Für das Spiel: Boden, weicher Fels, harter Fels, Grundgestein, Risse, Splitter, Figur, Zielmarke. Für die Vitrine: toter und lebendiger Boden, Moos, Ranke, Faserpflanze, Stein, Weidetier, Ruine. | Konzept 7.4: Jede Grafik hängt an einer Kennung. |
|
||
| Austausch | `Art.load_overrides` lädt beim Start jedes Bild `game/art/<Kennung>.png`; es ersetzt die Zeichnung seiner Kennung. | Ein Artist kann Grafik austauschen, ohne dass sich Code ändert. |
|
||
| Stile | `ArtStyle` zeichnet schlichte Platzhalter. `PixelStyle` überschreibt jede Zeichnung mit Pixelgrafik; `WarmStyle`, `PastelStyle` und `GlowStyle` setzen nur Palette, Spielerfarben, Art der Umrisse und Stärke des Leuchtens. | Eine neue Grafik wird einmal gezeichnet und gilt in allen Abwandlungen. Fehlt einer Kennung die Zeichnung, fehlt nur ihr Aussehen; das Spiel läuft weiter. |
|
||
| Pixel | 16 × 16 Pixel pro Kachel, dreifach vergrößert. `project.godot` stellt die Texturfilterung auf „Nearest“ und rastet 2D-Transformationen auf ganze Bildschirmpixel ein; die Stile setzen jedes Bild auf ganze Pixel. | Sonst würden Pixel unscharf oder verschieden breit. |
|
||
| Bilder | `PixelStyle` macht jede Grafik einmal als kleines Bild (`Image`, daraus `ImageTexture`) und hebt es unter einem Schlüssel aus Kennung, Nachbarn, Abwandlung und Bewegungsschritt auf. Bewegung besteht aus wenigen Bildern: vier Laufbilder, sechs Wachstumsstufen, sechs Bilder für die Splitter, vier für das Funkeln des Mooses. | Konzept 7.4: Bewegung durch Programmierung. Gemessen: Das erste Zeichnen eines Stils dauert einmalig rund 0,1 Sekunden, danach braucht ein Bild etwa 4 ms. |
|
||
| Sicht | Schräg von vorn: Fels zeigt zum offenen Boden hin eine 6 Pixel hohe Vorderkante und wirft einen Schatten auf den Boden darunter. Figuren sind 16 × 24 Pixel groß und stehen mit den Füßen knapp unter ihrem Körper. | Wie in klassischen Farmspielen. Die Zielmarke deckt weiter genau die Kachel. |
|
||
| Zustand | Was eine Grafik zeigt, steht im Wörterbuch `state`, etwa die Nachbarn einer Kachel, die Zahl der Treffer, das Alter der Splitter oder wie weit eine Pflanze gewachsen ist. Die Einträge stehen im Kopf von `art_style.gd`. | Stile und Aufrufer kennen nur Kennung und Zustand, nicht einander. |
|
||
| Kachelformen | `TileShape.mask` beschreibt die acht Nachbarn einer Kachel als Bitmaske, im Uhrzeigersinn ab Norden; jenseits des Kartenrands zählt alles als Fels. `PixelStyle` beachtet eine Ecke nur, wenn beide Seiten daneben gesetzt sind, und kommt so mit höchstens 47 Formen je Felsart aus. | Konzept 7.4: Alle Kachelvarianten passen aneinander. |
|
||
| Zeichnen der Karte | Blöcke von 16 × 16 Kacheln. Ein Treffer zeichnet nur die Blöcke um seine Kachel neu. | Die ganze Karte neu zu zeichnen dauerte 18 bis 40 ms, zu lang für jeden Treffer. Gemessen nach dem Umbau: rund 9 ms für das ganze Bild mit einem Treffer, 35 bis 40 ms für das Bild nach einem Stilwechsel. |
|
||
| Eigener Schlag | Die Splitter erscheinen beim Klick. Risse und Abbau kommen weiter mit dem Takt, die Splitter anderer Spieler auch. | Probelauf M1c: Als Gast kam der Schlag erst nach der Rundreise zum Host zurück. |
|
||
| Namen | Die Namen über fremden Figuren werden in der Größe gezeichnet, die sie auf dem Bildschirm haben, und dann verkleinert. | Probelauf M1c: In 8 Pixeln gezeichnet und von der Kamera dreifach vergrößert, wirkten sie unscharf. |
|
||
| Wechsel und Wahl | F2 wechselt im Spiel. In der Vitrine wechseln die Tasten 1 bis 3, F2 und Knöpfe. `--style=NAME` wählt beim Start. Das Spiel merkt sich die Wahl in den Einstellungen, Abschnitt `display`, Schlüssel `style`. | Du vergleichst schnell und spielst im gewählten Stil weiter. |
|
||
| Esc | Esc pausiert im Spiel (`toggle_pause`) und führt in der Vitrine zurück (`back`). | Die Vitrine öffnet sich nur aus dem Menü, also nie während eines laufenden Spiels. |
|
||
| Vitrine | Eine eigene Ansicht aus dem Startmenü: 20 × 10 Kacheln, dreifach vergrößert, mit Beschriftungen. Die Pflanze wächst in 6 Sekunden, das Tier hüpft alle 1,2 Sekunden, drei Steine wandern die Ranke entlang, die Ruine erwacht und döst. | Die Prüffrage braucht Dinge, die es erst später gibt. |
|
||
| Spielerfarben | Jeder Stil hat acht Spielerfarben. Spieler n trägt Farbe n mod 8, ein Spieler, der noch nicht in der Welt ist, die erste. | Bis zu vier Spieler gleichzeitig unterscheiden sich sicher. |
|
||
|
||
## Global Constraints
|
||
|
||
- Arbeitsverzeichnis ist die Wurzel des Archivs: `F:/Development/_kiZeug/Grovewright`. Alle Befehle laufen dort in der Bash.
|
||
- Gearbeitet wird auf dem Branch `m1d-stilproben`, nicht auf `main`.
|
||
- Kern (`core/`) und Brücke (`bridge/`) bleiben unverändert. Dieser Plan ändert nur `game/`, `CLAUDE.md` und `docs/playtests/`.
|
||
- Spielregeln gehören in den Kern, nie in die Hülle. Die Dinge der Vitrine bewegen sich nach Formeln der Vitrine und ändern keine Welt.
|
||
- Auf dem Spielfeld zeichnet nur `Art`. Andere Skripte zeichnen dort keine eigenen Formen oder Farben; ausgenommen sind Texte wie die Namen über den Figuren.
|
||
- GDScript mit Typangaben (`var x := …`, Rückgabetypen an jeder Funktion).
|
||
- Texte, die Spieler sehen, kommen über `tr()` aus `game/i18n/texts.csv`, auf Deutsch und Englisch. Eingabeaktionen legt `game/scripts/input_setup.gd` an, nicht `project.godot`.
|
||
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()`, nie die echten Dateien des Spielers.
|
||
- Code, Bezeichner und Kommentare im Code sind Englisch. Dokumente und Commit-Nachrichten sind Deutsch.
|
||
- Jede Commit-Nachricht endet mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat. Die Commit-Nachrichten in diesem Plan zeigen `Claude Opus 5.5`; wer auf einem anderen Modell läuft, setzt dessen Namen ein.
|
||
- Nach jedem Task wird committet und hochgeladen. Das Hochladen ist vom Nutzer freigegeben.
|
||
- Vor jedem Commit laufen fehlerfrei: `cargo fmt --all -- --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo build -p grovewright-bridge`, `godot --headless --path game --import` und `godot --headless --path game -s res://tests/run_tests.gd`.
|
||
- Die GDScript-Tests geben drei bekannte Warnzeilen aus: zwei mit „save not usable“ und eine mit „settings could not be read“. Sie stammen aus Tests, die absichtlich unbrauchbare oder gesperrte Dateien lesen. Andere Warnungen oder Fehler gehören nicht in die Ausgabe eines grünen Laufs.
|
||
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört in den Commit. Der Ordner `game/.godot/` gehört nicht hinein.
|
||
- Werkzeugstand: Rust 1.98.1, Godot 4.7.2 (Standard-Ausgabe, im Pfad als `godot`), godot-rust 0.5.5, Git 2.54.0.
|
||
|
||
## Hinweis zur Vorabprüfung
|
||
|
||
Der gesamte Code dieses Plans wurde am 2. Oktober 2026 vorab gebaut. Du hast die drei Abwandlungen als Pixel-Probe gesehen und Warm gewählt. Danach wurde jede der acht Task-Grenzen einzeln in einer Kopie von `main` nachgestellt, jeweils mit dem roten und dem grünen Lauf, die der Plan beschreibt:
|
||
|
||
- GDScript-Tests: 96 in `main`, nach den Tasks 1 bis 8: 110, 119, 126, 130, 138, 143, 147 und 147. Jeder grüne Lauf gab genau die drei bekannten Warnzeilen aus.
|
||
- Rust: 151 Tests wie in `main`; rustfmt und Clippy meldeten nichts.
|
||
- Das Bildschirmfoto-Werkzeug lieferte nach den Tasks 2 und 4 bis 8 die Bilder, die der Plan beschreibt, ohne Warnung.
|
||
- Die Anweisungen dieses Plans, der Reihe nach auf `main` angewandt, ergeben nach jedem Task genau die geprüften Dateien.
|
||
|
||
Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten.
|
||
|
||
## Dateiübersicht
|
||
|
||
| Datei | Aufgabe |
|
||
|---|---|
|
||
| `game/scripts/art/art.gd` | Kennungen, Wahl des Stils, Bilder statt Zeichnungen |
|
||
| `game/scripts/art/art_style.gd` | Grundklasse der Stile: Platzhalter und drei kleine Helfer |
|
||
| `game/scripts/art/tile_shape.gd` | Nachbarmasken und eine feste Zahl je Kachel für kleine Abwandlungen |
|
||
| `game/scripts/art/pixel_style.gd` | Pixelgrafik aus Code für jede Kennung |
|
||
| `game/scripts/art/warm_style.gd`, `pastel_style.gd`, `glow_style.gd` | Die drei Abwandlungen: Palette, Spielerfarben, Umrisse, Leuchten |
|
||
| `game/project.godot` | Scharfe Pixel: Texturfilterung „Nearest“, Einrasten auf ganze Pixel |
|
||
| `game/scripts/map_view.gd` | Karte in Blöcken, Risse und Splitter |
|
||
| `game/scripts/avatar.gd`, `figures.gd`, `tile_marker.gd` | Eigene und fremde Figuren, Zielmarke |
|
||
| `game/scripts/showcase.gd` | Vitrine der Stilproben |
|
||
| `game/scripts/menu.gd`, `input_setup.gd` | Dazu: Knopf zur Vitrine; F2, Esc und die Tasten 1 bis 3 |
|
||
| `game/scripts/launch_options.gd`, `save_store.gd` | Dazu: `--style=NAME` und der gemerkte Stil |
|
||
| `game/scripts/main.gd` | Verbindet alles und zeigt den eigenen Schlag sofort |
|
||
| `game/i18n/texts.csv` | Namen der Stile, Texte der Vitrine, Hilfezeile |
|
||
| `game/tools/screenshot.gd` | Bildschirmfoto in einem Stil, auch von der Vitrine |
|
||
| `game/tests/test_*.gd` | Tests der Hülle |
|
||
| `CLAUDE.md`, `docs/playtests/m1d.md`, `docs/playtests/m1d/*.png` | Regeln für die Grafik, Fragebogen mit Bildern der drei Stile |
|
||
|
||
---
|
||
|
||
### Task 1: Kennungen, Kachelformen und Platzhalter
|
||
|
||
**Files:**
|
||
- Create: `game/scripts/art/tile_shape.gd`, `game/scripts/art/art_style.gd`, `game/scripts/art/art.gd`
|
||
- Create: `game/scripts/art/warm_style.gd`, `game/scripts/art/pastel_style.gd`, `game/scripts/art/glow_style.gd` (vorläufig; die Tasks 5 und 7 ersetzen sie)
|
||
- Modify: `game/i18n/texts.csv` (ganze Datei ersetzen)
|
||
- Test: `game/tests/test_tile_shape.gd`, `game/tests/test_art_style.gd`, `game/tests/test_art.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `TestCase` mit `check(condition, message)`, `check_eq(actual, expected, message)` und `tree`; `TestFiles.temporary_dir(name)` und `TestFiles.remove_dir(path)`; `I18n.load_translations(I18n.TEXTS_FILE)`
|
||
- Produces:
|
||
- `TileShape`: die Bits `N`, `NE`, `E`, `SE`, `S`, `SW`, `W`, `NW` (1 bis 128, im Uhrzeigersinn ab Norden), `OFFSETS: Array[Vector2i]`, `static flags(codes: PackedByteArray, members: Array[int]) -> PackedByteArray`, `static mask(tile_flags: PackedByteArray, width: int, height: int, tile: Vector2i, outside_set: bool) -> int`, `static variant(tile: Vector2i) -> int` (0 bis 2147483647, für jede Kachel immer gleich)
|
||
- `ArtStyle`: `key() -> StringName`, `title_key() -> String`, `draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary) -> void`, `player_color(player: int) -> Color` und je Kennung eine Zeichenmethode `(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void`: `_ground`, `_soft_rock`, `_hard_rock`, `_bedrock`, `_cracks`, `_hit`, `_figure`, `_marker`, `_dead_soil`, `_living_soil`, `_moss`, `_vine`, `_fiber_plant`, `_stone`, `_grazer`, `_ruin`. Dazu die statischen Helfer `share(variant: int, salt: int) -> float`, `sway(time: float, variant: int, speed := 1.3) -> float` und `along(points: PackedVector2Array, part: float) -> Vector2`.
|
||
- `Art`: Signal `changed`; die Kennungen `GROUND`, `SOFT_ROCK`, `HARD_ROCK`, `BEDROCK`, `CRACKS`, `HIT`, `FIGURE`, `MARKER`, `DEAD_SOIL`, `LIVING_SOIL`, `MOSS`, `VINE`, `FIBER_PLANT`, `STONE`, `GRAZER`, `RUIN` und `IDS: Array[StringName]`; `OVERRIDE_DIR := "res://art"`; `styles: Array[ArtStyle]` und `style: ArtStyle`; `static style_keys() -> Array[StringName]`; `select(key: StringName) -> bool`; `select_next() -> void`; `load_overrides(directory: String) -> void`; `has_override(id: StringName) -> bool`; `draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary = {}) -> void`
|
||
- `WarmStyle`, `PastelStyle` und `GlowStyle` mit `KEY` (`&"warm"`, `&"pastel"`, `&"glow"`) und den Texten `STYLE_WARM`, `STYLE_PASTEL`, `STYLE_GLOW`, vorerst ohne eigene Zeichnungen
|
||
|
||
- [ ] **Step 1: Auf den Branch wechseln**
|
||
|
||
Der Branch existiert bereits. Er wurde zusammen mit diesem Plan angelegt und hochgeladen.
|
||
|
||
```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.
|
||
|
||
|
||
func test_a_point_can_be_found_along_a_path() -> void:
|
||
var path := PackedVector2Array([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10)])
|
||
check_eq(ArtStyle.along(path, 0.0), Vector2(0, 0), "the start")
|
||
check_eq(ArtStyle.along(path, 0.5), Vector2(10, 0), "halfway is the bend")
|
||
check_eq(ArtStyle.along(path, 0.75), Vector2(10, 5), "three quarters")
|
||
check_eq(ArtStyle.along(path, 1.0), Vector2(10, 10), "the end")
|
||
check_eq(ArtStyle.along(path, 2.0), Vector2(10, 10), "never beyond the end")
|
||
|
||
|
||
func test_shares_lie_between_zero_and_one_and_stay_the_same() -> void:
|
||
for variant in [0, 1, 99, 123456, 2147483647]:
|
||
for salt in 4:
|
||
var share := ArtStyle.share(variant, salt)
|
||
check(share >= 0.0 and share < 1.0, "share of %d and %d: %f" % [variant, salt, share])
|
||
check_eq(ArtStyle.share(variant, salt), share, "the same again")
|
||
|
||
|
||
func test_a_sway_swings_gently_and_differs_between_things() -> void:
|
||
for tenth in 100:
|
||
var swing := ArtStyle.sway(tenth * 0.1, 7)
|
||
check(swing >= -1.0 and swing <= 1.0, "between left and right at %.1f s: %f" % [tenth * 0.1, swing])
|
||
check(ArtStyle.sway(0.0, 1) != ArtStyle.sway(0.0, 2), "two things do not swing alike")
|
||
```
|
||
|
||
Create `game/tests/test_art.gd`:
|
||
|
||
```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, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY], "warm, pastel, glow")
|
||
check_eq(art.style, art.styles[0], "the first one is chosen at first")
|
||
|
||
|
||
func test_a_style_is_chosen_by_its_key() -> void:
|
||
var art := Art.new()
|
||
var changes := [0]
|
||
art.changed.connect(func() -> void: changes[0] += 1)
|
||
check(art.select(PastelStyle.KEY), "the pastel style exists")
|
||
check_eq(art.style.key(), PastelStyle.KEY, "and is chosen")
|
||
check(art.select(PastelStyle.KEY), "choosing it again works")
|
||
check_eq(changes[0], 1, "only a real change is announced")
|
||
check(not art.select(&"watercolour"), "an unknown key is refused")
|
||
check_eq(art.style.key(), PastelStyle.KEY, "and changes nothing")
|
||
|
||
|
||
func test_the_next_style_follows_and_the_first_comes_after_the_last() -> void:
|
||
var art := Art.new()
|
||
var seen: Array[StringName] = []
|
||
for i in art.styles.size() + 1:
|
||
seen.append(art.style.key())
|
||
art.select_next()
|
||
check_eq(seen, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY, WarmStyle.KEY], "round and round")
|
||
|
||
|
||
func test_every_style_has_a_name_in_every_language() -> void:
|
||
for translation in I18n.load_translations(I18n.TEXTS_FILE):
|
||
for style in Art.new().styles:
|
||
var title := String(translation.get_message(style.title_key()))
|
||
check(not title.is_empty(), "%s in %s" % [style.title_key(), translation.locale])
|
||
|
||
|
||
func test_a_picture_named_after_an_id_replaces_its_drawing() -> void:
|
||
var directory := TestFiles.temporary_dir("art")
|
||
DirAccess.make_dir_recursive_absolute(directory.path_join("terrain"))
|
||
var image := Image.create(16, 16, false, Image.FORMAT_RGBA8)
|
||
image.fill(Color.RED)
|
||
image.save_png(directory.path_join("terrain/ground.png"))
|
||
var art := Art.new()
|
||
art.load_overrides(directory)
|
||
check(art.has_override(Art.GROUND), "the ground has a picture")
|
||
check(not art.has_override(Art.SOFT_ROCK), "soft rock has none")
|
||
check_eq(await _draw_everything(art), Art.IDS.size(), "the picture is drawn like the rest")
|
||
art.load_overrides(directory.path_join("missing"))
|
||
check(not art.has_override(Art.GROUND), "loading again drops the picture")
|
||
TestFiles.remove_dir(directory)
|
||
|
||
|
||
func test_without_the_folder_of_pictures_everything_is_drawn() -> void:
|
||
var art := Art.new()
|
||
art.load_overrides(Art.OVERRIDE_DIR)
|
||
for id in Art.IDS:
|
||
check(not art.has_override(id), "no picture for %s" % id)
|
||
```
|
||
|
||
- [ ] **Step 3: Tests ausführen und Fehlschlag sehen**
|
||
|
||
Run:
|
||
|
||
```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
|
||
|
||
|
||
## A gentle swing from -1 to 1, slow and different for every `variant`.
|
||
static func sway(time: float, variant: int, speed := 1.3) -> float:
|
||
return sin(time * speed + share(variant, 7) * TAU)
|
||
|
||
|
||
## The point at `part` of the way along `points`, from 0 to 1.
|
||
static func along(points: PackedVector2Array, part: float) -> Vector2:
|
||
var total := 0.0
|
||
for i in points.size() - 1:
|
||
total += points[i].distance_to(points[i + 1])
|
||
var left := clampf(part, 0.0, 1.0) * total
|
||
for i in points.size() - 1:
|
||
var length := points[i].distance_to(points[i + 1])
|
||
if left <= length:
|
||
return points[i].lerp(points[i + 1], left / length if length > 0.0 else 0.0)
|
||
left -= length
|
||
return points[points.size() - 1]
|
||
```
|
||
|
||
Create `game/scripts/art/art.gd`:
|
||
|
||
```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] = [WarmStyle.new(), PastelStyle.new(), GlowStyle.new()]
|
||
## The style everything is drawn in.
|
||
var style: ArtStyle = styles[0]
|
||
var _overrides: Dictionary[StringName, Texture2D] = {}
|
||
|
||
|
||
## The keys of the styles, as LaunchOptions and the settings name them.
|
||
static func style_keys() -> Array[StringName]:
|
||
return [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY]
|
||
|
||
|
||
## Draws everything in the style named `key` from now on. Returns false, and
|
||
## changes nothing, if no style has that key.
|
||
func select(key: StringName) -> bool:
|
||
for candidate in styles:
|
||
if candidate.key() == key:
|
||
if candidate != style:
|
||
style = candidate
|
||
changed.emit()
|
||
return true
|
||
return false
|
||
|
||
|
||
## Switches to the style after the current one, and from the last to the
|
||
## first.
|
||
func select_next() -> void:
|
||
style = styles[(styles.find(style) + 1) % styles.size()]
|
||
changed.emit()
|
||
|
||
|
||
## Uses the picture `<directory>/<id>.png` instead of the drawing, for every
|
||
## id that has one. Pictures loaded earlier are dropped.
|
||
func load_overrides(directory: String) -> void:
|
||
_overrides.clear()
|
||
for id in IDS:
|
||
var texture := _load_texture(directory.path_join(String(id) + ".png"))
|
||
if texture != null:
|
||
_overrides[id] = texture
|
||
changed.emit()
|
||
|
||
|
||
## True if a picture replaces the drawing of `id`.
|
||
func has_override(id: StringName) -> bool:
|
||
return _overrides.has(id)
|
||
|
||
|
||
## Draws the graphic `id` into `rect` on `canvas`. Call it only while
|
||
## `canvas` draws, from its _draw(). `state` carries what the graphic shows,
|
||
## for example how far a plant has grown; see the styles for the entries.
|
||
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary = {}) -> void:
|
||
var texture: Texture2D = _overrides.get(id)
|
||
if texture != null:
|
||
canvas.draw_texture_rect(texture, rect, false)
|
||
else:
|
||
style.draw(canvas, id, rect, state)
|
||
|
||
|
||
static func _load_texture(path: String) -> Texture2D:
|
||
if path.begins_with("res://"):
|
||
return load(path) as Texture2D if ResourceLoader.exists(path) else null
|
||
if not FileAccess.file_exists(path):
|
||
return null
|
||
var image := Image.load_from_file(path)
|
||
return ImageTexture.create_from_image(image) if image != null else null
|
||
```
|
||
|
||
Die drei Stile sind vorerst dünn: Sie nennen ihren Schlüssel und ihren Namen und zeichnen die Platzhalter von `ArtStyle`. Die Tasks 5 und 7 ersetzen sie durch die fertigen Abwandlungen.
|
||
|
||
Create `game/scripts/art/warm_style.gd`:
|
||
|
||
```gdscript
|
||
class_name WarmStyle
|
||
extends ArtStyle
|
||
## Warm, full colours and dark outlines, close to the classic farming games.
|
||
## Until it draws pixel art, it draws the placeholders of ArtStyle.
|
||
|
||
const KEY := &"warm"
|
||
|
||
|
||
func key() -> StringName:
|
||
return KEY
|
||
|
||
|
||
func title_key() -> String:
|
||
return "STYLE_WARM"
|
||
```
|
||
|
||
Create `game/scripts/art/pastel_style.gd`:
|
||
|
||
```gdscript
|
||
class_name PastelStyle
|
||
extends ArtStyle
|
||
## Soft, light colours with outlines in a darker shade of what they surround.
|
||
## Until it draws pixel art, it draws the placeholders of ArtStyle.
|
||
|
||
const KEY := &"pastel"
|
||
|
||
|
||
func key() -> StringName:
|
||
return KEY
|
||
|
||
|
||
func title_key() -> String:
|
||
return "STYLE_PASTEL"
|
||
```
|
||
|
||
Create `game/scripts/art/glow_style.gd`:
|
||
|
||
```gdscript
|
||
class_name GlowStyle
|
||
extends ArtStyle
|
||
## A dim, cool world in which everything living shines and glows.
|
||
## Until it draws pixel art, it draws the placeholders of ArtStyle.
|
||
|
||
const KEY := &"glow"
|
||
|
||
|
||
func key() -> StringName:
|
||
return KEY
|
||
|
||
|
||
func title_key() -> String:
|
||
return "STYLE_GLOW"
|
||
```
|
||
|
||
Ersetze den gesamten Inhalt von `game/i18n/texts.csv` durch die folgende Fassung. Neu sind die letzten drei Zeilen mit den Namen der Stile.
|
||
|
||
```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_WARM,Warm,Warm
|
||
STYLE_PASTEL,Pastell,Pastel
|
||
STYLE_GLOW,Glimmen,Glow
|
||
```
|
||
|
||
- [ ] **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 `110 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. TileShape beschreibt
|
||
die Nachbarn einer Kachel, damit Felsmassen außen rund und innen
|
||
geschlossen gezeichnet werden können. Die drei Stile Warm, Pastell und
|
||
Glimmen gibt es schon mit Namen, noch ohne eigene Zeichnungen.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
EOF
|
||
git push -u origin m1d-stilproben
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Spielfeld über Art
|
||
|
||
**Files:**
|
||
- Modify: `game/scripts/map_view.gd`, `game/scripts/avatar.gd`, `game/scripts/figures.gd`, `game/scripts/tile_marker.gd`, `game/scripts/main.gd` (jeweils ganze Datei ersetzen)
|
||
- Test: `game/tests/test_map_view.gd` (neu); `game/tests/test_avatar.gd` (zwei Tests am Ende); `game/tests/test_figures.gd`, `game/tests/test_main.gd` (je ein Test am Ende)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Art` und `TileShape` aus Task 1. Aus `GwSession`: `is_running()`, `map_width()`, `map_height()`, `terrain_codes()`, `damage_codes()`, `terrain_at(x, y)`, `spawn_point()`, `local_player()`, `player_name(player)`, `dig(x, y)`, `step()` und die Konstanten `TERRAIN_GROUND`, `TERRAIN_SOFT_ROCK`, `TERRAIN_HARD_ROCK`, `TERRAIN_BEDROCK`, `DIG_COOLDOWN_TICKS`. Aus `GridMotion`: `TILE_SIZE`, `move`, `tile_center`.
|
||
- Produces:
|
||
- `MapView`: `art: Art`; `SOLID: Array[int]` (weicher Fels, harter Fels, Grundgestein), `BLOCK := 16`, `HIT_SECONDS := 0.35`; `static id_of(terrain: int) -> StringName`; `reload()` nimmt die ganze Karte neu aus der Sitzung, `redraw()` zeichnet alles neu, `refresh_tile(tile: Vector2i)` nimmt eine Kachel neu und zeichnet die Blöcke um sie, `hit(tile: Vector2i)` zeigt Splitter; für Tests `block_count() -> int`, `burst_count() -> int` und `mask_at(tile: Vector2i) -> int`. Wer die Karte neu zeigen will, ruft `reload()`; `queue_redraw()` auf `MapView` selbst zeichnet die Karte nicht mehr.
|
||
- `Avatar`: `art: Art`, `player := -1`, `facing := 1.0`, `step := 0.0`, `moving := false`, `STEP_PER_PIXEL := 0.16`, `static figure_rect(at: Vector2) -> Rect2`: eine Kachel breit, anderthalb hoch, die Unterkante bei `at.y + RADIUS + 1`
|
||
- `Figures`: `art: Art`, `facing_of(player: int) -> float`; die Namen stehen über `Avatar.figure_rect`
|
||
- `TileMarker`: `art: Art`
|
||
- Hauptszene: `art: Art`. `dig_at(tile)` zeigt die Splitter sofort, wenn die Kachel kein Boden ist.
|
||
|
||
- [ ] **Step 1: Fehlschlagende Tests schreiben**
|
||
|
||
Create `game/tests/test_map_view.gd`:
|
||
|
||
```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` diese beiden Tests 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()
|
||
|
||
|
||
func test_a_figure_stands_upright_in_its_tile() -> void:
|
||
var rect := Avatar.figure_rect(Vector2(100, 100))
|
||
check_eq(rect.size, Vector2(GridMotion.TILE_SIZE, GridMotion.TILE_SIZE * 1.5), "a tile wide, one and a half tall")
|
||
check_eq(rect.get_center().x, 100.0, "centred on the figure")
|
||
check_eq(rect.end.y, 100.0 + Avatar.RADIUS + 1.0, "its feet just below its body")
|
||
```
|
||
|
||
Füge am Ende von `game/tests/test_figures.gd` diesen Test an, wieder nach zwei Leerzeilen:
|
||
|
||
```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 `107 passed, 4 failed`. `test_avatar.gd` lädt nicht (`SCRIPT ERROR: Parse Error: Static function "figure_rect()" not found in base "Avatar".`), `test_map_view.gd` auch nicht (`Parse Error: Cannot find member "BLOCK" in base "MapView".`). Die angehängten Tests in `test_figures.gd` und `test_main.gd` scheitern mit `Invalid call. Nonexistent function 'facing_of' in base 'Node2D (Figures)'.` und `Invalid call. Nonexistent function 'burst_count' in base 'Node2D (MapView)'.` Am Ende meldet Godot liegen gebliebene Objekte (`… were leaked at exit`), weil die abgebrochenen Tests nicht aufräumen.
|
||
|
||
- [ ] **Step 3: Karte, Figuren und Zielmarke über Art zeichnen**
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/map_view.gd` durch:
|
||
|
||
```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
|
||
|
||
|
||
## Where a figure standing at `at` is drawn: a tile wide and one and a half
|
||
## tiles tall, with its feet just below its body, so that it stands upright
|
||
## in its tile.
|
||
static func figure_rect(at: Vector2) -> Rect2:
|
||
var size := GridMotion.TILE_SIZE
|
||
return Rect2(at.x - size / 2.0, at.y + RADIUS + 1.0 - size * 1.5, size, size * 1.5)
|
||
|
||
|
||
func _process(delta: float) -> void:
|
||
walk(Input.get_vector("move_left", "move_right", "move_up", "move_down"), delta)
|
||
|
||
|
||
## Moves the figure in `direction` for `delta` seconds, sliding along walls.
|
||
func walk(direction: Vector2, delta: float) -> void:
|
||
var was_moving := moving
|
||
moving = false
|
||
if direction == Vector2.ZERO or not is_walkable.is_valid():
|
||
if was_moving:
|
||
queue_redraw()
|
||
return
|
||
var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS)
|
||
var before := position
|
||
position = GridMotion.move(position, motion, RADIUS, is_walkable)
|
||
var walked := position.distance_to(before)
|
||
moving = walked > 0.0
|
||
step += walked * STEP_PER_PIXEL
|
||
if direction.x != 0.0:
|
||
facing = signf(direction.x)
|
||
queue_redraw()
|
||
|
||
|
||
func _draw() -> void:
|
||
if art != null:
|
||
art.draw(self, Art.FIGURE, figure_rect(Vector2.ZERO), {"player": player, "facing": facing, "step": step, "moving": moving})
|
||
```
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/figures.gd` durch:
|
||
|
||
```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, Avatar.figure_rect(at).position.y - 2.0)
|
||
var text := session.player_name(player)
|
||
var size := roundi(NAME_SIZE * zoom)
|
||
draw_set_transform(label, 0.0, Vector2.ONE / zoom)
|
||
draw_string_outline(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size, maxi(2, roundi(zoom * 1.5)), NAME_OUTLINE)
|
||
draw_string(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size)
|
||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||
```
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/tile_marker.gd` durch:
|
||
|
||
```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 `119 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
|
||
|
||
- [ ] **Step 5: Bildschirmfoto prüfen**
|
||
|
||
Run: `godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-task2.png"`
|
||
|
||
Expected: Ein Fenster öffnet sich kurz. Die Ausgabe endet mit `screenshot saved: …m1d-task2.png` und enthält weder Warnung noch Fehler. Das Bild zeigt die Karte in den Platzhaltern von `ArtStyle`, so flach wie vor diesem Plan: brauner Boden, graue Felsen mit eckigen Kanten, die Figur als heller Kreis mit dunklem Rand. Im getroffenen Fels rechts neben der Figur steht ein kurzer dunkler Strich als Riss. Oben links steht `Stein: 0`, unten die Hilfezeile.
|
||
|
||
- [ ] **Step 6: Alles prüfen**
|
||
|
||
Run:
|
||
|
||
```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 stehen
|
||
aufrecht in ihrer Kachel, schreiten und schauen in die Richtung, in die
|
||
sie gehen. Der eigene Schlag zeigt seine Splitter sofort, auch als Gast,
|
||
und die Namen über fremden Figuren werden in Bildschirmgröße gezeichnet
|
||
und bleiben scharf.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
EOF
|
||
git push
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Vitrine der Stilproben
|
||
|
||
**Files:**
|
||
- Create: `game/scripts/showcase.gd`
|
||
- Modify: `game/scripts/menu.gd`, `game/scripts/input_setup.gd`, `game/i18n/texts.csv`, `game/scripts/main.gd` (jeweils ganze Datei ersetzen)
|
||
- Test: `game/tests/test_showcase.gd` (neu); `game/tests/test_input_setup.gd` (ganze Datei ersetzen); `game/tests/test_menu.gd`, `game/tests/test_main.gd` (je ein Test am Ende)
|
||
|
||
**Interfaces:**
|
||
- Consumes: aus Task 1 `Art` (`styles`, `style`, `select`, `select_next`, `changed`, `draw`), `ArtStyle.along`, `ArtStyle.title_key()` und `TileShape`; aus Task 2 `Avatar.figure_rect` und `MapView.redraw()`; `GridMotion.TILE_SIZE`; die Hilfen `_menu()` und `_free(menu)` in `game/tests/test_menu.gd`
|
||
- Produces:
|
||
- `Showcase` (CanvasLayer): Signal `closed`; `art: Art`, `time: float` (Sekunden seit dem Öffnen), `style_buttons: Array[Button]`, `back_button: Button`; die Konstanten `GRID`, `ZOOM`, `GROW_SECONDS`, `GROWN_SECONDS`, `HOP_EVERY`, `HOP_SECONDS`, `STONE_SPEED`, `STONES`, `VINE`, `MOSS`, `PLANT`, `GRAZER_HOME`, `RUIN`, `FIGURES`; die Bewegungen `static growth_at(seconds: float) -> float`, `static hop_at(seconds: float) -> float`, `static grazer_at(seconds: float) -> Vector2`, `static stones_at(seconds: float) -> Array[float]` und `static awake_at(seconds: float) -> float`; `caption_keys() -> PackedStringArray`; `draw_scene(canvas: CanvasItem)`
|
||
- `Menu`: Signal `showcase_chosen` und `showcase_button`
|
||
- Eingabeaktionen `next_style` (F2), `back` (Esc), `style_1`, `style_2` und `style_3` (Tasten 1 bis 3)
|
||
- Hauptszene: `showcase: Showcase`, `open_showcase()` und `close_showcase()`. Nach jedem Wechsel des Stils zeichnet `_redraw_art()` das Spielfeld neu.
|
||
- Texte `MENU_SHOWCASE`, `SHOWCASE_TITLE`, `SHOWCASE_BACK` und die Beschriftungen `SHOWCASE_ROCK`, `SHOWCASE_DEAD_SOIL`, `SHOWCASE_LIVING_SOIL`, `SHOWCASE_MOSS`, `SHOWCASE_PLANT`, `SHOWCASE_GRAZER`, `SHOWCASE_VINE`, `SHOWCASE_RUIN`, `SHOWCASE_FIGURES`
|
||
|
||
- [ ] **Step 1: Fehlschlagende Tests schreiben**
|
||
|
||
Create `game/tests/test_showcase.gd`:
|
||
|
||
```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(), GlowStyle.KEY, "a button there chooses the style")
|
||
await tree.process_frame
|
||
main.showcase.back_button.pressed.emit()
|
||
check(main.menu.visible, "the menu is back")
|
||
check(not main.showcase.visible, "the samples are gone")
|
||
_clean_up([directory])
|
||
```
|
||
|
||
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
|
||
|
||
Run:
|
||
|
||
```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 `118 passed, 4 failed`. `test_showcase.gd` lädt nicht (`SCRIPT ERROR: Parse Error: Identifier "Showcase" not declared in the current scope.`). `test_every_action_of_the_game_exists` vermisst `action next_style`, `action back` und `action style_1` bis `action style_3`. Die neuen Tests in `test_menu.gd` und `test_main.gd` scheitern mit `Invalid access to property or key 'showcase_chosen' on a base object of type 'CanvasLayer (Menu)'.` beziehungsweise `… key 'showcase_button' …`.
|
||
|
||
- [ ] **Step 3: Vitrine, Menü, Tasten und Texte schreiben**
|
||
|
||
Die Vitrine baut ihre Szene aus einem Raster von Buchstaben. Pflanze, Tier, Steine und Ruine bewegen sich nach Formeln der Zeit seit dem Öffnen; die Formeln sind statisch, damit die Tests sie ohne Szene prüfen.
|
||
|
||
Create `game/scripts/showcase.gd`:
|
||
|
||
```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_WARM,Warm,Warm
|
||
STYLE_PASTEL,Pastell,Pastel
|
||
STYLE_GLOW,Glimmen,Glow
|
||
MENU_SHOWCASE,Stilproben ansehen,See the style samples
|
||
SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s
|
||
SHOWCASE_BACK,Zurück,Back
|
||
SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock
|
||
SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil
|
||
SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil
|
||
SHOWCASE_MOSS,Lebensmoos,Life moss
|
||
SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows
|
||
SHOWCASE_GRAZER,Weidetier,Grazer
|
||
SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones
|
||
SHOWCASE_RUIN,Ruine erwacht,A ruin wakes
|
||
SHOWCASE_FIGURES,Spielfiguren,Player figures
|
||
```
|
||
|
||
In der Hauptszene kommt gegenüber Task 2 die Vitrine hinzu: Sie wird beim Start versteckt und angehalten angelegt, `open_showcase()` zeigt sie statt des Menüs, und ihr Signal `closed` führt über `close_showcase()` zurück. Weil die Knöpfe der Vitrine den Stil wechseln, zeichnet `_redraw_art()` bei jedem Signal `changed` von `art` das Spielfeld neu.
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/main.gd` durch:
|
||
|
||
```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 `126 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, sonst Warm. F2 wechselt im Spiel und meldet `STYLE_SWITCHED`. Jeder Wechsel wird gemerkt.
|
||
- `res://tools/screenshot.gd`, aufgerufen mit `-- <Pfad der PNG-Datei> [--style=NAME] [--showcase]`
|
||
- Texte: `STYLE_SWITCHED`; die Hilfezeile `HELP` nennt F2
|
||
|
||
- [ ] **Step 1: Fehlschlagende Tests schreiben**
|
||
|
||
Füge am Ende von `game/tests/test_launch_options.gd` diesen Test an, nach zwei Leerzeilen:
|
||
|
||
```gdscript
|
||
func test_a_style_can_be_chosen() -> void:
|
||
check_eq(LaunchOptions.parse(PackedStringArray(["--style=glow"])).style, GlowStyle.KEY, "a known style")
|
||
check_eq(LaunchOptions.parse(PackedStringArray()).style, &"", "none without the argument")
|
||
var unknown := LaunchOptions.parse(PackedStringArray(["--style=watercolour"]))
|
||
check_eq(unknown.style, &"", "an unknown style is not taken")
|
||
check_eq(unknown.ignored, PackedStringArray(["--style=watercolour"]), "but listed as ignored")
|
||
```
|
||
|
||
Füge am Ende von `game/tests/test_save_store.gd` diesen Test an, nach zwei Leerzeilen:
|
||
|
||
```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("pastel")
|
||
check_eq(SaveStore.new(directory).style(), "pastel", "kept for the next start")
|
||
TestFiles.remove_dir(directory)
|
||
```
|
||
|
||
Füge am Ende von `game/tests/test_main.gd` diese beiden Tests an, nach zwei Leerzeilen:
|
||
|
||
```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=pastel"])
|
||
tree.root.add_child(main)
|
||
_started.append(main)
|
||
check_eq(main.art.style.key(), PastelStyle.KEY, "the style of the command line")
|
||
_clean_up([directory])
|
||
```
|
||
|
||
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
|
||
|
||
Run:
|
||
|
||
```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 `126 passed, 4 failed`:
|
||
|
||
- `test_a_style_can_be_chosen` mit `Invalid access to property or key 'style' on a base object of type 'RefCounted (LaunchOptions)'.`
|
||
- `test_the_chosen_style_is_kept` mit `Invalid call. Nonexistent function 'style' in base 'RefCounted (SaveStore)'.`
|
||
- `test_f2_switches_the_style_and_the_choice_is_kept` mit `another style`
|
||
- `test_the_command_line_chooses_the_style` mit `the style of the command line: expected pastel, got warm`
|
||
|
||
Dazu kommen `WARNING: ignored command line argument: --style=pastel` und `ERROR: String formatting error: not all arguments converted during string formatting.`, weil es den Text `STYLE_SWITCHED` noch nicht gibt.
|
||
|
||
- [ ] **Step 3: Befehlszeile, Einstellung, Hauptszene, Texte und Werkzeug schreiben**
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/launch_options.gd` durch die folgende Fassung. Neu sind die Zeilen zu `--style` in der Beschreibung, das Feld `style` und der Zweig `"--style"` in `parse`.
|
||
|
||
```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: warm,
|
||
## pastel or glow
|
||
|
||
## Longest profile name.
|
||
const MAX_PROFILE_LENGTH := 32
|
||
## Characters a profile name may contain.
|
||
const PROFILE_CHARACTERS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
|
||
|
||
var profile := ""
|
||
var player_name := ""
|
||
## How to start without the menu: "", "solo", "host" or "join".
|
||
var start := ""
|
||
var port := NetLink.DEFAULT_PORT
|
||
var address := ""
|
||
## The style to draw in, or empty for the one in the settings.
|
||
var style := &""
|
||
## Arguments that were not understood and are ignored.
|
||
var ignored := PackedStringArray()
|
||
|
||
|
||
## Reads `arguments`. Unknown arguments and bad values end up in `ignored`.
|
||
static func parse(arguments: PackedStringArray) -> LaunchOptions:
|
||
var options := LaunchOptions.new()
|
||
for argument in arguments:
|
||
var value := argument.get_slice("=", 1) if argument.contains("=") else ""
|
||
match argument.get_slice("=", 0):
|
||
"--profile":
|
||
if is_valid_profile(value):
|
||
options.profile = value
|
||
else:
|
||
options.ignored.append(argument)
|
||
"--name":
|
||
options.player_name = value
|
||
"--solo":
|
||
options.start = "solo"
|
||
"--host":
|
||
options.start = "host"
|
||
"--port":
|
||
if value.is_valid_int() and value.to_int() >= 0 and value.to_int() <= 65535:
|
||
options.port = value.to_int()
|
||
else:
|
||
options.ignored.append(argument)
|
||
"--join":
|
||
options.start = "join"
|
||
options.address = value
|
||
"--style":
|
||
if Art.style_keys().has(StringName(value)):
|
||
options.style = StringName(value)
|
||
else:
|
||
options.ignored.append(argument)
|
||
_:
|
||
options.ignored.append(argument)
|
||
return options
|
||
|
||
|
||
## Whether `name` can name a profile folder: up to 32 of the letters A to Z,
|
||
## digits, "-" and "_".
|
||
static func is_valid_profile(name: String) -> bool:
|
||
if name.is_empty() or name.length() > MAX_PROFILE_LENGTH:
|
||
return false
|
||
for character in name:
|
||
if not PROFILE_CHARACTERS.contains(character):
|
||
return false
|
||
return true
|
||
|
||
|
||
## The folder for the player's files. The default profile uses the user
|
||
## folder itself, so that saves of earlier versions stay where they are.
|
||
func storage_dir() -> String:
|
||
if profile.is_empty():
|
||
return "user://"
|
||
return "user://profiles".path_join(profile)
|
||
```
|
||
|
||
Füge in `game/scripts/save_store.gd` direkt vor der Zeile `func _setting(section: String, key: String) -> String:` diese beiden Funktionen ein. Danach folgen zwei Leerzeilen, wie zwischen allen Funktionen:
|
||
|
||
```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_WARM,Warm,Warm
|
||
STYLE_PASTEL,Pastell,Pastel
|
||
STYLE_GLOW,Glimmen,Glow
|
||
STYLE_SWITCHED,Stil: %s,Style: %s
|
||
MENU_SHOWCASE,Stilproben ansehen,See the style samples
|
||
SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s
|
||
SHOWCASE_BACK,Zurück,Back
|
||
SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock
|
||
SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil
|
||
SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil
|
||
SHOWCASE_MOSS,Lebensmoos,Life moss
|
||
SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows
|
||
SHOWCASE_GRAZER,Weidetier,Grazer
|
||
SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones
|
||
SHOWCASE_RUIN,Ruine erwacht,A ruin wakes
|
||
SHOWCASE_FIGURES,Spielfiguren,Player figures
|
||
```
|
||
|
||
Das Bildschirmfoto-Werkzeug lernt `--style=NAME` und `--showcase` und stellt eine zweite Figur mit Namen neben die eigene, damit die Namen auf dem Bild zu sehen sind. Das Foto entsteht jetzt acht Einzelbilder nach dem Schlag, solange die Splitter noch fliegen.
|
||
|
||
Ersetze den gesamten Inhalt von `game/tools/screenshot.gd` durch:
|
||
|
||
```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 `130 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=pastel
|
||
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-task4-vitrine.png" --showcase
|
||
```
|
||
|
||
Expected: Beide Aufrufe enden mit `screenshot saved: …` und geben weder Warnung noch Fehler aus. Beide Bilder zeigen noch die Platzhalter, denn die Pixelgrafik folgt erst ab Task 5. Das erste Bild zeigt das Spiel wie nach Task 2, dazu links eine zweite Figur, über der scharf „Spieler“ steht, und kleine helle Splitter am getroffenen Fels. Das zweite Bild zeigt die Vitrine: oben „Stilprobe: Warm“, in der Mitte die Szene aus Rechtecken, Kreisen und Linien mit neun weißen Beschriftungen, unten die Knöpfe „1 Warm“ (ausgegraut), „2 Pastell“, „3 Glimmen“ und „Zurück“.
|
||
|
||
- [ ] **Step 6: Alles prüfen**
|
||
|
||
Run:
|
||
|
||
```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: Pixelgrafik für Boden, Fels und Figuren
|
||
|
||
`PixelStyle` zeichnet jede Grafik Pixel für Pixel in ein kleines Bild und hebt es auf. Dieser Task bringt den Boden, die drei Felsarten mit Vorderkante und Schatten, Risse, Splitter, die Zielmarke und die Figuren mit Laufbildern; Task 6 bringt die Dinge der Vitrine. `WarmStyle` wird die erste fertige Abwandlung und bekommt schon die ganze Palette, auch die Farben, die erst Task 6 braucht.
|
||
|
||
**Files:**
|
||
- Create: `game/scripts/art/pixel_style.gd`
|
||
- Modify: `game/scripts/art/warm_style.gd`, `game/project.godot` (jeweils ganze Datei ersetzen)
|
||
- Test: `game/tests/test_pixel_style.gd`, `game/tests/test_warm_style.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ArtStyle` mit seinen Zeichenmethoden und `ArtStyle.along`; `TileShape` mit seinen Bits und `TileShape.variant`; die Einträge von `state` laut Kopf von `art_style.gd`; `Avatar.figure_rect` aus Task 2, in das eine Figur gezeichnet wird
|
||
- Produces:
|
||
- `PixelStyle` (erbt von `ArtStyle`): die Konstanten `TILE := 16`, `FACE := 6`, `HIT_FRAMES := 6`, `FIGURE_SIZE := Vector2i(16, 24)` und `CLEAR`; die Felder `palette: Dictionary[String, Color]`, `players: Array[Color]` und `tinted_outlines: bool`, die eine Unterklasse füllt
|
||
- die innere Klasse `PixelStyle.Pixels` mit `image: Image`, `size: Vector2i`, `has`, `put`, `at`, `filled`, `shade`, `rect`, `ellipse`, `line` und `outline(color: Color, tinted: bool)`
|
||
- `_image(key: String, make: Callable) -> Texture2D`: macht das Bild unter `key` nur beim ersten Mal; `static _shape(mask: int) -> int`; `static _spot(index: int, variant: int, low: int, span: int) -> Vector2i`; `static _round(pixels, corner, inward, ink)`; `_shadow_image(width: int) -> Pixels`
|
||
- die Bilder `_earth(variant: int, shadow: int) -> Pixels`, `_rock_image(kind: String, mask: int, variant: int) -> Pixels` (`kind` ist `"soft"`, `"hard"` oder `"bed"`) und `_figure_image(colour: int, frame: int, left: bool) -> Pixels` (Bild 0 steht, 1 bis 4 laufen)
|
||
- die Farben der Palette mit den Namen, die `warm_style.gd` setzt; `PastelStyle` und `GlowStyle` in Task 7 setzen dieselben Namen
|
||
|
||
- [ ] **Step 1: Fehlschlagende Tests schreiben**
|
||
|
||
Create `game/tests/test_pixel_style.gd`:
|
||
|
||
```gdscript
|
||
extends TestCase
|
||
## The pixel art of the ground, the rock and the figures, which every pixel
|
||
## style draws.
|
||
|
||
|
||
## How many pixels of `image` are not transparent.
|
||
static func _opaque(image: Image) -> int:
|
||
var count := 0
|
||
for y in image.get_height():
|
||
for x in image.get_width():
|
||
if image.get_pixel(x, y).a > 0.0:
|
||
count += 1
|
||
return count
|
||
|
||
|
||
func test_the_field_is_drawn_in_sharp_pixels() -> void:
|
||
check_eq(ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_filter"), 0, "each pixel a square, not blurred")
|
||
check(ProjectSettings.get_setting("rendering/2d/snap/snap_2d_transforms_to_pixel"), "on whole pixels of the screen")
|
||
|
||
|
||
func test_ground_fills_its_tile_and_lies_in_the_shadow_of_rock() -> void:
|
||
var style := WarmStyle.new()
|
||
var open := style._earth(3, 0).image
|
||
check_eq(open.get_size(), Vector2i(PixelStyle.TILE, PixelStyle.TILE), "a tile")
|
||
check_eq(_opaque(open), PixelStyle.TILE * PixelStyle.TILE, "no gaps")
|
||
var shaded := style._earth(3, TileShape.N).image
|
||
check(shaded.get_pixel(5, 0).get_luminance() < open.get_pixel(5, 0).get_luminance(), "darker below rock")
|
||
check_eq(shaded.get_pixel(5, 8), open.get_pixel(5, 8), "but only along the rock")
|
||
|
||
|
||
func test_a_lone_rock_is_round_and_shows_its_face() -> void:
|
||
var style := WarmStyle.new()
|
||
var lone := style._rock_image("soft", 0, 0).image
|
||
check_eq(lone.get_pixel(0, 0).a, 0.0, "a round corner, the ground shows there")
|
||
check_eq(lone.get_pixel(PixelStyle.TILE - 1, PixelStyle.TILE - 1).a, 0.0, "round at the bottom too")
|
||
check_eq(lone.get_pixel(8, 0), style.palette["outline"], "outlined at the top")
|
||
check_eq(lone.get_pixel(8, PixelStyle.TILE - 1), style.palette["outline"], "and where it meets the ground")
|
||
check_eq(lone.get_pixel(5, PixelStyle.TILE - 3), style.palette["soft_face"], "its face toward the open ground")
|
||
|
||
|
||
func test_rock_inside_a_mass_has_neither_edge_nor_face() -> void:
|
||
var style := WarmStyle.new()
|
||
var inside := style._rock_image("soft", 255, 0).image
|
||
check_eq(_opaque(inside), PixelStyle.TILE * PixelStyle.TILE, "no gaps")
|
||
for y in PixelStyle.TILE:
|
||
for x in PixelStyle.TILE:
|
||
var colour := inside.get_pixel(x, y)
|
||
check(colour != style.palette["outline"] and colour != style.palette["soft_face"], "inside at %d, %d" % [x, y])
|
||
|
||
|
||
func test_only_the_corners_that_matter_tell_tiles_apart() -> void:
|
||
check_eq(PixelStyle._shape(TileShape.NE), 0, "a corner alone does not count")
|
||
check_eq(PixelStyle._shape(TileShape.N | TileShape.E | TileShape.NE), TileShape.N | TileShape.E | TileShape.NE, "between two sides it does")
|
||
check_eq(PixelStyle._shape(255), 255, "all of them inside a mass")
|
||
|
||
|
||
func test_each_picture_is_made_only_once() -> void:
|
||
var style := WarmStyle.new()
|
||
var made := [0]
|
||
var make := func() -> PixelStyle.Pixels:
|
||
made[0] += 1
|
||
return PixelStyle.Pixels.new(1, 1)
|
||
var first := style._image("test/once", make)
|
||
var again := style._image("test/once", make)
|
||
check_eq(again, first, "the same picture")
|
||
check_eq(made[0], 1, "made once")
|
||
|
||
|
||
func test_a_figure_walks_in_four_steps_and_faces_both_ways() -> void:
|
||
var style := WarmStyle.new()
|
||
var frames: Array[PackedByteArray] = []
|
||
for frame in 5:
|
||
var image := style._figure_image(0, frame, false).image
|
||
check_eq(image.get_size(), PixelStyle.FIGURE_SIZE, "a tile wide, one and a half tall")
|
||
check(not frames.has(image.get_data()), "frame %d differs from the others" % frame)
|
||
frames.append(image.get_data())
|
||
var left := style._figure_image(0, 1, true).image
|
||
left.flip_x()
|
||
check_eq(left.get_data(), frames[1], "facing left mirrors facing right")
|
||
var other := style._figure_image(1, 0, false).image
|
||
check(other.get_data() != frames[0], "another player has another colour")
|
||
```
|
||
|
||
Create `game/tests/test_warm_style.gd`:
|
||
|
||
```gdscript
|
||
extends TestCase
|
||
|
||
|
||
func test_four_players_have_four_colours() -> void:
|
||
var style := WarmStyle.new()
|
||
var colours := {}
|
||
for player in 4:
|
||
colours[style.player_color(player)] = true
|
||
check_eq(colours.size(), 4, "four players, four colours")
|
||
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
|
||
```
|
||
|
||
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
|
||
|
||
Run:
|
||
|
||
```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 `130 passed, 2 failed`. `test_pixel_style.gd` lädt nicht; davor stehen Zeilen wie `SCRIPT ERROR: Parse Error: Identifier "PixelStyle" not declared in the current scope.` Unter `FAIL test_warm_style.gd test_four_players_have_four_colours` steht `four players, four colours: expected 4, got 2`, denn der Platzhalter kennt nur zwei Farben.
|
||
|
||
- [ ] **Step 3: Pixelgrafik, Palette und scharfe Pixel einbauen**
|
||
|
||
Create `game/scripts/art/pixel_style.gd`:
|
||
|
||
```gdscript
|
||
class_name PixelStyle
|
||
extends ArtStyle
|
||
## Pixel art in the manner of classic farming games, drawn in code. A tile
|
||
## is 16 by 16 pixels, seen a little from the front: rock shows its face
|
||
## toward open ground, and figures, animals and plants stand upright. Every
|
||
## graphic is made once as a small image, pixel by pixel from the palette,
|
||
## and kept; the camera enlarges each pixel to a crisp square.
|
||
##
|
||
## The subclasses choose the palette and the outlines.
|
||
|
||
## Pixels along the edge of a tile.
|
||
const TILE := 16
|
||
## Rows of the face that rock shows toward open ground below it.
|
||
const FACE := 6
|
||
## Images of a burst of chips, from the hit to its end.
|
||
const HIT_FRAMES := 6
|
||
## Size of a figure: a tile wide and one and a half tiles tall.
|
||
const FIGURE_SIZE := Vector2i(16, 24)
|
||
const CLEAR := Color(0, 0, 0, 0)
|
||
|
||
## Colours by name; the subclass fills them.
|
||
var palette: Dictionary[String, Color] = {}
|
||
## The colours of the players, in the order of their numbers.
|
||
var players: Array[Color] = []
|
||
## True for outlines in a darker shade of what they surround, false for one
|
||
## dark outline colour.
|
||
var tinted_outlines := false
|
||
var _textures: Dictionary[String, Texture2D] = {}
|
||
|
||
|
||
## A small image that the drawings put their pixels on.
|
||
class Pixels:
|
||
const SIDES: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
|
||
const CLEAR := Color(0, 0, 0, 0)
|
||
|
||
var image: Image
|
||
var size: Vector2i
|
||
|
||
func _init(width: int, height: int) -> void:
|
||
size = Vector2i(width, height)
|
||
image = Image.create_empty(width, height, false, Image.FORMAT_RGBA8)
|
||
|
||
func has(x: int, y: int) -> bool:
|
||
return x >= 0 and y >= 0 and x < size.x and y < size.y
|
||
|
||
func put(x: int, y: int, color: Color) -> void:
|
||
if has(x, y):
|
||
image.set_pixel(x, y, color)
|
||
|
||
## The colour at x, y, transparent outside the image.
|
||
func at(x: int, y: int) -> Color:
|
||
return image.get_pixel(x, y) if has(x, y) else CLEAR
|
||
|
||
func filled(x: int, y: int) -> bool:
|
||
return at(x, y).a > 0.0
|
||
|
||
## Lays the translucent `color` over the pixel at x, y.
|
||
func shade(x: int, y: int, color: Color) -> void:
|
||
if has(x, y):
|
||
image.set_pixel(x, y, image.get_pixel(x, y).blend(color))
|
||
|
||
func rect(x: int, y: int, width: int, height: int, color: Color) -> void:
|
||
for row in range(y, y + height):
|
||
for column in range(x, x + width):
|
||
put(column, row, color)
|
||
|
||
## Fills the ellipse around cx, cy with the half axes rx and ry.
|
||
func ellipse(cx: float, cy: float, rx: float, ry: float, color: Color) -> void:
|
||
for row in range(floori(cy - ry), ceili(cy + ry) + 1):
|
||
for column in range(floori(cx - rx), ceili(cx + rx) + 1):
|
||
var dx := (column + 0.5 - cx) / rx
|
||
var dy := (row + 0.5 - cy) / ry
|
||
if dx * dx + dy * dy <= 1.0:
|
||
put(column, row, color)
|
||
|
||
## A line of single pixels from x0, y0 to x1, y1.
|
||
func line(x0: int, y0: int, x1: int, y1: int, color: Color) -> void:
|
||
var dx := absi(x1 - x0)
|
||
var dy := -absi(y1 - y0)
|
||
var sx := 1 if x0 < x1 else -1
|
||
var sy := 1 if y0 < y1 else -1
|
||
var error := dx + dy
|
||
var x := x0
|
||
var y := y0
|
||
while true:
|
||
put(x, y, color)
|
||
if x == x1 and y == y1:
|
||
return
|
||
var twice := 2 * error
|
||
if twice >= dy:
|
||
error += dy
|
||
x += sx
|
||
if twice <= dx:
|
||
error += dx
|
||
y += sy
|
||
|
||
## Surrounds everything solid drawn so far with a line one pixel wide: in
|
||
## `color`, or, with `tinted`, in a darker shade of the pixel beside it.
|
||
func outline(color: Color, tinted: bool) -> void:
|
||
var found: Array[Vector3i] = []
|
||
var colors: Array[Color] = []
|
||
for y in size.y:
|
||
for x in size.x:
|
||
if filled(x, y):
|
||
continue
|
||
for side in SIDES:
|
||
var beside := at(x + side.x, y + side.y)
|
||
if beside.a >= 1.0:
|
||
found.append(Vector3i(x, y, 0))
|
||
colors.append(beside.darkened(0.5) if tinted else color)
|
||
break
|
||
for index in found.size():
|
||
put(found[index].x, found[index].y, colors[index])
|
||
|
||
|
||
func player_color(player: int) -> Color:
|
||
return players[maxi(0, player) % players.size()]
|
||
|
||
|
||
func _c(name: String) -> Color:
|
||
return palette[name]
|
||
|
||
|
||
## The dark line around things, or around a thing of colour `fill` when
|
||
## outlines are tinted.
|
||
func _ink(fill: Color) -> Color:
|
||
return fill.darkened(0.5) if tinted_outlines else _c("outline")
|
||
|
||
|
||
## A number from 0 to 1 that looks random and is the same for the same
|
||
## arguments.
|
||
static func _noise(x: int, y: int, salt: int) -> float:
|
||
return float(TileShape.variant(Vector2i(x * 31 + salt * 977, y * 17 - salt * 131)) % 1000) / 1000.0
|
||
|
||
|
||
## The texture made by `make` under `key`, made only the first time.
|
||
func _image(key: String, make: Callable) -> Texture2D:
|
||
var texture: Texture2D = _textures.get(key)
|
||
if texture == null:
|
||
var pixels: Pixels = make.call()
|
||
texture = ImageTexture.create_from_image(pixels.image)
|
||
_textures[key] = texture
|
||
return texture
|
||
|
||
|
||
## Draws `texture` with its top left corner at `at`, on whole pixels.
|
||
static func _put(canvas: CanvasItem, texture: Texture2D, at: Vector2, modulate := Color.WHITE) -> void:
|
||
canvas.draw_texture(texture, at.round(), modulate)
|
||
|
||
|
||
## The neighbours of `mask` that change how a tile looks: the four sides,
|
||
## and a corner only where both sides beside it are set.
|
||
static func _shape(mask: int) -> int:
|
||
var result := mask & (TileShape.N | TileShape.E | TileShape.S | TileShape.W)
|
||
var corners := [
|
||
[TileShape.NE, TileShape.N | TileShape.E], [TileShape.SE, TileShape.S | TileShape.E],
|
||
[TileShape.SW, TileShape.S | TileShape.W], [TileShape.NW, TileShape.N | TileShape.W],
|
||
]
|
||
for corner: Array in corners:
|
||
var sides: int = corner[1]
|
||
if (mask & corner[0]) != 0 and (mask & sides) == sides:
|
||
result |= corner[0]
|
||
return result
|
||
|
||
|
||
# Terrain.
|
||
|
||
|
||
func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
|
||
var variant: int = state.get("variant", 0) % 8
|
||
_put(canvas, _image("ground/%d/%d" % [shadow, variant], func() -> Pixels: return _earth(variant, shadow)), rect.position)
|
||
|
||
|
||
## The bare earth of the dead world: up to two soft patches of darker earth,
|
||
## a light grain or two, now and then a pebble.
|
||
func _earth(variant: int, shadow: int) -> Pixels:
|
||
var pixels := Pixels.new(TILE, TILE)
|
||
pixels.rect(0, 0, TILE, TILE, _c("ground"))
|
||
var patches: int = [0, 1, 2, 1, 0, 1, 1, 2][variant]
|
||
for patch in patches:
|
||
var at := _spot(patch, variant, 2, 11)
|
||
for dy in 2:
|
||
for dx in range(-1, 2):
|
||
if _noise(at.x + dx, at.y + dy, variant + 40) < 0.6:
|
||
pixels.put(at.x + dx, at.y + dy, _c("ground_dark"))
|
||
for grain in 1 + variant % 2:
|
||
var at := _spot(grain + 4, variant, 1, 14)
|
||
pixels.put(at.x, at.y, _c("ground_light"))
|
||
if variant % 4 == 1:
|
||
var at := _spot(9, variant, 3, 9)
|
||
_pebble(pixels, at.x, at.y)
|
||
_cast_shadow(pixels, shadow)
|
||
return pixels
|
||
|
||
|
||
## A point in the tile that is the same for the same `index` and `variant`,
|
||
## from `low` to `low + span - 1` on both axes.
|
||
static func _spot(index: int, variant: int, low: int, span: int) -> Vector2i:
|
||
return Vector2i(low + int(_noise(index, 1, variant) * span), low + int(_noise(index, 2, variant) * span))
|
||
|
||
|
||
func _pebble(pixels: Pixels, x: int, y: int) -> void:
|
||
pixels.put(x, y, _c("pebble_light"))
|
||
pixels.put(x + 1, y, _c("pebble"))
|
||
pixels.put(x, y + 1, _c("pebble"))
|
||
pixels.put(x + 1, y + 1, _c("pebble_dark"))
|
||
|
||
|
||
## Darkens the ground where rock to the north or west casts its shadow; the
|
||
## light falls from the top left.
|
||
func _cast_shadow(pixels: Pixels, shadow: int) -> void:
|
||
var deep := Color(0, 0, 0, 0.32)
|
||
var faint := Color(0, 0, 0, 0.16)
|
||
if (shadow & TileShape.N) != 0:
|
||
for x in TILE:
|
||
pixels.shade(x, 0, deep)
|
||
pixels.shade(x, 1, deep if x % 2 == 0 else faint)
|
||
pixels.shade(x, 2, faint if x % 2 == 1 else CLEAR)
|
||
if (shadow & TileShape.W) != 0:
|
||
for y in range(1 if (shadow & TileShape.N) != 0 else 0, TILE):
|
||
pixels.shade(0, y, faint)
|
||
elif (shadow & (TileShape.N | TileShape.NW)) == TileShape.NW:
|
||
pixels.shade(0, 0, deep)
|
||
|
||
|
||
func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
_rock(canvas, rect, state, "soft")
|
||
|
||
|
||
func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
_rock(canvas, rect, state, "hard")
|
||
|
||
|
||
func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
_rock(canvas, rect, state, "bed")
|
||
|
||
|
||
## Rock of `kind`: its top, and its face where the ground below is open.
|
||
## The ground shows where the outer corners are round.
|
||
func _rock(canvas: CanvasItem, rect: Rect2, state: Dictionary, kind: String) -> void:
|
||
var mask := _shape(state.get("solid", 0))
|
||
var variant: int = state.get("variant", 0) % 4
|
||
_ground(canvas, rect, {"variant": state.get("variant", 0)})
|
||
var key := "%s/%d/%d" % [kind, mask, variant]
|
||
_put(canvas, _image(key, func() -> Pixels: return _rock_image(kind, mask, variant)), rect.position)
|
||
|
||
|
||
func _rock_image(kind: String, mask: int, variant: int) -> Pixels:
|
||
var open_n := (mask & TileShape.N) == 0
|
||
var open_e := (mask & TileShape.E) == 0
|
||
var open_s := (mask & TileShape.S) == 0
|
||
var open_w := (mask & TileShape.W) == 0
|
||
var top_rows := TILE - FACE if open_s else TILE
|
||
var ink := _c("outline")
|
||
var pixels := Pixels.new(TILE, TILE)
|
||
for y in TILE:
|
||
for x in TILE:
|
||
var color := _c(kind + "_top")
|
||
if y >= top_rows:
|
||
color = _c(kind + "_face")
|
||
if y == top_rows or ((x + variant) % 4 == 0 and y < TILE - 2):
|
||
color = _c(kind + "_face_dark")
|
||
else:
|
||
if (open_n and y == 1) or (open_w and x == 1):
|
||
color = _c(kind + "_top_light")
|
||
elif open_e and x == TILE - 2:
|
||
color = _c(kind + "_top_dark")
|
||
if open_s and y == top_rows - 1:
|
||
color = _c(kind + "_lip")
|
||
pixels.put(x, y, color)
|
||
_rock_marks(pixels, kind, variant, top_rows)
|
||
if open_n:
|
||
pixels.rect(0, 0, TILE, 1, ink)
|
||
if open_w:
|
||
pixels.rect(0, 0, 1, TILE, ink)
|
||
if open_e:
|
||
pixels.rect(TILE - 1, 0, 1, TILE, ink)
|
||
if open_s:
|
||
pixels.rect(0, TILE - 1, TILE, 1, ink)
|
||
# Inner corners, where the mass turns around open ground.
|
||
if not open_n and not open_w and (mask & TileShape.NW) == 0:
|
||
pixels.put(0, 0, ink)
|
||
if not open_n and not open_e and (mask & TileShape.NE) == 0:
|
||
pixels.put(TILE - 1, 0, ink)
|
||
if not open_s and not open_w and (mask & TileShape.SW) == 0:
|
||
pixels.rect(0, TILE - FACE, 1, FACE, ink)
|
||
if not open_s and not open_e and (mask & TileShape.SE) == 0:
|
||
pixels.rect(TILE - 1, TILE - FACE, 1, FACE, ink)
|
||
# Round outer corners.
|
||
if open_n and open_w:
|
||
_round(pixels, Vector2i(0, 0), Vector2i(1, 1), ink)
|
||
if open_n and open_e:
|
||
_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), ink)
|
||
if open_s and open_w:
|
||
_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), ink)
|
||
if open_s and open_e:
|
||
_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), ink)
|
||
return pixels
|
||
|
||
|
||
## Cuts the corner at `corner` and closes the outline one pixel further in,
|
||
## in the direction `inward`.
|
||
static func _round(pixels: Pixels, corner: Vector2i, inward: Vector2i, ink: Color) -> void:
|
||
pixels.put(corner.x, corner.y, CLEAR)
|
||
pixels.put(corner.x + inward.x, corner.y, CLEAR)
|
||
pixels.put(corner.x, corner.y + inward.y, CLEAR)
|
||
pixels.put(corner.x + inward.x, corner.y + inward.y, ink)
|
||
|
||
|
||
## Small details on the top of rock: pores and chips on soft rock, glinting
|
||
## crystals in hard rock, layers in bedrock.
|
||
func _rock_marks(pixels: Pixels, kind: String, variant: int, top_rows: int) -> void:
|
||
var room := maxi(1, top_rows - 6)
|
||
for pore in 2:
|
||
var spot := Vector2i(3 + int(_noise(pore, 8, variant) * 10.0), 3 + int(_noise(pore, 9, variant) * room))
|
||
pixels.put(spot.x, spot.y, _c(kind + "_top_dark"))
|
||
var at := Vector2i(4 + int(_noise(7, 8, variant) * 7.0), 3 + int(_noise(7, 9, variant) * room))
|
||
match kind:
|
||
"soft":
|
||
if variant % 2 == 0:
|
||
pixels.put(at.x, at.y, _c("soft_top_dark"))
|
||
pixels.put(at.x + 1, at.y, _c("soft_top_dark"))
|
||
pixels.put(at.x + 1, at.y - 1, _c("soft_top_light"))
|
||
pixels.put(at.x + 2, at.y + 1, _c("soft_top_dark"))
|
||
"hard":
|
||
if variant % 2 == 1:
|
||
pixels.put(at.x, at.y, _c("hard_glint"))
|
||
pixels.put(at.x + 1, at.y, _c("hard_top_light"))
|
||
pixels.put(at.x, at.y + 1, _c("hard_top_light"))
|
||
"bed":
|
||
for row in range(4, top_rows - 1, 5):
|
||
for column in range(3, TILE - 3):
|
||
if _noise(column, row, variant) < 0.5:
|
||
pixels.put(column, row, _c("bed_top_dark"))
|
||
|
||
|
||
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var hits := clampi(state.get("hits", 1), 1, 3)
|
||
var variant: int = state.get("variant", 0) % 4
|
||
_put(canvas, _image("cracks/%d/%d" % [hits, variant], func() -> Pixels: return _crack_image(hits, variant)), rect.position)
|
||
|
||
|
||
func _crack_image(hits: int, variant: int) -> Pixels:
|
||
var pixels := Pixels.new(TILE, TILE)
|
||
var path: Array[Vector2i] = []
|
||
var at := Vector2i(5 + variant, 3)
|
||
for step in 2 + hits * 3:
|
||
path.append(at)
|
||
var turn := _noise(step, hits, variant)
|
||
at += Vector2i(-1 if turn < 0.3 else (1 if turn > 0.65 else 0), 1)
|
||
if hits >= 2:
|
||
var branch: Vector2i = path[path.size() >> 1]
|
||
for step in 3:
|
||
branch += Vector2i(1, 1 if step % 2 == 0 else 0)
|
||
path.append(branch)
|
||
for point in path:
|
||
pixels.put(point.x + 1, point.y, _c("soft_top_light"))
|
||
for point in path:
|
||
pixels.put(point.x, point.y, _c("outline"))
|
||
return pixels
|
||
|
||
|
||
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var frame := clampi(int(float(state.get("age", 0.0)) * HIT_FRAMES), 0, HIT_FRAMES - 1)
|
||
var variant: int = state.get("variant", 0) % 4
|
||
_put(canvas, _image("hit/%d/%d" % [frame, variant], func() -> Pixels: return _hit_image(frame, variant)), rect.position - Vector2(8, 8))
|
||
|
||
|
||
## Chips that fly up and fall, and a puff of dust, in a square of two tiles
|
||
## around the tile.
|
||
func _hit_image(frame: int, variant: int) -> Pixels:
|
||
var pixels := Pixels.new(TILE * 2, TILE * 2)
|
||
var t := (frame + 1.0) / HIT_FRAMES
|
||
for chip in 5:
|
||
var angle := -PI / 2.0 + (chip - 2) * 0.6 + (variant - 1.5) * 0.15
|
||
var distance := 3.0 + t * 10.0
|
||
var x := roundi(TILE + cos(angle) * distance)
|
||
var y := roundi(TILE - 1 + sin(angle) * distance + t * t * 10.0)
|
||
pixels.put(x, y, _c("soft_top_light"))
|
||
if frame < 3 and chip % 2 == 0:
|
||
pixels.put(x + 1, y, _c("soft_top"))
|
||
pixels.put(x, y + 1, _c("soft_face"))
|
||
pixels.put(x + 1, y + 1, _c("soft_face_dark"))
|
||
if frame < 3:
|
||
pixels.outline(_c("outline"), tinted_outlines)
|
||
var radius := 3.0 + frame * 2.0
|
||
for puff in 8:
|
||
var angle := TAU * puff / 8.0 + variant
|
||
var dust := _c("dust")
|
||
dust.a = 0.75 - frame * 0.2
|
||
pixels.put(roundi(TILE + cos(angle) * radius), roundi(TILE - 1 + sin(angle) * radius * 0.6), dust)
|
||
return pixels
|
||
|
||
|
||
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var reachable: bool = state.get("reachable", false)
|
||
_put(canvas, _image("marker/%s" % reachable, func() -> Pixels: return _marker_image(reachable)), rect.position - Vector2(1, 1))
|
||
|
||
|
||
## Four corners around the tile, with a dark rim that shows on any ground.
|
||
func _marker_image(reachable: bool) -> Pixels:
|
||
var pixels := Pixels.new(TILE + 2, TILE + 2)
|
||
var color := _c("marker_near") if reachable else _c("marker_far")
|
||
var last := TILE
|
||
for corner: Vector2i in [Vector2i(1, 1), Vector2i(last, 1), Vector2i(1, last), Vector2i(last, last)]:
|
||
var dx := 1 if corner.x == 1 else -1
|
||
var dy := 1 if corner.y == 1 else -1
|
||
for i in 4:
|
||
pixels.put(corner.x + dx * i, corner.y, color)
|
||
pixels.put(corner.x, corner.y + dy * i, color)
|
||
pixels.outline(_c("outline"), false)
|
||
return pixels
|
||
|
||
|
||
# Figures.
|
||
|
||
|
||
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var frame := 0
|
||
if state.get("moving", false):
|
||
frame = 1 + int(fposmod(float(state.get("step", 0.0)), TAU) / TAU * 4.0) % 4
|
||
var colour := maxi(0, state.get("player", -1)) % players.size()
|
||
var left: bool = float(state.get("facing", 1.0)) < 0.0
|
||
var foot := Vector2(rect.get_center().x, rect.end.y)
|
||
_put(canvas, _image("shadow/12", func() -> Pixels: return _shadow_image(12)), foot - Vector2(6, 2))
|
||
var key := "figure/%d/%d/%s" % [colour, frame, left]
|
||
_put(canvas, _image(key, func() -> Pixels: return _figure_image(colour, frame, left)), foot - Vector2(FIGURE_SIZE.x / 2.0, FIGURE_SIZE.y))
|
||
|
||
|
||
## A soft shadow on the ground, `width` pixels wide.
|
||
func _shadow_image(width: int) -> Pixels:
|
||
var pixels := Pixels.new(width, 3)
|
||
pixels.ellipse(width / 2.0, 1.5, width / 2.0, 1.5, Color(0, 0, 0, 0.28))
|
||
return pixels
|
||
|
||
|
||
## A figure in walking frame `frame` (0 standing, 1 to 4 walking), facing
|
||
## right or, with `left`, left. The shirt has the colour of the player.
|
||
func _figure_image(colour: int, frame: int, left: bool) -> Pixels:
|
||
var pixels := Pixels.new(FIGURE_SIZE.x, FIGURE_SIZE.y)
|
||
var shirt := players[colour]
|
||
var bob := -1 if frame == 2 or frame == 4 else 0
|
||
# The far leg and the near leg, by frame.
|
||
var legs: Array[Vector2i] = [Vector2i(6, 9), Vector2i(5, 10), Vector2i(7, 8), Vector2i(10, 5), Vector2i(8, 7)]
|
||
for leg in 2:
|
||
var x := legs[frame].x if leg == 0 else legs[frame].y
|
||
var lifted := 1 if frame > 0 and frame % 2 == 1 and leg == 0 else 0
|
||
pixels.rect(x, 18, 2, 4 - lifted, _c("pants_dark") if leg == 0 else _c("pants"))
|
||
pixels.rect(x, 22 - lifted, 3, 2, _c("boots"))
|
||
pixels.rect(5, 11 + bob, 6, 8, shirt)
|
||
pixels.rect(5, 11 + bob, 1, 8, shirt.darkened(0.2))
|
||
pixels.rect(5, 18 + bob, 6, 1, shirt.darkened(0.35))
|
||
var swing: int = [0, 1, 0, -1, 0][frame]
|
||
pixels.rect(8 + swing, 12 + bob, 2, 5, shirt.darkened(0.15))
|
||
pixels.rect(8 + swing, 17 + bob, 2, 1, _c("skin"))
|
||
# The head: hair behind, the face in front, bangs above the eye.
|
||
pixels.ellipse(7.8, 5.8 + bob, 5.2, 5.0, _c("hair"))
|
||
pixels.ellipse(9.6, 7.4 + bob, 3.7, 3.9, _c("skin"))
|
||
pixels.rect(9, 3 + bob, 4, 2, _c("hair"))
|
||
pixels.put(6, 2 + bob, _c("hair_light"))
|
||
pixels.put(7, 2 + bob, _c("hair_light"))
|
||
pixels.put(8, 1 + bob, _c("hair_light"))
|
||
pixels.put(11, 6 + bob, _c("eye"))
|
||
pixels.put(11, 7 + bob, _c("eye"))
|
||
pixels.put(8, 8 + bob, _c("skin_shade"))
|
||
pixels.put(12, 9 + bob, _c("skin_shade"))
|
||
pixels.outline(_ink(shirt), tinted_outlines)
|
||
if left:
|
||
pixels.image.flip_x()
|
||
return pixels
|
||
```
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/art/warm_style.gd` durch:
|
||
|
||
```gdscript
|
||
class_name WarmStyle
|
||
extends PixelStyle
|
||
## Warm, full colours and dark outlines around figures and things, close to
|
||
## the classic farming games.
|
||
|
||
const KEY := &"warm"
|
||
|
||
|
||
func _init() -> void:
|
||
palette = {
|
||
"outline": Color("3a2820"), "dust": Color("e9dcc6"),
|
||
"ground": Color("b98a5e"), "ground_dark": Color("a07650"), "ground_light": Color("cba077"),
|
||
"pebble": Color("8e8072"), "pebble_light": Color("b8ab9a"), "pebble_dark": Color("6b5f54"),
|
||
"dead_crack": Color("8a6646"),
|
||
"soft_top": Color("bcb0a0"), "soft_top_dark": Color("a69a8a"), "soft_top_light": Color("d6ccbd"),
|
||
"soft_face": Color("8f8170"), "soft_face_dark": Color("776a5b"), "soft_lip": Color("e4dccf"),
|
||
"hard_top": Color("7c88a2"), "hard_top_dark": Color("68738c"), "hard_top_light": Color("9aa6c0"),
|
||
"hard_face": Color("596380"), "hard_face_dark": Color("454d66"), "hard_lip": Color("b6c2da"),
|
||
"hard_glint": Color("dff4ff"),
|
||
"bed_top": Color("51453e"), "bed_top_dark": Color("433832"), "bed_top_light": Color("62554c"),
|
||
"bed_face": Color("382e29"), "bed_face_dark": Color("2b231f"), "bed_lip": Color("6e6157"),
|
||
"grass": Color("64a83f"), "grass_dark": Color("4e8f33"), "grass_light": Color("8acb52"),
|
||
"grass_edge": Color("3f7a2c"),
|
||
"flower_a": Color("f7e25c"), "flower_b": Color("fbf6ea"), "flower_c": Color("ef7fa6"),
|
||
"moss": Color("4e9a3a"), "moss_dark": Color("3b7b2e"), "moss_light": Color("7ec552"), "spore": Color("ffe57e"),
|
||
"vine": Color("4f9439"), "vine_dark": Color("38702a"), "leaf": Color("68b545"), "leaf_dark": Color("4c8d35"),
|
||
"stem": Color("5c9c3c"), "bud": Color("acd97a"), "fibre": Color("f8f3e6"), "fibre_shade": Color("dcd3c0"),
|
||
"seed": Color("7b5433"),
|
||
"stone": Color("a29a8f"), "stone_light": Color("c8c1b6"), "stone_dark": Color("7c756d"),
|
||
"wool": Color("f5f0e6"), "wool_shade": Color("dbd2c2"), "wool_light": Color("ffffff"),
|
||
"face": Color("4b3a31"), "hoof": Color("35281f"),
|
||
"ruin": Color("aaa395"), "ruin_dark": Color("8a8376"), "ruin_light": Color("c6bfb2"),
|
||
"ruin_moss": Color("5f9f43"), "door": Color("2b231f"), "glow": Color("ffd560"), "glow_light": Color("fff4c6"),
|
||
"skin": Color("f4cba5"), "skin_shade": Color("dba67f"), "hair": Color("6e4128"), "hair_light": Color("8f5a37"),
|
||
"eye": Color("2a1b15"), "pants": Color("4b5b8d"), "pants_dark": Color("3a4672"), "boots": Color("5b3a26"),
|
||
"marker_near": Color("fff6c2"), "marker_far": Color("ff705c"),
|
||
}
|
||
players = [
|
||
Color("d9573f"), Color("3f7fd1"), Color("e9b23c"), Color("4ea84a"),
|
||
Color("9a62c9"), Color("39a9a0"), Color("e271a1"), Color("e8873b"),
|
||
]
|
||
|
||
|
||
func key() -> StringName:
|
||
return KEY
|
||
|
||
|
||
func title_key() -> String:
|
||
return "STYLE_WARM"
|
||
```
|
||
|
||
Ersetze den gesamten Inhalt von `game/project.godot` durch die folgende Fassung. Neu sind die letzten beiden Zeilen im Abschnitt `[rendering]`: Texturfilterung „Nearest“ und Einrasten auf ganze Pixel.
|
||
|
||
```ini
|
||
; Engine configuration file.
|
||
; Input actions are registered in code (scripts/input_setup.gd), so this file
|
||
; holds no serialized input events.
|
||
|
||
config_version=5
|
||
|
||
[application]
|
||
|
||
config/name="Grovewright"
|
||
run/main_scene="res://main.tscn"
|
||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||
|
||
[display]
|
||
|
||
window/size/viewport_width=1280
|
||
window/size/viewport_height=720
|
||
|
||
[rendering]
|
||
|
||
renderer/rendering_method="gl_compatibility"
|
||
renderer/rendering_method.mobile="gl_compatibility"
|
||
textures/canvas_textures/default_texture_filter=0
|
||
2d/snap/snap_2d_transforms_to_pixel=true
|
||
```
|
||
|
||
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
godot --headless --path game --import
|
||
godot --headless --path game -s res://tests/run_tests.gd
|
||
```
|
||
|
||
Expected: Exit-Code 0 und `138 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
|
||
|
||
- [ ] **Step 5: Bildschirmfotos ansehen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-karte.png" --style=warm
|
||
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-vitrine.png" --style=warm --showcase
|
||
```
|
||
|
||
Expected: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Alle Pixel sind scharfe Quadrate, nichts ist verschwommen.
|
||
|
||
- Spiel: Warmer, brauner Erdboden mit wenigen dunkleren Flecken, hellen Körnern und kleinen Kieseln. Weicher Fels ist ein hellgraues Plateau mit dunklem Umriss, außen abgerundet und innen geschlossen. Zum offenen Boden hin zeigt er unten eine dunklere Vorderkante mit senkrechten Streifen und wirft einen Schatten auf den Boden darunter. Im getroffenen Fels steht ein dunkler Riss mit Splittern. Die beiden Figuren stehen auf einem kleinen Schatten: braune Haare, helles Gesicht mit Auge, rotes Hemd, blaue Hose und Stiefel. Über der linken steht scharf „Spieler“.
|
||
- Vitrine: Oben steht „Stilprobe: Warm“. Oben links liegen Grundgestein (fast schwarzbraun mit Schichten), weicher Fels (hell) und harter Fels (blaugrau mit hellen Glanzpunkten) als Pixelgrafik mit Vorderkanten. Die beiden Figuren in Rot und Blau sind Pixelfiguren. Alles andere zeigt noch die Platzhalter von `ArtStyle`: graubraunen und grünen Boden, Kreise für Moos, Tier und Stein, einen Strich als Pflanze, eine Linie als Ranke, ein graues Feld mit gelbem Kreis als Ruine. An den runden Ecken des Felses blitzt der braune Boden der Karte durch; das gleicht Task 6 aus, weil toter Boden dann dieselbe Erde ist.
|
||
|
||
- [ ] **Step 6: Alles prüfen**
|
||
|
||
Run:
|
||
|
||
```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'
|
||
Pixelgrafik für Boden, Fels und Figuren
|
||
|
||
PixelStyle zeichnet jede Grafik Pixel für Pixel in ein kleines Bild,
|
||
16 mal 16 Pixel pro Kachel, und hebt es auf. Fels ist außen rund, innen
|
||
geschlossen und zeigt zum offenen Boden hin seine Vorderkante; Figuren
|
||
stehen aufrecht und haben vier Laufbilder. Warm bekommt als erster Stil
|
||
seine Palette und acht Spielerfarben. Die Texturfilterung steht auf
|
||
Nearest, damit die Pixel scharf bleiben.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
EOF
|
||
git push
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Pixelgrafik für die Dinge der Vitrine
|
||
|
||
Der letzte Abschnitt von `PixelStyle` zeichnet, was die Vitrine aus späteren Meilensteinen zeigt: toten Boden mit Rissen, lebendigen Boden mit Gras, Blumen und unregelmäßigem Rand, Moos mit funkelnden Sporen, die Ranke, die wachsende Faserpflanze, den Stein, das Weidetier und die Ruine mit ihrem Licht.
|
||
|
||
**Files:**
|
||
- Modify: `game/scripts/art/pixel_style.gd` (zwei Stellen einfügen, einen Abschnitt anhängen)
|
||
- Test: `game/tests/test_pixel_things.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: alles aus Task 5, dazu `ArtStyle.sway` und `ArtStyle.along`
|
||
- Produces:
|
||
- `PixelStyle.GROWTH_STAGES := 6` und das Feld `glow: float`, das eine Unterklasse setzt
|
||
- die Zeichnungen für `DEAD_SOIL`, `LIVING_SOIL`, `MOSS`, `VINE`, `FIBER_PLANT`, `STONE`, `GRAZER` und `RUIN`
|
||
- die Bilder `_crack_soil(pixels: Pixels, variant: int) -> Pixels`, `_grass_image(living: int, variant: int) -> Pixels`, `_moss_image(variant: int, twinkle: int) -> Pixels`, `_vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels`, `_plant_image(stage: int, lean: int) -> Pixels`, `_stone_image() -> Pixels`, `_grazer_image(tucked: bool, left: bool) -> Pixels`, `_ruin_image() -> Pixels` und `_ruin_glow() -> Pixels`
|
||
|
||
- [ ] **Step 1: Fehlschlagenden Test schreiben**
|
||
|
||
Create `game/tests/test_pixel_things.gd`:
|
||
|
||
```gdscript
|
||
extends TestCase
|
||
## The pixel art of the things of later milestones in the showcase.
|
||
|
||
|
||
## The highest row of `image` with a pixel that is not transparent.
|
||
static func _top(image: Image) -> int:
|
||
for y in image.get_height():
|
||
for x in image.get_width():
|
||
if image.get_pixel(x, y).a > 0.0:
|
||
return y
|
||
return image.get_height()
|
||
|
||
|
||
func test_a_plant_grows_taller_from_stage_to_stage() -> void:
|
||
var style := WarmStyle.new()
|
||
var tops: Array[int] = []
|
||
for stage in PixelStyle.GROWTH_STAGES:
|
||
tops.append(_top(style._plant_image(stage, 0).image))
|
||
for stage in range(1, tops.size()):
|
||
check(tops[stage] < tops[stage - 1], "stage %d is taller than stage %d: %s" % [stage, stage - 1, tops])
|
||
|
||
|
||
func test_grass_has_a_ragged_edge_only_toward_bare_ground() -> void:
|
||
var style := WarmStyle.new()
|
||
var inside := style._grass_image(255, 0).image
|
||
var lone := style._grass_image(0, 0).image
|
||
var tile := PixelStyle.TILE
|
||
var inside_gaps := 0
|
||
var lone_gaps := 0
|
||
for y in tile:
|
||
for x in tile:
|
||
inside_gaps += 1 if inside.get_pixel(x, y).a == 0.0 else 0
|
||
lone_gaps += 1 if lone.get_pixel(x, y).a == 0.0 else 0
|
||
check_eq(inside_gaps, 0, "living all around: no gaps")
|
||
check(lone_gaps > 4, "bare ground all around: a ragged, round edge (%d gaps)" % lone_gaps)
|
||
check_eq(lone.get_pixel(0, 0).a, 0.0, "round at the corner")
|
||
|
||
|
||
func test_dead_soil_is_the_bare_earth_with_cracks() -> void:
|
||
var style := WarmStyle.new()
|
||
var earth := style._earth(0, 0).image
|
||
var cracked := style._crack_soil(style._earth(0, 0), 0).image
|
||
check(cracked.get_data() != earth.get_data(), "cracks in every other tile")
|
||
check_eq(style._crack_soil(style._earth(1, 0), 1).image.get_data(), style._earth(1, 0).image.get_data(), "none in the others")
|
||
|
||
|
||
func test_an_animal_in_the_air_tucks_its_legs() -> void:
|
||
var style := WarmStyle.new()
|
||
var standing := style._grazer_image(false, false).image
|
||
var tucked := style._grazer_image(true, false).image
|
||
check(standing.get_pixel(5, 14).a > 0.0, "standing on its legs")
|
||
check_eq(tucked.get_pixel(5, 14).a, 0.0, "legs tucked in the air")
|
||
|
||
|
||
func test_the_ruin_glows_in_its_doorway() -> void:
|
||
var style := WarmStyle.new()
|
||
var light := style._ruin_glow().image
|
||
check(light.get_pixel(24, 31).a > 0.5, "bright in the middle of the doorway")
|
||
check_eq(light.get_pixel(3, 30).a, 0.0, "dark on the walls")
|
||
```
|
||
|
||
- [ ] **Step 2: Test ausführen und Fehlschlag sehen**
|
||
|
||
Run:
|
||
|
||
```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 `138 passed, 1 failed`. `test_pixel_things.gd` lädt nicht; davor steht `SCRIPT ERROR: Parse Error: Cannot find member "GROWTH_STAGES" in base "PixelStyle".`
|
||
|
||
- [ ] **Step 3: Die Dinge der Vitrine zeichnen**
|
||
|
||
Füge in `game/scripts/art/pixel_style.gd` direkt vor der Zeile `## Size of a figure: a tile wide and one and a half tiles tall.` diese Zeilen ein:
|
||
|
||
```gdscript
|
||
## Stages of a growing plant, from the seed to the fibres.
|
||
const GROWTH_STAGES := 6
|
||
```
|
||
|
||
Füge in `game/scripts/art/pixel_style.gd` direkt vor der Zeile `var _textures: Dictionary[String, Texture2D] = {}` diese Zeilen ein:
|
||
|
||
```gdscript
|
||
## How strongly living things glow, from 0 (not at all) to 1; the subclass
|
||
## chooses it.
|
||
var glow := 0.0
|
||
```
|
||
|
||
Füge am Ende von `game/scripts/art/pixel_style.gd` diesen Abschnitt an. Wie zwischen allen Funktionen stehen davor zwei Leerzeilen:
|
||
|
||
```gdscript
|
||
# Things of later milestones, shown in the showcase.
|
||
|
||
|
||
func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
|
||
var variant: int = state.get("variant", 0) % 8
|
||
var key := "dead/%d/%d" % [shadow, variant]
|
||
_put(canvas, _image(key, func() -> Pixels: return _crack_soil(_earth(variant, shadow), variant)), rect.position)
|
||
|
||
|
||
## Dry cracks in every other tile of dead soil. Returns `pixels`.
|
||
func _crack_soil(pixels: Pixels, variant: int) -> Pixels:
|
||
if variant % 2 == 0:
|
||
var at := _spot(12, variant, 3, 7)
|
||
for step in 5:
|
||
pixels.put(at.x, at.y, _c("dead_crack"))
|
||
if step == 2:
|
||
pixels.put(at.x, at.y + 1, _c("dead_crack"))
|
||
pixels.put(at.x - 1, at.y + 2, _c("dead_crack"))
|
||
at += Vector2i(1, -1 if _noise(step, 5, variant) < 0.4 else 0)
|
||
return pixels
|
||
|
||
|
||
func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var living := _shape(state.get("living", 0))
|
||
var variant: int = state.get("variant", 0) % 6
|
||
_ground(canvas, rect, {"variant": state.get("variant", 0)})
|
||
_put(canvas, _image("living/%d/%d" % [living, variant], func() -> Pixels: return _grass_image(living, variant)), rect.position)
|
||
|
||
|
||
## Grass with blades and a few flowers, with a soft edge toward the sides
|
||
## that are not living.
|
||
func _grass_image(living: int, variant: int) -> Pixels:
|
||
var open_n := (living & TileShape.N) == 0
|
||
var open_e := (living & TileShape.E) == 0
|
||
var open_s := (living & TileShape.S) == 0
|
||
var open_w := (living & TileShape.W) == 0
|
||
var pixels := Pixels.new(TILE, TILE)
|
||
pixels.rect(0, 0, TILE, TILE, _c("grass"))
|
||
for tuft in 6:
|
||
var at := _spot(tuft + 10, variant, 2, 12)
|
||
pixels.put(at.x, at.y, _c("grass_dark"))
|
||
pixels.put(at.x - 1, at.y - 1, _c("grass_dark"))
|
||
pixels.put(at.x + 1, at.y - 1, _c("grass_dark"))
|
||
for blade in 4:
|
||
var at := _spot(blade + 20, variant, 1, 14)
|
||
pixels.put(at.x, at.y, _c("grass_light"))
|
||
if variant % 3 == 0:
|
||
var x := 4 + variant % 7
|
||
var y := 5 + variant % 5
|
||
var petal := _c("flower_b") if variant % 2 == 0 else _c("flower_c")
|
||
for side in Pixels.SIDES:
|
||
pixels.put(x + side.x, y + side.y, petal)
|
||
pixels.put(x, y, _c("flower_a"))
|
||
if glow > 0.0:
|
||
for spark in 2:
|
||
var light := _c("spore")
|
||
light.a = glow * 0.8
|
||
pixels.put(2 + int(_noise(spark, 21, variant) * 12.0), 2 + int(_noise(spark, 22, variant) * 12.0), light)
|
||
# A ragged edge toward bare ground.
|
||
for i in TILE:
|
||
var ragged := 1 if _noise(i, 23, variant) < 0.45 else 0
|
||
if open_n:
|
||
_edge(pixels, Vector2i(i, 0), Vector2i(0, 1), ragged)
|
||
if open_s:
|
||
_edge(pixels, Vector2i(i, TILE - 1), Vector2i(0, -1), ragged)
|
||
if open_w:
|
||
_edge(pixels, Vector2i(0, i), Vector2i(1, 0), ragged)
|
||
if open_e:
|
||
_edge(pixels, Vector2i(TILE - 1, i), Vector2i(-1, 0), ragged)
|
||
if open_n and open_w:
|
||
_round(pixels, Vector2i(0, 0), Vector2i(1, 1), _c("grass_edge"))
|
||
if open_n and open_e:
|
||
_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), _c("grass_edge"))
|
||
if open_s and open_w:
|
||
_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), _c("grass_edge"))
|
||
if open_s and open_e:
|
||
_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), _c("grass_edge"))
|
||
return pixels
|
||
|
||
|
||
## Clears `cut` pixels at `at` and draws the darker edge of the grass behind
|
||
## them, one step further in the direction `inward`.
|
||
func _edge(pixels: Pixels, at: Vector2i, inward: Vector2i, cut: int) -> void:
|
||
for depth in cut:
|
||
var gone := at + inward * depth
|
||
pixels.put(gone.x, gone.y, CLEAR)
|
||
var edge := at + inward * cut
|
||
pixels.put(edge.x, edge.y, _c("grass_edge"))
|
||
|
||
|
||
func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var variant: int = state.get("variant", 0) % 3
|
||
var twinkle := int(float(state.get("time", 0.0)) * 2.5) % 4
|
||
var foot := Vector2(rect.get_center().x, rect.end.y)
|
||
_put(canvas, _image("moss/%d/%d" % [variant, twinkle], func() -> Pixels: return _moss_image(variant, twinkle)), foot - Vector2(8, 13))
|
||
|
||
|
||
## A cushion of moss whose spores twinkle one after another.
|
||
func _moss_image(variant: int, twinkle: int) -> Pixels:
|
||
var pixels := Pixels.new(TILE, 13)
|
||
var shift := variant - 1
|
||
for bump: Vector4 in [Vector4(4.5 + shift, 9.5, 4, 3.2), Vector4(11, 9.2, 4.3, 3.5), Vector4(7.8 - shift, 6.8, 3.8, 3.4)]:
|
||
pixels.ellipse(bump.x, bump.y, bump.z, bump.w, _c("moss"))
|
||
for y in pixels.size.y:
|
||
for x in pixels.size.x:
|
||
if not pixels.filled(x, y):
|
||
continue
|
||
if y >= 11:
|
||
pixels.put(x, y, _c("moss_dark"))
|
||
elif not pixels.filled(x, y - 1) or not pixels.filled(x, y - 2):
|
||
pixels.put(x, y, _c("moss_light"))
|
||
pixels.outline(_ink(_c("moss")), tinted_outlines)
|
||
var spores: Array[Vector2i] = [Vector2i(4 + shift, 8), Vector2i(8 - shift, 5), Vector2i(12, 8)]
|
||
for index in spores.size():
|
||
var at := spores[index]
|
||
var bright := index == twinkle
|
||
var spore := _c("spore") if bright else _c("spore").darkened(0.2)
|
||
pixels.put(at.x, at.y, spore)
|
||
pixels.put(at.x + 1, at.y, spore)
|
||
if bright:
|
||
pixels.put(at.x, at.y - 1, _c("spore"))
|
||
if glow > 0.0:
|
||
var halo := _c("spore")
|
||
halo.a = 0.45 * glow
|
||
for side in Pixels.SIDES:
|
||
pixels.shade(at.x + side.x, at.y + side.y + 1, halo)
|
||
return pixels
|
||
|
||
|
||
func _vine(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var points: PackedVector2Array = state.get("points", PackedVector2Array())
|
||
if points.size() < 2:
|
||
return
|
||
var origin := (rect.position - Vector2(6, 6)).floor()
|
||
var size := Vector2i(ceili(rect.size.x) + 13, ceili(rect.size.y) + 13)
|
||
var key := "vine/%d/%d/%d" % [hash(points), size.x, size.y]
|
||
_put(canvas, _image(key, func() -> Pixels: return _vine_image(points, origin, size)), origin)
|
||
|
||
|
||
## A vine two pixels thick along `points`, with leaves on alternate sides.
|
||
func _vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels:
|
||
var pixels := Pixels.new(size.x, size.y)
|
||
for i in points.size() - 1:
|
||
var from := Vector2i((points[i] - origin).round())
|
||
var to := Vector2i((points[i + 1] - origin).round())
|
||
pixels.line(from.x, from.y - 1, to.x, to.y - 1, _c("leaf"))
|
||
pixels.line(from.x, from.y, to.x, to.y, _c("vine"))
|
||
pixels.line(from.x, from.y + 1, to.x, to.y + 1, _c("vine_dark"))
|
||
var length := 0.0
|
||
for i in points.size() - 1:
|
||
length += points[i].distance_to(points[i + 1])
|
||
var leaves := int(length / 9.0)
|
||
for leaf in leaves:
|
||
var at := Vector2i((along(points, (leaf + 0.5) / leaves) - origin).round())
|
||
var up := leaf % 2 == 0
|
||
var dy := -2 if up else 2
|
||
var tip := -1 if up else 1
|
||
pixels.put(at.x, at.y + dy, _c("leaf"))
|
||
pixels.put(at.x + 1, at.y + dy, _c("leaf"))
|
||
pixels.put(at.x + 1, at.y + dy + tip, _c("leaf"))
|
||
pixels.put(at.x + 2, at.y + dy + tip, _c("leaf_dark"))
|
||
pixels.outline(_ink(_c("vine")), tinted_outlines)
|
||
return pixels
|
||
|
||
|
||
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var stage := clampi(int(float(state.get("growth", 1.0)) * GROWTH_STAGES), 0, GROWTH_STAGES - 1)
|
||
var lean := clampi(roundi(sway(state.get("time", 0.0), 3) * 1.4), -1, 1)
|
||
var foot := Vector2(rect.get_center().x, rect.end.y)
|
||
_put(canvas, _image("plant/%d/%d" % [stage, lean], func() -> Pixels: return _plant_image(stage, lean)), foot - Vector2(8, 28))
|
||
|
||
|
||
## A fibre plant at growth stage `stage`, its top leaning by `lean` pixels:
|
||
## a seed in a small mound, a sprout, a stalk with leaves, buds and at last
|
||
## white tufts of fibre.
|
||
func _plant_image(stage: int, lean: int) -> Pixels:
|
||
var pixels := Pixels.new(TILE, 28)
|
||
pixels.rect(5, 26, 6, 1, _c("ground_dark"))
|
||
pixels.rect(4, 27, 8, 1, _c("ground_dark"))
|
||
if stage == 0:
|
||
pixels.put(7, 25, _c("seed"))
|
||
pixels.put(8, 25, _c("seed"))
|
||
pixels.outline(_ink(_c("seed")), tinted_outlines)
|
||
return pixels
|
||
var heights: Array[int] = [0, 4, 8, 13, 17, 19]
|
||
var height := heights[stage]
|
||
var top := Vector2i(8, 25)
|
||
for i in height:
|
||
var x := 8 + roundi(lean * 2.0 * i / 19.0)
|
||
top = Vector2i(x, 25 - i)
|
||
pixels.put(top.x, top.y, _c("stem"))
|
||
@warning_ignore("integer_division")
|
||
for pair in range(1, height / 3 + 1):
|
||
var y := 26 - pair * 3
|
||
var x := 8 + roundi(lean * 2.0 * (25 - y) / 19.0)
|
||
var side := 1 if pair % 2 == 0 else -1
|
||
pixels.put(x + side, y, _c("leaf"))
|
||
pixels.put(x + side * 2, y, _c("leaf"))
|
||
pixels.put(x + side * 2, y - 1, _c("leaf"))
|
||
pixels.put(x + side * 3, y - 1, _c("leaf_dark"))
|
||
pixels.put(x - side, y - 1, _c("leaf_dark"))
|
||
if stage == 1:
|
||
pixels.put(top.x - 1, top.y, _c("leaf"))
|
||
pixels.put(top.x + 1, top.y, _c("leaf"))
|
||
if stage == 4:
|
||
pixels.ellipse(top.x + 0.5, top.y - 0.5, 1.6, 1.6, _c("bud"))
|
||
if stage == 5:
|
||
for tuft: Vector3 in [Vector3(0, -2, 2.3), Vector3(-3, 1, 1.8), Vector3(3, 2, 1.8)]:
|
||
pixels.ellipse(top.x + 0.5 + tuft.x, top.y + tuft.y, tuft.z, tuft.z, _c("fibre"))
|
||
pixels.put(top.x + int(tuft.x), top.y + int(tuft.y) + 1, _c("fibre_shade"))
|
||
pixels.outline(_ink(_c("stem")), tinted_outlines)
|
||
return pixels
|
||
|
||
|
||
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
|
||
_put(canvas, _image("stone", func() -> Pixels: return _stone_image()), Vector2(rect.get_center().x - 4, rect.end.y - 8))
|
||
|
||
|
||
func _stone_image() -> Pixels:
|
||
var pixels := Pixels.new(8, 8)
|
||
pixels.ellipse(4, 4.2, 3.2, 2.7, _c("stone"))
|
||
pixels.rect(2, 6, 4, 1, _c("stone_dark"))
|
||
pixels.put(2, 3, _c("stone_light"))
|
||
pixels.put(3, 2, _c("stone_light"))
|
||
pixels.outline(_ink(_c("stone")), tinted_outlines)
|
||
return pixels
|
||
|
||
|
||
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var height := roundi(sin(PI * clampf(state.get("hop", 0.0), 0.0, 1.0)) * 5.0)
|
||
var left: bool = float(state.get("facing", 1.0)) < 0.0
|
||
var foot := Vector2(rect.get_center().x, rect.end.y)
|
||
var width := 14 - height
|
||
_put(canvas, _image("shadow/%d" % width, func() -> Pixels: return _shadow_image(width)), foot - Vector2(width / 2.0, 2))
|
||
var tucked := height > 1
|
||
_put(canvas, _image("grazer/%s/%s" % [tucked, left], func() -> Pixels: return _grazer_image(tucked, left)), foot - Vector2(10, 16 + height))
|
||
|
||
|
||
## A woolly animal, facing right or, with `left`, left; in the air its legs
|
||
## are `tucked`.
|
||
func _grazer_image(tucked: bool, left: bool) -> Pixels:
|
||
var pixels := Pixels.new(20, 16)
|
||
for leg in [5, 7, 11, 13]:
|
||
pixels.rect(leg, 12, 1, 1 if tucked else 3, _c("hoof"))
|
||
for puff: Vector4 in [Vector4(9, 8, 6.5, 4.5), Vector4(5.5, 9.5, 4, 3.5), Vector4(12.5, 9.5, 4.5, 3.5), Vector4(8, 5.8, 4, 3), Vector4(11.5, 6.3, 3.5, 3)]:
|
||
pixels.ellipse(puff.x, puff.y, puff.z, puff.w, _c("wool"))
|
||
for y in pixels.size.y:
|
||
for x in pixels.size.x:
|
||
if pixels.at(x, y) == _c("wool"):
|
||
if y >= 11:
|
||
pixels.put(x, y, _c("wool_shade"))
|
||
elif not pixels.filled(x, y - 1) and _noise(x, y, 4) < 0.6:
|
||
pixels.put(x, y, _c("wool_light"))
|
||
pixels.ellipse(16.2, 8.0, 2.6, 3.2, _c("face"))
|
||
pixels.put(14, 5, _c("face"))
|
||
pixels.put(15, 5, _c("face"))
|
||
pixels.put(17, 7, _c("wool_light"))
|
||
pixels.outline(_ink(_c("wool")), tinted_outlines)
|
||
if left:
|
||
pixels.image.flip_x()
|
||
return pixels
|
||
|
||
|
||
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
|
||
var awake := clampf(state.get("awake", 0.0), 0.0, 1.0)
|
||
_put(canvas, _image("ruin", func() -> Pixels: return _ruin_image()), rect.position)
|
||
_put(canvas, _image("ruin_glow", func() -> Pixels: return _ruin_glow()), rect.position, Color(1, 1, 1, awake))
|
||
|
||
|
||
## A gate of old stone blocks, three tiles wide, with moss on top and a dark
|
||
## doorway.
|
||
func _ruin_image() -> Pixels:
|
||
var pixels := Pixels.new(TILE * 3, TILE * 3)
|
||
var stone := _c("ruin")
|
||
pixels.rect(3, 12, 12, 34, stone)
|
||
pixels.rect(33, 12, 12, 34, stone)
|
||
pixels.rect(1, 6, 46, 9, stone)
|
||
pixels.rect(1, 3, 46, 3, _c("ruin_light"))
|
||
pixels.rect(15, 15, 18, 31, _c("door"))
|
||
pixels.rect(9, 44, 30, 2, _c("ruin_dark"))
|
||
pixels.rect(7, 46, 34, 2, stone)
|
||
# Blocks: a joint every five rows, and between the blocks of each row.
|
||
for course in 8:
|
||
var y := 8 + course * 5
|
||
for x in range(1, 47):
|
||
if pixels.at(x, y) == stone:
|
||
pixels.put(x, y, _c("ruin_dark"))
|
||
var offset := 0 if course % 2 == 0 else 4
|
||
for x in range(3 + offset, 47, 8):
|
||
for row in range(y + 1, y + 5):
|
||
if pixels.at(x, row) == stone:
|
||
pixels.put(x, row, _c("ruin_dark"))
|
||
for x in range(1, 47):
|
||
if _noise(x, 40, 1) < 0.45:
|
||
pixels.put(x, 3, _c("ruin_moss"))
|
||
if _noise(x, 41, 1) < 0.25:
|
||
pixels.put(x, 4, _c("ruin_moss"))
|
||
for strand: Vector2i in [Vector2i(5, 4), Vector2i(12, 3), Vector2i(36, 5), Vector2i(43, 2)]:
|
||
pixels.rect(strand.x, 15, 1, strand.y, _c("ruin_moss"))
|
||
pixels.outline(_ink(stone), tinted_outlines)
|
||
return pixels
|
||
|
||
|
||
## The light in the doorway and the runes above it, shown as the ruin wakes.
|
||
func _ruin_glow() -> Pixels:
|
||
var pixels := Pixels.new(TILE * 3, TILE * 3)
|
||
var center := Vector2(24, 31)
|
||
for y in range(15, 46):
|
||
for x in range(15, 33):
|
||
var distance := Vector2(x + 0.5, y + 0.5).distance_to(center)
|
||
var light := _c("glow_light") if distance < 4.0 else _c("glow")
|
||
light.a = clampf(1.0 - distance / 13.0, 0.0, 1.0) * (0.8 + 0.2 * glow)
|
||
if light.a > 0.05:
|
||
pixels.put(x, y, light)
|
||
for rune in 3:
|
||
var x := 16 + rune * 7
|
||
for dot: Vector2i in [Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(0, 2), Vector2i(1, 2)]:
|
||
pixels.put(x + dot.x + (rune % 2), 8 + dot.y, _c("glow"))
|
||
return pixels
|
||
```
|
||
|
||
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
godot --headless --path game --import
|
||
godot --headless --path game -s res://tests/run_tests.gd
|
||
```
|
||
|
||
Expected: Exit-Code 0 und `143 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
|
||
|
||
- [ ] **Step 5: Bildschirmfoto der Vitrine ansehen**
|
||
|
||
Run: `godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-vitrine.png" --style=warm --showcase`
|
||
|
||
Expected: Der Aufruf endet mit `screenshot saved: …` ohne Warnung. Sieh dir das Bild an. Jetzt ist die ganze Vitrine Pixelgrafik, nirgends steht mehr ein Platzhalter. Toter Boden ist dieselbe braune Erde wie im Spiel, in jeder zweiten Kachel mit einem kleinen Riss. Lebendiger Boden ist sattgrünes Gras mit dunklen Grasbüscheln, hellen Halmen und einzelnen Blumen in Gelb, Weiß und Rosa; sein Rand ist unregelmäßig und dunkler. Dazu kommen Moospolster mit gelben Sporen, die Ranke als grünes Band mit Blättern und drei grauen Steinen, die Faserpflanze mit weißen Faserbüscheln, ein Schaf mit dunklem Gesicht in der Luft und die Ruine als Steintor aus Blöcken mit Moos, Runen und goldenem Licht im Tor.
|
||
|
||
- [ ] **Step 6: Alles prüfen**
|
||
|
||
Run:
|
||
|
||
```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'
|
||
Pixelgrafik für die Dinge der Vitrine
|
||
|
||
Toter Boden hat Risse, lebendiger Boden Gras, Blumen und einen
|
||
unregelmäßigen Rand. Moos funkelt, die Ranke trägt Blätter, die
|
||
Faserpflanze wächst in sechs Stufen bis zu den Fasern und wiegt sich,
|
||
das Weidetier zieht im Sprung die Beine an, und die Ruine leuchtet,
|
||
wenn sie erwacht.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
EOF
|
||
git push
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Abwandlungen Pastell und Glimmen
|
||
|
||
Pastell: weiche, helle Farben und Umrisse in einem dunkleren Ton der Fläche. Glimmen: eine gedämpfte, kühle tote Welt in Grau und Schiefer, in der alles Lebendige kräftig ist und leuchtet. Beide setzen dieselben Farbnamen wie Warm.
|
||
|
||
**Files:**
|
||
- Modify: `game/scripts/art/pastel_style.gd`, `game/scripts/art/glow_style.gd` (jeweils ganze Datei ersetzen)
|
||
- Test: `game/tests/test_pastel_style.gd`, `game/tests/test_glow_style.gd`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PixelStyle` aus den Tasks 5 und 6 mit `palette`, `players`, `tinted_outlines` und `glow`; die Farbnamen von `WarmStyle`
|
||
- Produces: `PastelStyle` (`tinted_outlines = true`) und `GlowStyle` (`glow = 1.0`) als fertige Abwandlungen von `PixelStyle`, je mit acht Spielerfarben
|
||
|
||
- [ ] **Step 1: Fehlschlagende Tests schreiben**
|
||
|
||
Create `game/tests/test_pastel_style.gd`:
|
||
|
||
```gdscript
|
||
extends TestCase
|
||
|
||
|
||
func test_four_players_have_four_colours() -> void:
|
||
var style := PastelStyle.new()
|
||
var colours := {}
|
||
for player in 4:
|
||
colours[style.player_color(player)] = true
|
||
check_eq(colours.size(), 4, "four players, four colours")
|
||
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
|
||
|
||
|
||
func test_it_has_every_colour_the_warm_style_has() -> void:
|
||
var names := PastelStyle.new().palette.keys()
|
||
var warm := WarmStyle.new().palette.keys()
|
||
names.sort()
|
||
warm.sort()
|
||
check_eq(names, warm, "the same colours by name")
|
||
```
|
||
|
||
Create `game/tests/test_glow_style.gd`:
|
||
|
||
```gdscript
|
||
extends TestCase
|
||
|
||
|
||
func test_four_players_have_four_colours() -> void:
|
||
var style := GlowStyle.new()
|
||
var colours := {}
|
||
for player in 4:
|
||
colours[style.player_color(player)] = true
|
||
check_eq(colours.size(), 4, "four players, four colours")
|
||
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
|
||
|
||
|
||
func test_it_has_every_colour_the_warm_style_has() -> void:
|
||
var names := GlowStyle.new().palette.keys()
|
||
var warm := WarmStyle.new().palette.keys()
|
||
names.sort()
|
||
warm.sort()
|
||
check_eq(names, warm, "the same colours by name")
|
||
```
|
||
|
||
- [ ] **Step 2: Tests ausführen und Fehlschlag sehen**
|
||
|
||
Run:
|
||
|
||
```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 `143 passed, 2 failed`. `test_pastel_style.gd` und `test_glow_style.gd` laden nicht; davor steht je `SCRIPT ERROR: Parse Error: Cannot infer the type of "names" variable because the value doesn't have a set type.`, denn die vorläufigen Stile haben noch keine Palette.
|
||
|
||
- [ ] **Step 3: Die beiden Abwandlungen schreiben**
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/art/pastel_style.gd` durch:
|
||
|
||
```gdscript
|
||
class_name PastelStyle
|
||
extends PixelStyle
|
||
## Soft, light colours, and outlines in a darker shade of what they
|
||
## surround instead of one dark line.
|
||
|
||
const KEY := &"pastel"
|
||
|
||
|
||
func _init() -> void:
|
||
tinted_outlines = true
|
||
palette = {
|
||
"outline": Color("7a6670"), "dust": Color("fff6ea"),
|
||
"ground": Color("e2c7a6"), "ground_dark": Color("d2b591"), "ground_light": Color("eedac0"),
|
||
"pebble": Color("c2b6aa"), "pebble_light": Color("ddd4ca"), "pebble_dark": Color("a3978c"),
|
||
"dead_crack": Color("c3a585"),
|
||
"soft_top": Color("dcd5cf"), "soft_top_dark": Color("cbc3bc"), "soft_top_light": Color("eeeae6"),
|
||
"soft_face": Color("bab1ab"), "soft_face_dark": Color("a89f99"), "soft_lip": Color("f6f3f1"),
|
||
"hard_top": Color("abb6da"), "hard_top_dark": Color("99a4c9"), "hard_top_light": Color("c5ceea"),
|
||
"hard_face": Color("8e98bf"), "hard_face_dark": Color("7c86ad"), "hard_lip": Color("dde4f6"),
|
||
"hard_glint": Color("ffffff"),
|
||
"bed_top": Color("8d8292"), "bed_top_dark": Color("7e7383"), "bed_top_light": Color("9e93a3"),
|
||
"bed_face": Color("6f6574"), "bed_face_dark": Color("615866"), "bed_lip": Color("b0a6b5"),
|
||
"grass": Color("9dd28b"), "grass_dark": Color("86c177"), "grass_light": Color("bae4a7"),
|
||
"grass_edge": Color("74ae67"),
|
||
"flower_a": Color("fff2a8"), "flower_b": Color("ffffff"), "flower_c": Color("ffb6cf"),
|
||
"moss": Color("88c57b"), "moss_dark": Color("72ae67"), "moss_light": Color("abde9a"), "spore": Color("fff0a8"),
|
||
"vine": Color("80c077"), "vine_dark": Color("67a460"), "leaf": Color("94d086"), "leaf_dark": Color("76b16a"),
|
||
"stem": Color("88c37b"), "bud": Color("cae9b2"), "fibre": Color("fffdf6"), "fibre_shade": Color("ede7db"),
|
||
"seed": Color("b38d6c"),
|
||
"stone": Color("c6bfb8"), "stone_light": Color("ded9d4"), "stone_dark": Color("aaa39c"),
|
||
"wool": Color("fffaf2"), "wool_shade": Color("ede5d9"), "wool_light": Color("ffffff"),
|
||
"face": Color("8c7c76"), "hoof": Color("71635d"),
|
||
"ruin": Color("cfc8d2"), "ruin_dark": Color("b7b0bd"), "ruin_light": Color("e2dde5"),
|
||
"ruin_moss": Color("94c888"), "door": Color("62596d"), "glow": Color("ffe7a2"), "glow_light": Color("fffbe9"),
|
||
"skin": Color("fcddc3"), "skin_shade": Color("efc3a4"), "hair": Color("b27c5c"), "hair_light": Color("cb987c"),
|
||
"eye": Color("5c4852"), "pants": Color("91a2d2"), "pants_dark": Color("7c8dbf"), "boots": Color("a28068"),
|
||
"marker_near": Color("ffffff"), "marker_far": Color("ff9c8c"),
|
||
}
|
||
players = [
|
||
Color("f08c7c"), Color("7cb0ea"), Color("f2ce6c"), Color("88cb7c"),
|
||
Color("ba98e2"), Color("76d1c8"), Color("f2a2c6"), Color("f4af7a"),
|
||
]
|
||
|
||
|
||
func key() -> StringName:
|
||
return KEY
|
||
|
||
|
||
func title_key() -> String:
|
||
return "STYLE_PASTEL"
|
||
```
|
||
|
||
Ersetze den gesamten Inhalt von `game/scripts/art/glow_style.gd` durch:
|
||
|
||
```gdscript
|
||
class_name GlowStyle
|
||
extends PixelStyle
|
||
## A dim, cool world in grey and slate, in which everything living shines in
|
||
## strong colours and glows: the dead and the living apart at a glance.
|
||
|
||
const KEY := &"glow"
|
||
|
||
|
||
func _init() -> void:
|
||
glow = 1.0
|
||
palette = {
|
||
"outline": Color("15171f"), "dust": Color("b9b5af"),
|
||
"ground": Color("6f6a68"), "ground_dark": Color("5f5a59"), "ground_light": Color("7f7a77"),
|
||
"pebble": Color("57534f"), "pebble_light": Color("8a8580"), "pebble_dark": Color("45413e"),
|
||
"dead_crack": Color("4c4846"),
|
||
"soft_top": Color("8d8a87"), "soft_top_dark": Color("7c7976"), "soft_top_light": Color("a3a09c"),
|
||
"soft_face": Color("636160"), "soft_face_dark": Color("514f4f"), "soft_lip": Color("b5b2ae"),
|
||
"hard_top": Color("4f5a75"), "hard_top_dark": Color("434d66"), "hard_top_light": Color("62708f"),
|
||
"hard_face": Color("39425a"), "hard_face_dark": Color("2e354a"), "hard_lip": Color("7a89aa"),
|
||
"hard_glint": Color("8ff0ff"),
|
||
"bed_top": Color("2e2b30"), "bed_top_dark": Color("262328"), "bed_top_light": Color("3a363c"),
|
||
"bed_face": Color("1e1b20"), "bed_face_dark": Color("171519"), "bed_lip": Color("48434a"),
|
||
"grass": Color("3fbf5a"), "grass_dark": Color("2e9c48"), "grass_light": Color("6ee887"),
|
||
"grass_edge": Color("1f7a3a"),
|
||
"flower_a": Color("fff27a"), "flower_b": Color("e8fff0"), "flower_c": Color("ff8ad0"),
|
||
"moss": Color("35c46a"), "moss_dark": Color("25a052"), "moss_light": Color("7cf5a5"), "spore": Color("fff59a"),
|
||
"vine": Color("3dbb63"), "vine_dark": Color("2a8f4a"), "leaf": Color("52d47a"), "leaf_dark": Color("34a85c"),
|
||
"stem": Color("45c068"), "bud": Color("a0f5b8"), "fibre": Color("f0fff6"), "fibre_shade": Color("c8e8d6"),
|
||
"seed": Color("8a6a4a"),
|
||
"stone": Color("8e8b88"), "stone_light": Color("aeaaa6"), "stone_dark": Color("6c6967"),
|
||
"wool": Color("eef2f0"), "wool_shade": Color("cfd6d3"), "wool_light": Color("ffffff"),
|
||
"face": Color("3a3438"), "hoof": Color("2a2629"),
|
||
"ruin": Color("7d7a80"), "ruin_dark": Color("68656b"), "ruin_light": Color("94919a"),
|
||
"ruin_moss": Color("3fbf6a"), "door": Color("121014"), "glow": Color("ffe066"), "glow_light": Color("fffbd0"),
|
||
"skin": Color("f0c4a0"), "skin_shade": Color("d39e7a"), "hair": Color("3d2a24"), "hair_light": Color("5a3e34"),
|
||
"eye": Color("1a1416"), "pants": Color("3b4466"), "pants_dark": Color("2e3552"), "boots": Color("3a2a22"),
|
||
"marker_near": Color("c8fff0"), "marker_far": Color("ff6a5a"),
|
||
}
|
||
players = [
|
||
Color("ff5f4f"), Color("4f9fff"), Color("ffc847"), Color("4fd06a"),
|
||
Color("b77aff"), Color("3fd8c8"), Color("ff7ac0"), Color("ff9a4a"),
|
||
]
|
||
|
||
|
||
func key() -> StringName:
|
||
return KEY
|
||
|
||
|
||
func title_key() -> String:
|
||
return "STYLE_GLOW"
|
||
```
|
||
|
||
- [ ] **Step 4: Tests ausführen und Erfolg sehen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
godot --headless --path game --import
|
||
godot --headless --path game -s res://tests/run_tests.gd
|
||
```
|
||
|
||
Expected: Exit-Code 0 und `147 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen.
|
||
|
||
- [ ] **Step 5: Bildschirmfotos ansehen**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
for key in pastel glow; do
|
||
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-$key-karte.png" --style=$key
|
||
godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-$key-vitrine.png" --style=$key --showcase
|
||
done
|
||
```
|
||
|
||
Expected: Alle vier Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir die Bilder an. Nirgends steht ein Platzhalter.
|
||
|
||
- Pastell: dieselben Formen wie Warm, aber in weichen, hellen Farben: heller Sandboden, hellgrauer Fels, lavendelblauer harter Fels, zartgrünes Gras. Die Umrisse sind kein Schwarzbraun, sondern ein dunklerer Ton der Fläche. Die Vitrine hat oben „Stilprobe: Pastell“, unten ist „2 Pastell“ ausgegraut.
|
||
- Glimmen: eine graue, gedämpfte tote Welt mit grauem Boden, grauem Fels, dunklem schieferblauem harten Fels, fast schwarzem Grundgestein und dunklen Umrissen. Das Lebendige ist kräftig: leuchtend grünes Gras mit hellen Funken, Moos mit leuchtenden Sporen, die Ruine mit hellem Licht. Die Vitrine hat oben „Stilprobe: Glimmen“, unten ist „3 Glimmen“ ausgegraut.
|
||
|
||
- [ ] **Step 6: Alles prüfen**
|
||
|
||
Run:
|
||
|
||
```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'
|
||
Abwandlungen Pastell und Glimmen
|
||
|
||
Pastell zeichnet mit weichen, hellen Farben und Umrissen im dunkleren
|
||
Ton der Fläche. Glimmen zeigt eine gedämpfte, kühle tote Welt, in der
|
||
alles Lebendige kräftig ist und leuchtet. Beide haben dieselben
|
||
Farbnamen wie Warm und je acht Spielerfarben.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
EOF
|
||
git push
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: Regeln, Fragebogen und Bilder für den Probelauf
|
||
|
||
**Files:**
|
||
- Modify: `CLAUDE.md` (ganze Datei ersetzen)
|
||
- Create: `docs/playtests/m1d.md`
|
||
- Create: `docs/playtests/m1d/warm-karte.png`, `warm-vitrine.png`, `pastell-karte.png`, `pastell-vitrine.png`, `glimmen-karte.png`, `glimmen-vitrine.png` (vom Bildschirmfoto-Werkzeug erzeugt)
|
||
|
||
**Interfaces:**
|
||
- Consumes: das Bildschirmfoto-Werkzeug aus Task 4 und die drei Stile aus den Tasks 5 bis 7
|
||
- Produces: der Abschnitt „Grafik“ in `CLAUDE.md`; der Fragebogen `docs/playtests/m1d.md` mit sechs Bildern
|
||
|
||
- [ ] **Step 1: Regeln schreiben**
|
||
|
||
Gegenüber `main` bekommt `CLAUDE.md` den Abschnitt „Grafik“. Unter „Prüfen“ stehen die Optionen des Bildschirmfoto-Werkzeugs, unter „Spielen“ das Argument `--style=NAME`. Bei der Verbindung, die als verloren gilt, heißt es jetzt „nach etwa“ statt „nach höchstens“, wie in #8 vermerkt.
|
||
|
||
Ersetze den gesamten Inhalt von `CLAUDE.md` durch:
|
||
|
||
````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.
|
||
- Die Grafik ist Pixelgrafik aus Code (Konzept 7.4): 16 × 16 Pixel pro Kachel, schräg von vorn gesehen. `PixelStyle` (`game/scripts/art/pixel_style.gd`) zeichnet jede Kennung Pixel für Pixel in ein kleines Bild und hebt es auf. Die Stile `WarmStyle`, `PastelStyle` und `GlowStyle` wählen nur Palette, Umrisse und Leuchten; Warm ist der Standard. F2 wechselt im Spiel, „Stilproben ansehen“ im Menü öffnet die Vitrine, und das Spiel merkt sich die Wahl.
|
||
- Eine neue Kennung braucht eine Zeichnung in `PixelStyle` und ihre Farben in jeder Palette; sonst zeichnet `ArtStyle` nur einen Platzhalter. `game/tests/test_art.gd` lässt jeden Stil jede Kennung zeichnen.
|
||
- Damit die Pixel scharf bleiben, stellt `project.godot` die Texturfilterung auf „Nearest“ und rastet 2D-Transformationen auf ganze Bildschirmpixel ein. Die Kamera vergrößert ganzzahlig (`CAMERA_ZOOM` in `main.gd`), und die Stile setzen ihre Bilder auf ganze Pixel.
|
||
- Ein Bild `game/art/<Kennung>.png` ersetzt die Zeichnung seiner Kennung, ohne dass sich Code ändert. So kann ein Artist Grafik austauschen. Ein solches Bild ist ein einzelnes festes Bild; Kachelränder und Bewegung zeichnet bisher nur der Code.
|
||
- Die Karte zeichnet in Blöcken von 16 × 16 Kacheln (`MapView`); nach einem Treffer werden nur die Blöcke um die Kachel neu gezeichnet. Jede Kachel kennt ihre Nachbarn (`TileShape`), damit Felsmassen außen rund und innen geschlossen sind und zum offenen Boden hin ihre Vorderkante zeigen.
|
||
|
||
## Prüfen
|
||
|
||
Vor jedem Commit müssen alle Befehle fehlerfrei laufen:
|
||
|
||
```bash
|
||
cargo fmt --all -- --check
|
||
cargo clippy --workspace --all-targets -- -D warnings
|
||
cargo test --workspace
|
||
cargo build -p grovewright-bridge
|
||
godot --headless --path game --import
|
||
godot --headless --path game -s res://tests/run_tests.gd
|
||
```
|
||
|
||
Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust-Erweiterung beim Beenden ab (Exit-Code 139 in der Bash). Der Import ist dann trotzdem erfolgt; ein zweiter Aufruf läuft fehlerfrei durch. `play.bat` wiederholt den Import deshalb einmal.
|
||
|
||
Der Test `game/tests/test_two_instances.gd` startet einen zweiten Godot-Prozess als Gast (`game/tools/guest_bot.gd`) und prüft, dass beide Welten gleich bleiben. Er braucht deshalb die gebaute Brücke und den Import wie alle anderen Tests der Hülle.
|
||
|
||
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände, Aufzeichnungen oder Nachrichten betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt (`SNAPSHOT_VERSION`, `RECORDING_VERSION` oder `PROTOCOL_VERSION`). Erst danach passt du den festgeschriebenen Wert bewusst an.
|
||
|
||
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert. `--style=NAME` wählt einen Stil, `--showcase` zeigt die Vitrine statt des Spiels:
|
||
|
||
```bash
|
||
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei> [--style=NAME] [--showcase]
|
||
```
|
||
|
||
## Spielen
|
||
|
||
`play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`.
|
||
|
||
Ein laufendes Spiel hält `target\debug\grovewright_bridge.dll` offen. Ändern sich danach Brücke oder Kern, scheitert `cargo build` mit „Zugriff verweigert (os error 5)“, bis alle Spielfenster geschlossen sind.
|
||
|
||
Argumente hinter `play.bat` gehen an das Spiel:
|
||
|
||
| Argument | Wirkung |
|
||
|---|---|
|
||
| `--profile=NAME` | Eigener Ordner unter `profiles\NAME` für Schlüssel, Spielstand und Aufzeichnungen. Zwei Fenster auf einem Rechner brauchen zwei Profile, sonst sind sie derselbe Spieler. Erlaubt sind bis zu 32 Zeichen aus A bis Z, a bis z, Ziffern, `-` und `_`; einen anderen Namen übergeht das Spiel mit einer Warnung im Log und nimmt das Standardprofil. |
|
||
| `--name=NAME` | Name des Spielers |
|
||
| `--solo`, `--host` | Startet ohne Menü allein beziehungsweise mit offener Welt |
|
||
| `--port=PORT` | Port einer offenen Welt |
|
||
| `--join=ADRESSE` | Tritt ohne Menü einem Host bei; die Adresse darf auf `:PORT` enden |
|
||
| `--style=NAME` | Zeichnet im Stil `warm`, `pastel` oder `glow` statt im zuletzt gewählten |
|
||
|
||
`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Die Datei reicht weitere Argumente ebenfalls an das Spiel weiter, etwa `--name=Zweiter`.
|
||
|
||
## Sprache
|
||
|
||
- Code, Bezeichner und Kommentare im Code: Englisch.
|
||
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
|
||
- Texte im Spiel: Deutsch und Englisch über `game/i18n/texts.csv`.
|
||
|
||
## Git
|
||
|
||
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). Issues und Pull Requests sind freigegeben.
|
||
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
|
||
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
|
||
- Commit-Nachrichten enden mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat.
|
||
````
|
||
|
||
- [ ] **Step 2: Bilder für den Fragebogen aufnehmen**
|
||
|
||
Der Pfad muss absolut sein, denn Godot legt relative Pfade unter `game/` an. `pwd -W` gibt in der Git-Bash den Windows-Pfad aus.
|
||
|
||
```bash
|
||
mkdir -p docs/playtests/m1d
|
||
OUT="$(pwd -W)/docs/playtests/m1d"
|
||
for pair in warm:warm pastel:pastell glow:glimmen; do
|
||
godot --path game -s res://tools/screenshot.gd -- "$OUT/${pair#*:}-karte.png" --style=${pair%%:*}
|
||
godot --path game -s res://tools/screenshot.gd -- "$OUT/${pair#*:}-vitrine.png" --style=${pair%%:*} --showcase
|
||
done
|
||
ls -l docs/playtests/m1d
|
||
```
|
||
|
||
Expected: Jeder der sechs Aufrufe endet mit `screenshot saved: …` ohne Warnung. `ls` zeigt die sechs Dateien `glimmen-karte.png`, `glimmen-vitrine.png`, `pastell-karte.png`, `pastell-vitrine.png`, `warm-karte.png` und `warm-vitrine.png`, jede zwischen 35 und 75 KB groß. Die genauen Größen schwanken von Lauf zu Lauf um einige Bytes.
|
||
|
||
- [ ] **Step 3: Bilder ansehen**
|
||
|
||
Sieh dir alle sechs Bilder an. Jedes muss zu seiner Beschreibung aus den Tasks 5 bis 7 passen: das Spiel mit zwei Figuren und dem Namen „Spieler“, die Vitrine mit allen neun Beschriftungen und dem Titel ihres Stils.
|
||
|
||
- [ ] **Step 4: Fragebogen schreiben**
|
||
|
||
Create `docs/playtests/m1d.md`:
|
||
|
||
```markdown
|
||
# Probelauf M1d: Stilproben
|
||
|
||
M1d zeichnet das Spielfeld als Pixelgrafik aus Code, im Stil klassischer Farmspiele, aber eigenständig. Es gibt drei Abwandlungen: Warm, Pastell und Glimmen. Nach der ersten Probe war Warm dein Favorit; jetzt siehst du die Stile im Spiel und in Bewegung. Das beantwortet die Prüffrage von Meilenstein 1: „Welche der drei Stilproben soll es sein?“ Die Vitrine zeigt dazu Dinge aus späteren Meilensteinen, die es zum Spielen noch nicht gibt.
|
||
|
||
## So siehst du die Proben
|
||
|
||
1. Doppelklick auf `play.bat`.
|
||
2. Im Menü auf „Stilproben ansehen“. Die Vitrine zeigt
|
||
- toten und lebendigen Boden,
|
||
- Lebensmoos,
|
||
- eine Ranke, die Steine trägt,
|
||
- eine wachsende Faserpflanze,
|
||
- ein hüpfendes Weidetier,
|
||
- eine erwachende Ruine,
|
||
- zwei Spielfiguren.
|
||
|
||
Mit 1, 2 und 3 oder den Knöpfen unten wechselst du den Stil, mit Esc oder „Zurück“ geht es ins Menü.
|
||
3. Im Spiel wechselt F2 den Stil. Lauf herum, schlag Stein und sieh dir Fels, Figur, Zielmarke und Splitter an. Das Spiel merkt sich den zuletzt gewählten Stil.
|
||
4. Zu zweit wie in M1c: Die Namen über den Figuren sind jetzt scharf, und dein eigener Schlag zeigt seine Splitter sofort, auch als Gast.
|
||
|
||
So sehen die drei Proben aus:
|
||
|
||
| Warm | Pastell | Glimmen |
|
||
|---|---|---|
|
||
|  |  |  |
|
||
|  |  |  |
|
||
|
||
## Fragen
|
||
|
||
1. Bleibt es bei Warm, oder gefällt dir im Spiel ein anderer Stil besser?
|
||
2. Was gefällt dir an deinem Favoriten am besten, und was stört dich daran?
|
||
3. Gibt es an den anderen beiden etwas, das du übernehmen möchtest, etwa das Leuchten des Lebendigen aus Glimmen?
|
||
4. Erkennst du auf einen Blick, was Boden, weicher Fels und harter Fels ist? Und was lebendiger Boden ist?
|
||
5. Wirkt das Lebendige lebendig: das Moos, die Ranke, die wachsende Pflanze, das Tier, die erwachende Ruine? Oder wirkt es nüchtern?
|
||
6. Ist die Bewegung angenehm: Laufen, Wiegen, Wachsen und Hüpfen? Oder ist es zu viel oder zu wenig?
|
||
7. Findest du deine Figur sofort, auch neben anderen? Sind Figuren und Dinge groß genug?
|
||
8. Wirkt ein Schlag als Gast jetzt sofort, und sind die Namen über den Figuren scharf?
|
||
|
||
Antworte formlos im Chat oder als Kommentar in Gitea.
|
||
```
|
||
|
||
- [ ] **Step 5: Alles prüfen**
|
||
|
||
Run:
|
||
|
||
```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 `147 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 die Grafik entsteht: Kennungen, Pixelgrafik
|
||
aus Code, scharfe Pixel, Bilder, die Zeichnungen ersetzen, Blöcke und
|
||
Kachelformen. Das Bildschirmfoto-Werkzeug und --style=NAME stehen bei
|
||
ihren Befehlen. Eine stille Verbindung gilt nach etwa
|
||
NetLink.TIMEOUT_MSEC als verloren. Ein Fragebogen mit Bildern der drei
|
||
Stile begleitet den Probelauf.
|
||
|
||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
EOF
|
||
git push
|
||
```
|