From 5e9836c64ab7e482990a2b8f01e6c2e842621169 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Fri, 2 Oct 2026 11:00:41 +0200 Subject: [PATCH] Plan M1d auf Pixelgrafik umgestellt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/KONZEPT.md | 4 +- .../plans/2026-10-02-m1d-stilproben.md | 2518 +++++++++-------- 2 files changed, 1319 insertions(+), 1203 deletions(-) diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index 2ae4b2f..b975666 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -181,7 +181,7 @@ Die eigene Spielfigur bewegt sich sofort und läuft außerhalb des Gleichschritt ### 7.4 Grafik und Ton -- **Spielfeld aus Code.** Alle Grafiken auf dem Spielfeld sind Vektorzeichnungen in einem flachen, klaren Stil. Bewegung entsteht durch Programmierung. So passen alle Kachelvarianten garantiert aneinander. +- **Spielfeld aus Code.** Alle Grafiken auf dem Spielfeld sind Pixelgrafik im Stil klassischer Farmspiele wie Stardew Valley, aber eigenständig: 16 × 16 Pixel pro Kachel, schräg von vorn gesehen. Der Code zeichnet sie Pixel für Pixel aus einer Farbpalette; Figuren und Tiere sind kleine Pixelbilder im Quelltext. Bewegung entsteht durch Programmierung. So passen alle Kachelvarianten garantiert aneinander, und der Stil lässt sich nach jedem Playtest weiter anpassen. Bis zu den ersten Stilproben waren hier flache Vektorzeichnungen vorgesehen. - **Porträts und Titelbild als KI-Bilder** über den Skill `kie-ai`. Du wählst aus. - **Austauschbar.** Jede Grafik hängt an einer Kennung. Ein Artist kann sie später ersetzen, ohne dass sich Code ändert. - **Ton.** Lizenzfreie Geräusche und Musik im letzten Meilenstein. @@ -251,7 +251,7 @@ Offen, zu klären im jeweiligen Meilenstein: - **Wachstumsdauern.** Die Richtwerte aus 3.1 werden im Playtest eingestellt. - **Verhältnis Werkstatt zu Hain.** Wie viel Hain ein Ofen braucht, entscheidet, ob die Bodenregel reizt oder nervt. - **Andocken.** Wie Ranken, Ladetiere, Kisten und Ruinen genau aneinander anschließen, wird in den Meilensteinen 2 und 3 festgelegt. -- **Grafikstil.** Du wählst im ersten Meilenstein aus drei Stilproben. +- **Grafikstil.** Die Richtung steht: Pixelgrafik aus Code. Im ersten Meilenstein wählst du aus drei Abwandlungen (Warm, Pastell, Glimmen); nach der ersten Probe ist Warm dein Favorit. Feinheiten passen wir nach den Playtests an. - **Namen.** Alle Namen für Arten, Tiere und Waren sind Arbeitstitel. - **Titel.** Eine Websuche fand kein Spiel namens „Grovewright". Vor einem Steam-Release ist eine Markenrecherche nötig. diff --git a/docs/superpowers/plans/2026-10-02-m1d-stilproben.md b/docs/superpowers/plans/2026-10-02-m1d-stilproben.md index 6026fe9..e24577c 100644 --- a/docs/superpowers/plans/2026-10-02-m1d-stilproben.md +++ b/docs/superpowers/plans/2026-10-02-m1d-stilproben.md @@ -2,13 +2,13 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Das Spielfeld lässt sich in drei Stilen zeichnen, alle aus Code: Scherenschnitt, Kontur und Geometrisch. Eine Vitrine zeigt jeden Stil zusammen mit Dingen späterer Meilensteine, F2 wechselt den Stil im Spiel, und das Spiel merkt sich die Wahl. Damit lässt sich die Prüffrage von Meilenstein 1 beantworten: „Welche der drei Stilproben soll es sein?“ +**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, eine Unterklasse von `ArtStyle`, zeichnet sie mit den Zeichenbefehlen von Godot; ein Bild `game/art/.png` ersetzt die Zeichnung, ohne dass sich Code ändert. `TileShape` gibt jeder Kachel ihre acht Nachbarn, damit Felsmassen außen rund und innen geschlossen sind. `MapView` zeichnet die Karte in Blöcken und nach einem Treffer nur die Blöcke um die Kachel neu. Kern und Brücke bleiben unverändert, denn Grafik ändert nichts an der gemeinsamen Welt. +**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/.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“), GDScript mit Typangaben, die Zeichenbefehle von `CanvasItem` mit Kantenglättung. Rust 1.98.1 und godot-rust 0.5.5 bleiben unverändert. +**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“, „Austauschbar“), Abschnitt 8 (Meilenstein 1 mit der Prüffrage „Welche der drei Stilproben soll es sein?“) und Abschnitt 11 („Grafikstil“ und das Risiko „Grafik aus Code kann nüchtern wirken“). Dazu kommen die Antworten aus dem Probelauf von M1c im Kommentar zu Gitea #8 und deine Wahl der drei Richtungen und der Szene vor diesem Plan. +**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 @@ -26,9 +26,9 @@ Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prü | Punkt | Task | |---|---| | Konzept 7.4: Jede Grafik hängt an einer Kennung; ein Artist kann sie ersetzen, ohne dass sich Code ändert | 1: `Art` mit 16 Kennungen und Bildern unter `game/art/` | -| Konzept 7.4: Spielfeld aus Code, flach und klar, Bewegung durch Programmierung, alle Kachelvarianten passen aneinander | 1 und 2: Nachbarmasken und Blöcke; 5 bis 7: die drei Stile | -| Konzept 8 und 11: drei Stilproben zur Wahl | 3: Vitrine; 4: Wechsel mit F2 und über die Befehlszeile; 8: Fragebogen mit Bildern | -| Konzept 11, Risiko „Grafik aus Code kann nüchtern wirken“ | 3: Die Vitrine zeigt lebendigen Boden, Moos, Ranke, Pflanze, Tier und Ruine in Bewegung | +| 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. | @@ -37,7 +37,8 @@ Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prü - Regeln für lebendigen Boden, Moos, Ranken, Pflanzen, Tiere und Ruinen. Sie kommen ab Meilenstein 2. Die Vitrine bewegt ihre Dinge nach festen Formeln der Hülle; der Kern weiß nichts von ihnen. - Bilder von einem Artist, Porträts, Titelbild und Ton (Konzept 7.4, Meilenstein 6) -- Das Entfernen der Stile, die du nicht wählst. Das entscheidet sich nach deinem Probelauf. +- 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 @@ -45,15 +46,18 @@ Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prü | Thema | Festlegung | Grund | |---|---|---| | Ort | Nur die Hülle ändert sich, dazu `CLAUDE.md` und `docs/`. | Grafik ändert nichts an der gemeinsamen Welt. | -| Richtungen | Scherenschnitt: Papierlagen ohne Umriss mit weichen Schatten, gedeckte Erdfarben, Lebendiges leuchtet grün und golden. Kontur: kräftige dunkle Umrisse um flache, starke Farben, gut lesbar wie ein Comic. Geometrisch: ruhige Pastellformen ohne Umriss und Schatten, Muster nach dem Raster. | Deine Wahl vor diesem Plan | +| 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/.png`; es ersetzt die Zeichnung seiner Kennung. | Ein Artist kann Grafik austauschen, ohne dass sich Code ändert. | -| Stile | Ein Stil ist eine Unterklasse von `ArtStyle` und überschreibt je Kennung eine Zeichenmethode. `ArtStyle` selbst zeichnet schlichte Platzhalter. | Ein Stil steckt in einer Datei. Fehlt einer neuen Kennung die Zeichnung, fehlt nur ihr Aussehen; das Spiel läuft weiter. | +| 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. | -| Kanten | Glatte Kanten kommen aus den Zeichenbefehlen mit `antialiased = true`. | Der Renderer „GL Compatibility“ kennt kein MSAA in 2D; eingeschaltet meldet es einen Engine-Fehler. | -| Kachelformen | `TileShape.mask` beschreibt die acht Nachbarn einer Kachel als Bitmaske, im Uhrzeigersinn ab Norden. Fels ist außen rund und innen geschlossen; jenseits des Kartenrands zählt alles als Fels. | Konzept 7.4: Alle Kachelvarianten passen aneinander. | -| Zeichnen der Karte | Blöcke von 16 × 16 Kacheln. Ein Treffer zeichnet nur die Blöcke um seine Kachel neu. | Die ganze Karte neu zu zeichnen dauerte 18 bis 40 ms, zu lang für jeden Treffer. Nach dem Umbau gemessen: rund 8 ms, um die Karte beim Start aufzubauen, rund 7 ms für das ganze Bild nach einem Treffer, höchstens 40 ms für das Bild nach einem Stilwechsel. | +| 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. | @@ -65,7 +69,7 @@ Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prü - 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/`. +- 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). @@ -81,9 +85,9 @@ Danach ist Meilenstein 1 vollständig, und dein Probelauf beantwortet seine Prü ## Hinweis zur Vorabprüfung -Der gesamte Code dieses Plans wurde am 2. Oktober 2026 vorab gebaut. Alle drei Stile wurden auf der Karte und in der Vitrine angesehen und nachgebessert. Danach wurde jede der acht Task-Grenzen einzeln in einer Kopie von `main` nachgestellt, jeweils mit dem roten und dem grünen Lauf, die der Plan beschreibt: +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: 113, 121, 128, 132, 133, 134, 135 und 135. Jeder grüne Lauf gab genau die drei bekannten Warnzeilen aus. +- 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. @@ -95,9 +99,11 @@ Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten. | Datei | Aufgabe | |---|---| | `game/scripts/art/art.gd` | Kennungen, Wahl des Stils, Bilder statt Zeichnungen | -| `game/scripts/art/art_style.gd` | Grundklasse der Stile: Platzhalter und Helfer für Formen | +| `game/scripts/art/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/papercut_style.gd`, `outline_style.gd`, `geometric_style.gd` | Die drei Stile | +| `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 | @@ -115,7 +121,7 @@ Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten. **Files:** - Create: `game/scripts/art/tile_shape.gd`, `game/scripts/art/art_style.gd`, `game/scripts/art/art.gd` -- Create: `game/scripts/art/papercut_style.gd`, `game/scripts/art/outline_style.gd`, `game/scripts/art/geometric_style.gd` (vorläufig; die Tasks 5 bis 7 ersetzen sie) +- 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` @@ -123,9 +129,9 @@ Weicht ein Schritt ab, ist zuerst ein Übertragungsfehler zu vermuten. - Consumes: `TestCase` mit `check(condition, message)`, `check_eq(actual, expected, message)` und `tree`; `TestFiles.temporary_dir(name)` und `TestFiles.remove_dir(path)`; `I18n.load_translations(I18n.TEXTS_FILE)` - Produces: - `TileShape`: die Bits `N`, `NE`, `E`, `SE`, `S`, `SW`, `W`, `NW` (1 bis 128, im Uhrzeigersinn ab Norden), `OFFSETS: Array[Vector2i]`, `static flags(codes: PackedByteArray, members: Array[int]) -> PackedByteArray`, `static mask(tile_flags: PackedByteArray, width: int, height: int, tile: Vector2i, outside_set: bool) -> int`, `static variant(tile: Vector2i) -> int` (0 bis 2147483647, für jede Kachel immer gleich) - - `ArtStyle`: `key() -> StringName`, `title_key() -> String`, `draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary) -> void`, `player_color(player: int) -> Color` und je Kennung eine Zeichenmethode `(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void`: `_ground`, `_soft_rock`, `_hard_rock`, `_bedrock`, `_cracks`, `_hit`, `_figure`, `_marker`, `_dead_soil`, `_living_soil`, `_moss`, `_vine`, `_fiber_plant`, `_stone`, `_grazer`, `_ruin`. Dazu die statischen Helfer `share(variant, salt) -> float`, `rounded_rect(rect, mask, radius, segments := 4) -> PackedVector2Array`, `inset_open_sides(rect, mask, inset) -> Rect2`, `sway(time, variant, speed := 1.3) -> float`, `regular_polygon(center, radius, corners, rotation := 0.0) -> PackedVector2Array`, `closed(points) -> PackedVector2Array`, `along(points, part) -> Vector2`, `ellipse(center, radii, segments := 14) -> PackedVector2Array`, `slit(from, to, width) -> PackedVector2Array` und `leaf(base, angle, length, width) -> PackedVector2Array` + - `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` - - `PapercutStyle`, `OutlineStyle` und `GeometricStyle` mit `KEY` (`&"papercut"`, `&"outline"`, `&"geometric"`) und den Texten `STYLE_PAPERCUT`, `STYLE_OUTLINE`, `STYLE_GEOMETRIC`, vorerst ohne eigene Zeichnungen + - `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** @@ -188,29 +194,6 @@ Create `game/tests/test_art_style.gd`: extends TestCase ## The helpers that every style draws with. -const TILE := Rect2(0, 0, 16, 16) - - -func test_a_lone_tile_is_round_at_every_corner() -> void: - var points := ArtStyle.rounded_rect(TILE, 0, 4.0, 4) - check_eq(points.size(), 20, "five points for each of the four corners") - for point in points: - check(TILE.grow(0.001).has_point(point), "inside the tile: %s" % point) - check(not points.has(Vector2.ZERO), "the corner itself is cut off") - - -func test_a_tile_inside_a_mass_keeps_its_square_corners() -> void: - var all := 255 - check_eq(ArtStyle.rounded_rect(TILE, all, 4.0), PackedVector2Array([Vector2(0, 0), Vector2(16, 0), Vector2(16, 16), Vector2(0, 16)]), "four corners") - var top_left_open := ArtStyle.rounded_rect(TILE, TileShape.E | TileShape.S, 4.0, 4) - check_eq(top_left_open.size(), 5 + 1 + 1 + 1, "only the corner between the open sides is round") - - -func test_inner_layers_shrink_only_toward_open_sides() -> void: - var inner := ArtStyle.inset_open_sides(TILE, TileShape.N | TileShape.W, 2.0) - check_eq(inner, Rect2(0, 0, 14, 14), "kept toward the neighbours above and to the left") - check_eq(ArtStyle.inset_open_sides(TILE, 0, 2.0), Rect2(2, 2, 12, 12), "smaller on every open side") - func test_a_point_can_be_found_along_a_path() -> void: var path := PackedVector2Array([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10)]) @@ -229,12 +212,11 @@ func test_shares_lie_between_zero_and_one_and_stay_the_same() -> void: check_eq(ArtStyle.share(variant, salt), share, "the same again") -func test_shapes_have_their_corners() -> void: - check_eq(ArtStyle.regular_polygon(Vector2.ZERO, 2.0, 6).size(), 6, "a hexagon") - check_eq(ArtStyle.ellipse(Vector2.ZERO, Vector2(3, 1), 12).size(), 12, "an ellipse") - check_eq(ArtStyle.slit(Vector2.ZERO, Vector2(10, 0), 2.0), PackedVector2Array([Vector2(0, 0), Vector2(5, -1), Vector2(10, 0), Vector2(5, 1)]), "a cut") - check_eq(ArtStyle.leaf(Vector2.ZERO, 0.0, 10.0, 4.0)[3], Vector2(10, 0), "a leaf ends at its tip") - check_eq(ArtStyle.closed(PackedVector2Array([Vector2.ZERO, Vector2.ONE])), PackedVector2Array([Vector2.ZERO, Vector2.ONE, Vector2.ZERO]), "closed") +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`: @@ -297,7 +279,7 @@ func test_the_styles_are_offered_in_a_fixed_order() -> void: for style in art.styles: keys.append(style.key()) check_eq(keys, Art.style_keys(), "the keys in the order of the styles") - check_eq(keys, [PapercutStyle.KEY, OutlineStyle.KEY, GeometricStyle.KEY], "papercut, outline, geometric") + check_eq(keys, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY], "warm, pastel, glow") check_eq(art.style, art.styles[0], "the first one is chosen at first") @@ -305,12 +287,12 @@ func test_a_style_is_chosen_by_its_key() -> void: var art := Art.new() var changes := [0] art.changed.connect(func() -> void: changes[0] += 1) - check(art.select(OutlineStyle.KEY), "the outline style exists") - check_eq(art.style.key(), OutlineStyle.KEY, "and is chosen") - check(art.select(OutlineStyle.KEY), "choosing it again works") + check(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(), OutlineStyle.KEY, "and changes nothing") + check_eq(art.style.key(), PastelStyle.KEY, "and changes nothing") func test_the_next_style_follows_and_the_first_comes_after_the_last() -> void: @@ -319,7 +301,7 @@ func test_the_next_style_follows_and_the_first_comes_after_the_last() -> void: for i in art.styles.size() + 1: seen.append(art.style.key()) art.select_next() - check_eq(seen, [PapercutStyle.KEY, OutlineStyle.KEY, GeometricStyle.KEY, PapercutStyle.KEY], "round and round") + 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: @@ -599,64 +581,11 @@ static func share(variant: int, salt: int) -> float: return float(TileShape.variant(Vector2i(variant & 0xffff, salt)) % 10007) / 10007.0 -## The outline of `rect` as points clockwise, with each corner rounded by -## `radius` where neither neighbour beside the corner is set in `mask`, so -## that a mass of tiles is round only on its outside. -static func rounded_rect(rect: Rect2, mask: int, radius: float, segments := 4) -> PackedVector2Array: - var points := PackedVector2Array() - var corners := [ - [rect.position, TileShape.W | TileShape.N, PI], - [Vector2(rect.end.x, rect.position.y), TileShape.N | TileShape.E, 1.5 * PI], - [rect.end, TileShape.E | TileShape.S, 0.0], - [Vector2(rect.position.x, rect.end.y), TileShape.S | TileShape.W, 0.5 * PI], - ] - for corner: Array in corners: - var at: Vector2 = corner[0] - var sides: int = corner[1] - if mask & sides != 0 or radius <= 0.0: - points.append(at) - continue - var inward := Vector2(radius if at.x == rect.position.x else -radius, radius if at.y == rect.position.y else -radius) - var center := at + inward - var start: float = corner[2] - for i in segments + 1: - var angle := start + 0.5 * PI * i / segments - points.append(center + Vector2(cos(angle), sin(angle)) * radius) - return points - - -## `rect` made smaller by `inset` on each side that does not touch a -## neighbour set in `mask`, so that the inner layers of a mass of tiles join. -static func inset_open_sides(rect: Rect2, mask: int, inset: float) -> Rect2: - var left := 0.0 if mask & TileShape.W else inset - var top := 0.0 if mask & TileShape.N else inset - var right := 0.0 if mask & TileShape.E else inset - var bottom := 0.0 if mask & TileShape.S else inset - return Rect2(rect.position + Vector2(left, top), rect.size - Vector2(left + right, top + bottom)) - - ## A gentle swing from -1 to 1, slow and different for every `variant`. static func sway(time: float, variant: int, speed := 1.3) -> float: return sin(time * speed + share(variant, 7) * TAU) -## The points of a regular polygon with `corners` corners around `center`. -static func regular_polygon(center: Vector2, radius: float, corners: int, rotation := 0.0) -> PackedVector2Array: - var points := PackedVector2Array() - for i in corners: - var angle := rotation + TAU * i / corners - points.append(center + Vector2(cos(angle), sin(angle)) * radius) - return points - - -## A closed copy of `points`, for drawing the outline of a polygon. -static func closed(points: PackedVector2Array) -> PackedVector2Array: - var result := points.duplicate() - if not points.is_empty(): - result.append(points[0]) - return result - - ## The point at `part` of the way along `points`, from 0 to 1. static func along(points: PackedVector2Array, part: float) -> Vector2: var total := 0.0 @@ -669,38 +598,6 @@ static func along(points: PackedVector2Array, part: float) -> Vector2: return points[i].lerp(points[i + 1], left / length if length > 0.0 else 0.0) left -= length return points[points.size() - 1] - - -## The points of an ellipse with the half axes `radii` around `center`. -static func ellipse(center: Vector2, radii: Vector2, segments := 14) -> PackedVector2Array: - var points := PackedVector2Array() - for i in segments: - var angle := TAU * i / segments - points.append(center + Vector2(cos(angle) * radii.x, sin(angle) * radii.y)) - return points - - -## A narrow cut from `from` to `to`, `width` wide in the middle and pointed -## at both ends. -static func slit(from: Vector2, to: Vector2, width: float) -> PackedVector2Array: - var middle := (from + to) / 2.0 - var side := (to - from).orthogonal().normalized() * width / 2.0 - return PackedVector2Array([from, middle + side, to, middle - side]) - - -## A leaf `length` long and `width` wide that grows from `base` in the -## direction `angle`. -static func leaf(base: Vector2, angle: float, length: float, width: float) -> PackedVector2Array: - var along := Vector2.from_angle(angle) - var side := along.orthogonal() * width / 2.0 - return PackedVector2Array([ - base, - base + along * length * 0.35 + side, - base + along * length * 0.7 + side * 0.7, - base + along * length, - base + along * length * 0.7 - side * 0.7, - base + along * length * 0.35 - side, - ]) ``` Create `game/scripts/art/art.gd`: @@ -747,7 +644,7 @@ const IDS: Array[StringName] = [ const OVERRIDE_DIR := "res://art" ## The styles to choose from, in the order the game offers them. -var styles: Array[ArtStyle] = [PapercutStyle.new(), OutlineStyle.new(), GeometricStyle.new()] +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] = {} @@ -755,7 +652,7 @@ var _overrides: Dictionary[StringName, Texture2D] = {} ## The keys of the styles, as LaunchOptions and the settings name them. static func style_keys() -> Array[StringName]: - return [PapercutStyle.KEY, OutlineStyle.KEY, GeometricStyle.KEY] + return [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY] ## Draws everything in the style named `key` from now on. Returns false, and @@ -813,17 +710,17 @@ static func _load_texture(path: String) -> Texture2D: return ImageTexture.create_from_image(image) if image != null else null ``` -Die drei Stile sind vorerst dünn: Sie nennen ihren Schlüssel und ihren Namen und zeichnen die Platzhalter von `ArtStyle`. Die Tasks 5 bis 7 ersetzen sie durch die fertigen Stile. +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/papercut_style.gd`: +Create `game/scripts/art/warm_style.gd`: ```gdscript -class_name PapercutStyle +class_name WarmStyle extends ArtStyle -## Layers of cut paper without outlines, with soft shadows. -## Until it has drawings of its own, it draws the placeholders of 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 := &"papercut" +const KEY := &"warm" func key() -> StringName: @@ -831,18 +728,18 @@ func key() -> StringName: func title_key() -> String: - return "STYLE_PAPERCUT" + return "STYLE_WARM" ``` -Create `game/scripts/art/outline_style.gd`: +Create `game/scripts/art/pastel_style.gd`: ```gdscript -class_name OutlineStyle +class_name PastelStyle extends ArtStyle -## Bold dark outlines around flat colours, easy to read like a comic. -## Until it has drawings of its own, it draws the placeholders of ArtStyle. +## 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 := &"outline" +const KEY := &"pastel" func key() -> StringName: @@ -850,18 +747,18 @@ func key() -> StringName: func title_key() -> String: - return "STYLE_OUTLINE" + return "STYLE_PASTEL" ``` -Create `game/scripts/art/geometric_style.gd`: +Create `game/scripts/art/glow_style.gd`: ```gdscript -class_name GeometricStyle +class_name GlowStyle extends ArtStyle -## Plain geometric forms in calm pastel colours. -## Until it has drawings of its own, it draws the placeholders of 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 := &"geometric" +const KEY := &"glow" func key() -> StringName: @@ -869,7 +766,7 @@ func key() -> StringName: func title_key() -> String: - return "STYLE_GEOMETRIC" + 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. @@ -912,9 +809,9 @@ LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when pl HOST_SAVES,Nur der Host speichert,Only the host saves KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now -STYLE_PAPERCUT,Scherenschnitt,Papercut -STYLE_OUTLINE,Kontur,Outline -STYLE_GEOMETRIC,Geometrisch,Geometric +STYLE_WARM,Warm,Warm +STYLE_PASTEL,Pastell,Pastel +STYLE_GLOW,Glimmen,Glow ``` - [ ] **Step 5: Tests ausführen und Erfolg sehen** @@ -926,7 +823,7 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `113 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +Expected: Exit-Code 0 und `110 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. - [ ] **Step 6: Alles prüfen** @@ -948,10 +845,10 @@ Kennungen, Kachelformen und Platzhalter für die Grafik Jede Grafik auf dem Spielfeld bekommt eine Kennung. Art zeichnet sie im gewählten Stil oder aus einem Bild gleichen Namens unter game/art. -ArtStyle zeichnet vorerst schlichte Platzhalter und bringt Helfer für -Formen mit. TileShape beschreibt die Nachbarn einer Kachel, damit -Felsmassen außen rund und innen geschlossen gezeichnet werden können. -Die drei Stile gibt es schon mit Namen, noch ohne eigene Zeichnungen. +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 EOF @@ -964,14 +861,14 @@ git push -u origin m1d-stilproben **Files:** - Modify: `game/scripts/map_view.gd`, `game/scripts/avatar.gd`, `game/scripts/figures.gd`, `game/scripts/tile_marker.gd`, `game/scripts/main.gd` (jeweils ganze Datei ersetzen) -- Test: `game/tests/test_map_view.gd` (neu); `game/tests/test_avatar.gd`, `game/tests/test_figures.gd`, `game/tests/test_main.gd` (je ein Test am Ende) +- 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` - - `Figures`: `art: Art`, `facing_of(player: int) -> float` + - `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. @@ -1076,7 +973,7 @@ func test_without_a_world_nothing_is_drawn() -> void: _free(view) ``` -Füge am Ende von `game/tests/test_avatar.gd` diesen Test an. Wie zwischen allen Funktionen stehen davor zwei Leerzeilen: +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: @@ -1093,6 +990,13 @@ func test_a_walking_figure_steps_and_faces_the_way_it_goes() -> void: 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: @@ -1131,7 +1035,7 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Der Testlauf endet mit Exit-Code 1 und `113 passed, 4 failed`. `test_map_view.gd` lädt nicht (`SCRIPT ERROR: Parse Error: Cannot find member "BLOCK" in base "MapView".`). Die drei angehängten Tests scheitern mit `Invalid access to property or key 'moving' on a base object of type 'Node2D (Avatar)'.`, `Invalid call. Nonexistent function 'facing_of' in base 'Node2D (Figures)'.` und `Invalid call. Nonexistent function 'burst_count' in base 'Node2D (MapView)'.` Am Ende meldet Godot liegen gebliebene Objekte (`… were leaked at exit`), weil die abgebrochenen Tests nicht aufräumen. +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** @@ -1355,10 +1259,12 @@ var step := 0.0 var moving := false -## The square a figure standing at `at` is drawn in. +## 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 - Vector2(size, size) / 2.0, Vector2(size, 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: @@ -1477,7 +1383,7 @@ func _draw() -> void: var state := {"player": player, "facing": facing_of(player), "step": _step.get(player, 0.0), "moving": _moving.get(player, false)} art.draw(self, Art.FIGURE, Avatar.figure_rect(at), state) if session != null: - var label := Vector2(at.x - NAME_WIDTH / 2.0, at.y - GridMotion.TILE_SIZE / 2.0 - 2.0) + var 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) @@ -2007,7 +1913,7 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `121 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +Expected: Exit-Code 0 und `119 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. - [ ] **Step 5: Bildschirmfoto prüfen** @@ -2035,10 +1941,11 @@ Spielfeld über Art zeichnen Karte, Figuren und Zielmarke zeichnet jetzt Art. Die Karte zeichnet in Blöcken von 16 mal 16 Kacheln und nach einem Treffer nur die Blöcke um -die Kachel neu; jede Kachel kennt ihre Nachbarn. Figuren schreiten und -schauen in die Richtung, in die sie gehen. Der eigene Schlag zeigt -seine Splitter sofort, auch als Gast, und die Namen über fremden -Figuren werden in Bildschirmgröße gezeichnet und bleiben scharf. +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 EOF @@ -2174,7 +2081,7 @@ func test_the_style_samples_open_from_the_menu_and_lead_back_to_it() -> void: check(main.showcase.visible, "the samples show") check(not main.menu.visible, "instead of the menu") main.showcase.style_buttons[2].pressed.emit() - check_eq(main.art.style.key(), GeometricStyle.KEY, "a button there chooses the style") + 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") @@ -2191,7 +2098,7 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Der Testlauf endet mit Exit-Code 1 und `120 passed, 4 failed`. `test_showcase.gd` lädt nicht (`SCRIPT ERROR: Parse Error: Identifier "Showcase" not declared in the current scope.`). `test_every_action_of_the_game_exists` vermisst `action next_style`, `action back` und `action style_1` bis `action style_3`. Die neuen Tests in `test_menu.gd` und `test_main.gd` scheitern mit `Invalid access to property or key 'showcase_chosen' on a base object of type 'CanvasLayer (Menu)'.` beziehungsweise `… key 'showcase_button' …`. +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** @@ -2629,9 +2536,9 @@ LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when pl HOST_SAVES,Nur der Host speichert,Only the host saves KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now -STYLE_PAPERCUT,Scherenschnitt,Papercut -STYLE_OUTLINE,Kontur,Outline -STYLE_GEOMETRIC,Geometrisch,Geometric +STYLE_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 @@ -3163,7 +3070,7 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `128 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +Expected: Exit-Code 0 und `126 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. - [ ] **Step 5: Alles prüfen** @@ -3207,7 +3114,7 @@ git push - Produces: - `LaunchOptions.style: StringName`: der Stil aus `--style=NAME`, leer ohne Angabe. Ein unbekannter Name landet in `ignored`. - `SaveStore.style() -> String` und `SaveStore.set_style(key: String) -> void`, in den Einstellungen unter Abschnitt `display`, Schlüssel `style` - - Hauptszene: Beim Start gilt der Stil der Befehlszeile, sonst der gemerkte. F2 wechselt im Spiel und meldet `STYLE_SWITCHED`. Jeder Wechsel wird gemerkt. + - 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 `-- [--style=NAME] [--showcase]` - Texte: `STYLE_SWITCHED`; die Hilfezeile `HELP` nennt F2 @@ -3217,7 +3124,7 @@ Füge am Ende von `game/tests/test_launch_options.gd` diesen Test an, nach zwei ```gdscript func test_a_style_can_be_chosen() -> void: - check_eq(LaunchOptions.parse(PackedStringArray(["--style=geometric"])).style, GeometricStyle.KEY, "a known style") + check_eq(LaunchOptions.parse(PackedStringArray(["--style=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") @@ -3231,8 +3138,8 @@ func test_the_chosen_style_is_kept() -> void: var directory := TestFiles.temporary_dir("style") var store := SaveStore.new(directory) check_eq(store.style(), "", "no style at first") - store.set_style("outline") - check_eq(SaveStore.new(directory).style(), "outline", "kept for the next start") + store.set_style("pastel") + check_eq(SaveStore.new(directory).style(), "pastel", "kept for the next start") TestFiles.remove_dir(directory) ``` @@ -3259,10 +3166,10 @@ func test_the_command_line_chooses_the_style() -> void: var directory := TestFiles.temporary_dir("style-option") var main: Node = load("res://main.tscn").instantiate() main.storage_dir = directory - main.command_line = PackedStringArray(["--style=outline"]) + main.command_line = PackedStringArray(["--style=pastel"]) tree.root.add_child(main) _started.append(main) - check_eq(main.art.style.key(), OutlineStyle.KEY, "the style of the command line") + check_eq(main.art.style.key(), PastelStyle.KEY, "the style of the command line") _clean_up([directory]) ``` @@ -3275,14 +3182,14 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Der Testlauf endet mit Exit-Code 1 und `128 passed, 4 failed`: +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 outline, got papercut` +- `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=outline` und `ERROR: String formatting error: not all arguments converted during string formatting.`, weil es den Text `STYLE_SWITCHED` noch nicht gibt. +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** @@ -3300,8 +3207,8 @@ extends RefCounted ## --host opens the world for guests at once ## --port=PORT the port an open world listens on ## --join=ADDRESS joins a host at once; ADDRESS may end in ":PORT" -## --style=NAME draws the playing field in the style NAME: papercut, -## outline or geometric +## --style=NAME draws the playing field in the style NAME: warm, +## pastel or glow ## Longest profile name. const MAX_PROFILE_LENGTH := 32 @@ -3941,9 +3848,9 @@ LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when pl HOST_SAVES,Nur der Host speichert,Only the host saves KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now -STYLE_PAPERCUT,Scherenschnitt,Papercut -STYLE_OUTLINE,Kontur,Outline -STYLE_GEOMETRIC,Geometrisch,Geometric +STYLE_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 @@ -4056,18 +3963,18 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `132 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +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=outline +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 Stile zeichnen erst ab Task 5 selbst. Das erste Bild zeigt das Spiel wie nach Task 2, dazu links eine zweite Figur, über der scharf „Spieler“ steht, und kleine helle Splitter am getroffenen Fels. Das zweite Bild zeigt die Vitrine: oben „Stilprobe: Scherenschnitt“, in der Mitte die Szene aus Rechtecken, Kreisen und Linien mit neun weißen Beschriftungen, unten die Knöpfe „1 Scherenschnitt“ (ausgegraut), „2 Kontur“, „3 Geometrisch“ und „Zurück“. +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** @@ -4099,28 +4006,120 @@ git push --- -### Task 5: Stil Scherenschnitt +### Task 5: Pixelgrafik für Boden, Fels und Figuren -Lagen aus geschnittenem Papier ohne Umriss, mit weichen Schatten. Gedeckte Erdfarben; Lebendiges leuchtet grün und golden. Das Licht fällt von oben links, also wirft Fels seinen Schatten nach rechts unten auf den Boden. +`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:** -- Modify: `game/scripts/art/papercut_style.gd` (ganze Datei ersetzen) -- Test: `game/tests/test_papercut_style.gd` (neu) +- 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 Helfern, `TileShape`, `Art.IDS`, die Einträge von `state` laut Kopf von `art_style.gd` -- Produces: `PapercutStyle` zeichnet jede Kennung selbst; `PLAYER_COLORS: Array[Color]` mit acht Farben und `player_color(player: int) -> Color` +- 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: Fehlschlagenden Test schreiben** +- [ ] **Step 1: Fehlschlagende Tests schreiben** -Create `game/tests/test_papercut_style.gd`: +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 := PapercutStyle.new() + var style := WarmStyle.new() var colours := {} for player in 4: colours[style.player_color(player)] = true @@ -4128,7 +4127,7 @@ func test_four_players_have_four_colours() -> void: check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first") ``` -- [ ] **Step 2: Test ausführen und Fehlschlag sehen** +- [ ] **Step 2: Tests ausführen und Fehlschlag sehen** Run: @@ -4137,68 +4136,530 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 1 und `132 passed, 1 failed`; unter `FAIL test_papercut_style.gd test_four_players_have_four_colours` steht `four players, four colours: expected 4, got 2`, denn der Platzhalter kennt nur zwei Farben. +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: Stil schreiben** +- [ ] **Step 3: Pixelgrafik, Palette und scharfe Pixel einbauen** -Ersetze den gesamten Inhalt von `game/scripts/art/papercut_style.gd` durch: +Create `game/scripts/art/pixel_style.gd`: ```gdscript -class_name PapercutStyle +class_name PixelStyle extends ArtStyle -## Layers of cut paper without outlines, with soft shadows. Muted earth -## colours; living things glow in green and gold. Light falls from the top -## left, so rock casts its shadow onto the ground below and to the right. +## 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. -const KEY := &"papercut" +## 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) -const GROUND := Color("9a8468") -const GROUND_FLECK := Color("8d7860") -const GROUND_FIBRE := Color("a8926f") -const SHADOW := Color(0.16, 0.11, 0.07, 0.38) -## The outer, fainter part of a shadow on the ground. -const SHADOW_FAINT := Color(0.16, 0.11, 0.07, 0.17) -const SOFT_BASE := Color("8b8275") -const SOFT_TOP := Color("bab1a2") -const SOFT_SCRAP := Color("a69d8e") -const HARD_BASE := Color("454c5b") -const HARD_TOP := Color("646c7e") -const HARD_FACET := Color("7f8899") -const BEDROCK_BASE := Color("241e1a") -const BEDROCK_TOP := Color("342b25") -const CUT := Color("2a2119") -const DEAD_CUT := Color("6f5e4a") -const LIVING_BASE := Color("587f3e") -const LIVING_TOP := Color("72a04c") -const GOLD := Color("ebc75e") -const MOSS_DARK := Color("4b7633") -const MOSS_LIGHT := Color("89bd59") -const VINE_DARK := Color("3d672b") -const VINE_LIGHT := Color("6e9d44") -const LEAF := Color("88ba57") -const STONE_BASE := Color("8f877a") -const STONE_TOP := Color("b1a999") -const STEM := Color("5a8739") -const FIBRE := Color("f1e8d2") -const SEED := Color("7a5b3a") -const WOOL := Color("eee6d4") -const WOOL_SHADE := Color("d3c7b0") -const FACE := Color("4a3a30") -const RUIN_BASE := Color("767067") -const RUIN_TOP := Color("a39c90") -const RUIN_DARK := Color("2d2722") -const SKIN := Color("e9cba4") -const HAIR := Color("4b3528") -const LEGS := Color("3a2f28") -const MARKER_NEAR := Color("f0cf6a") -const MARKER_FAR := Color("c25b4c") -const PLAYER_COLORS: Array[Color] = [ - Color("c5563f"), Color("3f7cad"), Color("d99a3d"), Color("6a9a4c"), - Color("8b5a7c"), Color("4a9a96"), Color("b07a52"), Color("6b6fa8"), -] -## How far each of the two bands of a rock shadow reaches onto the ground, -## in pixels: a darker band along the rock and a fainter one beyond it. -const SHADOW_DEPTH := 1.8 +## 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: @@ -4206,302 +4667,35 @@ func key() -> StringName: func title_key() -> String: - return "STYLE_PAPERCUT" + 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. -func player_color(player: int) -> Color: - return PLAYER_COLORS[maxi(0, player) % PLAYER_COLORS.size()] +```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 -func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - canvas.draw_rect(rect, GROUND) - _paper_bits(canvas, rect, state.get("variant", 0), GROUND_FLECK, GROUND_FIBRE) - _shadow(canvas, rect, state.get("solid", 0)) +[application] +config/name="Grovewright" +run/main_scene="res://main.tscn" +config/features=PackedStringArray("4.7", "GL Compatibility") -func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var solid: int = state.get("solid", 0) - var variant: int = state.get("variant", 0) - _raised(canvas, rect, solid, SOFT_BASE, SOFT_TOP) - if share(variant, 3) < 0.4: - var at := rect.position + Vector2(4, 4) + Vector2(share(variant, 4), share(variant, 5)) * 6.0 - canvas.draw_colored_polygon(regular_polygon(at, 2.0, 5, share(variant, 6) * TAU), SOFT_SCRAP) +[display] +window/size/viewport_width=1280 +window/size/viewport_height=720 -func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var solid: int = state.get("solid", 0) - var variant: int = state.get("variant", 0) - _raised(canvas, rect, solid, HARD_BASE, HARD_TOP) - # An angled facet, as if the hard stone had split along a plane. - var c := rect.get_center() - var flip := 1.0 if share(variant, 3) < 0.5 else -1.0 - var facet := PackedVector2Array([ - c + Vector2(-5.0 * flip, -4.0), c + Vector2(4.0 * flip, -2.0), c + Vector2(-1.0 * flip, 5.0), - ]) - canvas.draw_colored_polygon(facet, HARD_FACET) +[rendering] - -func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - _raised(canvas, rect, state.get("solid", 0), BEDROCK_BASE, BEDROCK_TOP) - - -func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var hits: int = state.get("hits", 1) - var variant: int = state.get("variant", 0) - var c := rect.get_center() - var start := c + Vector2(-5.0, -3.0 + share(variant, 8) * 2.0) - canvas.draw_colored_polygon(slit(start, c + Vector2(4.0, 3.0), 1.4), CUT) - if hits >= 2: - canvas.draw_colored_polygon(slit(c + Vector2(3.5, -5.0), c + Vector2(-1.0, 4.5), 1.2), CUT) - - -func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var age: float = state.get("age", 0.0) - var variant: int = state.get("variant", 0) - var c := rect.get_center() - for flake in 5: - var direction := Vector2.from_angle(TAU * flake / 5.0 + share(variant, flake) * 0.8) - var at := c + direction * (2.0 + 9.0 * age) + Vector2(0, 6.0 * age * age) - var turn := age * 5.0 + flake - var color := SOFT_TOP if flake % 2 == 0 else SOFT_BASE - color.a = 1.0 - age - canvas.draw_colored_polygon(regular_polygon(at, 1.6, 3, turn), color) - - -func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var facing: float = state.get("facing", 1.0) - var step: float = state.get("step", 0.0) - var moving: bool = state.get("moving", false) - var swing := sin(step) if moving else 0.0 - var lift := absf(swing) * 1.0 - var c := rect.get_center() + Vector2(0, -lift) - var feet := rect.get_center() + Vector2(0, 6.0) - canvas.draw_colored_polygon(ellipse(feet, Vector2(4.5, 1.6)), SHADOW) - for side in [-1.0, 1.0]: - var leg := Vector2(side * 1.6 + side * swing * 1.2, 4.5) - canvas.draw_rect(Rect2(c + leg - Vector2(0.9, 1.5), Vector2(1.8, 3.0)), LEGS) - var body := PackedVector2Array([ - c + Vector2(-3.6, 4.0), c + Vector2(-2.6, -1.8), c + Vector2(2.6, -1.8), c + Vector2(3.6, 4.0), - ]) - canvas.draw_colored_polygon(body, player_color(state.get("player", -1))) - var head := c + Vector2(0, -4.6) - canvas.draw_circle(head, 3.1, SKIN, true, -1.0, true) - # Hair on the back of the head, so that the figure looks the way it goes. - canvas.draw_colored_polygon(ellipse(head + Vector2(-facing * 1.2, -1.2), Vector2(2.4, 1.9)), HAIR) - canvas.draw_circle(head + Vector2(facing * 1.3, -0.2), 0.55, LEGS, true, -1.0, true) - - -func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var color := MARKER_NEAR if state.get("reachable", false) else MARKER_FAR - var inner := rect.grow(-1.0) - var arm := 4.0 - var thick := 1.6 - for corner in 4: - var x := inner.position.x if corner % 3 == 0 else inner.end.x - var y := inner.position.y if corner < 2 else inner.end.y - var sx := 1.0 if corner % 3 == 0 else -1.0 - var sy := 1.0 if corner < 2 else -1.0 - var bracket := PackedVector2Array([ - Vector2(x, y), Vector2(x + sx * arm, y), Vector2(x + sx * arm, y + sy * thick), - Vector2(x + sx * thick, y + sy * thick), Vector2(x + sx * thick, y + sy * arm), Vector2(x, y + sy * arm), - ]) - var shade := bracket.duplicate() - for i in shade.size(): - shade[i] += Vector2(0.6, 0.6) - canvas.draw_colored_polygon(shade, SHADOW) - canvas.draw_colored_polygon(bracket, color) - - -func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var variant: int = state.get("variant", 0) - canvas.draw_rect(rect, GROUND) - if share(variant, 1) < 0.55: - var a := rect.position + Vector2(share(variant, 2) * 6.0 + 2.0, share(variant, 3) * 4.0 + 3.0) - var b := a + Vector2(6.0, 4.0 - share(variant, 4) * 8.0) - canvas.draw_colored_polygon(slit(a, b, 1.1), DEAD_CUT) - canvas.draw_colored_polygon(slit(b, b + Vector2(3.0, 3.0), 0.8), DEAD_CUT) - _shadow(canvas, rect, state.get("solid", 0)) - - -func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var living: int = state.get("living", 0) - var variant: int = state.get("variant", 0) - canvas.draw_rect(rect, GROUND) - canvas.draw_colored_polygon(rounded_rect(rect, living, 5.0), LIVING_BASE) - canvas.draw_colored_polygon(rounded_rect(inset_open_sides(rect, living, 1.4), living, 3.6), LIVING_TOP) - if share(variant, 1) < 0.5: - var at := rect.position + Vector2(3, 3) + Vector2(share(variant, 2), share(variant, 3)) * 10.0 - canvas.draw_circle(at, 0.8, GOLD, true, -1.0, true) - _shadow(canvas, rect, state.get("solid", 0)) - - -func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var variant: int = state.get("variant", 0) - var time: float = state.get("time", 0.0) - var foot := Vector2(rect.get_center().x, rect.end.y - 3.0) - var breathe := 1.0 + 0.06 * sway(time, variant, 1.6) - canvas.draw_colored_polygon(ellipse(foot + Vector2(1.0, 1.2), Vector2(6.0, 2.0)), SHADOW) - var blobs: Array[Vector3] = [Vector3(-3.5, -1.5, 3.0), Vector3(0.5, -3.0, 3.6), Vector3(3.8, -1.2, 2.7)] - for blob in blobs: - canvas.draw_circle(foot + Vector2(blob.x, blob.y) * breathe, blob.z * breathe, MOSS_DARK, true, -1.0, true) - for blob in blobs: - canvas.draw_circle(foot + Vector2(blob.x - 0.6, blob.y - 0.8) * breathe, blob.z * 0.7 * breathe, MOSS_LIGHT, true, -1.0, true) - for spark in 3: - var glow := 0.55 + 0.45 * sin(time * 2.0 + spark * 2.1 + share(variant, spark) * TAU) - var gold := GOLD - gold.a = glow - canvas.draw_circle(foot + Vector2(-3.0 + spark * 3.0, -4.0 + (spark % 2) * 2.0), 0.7, gold, true, -1.0, true) - - -func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void: - var points: PackedVector2Array = state.get("points", PackedVector2Array()) - var time: float = state.get("time", 0.0) - if points.size() < 2: - return - var shade := points.duplicate() - for i in shade.size(): - shade[i] += Vector2(1.0, 1.4) - canvas.draw_polyline(shade, SHADOW, 3.0, true) - canvas.draw_polyline(points, VINE_DARK, 3.2, true) - var top := points.duplicate() - for i in top.size(): - top[i] -= Vector2(0.3, 0.4) - canvas.draw_polyline(top, VINE_LIGHT, 1.6, true) - var count := 9 - for i in count: - var share_along := (i + 0.5) / count - var at := along(points, share_along) - var ahead := along(points, minf(1.0, share_along + 0.02)) - var side := 1.0 if i % 2 == 0 else -1.0 - var angle := (ahead - at).angle() + side * 0.9 + 0.25 * sway(time, i, 1.4) - canvas.draw_colored_polygon(leaf(at, angle, 5.0, 2.6), LEAF) - - -func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var growth: float = state.get("growth", 1.0) - var time: float = state.get("time", 0.0) - var foot := Vector2(rect.get_center().x, rect.end.y - 2.0) - canvas.draw_colored_polygon(ellipse(foot + Vector2(1.0, 0.8), Vector2(4.0, 1.4)), SHADOW) - if growth < 0.15: - canvas.draw_colored_polygon(ellipse(foot + Vector2(0, -1.2), Vector2(2.2, 1.6)), SEED) - if growth > 0.08: - canvas.draw_colored_polygon(leaf(foot + Vector2(0, -2.2), -PI / 2.0, 2.5, 1.4), LEAF) - return - var grown := (growth - 0.15) / 0.85 - var height := lerpf(4.0, 20.0, grown) - canvas.draw_set_transform(foot, 0.07 * sway(time, 3, 1.1), Vector2.ONE) - canvas.draw_colored_polygon(slit(Vector2.ZERO, Vector2(0, -height), 1.6), STEM) - var leaves := 1 + int(grown * 5.0) - for i in leaves: - var at := Vector2(0, -height * (0.2 + 0.7 * i / maxf(1.0, leaves))) - var side := 1.0 if i % 2 == 0 else -1.0 - canvas.draw_colored_polygon(leaf(at, -PI / 2.0 + side * 1.0, lerpf(3.0, 6.0, grown), 2.6), LEAF) - if grown > 0.75: - var tuft := (grown - 0.75) / 0.25 - for puff in 4: - var at := Vector2(-2.0 + puff * 1.4, -height - 1.0 - (puff % 2) * 1.2) - canvas.draw_circle(at, 1.4 * tuft + 0.3, FIBRE, true, -1.0, true) - canvas.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) - - -func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - var c := rect.get_center() - canvas.draw_colored_polygon(ellipse(c + Vector2(0.8, 2.6), Vector2(3.0, 1.0)), SHADOW) - canvas.draw_colored_polygon(regular_polygon(c, 3.0, 6, 0.3), STONE_BASE) - canvas.draw_colored_polygon(regular_polygon(c + Vector2(-0.4, -0.5), 2.2, 6, 0.3), STONE_TOP) - - -func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var hop: float = state.get("hop", 1.0) - var facing: float = state.get("facing", 1.0) - var lift := sin(PI * hop) * 4.0 - var foot := Vector2(rect.get_center().x, rect.end.y - 2.0) - canvas.draw_colored_polygon(ellipse(foot + Vector2(0.8, 0.6), Vector2(5.5 - lift * 0.5, 1.6)), SHADOW) - var body := foot + Vector2(0, -6.0 - lift) - for leg in [-2.5, 2.5]: - canvas.draw_rect(Rect2(body + Vector2(leg - 0.7, 3.0), Vector2(1.4, 3.0 - lift * 0.3)), FACE) - var puffs: Array[Vector3] = [ - Vector3(-3.5, 0.5, 3.0), Vector3(0.0, -1.5, 3.4), Vector3(3.2, 0.3, 3.0), Vector3(0.0, 1.8, 3.0), - ] - for puff in puffs: - canvas.draw_circle(body + Vector2(puff.x, puff.y), puff.z, WOOL_SHADE, true, -1.0, true) - for puff in puffs: - canvas.draw_circle(body + Vector2(puff.x - 0.5, puff.y - 0.6), puff.z * 0.78, WOOL, true, -1.0, true) - var head := body + Vector2(facing * 5.0, -1.2) - canvas.draw_colored_polygon(ellipse(head, Vector2(2.4, 2.0)), FACE) - canvas.draw_colored_polygon(ellipse(head + Vector2(-facing * 1.6, -1.6), Vector2(1.2, 0.7)), FACE) - canvas.draw_circle(head + Vector2(facing * 0.9, -0.4), 0.45, WOOL, true, -1.0, true) - - -func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var awake: float = state.get("awake", 0.0) - var time: float = state.get("time", 0.0) - var tile := rect.size.x / 3.0 - var floor_y := rect.end.y - 3.0 - var center := Vector2(rect.get_center().x, floor_y - tile * 1.2) - # The shadow of the ruin and the dark space under its arch. - canvas.draw_colored_polygon(ellipse(Vector2(center.x + 3.0, floor_y + 1.0), Vector2(rect.size.x * 0.48, 3.5)), SHADOW) - var opening := Rect2(rect.position.x + tile * 0.75, rect.position.y + tile * 0.9, tile * 1.5, floor_y - rect.position.y - tile * 0.9) - canvas.draw_rect(opening, RUIN_DARK) - # The glow of the sleeping grove, brighter the more it is awake. - for ring in 4: - var gold := GOLD - gold.a = awake * (0.18 + 0.12 * ring) - canvas.draw_circle(center + Vector2(0, 4), 9.0 - ring * 2.0, gold, true, -1.0, true) - var crown := center + Vector2(0, 0.5 * sin(time * 1.2)) - canvas.draw_colored_polygon(slit(Vector2(center.x, floor_y), crown + Vector2(0, 4), 2.6), SEED) - for blob in 3: - var at := crown + Vector2(-3.5 + blob * 3.5, -1.0 + (blob % 2) * -2.0) - canvas.draw_circle(at, 3.4, MOSS_DARK.lerp(GOLD, awake * 0.35), true, -1.0, true) - # Two pillars and the lintel, each of two layers of paper. - for x in [rect.position.x, rect.end.x - tile * 0.75]: - var pillar := Rect2(x, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6) - canvas.draw_colored_polygon(rounded_rect(pillar, 0, 2.0), RUIN_BASE) - canvas.draw_colored_polygon(rounded_rect(pillar.grow(-1.4), 0, 1.4), RUIN_TOP) - var vein := GOLD - vein.a = awake * 0.8 - canvas.draw_line(pillar.get_center() + Vector2(0, -6), pillar.get_center() + Vector2(1.5, 6), vein, 1.0, true) - var lintel := Rect2(rect.position.x - 1.0, rect.position.y + tile * 0.25, rect.size.x + 2.0, tile * 0.6) - canvas.draw_colored_polygon(rounded_rect(lintel, 0, 2.0), RUIN_BASE) - canvas.draw_colored_polygon(rounded_rect(lintel.grow(-1.4), 0, 1.4), RUIN_TOP) - var step := Rect2(rect.position.x + tile * 0.4, floor_y - 2.0, rect.size.x - tile * 0.8, 3.0) - canvas.draw_rect(step, RUIN_BASE) - - -## A mass of rock: ground where the corners are cut round, the darker lower -## layer of paper and the lighter upper one, inset on the open sides. -func _raised(canvas: CanvasItem, rect: Rect2, solid: int, base: Color, top: Color) -> void: - canvas.draw_rect(rect, GROUND) - _shadow(canvas, rect, solid) - canvas.draw_colored_polygon(rounded_rect(rect, solid, 4.0), base) - canvas.draw_colored_polygon(rounded_rect(inset_open_sides(rect, solid, 1.6), solid, 2.6), top) - - -## The shadow that rock above and to the left casts onto this tile: a darker -## band along the rock and a fainter one beyond, so that it fades. -func _shadow(canvas: CanvasItem, rect: Rect2, solid: int) -> void: - var above := solid & TileShape.N != 0 - var left := solid & TileShape.W != 0 - for band: Array in [[0.0, SHADOW], [SHADOW_DEPTH, SHADOW_FAINT]]: - var offset: float = band[0] - var color: Color = band[1] - if above: - canvas.draw_rect(Rect2(rect.position + Vector2(0, offset), Vector2(rect.size.x, SHADOW_DEPTH)), color) - if left: - var top := offset + SHADOW_DEPTH if above else 0.0 - canvas.draw_rect(Rect2(rect.position + Vector2(offset, top), Vector2(SHADOW_DEPTH, rect.size.y - top)), color) - if not above and not left and solid & TileShape.NW != 0: - canvas.draw_rect(Rect2(rect.position, Vector2(SHADOW_DEPTH + offset, SHADOW_DEPTH + offset)), color) - - -## Small flecks and fibres in the paper, on some tiles. -func _paper_bits(canvas: CanvasItem, rect: Rect2, variant: int, fleck: Color, fibre: Color) -> void: - if share(variant, 11) < 0.25: - var at := rect.position + Vector2(3, 3) + Vector2(share(variant, 12), share(variant, 13)) * 10.0 - canvas.draw_colored_polygon(regular_polygon(at, 1.1 + share(variant, 14), 5, share(variant, 15) * TAU), fleck) - if share(variant, 16) < 0.2: - var from := rect.position + Vector2(2, 2) + Vector2(share(variant, 17), share(variant, 18)) * 9.0 - canvas.draw_line(from, from + Vector2.from_angle(share(variant, 19) * TAU) * 3.5, fibre, 0.7, true) +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** @@ -4513,21 +4707,21 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `133 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +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-scherenschnitt-karte.png" --style=papercut -godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-scherenschnitt-vitrine.png" --style=papercut --showcase +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. Nirgends steht mehr ein Platzhalter, also kein einfarbiges Rechteck und kein schlichter Kreis. +Expected: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Alle Pixel sind scharfe Quadrate, nichts ist verschwommen. -- Spiel: Der Boden ist braunes Packpapier mit feinen Flecken und Fasern. Fels besteht aus zwei hellgrauen Papierlagen mit wenigen Flecken, außen abgerundet und innen geschlossen, und wirft nach rechts unten einen weichen Schatten. Im getroffenen Fels steht ein dunkler Schnitt, darum fliegen Papierschnipsel. Die beiden Figuren stehen auf einem Schatten: Körper in Rotbraun, heller Kopf, dunkles Haar, Beine. Über der linken steht scharf „Spieler“. -- Vitrine: Grundgestein fast schwarz, weicher Fels hell, harter Fels blaugrau mit hellen Dreiecksfacetten, alles mit Schatten. Toter Boden mit eingeschnittenen Rissen, lebendiger Boden als grüne Papierlage mit goldenen Pünktchen. Moospolster, eine Ranke als Band mit Blättern und drei Steinen, eine fast ausgewachsene Faserpflanze mit weißen Fasern, ein Wollschaf in der Luft, die Ruine als Steintor mit goldenem Leuchten, zwei Figuren in Rot und Blau. Oben „Stilprobe: Scherenschnitt“, unten ist „1 Scherenschnitt“ ausgegraut. +- 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** @@ -4545,12 +4739,14 @@ Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 best ```bash git add game git commit -F - <<'EOF' -Stil Scherenschnitt +Pixelgrafik für Boden, Fels und Figuren -Lagen aus geschnittenem Papier ohne Umriss, mit weichen Schatten nach -rechts unten. Gedeckte Erdfarben; Moos, lebendiger Boden und die Ruine -leuchten grün und golden. Pflanzen wiegen sich, die Faserpflanze wächst -vom Samen bis zu den Fasern, und jeder Spieler hat eine eigene Farbe. +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 EOF @@ -4559,33 +4755,85 @@ git push --- -### Task 6: Stil Kontur +### Task 6: Pixelgrafik für die Dinge der Vitrine -Kräftige dunkle Umrisse um flache, starke Farben, gut lesbar wie ein Comic. Felsmassen haben ihren Umriss nur dort, wo sie an offenen Boden grenzen, und jedes Ding hat einen dunklen Umriss um seine ganze Form. +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/outline_style.gd` (ganze Datei ersetzen) -- Test: `game/tests/test_outline_style.gd` (neu) +- Modify: `game/scripts/art/pixel_style.gd` (zwei Stellen einfügen, einen Abschnitt anhängen) +- Test: `game/tests/test_pixel_things.gd` **Interfaces:** -- Consumes: `ArtStyle` mit seinen Zeichenmethoden und Helfern, `TileShape`, `Art.IDS`, die Einträge von `state` laut Kopf von `art_style.gd` -- Produces: `OutlineStyle` zeichnet jede Kennung selbst; `PLAYER_COLORS: Array[Color]` mit acht Farben und `player_color(player: int) -> Color` +- 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_outline_style.gd`: +Create `game/tests/test_pixel_things.gd`: ```gdscript extends TestCase +## The pixel art of the things of later milestones in the showcase. -func test_four_players_have_four_colours() -> void: - var style := OutlineStyle.new() - var colours := {} - for player in 4: - colours[style.player_color(player)] = true - check_eq(colours.size(), 4, "four players, four colours") - check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first") +## 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** @@ -4597,374 +4845,348 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 1 und `133 passed, 1 failed`; unter `FAIL test_outline_style.gd test_four_players_have_four_colours` steht `four players, four colours: expected 4, got 2`. +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: Stil schreiben** +- [ ] **Step 3: Die Dinge der Vitrine zeichnen** -Ersetze den gesamten Inhalt von `game/scripts/art/outline_style.gd` durch: +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 -class_name OutlineStyle -extends ArtStyle -## Bold dark outlines around flat colours, few and strong colours, easy to -## read like a comic. Masses of rock are outlined only where they meet open -## ground, and each thing gets one dark outline around its whole shape. +## Stages of a growing plant, from the seed to the fibres. +const GROWTH_STAGES := 6 +``` -const KEY := &"outline" +Füge in `game/scripts/art/pixel_style.gd` direkt vor der Zeile `var _textures: Dictionary[String, Texture2D] = {}` diese Zeilen ein: -const INK := Color("2b1d14") -## Width of the outlines, in pixels of the playing field. -const LINE := 1.1 -const GROUND := Color("d6a35a") -const GROUND_DOT := Color("bd8a43") -const SOFT_FILL := Color("c8c1d6") -const SOFT_SHADE := Color("a79fbd") -const SOFT_LIGHT := Color("eeeaf5") -const HARD_FILL := Color("4a63a6") -const HARD_SHADE := Color("374c88") -const HARD_GLINT := Color("a9c3ff") -const DEAD_CRACK := Color("9a6b31") -const LIVING := Color("5cbf48") -const LIVING_SHADE := Color("3f9a33") -const SPARK := Color("ffe14d") -const MOSS := Color("49b13a") -const MOSS_LIGHT := Color("8de06a") -const VINE := Color("3fa34a") -const LEAF := Color("6fd259") -const STONE := Color("b3aba0") -const STEM := Color("3f9a33") -const FIBRE := Color("fffaf0") -const SEED := Color("8a5a35") -const WOOL := Color("fbf8f0") -const FACE := Color("3a2a22") -const RUIN := Color("a8a49b") -const RUIN_SHADE := Color("8a857b") -const RUIN_DARK := Color("3b322b") -const GLOW := Color("ffd84a") -const SKIN := Color("f5c79a") -const MARKER_NEAR := Color("7ddc4f") -const MARKER_FAR := Color("ee5a3c") -const PLAYER_COLORS: Array[Color] = [ - Color("e8483b"), Color("2d8fe0"), Color("f5a623"), Color("33b864"), - Color("a35bd6"), Color("1fb5b5"), Color("f06aa8"), Color("6d7ce8"), -] +```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: -func key() -> StringName: - return KEY - - -func title_key() -> String: - return "STYLE_OUTLINE" - - -func player_color(player: int) -> Color: - return PLAYER_COLORS[maxi(0, player) % PLAYER_COLORS.size()] - - -func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var variant: int = state.get("variant", 0) - canvas.draw_rect(rect, GROUND) - if share(variant, 1) < 0.3: - var at := rect.position + Vector2(3, 3) + Vector2(share(variant, 2), share(variant, 3)) * 10.0 - canvas.draw_circle(at, 0.8, GROUND_DOT, true, -1.0, true) - canvas.draw_circle(at + Vector2(2.2, 1.0), 0.55, GROUND_DOT, true, -1.0, true) - - -func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var solid: int = state.get("solid", 0) - var variant: int = state.get("variant", 0) - _mass(canvas, rect, solid, SOFT_FILL, SOFT_SHADE) - if share(variant, 3) < 0.3: - var at := rect.position + Vector2(4, 4) + Vector2(share(variant, 4), share(variant, 5)) * 5.0 - canvas.draw_line(at, at + Vector2(3.0, -1.2), SOFT_LIGHT, 0.9, true) - - -func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var solid: int = state.get("solid", 0) - var variant: int = state.get("variant", 0) - _mass(canvas, rect, solid, HARD_FILL, HARD_SHADE) - if share(variant, 3) < 0.45: - var at := rect.position + Vector2(5, 5) + Vector2(share(variant, 4), share(variant, 5)) * 5.0 - var glint := PackedVector2Array([at + Vector2(0, -2.4), at + Vector2(1.4, 0), at + Vector2(0, 2.4), at + Vector2(-1.4, 0)]) - canvas.draw_colored_polygon(glint, HARD_GLINT) - canvas.draw_polyline(closed(glint), INK, 0.6, true) - - -func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - canvas.draw_rect(rect, GROUND) - canvas.draw_colored_polygon(rounded_rect(rect, state.get("solid", 0), 4.0), INK) - - -func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var hits: int = state.get("hits", 1) - var c := rect.get_center() - canvas.draw_polyline(PackedVector2Array([c + Vector2(-5, -4), c + Vector2(-1, -1), c + Vector2(-2, 2), c + Vector2(4, 5)]), INK, 1.0, true) - if hits >= 2: - canvas.draw_polyline(PackedVector2Array([c + Vector2(5, -5), c + Vector2(2, -1), c + Vector2(3, 2)]), INK, 1.0, true) - - -func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var age: float = state.get("age", 0.0) - var variant: int = state.get("variant", 0) - var c := rect.get_center() - for shard in 5: - var direction := Vector2.from_angle(TAU * shard / 5.0 + share(variant, shard) * 0.8) - var at := c + direction * (2.0 + 9.0 * age) + Vector2(0, 6.0 * age * age) - var points := regular_polygon(at, 1.7, 3, age * 6.0 + shard) - var fill := SOFT_FILL - var ink := INK - fill.a = 1.0 - age - ink.a = 1.0 - age - canvas.draw_colored_polygon(points, fill) - canvas.draw_polyline(closed(points), ink, 0.6, true) - - -func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var facing: float = state.get("facing", 1.0) - var step: float = state.get("step", 0.0) - var moving: bool = state.get("moving", false) - var swing := sin(step) if moving else 0.0 - var c := rect.get_center() + Vector2(0, -absf(swing)) - var head := c + Vector2(0, -4.2) - var feet: Array[Vector2] = [c + Vector2(-1.8 + swing * 1.2, 5.0), c + Vector2(1.8 - swing * 1.2, 5.0)] - var body := ellipse(c + Vector2(0, 1.0), Vector2(3.6, 3.6)) - # One outline around the whole figure: dark shapes first, colours on top. - for foot in feet: - canvas.draw_colored_polygon(ellipse(foot, Vector2(1.7, 1.2) + Vector2(LINE, LINE)), INK) - canvas.draw_colored_polygon(ellipse(c + Vector2(0, 1.0), Vector2(3.6 + LINE, 3.6 + LINE)), INK) - canvas.draw_circle(head, 3.0 + LINE, INK, true, -1.0, true) - for foot in feet: - canvas.draw_colored_polygon(ellipse(foot, Vector2(1.7, 1.2)), INK.lightened(0.25)) - canvas.draw_colored_polygon(body, player_color(state.get("player", -1))) - canvas.draw_circle(head, 3.0, SKIN, true, -1.0, true) - for eye in [-0.6, 0.9]: - canvas.draw_circle(head + Vector2(facing * (0.9 + eye), -0.3), 0.5, INK, true, -1.0, true) - - -func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var color := MARKER_NEAR if state.get("reachable", false) else MARKER_FAR - var inner := rect.grow(-1.2) - canvas.draw_rect(inner, INK, false, 2.4, true) - canvas.draw_rect(inner, color, false, 1.2, true) +```gdscript +# Things of later milestones, shown in the showcase. func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var variant: int = state.get("variant", 0) - canvas.draw_rect(rect, GROUND) - if share(variant, 1) < 0.5: - var a := rect.position + Vector2(2, 3) + Vector2(share(variant, 2), share(variant, 3)) * 6.0 - canvas.draw_polyline(PackedVector2Array([a, a + Vector2(3, 2), a + Vector2(5, 1), a + Vector2(7, 4)]), DEAD_CRACK, 0.8, true) + 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: int = state.get("living", 0) - var variant: int = state.get("variant", 0) - canvas.draw_rect(rect, GROUND) - canvas.draw_colored_polygon(rounded_rect(rect, living, 5.0), LIVING) - _edges(canvas, rect, living, 5.0, LIVING_SHADE, LINE) - if share(variant, 1) < 0.4: - var at := rect.position + Vector2(4, 6) + Vector2(share(variant, 2), share(variant, 3)) * 7.0 - canvas.draw_polyline(PackedVector2Array([at + Vector2(-1.2, -1.8), at, at + Vector2(1.2, -2.0)]), LIVING_SHADE, 0.8, true) + 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) - var time: float = state.get("time", 0.0) - var foot := Vector2(rect.get_center().x, rect.end.y - 3.0) - var breathe := 1.0 + 0.06 * sway(time, variant, 1.6) - var blobs: Array[Vector3] = [Vector3(-3.5, -1.5, 3.0), Vector3(0.5, -3.0, 3.6), Vector3(3.8, -1.2, 2.7)] - for blob in blobs: - canvas.draw_circle(foot + Vector2(blob.x, blob.y) * breathe, blob.z * breathe + LINE, INK, true, -1.0, true) - for blob in blobs: - canvas.draw_circle(foot + Vector2(blob.x, blob.y) * breathe, blob.z * breathe, MOSS, true, -1.0, true) - canvas.draw_circle(foot + Vector2(blob.x - 0.8, blob.y - 0.9) * breathe, blob.z * 0.35, MOSS_LIGHT, true, -1.0, true) - for spark in 2: - var glow := 0.5 + 0.5 * sin(time * 2.4 + spark * 2.3 + share(variant, spark) * TAU) - _sparkle(canvas, foot + Vector2(-2.5 + spark * 5.0, -6.5 + spark), 1.8 * glow) + 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)) -func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void: +## 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()) - var time: float = state.get("time", 0.0) if points.size() < 2: return - var leaves: Array[PackedVector2Array] = [] - var count := 9 - for i in count: - var part := (i + 0.5) / count - var at := along(points, part) - var ahead := along(points, minf(1.0, part + 0.02)) - var side := 1.0 if i % 2 == 0 else -1.0 - leaves.append(leaf(at, (ahead - at).angle() + side * 0.9 + 0.25 * sway(time, i, 1.4), 5.0, 2.8)) - for blade in leaves: - canvas.draw_polyline(closed(blade), INK, LINE * 1.6, true) - canvas.draw_polyline(points, INK, 2.2 + LINE * 2.0, true) - canvas.draw_polyline(points, VINE, 2.2, true) - for blade in leaves: - canvas.draw_colored_polygon(blade, LEAF) + 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 growth: float = state.get("growth", 1.0) - var time: float = state.get("time", 0.0) - var foot := Vector2(rect.get_center().x, rect.end.y - 2.0) - if growth < 0.15: - var seed := ellipse(foot + Vector2(0, -1.2), Vector2(2.2, 1.6)) - canvas.draw_colored_polygon(ellipse(foot + Vector2(0, -1.2), Vector2(2.2 + LINE, 1.6 + LINE)), INK) - canvas.draw_colored_polygon(seed, SEED) - return - var grown := (growth - 0.15) / 0.85 - var height := lerpf(4.0, 20.0, grown) - canvas.draw_set_transform(foot, 0.07 * sway(time, 3, 1.1), Vector2.ONE) - var leaves: Array[PackedVector2Array] = [] - var count := 1 + int(grown * 5.0) - for i in count: - var at := Vector2(0, -height * (0.2 + 0.7 * i / maxf(1.0, count))) - var side := 1.0 if i % 2 == 0 else -1.0 - leaves.append(leaf(at, -PI / 2.0 + side * 1.0, lerpf(3.0, 6.0, grown), 2.8)) - var tuft := clampf((grown - 0.75) / 0.25, 0.0, 1.0) - var puffs: Array[Vector2] = [] - if tuft > 0.0: - for puff in 4: - puffs.append(Vector2(-2.0 + puff * 1.4, -height - 1.0 - (puff % 2) * 1.2)) - for blade in leaves: - canvas.draw_polyline(closed(blade), INK, LINE * 1.6, true) - canvas.draw_line(Vector2.ZERO, Vector2(0, -height), INK, 1.6 + LINE * 2.0, true) - for puff in puffs: - canvas.draw_circle(puff, 1.4 * tuft + 0.3 + LINE, INK, true, -1.0, true) - canvas.draw_line(Vector2.ZERO, Vector2(0, -height), STEM, 1.6, true) - for blade in leaves: - canvas.draw_colored_polygon(blade, LEAF) - for puff in puffs: - canvas.draw_circle(puff, 1.4 * tuft + 0.3, FIBRE, true, -1.0, true) - canvas.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) + 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: - var c := rect.get_center() - var pebble := regular_polygon(c, 2.8, 6, 0.3) - canvas.draw_colored_polygon(regular_polygon(c, 2.8 + LINE, 6, 0.3), INK) - canvas.draw_colored_polygon(pebble, STONE) - canvas.draw_line(c + Vector2(-1.2, -1.0), c + Vector2(0.4, -1.6), Color.WHITE, 0.6, true) + _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 hop: float = state.get("hop", 1.0) - var facing: float = state.get("facing", 1.0) - var lift := sin(PI * hop) * 4.0 - var foot := Vector2(rect.get_center().x, rect.end.y - 2.0) - var body := foot + Vector2(0, -6.0 - lift) - var puffs: Array[Vector3] = [ - Vector3(-3.5, 0.5, 3.0), Vector3(0.0, -1.5, 3.4), Vector3(3.2, 0.3, 3.0), Vector3(0.0, 1.8, 3.0), - ] - var head := body + Vector2(facing * 5.0, -1.2) - for leg in [-2.5, 2.5]: - canvas.draw_line(body + Vector2(leg, 3.0), body + Vector2(leg, 6.0 - lift * 0.3), INK, 1.6, true) - for puff in puffs: - canvas.draw_circle(body + Vector2(puff.x, puff.y), puff.z + LINE, INK, true, -1.0, true) - canvas.draw_colored_polygon(ellipse(head, Vector2(2.4 + LINE, 2.0 + LINE)), INK) - for puff in puffs: - canvas.draw_circle(body + Vector2(puff.x, puff.y), puff.z, WOOL, true, -1.0, true) - canvas.draw_colored_polygon(ellipse(head, Vector2(2.4, 2.0)), FACE) - canvas.draw_circle(head + Vector2(facing * 0.9, -0.4), 0.6, WOOL, true, -1.0, true) + 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: float = state.get("awake", 0.0) - var time: float = state.get("time", 0.0) - var tile := rect.size.x / 3.0 - var floor_y := rect.end.y - 3.0 - var center := Vector2(rect.get_center().x, floor_y - tile * 1.2) - var opening := Rect2(rect.position.x + tile * 0.75, rect.position.y + tile * 0.9, tile * 1.5, floor_y - rect.position.y - tile * 0.9) - canvas.draw_rect(opening, RUIN_DARK) - # Rays of the waking grove. - for ray in 8: - var direction := Vector2.from_angle(TAU * ray / 8.0 + time * 0.3) - var glow := GLOW - glow.a = awake * 0.7 - canvas.draw_line(center + direction * 4.0, center + direction * (6.0 + 3.0 * awake), glow, 1.0, true) - canvas.draw_circle(center, 3.5 + LINE, INK, true, -1.0, true) - var core := GLOW.lerp(Color.WHITE, 0.4 * awake) - canvas.draw_circle(center, 3.5, core, true, -1.0, true) - var blocks: Array[Rect2] = [ - Rect2(rect.position.x, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6), - Rect2(rect.end.x - tile * 0.75, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6), - Rect2(rect.position.x - 1.0, rect.position.y + tile * 0.25, rect.size.x + 2.0, tile * 0.6), - ] - for block in blocks: - canvas.draw_rect(block, RUIN) - canvas.draw_rect(Rect2(block.position.x, block.end.y - 2.0, block.size.x, 2.0), RUIN_SHADE) - canvas.draw_rect(block, INK, false, LINE, true) - canvas.draw_rect(Rect2(rect.position.x + tile * 0.4, floor_y - 2.0, rect.size.x - tile * 0.8, 3.0), RUIN_SHADE) - canvas.draw_rect(Rect2(rect.position.x + tile * 0.4, floor_y - 2.0, rect.size.x - tile * 0.8, 3.0), INK, false, LINE, true) + 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 mass of rock: ground where corners are cut round, the fill, a darker -## band along its lower open edge and the outline where it meets the ground. -func _mass(canvas: CanvasItem, rect: Rect2, solid: int, fill: Color, shade: Color) -> void: - canvas.draw_rect(rect, GROUND) - canvas.draw_colored_polygon(rounded_rect(rect, solid, 4.0), fill) - if solid & TileShape.S == 0: - # The band is as high as the corners are round, so that it can take - # the round lower corners of the tile. - var band := Rect2(rect.position.x, rect.end.y - 4.0, rect.size.x, 4.0) - canvas.draw_colored_polygon(rounded_rect(band, solid | TileShape.N, 4.0), shade) - _edges(canvas, rect, solid, 4.0, INK, LINE) +## 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 outline of a mass of tiles where it meets open ground: along every -## open side, round around open corners and filled into inner corners, all -## inside `rect`, so that tiles drawn later cannot cover half of it. -func _edges(canvas: CanvasItem, rect: Rect2, mask: int, radius: float, color: Color, width: float) -> void: - var half := width / 2.0 - var inner := inset_open_sides(rect, mask, half) - var r := maxf(0.0, radius - half) - var round_tl := mask & (TileShape.N | TileShape.W) == 0 - var round_tr := mask & (TileShape.N | TileShape.E) == 0 - var round_br := mask & (TileShape.S | TileShape.E) == 0 - var round_bl := mask & (TileShape.S | TileShape.W) == 0 - if mask & TileShape.N == 0: - canvas.draw_line(Vector2(inner.position.x + (r if round_tl else 0.0), inner.position.y), Vector2(inner.end.x - (r if round_tr else 0.0), inner.position.y), color, width, true) - if mask & TileShape.S == 0: - canvas.draw_line(Vector2(inner.position.x + (r if round_bl else 0.0), inner.end.y), Vector2(inner.end.x - (r if round_br else 0.0), inner.end.y), color, width, true) - if mask & TileShape.W == 0: - canvas.draw_line(Vector2(inner.position.x, inner.position.y + (r if round_tl else 0.0)), Vector2(inner.position.x, inner.end.y - (r if round_bl else 0.0)), color, width, true) - if mask & TileShape.E == 0: - canvas.draw_line(Vector2(inner.end.x, inner.position.y + (r if round_tr else 0.0)), Vector2(inner.end.x, inner.end.y - (r if round_br else 0.0)), color, width, true) - if round_tl: - canvas.draw_arc(inner.position + Vector2(r, r), r, PI, 1.5 * PI, 6, color, width, true) - if round_tr: - canvas.draw_arc(Vector2(inner.end.x - r, inner.position.y + r), r, 1.5 * PI, TAU, 6, color, width, true) - if round_br: - canvas.draw_arc(inner.end - Vector2(r, r), r, 0.0, 0.5 * PI, 6, color, width, true) - if round_bl: - canvas.draw_arc(Vector2(inner.position.x + r, inner.end.y - r), r, 0.5 * PI, PI, 6, color, width, true) - # Inner corners: both sides join neighbours, but the tile across the - # corner is open, so the outlines of the neighbours meet here. - var corners: Array[Array] = [ - [TileShape.N | TileShape.W, TileShape.NW, rect.position], - [TileShape.N | TileShape.E, TileShape.NE, Vector2(rect.end.x - width, rect.position.y)], - [TileShape.S | TileShape.E, TileShape.SE, rect.end - Vector2(width, width)], - [TileShape.S | TileShape.W, TileShape.SW, Vector2(rect.position.x, rect.end.y - width)], - ] - for corner in corners: - var sides: int = corner[0] - var across: int = corner[1] - if mask & sides == sides and mask & across == 0: - canvas.draw_rect(Rect2(corner[2], Vector2(width, width)), color) - - -## A four-pointed sparkle of `size` around `at`. -func _sparkle(canvas: CanvasItem, at: Vector2, size: float) -> void: - if size <= 0.1: - return - var star := PackedVector2Array([ - at + Vector2(0, -size), at + Vector2(size * 0.3, -size * 0.3), at + Vector2(size, 0), - at + Vector2(size * 0.3, size * 0.3), at + Vector2(0, size), at + Vector2(-size * 0.3, size * 0.3), - at + Vector2(-size, 0), at + Vector2(-size * 0.3, -size * 0.3), - ]) - canvas.draw_colored_polygon(star, SPARK) +## 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** @@ -4976,21 +5198,13 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `134 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +Expected: Exit-Code 0 und `143 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. -- [ ] **Step 5: Bildschirmfotos ansehen** +- [ ] **Step 5: Bildschirmfoto der Vitrine ansehen** -Run: +Run: `godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-warm-vitrine.png" --style=warm --showcase` -```bash -godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-kontur-karte.png" --style=outline -godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-kontur-vitrine.png" --style=outline --showcase -``` - -Expected: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Nirgends steht mehr ein Platzhalter. - -- Spiel: Ockerfarbener Boden mit feinen Punkten. Fels ist hell lila, außen abgerundet, mit dunklem Umriss nur zum Boden hin, einem dunkleren Band an der Unterkante und kleinen hellen Strichen. Im getroffenen Fels steht ein gezackter dunkler Riss mit Splittern. Die Figuren haben einen dunklen Umriss, einen roten Körper und einen hellen Kopf. Über der linken steht scharf „Spieler“. -- Vitrine: Harter Fels kräftig blau, weicher Fels hell lila, Grundgestein dunkelbraun, alles mit dunklem Umriss. Toter Boden mit kleinen Rissen, lebendiger Boden kräftig grün mit hellen Häkchen. Moos mit gelben Funken, eine Ranke mit Blättern und Steinen, die Faserpflanze, ein Schaf und die Ruine als Tor mit gelber Sonne, jedes Ding mit dunklem Umriss. Zwei Figuren in Rot und Blau. Oben „Stilprobe: Kontur“, unten ist „2 Kontur“ ausgegraut. +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** @@ -5008,12 +5222,13 @@ Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 best ```bash git add game git commit -F - <<'EOF' -Stil Kontur +Pixelgrafik für die Dinge der Vitrine -Kräftige dunkle Umrisse um flache, starke Farben, gut lesbar wie ein -Comic. Felsmassen haben ihren Umriss nur zum offenen Boden hin, jedes -Ding hat einen Umriss um seine ganze Form, und Lebendiges funkelt gelb. -Jeder Spieler hat eine eigene Farbe. +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 EOF @@ -5022,36 +5237,67 @@ git push --- -### Task 7: Stil Geometrisch +### Task 7: Abwandlungen Pastell und Glimmen -Schlichte geometrische Formen in ruhigen Pastellfarben, ordentlich und still: Quadrate, Dreiecke, Kreise und Sechsecke, ohne Umriss und ohne Schatten. Muster folgen dem Raster, das Feld wirkt geordnet. +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/geometric_style.gd` (ganze Datei ersetzen) -- Test: `game/tests/test_geometric_style.gd` (neu) +- 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: `ArtStyle` mit seinen Zeichenmethoden und Helfern, `TileShape`, `Art.IDS`, die Einträge von `state` laut Kopf von `art_style.gd` -- Produces: `GeometricStyle` zeichnet jede Kennung selbst; `PLAYER_COLORS: Array[Color]` mit acht Farben und `player_color(player: int) -> Color` +- 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: Fehlschlagenden Test schreiben** +- [ ] **Step 1: Fehlschlagende Tests schreiben** -Create `game/tests/test_geometric_style.gd`: +Create `game/tests/test_pastel_style.gd`: ```gdscript extends TestCase func test_four_players_have_four_colours() -> void: - var style := GeometricStyle.new() + 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") ``` -- [ ] **Step 2: Test ausführen und Fehlschlag sehen** +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: @@ -5060,51 +5306,55 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 1 und `134 passed, 1 failed`; unter `FAIL test_geometric_style.gd test_four_players_have_four_colours` steht `four players, four colours: expected 4, got 2`. +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: Stil schreiben** +- [ ] **Step 3: Die beiden Abwandlungen schreiben** -Ersetze den gesamten Inhalt von `game/scripts/art/geometric_style.gd` durch: +Ersetze den gesamten Inhalt von `game/scripts/art/pastel_style.gd` durch: ```gdscript -class_name GeometricStyle -extends ArtStyle -## Plain geometric forms in calm pastel colours, tidy and quiet: squares, -## triangles, circles and hexagons, without outlines or shadows. Patterns -## follow the grid, so the field looks ordered. +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 := &"geometric" +const KEY := &"pastel" -const GROUND := Color("ead9b3") -const GROUND_DOT := Color("dcc699") -const SOFT_LIGHT := Color("c6d0e2") -const SOFT_DARK := Color("aebad0") -const HARD := Color("8d94c7") -const HARD_HEX := Color("a8aedc") -const BEDROCK := Color("4a4e69") -const LINE := Color("5b6181") -const DEAD_MARK := Color("cdb78c") -const LIVING := Color("b9e4c7") -const LIVING_DOT := Color("9bd4ae") -const MOSS_DARK := Color("4f9e6c") -const MOSS_LIGHT := Color("7cc493") -const VINE := Color("4c946a") -const LEAF := Color("6fbb88") -const STONE := Color("aab6cd") -const FIBRE := Color("fffaf0") -const SEED := Color("c49b74") -const BODY := Color("f6f2e9") -const FACE := Color("6d6875") -const RUIN := Color("bab5ca") -const RUIN_DARK := Color("5f5b6f") -const GLOW := Color("f6d77e") -const SKIN := Color("f2d4bf") -const MARKER_NEAR := Color("7fca97") -const MARKER_FAR := Color("ef8b7e") -const PLAYER_COLORS: Array[Color] = [ - Color("e9665f"), Color("4f8fcc"), Color("ef9f2f"), Color("4fa870"), - Color("9a7bd8"), Color("2fa9a3"), Color("e27aae"), Color("6f7fd6"), -] + +func _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: @@ -5112,199 +5362,62 @@ func key() -> StringName: func title_key() -> String: - return "STYLE_GEOMETRIC" + 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 player_color(player: int) -> Color: - return PLAYER_COLORS[maxi(0, player) % PLAYER_COLORS.size()] - - -func _ground(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_rect(rect, GROUND) - if _lattice(rect, 3): - canvas.draw_circle(rect.get_center(), 1.1, GROUND_DOT, true, -1.0, true) - - -func _soft_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_rect(rect, SOFT_LIGHT) - var tile := _tile(rect) - var corner := rect.position if (tile.x + tile.y) % 2 == 0 else Vector2(rect.end.x, rect.position.y) - var opposite := rect.end if (tile.x + tile.y) % 2 == 0 else Vector2(rect.position.x, rect.end.y) - canvas.draw_colored_polygon(PackedVector2Array([corner, opposite, Vector2(corner.x, opposite.y)]), SOFT_DARK) - - -func _hard_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_rect(rect, HARD) - canvas.draw_colored_polygon(regular_polygon(rect.get_center(), rect.size.x * 0.38, 6, PI / 6.0), HARD_HEX) - - -func _bedrock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_rect(rect, BEDROCK) - - -func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var hits: int = state.get("hits", 1) - var inner := rect.grow(-3.5) - canvas.draw_line(inner.position, inner.end, LINE, 1.0, true) - if hits >= 2: - canvas.draw_line(Vector2(inner.end.x, inner.position.y), Vector2(inner.position.x, inner.end.y), LINE, 1.0, true) - - -func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var age: float = state.get("age", 0.0) - var c := rect.get_center() - for chip in 4: - var direction := Vector2.from_angle(TAU * chip / 4.0 + PI / 4.0) - var at := c + direction * (2.0 + 8.0 * age) - var color := SOFT_DARK - color.a = 1.0 - age - canvas.draw_colored_polygon(regular_polygon(at, 1.8 * (1.0 - age * 0.5), 4, age * 4.0), color) - - -func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var facing: float = state.get("facing", 1.0) - var step: float = state.get("step", 0.0) - var moving: bool = state.get("moving", false) - var swing := sin(step) if moving else 0.0 - var c := rect.get_center() + Vector2(0, -absf(swing)) - for side in [-1.0, 1.0]: - var foot := c + Vector2(side * 1.8 + side * swing * 1.0, 5.2) - canvas.draw_rect(Rect2(foot - Vector2(1.0, 1.0), Vector2(2.0, 2.0)), FACE) - var body := PackedVector2Array([c + Vector2(0, -3.0), c + Vector2(4.3, 4.8), c + Vector2(-4.3, 4.8)]) - canvas.draw_colored_polygon(body, player_color(state.get("player", -1))) - var head := c + Vector2(0, -5.0) - canvas.draw_circle(head, 3.0, SKIN, true, -1.0, true) - canvas.draw_circle(head + Vector2(facing * 1.2, -0.2), 0.55, FACE, true, -1.0, true) - - -func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var color := MARKER_NEAR if state.get("reachable", false) else MARKER_FAR - var size := 4.0 - var corners: Array[Array] = [ - [rect.position, Vector2(1, 1)], - [Vector2(rect.end.x, rect.position.y), Vector2(-1, 1)], - [rect.end, Vector2(-1, -1)], - [Vector2(rect.position.x, rect.end.y), Vector2(1, -1)], +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"), ] - for corner in corners: - var at: Vector2 = corner[0] - var inward: Vector2 = corner[1] - canvas.draw_colored_polygon(PackedVector2Array([at, at + Vector2(inward.x * size, 0), at + Vector2(0, inward.y * size)]), color) -func _dead_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_rect(rect, GROUND) - if _lattice(rect, 4): - var c := rect.get_center() - canvas.draw_line(c - Vector2(1.6, 1.6), c + Vector2(1.6, 1.6), DEAD_MARK, 0.8, true) - canvas.draw_line(c + Vector2(1.6, -1.6), c + Vector2(-1.6, 1.6), DEAD_MARK, 0.8, true) +func key() -> StringName: + return KEY -func _living_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_rect(rect, LIVING) - if _lattice(rect, 2): - canvas.draw_circle(rect.get_center(), 1.2, LIVING_DOT, true, -1.0, true) - - -func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var variant: int = state.get("variant", 0) - var time: float = state.get("time", 0.0) - var foot := Vector2(rect.get_center().x, rect.end.y - 4.0) - var breathe := 1.0 + 0.08 * sway(time, variant, 1.6) - canvas.draw_circle(foot + Vector2(-3.0, 0.0), 2.6 * breathe, MOSS_DARK, true, -1.0, true) - canvas.draw_circle(foot + Vector2(2.6, 0.4), 2.2 * breathe, MOSS_DARK, true, -1.0, true) - canvas.draw_circle(foot + Vector2(0.0, -2.6), 3.0 * breathe, MOSS_LIGHT, true, -1.0, true) - - -func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void: - var points: PackedVector2Array = state.get("points", PackedVector2Array()) - var time: float = state.get("time", 0.0) - if points.size() < 2: - return - canvas.draw_polyline(points, VINE, 1.0, true) - var beads := 24 - for i in beads + 1: - var part := float(i) / beads - var at := along(points, part) - if i % 3 == 1: - var ahead := along(points, minf(1.0, part + 0.02)) - var side := 1.0 if i % 2 == 0 else -1.0 - var angle := (ahead - at).angle() + side * (PI / 2.0) + 0.2 * sway(time, i, 1.4) - var tip := at + Vector2.from_angle(angle) * 4.0 - var base := Vector2.from_angle(angle).orthogonal() * 1.3 - canvas.draw_colored_polygon(PackedVector2Array([at + base, tip, at - base]), LEAF) - canvas.draw_circle(at, 1.4, VINE, true, -1.0, true) - - -func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var growth: float = state.get("growth", 1.0) - var time: float = state.get("time", 0.0) - var foot := Vector2(rect.get_center().x, rect.end.y - 2.0) - if growth < 0.15: - canvas.draw_circle(foot + Vector2(0, -1.5), 1.8, SEED, true, -1.0, true) - return - var grown := (growth - 0.15) / 0.85 - var height := lerpf(4.0, 20.0, grown) - canvas.draw_set_transform(foot, 0.06 * sway(time, 3, 1.1), Vector2.ONE) - canvas.draw_line(Vector2.ZERO, Vector2(0, -height), VINE, 1.2, true) - var leaves := 1 + int(grown * 5.0) - for i in leaves: - var at := Vector2(0, -height * (0.2 + 0.7 * i / maxf(1.0, leaves))) - var side := 1.0 if i % 2 == 0 else -1.0 - var size := lerpf(2.5, 4.5, grown) - canvas.draw_colored_polygon(PackedVector2Array([at, at + Vector2(side * size, -size * 0.6), at + Vector2(0, -size * 0.9)]), LEAF) - if grown > 0.75: - var tuft := (grown - 0.75) / 0.25 - for puff in 3: - canvas.draw_circle(Vector2(-1.6 + puff * 1.6, -height - 1.0), 1.3 * tuft + 0.2, FIBRE, true, -1.0, true) - canvas.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE) - - -func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void: - canvas.draw_colored_polygon(regular_polygon(rect.get_center(), 2.8, 4, PI / 4.0), STONE) - - -func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var hop: float = state.get("hop", 1.0) - var facing: float = state.get("facing", 1.0) - var lift := sin(PI * hop) * 4.0 - var foot := Vector2(rect.get_center().x, rect.end.y - 2.0) - var body := foot + Vector2(0, -5.5 - lift) - for leg in [-2.0, 2.0]: - canvas.draw_line(body + Vector2(leg, 2.5), body + Vector2(leg, 5.5 - lift * 0.3), FACE, 1.2, true) - canvas.draw_circle(body, 4.4, BODY, true, -1.0, true) - canvas.draw_circle(body + Vector2(facing * 4.6, -1.6), 2.2, FACE, true, -1.0, true) - - -func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void: - var awake: float = state.get("awake", 0.0) - var tile := rect.size.x / 3.0 - var floor_y := rect.end.y - 3.0 - var center := Vector2(rect.get_center().x, floor_y - tile * 1.2) - canvas.draw_rect(Rect2(rect.position.x + tile * 0.75, rect.position.y + tile * 0.9, tile * 1.5, floor_y - rect.position.y - tile * 0.9), RUIN_DARK) - for ring in 3: - var glow := GLOW - glow.a = awake * (0.35 + 0.3 * ring) - canvas.draw_circle(center, 8.5 - ring * 2.4, glow, true, -1.0, true) - canvas.draw_rect(Rect2(rect.position.x, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6), RUIN) - canvas.draw_rect(Rect2(rect.end.x - tile * 0.75, rect.position.y + tile * 0.6, tile * 0.75, floor_y - rect.position.y - tile * 0.6), RUIN) - canvas.draw_rect(Rect2(rect.position.x - 1.0, rect.position.y + tile * 0.25, rect.size.x + 2.0, tile * 0.6), RUIN) - canvas.draw_colored_polygon(PackedVector2Array([ - Vector2(rect.position.x - 1.0, rect.position.y + tile * 0.25), Vector2(rect.get_center().x, rect.position.y - tile * 0.3), - Vector2(rect.end.x + 1.0, rect.position.y + tile * 0.25), - ]), RUIN) - - -## The tile that `rect` covers. -static func _tile(rect: Rect2) -> Vector2i: - return Vector2i(rect.position / GridMotion.TILE_SIZE) - - -## True for every `every`th tile along both diagonals, which makes an even, -## open pattern across the field. -static func _lattice(rect: Rect2, every: int) -> bool: - var tile := _tile(rect) - return posmod(tile.x + tile.y * 2, every) == 0 and posmod(tile.x - tile.y, 2) == 0 +func title_key() -> String: + return "STYLE_GLOW" ``` - [ ] **Step 4: Tests ausführen und Erfolg sehen** @@ -5316,21 +5429,23 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: Exit-Code 0 und `135 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. +Expected: Exit-Code 0 und `147 passed, 0 failed`. Die einzigen Warnungen sind die drei bekannten Zeilen. - [ ] **Step 5: Bildschirmfotos ansehen** Run: ```bash -godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-geometrisch-karte.png" --style=geometric -godot --path game -s res://tools/screenshot.gd -- "$TEMP/m1d-geometrisch-vitrine.png" --style=geometric --showcase +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: Beide Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir beide Bilder an. Nirgends steht mehr ein Platzhalter. +Expected: Alle vier Aufrufe enden mit `screenshot saved: …` ohne Warnung. Sieh dir die Bilder an. Nirgends steht ein Platzhalter. -- Spiel: Heller Sandboden mit kleinen Rauten in einem regelmäßigen Raster. Weicher Fels besteht aus hellblauen und graublauen Dreiecken, ohne Umriss und ohne Schatten. Im getroffenen Fels steht ein dünner dunkler Strich als Riss. Die Figuren sind rote Dreiecke mit rundem Kopf. Über der linken steht scharf „Spieler“. -- Vitrine: Grundgestein dunkel, weicher Fels aus Dreiecken, harter Fels lila mit hellen Sechsecken. Toter Boden mit kleinen Kreuzen im Raster, lebendiger Boden mintgrün mit Punkten. Moos aus Kreisen, die Ranke als Perlenschnur mit eckigen Steinen, die Faserpflanze, ein Schaf, die Ruine als Haus mit Giebel und goldenem Leuchten. Zwei Figuren als rotes und blaues Dreieck. Oben „Stilprobe: Geometrisch“, unten ist „3 Geometrisch“ ausgegraut. +- 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** @@ -5348,12 +5463,12 @@ Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 best ```bash git add game git commit -F - <<'EOF' -Stil Geometrisch +Abwandlungen Pastell und Glimmen -Schlichte Formen in ruhigen Pastellfarben, ohne Umriss und ohne -Schatten: Dreiecke für weichen Fels, Sechsecke für harten, Kreise für -Moos und eine Perlenschnur als Ranke. Muster folgen dem Raster, und -jeder Spieler hat eine eigene Farbe. +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 EOF @@ -5367,7 +5482,7 @@ git push **Files:** - Modify: `CLAUDE.md` (ganze Datei ersetzen) - Create: `docs/playtests/m1d.md` -- Create: `docs/playtests/m1d/scherenschnitt-karte.png`, `scherenschnitt-vitrine.png`, `kontur-karte.png`, `kontur-vitrine.png`, `geometrisch-karte.png`, `geometrisch-vitrine.png` (vom Bildschirmfoto-Werkzeug erzeugt) +- 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 @@ -5442,11 +5557,11 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections ## Grafik - Alles auf dem Spielfeld zeichnet `Art` (`game/scripts/art/art.gd`), jedes Ding unter einer Kennung wie `terrain/soft_rock`. Andere Skripte zeichnen keine eigenen Formen oder Farben aufs Spielfeld; ausgenommen sind Texte wie die Namen über den Figuren. -- Ein Stil ist eine Unterklasse von `ArtStyle` und zeichnet jede Kennung aus Code: flach, klar, Bewegung aus Programmierung (Konzept 7.4). Zur Wahl stehen Scherenschnitt, Kontur und Geometrisch. F2 wechselt im Spiel, „Stilproben ansehen“ im Menü öffnet die Vitrine, und das Spiel merkt sich die Wahl. -- Eine neue Kennung braucht eine Zeichnung in jedem Stil; sonst zeichnet `ArtStyle` nur einen Platzhalter. `game/tests/test_art.gd` lässt jeden Stil jede Kennung zeichnen. -- Ein Bild `game/art/.png` ersetzt die Zeichnung seiner Kennung, ohne dass sich Code ändert. So kann ein Artist Grafik austauschen. -- Die Karte zeichnet in Blöcken von 16 × 16 Kacheln (`MapView`); nach einem Treffer werden nur die Blöcke um die Kachel neu gezeichnet. Jede Kachel kennt ihre Nachbarn (`TileShape`), damit Felsmassen außen rund und innen geschlossen sind. -- Der Renderer der Hülle kennt kein MSAA in 2D. Glatte Kanten entstehen über die Zeichenbefehle mit `antialiased`, etwa bei Kreisen und Linien. +- 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/.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 @@ -5488,7 +5603,7 @@ Argumente hinter `play.bat` gehen an das Spiel: | `--solo`, `--host` | Startet ohne Menü allein beziehungsweise mit offener Welt | | `--port=PORT` | Port einer offenen Welt | | `--join=ADRESSE` | Tritt ohne Menü einem Host bei; die Adresse darf auf `:PORT` enden | -| `--style=NAME` | Zeichnet im Stil `papercut`, `outline` oder `geometric` statt im zuletzt gewählten | +| `--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`. @@ -5513,18 +5628,18 @@ Der Pfad muss absolut sein, denn Godot legt relative Pfade unter `game/` an. `pw ```bash mkdir -p docs/playtests/m1d OUT="$(pwd -W)/docs/playtests/m1d" -for pair in papercut:scherenschnitt outline:kontur geometric:geometrisch; do +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 `geometrisch-karte.png`, `geometrisch-vitrine.png`, `kontur-karte.png`, `kontur-vitrine.png`, `scherenschnitt-karte.png` und `scherenschnitt-vitrine.png`, jede zwischen 40 und 120 KB groß. Die genauen Größen schwanken von Lauf zu Lauf um einige Bytes. +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 in Step 5 der Tasks 5, 6 und 7 passen: das Spiel mit zwei Figuren und dem Namen „Spieler“, die Vitrine mit allen neun Beschriftungen und dem Titel ihres Stils. +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** @@ -5533,7 +5648,7 @@ Create `docs/playtests/m1d.md`: ```markdown # Probelauf M1d: Stilproben -M1d zeigt das Spielfeld in drei Stilen, alle aus Code gezeichnet: Scherenschnitt, Kontur und Geometrisch. Du wählst einen davon für das ganze Spiel. Das ist die Prüffrage von Meilenstein 1: „Welche der drei Stilproben soll es sein?“ Die Vitrine zeigt dazu Dinge aus späteren Meilensteinen, die es zum Spielen noch nicht gibt. +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 @@ -5553,21 +5668,21 @@ M1d zeigt das Spielfeld in drei Stilen, alle aus Code gezeichnet: Scherenschnitt So sehen die drei Proben aus: -| Scherenschnitt | Kontur | Geometrisch | +| Warm | Pastell | Glimmen | |---|---|---| -| ![Scherenschnitt, Vitrine](m1d/scherenschnitt-vitrine.png) | ![Kontur, Vitrine](m1d/kontur-vitrine.png) | ![Geometrisch, Vitrine](m1d/geometrisch-vitrine.png) | -| ![Scherenschnitt, Spiel](m1d/scherenschnitt-karte.png) | ![Kontur, Spiel](m1d/kontur-karte.png) | ![Geometrisch, Spiel](m1d/geometrisch-karte.png) | +| ![Warm, Vitrine](m1d/warm-vitrine.png) | ![Pastell, Vitrine](m1d/pastell-vitrine.png) | ![Glimmen, Vitrine](m1d/glimmen-vitrine.png) | +| ![Warm, Spiel](m1d/warm-karte.png) | ![Pastell, Spiel](m1d/pastell-karte.png) | ![Glimmen, Spiel](m1d/glimmen-karte.png) | ## Fragen -1. Welcher Stil soll es sein: Scherenschnitt, Kontur oder Geometrisch? +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? +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: Wiegen, Wachsen, Hüpfen und die Schritte der Figur? Oder ist es zu viel oder zu wenig? -7. Findest du deine Figur sofort, auch neben anderen? -8. Wirkt ein Schlag als Gast jetzt sofort? Stört die Verzögerung noch? +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. ``` @@ -5583,7 +5698,7 @@ godot --headless --path game --import godot --headless --path game -s res://tests/run_tests.gd ``` -Expected: keine Ausgabe von rustfmt, keine Warnung von Clippy, zusammen 151 bestandene Rust-Tests; der GDScript-Lauf endet mit Exit-Code 0 und `135 passed, 0 failed` +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** @@ -5592,11 +5707,12 @@ git add CLAUDE.md docs/playtests/m1d.md docs/playtests/m1d git commit -F - <<'EOF' Regeln, Fragebogen und Bilder für den Probelauf M1d -CLAUDE.md beschreibt, wie Grafik entsteht: Kennungen, Stile, Bilder, -die Zeichnungen ersetzen, Blöcke und Kachelformen. Das -Bildschirmfoto-Werkzeug und --style=NAME stehen bei ihren Befehlen. -Eine stille Verbindung gilt nach etwa NetLink.TIMEOUT_MSEC als verloren. -Ein Fragebogen mit Bildern der drei Stile begleitet den Probelauf. +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 EOF