M1d: Stilproben (#12)

Pixelgrafik aus Code im Stil klassischer Farmspiele, aber eigenständig,
in drei Abwandlungen: Warm, Pastell und Glimmen. Eine Vitrine zeigt jeden
Stil mit Dingen späterer Meilensteine, F2 wechselt den Stil im Spiel, und
das Spiel merkt sich die Wahl. Der eigene Schlag zeigt seine Splitter
sofort, auch als Gast, und die Namen über den Figuren bleiben scharf.
151 Rust-Tests (unverändert) und 154 GDScript-Tests; jede Task und der
ganze Branch geprüft.

Closes #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #12.
This commit is contained in:
2026-10-02 13:01:52 +00:00
67 changed files with 8534 additions and 100 deletions
+13 -3
View File
@@ -51,12 +51,21 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt.
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()` (Ordner unter `user://tests`), nie die echten Dateien des Spielers.
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig, aber geordnet über Kanal 1; ENet drosselt sie nie. Eine Verbindung, deren Pakete unbeantwortet bleiben, gilt nach höchstens `NetLink.TIMEOUT_MSEC` als verloren. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig, aber geordnet über Kanal 1; ENet drosselt sie nie. Eine Verbindung, deren Pakete unbeantwortet bleiben, gilt nach etwa `NetLink.TIMEOUT_MSEC` als verloren; ENet prüft die Grenze nur, wenn eine Wiederholung fällig ist. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
- Nur der Host speichert. Laden mit F9 geht nur ohne geöffnete Welt („Allein spielen“).
- Eine Pause beginnt nur ohne Gäste und lässt sich immer beenden. Nimmt der Host einen Gast an, endet sie; eine bloße Verbindung ohne Gruß beendet sie nicht.
- Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück. Eine angehaltene Sitzung hinterlässt keine Aufzeichnung.
- Tests mit Netzwerk lauschen nur auf `127.0.0.1` und lassen das System den Port wählen (Port 0), damit sie nie an einem belegten Port scheitern.
## Grafik
- Alles auf dem Spielfeld zeichnet `Art` (`game/scripts/art/art.gd`), jedes Ding unter einer Kennung wie `terrain/soft_rock`. Andere Skripte zeichnen keine eigenen Formen oder Farben aufs Spielfeld; ausgenommen sind Texte wie die Namen über den Figuren.
- Die Grafik ist Pixelgrafik aus Code (Konzept 7.4): 16 × 16 Pixel pro Kachel, schräg von vorn gesehen. `PixelStyle` (`game/scripts/art/pixel_style.gd`) zeichnet jede Kennung Pixel für Pixel in ein kleines Bild und hebt es auf. Die Stile `WarmStyle`, `PastelStyle` und `GlowStyle` wählen nur Palette, Umrisse und Leuchten; Warm ist der Standard. F2 wechselt im Spiel, „Stilproben ansehen“ im Menü öffnet die Vitrine, und das Spiel merkt sich die Wahl.
- Eine neue Kennung bekommt eine Konstante in `Art` und einen Eintrag in `Art.IDS`, einen Zweig mit Platzhalter in `ArtStyle.draw`, eine Zeichnung in `PixelStyle` und ihre Farben in jeder Palette. Fehlt nur die Zeichnung, zeichnet `ArtStyle` den Platzhalter; fehlt eine Farbe, bricht die Zeichnung mit einem Fehler ab. Nur Kennungen in `Art.IDS` lassen sich durch Bilder ersetzen, und `game/tests/test_art.gd` lässt jeden Stil jede dieser Kennungen zeichnen.
- Damit die Pixel scharf bleiben, stellt `project.godot` die Texturfilterung auf „Nearest“ und rastet 2D-Transformationen auf ganze Bildschirmpixel ein. Die Kamera vergrößert ganzzahlig (`CAMERA_ZOOM` in `main.gd`), und die Stile setzen ihre Bilder auf ganze Pixel.
- Ein Bild `game/art/<Kennung>.png` ersetzt die Zeichnung seiner Kennung, ohne dass sich Code ändert. So kann ein Artist Grafik austauschen. Ein solches Bild ist ein einzelnes festes Bild; Kachelränder und Bewegung zeichnet bisher nur der Code.
- Die Karte zeichnet in Blöcken von 16 × 16 Kacheln (`MapView`); nach einem Treffer werden nur die Blöcke um die Kachel neu gezeichnet. Jede Kachel kennt ihre Nachbarn (`TileShape`), damit Felsmassen außen rund und innen geschlossen sind und zum offenen Boden hin ihre Vorderkante zeigen.
## Prüfen
Vor jedem Commit müssen alle Befehle fehlerfrei laufen:
@@ -76,10 +85,10 @@ Der Test `game/tests/test_two_instances.gd` startet einen zweiten Godot-Prozess
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände, Aufzeichnungen oder Nachrichten betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt (`SNAPSHOT_VERSION`, `RECORDING_VERSION` oder `PROTOCOL_VERSION`). Erst danach passt du den festgeschriebenen Wert bewusst an.
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert:
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert. `--style=NAME` wählt einen Stil, `--showcase` zeigt die Vitrine statt des Spiels:
```bash
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei>
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei> [--style=NAME] [--showcase]
```
## Spielen
@@ -97,6 +106,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 `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`.
+2 -2
View File
@@ -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.
+40
View File
@@ -0,0 +1,40 @@
# Probelauf M1d: Stilproben
M1d zeichnet das Spielfeld als Pixelgrafik aus Code, im Stil klassischer Farmspiele, aber eigenständig. Es gibt drei Abwandlungen: Warm, Pastell und Glimmen. Nach der ersten Probe war Warm dein Favorit; jetzt siehst du die Stile im Spiel und in Bewegung. Das beantwortet die Prüffrage von Meilenstein 1: „Welche der drei Stilproben soll es sein?“ Die Vitrine zeigt dazu Dinge aus späteren Meilensteinen, die es zum Spielen noch nicht gibt.
## So siehst du die Proben
1. Doppelklick auf `play.bat`.
2. Im Menü auf „Stilproben ansehen“. Die Vitrine zeigt
- weichen und harten Fels und Grundgestein,
- toten und lebendigen Boden,
- Lebensmoos,
- eine Ranke, die Steine trägt,
- eine wachsende Faserpflanze,
- ein hüpfendes Weidetier,
- eine erwachende Ruine,
- zwei Spielfiguren.
Mit 1, 2 und 3 oder den Knöpfen unten wechselst du den Stil, mit Esc oder „Zurück“ geht es ins Menü.
3. Im Spiel wechselt F2 den Stil. Lauf herum, schlag Stein und sieh dir Fels, Figur, Zielmarke und Splitter an. Das Spiel merkt sich den zuletzt gewählten Stil.
4. Zu zweit wie in M1c: Die Namen über den Figuren sind jetzt scharf, und dein eigener Schlag zeigt seine Splitter sofort, auch als Gast. Risse und das Brechen des Felses kommen weiter erst mit der Antwort des Hosts. Splitter fliegen bei jedem Schlag auf Fels, auch auf harten Fels, den erst eine Kupferspitzhacke bricht, und auf Grundgestein, das nie bricht.
So sehen die drei Proben aus:
| Warm | Pastell | Glimmen |
|---|---|---|
| ![Warm, Vitrine](m1d/warm-vitrine.png) | ![Pastell, Vitrine](m1d/pastell-vitrine.png) | ![Glimmen, Vitrine](m1d/glimmen-vitrine.png) |
| ![Warm, Spiel](m1d/warm-karte.png) | ![Pastell, Spiel](m1d/pastell-karte.png) | ![Glimmen, Spiel](m1d/glimmen-karte.png) |
## Fragen
1. Bleibt es bei Warm, oder gefällt dir im Spiel ein anderer Stil besser?
2. Was gefällt dir an deinem Favoriten am besten, und was stört dich daran?
3. Gibt es an den anderen beiden etwas, das du übernehmen möchtest, etwa das Leuchten des Lebendigen aus Glimmen?
4. Erkennst du auf einen Blick, was Boden, weicher Fels, harter Fels und Grundgestein ist? Und was lebendiger Boden ist?
5. Wirkt das Lebendige lebendig: das Moos, die Ranke, die wachsende Pflanze, das Tier, die erwachende Ruine? Oder wirkt es nüchtern?
6. Ist die Bewegung angenehm: Laufen, Wiegen, Wachsen und Hüpfen? Oder ist es zu viel oder zu wenig?
7. Findest du deine Figur sofort, auch neben anderen? Sind Figuren und Dinge groß genug?
8. Wirkt ein Schlag als Gast jetzt sofort, auch wenn Risse und Brechen erst mit der Antwort des Hosts kommen? Und sind die Namen über den Figuren scharf?
Antworte formlos im Chat oder als Kommentar in Gitea.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -7,7 +7,7 @@ LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
PAUSED,Pause,Paused
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F2: Stil wechseln · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F2: change the style · F5: save · F9: load · Esc: pause
DEFAULT_NAME,Spieler,Player
MENU_NAME,Dein Name,Your name
MENU_SOLO,Allein spielen,Play alone
@@ -35,3 +35,19 @@ 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_WARM,Warm,Warm
STYLE_PASTEL,Pastell,Pastel
STYLE_GLOW,Glimmen,Glow
STYLE_SWITCHED,Stil: %s,Style: %s
MENU_SHOWCASE,Stilproben ansehen,See the style samples
SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s
SHOWCASE_BACK,Zurück,Back
SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock
SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil
SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil
SHOWCASE_MOSS,Lebensmoos,Life moss
SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows
SHOWCASE_GRAZER,Weidetier,Grazer
SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones
SHOWCASE_RUIN,Ruine erwacht,A ruin wakes
SHOWCASE_FIGURES,Spielfiguren,Player figures
1 keys de en
7 INVENTORY_FULL Die Tasche ist voll Your bag is full
8 PAUSED Pause Paused
9 SIMULATION_STOPPED Die Simulation wurde angehalten The simulation stopped
10 HELP WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause WASD: laufen · Linke Maustaste: Stein schlagen · F2: Stil wechseln · F5: speichern · F9: laden · Esc: Pause WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause WASD: walk · Left mouse button: break rock · F2: change the style · F5: save · F9: load · Esc: pause
11 DEFAULT_NAME Spieler Player
12 MENU_NAME Dein Name Your name
13 MENU_SOLO Allein spielen Play alone
35 HOST_SAVES Nur der Host speichert Only the host saves
36 KEY_UNREADABLE Dein Spielerschlüssel lässt sich gerade nicht lesen Your player key cannot be read right now
37 SAVE_UNREADABLE Der Spielstand lässt sich gerade nicht lesen The save cannot be read right now
38 STYLE_WARM Warm Warm
39 STYLE_PASTEL Pastell Pastel
40 STYLE_GLOW Glimmen Glow
41 STYLE_SWITCHED Stil: %s Style: %s
42 MENU_SHOWCASE Stilproben ansehen See the style samples
43 SHOWCASE_TITLE Stilprobe: %s Style sample: %s
44 SHOWCASE_BACK Zurück Back
45 SHOWCASE_ROCK Weicher und harter Fels Soft and hard rock
46 SHOWCASE_DEAD_SOIL Toter Boden Dead soil
47 SHOWCASE_LIVING_SOIL Lebendiger Boden Living soil
48 SHOWCASE_MOSS Lebensmoos Life moss
49 SHOWCASE_PLANT Faserpflanze wächst A fibre plant grows
50 SHOWCASE_GRAZER Weidetier Grazer
51 SHOWCASE_VINE Ranke trägt Steine A vine carries stones
52 SHOWCASE_RUIN Ruine erwacht A ruin wakes
53 SHOWCASE_FIGURES Spielfiguren Player figures
+2
View File
@@ -19,3 +19,5 @@ window/size/viewport_height=720
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
+105
View File
@@ -0,0 +1,105 @@
class_name Art
extends RefCounted
## Every graphic on the playing field has an id. Art draws it in the chosen
## style, or from a picture whose file name is the id, so that an artist can
## replace a graphic without changing code: res://art/terrain/ground.png
## replaces the drawing of GROUND.
## The style changed, or pictures were loaded. Everything drawn by Art has to
## be drawn again.
signal changed
const GROUND := &"terrain/ground"
const SOFT_ROCK := &"terrain/soft_rock"
const HARD_ROCK := &"terrain/hard_rock"
const BEDROCK := &"terrain/bedrock"
## The cracks of a tile that took hits, drawn over the tile.
const CRACKS := &"terrain/cracks"
## The burst where a pickaxe lands.
const HIT := &"effect/hit"
const FIGURE := &"figure"
## The outline of the tile under the mouse.
const MARKER := &"marker"
## Graphics of later milestones, shown in the showcase of the style samples.
const DEAD_SOIL := &"soil/dead"
const LIVING_SOIL := &"soil/living"
const MOSS := &"plant/moss"
const VINE := &"plant/vine"
const FIBER_PLANT := &"plant/fiber"
const STONE := &"item/stone"
const GRAZER := &"animal/grazer"
const RUIN := &"ruin"
## Every id, in the order the showcase and the checks go through them.
const IDS: Array[StringName] = [
GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, CRACKS, HIT, FIGURE, MARKER,
DEAD_SOIL, LIVING_SOIL, MOSS, VINE, FIBER_PLANT, STONE, GRAZER, RUIN,
]
## The folder where pictures that replace drawings are looked for.
const OVERRIDE_DIR := "res://art"
## The styles to choose from, in the order the game offers them.
var styles: Array[ArtStyle] = [WarmStyle.new(), PastelStyle.new(), GlowStyle.new()]
## The style everything is drawn in.
var style: ArtStyle = styles[0]
var _overrides: Dictionary[StringName, Texture2D] = {}
## The keys of the styles, as LaunchOptions and the settings name them.
static func style_keys() -> Array[StringName]:
return [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY]
## Draws everything in the style named `key` from now on. Returns false, and
## changes nothing, if no style has that key.
func select(key: StringName) -> bool:
for candidate in styles:
if candidate.key() == key:
if candidate != style:
style = candidate
changed.emit()
return true
return false
## Switches to the style after the current one, and from the last to the
## first.
func select_next() -> void:
style = styles[(styles.find(style) + 1) % styles.size()]
changed.emit()
## Uses the picture `<directory>/<id>.png` instead of the drawing, for every
## id that has one. Pictures loaded earlier are dropped.
func load_overrides(directory: String) -> void:
_overrides.clear()
for id in IDS:
var texture := _load_texture(directory.path_join(String(id) + ".png"))
if texture != null:
_overrides[id] = texture
changed.emit()
## True if a picture replaces the drawing of `id`.
func has_override(id: StringName) -> bool:
return _overrides.has(id)
## Draws the graphic `id` into `rect` on `canvas`. Call it only while
## `canvas` draws, from its _draw(). `state` carries what the graphic shows,
## for example how far a plant has grown; see the styles for the entries.
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary = {}) -> void:
var texture: Texture2D = _overrides.get(id)
if texture != null:
canvas.draw_texture_rect(texture, rect, false)
else:
style.draw(canvas, id, rect, state)
static func _load_texture(path: String) -> Texture2D:
if path.begins_with("res://"):
return load(path) as Texture2D if ResourceLoader.exists(path) else null
if not FileAccess.file_exists(path):
return null
var image := Image.load_from_file(path)
return ImageTexture.create_from_image(image) if image != null else null
+1
View File
@@ -0,0 +1 @@
uid://dxmii13634y22
+192
View File
@@ -0,0 +1,192 @@
class_name ArtStyle
extends RefCounted
## One look of the playing field. Draws every graphic that Art names, with
## code. This base class draws plain placeholders; each style overrides the
## drawing of every id.
##
## The entries of `state`, by id:
## - GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, DEAD_SOIL and LIVING_SOIL:
## `solid`, a TileShape mask of the neighbours that are rock or the edge of
## the map; `living`, for LIVING_SOIL, the mask of living neighbours;
## `variant`, from TileShape.variant
## - CRACKS: `hits`, 1 or more; `variant`
## - HIT: `age`, from 0 when the pickaxe lands to 1 when the burst is over;
## `variant`
## - FIGURE: `player`, the player's number or -1; `facing`, 1.0 to the
## right or -1.0 to the left; `step`, an angle that grows while the figure
## walks; `moving`
## - MARKER: `reachable`
## - MOSS: `variant`; `time`, in seconds
## - VINE: `points`, the path of the vine; `time`
## - FIBER_PLANT: `growth`, from 0 (a seed) to 1 (grown); `time`
## - GRAZER: `hop`, from 0 to 1 during one hop; `facing`
## - RUIN: `awake`, from 0 (asleep) to 1 (awake); `time`
## - STONE: nothing
## The name of the style on the command line and in the settings. The base
## class is no style to choose and has none.
func key() -> StringName:
return &""
## The key of the style's name in the texts, or an empty text for the base
## class.
func title_key() -> String:
return ""
## Draws the graphic `id` into `rect` on `canvas`, while `canvas` draws.
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary) -> void:
match id:
Art.GROUND:
_ground(canvas, rect, state)
Art.SOFT_ROCK:
_soft_rock(canvas, rect, state)
Art.HARD_ROCK:
_hard_rock(canvas, rect, state)
Art.BEDROCK:
_bedrock(canvas, rect, state)
Art.CRACKS:
_cracks(canvas, rect, state)
Art.HIT:
_hit(canvas, rect, state)
Art.FIGURE:
_figure(canvas, rect, state)
Art.MARKER:
_marker(canvas, rect, state)
Art.DEAD_SOIL:
_dead_soil(canvas, rect, state)
Art.LIVING_SOIL:
_living_soil(canvas, rect, state)
Art.MOSS:
_moss(canvas, rect, state)
Art.VINE:
_vine(canvas, rect, state)
Art.FIBER_PLANT:
_fiber_plant(canvas, rect, state)
Art.STONE:
_stone(canvas, rect, state)
Art.GRAZER:
_grazer(canvas, rect, state)
Art.RUIN:
_ruin(canvas, rect, state)
_:
push_error("no drawing for the graphic %s" % id)
## The colour of the figure of `player`. Players keep their colour; -1, a
## player not in the world yet, gets the first.
func player_color(player: int) -> Color:
return Color(0.95, 0.85, 0.6) if player <= 0 else Color(0.55, 0.8, 0.95)
func _ground(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.42, 0.36, 0.28))
func _soft_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.56, 0.55, 0.52))
func _hard_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.33, 0.33, 0.37))
func _bedrock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.12, 0.12, 0.14))
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hits: int = state.get("hits", 1)
var inset := rect.grow(-3.0)
for line in hits:
var part := (line + 1.0) / (hits + 1.0)
var from := Vector2(inset.position.x, inset.position.y + inset.size.y * part)
var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - part))
canvas.draw_line(from, to, Color(0.1, 0.08, 0.06, 0.8), 1.0)
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var age: float = state.get("age", 0.0)
for chip in 4:
var direction := Vector2.RIGHT.rotated(TAU * chip / 4.0 + 0.4)
var at := rect.get_center() + direction * rect.size.x * 0.6 * age
canvas.draw_rect(Rect2(at - Vector2(1, 1), Vector2(2, 2)), Color(0.8, 0.8, 0.75, 1.0 - age))
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var radius := rect.size.x * 0.3
canvas.draw_circle(rect.get_center(), radius + 1.0, Color(0.15, 0.1, 0.05))
canvas.draw_circle(rect.get_center(), radius, player_color(state.get("player", -1)))
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var color := Color(0.4, 1.0, 0.4) if state.get("reachable", false) else Color(1.0, 0.4, 0.4)
canvas.draw_rect(rect, color, false, 1.0)
func _dead_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.45, 0.42, 0.38))
func _living_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.35, 0.5, 0.25))
func _moss(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_circle(rect.get_center(), rect.size.x * 0.3, Color(0.3, 0.65, 0.3))
func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
if points.size() >= 2:
canvas.draw_polyline(points, Color(0.25, 0.6, 0.25), 2.0)
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var growth: float = state.get("growth", 1.0)
var foot := Vector2(rect.get_center().x, rect.end.y)
canvas.draw_line(foot, foot - Vector2(0, rect.size.y * growth), Color(0.4, 0.7, 0.3), 2.0)
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_circle(rect.get_center(), rect.size.x * 0.4, Color(0.6, 0.6, 0.58))
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var lift := sin(PI * float(state.get("hop", 0.0))) * rect.size.y * 0.3
canvas.draw_circle(rect.get_center() - Vector2(0, lift), rect.size.x * 0.35, Color(0.92, 0.9, 0.85))
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.5, 0.5, 0.48))
var awake: float = state.get("awake", 0.0)
canvas.draw_circle(rect.get_center(), rect.size.x * 0.2, Color(1.0, 0.85, 0.3, awake))
# Helpers for the styles.
## A share from 0 to 1 that is the same for the same `variant` and `salt`.
static func share(variant: int, salt: int) -> float:
return float(TileShape.variant(Vector2i(variant & 0xffff, salt)) % 10007) / 10007.0
## A gentle swing from -1 to 1, slow and different for every `variant`.
static func sway(time: float, variant: int, speed := 1.3) -> float:
return sin(time * speed + share(variant, 7) * TAU)
## The point at `part` of the way along `points`, from 0 to 1.
static func along(points: PackedVector2Array, part: float) -> Vector2:
var total := 0.0
for i in points.size() - 1:
total += points[i].distance_to(points[i + 1])
var left := clampf(part, 0.0, 1.0) * total
for i in points.size() - 1:
var length := points[i].distance_to(points[i + 1])
if left <= length:
return points[i].lerp(points[i + 1], left / length if length > 0.0 else 0.0)
left -= length
return points[points.size() - 1]
+1
View File
@@ -0,0 +1 @@
uid://o2dqirvunvw4
+50
View File
@@ -0,0 +1,50 @@
class_name GlowStyle
extends PixelStyle
## A dim, cool world in grey and slate, in which everything living shines in
## strong colours and glows: the dead and the living apart at a glance.
const KEY := &"glow"
func _init() -> void:
glow = 1.0
palette = {
"outline": Color("15171f"), "dust": Color("b9b5af"),
"ground": Color("6f6a68"), "ground_dark": Color("5f5a59"), "ground_light": Color("7f7a77"),
"pebble": Color("57534f"), "pebble_light": Color("8a8580"), "pebble_dark": Color("45413e"),
"dead_crack": Color("4c4846"),
"soft_top": Color("8d8a87"), "soft_top_dark": Color("7c7976"), "soft_top_light": Color("a3a09c"),
"soft_face": Color("636160"), "soft_face_dark": Color("514f4f"), "soft_lip": Color("b5b2ae"),
"hard_top": Color("4f5a75"), "hard_top_dark": Color("434d66"), "hard_top_light": Color("62708f"),
"hard_face": Color("39425a"), "hard_face_dark": Color("2e354a"), "hard_lip": Color("7a89aa"),
"hard_glint": Color("8ff0ff"),
"bed_top": Color("2e2b30"), "bed_top_dark": Color("262328"), "bed_top_light": Color("3a363c"),
"bed_face": Color("1e1b20"), "bed_face_dark": Color("171519"), "bed_lip": Color("48434a"),
"grass": Color("3fbf5a"), "grass_dark": Color("2e9c48"), "grass_light": Color("6ee887"),
"grass_edge": Color("1f7a3a"),
"flower_a": Color("fff27a"), "flower_b": Color("e8fff0"), "flower_c": Color("ff8ad0"),
"moss": Color("35c46a"), "moss_dark": Color("25a052"), "moss_light": Color("7cf5a5"), "spore": Color("fff59a"),
"vine": Color("3dbb63"), "vine_dark": Color("2a8f4a"), "leaf": Color("52d47a"), "leaf_dark": Color("34a85c"),
"stem": Color("45c068"), "bud": Color("a0f5b8"), "fibre": Color("f0fff6"), "fibre_shade": Color("c8e8d6"),
"seed": Color("8a6a4a"),
"stone": Color("8e8b88"), "stone_light": Color("aeaaa6"), "stone_dark": Color("6c6967"),
"wool": Color("eef2f0"), "wool_shade": Color("cfd6d3"), "wool_light": Color("ffffff"),
"face": Color("3a3438"), "hoof": Color("2a2629"),
"ruin": Color("7d7a80"), "ruin_dark": Color("68656b"), "ruin_light": Color("94919a"),
"ruin_moss": Color("3fbf6a"), "door": Color("121014"), "glow": Color("ffe066"), "glow_light": Color("fffbd0"),
"skin": Color("f0c4a0"), "skin_shade": Color("d39e7a"), "hair": Color("3d2a24"), "hair_light": Color("5a3e34"),
"eye": Color("1a1416"), "pants": Color("3b4466"), "pants_dark": Color("2e3552"), "boots": Color("3a2a22"),
"marker_near": Color("c8fff0"), "marker_far": Color("ff6a5a"),
}
players = [
Color("ff5f4f"), Color("4f9fff"), Color("ffc847"), Color("4fd06a"),
Color("b77aff"), Color("3fd8c8"), Color("ff7ac0"), Color("ff9a4a"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_GLOW"
+1
View File
@@ -0,0 +1 @@
uid://ce6e46e8mwi8f
+50
View File
@@ -0,0 +1,50 @@
class_name PastelStyle
extends PixelStyle
## Soft, light colours, and outlines in a darker shade of what they
## surround instead of one dark line.
const KEY := &"pastel"
func _init() -> void:
tinted_outlines = true
palette = {
"outline": Color("7a6670"), "dust": Color("fff6ea"),
"ground": Color("e2c7a6"), "ground_dark": Color("d2b591"), "ground_light": Color("eedac0"),
"pebble": Color("c2b6aa"), "pebble_light": Color("ddd4ca"), "pebble_dark": Color("a3978c"),
"dead_crack": Color("c3a585"),
"soft_top": Color("dcd5cf"), "soft_top_dark": Color("cbc3bc"), "soft_top_light": Color("eeeae6"),
"soft_face": Color("bab1ab"), "soft_face_dark": Color("a89f99"), "soft_lip": Color("f6f3f1"),
"hard_top": Color("abb6da"), "hard_top_dark": Color("99a4c9"), "hard_top_light": Color("c5ceea"),
"hard_face": Color("8e98bf"), "hard_face_dark": Color("7c86ad"), "hard_lip": Color("dde4f6"),
"hard_glint": Color("ffffff"),
"bed_top": Color("8d8292"), "bed_top_dark": Color("7e7383"), "bed_top_light": Color("9e93a3"),
"bed_face": Color("6f6574"), "bed_face_dark": Color("615866"), "bed_lip": Color("b0a6b5"),
"grass": Color("9dd28b"), "grass_dark": Color("86c177"), "grass_light": Color("bae4a7"),
"grass_edge": Color("74ae67"),
"flower_a": Color("fff2a8"), "flower_b": Color("ffffff"), "flower_c": Color("ffb6cf"),
"moss": Color("88c57b"), "moss_dark": Color("72ae67"), "moss_light": Color("abde9a"), "spore": Color("fff0a8"),
"vine": Color("80c077"), "vine_dark": Color("67a460"), "leaf": Color("94d086"), "leaf_dark": Color("76b16a"),
"stem": Color("88c37b"), "bud": Color("cae9b2"), "fibre": Color("fffdf6"), "fibre_shade": Color("ede7db"),
"seed": Color("b38d6c"),
"stone": Color("c6bfb8"), "stone_light": Color("ded9d4"), "stone_dark": Color("aaa39c"),
"wool": Color("fffaf2"), "wool_shade": Color("ede5d9"), "wool_light": Color("ffffff"),
"face": Color("8c7c76"), "hoof": Color("71635d"),
"ruin": Color("cfc8d2"), "ruin_dark": Color("b7b0bd"), "ruin_light": Color("e2dde5"),
"ruin_moss": Color("94c888"), "door": Color("62596d"), "glow": Color("ffe7a2"), "glow_light": Color("fffbe9"),
"skin": Color("fcddc3"), "skin_shade": Color("efc3a4"), "hair": Color("b27c5c"), "hair_light": Color("cb987c"),
"eye": Color("5c4852"), "pants": Color("91a2d2"), "pants_dark": Color("7c8dbf"), "boots": Color("a28068"),
"marker_near": Color("ffffff"), "marker_far": Color("ff9c8c"),
}
players = [
Color("f08c7c"), Color("7cb0ea"), Color("f2ce6c"), Color("88cb7c"),
Color("ba98e2"), Color("76d1c8"), Color("f2a2c6"), Color("f4af7a"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_PASTEL"
+1
View File
@@ -0,0 +1 @@
uid://dr2fry8j3yai8
+801
View File
@@ -0,0 +1,801 @@
class_name PixelStyle
extends ArtStyle
## Pixel art in the manner of classic farming games, drawn in code. A tile
## is 16 by 16 pixels, seen a little from the front: rock shows its face
## toward open ground, and figures, animals and plants stand upright. Every
## graphic is made once as a small image, pixel by pixel from the palette,
## and kept; the camera enlarges each pixel to a crisp square.
##
## The subclasses choose the palette and the outlines.
## Pixels along the edge of a tile.
const TILE := 16
## Rows of the face that rock shows toward open ground below it.
const FACE := 6
## Images of a burst of chips, from the hit to its end.
const HIT_FRAMES := 6
## Stages of a growing plant, from the seed to the fibres.
const GROWTH_STAGES := 6
## Size of a figure: a tile wide and one and a half tiles tall.
const FIGURE_SIZE := Vector2i(16, 24)
const CLEAR := Color(0, 0, 0, 0)
## Colours by name; the subclass fills them.
var palette: Dictionary[String, Color] = {}
## The colours of the players, in the order of their numbers.
var players: Array[Color] = []
## True for outlines in a darker shade of what they surround, false for one
## dark outline colour.
var tinted_outlines := false
## How strongly living things glow, from 0 (not at all) to 1; the subclass
## chooses it.
var glow := 0.0
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]
# The body sinks a pixel while the legs are apart, so the head always keeps
# a free row above it for its outline.
var bob := 1 if frame == 1 or frame == 3 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
# Things of later milestones, shown in the showcase.
func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
var variant: int = state.get("variant", 0) % 8
var key := "dead/%d/%d" % [shadow, variant]
_put(canvas, _image(key, func() -> Pixels: return _crack_soil(_earth(variant, shadow), variant)), rect.position)
## Dry cracks in every other tile of dead soil. Returns `pixels`.
func _crack_soil(pixels: Pixels, variant: int) -> Pixels:
if variant % 2 == 0:
var at := _spot(12, variant, 3, 7)
for step in 5:
pixels.put(at.x, at.y, _c("dead_crack"))
if step == 2:
pixels.put(at.x, at.y + 1, _c("dead_crack"))
pixels.put(at.x - 1, at.y + 2, _c("dead_crack"))
at += Vector2i(1, -1 if _noise(step, 5, variant) < 0.4 else 0)
return pixels
func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var living := _shape(state.get("living", 0))
var variant: int = state.get("variant", 0) % 6
_ground(canvas, rect, {"variant": state.get("variant", 0)})
_put(canvas, _image("living/%d/%d" % [living, variant], func() -> Pixels: return _grass_image(living, variant)), rect.position)
## Grass with blades and a few flowers, with a soft edge toward the sides
## that are not living.
func _grass_image(living: int, variant: int) -> Pixels:
var open_n := (living & TileShape.N) == 0
var open_e := (living & TileShape.E) == 0
var open_s := (living & TileShape.S) == 0
var open_w := (living & TileShape.W) == 0
var pixels := Pixels.new(TILE, TILE)
pixels.rect(0, 0, TILE, TILE, _c("grass"))
for tuft in 6:
var at := _spot(tuft + 10, variant, 2, 12)
pixels.put(at.x, at.y, _c("grass_dark"))
pixels.put(at.x - 1, at.y - 1, _c("grass_dark"))
pixels.put(at.x + 1, at.y - 1, _c("grass_dark"))
for blade in 4:
var at := _spot(blade + 20, variant, 1, 14)
pixels.put(at.x, at.y, _c("grass_light"))
if variant % 3 == 0:
var x := 4 + variant % 7
var y := 5 + variant % 5
var petal := _c("flower_b") if variant % 2 == 0 else _c("flower_c")
for side in Pixels.SIDES:
pixels.put(x + side.x, y + side.y, petal)
pixels.put(x, y, _c("flower_a"))
if glow > 0.0:
for spark in 2:
var light := _c("spore")
light.a = glow * 0.8
pixels.put(2 + int(_noise(spark, 21, variant) * 12.0), 2 + int(_noise(spark, 22, variant) * 12.0), light)
# A ragged edge toward bare ground.
for i in TILE:
var ragged := 1 if _noise(i, 23, variant) < 0.45 else 0
if open_n:
_edge(pixels, Vector2i(i, 0), Vector2i(0, 1), ragged)
if open_s:
_edge(pixels, Vector2i(i, TILE - 1), Vector2i(0, -1), ragged)
if open_w:
_edge(pixels, Vector2i(0, i), Vector2i(1, 0), ragged)
if open_e:
_edge(pixels, Vector2i(TILE - 1, i), Vector2i(-1, 0), ragged)
if open_n and open_w:
_round(pixels, Vector2i(0, 0), Vector2i(1, 1), _c("grass_edge"))
if open_n and open_e:
_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), _c("grass_edge"))
if open_s and open_w:
_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), _c("grass_edge"))
if open_s and open_e:
_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), _c("grass_edge"))
return pixels
## Clears `cut` pixels at `at` and draws the darker edge of the grass behind
## them, one step further in the direction `inward`.
func _edge(pixels: Pixels, at: Vector2i, inward: Vector2i, cut: int) -> void:
for depth in cut:
var gone := at + inward * depth
pixels.put(gone.x, gone.y, CLEAR)
var edge := at + inward * cut
pixels.put(edge.x, edge.y, _c("grass_edge"))
func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0) % 3
var twinkle := int(float(state.get("time", 0.0)) * 2.5) % 4
var foot := Vector2(rect.get_center().x, rect.end.y)
_put(canvas, _image("moss/%d/%d" % [variant, twinkle], func() -> Pixels: return _moss_image(variant, twinkle)), foot - Vector2(10, 13))
## A cushion of moss whose spores twinkle one after another, with room on
## both sides for its outline.
func _moss_image(variant: int, twinkle: int) -> Pixels:
var pixels := Pixels.new(TILE + 2, 13)
var shift := variant - 1
for bump: Vector4 in [Vector4(6.5 + shift, 9.5, 4, 3.2), Vector4(13, 9.2, 4.3, 3.5), Vector4(9.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(6 + shift, 8), Vector2i(10 - shift, 5), Vector2i(14, 8)]
for index in spores.size():
var at := spores[index]
var bright := index == twinkle
var spore := _c("spore") if bright else _c("spore").darkened(0.2)
pixels.put(at.x, at.y, spore)
pixels.put(at.x + 1, at.y, spore)
if bright:
pixels.put(at.x, at.y - 1, _c("spore"))
if glow > 0.0:
var halo := _c("spore")
halo.a = 0.45 * glow
for side in Pixels.SIDES:
pixels.shade(at.x + side.x, at.y + side.y + 1, halo)
return pixels
func _vine(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
if points.size() < 2:
return
var origin := (rect.position - Vector2(6, 6)).floor()
var size := Vector2i(ceili(rect.size.x) + 13, ceili(rect.size.y) + 13)
var key := "vine/%d/%d/%d" % [hash(points), size.x, size.y]
_put(canvas, _image(key, func() -> Pixels: return _vine_image(points, origin, size)), origin)
## A vine two pixels thick along `points`, with leaves on alternate sides.
func _vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels:
var pixels := Pixels.new(size.x, size.y)
for i in points.size() - 1:
var from := Vector2i((points[i] - origin).round())
var to := Vector2i((points[i + 1] - origin).round())
pixels.line(from.x, from.y - 1, to.x, to.y - 1, _c("leaf"))
pixels.line(from.x, from.y, to.x, to.y, _c("vine"))
pixels.line(from.x, from.y + 1, to.x, to.y + 1, _c("vine_dark"))
var length := 0.0
for i in points.size() - 1:
length += points[i].distance_to(points[i + 1])
var leaves := int(length / 9.0)
for leaf in leaves:
var at := Vector2i((along(points, (leaf + 0.5) / leaves) - origin).round())
var up := leaf % 2 == 0
var dy := -2 if up else 2
var tip := -1 if up else 1
pixels.put(at.x, at.y + dy, _c("leaf"))
pixels.put(at.x + 1, at.y + dy, _c("leaf"))
pixels.put(at.x + 1, at.y + dy + tip, _c("leaf"))
pixels.put(at.x + 2, at.y + dy + tip, _c("leaf_dark"))
pixels.outline(_ink(_c("vine")), tinted_outlines)
return pixels
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var stage := clampi(int(float(state.get("growth", 1.0)) * GROWTH_STAGES), 0, GROWTH_STAGES - 1)
var lean := clampi(roundi(sway(state.get("time", 0.0), 3) * 1.4), -1, 1)
var foot := Vector2(rect.get_center().x, rect.end.y)
_put(canvas, _image("plant/%d/%d" % [stage, lean], func() -> Pixels: return _plant_image(stage, lean)), foot - Vector2(8, 28))
## A fibre plant at growth stage `stage`, its top leaning by `lean` pixels:
## a seed in a small mound, a sprout, a stalk with leaves, buds and at last
## white tufts of fibre.
func _plant_image(stage: int, lean: int) -> Pixels:
var pixels := Pixels.new(TILE, 28)
pixels.rect(5, 26, 6, 1, _c("ground_dark"))
pixels.rect(4, 27, 8, 1, _c("ground_dark"))
if stage == 0:
pixels.put(7, 25, _c("seed"))
pixels.put(8, 25, _c("seed"))
pixels.outline(_ink(_c("seed")), tinted_outlines)
return pixels
var heights: Array[int] = [0, 4, 8, 13, 17, 19]
var height := heights[stage]
var top := Vector2i(8, 25)
for i in height:
var x := 8 + roundi(lean * 2.0 * i / 19.0)
top = Vector2i(x, 25 - i)
pixels.put(top.x, top.y, _c("stem"))
@warning_ignore("integer_division")
for pair in range(1, height / 3 + 1):
var y := 26 - pair * 3
var x := 8 + roundi(lean * 2.0 * (25 - y) / 19.0)
var side := 1 if pair % 2 == 0 else -1
pixels.put(x + side, y, _c("leaf"))
pixels.put(x + side * 2, y, _c("leaf"))
pixels.put(x + side * 2, y - 1, _c("leaf"))
pixels.put(x + side * 3, y - 1, _c("leaf_dark"))
pixels.put(x - side, y - 1, _c("leaf_dark"))
if stage == 1:
pixels.put(top.x - 1, top.y, _c("leaf"))
pixels.put(top.x + 1, top.y, _c("leaf"))
if stage == 4:
pixels.ellipse(top.x + 0.5, top.y - 0.5, 1.6, 1.6, _c("bud"))
if stage == 5:
for tuft: Vector3 in [Vector3(0, -2, 2.3), Vector3(-3, 1, 1.8), Vector3(3, 2, 1.8)]:
pixels.ellipse(top.x + 0.5 + tuft.x, top.y + tuft.y, tuft.z, tuft.z, _c("fibre"))
pixels.put(top.x + int(tuft.x), top.y + int(tuft.y) + 1, _c("fibre_shade"))
pixels.outline(_ink(_c("stem")), tinted_outlines)
return pixels
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
_put(canvas, _image("stone", func() -> Pixels: return _stone_image()), Vector2(rect.get_center().x - 4, rect.end.y - 8))
func _stone_image() -> Pixels:
var pixels := Pixels.new(8, 8)
pixels.ellipse(4, 4.2, 3.2, 2.7, _c("stone"))
pixels.rect(2, 6, 4, 1, _c("stone_dark"))
pixels.put(2, 3, _c("stone_light"))
pixels.put(3, 2, _c("stone_light"))
pixels.outline(_ink(_c("stone")), tinted_outlines)
return pixels
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var height := roundi(sin(PI * clampf(state.get("hop", 0.0), 0.0, 1.0)) * 5.0)
var left: bool = float(state.get("facing", 1.0)) < 0.0
var foot := Vector2(rect.get_center().x, rect.end.y)
var width := 14 - height
_put(canvas, _image("shadow/%d" % width, func() -> Pixels: return _shadow_image(width)), foot - Vector2(width / 2.0, 2))
var tucked := height > 1
_put(canvas, _image("grazer/%s/%s" % [tucked, left], func() -> Pixels: return _grazer_image(tucked, left)), foot - Vector2(10, 16 + height))
## A woolly animal, facing right or, with `left`, left; in the air its legs
## are `tucked`.
func _grazer_image(tucked: bool, left: bool) -> Pixels:
var pixels := Pixels.new(20, 16)
for leg in [5, 7, 11, 13]:
pixels.rect(leg, 12, 1, 1 if tucked else 3, _c("hoof"))
for puff: Vector4 in [Vector4(9, 8, 6.5, 4.5), Vector4(5.5, 9.5, 4, 3.5), Vector4(12.5, 9.5, 4.5, 3.5), Vector4(8, 5.8, 4, 3), Vector4(11.5, 6.3, 3.5, 3)]:
pixels.ellipse(puff.x, puff.y, puff.z, puff.w, _c("wool"))
for y in pixels.size.y:
for x in pixels.size.x:
if pixels.at(x, y) == _c("wool"):
if y >= 11:
pixels.put(x, y, _c("wool_shade"))
elif not pixels.filled(x, y - 1) and _noise(x, y, 4) < 0.6:
pixels.put(x, y, _c("wool_light"))
pixels.ellipse(16.2, 8.0, 2.6, 3.2, _c("face"))
pixels.put(14, 5, _c("face"))
pixels.put(15, 5, _c("face"))
pixels.put(17, 7, _c("wool_light"))
pixels.outline(_ink(_c("wool")), tinted_outlines)
if left:
pixels.image.flip_x()
return pixels
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var awake := clampf(state.get("awake", 0.0), 0.0, 1.0)
_put(canvas, _image("ruin", func() -> Pixels: return _ruin_image()), rect.position)
_put(canvas, _image("ruin_glow", func() -> Pixels: return _ruin_glow()), rect.position, Color(1, 1, 1, awake))
## A gate of old stone blocks, three tiles wide, with moss on top and a dark
## doorway.
func _ruin_image() -> Pixels:
var pixels := Pixels.new(TILE * 3, TILE * 3)
var stone := _c("ruin")
pixels.rect(3, 12, 12, 34, stone)
pixels.rect(33, 12, 12, 34, stone)
pixels.rect(1, 6, 46, 9, stone)
pixels.rect(1, 3, 46, 3, _c("ruin_light"))
pixels.rect(15, 15, 18, 31, _c("door"))
pixels.rect(9, 44, 30, 2, _c("ruin_dark"))
pixels.rect(7, 46, 34, 2, stone)
# Blocks: a joint every five rows, and between the blocks of each row.
for course in 8:
var y := 8 + course * 5
for x in range(1, 47):
if pixels.at(x, y) == stone:
pixels.put(x, y, _c("ruin_dark"))
var offset := 0 if course % 2 == 0 else 4
for x in range(3 + offset, 47, 8):
for row in range(y + 1, y + 5):
if pixels.at(x, row) == stone:
pixels.put(x, row, _c("ruin_dark"))
for x in range(1, 47):
if _noise(x, 40, 1) < 0.45:
pixels.put(x, 3, _c("ruin_moss"))
if _noise(x, 41, 1) < 0.25:
pixels.put(x, 4, _c("ruin_moss"))
for strand: Vector2i in [Vector2i(5, 4), Vector2i(12, 3), Vector2i(36, 5), Vector2i(43, 2)]:
pixels.rect(strand.x, 15, 1, strand.y, _c("ruin_moss"))
pixels.outline(_ink(stone), tinted_outlines)
return pixels
## The light in the doorway and the runes above it, shown as the ruin wakes.
func _ruin_glow() -> Pixels:
var pixels := Pixels.new(TILE * 3, TILE * 3)
var center := Vector2(24, 31)
for y in range(15, 46):
for x in range(15, 33):
var distance := Vector2(x + 0.5, y + 0.5).distance_to(center)
var light := _c("glow_light") if distance < 4.0 else _c("glow")
light.a = clampf(1.0 - distance / 13.0, 0.0, 1.0) * (0.8 + 0.2 * glow)
if light.a > 0.05:
pixels.put(x, y, light)
for rune in 3:
var x := 16 + rune * 7
for dot: Vector2i in [Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(0, 2), Vector2i(1, 2)]:
pixels.put(x + dot.x + (rune % 2), 8 + dot.y, _c("glow"))
return pixels
+1
View File
@@ -0,0 +1 @@
uid://jejn3txd0eor
+49
View File
@@ -0,0 +1,49 @@
class_name TileShape
extends RefCounted
## Neighbourhoods on the tile grid, so that a style can draw each tile to fit
## the tiles around it, and a stable number per tile for small variations.
## Bits of a neighbour mask, clockwise from north.
const N := 1
const NE := 2
const E := 4
const SE := 8
const S := 16
const SW := 32
const W := 64
const NW := 128
## Where the neighbour of each bit lies, in the order of the bits.
const OFFSETS: Array[Vector2i] = [
Vector2i(0, -1), Vector2i(1, -1), Vector2i(1, 0), Vector2i(1, 1),
Vector2i(0, 1), Vector2i(-1, 1), Vector2i(-1, 0), Vector2i(-1, -1),
]
## One byte per tile of `codes`: 1 if `members` holds its code, else 0.
static func flags(codes: PackedByteArray, members: Array[int]) -> PackedByteArray:
var result := PackedByteArray()
result.resize(codes.size())
for index in codes.size():
result[index] = 1 if members.has(codes[index]) else 0
return result
## The neighbours of `tile` whose flag is 1, as a mask of the bits above, on
## a map of `width` by `height` tiles. Beyond the edge of the map, every
## neighbour counts as set if `outside_set` is true.
static func mask(tile_flags: PackedByteArray, width: int, height: int, tile: Vector2i, outside_set: bool) -> int:
var result := 0
for bit in 8:
var at := tile + OFFSETS[bit]
var inside := at.x >= 0 and at.y >= 0 and at.x < width and at.y < height
if (inside and tile_flags[at.y * width + at.x] == 1) or (not inside and outside_set):
result |= 1 << bit
return result
## A number from 0 to 2147483647 that is the same for `tile` every time and
## looks random from one tile to the next.
static func variant(tile: Vector2i) -> int:
var h := (tile.x * 374761393 + tile.y * 668265263) & 0x7fffffff
h = ((h ^ (h >> 13)) * 1274126177) & 0x7fffffff
return h ^ (h >> 16)
+1
View File
@@ -0,0 +1 @@
uid://dfj1ix7h3wky2
+49
View File
@@ -0,0 +1,49 @@
class_name WarmStyle
extends PixelStyle
## Warm, full colours and dark outlines around figures and things, close to
## the classic farming games.
const KEY := &"warm"
func _init() -> void:
palette = {
"outline": Color("3a2820"), "dust": Color("e9dcc6"),
"ground": Color("b98a5e"), "ground_dark": Color("a07650"), "ground_light": Color("cba077"),
"pebble": Color("8e8072"), "pebble_light": Color("b8ab9a"), "pebble_dark": Color("6b5f54"),
"dead_crack": Color("8a6646"),
"soft_top": Color("bcb0a0"), "soft_top_dark": Color("a69a8a"), "soft_top_light": Color("d6ccbd"),
"soft_face": Color("8f8170"), "soft_face_dark": Color("776a5b"), "soft_lip": Color("e4dccf"),
"hard_top": Color("7c88a2"), "hard_top_dark": Color("68738c"), "hard_top_light": Color("9aa6c0"),
"hard_face": Color("596380"), "hard_face_dark": Color("454d66"), "hard_lip": Color("b6c2da"),
"hard_glint": Color("dff4ff"),
"bed_top": Color("51453e"), "bed_top_dark": Color("433832"), "bed_top_light": Color("62554c"),
"bed_face": Color("382e29"), "bed_face_dark": Color("2b231f"), "bed_lip": Color("6e6157"),
"grass": Color("64a83f"), "grass_dark": Color("4e8f33"), "grass_light": Color("8acb52"),
"grass_edge": Color("3f7a2c"),
"flower_a": Color("f7e25c"), "flower_b": Color("fbf6ea"), "flower_c": Color("ef7fa6"),
"moss": Color("4e9a3a"), "moss_dark": Color("3b7b2e"), "moss_light": Color("7ec552"), "spore": Color("ffe57e"),
"vine": Color("4f9439"), "vine_dark": Color("38702a"), "leaf": Color("68b545"), "leaf_dark": Color("4c8d35"),
"stem": Color("5c9c3c"), "bud": Color("acd97a"), "fibre": Color("f8f3e6"), "fibre_shade": Color("dcd3c0"),
"seed": Color("7b5433"),
"stone": Color("a29a8f"), "stone_light": Color("c8c1b6"), "stone_dark": Color("7c756d"),
"wool": Color("f5f0e6"), "wool_shade": Color("dbd2c2"), "wool_light": Color("ffffff"),
"face": Color("4b3a31"), "hoof": Color("35281f"),
"ruin": Color("aaa395"), "ruin_dark": Color("8a8376"), "ruin_light": Color("c6bfb2"),
"ruin_moss": Color("5f9f43"), "door": Color("2b231f"), "glow": Color("ffd560"), "glow_light": Color("fff4c6"),
"skin": Color("f4cba5"), "skin_shade": Color("dba67f"), "hair": Color("6e4128"), "hair_light": Color("8f5a37"),
"eye": Color("2a1b15"), "pants": Color("4b5b8d"), "pants_dark": Color("3a4672"), "boots": Color("5b3a26"),
"marker_near": Color("fff6c2"), "marker_far": Color("ff705c"),
}
players = [
Color("d9573f"), Color("3f7fd1"), Color("e9b23c"), Color("4ea84a"),
Color("9a62c9"), Color("39a9a0"), Color("e271a1"), Color("e8873b"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_WARM"
+1
View File
@@ -0,0 +1 @@
uid://7kb2jlqpvvoe
+36 -3
View File
@@ -1,7 +1,8 @@
class_name Avatar
extends Node2D
## The local player's figure. It moves outside the lockstep simulation; only
## its pickaxe hits become commands.
## its pickaxe hits become commands. `art` draws it, stepping while it walks
## and facing the way it went last.
## Walking speed: five tiles per second.
const SPEED := 5.0 * GridMotion.TILE_SIZE
@@ -9,9 +10,30 @@ const RADIUS := 5.0
## Longest frame that moves the figure in one go. Longer frames are cut, so
## that the figure cannot pass through a wall after a stall.
const MAX_STEP_SECONDS := 0.1
## How far the step of a walking figure turns per pixel, in radians: about
## two steps per second at walking speed.
const STEP_PER_PIXEL := 0.16
## Callable(x: int, y: int) -> bool that says whether a tile can be walked on.
var is_walkable: Callable
var art: Art
## The number of the player the figure belongs to, for its colour, or -1
## before the player is in the world.
var player := -1
## 1.0 while the figure faces right, -1.0 while it faces left.
var facing := 1.0
## Grows while the figure walks; the styles swing the steps by it.
var step := 0.0
## True while the figure walked in the last frame.
var moving := false
## Where a figure standing at `at` is drawn: a tile wide and one and a half
## tiles tall, with its feet just below its body, so that it stands upright
## in its tile.
static func figure_rect(at: Vector2) -> Rect2:
var size := GridMotion.TILE_SIZE
return Rect2(at.x - size / 2.0, at.y + RADIUS + 1.0 - size * 1.5, size, size * 1.5)
func _process(delta: float) -> void:
@@ -20,12 +42,23 @@ func _process(delta: float) -> void:
## Moves the figure in `direction` for `delta` seconds, sliding along walls.
func walk(direction: Vector2, delta: float) -> void:
var was_moving := moving
moving = false
if direction == Vector2.ZERO or not is_walkable.is_valid():
if was_moving:
queue_redraw()
return
var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS)
var before := position
position = GridMotion.move(position, motion, RADIUS, is_walkable)
var walked := position.distance_to(before)
moving = walked > 0.0
step += walked * STEP_PER_PIXEL
if direction.x != 0.0:
facing = signf(direction.x)
queue_redraw()
func _draw() -> void:
draw_circle(Vector2.ZERO, RADIUS + 1.0, Color(0.15, 0.1, 0.05))
draw_circle(Vector2.ZERO, RADIUS, Color(0.95, 0.85, 0.6))
if art != null:
art.draw(self, Art.FIGURE, figure_rect(Vector2.ZERO), {"player": player, "facing": facing, "step": step, "moving": moving})
+43 -14
View File
@@ -1,19 +1,27 @@
class_name Figures
extends Node2D
## The figures of the other players, with their names. A new position comes
## over the network about ten times a second; in between, each figure glides
## toward the latest one.
## The figures of the other players, with their names, drawn by `art`. A new
## position comes over the network about ten times a second; in between,
## each figure glides toward the latest one, stepping and facing the way it
## goes.
## How quickly a figure closes the gap to its latest position, per second.
const GLIDE := 15.0
const COLOR := Color(0.55, 0.8, 0.95)
const OUTLINE := Color(0.05, 0.1, 0.15)
## Pixels per frame below which a gliding figure counts as standing.
const STANDING := 0.05
## Size and width of the names, in pixels of the playing field. The names
## are drawn at the size they have on screen, so that they stay sharp.
const NAME_SIZE := 8
const NAME_WIDTH := 80.0
const NAME_OUTLINE := Color(0.05, 0.05, 0.06)
var session: GwSession
var art: Art
var _shown: Dictionary[int, Vector2] = {}
var _latest: Dictionary[int, Vector2] = {}
var _facing: Dictionary[int, float] = {}
var _step: Dictionary[int, float] = {}
var _moving: Dictionary[int, bool] = {}
## Moves the figure of `player` toward `position`. A figure that was not
@@ -27,14 +35,14 @@ func place(player: int, position: Vector2) -> void:
## Removes the figure of `player`.
func forget(player: int) -> void:
_shown.erase(player)
_latest.erase(player)
for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]:
figures.erase(player)
queue_redraw()
func clear() -> void:
_shown.clear()
_latest.clear()
for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]:
figures.clear()
queue_redraw()
@@ -43,21 +51,42 @@ func shown_at(player: int) -> Vector2:
return _shown.get(player, Vector2.INF)
## 1.0 if the figure of `player` faces right, -1.0 if it faces left.
func facing_of(player: int) -> float:
return _facing.get(player, 1.0)
func _process(delta: float) -> void:
if _shown.is_empty():
return
var weight := minf(1.0, GLIDE * delta)
for player in _shown:
_shown[player] = _shown[player].lerp(_latest[player], weight)
var before := _shown[player]
_shown[player] = before.lerp(_latest[player], weight)
var moved := _shown[player] - before
if absf(moved.x) > STANDING:
_facing[player] = signf(moved.x)
_step[player] = _step.get(player, 0.0) + moved.length() * Avatar.STEP_PER_PIXEL
_moving[player] = moved.length() > STANDING
queue_redraw()
func _draw() -> void:
if art == null:
return
var font := ThemeDB.fallback_font
# The camera enlarges the field. Text drawn at its size on screen and then
# shrunk stays sharp; text drawn small and enlarged would blur.
var zoom := maxf(1.0, get_global_transform_with_canvas().get_scale().x)
for player in _shown:
var at := _shown[player]
draw_circle(at, Avatar.RADIUS + 1.0, OUTLINE)
draw_circle(at, Avatar.RADIUS, COLOR)
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 - Avatar.RADIUS - 3.0)
draw_string(font, label, session.player_name(player), HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH, NAME_SIZE)
var label := Vector2(at.x - NAME_WIDTH / 2.0, Avatar.figure_rect(at).position.y - 2.0)
var text := session.player_name(player)
var size := roundi(NAME_SIZE * zoom)
draw_set_transform(label, 0.0, Vector2.ONE / zoom)
draw_string_outline(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size, maxi(2, roundi(zoom * 1.5)), NAME_OUTLINE)
draw_string(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size)
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
+5 -2
View File
@@ -10,6 +10,7 @@ var _message := Label.new()
var _status := Label.new()
var _help := Label.new()
var _message_left := 0.0
var _paused := false
func _ready() -> void:
@@ -34,7 +35,7 @@ func _process(delta: float) -> void:
return
_message_left -= delta
if _message_left <= 0.0:
_message.text = ""
_message.text = tr("PAUSED") if _paused else ""
func show_stone(count: int) -> void:
@@ -48,8 +49,10 @@ func flash(key: String, values: Array = []) -> void:
_message_left = MESSAGE_SECONDS
## Shows or hides the pause notice. It stays until the game continues.
## Shows or hides the pause notice. It stays until the game continues and
## returns when a message shown in between ends.
func show_paused(paused: bool) -> void:
_paused = paused
_message.text = tr("PAUSED") if paused else ""
_message_left = 0.0
+5
View File
@@ -13,6 +13,11 @@ static func install() -> void:
_add_keys("save_game", [KEY_F5])
_add_keys("load_game", [KEY_F9])
_add_keys("toggle_pause", [KEY_ESCAPE])
_add_keys("next_style", [KEY_F2])
_add_keys("back", [KEY_ESCAPE])
_add_keys("style_1", [KEY_1])
_add_keys("style_2", [KEY_2])
_add_keys("style_3", [KEY_3])
_add_mouse_button("dig", MOUSE_BUTTON_LEFT)
+9
View File
@@ -9,6 +9,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: warm,
## pastel or glow
## Longest profile name.
const MAX_PROFILE_LENGTH := 32
@@ -21,6 +23,8 @@ var player_name := ""
var start := ""
var port := NetLink.DEFAULT_PORT
var address := ""
## The style to draw in, or empty for the one in the settings.
var style := &""
## Arguments that were not understood and are ignored.
var ignored := PackedStringArray()
@@ -50,6 +54,11 @@ static func parse(arguments: PackedStringArray) -> LaunchOptions:
"--join":
options.start = "join"
options.address = value
"--style":
if Art.style_keys().has(StringName(value)):
options.style = StringName(value)
else:
options.ignored.append(argument)
_:
options.ignored.append(argument)
return options
+97 -6
View File
@@ -40,7 +40,10 @@ var session: GwSession
## The network connection of an open world or a guest, or null.
var link: SessionLink
var clock: TickClock
## The style the playing field is drawn in.
var art: Art
var menu: Menu
var showcase: Showcase
var map_view: MapView
var marker: TileMarker
var avatar: Avatar
@@ -60,6 +63,9 @@ var _joining := ""
## The host's accepted guests when _process last looked. A new one ends a
## pause.
var _known_guests := 0
## How many own hits on each tile showed their burst of chips at once and
## are not reported by the core yet. Their burst is not shown a second time.
var _own_hits: Dictionary[Vector2i, int] = {}
func _ready() -> void:
@@ -75,13 +81,21 @@ func _ready() -> void:
if _player_key.is_empty():
push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
clock = TickClock.new(GwSession.TICKS_PER_SECOND)
art = Art.new()
art.load_overrides(Art.OVERRIDE_DIR)
if not art.select(options.style):
art.select(StringName(store.style()))
map_view = MapView.new()
map_view.art = art
add_child(map_view)
marker = TileMarker.new()
marker.art = art
add_child(marker)
figures = Figures.new()
figures.art = art
add_child(figures)
avatar = Avatar.new()
avatar.art = art
add_child(avatar)
var camera := Camera2D.new()
camera.zoom = CAMERA_ZOOM
@@ -92,8 +106,15 @@ func _ready() -> void:
add_child(menu)
menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
art.changed.connect(_redraw_art)
menu.play_chosen.connect(play)
menu.join_chosen.connect(join)
menu.showcase_chosen.connect(open_showcase)
showcase = Showcase.new()
showcase.art = art
showcase.closed.connect(close_showcase)
_hide_showcase()
add_child(showcase)
_show_menu("")
match options.start:
"solo":
@@ -196,18 +217,51 @@ func _show_menu(status: String) -> void:
## Shows the world of the session and puts the figure on the spawn.
func _show_world() -> void:
menu.hide()
_hide_showcase()
hud.show()
for node: Node2D in [avatar, marker, figures]:
node.show()
map_view.session = session
map_view.queue_redraw()
map_view.reload()
figures.session = session
avatar.player = session.local_player()
figures.clear()
avatar.is_walkable = session.is_walkable
avatar.position = GridMotion.tile_center(session.spawn_point())
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
_update_status()
_next_dig_tick = 0
_own_hits.clear()
## Shows the style samples instead of the menu.
func open_showcase() -> void:
menu.hide()
showcase.time = 0.0
showcase.process_mode = Node.PROCESS_MODE_INHERIT
showcase.show()
## Goes back from the style samples to the menu.
func close_showcase() -> void:
_hide_showcase()
menu.show()
## Hides the style samples and stops them. A world that starts while they are
## open, such as the one a joining guest receives, takes their place.
func _hide_showcase() -> void:
showcase.hide()
showcase.process_mode = Node.PROCESS_MODE_DISABLED
## Draws the playing field again after the style changed, and remembers the
## style for the next start.
func _redraw_art() -> void:
map_view.redraw()
for node: Node2D in [marker, figures, avatar]:
node.queue_redraw()
store.set_style(String(art.style.key()))
## Says in the status line how the game is shared, and with how many.
@@ -230,9 +284,10 @@ func _leave(message: String) -> void:
link = null
session = null
map_view.session = null
map_view.queue_redraw()
map_view.reload()
avatar.is_walkable = Callable()
figures.clear()
_own_hits.clear()
_show_menu(message)
@@ -295,7 +350,8 @@ func _on_notice(notice: Dictionary) -> void:
if notice["resync"]:
resyncs += 1
_keep_diverged_recording()
map_view.queue_redraw()
map_view.reload()
_own_hits.clear()
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
hud.flash("RESYNCED")
else:
@@ -338,6 +394,9 @@ func _unhandled_input(event: InputEvent) -> void:
_set_paused(not clock.paused)
else:
hud.flash("PAUSE_ONLY_ALONE")
elif event.is_action_pressed("next_style"):
art.select_next()
hud.flash("STYLE_SWITCHED", [tr(art.style.title_key())])
## Goes back to the saved world. Only when playing alone: guests would no
@@ -390,8 +449,23 @@ func _apply(events: Array[Dictionary]) -> void:
var local := session.local_player()
for event in events:
match event["type"]:
"tile_hit", "tile_changed":
map_view.queue_redraw()
"tile_hit":
var tile := Vector2i(event["x"], event["y"])
map_view.refresh_tile(tile)
# The own hits showed their burst when they were made.
if event["player"] == local:
_own_hit_reported(tile)
else:
map_view.hit(tile)
"tile_changed":
var tile := Vector2i(event["x"], event["y"])
map_view.refresh_tile(tile)
# The core does not say whose hit broke the tile; an own hit
# showed its burst already.
if _own_hits.has(tile):
_own_hits.erase(tile)
else:
map_view.hit(tile)
"item_gained":
if event["player"] == local:
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
@@ -401,6 +475,9 @@ func _apply(events: Array[Dictionary]) -> void:
"player_joined":
if event["player"] != local:
hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
else:
avatar.player = local
avatar.queue_redraw()
_update_status()
"player_left":
figures.forget(event["player"])
@@ -422,7 +499,9 @@ func _try_dig() -> void:
dig_at(marker.tile)
## Queues a pickaxe hit on `tile`, at most once per cooldown. The caller must
## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its
## burst of chips at once: the hit itself lands with a later tick, which for a
## guest comes back from the host only after the round trip. The caller must
## check the reach: figures move outside the lockstep, so the core does not
## know where the figure is. The core checks the cooldown, the tile, its
## terrain, the tool tier and the free space in the inventory.
@@ -431,6 +510,18 @@ func dig_at(tile: Vector2i) -> void:
return
session.dig(tile.x, tile.y)
_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS
if MapView.SOLID.has(session.terrain_at(tile.x, tile.y)):
map_view.hit(tile)
_own_hits[tile] = _own_hits.get(tile, 0) + 1
## Counts one own hit on `tile` as reported by the core.
func _own_hit_reported(tile: Vector2i) -> void:
var left: int = _own_hits.get(tile, 0) - 1
if left > 0:
_own_hits[tile] = left
else:
_own_hits.erase(tile)
## Writes the running world as the save. Returns false if nothing was
+167 -33
View File
@@ -1,48 +1,182 @@
class_name MapView
extends Node2D
## Draws the terrain of the shared world. The colours are placeholders until
## a style is chosen from the samples of plan M1d.
## Draws the terrain of the shared world in the style of `art`: every tile to
## fit its neighbours, the cracks of the hits it took, and a short burst of
## chips where a pickaxe lands. The map is drawn in square blocks, so that a
## hit draws only the blocks around its tile again.
const CRACK_COLOR := Color(0.1, 0.08, 0.06, 0.8)
## The terrain that rock masses and the edge of the map are made of. Tiles
## beside it are drawn to fit it.
const SOLID: Array[int] = [GwSession.TERRAIN_SOFT_ROCK, GwSession.TERRAIN_HARD_ROCK, GwSession.TERRAIN_BEDROCK]
## Tiles along each side of a block.
const BLOCK := 16
## Seconds a burst of chips lasts.
const HIT_SECONDS := 0.35
var session: GwSession
var art: Art
var _width := 0
var _height := 0
var _terrain := PackedByteArray()
var _damage := PackedByteArray()
var _solid := PackedByteArray()
var _masks := PackedInt32Array()
var _variants := PackedInt32Array()
var _blocks: Array[MapBlock] = []
var _bursts: Array[Dictionary] = []
var _burst_layer := Node2D.new()
func _draw() -> void:
## A square part of the map that draws itself.
class MapBlock extends Node2D:
var view: MapView
var tiles: Rect2i
func _init(owner_view: MapView, block_tiles: Rect2i) -> void:
view = owner_view
tiles = block_tiles
func _draw() -> void:
view.draw_block(self, tiles)
func _ready() -> void:
_burst_layer.draw.connect(_draw_bursts)
add_child(_burst_layer)
set_process(false)
## The graphic of a terrain code.
static func id_of(terrain: int) -> StringName:
match terrain:
GwSession.TERRAIN_SOFT_ROCK:
return Art.SOFT_ROCK
GwSession.TERRAIN_HARD_ROCK:
return Art.HARD_ROCK
GwSession.TERRAIN_BEDROCK:
return Art.BEDROCK
return Art.GROUND
## Takes the whole map from `session` anew and draws it, or shows nothing if
## no world runs. Call it when a world starts or was replaced.
func reload() -> void:
for block in _blocks:
remove_child(block)
block.free()
_blocks.clear()
_bursts.clear()
_burst_layer.queue_redraw()
if session == null or not session.is_running():
_width = 0
_height = 0
return
_width = session.map_width()
_height = session.map_height()
_terrain = session.terrain_codes()
_damage = session.damage_codes()
_solid = TileShape.flags(_terrain, SOLID)
_masks.resize(_width * _height)
_variants.resize(_width * _height)
for y in _height:
for x in _width:
var tile := Vector2i(x, y)
_masks[y * _width + x] = TileShape.mask(_solid, _width, _height, tile, true)
_variants[y * _width + x] = TileShape.variant(tile)
for top in range(0, _height, BLOCK):
for left in range(0, _width, BLOCK):
var block := MapBlock.new(self, Rect2i(left, top, mini(BLOCK, _width - left), mini(BLOCK, _height - top)))
_blocks.append(block)
add_child(block)
move_child(_burst_layer, -1)
## Draws everything again, for example in another style.
func redraw() -> void:
for block in _blocks:
block.queue_redraw()
## Takes `tile` anew from the session after it changed, and draws it and the
## tiles around it again, which may have to fit it differently now.
func refresh_tile(tile: Vector2i) -> void:
if _blocks.is_empty() or not Rect2i(0, 0, _width, _height).has_point(tile):
return
_terrain = session.terrain_codes()
_damage = session.damage_codes()
var index := tile.y * _width + tile.x
_solid[index] = 1 if SOLID.has(_terrain[index]) else 0
var touched: Array[MapBlock] = []
for y in range(tile.y - 1, tile.y + 2):
for x in range(tile.x - 1, tile.x + 2):
var around := Vector2i(x, y)
if not Rect2i(0, 0, _width, _height).has_point(around):
continue
_masks[y * _width + x] = TileShape.mask(_solid, _width, _height, around, true)
var block := _block_of(around)
if not touched.has(block):
touched.append(block)
for block in touched:
block.queue_redraw()
## The block that draws `tile`.
func _block_of(tile: Vector2i) -> MapBlock:
var across := ceili(_width / float(BLOCK))
@warning_ignore("integer_division")
return _blocks[(tile.y / BLOCK) * across + tile.x / BLOCK]
## Shows a burst of chips on `tile`, where a pickaxe just landed.
func hit(tile: Vector2i) -> void:
_bursts.append({"tile": tile, "age": 0.0})
set_process(true)
## The number of blocks the map is drawn in. Tests read it.
func block_count() -> int:
return _blocks.size()
## The number of bursts still shown. Tests read it.
func burst_count() -> int:
return _bursts.size()
## The neighbour mask a tile is drawn with. Tests read it.
func mask_at(tile: Vector2i) -> int:
return _masks[tile.y * _width + tile.x]
## Draws the tiles `tiles` onto `block`, while the block draws.
func draw_block(block: MapBlock, tiles: Rect2i) -> void:
if art == null:
return
var width := session.map_width()
var height := session.map_height()
var terrain := session.terrain_codes()
var damage := session.damage_codes()
var size := GridMotion.TILE_SIZE
for y in height:
for x in width:
var index := y * width + x
for y in range(tiles.position.y, tiles.end.y):
for x in range(tiles.position.x, tiles.end.x):
var index := y * _width + x
var rect := Rect2(x * size, y * size, size, size)
draw_rect(rect, color_of(terrain[index]))
if damage[index] > 0:
_draw_cracks(rect, damage[index])
var variant := _variants[index]
art.draw(block, id_of(_terrain[index]), rect, {"solid": _masks[index], "variant": variant})
if _damage[index] > 0:
art.draw(block, Art.CRACKS, rect, {"hits": _damage[index], "variant": variant})
## The placeholder colour of a terrain code.
static func color_of(terrain: int) -> Color:
if terrain == GwSession.TERRAIN_GROUND:
return Color(0.42, 0.36, 0.28)
if terrain == GwSession.TERRAIN_SOFT_ROCK:
return Color(0.56, 0.55, 0.52)
if terrain == GwSession.TERRAIN_HARD_ROCK:
return Color(0.33, 0.33, 0.37)
if terrain == GwSession.TERRAIN_BEDROCK:
return Color(0.12, 0.12, 0.14)
return Color.MAGENTA
func _process(delta: float) -> void:
for burst in _bursts:
burst["age"] += delta / HIT_SECONDS
_bursts = _bursts.filter(func(burst: Dictionary) -> bool: return burst["age"] < 1.0)
if _bursts.is_empty():
set_process(false)
_burst_layer.queue_redraw()
## One crack line for every hit the tile has taken.
func _draw_cracks(rect: Rect2, hits: int) -> void:
var inset := rect.grow(-3.0)
for line in hits:
var share := (line + 1.0) / (hits + 1.0)
var from := Vector2(inset.position.x, inset.position.y + inset.size.y * share)
var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - share))
draw_line(from, to, CRACK_COLOR, 1.0)
func _draw_bursts() -> void:
if art == null:
return
var size := GridMotion.TILE_SIZE
for burst in _bursts:
var tile: Vector2i = burst["tile"]
var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
art.draw(_burst_layer, Art.HIT, rect, {"age": burst["age"], "variant": TileShape.variant(tile)})
+6 -2
View File
@@ -1,16 +1,18 @@
class_name Menu
extends CanvasLayer
## The start screen: the player's name, then playing alone, opening the world
## for guests, or joining a host.
## for guests, or joining a host. Below, the way to the style samples.
signal play_chosen(open: bool)
signal join_chosen(address: String)
signal showcase_chosen
var name_field := LineEdit.new()
var address_field := LineEdit.new()
var solo_button := Button.new()
var host_button := Button.new()
var join_button := Button.new()
var showcase_button := Button.new()
var _status := Label.new()
@@ -39,15 +41,17 @@ func _ready() -> void:
row.add_child(button)
address_field.placeholder_text = tr("MENU_ADDRESS")
join_button.text = tr("MENU_JOIN")
showcase_button.text = tr("MENU_SHOWCASE")
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.custom_minimum_size = Vector2(380, 0)
for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, _status]:
for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, HSeparator.new(), showcase_button, _status]:
box.add_child(control)
solo_button.pressed.connect(func() -> void: play_chosen.emit(false))
host_button.pressed.connect(func() -> void: play_chosen.emit(true))
join_button.pressed.connect(func() -> void: join_chosen.emit(address_field.text.strip_edges()))
address_field.text_submitted.connect(func(_text: String) -> void: join_button.pressed.emit())
showcase_button.pressed.connect(func() -> void: showcase_chosen.emit())
## Shows `text` below the buttons.
+9
View File
@@ -146,6 +146,15 @@ func set_last_address(address: String) -> void:
_set_setting("network", "address", address)
## The key of the style the player chose last time, or an empty text.
func style() -> String:
return _setting("display", "style")
func set_style(key: String) -> void:
_set_setting("display", "style", key)
func _setting(section: String, key: String) -> String:
var settings := _read_settings(base_dir.path_join(SETTINGS_FILE))
if settings == null:
+268
View File
@@ -0,0 +1,268 @@
class_name Showcase
extends CanvasLayer
## The showcase of the style samples: one small scene in the chosen style,
## with the terrain of the test map and things of later milestones. Dead
## soil turns living, moss glows, a vine carries stones into a waking ruin, a
## plant grows and an animal hops. Buttons switch the style.
## The player wants to go back to the menu.
signal closed
## The scene, one letter per tile: B edge of the map, S soft rock, H hard
## rock, . dead soil, L living soil.
const GRID: Array[String] = [
"BBSSSHHH............",
"BSSSHHH.............",
"SS...H..............",
"..........LLLLLL....",
".......LLLLLLLLLLLL.",
".....LLLLLLLLLLLLLL.",
".....LLLLLLLLLLLLLL.",
".......LLLLLLLLLLL..",
".........LLLLLL.....",
"....................",
]
## Pixels of the scene per pixel on screen.
const ZOOM := 3.0
## Seconds from seed to grown plant, and the pause before it starts again.
const GROW_SECONDS := 6.0
const GROWN_SECONDS := 2.0
## Seconds between two hops, and how long a hop lasts.
const HOP_EVERY := 1.2
const HOP_SECONDS := 0.5
## How far stones travel along the vine per second, as a share of its length.
const STONE_SPEED := 0.1
const STONES := 3
## The path of the vine, in tiles: along the living soil into the ruin.
const VINE: Array[Vector2] = [
Vector2(6.8, 6.9), Vector2(9.0, 7.3), Vector2(11.0, 6.9),
Vector2(13.0, 7.1), Vector2(15.0, 6.4), Vector2(17.3, 5.7),
]
## Where things stand, in tiles: the middle of their foot; for the figures
## the middle of the tile they stand on.
const MOSS: Array[Vector2] = [Vector2(8.0, 4.6), Vector2(9.8, 3.3), Vector2(14.2, 3.4), Vector2(16.8, 6.8)]
const PLANT := Vector2(10.9, 4.4)
const GRAZER_HOME := Vector2(12.8, 5.2)
## The ruin covers three by three tiles from here.
const RUIN := Vector2i(16, 3)
## The figures of two players: the own one and one of another colour.
const FIGURES: Array[Vector2] = [Vector2(3.0, 5.0), Vector2(1.6, 6.6)]
var art: Art
## Seconds since the showcase opened.
var time := 0.0
var style_buttons: Array[Button] = []
var back_button := Button.new()
var _view := ShowcaseView.new()
var _title := Label.new()
var _captions: Array[Label] = []
var _grid_codes := PackedByteArray()
var _solid := PackedByteArray()
var _living := PackedByteArray()
## The node that draws the scene.
class ShowcaseView extends Node2D:
var showcase: Showcase
func _draw() -> void:
showcase.draw_scene(self)
func _ready() -> void:
_read_grid()
var background := ColorRect.new()
background.color = Color(0.08, 0.08, 0.09)
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(background)
_view.showcase = self
_view.scale = Vector2(ZOOM, ZOOM)
add_child(_view)
_title.add_theme_font_size_override("font_size", 28)
_title.position = Vector2(32, 20)
add_child(_title)
var row := HBoxContainer.new()
row.position = Vector2(32, 660)
row.add_theme_constant_override("separation", 12)
add_child(row)
for index in art.styles.size():
var button := Button.new()
button.text = "%d %s" % [index + 1, tr(art.styles[index].title_key())]
button.pressed.connect(func() -> void: art.select(art.styles[index].key()))
style_buttons.append(button)
row.add_child(button)
back_button.text = tr("SHOWCASE_BACK")
back_button.pressed.connect(func() -> void: closed.emit())
row.add_child(back_button)
_add_captions()
art.changed.connect(_on_style_changed)
_on_style_changed()
get_viewport().size_changed.connect(_layout)
_layout()
func _process(delta: float) -> void:
time += delta
_view.queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
for index in mini(3, art.styles.size()):
if event.is_action_pressed("style_%d" % (index + 1)):
art.select(art.styles[index].key())
if event.is_action_pressed("next_style"):
art.select_next()
elif event.is_action_pressed("back"):
closed.emit()
## How far the plant has grown at `seconds`, from 0 (a seed) to 1, over and
## over again.
static func growth_at(seconds: float) -> float:
return clampf(fposmod(seconds, GROW_SECONDS + GROWN_SECONDS) / GROW_SECONDS, 0.0, 1.0)
## How far the animal is through its hop at `seconds`: from 0 when it takes
## off to 1 when it lands, then 1 while it rests.
static func hop_at(seconds: float) -> float:
return clampf(fposmod(seconds, HOP_EVERY) / HOP_SECONDS, 0.0, 1.0)
## Where the animal is at `seconds`, in tiles: it hops around its home.
static func grazer_at(seconds: float) -> Vector2:
var hops := floorf(seconds / HOP_EVERY) + hop_at(seconds)
return GRAZER_HOME + Vector2(cos(hops * 0.9), sin(hops * 0.9) * 0.4)
## How far each stone is along the vine at `seconds`, from 0 to 1.
static func stones_at(seconds: float) -> Array[float]:
var shares: Array[float] = []
for stone in STONES:
shares.append(fposmod(seconds * STONE_SPEED + float(stone) / STONES, 1.0))
return shares
## How awake the ruin is at `seconds`: it wakes and dozes in slow breaths.
static func awake_at(seconds: float) -> float:
return 0.65 + 0.35 * sin(seconds * 1.2)
## The texts under the things in the scene, as keys. Tests read them.
func caption_keys() -> PackedStringArray:
var keys := PackedStringArray()
for label in _captions:
keys.append(label.get_meta("key"))
return keys
## Draws the whole scene onto `canvas`, while it draws.
func draw_scene(canvas: CanvasItem) -> void:
if art == null:
return
var size := GridMotion.TILE_SIZE
var width := GRID[0].length()
var height := GRID.size()
for y in height:
for x in width:
var tile := Vector2i(x, y)
var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
var code := _grid_codes[y * width + x]
var state := {
"solid": TileShape.mask(_solid, width, height, tile, false),
"living": TileShape.mask(_living, width, height, tile, false),
"variant": TileShape.variant(tile),
}
art.draw(canvas, _id_of(code), rect, state)
for at in MOSS:
art.draw(canvas, Art.MOSS, _tile_rect(at), {"variant": TileShape.variant(Vector2i(at)), "time": time})
var path := PackedVector2Array()
for point in VINE:
path.append(point * size)
art.draw(canvas, Art.VINE, _bounds(path), {"points": path, "time": time})
for share in stones_at(time):
var at := ArtStyle.along(path, share)
art.draw(canvas, Art.STONE, Rect2(at - Vector2(4, 5), Vector2(8, 8)))
art.draw(canvas, Art.FIBER_PLANT, _tile_rect(PLANT, Vector2(1.0, 1.8)), {"growth": growth_at(time), "time": time})
var grazer := grazer_at(time)
var heading := grazer_at(time + 0.1) - grazer
art.draw(canvas, Art.GRAZER, _tile_rect(grazer), {"hop": hop_at(time), "facing": 1.0 if heading.x >= 0.0 else -1.0})
var ruin := Rect2(Vector2(RUIN) * size, Vector2(3, 3) * size)
art.draw(canvas, Art.RUIN, ruin, {"awake": awake_at(time), "time": time})
for player in FIGURES.size():
var at := (FIGURES[player] + Vector2(0.5, 0.5)) * size
art.draw(canvas, Art.FIGURE, Avatar.figure_rect(at), {"player": player, "facing": 1.0, "step": time * 4.0 + player, "moving": true})
func _read_grid() -> void:
var codes := {".": 0, "L": 1, "S": 2, "H": 3, "B": 4}
_grid_codes.clear()
for line in GRID:
for letter in line:
_grid_codes.append(codes[letter])
_solid = TileShape.flags(_grid_codes, [2, 3, 4])
_living = TileShape.flags(_grid_codes, [1])
static func _id_of(code: int) -> StringName:
return [Art.DEAD_SOIL, Art.LIVING_SOIL, Art.SOFT_ROCK, Art.HARD_ROCK, Art.BEDROCK][code]
## A square of one tile, or of `size` tiles, whose bottom middle stands at
## `at` (in tiles) plus half a tile.
static func _tile_rect(at: Vector2, size := Vector2.ONE) -> Rect2:
var tile := GridMotion.TILE_SIZE
var foot := (at + Vector2(0.5, 1.0)) * tile
return Rect2(foot - Vector2(size.x * 0.5, size.y) * tile, size * tile)
static func _bounds(points: PackedVector2Array) -> Rect2:
var rect := Rect2(points[0], Vector2.ZERO)
for point in points:
rect = rect.expand(point)
return rect
func _add_captions() -> void:
var entries := [
["SHOWCASE_ROCK", Vector2(4.0, 3.1)],
["SHOWCASE_DEAD_SOIL", Vector2(2.6, 8.8)],
["SHOWCASE_LIVING_SOIL", Vector2(12.5, 9.15)],
["SHOWCASE_MOSS", Vector2(8.4, 5.6)],
["SHOWCASE_PLANT", Vector2(11.4, 2.3)],
["SHOWCASE_GRAZER", Vector2(13.3, 6.3)],
["SHOWCASE_VINE", Vector2(10.6, 7.7)],
["SHOWCASE_RUIN", Vector2(17.5, 1.9)],
["SHOWCASE_FIGURES", Vector2(2.6, 7.7)],
]
for entry: Array in entries:
var label := Label.new()
label.text = tr(entry[0])
label.set_meta("key", entry[0])
label.set_meta("at", entry[1])
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_outline_color", Color(0.05, 0.05, 0.06))
label.add_theme_constant_override("outline_size", 5)
_captions.append(label)
add_child(label)
## Puts the scene in the middle of the screen and the texts under their
## things.
func _layout() -> void:
var scene_size := Vector2(GRID[0].length(), GRID.size()) * GridMotion.TILE_SIZE * ZOOM
var screen := get_viewport().get_visible_rect().size
_view.position = ((screen - scene_size) / 2.0).floor()
for label in _captions:
var at: Vector2 = label.get_meta("at")
label.size = Vector2(160, 20)
label.position = _view.position + at * GridMotion.TILE_SIZE * ZOOM - Vector2(80, 0)
func _on_style_changed() -> void:
_title.text = tr("SHOWCASE_TITLE") % tr(art.style.title_key())
for index in style_buttons.size():
style_buttons[index].disabled = art.styles[index] == art.style
_view.queue_redraw()
+1
View File
@@ -0,0 +1 @@
uid://bscs3cghniuuq
+7 -8
View File
@@ -1,16 +1,14 @@
class_name TileMarker
extends Node2D
## Outlines the tile under the mouse: green when the figure can reach it,
## red when it is too far away.
const REACHABLE_COLOR := Color(0.4, 1.0, 0.4)
const UNREACHABLE_COLOR := Color(1.0, 0.4, 0.4)
## Marks the tile under the mouse, drawn by `art`: one way when the figure
## can reach it, another when it is too far away.
var tile := Vector2i(-1, -1)
var in_reach := false
var art: Art
## Moves the outline to `new_tile`. Redraws only when something changed.
## Moves the mark to `new_tile`. Redraws only when something changed.
func show_tile(new_tile: Vector2i, reachable: bool) -> void:
if new_tile == tile and reachable == in_reach:
return
@@ -20,6 +18,7 @@ func show_tile(new_tile: Vector2i, reachable: bool) -> void:
func _draw() -> void:
if art == null:
return
var size := GridMotion.TILE_SIZE
var color := REACHABLE_COLOR if in_reach else UNREACHABLE_COLOR
draw_rect(Rect2(Vector2(tile) * size, Vector2(size, size)), color, false, 1.0)
art.draw(self, Art.MARKER, Rect2(Vector2(tile) * size, Vector2(size, size)), {"reachable": in_reach})
+110
View File
@@ -0,0 +1,110 @@
extends TestCase
## Draws every graphic of `art` twice, with `state` and with nothing, while
## it draws.
class Probe extends Node2D:
var art: Art
var state: Dictionary
var drawn := 0
func _draw() -> void:
for id in Art.IDS:
var rect := Rect2(0, 0, 48, 48) if id == Art.RUIN else Rect2(16, 16, 16, 16)
art.draw(self, id, rect, state)
art.draw(self, id, rect, {})
drawn += 1
## Everything a graphic can be asked to show, so that each drawing runs
## through all its parts.
static func _full_state() -> Dictionary:
return {
"solid": TileShape.N | TileShape.W | TileShape.NE, "living": TileShape.E | TileShape.S,
"variant": 123456, "hits": 2, "age": 0.5, "player": 1, "facing": -1.0, "step": 1.0,
"moving": true, "reachable": true, "time": 2.5, "growth": 0.9, "hop": 0.5, "awake": 0.7,
"points": PackedVector2Array([Vector2(0, 8), Vector2(10, 4), Vector2(24, 9)]),
}
## Lets `art` draw every graphic once and returns how many were drawn.
func _draw_everything(art: Art) -> int:
var probe := Probe.new()
probe.art = art
probe.state = _full_state()
tree.root.add_child(probe)
await tree.process_frame
await tree.process_frame
var drawn := probe.drawn
tree.root.remove_child(probe)
probe.free()
return drawn
func test_every_style_draws_every_graphic() -> void:
var art := Art.new()
for style in art.styles:
art.select(style.key())
check_eq(await _draw_everything(art), Art.IDS.size(), "%s draws them all" % style.key())
art.style = ArtStyle.new()
check_eq(await _draw_everything(art), Art.IDS.size(), "the placeholders too")
func test_the_styles_are_offered_in_a_fixed_order() -> void:
var art := Art.new()
var keys: Array[StringName] = []
for style in art.styles:
keys.append(style.key())
check_eq(keys, Art.style_keys(), "the keys in the order of the styles")
check_eq(keys, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY], "warm, pastel, glow")
check_eq(art.style, art.styles[0], "the first one is chosen at first")
func test_a_style_is_chosen_by_its_key() -> void:
var art := Art.new()
var changes := [0]
art.changed.connect(func() -> void: changes[0] += 1)
check(art.select(PastelStyle.KEY), "the pastel style exists")
check_eq(art.style.key(), PastelStyle.KEY, "and is chosen")
check(art.select(PastelStyle.KEY), "choosing it again works")
check_eq(changes[0], 1, "only a real change is announced")
check(not art.select(&"watercolour"), "an unknown key is refused")
check_eq(art.style.key(), PastelStyle.KEY, "and changes nothing")
func test_the_next_style_follows_and_the_first_comes_after_the_last() -> void:
var art := Art.new()
var seen: Array[StringName] = []
for i in art.styles.size() + 1:
seen.append(art.style.key())
art.select_next()
check_eq(seen, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY, WarmStyle.KEY], "round and round")
func test_every_style_has_a_name_in_every_language() -> void:
for translation in I18n.load_translations(I18n.TEXTS_FILE):
for style in Art.new().styles:
var title := String(translation.get_message(style.title_key()))
check(not title.is_empty(), "%s in %s" % [style.title_key(), translation.locale])
func test_a_picture_named_after_an_id_replaces_its_drawing() -> void:
var directory := TestFiles.temporary_dir("art")
DirAccess.make_dir_recursive_absolute(directory.path_join("terrain"))
var image := Image.create(16, 16, false, Image.FORMAT_RGBA8)
image.fill(Color.RED)
image.save_png(directory.path_join("terrain/ground.png"))
var art := Art.new()
art.load_overrides(directory)
check(art.has_override(Art.GROUND), "the ground has a picture")
check(not art.has_override(Art.SOFT_ROCK), "soft rock has none")
check_eq(await _draw_everything(art), Art.IDS.size(), "the picture is drawn like the rest")
art.load_overrides(directory.path_join("missing"))
check(not art.has_override(Art.GROUND), "loading again drops the picture")
TestFiles.remove_dir(directory)
func test_without_the_folder_of_pictures_everything_is_drawn() -> void:
var art := Art.new()
art.load_overrides("res://tests/no-pictures-here")
for id in Art.IDS:
check(not art.has_override(id), "no picture for %s" % id)
+1
View File
@@ -0,0 +1 @@
uid://dmuqgq1uvws3x
+26
View File
@@ -0,0 +1,26 @@
extends TestCase
## The helpers that every style draws with.
func test_a_point_can_be_found_along_a_path() -> void:
var path := PackedVector2Array([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10)])
check_eq(ArtStyle.along(path, 0.0), Vector2(0, 0), "the start")
check_eq(ArtStyle.along(path, 0.5), Vector2(10, 0), "halfway is the bend")
check_eq(ArtStyle.along(path, 0.75), Vector2(10, 5), "three quarters")
check_eq(ArtStyle.along(path, 1.0), Vector2(10, 10), "the end")
check_eq(ArtStyle.along(path, 2.0), Vector2(10, 10), "never beyond the end")
func test_shares_lie_between_zero_and_one_and_stay_the_same() -> void:
for variant in [0, 1, 99, 123456, 2147483647]:
for salt in 4:
var share := ArtStyle.share(variant, salt)
check(share >= 0.0 and share < 1.0, "share of %d and %d: %f" % [variant, salt, share])
check_eq(ArtStyle.share(variant, salt), share, "the same again")
func test_a_sway_swings_gently_and_differs_between_things() -> void:
for tenth in 100:
var swing := ArtStyle.sway(tenth * 0.1, 7)
check(swing >= -1.0 and swing <= 1.0, "between left and right at %.1f s: %f" % [tenth * 0.1, swing])
check(ArtStyle.sway(0.0, 1) != ArtStyle.sway(0.0, 2), "two things do not swing alike")
+1
View File
@@ -0,0 +1 @@
uid://cbkbq72ph6wdn
+23
View File
@@ -31,3 +31,26 @@ func test_without_a_map_the_figure_stays() -> void:
avatar.walk(Vector2.LEFT, 0.1)
check_eq(avatar.position, Vector2.ZERO, "no walkability callable")
avatar.free()
func test_a_walking_figure_steps_and_faces_the_way_it_goes() -> void:
var avatar := Avatar.new()
avatar.is_walkable = _open_field
avatar.position = GridMotion.tile_center(Vector2i(50, 50))
check(not avatar.moving, "standing at first")
avatar.walk(Vector2.LEFT, 0.1)
check(avatar.moving, "walking")
check_eq(avatar.facing, -1.0, "facing left")
check(avatar.step > 0.0, "the step turns")
avatar.walk(Vector2.UP, 0.1)
check_eq(avatar.facing, -1.0, "walking up keeps the way it faces")
avatar.walk(Vector2.ZERO, 0.1)
check(not avatar.moving, "standing again")
avatar.free()
func test_a_figure_stands_upright_in_its_tile() -> void:
var rect := Avatar.figure_rect(Vector2(100, 100))
check_eq(rect.size, Vector2(GridMotion.TILE_SIZE, GridMotion.TILE_SIZE * 1.5), "a tile wide, one and a half tall")
check_eq(rect.get_center().x, 100.0, "centred on the figure")
check_eq(rect.end.y, 100.0 + Avatar.RADIUS + 1.0, "its feet just below its body")
+10
View File
@@ -32,3 +32,13 @@ func test_figures_can_be_forgotten() -> void:
figures.clear()
check_eq(figures.shown_at(3), Vector2.INF, "all are gone")
figures.free()
func test_a_figure_faces_the_way_it_glides() -> void:
var figures := Figures.new()
figures.place(1, Vector2(100.0, 0.0))
check_eq(figures.facing_of(1), 1.0, "facing right at first")
figures.place(1, Vector2.ZERO)
figures._process(0.02)
check_eq(figures.facing_of(1), -1.0, "facing left while it glides to the left")
figures.free()
+18
View File
@@ -0,0 +1,18 @@
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")
+1
View File
@@ -0,0 +1 @@
uid://bllgwrsoumvpl
+13
View File
@@ -30,3 +30,16 @@ func test_the_status_line_stays_until_it_is_replaced() -> void:
check_eq(hud.message(), "", "the message is gone after a while")
check_eq(hud.status_text(), tr("STATUS_HOST") % [24680, 2], "the status line is not")
_free(hud)
func test_the_pause_notice_returns_after_a_message() -> void:
var hud := _hud()
hud.show_paused(true)
hud.flash("STYLE_SWITCHED", [tr("STYLE_PASTEL")])
hud._process(Hud.MESSAGE_SECONDS + 0.1)
check_eq(hud.message(), tr("PAUSED"), "still paused, the notice is back")
hud.show_paused(false)
hud.flash("SAVED")
hud._process(Hud.MESSAGE_SECONDS + 0.1)
check_eq(hud.message(), "", "without a pause the message just goes")
_free(hud)
+1 -1
View File
@@ -3,7 +3,7 @@ extends TestCase
func test_every_action_of_the_game_exists() -> void:
InputSetup.install()
for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause"]:
for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause", "next_style", "back", "style_1", "style_2", "style_3"]:
check(InputMap.has_action(action), "action %s" % action)
+8
View File
@@ -28,3 +28,11 @@ func test_profile_names_cannot_leave_the_profile_folder() -> void:
check_eq(options.storage_dir(), "user://", "a bad profile is ignored")
check_eq(options.port, NetLink.DEFAULT_PORT, "a bad port is ignored")
check_eq(options.ignored, PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]), "everything ignored is listed")
func test_a_style_can_be_chosen() -> void:
check_eq(LaunchOptions.parse(PackedStringArray(["--style=glow"])).style, GlowStyle.KEY, "a known style")
check_eq(LaunchOptions.parse(PackedStringArray()).style, &"", "none without the argument")
var unknown := LaunchOptions.parse(PackedStringArray(["--style=watercolour"]))
check_eq(unknown.style, &"", "an unknown style is not taken")
check_eq(unknown.ignored, PackedStringArray(["--style=watercolour"]), "but listed as ignored")
+112
View File
@@ -381,3 +381,115 @@ func test_a_mistyped_address_is_reported() -> void:
check(main.session == null, "nothing starts")
check_eq(main.menu.status(), tr("ADDRESS_INVALID") % "host:port", "the menu says why")
_clean_up([directory])
func test_an_own_hit_shows_its_burst_at_once() -> void:
var directory := TestFiles.temporary_dir("burst")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_SOFT_ROCK, "rock beside the clearing")
main.dig_at(rock)
check_eq(main.map_view.burst_count(), 1, "the burst shows before the hit lands in a tick")
_clean_up([directory])
func test_an_own_hit_that_breaks_the_rock_shows_its_burst_only_once() -> void:
var directory := TestFiles.temporary_dir("break")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
for blow in 3:
main.dig_at(rock)
for tick in GwSession.DIG_COOLDOWN_TICKS + 1:
main._run_tick()
check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three own hits break the rock")
check_eq(main.map_view.burst_count(), 3, "one burst per own hit, none more when the rock breaks")
_clean_up([directory])
func test_own_hits_reported_late_show_no_second_burst() -> void:
var directory := TestFiles.temporary_dir("late")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
main.dig_at(rock)
main._next_dig_tick = 0
main.dig_at(rock)
# As for a guest: the report of the first hit comes after the second hit,
# and the second hit breaks the rock.
var reports: Array[Dictionary] = [
{"type": "tile_hit", "player": main.session.local_player(), "x": rock.x, "y": rock.y},
{"type": "tile_changed", "x": rock.x, "y": rock.y},
]
main._apply(reports)
check_eq(main.map_view.burst_count(), 2, "two own hits, two bursts")
_clean_up([directory])
func test_the_style_samples_open_from_the_menu_and_lead_back_to_it() -> void:
var directory := TestFiles.temporary_dir("showcase")
var main := _start_main(directory)
main.menu.showcase_button.pressed.emit()
check(main.showcase.visible, "the samples show")
check(not main.menu.visible, "instead of the menu")
main.showcase.style_buttons[2].pressed.emit()
check_eq(main.art.style.key(), GlowStyle.KEY, "a button there chooses the style")
await tree.process_frame
main.showcase.back_button.pressed.emit()
check(main.menu.visible, "the menu is back")
check(not main.showcase.visible, "the samples are gone")
_clean_up([directory])
func test_a_world_that_starts_takes_the_place_of_the_style_samples() -> void:
var directory := TestFiles.temporary_dir("samples-world")
var main := _start_main(directory)
main.open_showcase()
check(main.showcase.visible, "the samples are open")
main.play(false)
check(not main.showcase.visible, "the world shows instead of the samples")
check_eq(main.showcase.process_mode, Node.PROCESS_MODE_DISABLED, "and the samples take no input")
check(not main.menu.visible, "the menu stays hidden")
_clean_up([directory])
func test_f2_switches_the_style_and_the_choice_is_kept() -> void:
var directory := TestFiles.temporary_dir("style")
var main := _start_main(directory)
main.play(false)
var first: StringName = main.art.style.key()
_press(main, "next_style")
var second: StringName = main.art.style.key()
check(second != first, "another style")
check_eq(main.hud.message(), tr("STYLE_SWITCHED") % tr(main.art.style.title_key()), "the player is told")
_stop_main(main)
var later := _start_main(directory)
check_eq(later.art.style.key(), second, "the next start draws in it again")
_clean_up([directory])
func test_the_command_line_chooses_the_style() -> void:
var directory := TestFiles.temporary_dir("style-option")
var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = directory
main.command_line = PackedStringArray(["--style=pastel"])
tree.root.add_child(main)
_started.append(main)
check_eq(main.art.style.key(), PastelStyle.KEY, "the style of the command line")
_clean_up([directory])
func test_hits_of_other_players_show_their_bursts() -> void:
var directory := TestFiles.temporary_dir("other-hits")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
var reports: Array[Dictionary] = [
{"type": "tile_hit", "player": main.session.local_player() + 1, "x": rock.x, "y": rock.y},
{"type": "tile_changed", "x": rock.x, "y": rock.y},
]
main._apply(reports)
check_eq(main.map_view.burst_count(), 2, "a hit and a breaking blow of another player")
_clean_up([directory])
+94
View File
@@ -0,0 +1,94 @@
extends TestCase
## The map view over a real world of the core.
var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])
func _session() -> GwSession:
var session := GwSession.new()
session.start_new(7)
session.join(key, "Ada")
session.step()
return session
func _view(session: GwSession) -> MapView:
var view := MapView.new()
view.art = Art.new()
view.session = session
tree.root.add_child(view)
view.reload()
return view
func _free(view: MapView) -> void:
tree.root.remove_child(view)
view.free()
## The first tile of the soft rock beside the spawn clearing.
func _rock(session: GwSession) -> Vector2i:
return session.spawn_point() + Vector2i(7, -2)
func test_the_map_is_drawn_in_blocks_that_cover_it() -> void:
var session := _session()
var view := _view(session)
var across := ceili(session.map_width() / float(MapView.BLOCK))
var down := ceili(session.map_height() / float(MapView.BLOCK))
check_eq(view.block_count(), across * down, "blocks over the whole map")
for style in Art.style_keys():
view.art.select(style)
view.redraw()
await tree.process_frame
_free(view)
func test_each_tile_is_drawn_to_fit_the_rock_around_it() -> void:
var session := _session()
var view := _view(session)
var solid := TileShape.flags(session.terrain_codes(), MapView.SOLID)
for tile: Vector2i in [Vector2i(0, 0), session.spawn_point(), _rock(session)]:
var expected := TileShape.mask(solid, session.map_width(), session.map_height(), tile, true)
check_eq(view.mask_at(tile), expected, "the neighbours of %s" % tile)
_free(view)
func test_a_broken_rock_no_longer_shapes_its_neighbours() -> void:
var session := _session()
var view := _view(session)
var rock := _rock(session)
var west := rock + Vector2i(-1, 0)
check(view.mask_at(west) & TileShape.E != 0, "the rock lies east of its neighbour")
for hit in 3:
session.dig(rock.x, rock.y)
session.step()
for wait in GwSession.DIG_COOLDOWN_TICKS:
session.step()
check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three hits break it")
view.refresh_tile(rock)
check(view.mask_at(west) & TileShape.E == 0, "now there is ground east of the neighbour")
await tree.process_frame
_free(view)
func test_a_burst_of_chips_shows_for_a_moment() -> void:
var view := _view(_session())
view.hit(Vector2i(3, 3))
check_eq(view.burst_count(), 1, "a burst")
view._process(MapView.HIT_SECONDS * 0.5)
check_eq(view.burst_count(), 1, "still in the air")
await tree.process_frame
view._process(MapView.HIT_SECONDS * 0.6)
check_eq(view.burst_count(), 0, "gone after its time")
_free(view)
func test_without_a_world_nothing_is_drawn() -> void:
var view := _view(_session())
view.session = null
view.reload()
check_eq(view.block_count(), 0, "no blocks")
view.refresh_tile(Vector2i(1, 1))
await tree.process_frame
_free(view)
+1
View File
@@ -0,0 +1 @@
uid://bp0vlwfwtlh7h
+10
View File
@@ -35,3 +35,13 @@ func test_the_name_and_the_status_line() -> void:
check_eq(menu.status(), "Verbinde …", "the status is shown")
check_eq(menu.solo_button.text, tr("MENU_SOLO"), "the labels come from the texts")
_free(menu)
func test_the_way_to_the_style_samples() -> void:
var menu := _menu()
var chosen := [0]
menu.showcase_chosen.connect(func() -> void: chosen[0] += 1)
check_eq(menu.showcase_button.text, tr("MENU_SHOWCASE"), "the button says where it leads")
menu.showcase_button.pressed.emit()
check_eq(chosen[0], 1, "it asks for the samples")
_free(menu)
+18
View File
@@ -0,0 +1,18 @@
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := PastelStyle.new()
var colours := {}
for player in 4:
colours[style.player_color(player)] = true
check_eq(colours.size(), 4, "four players, four colours")
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
func test_it_has_every_colour_the_warm_style_has() -> void:
var names := PastelStyle.new().palette.keys()
var warm := WarmStyle.new().palette.keys()
names.sort()
warm.sort()
check_eq(names, warm, "the same colours by name")
+1
View File
@@ -0,0 +1 @@
uid://biq70tu64w7gu
+90
View File
@@ -0,0 +1,90 @@
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")
func test_the_head_keeps_its_outline_in_every_step() -> void:
var style := WarmStyle.new()
for frame in 5:
var image := style._figure_image(0, frame, false).image
for x in image.get_width():
var top := image.get_pixel(x, 0)
check(top.a == 0.0 or top == style.palette["outline"], "frame %d, column %d: %s" % [frame, x, top])
+1
View File
@@ -0,0 +1 @@
uid://dr01kkrpn22sy
+69
View File
@@ -0,0 +1,69 @@
extends TestCase
## The pixel art of the things of later milestones in the showcase.
## The highest row of `image` with a pixel that is not transparent.
static func _top(image: Image) -> int:
for y in image.get_height():
for x in image.get_width():
if image.get_pixel(x, y).a > 0.0:
return y
return image.get_height()
func test_a_plant_grows_taller_from_stage_to_stage() -> void:
var style := WarmStyle.new()
var tops: Array[int] = []
for stage in PixelStyle.GROWTH_STAGES:
tops.append(_top(style._plant_image(stage, 0).image))
for stage in range(1, tops.size()):
check(tops[stage] < tops[stage - 1], "stage %d is taller than stage %d: %s" % [stage, stage - 1, tops])
func test_grass_has_a_ragged_edge_only_toward_bare_ground() -> void:
var style := WarmStyle.new()
var inside := style._grass_image(255, 0).image
var lone := style._grass_image(0, 0).image
var tile := PixelStyle.TILE
var inside_gaps := 0
var lone_gaps := 0
for y in tile:
for x in tile:
inside_gaps += 1 if inside.get_pixel(x, y).a == 0.0 else 0
lone_gaps += 1 if lone.get_pixel(x, y).a == 0.0 else 0
check_eq(inside_gaps, 0, "living all around: no gaps")
check(lone_gaps > 4, "bare ground all around: a ragged, round edge (%d gaps)" % lone_gaps)
check_eq(lone.get_pixel(0, 0).a, 0.0, "round at the corner")
func test_dead_soil_is_the_bare_earth_with_cracks() -> void:
var style := WarmStyle.new()
var earth := style._earth(0, 0).image
var cracked := style._crack_soil(style._earth(0, 0), 0).image
check(cracked.get_data() != earth.get_data(), "cracks in every other tile")
check_eq(style._crack_soil(style._earth(1, 0), 1).image.get_data(), style._earth(1, 0).image.get_data(), "none in the others")
func test_an_animal_in_the_air_tucks_its_legs() -> void:
var style := WarmStyle.new()
var standing := style._grazer_image(false, false).image
var tucked := style._grazer_image(true, false).image
check(standing.get_pixel(5, 14).a > 0.0, "standing on its legs")
check_eq(tucked.get_pixel(5, 14).a, 0.0, "legs tucked in the air")
func test_the_ruin_glows_in_its_doorway() -> void:
var style := WarmStyle.new()
var light := style._ruin_glow().image
check(light.get_pixel(24, 31).a > 0.5, "bright in the middle of the doorway")
check_eq(light.get_pixel(3, 30).a, 0.0, "dark on the walls")
func test_moss_keeps_its_outline_on_both_sides() -> void:
var style := WarmStyle.new()
for variant in 3:
var image := style._moss_image(variant, 0).image
for y in image.get_height():
for x: int in [0, image.get_width() - 1]:
var pixel := image.get_pixel(x, y)
check(pixel.a == 0.0 or pixel == style.palette["outline"], "variant %d at %d, %d: %s" % [variant, x, y, pixel])
+1
View File
@@ -0,0 +1 @@
uid://cbm4wa0gxi4p1
+9
View File
@@ -189,3 +189,12 @@ func test_settings_that_cannot_be_read_right_now_are_not_overwritten() -> void:
check_eq(later.player_name(), "Ada", "the name on disk is the old one")
check_eq(later.last_address(), "192.168.1.20", "the address is kept")
TestFiles.remove_dir(directory)
func test_the_chosen_style_is_kept() -> void:
var directory := TestFiles.temporary_dir("style")
var store := SaveStore.new(directory)
check_eq(store.style(), "", "no style at first")
store.set_style("pastel")
check_eq(SaveStore.new(directory).style(), "pastel", "kept for the next start")
TestFiles.remove_dir(directory)
+61
View File
@@ -0,0 +1,61 @@
extends TestCase
## The showcase of the style samples: how its things move, and that it shows
## every style.
func test_the_plant_grows_from_a_seed_and_starts_again() -> void:
check_eq(Showcase.growth_at(0.0), 0.0, "a seed at first")
check(is_equal_approx(Showcase.growth_at(Showcase.GROW_SECONDS / 2.0), 0.5), "half grown halfway")
check_eq(Showcase.growth_at(Showcase.GROW_SECONDS), 1.0, "grown")
check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS - 0.01), 1.0, "it stays grown for a while")
check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS), 0.0, "then a seed again")
func test_the_animal_hops_then_rests_near_its_home() -> void:
check_eq(Showcase.hop_at(0.0), 0.0, "taking off")
check(is_equal_approx(Showcase.hop_at(Showcase.HOP_SECONDS / 2.0), 0.5), "in the air")
check_eq(Showcase.hop_at(Showcase.HOP_SECONDS), 1.0, "landed")
check_eq(Showcase.hop_at(Showcase.HOP_EVERY - 0.01), 1.0, "resting until the next hop")
for tenth in 100:
var at := Showcase.grazer_at(tenth * 0.1)
check(at.distance_to(Showcase.GRAZER_HOME) <= 1.01, "near home at %.1f s: %s" % [tenth * 0.1, at])
func test_stones_travel_along_the_vine_one_after_another() -> void:
var start := Showcase.stones_at(0.0)
check_eq(start.size(), Showcase.STONES, "every stone")
check_eq(start[0], 0.0, "the first one starts")
for stone in start:
check(stone >= 0.0 and stone < 1.0, "on the vine: %f" % stone)
var later := Showcase.stones_at(1.0)
check(is_equal_approx(later[0], Showcase.STONE_SPEED), "it moves on")
var round_trip := Showcase.stones_at(1.0 / Showcase.STONE_SPEED)
check(is_equal_approx(round_trip[0], 0.0) or is_equal_approx(round_trip[0], 1.0), "and comes round again")
func test_the_ruin_wakes_and_dozes() -> void:
for tenth in 100:
var awake := Showcase.awake_at(tenth * 0.1)
check(awake >= 0.29 and awake <= 1.0, "between dozing and awake: %f" % awake)
func test_the_showcase_shows_every_style_and_names_its_things() -> void:
I18n.install()
InputSetup.install()
var art := Art.new()
var showcase := Showcase.new()
showcase.art = art
tree.root.add_child(showcase)
for index in art.styles.size():
showcase.style_buttons[index].pressed.emit()
check_eq(art.style, art.styles[index], "button %d chooses its style" % (index + 1))
check(showcase.style_buttons[index].disabled, "the chosen one cannot be pressed again")
await tree.process_frame
for key in showcase.caption_keys():
check(tr(key) != key, "a text for %s" % key)
var closed := [0]
showcase.closed.connect(func() -> void: closed[0] += 1)
showcase.back_button.pressed.emit()
check_eq(closed[0], 1, "back leads away")
tree.root.remove_child(showcase)
showcase.free()
+1
View File
@@ -0,0 +1 @@
uid://bx2nja50aj8ju
+38
View File
@@ -0,0 +1,38 @@
extends TestCase
## A map of three by three tiles, row by row, with rock (1) in the middle and
## in the top right corner.
const CODES := [0, 0, 1, 0, 1, 0, 0, 0, 0]
func _flags() -> PackedByteArray:
return TileShape.flags(PackedByteArray(CODES), [1])
func test_flags_mark_the_tiles_of_the_given_codes() -> void:
check_eq(_flags(), PackedByteArray([0, 0, 1, 0, 1, 0, 0, 0, 0]), "rock is marked")
check_eq(TileShape.flags(PackedByteArray([0, 1, 2]), [1, 2]), PackedByteArray([0, 1, 1]), "several codes")
func test_a_mask_holds_the_marked_neighbours_clockwise_from_north() -> void:
check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(1, 1), false), TileShape.NE, "the middle sees the corner")
check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(1, 2), false), TileShape.N, "below the middle")
check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(2, 1), false), TileShape.N | TileShape.W, "right of the middle")
check_eq(TileShape.mask(_flags(), 3, 3, Vector2i(0, 0), false), TileShape.SE, "the top left corner")
func test_beyond_the_edge_neighbours_count_as_marked_if_asked() -> void:
var corner := TileShape.mask(_flags(), 3, 3, Vector2i(0, 0), true)
var outside := TileShape.N | TileShape.NE | TileShape.NW | TileShape.W | TileShape.SW
check_eq(corner, outside | TileShape.SE, "five neighbours lie outside")
func test_variants_stay_the_same_and_differ_between_neighbours() -> void:
check_eq(TileShape.variant(Vector2i(4, 7)), TileShape.variant(Vector2i(4, 7)), "the same every time")
var seen := {}
for y in 4:
for x in 4:
var variant := TileShape.variant(Vector2i(x, y))
check(variant >= 0, "never negative: %d" % variant)
seen[variant] = true
check_eq(seen.size(), 16, "sixteen tiles, sixteen variants")
+1
View File
@@ -0,0 +1 @@
uid://c4ecwk4sri50q
+10
View File
@@ -0,0 +1,10 @@
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := WarmStyle.new()
var colours := {}
for player in 4:
colours[style.player_color(player)] = true
check_eq(colours.size(), 4, "four players, four colours")
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
+1
View File
@@ -0,0 +1 @@
uid://d00aodstjeia5
+54 -25
View File
@@ -1,14 +1,22 @@
extends SceneTree
## Starts the game in a temporary storage folder, walks the figure toward the
## rock beside the clearing, hits it once and saves a screenshot. Lets a
## change to the look be checked without playing by hand. The player's real
## key and save stay untouched.
## rock beside the clearing, hits it once and saves a screenshot. A second
## figure with a name stands beside it. Lets a change to the look be checked
## without playing by hand. The player's real key and save stay untouched.
##
## From the repository root (opens a window for a moment):
## godot --path game -s res://tools/screenshot.gd -- <path of the PNG>
## [--style=NAME] [--showcase]
## --style=NAME draws in that style; --showcase shows the style samples
## instead of the game.
const STORAGE := "user://screenshot-run"
const FRAMES_AFTER_HIT := 30
## Frames between the hit and the picture: the burst of chips is still in
## the air, and the crack is there.
const FRAMES_AFTER_HIT := 8
## Seconds into the showcase at which the picture is taken: the plant is
## nearly grown and the animal in the air.
const SHOWCASE_SECONDS := 5.2
func _initialize() -> void:
@@ -16,37 +24,58 @@ func _initialize() -> void:
func _run() -> void:
var arguments := OS.get_cmdline_user_args()
var output := arguments[0] if arguments.size() > 0 else "user://screenshot.png"
var output := "user://screenshot.png"
var style := ""
var showcase := false
for argument in OS.get_cmdline_user_args():
if argument.begins_with("--style="):
style = argument
elif argument == "--showcase":
showcase = true
else:
output = argument
DirAccess.make_dir_recursive_absolute(STORAGE)
var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = STORAGE
main.command_line = PackedStringArray()
main.command_line = PackedStringArray([style] if not style.is_empty() else [])
root.add_child(main)
main.play(false)
await process_frame
# Four tiles to the right, toward the rock outcrop beside the clearing.
for frame in 48:
main.avatar.walk(Vector2.RIGHT, 1.0 / 60.0)
if showcase:
main.open_showcase()
await process_frame
var session: GwSession = main.session
var rock := session.spawn_point() + Vector2i(7, 0)
main.dig_at(rock)
for frame in FRAMES_AFTER_HIT:
main.showcase.time = SHOWCASE_SECONDS
await process_frame
await process_frame
else:
await _play(main)
var image := root.get_texture().get_image()
var error := image.save_png(output)
print("screenshot %s: %s" % ["saved" if error == OK else "failed", ProjectSettings.globalize_path(output)])
root.remove_child(main)
main.free()
for file in DirAccess.get_files_at(STORAGE):
DirAccess.remove_absolute(STORAGE.path_join(file))
for directory in DirAccess.get_directories_at(STORAGE):
var path := STORAGE.path_join(directory)
for file in DirAccess.get_files_at(path):
DirAccess.remove_absolute(path.path_join(file))
DirAccess.remove_absolute(path)
DirAccess.remove_absolute(STORAGE)
_remove(STORAGE)
quit(0 if error == OK else 1)
## Plays alone: walks four tiles to the right, toward the rock outcrop
## beside the clearing, and hits it once.
func _play(main: Node) -> void:
main.play(false)
await process_frame
for frame in 48:
main.avatar.walk(Vector2.RIGHT, 1.0 / 60.0)
await process_frame
var session: GwSession = main.session
main.figures.place(session.local_player(), main.avatar.position + Vector2(-40, 8))
var rock := session.spawn_point() + Vector2i(7, 0)
main.dig_at(rock)
for frame in FRAMES_AFTER_HIT:
await process_frame
func _remove(path: String) -> void:
for file in DirAccess.get_files_at(path):
DirAccess.remove_absolute(path.path_join(file))
for directory in DirAccess.get_directories_at(path):
_remove(path.path_join(directory))
DirAccess.remove_absolute(path)