Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 446fad7b55 | |||
| 27619b6927 | |||
| 949d5d2489 | |||
| 417cf30a9c | |||
| e53d296080 | |||
| 96db4cd052 | |||
| b4af22b8b9 | |||
| deba758e87 | |||
| d189a358cd | |||
| e14ce3465b | |||
| ab81f66ef0 | |||
| 714d604c58 | |||
| 9f65823f2d | |||
| 69b642cc60 | |||
| 55c09b0f1f | |||
| d2a7973022 | |||
| 9921aba63a | |||
| 5e9836c64a | |||
| 298bdf8c37 | |||
| 22f87f1ff4 | |||
| be22e9f358 | |||
| 7f4bbd9606 | |||
| 56101b15a5 | |||
| 84e786e531 | |||
| acce1ed435 | |||
| bfd237fd5c | |||
| 53ef02ae75 | |||
| d2da661452 | |||
| 51cd4572a8 | |||
| d5debc5bcb | |||
| a43ce66a89 | |||
| 0917450014 | |||
| 6c7c5bffc2 | |||
| 2faa3ccce3 | |||
| 155d90f3a5 | |||
| b48556d1f2 | |||
| b7d5383650 | |||
| a0cb6b12b7 | |||
| f2e4c91082 | |||
| 760bea36fd | |||
| fc42cbb8c8 | |||
| bc34bc61f7 | |||
| 5b76f828e0 | |||
| 0b20dd5459 | |||
| bd8533e189 | |||
| dff213cc61 | |||
| 400556134f | |||
| d986f2a5db | |||
| 8aaeae5cf7 | |||
| fc8d5cb164 | |||
| 842213c574 | |||
| 68d063209c | |||
| 754d7f1e92 | |||
| 68483c648e | |||
| 3761966303 | |||
| ccf9813c80 | |||
| 2607df8a97 | |||
| bd0f588714 | |||
| 5dacbf2222 | |||
| ef728cd100 | |||
| 8573a68901 | |||
| 5be2764276 | |||
| f66fbcdfc0 |
@@ -1,2 +1,4 @@
|
||||
# Same line endings on every machine.
|
||||
* text=auto eol=lf
|
||||
# Windows batch files need CRLF.
|
||||
*.bat text eol=crlf
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
# Rust
|
||||
/target/
|
||||
|
||||
# Godot
|
||||
/game/.godot/
|
||||
|
||||
# Packages built by paket.bat
|
||||
/dist/
|
||||
|
||||
@@ -9,48 +9,127 @@ Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, b
|
||||
| Ordner | Inhalt |
|
||||
|---|---|
|
||||
| `core/` | Rust-Kern: die gesamte gemeinsame Welt. Ohne Zugriff auf die Engine, ohne fremde Crates. |
|
||||
| `bridge/` | Rust-Brücke (GDExtension): macht den Kern für Godot aufrufbar und enthält keine Spielregeln. |
|
||||
| `game/` | Godot-Projekt mit der GDScript-Hülle: Darstellung, Eingabe, Oberfläche, Dateien. |
|
||||
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
|
||||
| `docs/superpowers/plans/` | Umsetzungspläne |
|
||||
| `docs/playtests/` | Fragebögen für Playtests |
|
||||
| `paket/` | Anleitung, die dem Paket für andere Rechner beiliegt |
|
||||
|
||||
Die Brücke zu Godot (`bridge/`), das Godot-Projekt (`game/`) und die Inhaltsdateien (`content/`) kommen mit späteren Plänen dazu.
|
||||
|
||||
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern.
|
||||
Faustregel: Was im Speicherstand steht oder alle Spieler betrifft, gehört in den Kern. Die Hülle darf Gleitkommazahlen nutzen, weil sie nichts an der gemeinsamen Welt ändert; sie schickt nur Befehle.
|
||||
|
||||
## Regeln für den Kern
|
||||
|
||||
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gilt für den Quelltext des Kerns in `core/src/`. Die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen.
|
||||
Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschritt). Das funktioniert nur, wenn der Kern auf jedem Rechner exakt gleich rechnet. Deshalb gelten für den Quelltext des Kerns in `core/src/` diese Regeln (die Tests in `core/tests/` dürfen zum Beispiel Dateien lesen):
|
||||
|
||||
- Nur ganze Zahlen, keine Gleitkommazahlen.
|
||||
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen.
|
||||
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand.
|
||||
- Zufall nur über `rng::Rng` mit fester Startzahl.
|
||||
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`.
|
||||
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Einzige Ausnahme ist `World::set_all_offline`: Es darf nur direkt nach dem Laden eines Speicherstands aufgerufen werden, bevor jemand beigetreten ist.
|
||||
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es in `snapshot.rs` und erhöht `SNAPSHOT_VERSION`.
|
||||
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler (`u64` bei 20 Takten pro Sekunde) laufen praktisch nie über und werden normal addiert. Werte aus geladenen Dateien müssen deshalb beim Laden geprüft werden.
|
||||
- Die Welt ändert sich nur durch einen `Command` in `World::step`. Ausnahmen sind das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet, und die Übernahme der Welt des Hosts mit `World::from_snapshot`, die ein Gast beim Beitritt und nach einer Abweichung macht.
|
||||
- Wer eine Sitzung aufzeichnet, ruft `Recording::step` statt `World::step` auf. So enthält die Aufzeichnung genau die ausgeführten Befehle.
|
||||
- Alles, was zum Zustand der Welt gehört, steht im Snapshot. Wer ein Feld ergänzt, ergänzt es im Kodierer und im Dekodierer seiner Struktur (`snapshot.rs` für die Welt, `player.rs` für Spieler, `inventory.rs` für das Inventar, `map.rs` für die Karte), prüft es beim Dekodieren oder in `check_restorable` und erhöht `SNAPSHOT_VERSION`.
|
||||
- Wo eine Rechnung im Spiel überlaufen kann, steht eine ausdrückliche Wahl: `saturating_`, `wrapping_` oder `checked_`. Taktzähler laufen praktisch nie über und werden normal addiert: Die Welt zählt in `u64`, eine Aufzeichnung in `u32`, was bei 20 Takten pro Sekunde für über sechs Jahre reicht. Takte aus geladenen Dateien begrenzt `MAX_RESTORABLE_TICK`.
|
||||
- Keine fremden Crates im Kern.
|
||||
|
||||
Der Test `core/tests/forbidden_apis.rs` durchsucht den Quelltext des Kerns nach verbotenen Begriffen. Sie dürfen dort auch in Kommentaren nicht vorkommen.
|
||||
Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections::HashMap` oder `std::fs::read`, auch über Aliase und gruppierte Importe. Der Test `core/tests/forbidden_apis.rs` durchsucht zusätzlich den Text nach verbotenen Begriffen; sie dürfen dort auch in Kommentaren nicht vorkommen. Gruppierte Importe aus `std` (`use std::{…}`) sind verboten, weil sie dem Text die Modulnamen verbergen: Jeder Pfad steht in einer eigenen `use`-Zeile. `core/tests/no_dependencies.rs` hält die Abhängigkeiten leer.
|
||||
|
||||
## Gemeinsame Welt im Netz
|
||||
|
||||
- Ein Spieler ist Host, die anderen sind Gäste. Der Host (`core/src/host.rs`) legt fest, welche Befehle in welchem Takt laufen, und schickt jeden Takt an seine Gäste. Jeder Gast (`core/src/guest.rs`) rechnet jeden Takt selbst nach. Allein spielen heißt: Host ohne Gäste.
|
||||
- Ein Gast bekommt beim Beitritt die Welt des Hosts als Snapshot. Einmal pro Sekunde liegt einem Takt die Prüfsumme des Hosts bei. Weicht die Welt des Gastes ab, holt er sich die Welt des Hosts neu, und die Hülle speichert die Aufzeichnung der abgewichenen Welt.
|
||||
- Die Nachrichten stehen in `core/src/protocol.rs`. Wer eine Nachricht ändert, erhöht `PROTOCOL_VERSION`. Der Anfang des Grußes und die ganze Ablehnung ändern sich dabei nie, damit Spiele jeder Version einander noch sagen können, dass sie nicht zusammenpassen; `core/tests/golden.rs` hält beides fest.
|
||||
- Die Regel-Kennung (`core/src/rules.rs`) leitet sich aus den Endzuständen eingefrorener Skripte ab. Rechner mit verschiedener Regel-Kennung spielen nicht zusammen, und Aufzeichnungen anderer Regeln werden abgelehnt. Eine neue Regel braucht deshalb ein neues Skript in `SCRIPTS`, das sie benutzt; bestehende Skripte ändert niemand.
|
||||
- Gäste schicken nur Aktionen, ohne Spielernummer. Der Host macht daraus Befehle für den Spieler der Verbindung und nimmt höchstens `MAX_ACTIONS_PER_TICK` pro Spieler und Takt an.
|
||||
- Ein Gast zählt die Abklingzeit der Spitzhacke in seinen eigenen Takten, die denen des Hosts um die Laufzeit nachlaufen. Kommt sein Schlag an, während die Abklingzeit seines Spielers noch läuft, hält ihn der Host bis zum ersten Takt danach; kommen mehrere, gilt der neueste. Sonst ginge übers Internet etwa jeder fünfte Schlag eines Gastes verloren.
|
||||
- Die Reichweite der Spitzhacke prüft nur die Hülle des eigenen Spielers. Figuren laufen außerhalb des Gleichschritts, also kennt der Kern ihre Position nicht. Ein veränderter Client könnte deshalb aus der Ferne graben. Außerdem stehen die Schlüssel aller Spieler in jedem Snapshot, den ein Gast bekommt; ein veränderter Client könnte sich damit als ein Spieler ausgeben, der gerade nicht online ist. Für ein Koop-Spiel unter Freunden nehmen wir beides hin.
|
||||
|
||||
## Regeln für Brücke und Hülle
|
||||
|
||||
- Die Brücke übersetzt nur. Neue Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
|
||||
- GDScript mit Typangaben schreiben (`var x := …`, Rückgabetypen an jeder Funktion).
|
||||
- Eingabeaktionen legt `game/scripts/input_setup.gd` an, nicht `project.godot`.
|
||||
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt.
|
||||
- Tests, die Dateien schreiben, nutzen `TestFiles.temporary_dir()` (Ordner unter `user://tests`), nie die echten Dateien des Spielers.
|
||||
- Godot erzeugt beim Import zu jedem Skript eine `.uid`-Datei. Sie gehört ins Archiv. Der Ordner `game/.godot/` gehört nicht hinein.
|
||||
- Das Netzwerk liegt in `game/scripts/net_link.gd` (ENet, Port 24680) und `game/scripts/session_link.gd`. Nachrichten des Kerns gehen zuverlässig und in Reihenfolge über Kanal 0, Positionen der Figuren unzuverlässig, aber geordnet über Kanal 1; ENet drosselt sie nie. Eine Verbindung, deren Pakete unbeantwortet bleiben, gilt nach etwa `NetLink.TIMEOUT_MSEC` als verloren; ENet prüft die Grenze nur, wenn eine Wiederholung fällig ist. `NetLink.close_peer` schließt eine Verbindung mit `peer_disconnect_later`, damit wartende Nachrichten wie eine Ablehnung noch ankommen.
|
||||
- Nur der Host speichert. Laden mit F9 geht nur ohne geöffnete Welt („Allein spielen“).
|
||||
- Eine Pause beginnt nur ohne Gäste und lässt sich immer beenden. Nimmt der Host einen Gast an, endet sie; eine bloße Verbindung ohne Gruß beendet sie nicht.
|
||||
- Hält der Kern mit einem internen Fehler an, schließt ein Host seine Welt für Gäste, und ein Gast kehrt ins Menü zurück. Eine angehaltene Sitzung hinterlässt keine Aufzeichnung.
|
||||
- Tests mit Netzwerk lauschen nur auf `127.0.0.1` und lassen das System den Port wählen (Port 0), damit sie nie an einem belegten Port scheitern.
|
||||
|
||||
## Grafik
|
||||
|
||||
- Alles auf dem Spielfeld zeichnet `Art` (`game/scripts/art/art.gd`), jedes Ding unter einer Kennung wie `terrain/soft_rock`. Andere Skripte zeichnen keine eigenen Formen oder Farben aufs Spielfeld; ausgenommen sind Texte wie die Namen über den Figuren.
|
||||
- Die Grafik ist Pixelgrafik aus Code (Konzept 7.4): 16 × 16 Pixel pro Kachel, schräg von vorn gesehen. `PixelStyle` (`game/scripts/art/pixel_style.gd`) zeichnet jede Kennung Pixel für Pixel in ein kleines Bild und hebt es auf. Die Stile `WarmStyle`, `PastelStyle` und `GlowStyle` wählen nur Palette, Umrisse und Leuchten; Warm ist der Standard. F2 wechselt im Spiel, „Stilproben ansehen“ im Menü öffnet die Vitrine, und das Spiel merkt sich die Wahl.
|
||||
- Eine neue Kennung 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 drei Befehle fehlerfrei laufen:
|
||||
Vor jedem Commit müssen alle Befehle fehlerfrei laufen:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo build -p grovewright-bridge
|
||||
godot --headless --path game --import
|
||||
godot --headless --path game -s res://tests/run_tests.gd
|
||||
```
|
||||
|
||||
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Die Fehlermeldung nennt die Versionskonstante, die dann erhöht werden muss. Erst danach wird der festgeschriebene Wert bewusst angepasst.
|
||||
Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust-Erweiterung beim Beenden ab (Exit-Code 139 in der Bash). Der Import ist dann trotzdem erfolgt; ein zweiter Aufruf läuft fehlerfrei durch. `play.bat` wiederholt den Import deshalb einmal.
|
||||
|
||||
Der Test `game/tests/test_two_instances.gd` startet einen zweiten Godot-Prozess als Gast (`game/tools/guest_bot.gd`) und prüft, dass beide Welten gleich bleiben. Er braucht deshalb die gebaute Brücke und den Import wie alle anderen Tests der Hülle.
|
||||
|
||||
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände, Aufzeichnungen oder Nachrichten betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt (`SNAPSHOT_VERSION`, `RECORDING_VERSION` oder `PROTOCOL_VERSION`). Erst danach passt du den festgeschriebenen Wert bewusst an.
|
||||
|
||||
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert. `--style=NAME` wählt einen Stil, `--showcase` zeigt die Vitrine statt des Spiels:
|
||||
|
||||
```bash
|
||||
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei> [--style=NAME] [--showcase]
|
||||
```
|
||||
|
||||
## Spielen
|
||||
|
||||
`play.bat` im Hauptordner baut die Brücke und startet das Spiel. Spielstand, Spielerschlüssel und Aufzeichnungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright`.
|
||||
|
||||
Ein laufendes Spiel hält `target\debug\grovewright_bridge.dll` offen. Ändern sich danach Brücke oder Kern, scheitert `cargo build` mit „Zugriff verweigert (os error 5)“, bis alle Spielfenster geschlossen sind.
|
||||
|
||||
Argumente hinter `play.bat` gehen an das Spiel:
|
||||
|
||||
| Argument | Wirkung |
|
||||
|---|---|
|
||||
| `--profile=NAME` | Eigener Ordner unter `profiles\NAME` für Schlüssel, Spielstand und Aufzeichnungen. Zwei Fenster auf einem Rechner brauchen zwei Profile, sonst sind sie derselbe Spieler. Erlaubt sind bis zu 32 Zeichen aus A bis Z, a bis z, Ziffern, `-` und `_`; einen anderen Namen übergeht das Spiel mit einer Warnung im Log und nimmt das Standardprofil. |
|
||||
| `--name=NAME` | Name des Spielers |
|
||||
| `--solo`, `--host` | Startet ohne Menü allein beziehungsweise mit offener Welt |
|
||||
| `--port=PORT` | Port einer offenen Welt |
|
||||
| `--join=ADRESSE` | Tritt ohne Menü einem Host bei; die Adresse darf auf `:PORT` enden |
|
||||
| `--style=NAME` | Zeichnet im Stil `warm`, `pastel` oder `glow` statt im zuletzt gewählten |
|
||||
|
||||
`play-zweites-fenster.bat` startet ein zweites Fenster mit dem Profil `zweites-fenster`. Die Datei reicht weitere Argumente ebenfalls an das Spiel weiter, etwa `--name=Zweiter`.
|
||||
|
||||
## Paket für andere Rechner
|
||||
|
||||
`paket.bat` baut aus dem ausgecheckten Stand ein Paket, das auf jedem Windows 10 oder 11 mit 64 Bit ohne Git, Rust und Godot läuft: `dist\Grovewright-<Commit>-windows.zip` mit `Grovewright.exe`, `Grovewright.pck`, der Brücke und der Anleitung `paket\LIESMICH.txt`. Ungesicherte Änderungen landen mit im Paket, der Name nennt aber nur den Commit.
|
||||
|
||||
- Für das Paket baut Cargo die Brücke als Release mit statisch gelinkter C-Laufzeit nach `target\x86_64-pc-windows-msvc\release`, damit Mitspieler kein Visual C++ Redistributable brauchen. `game/grovewright.gdextension` zeigt für Release-Exporte dorthin.
|
||||
- Die Exportvoreinstellung „Windows“ steht in `game/export_presets.cfg`. Sie lässt `tests/` und `tools/` weg und nimmt `i18n/*.csv` ausdrücklich mit, weil das Spiel seine Texte zur Laufzeit liest.
|
||||
- Der Export braucht die Exportvorlagen der installierten Godot-Version unter `%APPDATA%\Godot\export_templates\4.7.2.stable`, etwa über „Editor > Exportvorlagen verwalten“ im Godot-Editor.
|
||||
- Pakete und Starts mit `play.bat` spielen zusammen, solange Regel-Kennung und `PROTOCOL_VERSION` gleich sind; sonst lehnt der Host den Beitritt ab. Am sichersten nutzen alle denselben Commit.
|
||||
- Ein ausgepacktes Paket lässt sich mit einem eigenen `APPDATA` prüfen, etwa `Grovewright.exe --headless --quit-after 600 -- --solo`. So schreibt es nicht in die echten Spielerdateien, und Fehler stehen in `<APPDATA>\Godot\app_userdata\Grovewright\logs\godot.log`.
|
||||
|
||||
## Sprache
|
||||
|
||||
- Code, Bezeichner und Kommentare im Code: Englisch.
|
||||
- Dokumente, Pläne, Commit-Nachrichten, Issues und Pull Requests: Deutsch.
|
||||
- Texte im Spiel: Deutsch und Englisch über Übersetzungsdateien.
|
||||
- Texte im Spiel: Deutsch und Englisch über `game/i18n/texts.csv`.
|
||||
|
||||
## Git
|
||||
|
||||
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea).
|
||||
- Archiv: `https://git.race-cave.cloud/steven/Grovewright.git` (Gitea). Issues und Pull Requests sind freigegeben.
|
||||
- Gearbeitet wird auf einem Branch pro Plan, nicht direkt auf `main`.
|
||||
- Nach jedem abgeschlossenen Arbeitsschritt wird committet und hochgeladen. Das ist freigegeben.
|
||||
- Commit-Nachrichten enden mit einer Zeile `Co-Authored-By: Claude <Modell> <noreply@anthropic.com>`, die das Claude-Modell nennt, das den Commit erstellt hat.
|
||||
|
||||
Generated
+155
@@ -2,6 +2,161 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "gdextension-api"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3489a9e7463353467db3cfe3eb12de31c9b6200e4f928bde4959bf5db5b31d2f"
|
||||
|
||||
[[package]]
|
||||
name = "glam"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0"
|
||||
|
||||
[[package]]
|
||||
name = "godot"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e21dfe3a0c8c253cb293a4f915f2359fdb4cf45a4d0557cb1841ea99aae4edfb"
|
||||
dependencies = [
|
||||
"godot-core",
|
||||
"godot-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-bindings"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce6fed360f27cc44ee188246880bb842cdb96bf13aec1d0ec31cccfa50b1cf6"
|
||||
dependencies = [
|
||||
"gdextension-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-cell"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edc4695597110f27de8f0adbdf3afd01692e3596cc89de2aaa2a42d31b93bfc4"
|
||||
|
||||
[[package]]
|
||||
name = "godot-codegen"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08409103fba47d9cb5b64dcb0b679b06ba3153d9c96d5e918bb97f065d553c60"
|
||||
dependencies = [
|
||||
"godot-bindings",
|
||||
"heck",
|
||||
"nanoserde",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ab630dbc7ce797f09eda29025ebadab1b700778c988ad19c47bddc51874a90"
|
||||
dependencies = [
|
||||
"glam",
|
||||
"godot-bindings",
|
||||
"godot-cell",
|
||||
"godot-codegen",
|
||||
"godot-ffi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-ffi"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18c296047c042d74828b2cb2e71bbadd22dd4ff98346c696b6c0a4a5decbf57f"
|
||||
dependencies = [
|
||||
"godot-bindings",
|
||||
"godot-codegen",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "godot-macros"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9ffca7ec0dfad5885a96b3819b3dafc1014c36d2931464f96d9ab0bee87b52e"
|
||||
dependencies = [
|
||||
"godot-bindings",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"venial",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grovewright-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"godot",
|
||||
"grovewright-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grovewright-core"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "nanoserde"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a36fb3a748a4c9736ed7aeb5f2dfc99665247f1ce306abbddb2bf0ba2ac530a4"
|
||||
dependencies = [
|
||||
"nanoserde-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nanoserde-derive"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a846cbc04412cf509efcd8f3694b114fc700a035fb5a37f21517f9fb019f1ebc"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "venial"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a42528baceab6c7784446df2a10f4185078c39bf73dc614f154353f1a6b1229"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core"]
|
||||
members = ["core", "bridge"]
|
||||
|
||||
# The shipped game and the tests must agree on what happens when a number
|
||||
# overflows, so release builds keep the overflow checks that debug builds have.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "grovewright-bridge"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.94"
|
||||
publish = false
|
||||
description = "GDExtension that makes the Grovewright core callable from Godot"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
godot = { version = "=0.5.5", features = ["api-4-7"] }
|
||||
grovewright-core = { path = "../core" }
|
||||
|
||||
[lints.rust]
|
||||
# The MSVC linker reports the import library it writes on every build. That
|
||||
# is expected and not worth a warning.
|
||||
linker_messages = "allow"
|
||||
@@ -0,0 +1,269 @@
|
||||
//! The running session behind `GwSession`, without Godot types, so that its
|
||||
//! handling of internal errors can be tested in plain Rust.
|
||||
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
|
||||
use grovewright_core::guest::{Arrival, Guest, GuestError};
|
||||
use grovewright_core::host::{Divergence, Host, Peer};
|
||||
use grovewright_core::player::{PlayerId, PlayerKey};
|
||||
use grovewright_core::protocol::Action;
|
||||
use grovewright_core::world::{Event, World};
|
||||
|
||||
/// The connection number under which a guest reaches its host. The shell's
|
||||
/// network layer gives the host this number.
|
||||
pub const HOST_PEER: Peer = 1;
|
||||
|
||||
/// The machine's part in the session.
|
||||
pub enum Role {
|
||||
Host(Host),
|
||||
Guest(Guest),
|
||||
}
|
||||
|
||||
/// A session in either role. After an internal error of the core it is
|
||||
/// broken: the world may be half-updated, so nothing runs, saves or records
|
||||
/// any more.
|
||||
pub struct Driver {
|
||||
role: Role,
|
||||
broken: bool,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
/// Hosts `world`, new or loaded.
|
||||
pub fn host(world: World) -> Self {
|
||||
Self {
|
||||
role: Role::Host(Host::new(world)),
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins a host as the player `key` named `name`. The greeting waits in
|
||||
/// the outbox.
|
||||
pub fn guest(key: PlayerKey, name: &str) -> Self {
|
||||
Self {
|
||||
role: Role::Guest(Guest::new(key, name)),
|
||||
broken: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_host(&self) -> bool {
|
||||
matches!(self.role, Role::Host(_))
|
||||
}
|
||||
|
||||
pub fn is_broken(&self) -> bool {
|
||||
self.broken
|
||||
}
|
||||
|
||||
/// The world, once there is one. A guest has it after the host sent it.
|
||||
pub fn world(&self) -> Option<&World> {
|
||||
match &self.role {
|
||||
Role::Host(host) => Some(host.world()),
|
||||
Role::Guest(guest) => guest.world(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The player of this machine, once it is in the world.
|
||||
pub fn local_player(&self) -> Option<PlayerId> {
|
||||
match &self.role {
|
||||
Role::Host(host) => host.local_player(),
|
||||
Role::Guest(guest) => guest.local_player(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on the role unless the session is broken. A panic inside
|
||||
/// marks the session as broken and gives `None`.
|
||||
pub fn guarded<R>(&mut self, f: impl FnOnce(&mut Role) -> R) -> Option<R> {
|
||||
if self.broken {
|
||||
return None;
|
||||
}
|
||||
let role = &mut self.role;
|
||||
match catch_unwind(AssertUnwindSafe(|| f(role))) {
|
||||
Ok(result) => Some(result),
|
||||
Err(_) => {
|
||||
self.broken = true;
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues the entry of the host's own player. Returns false for a guest,
|
||||
/// which enters through its greeting.
|
||||
pub fn join_local(&mut self, key: PlayerKey, name: &str) -> bool {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => {
|
||||
host.join_local(key, name);
|
||||
true
|
||||
}
|
||||
Role::Guest(_) => false,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Carries out an action of this machine's player: the host queues it
|
||||
/// for the next tick, a guest sends it to the host.
|
||||
pub fn act(&mut self, action: Action) {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => host.act_local(action),
|
||||
Role::Guest(guest) => guest.act(action),
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs one tick: the host its next tick, a guest the oldest tick that
|
||||
/// arrived. `None` if a guest has no tick to run or the session broke.
|
||||
pub fn step(&mut self) -> Option<Vec<Event>> {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => Some(host.step()),
|
||||
Role::Guest(guest) => guest.step(),
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Ticks that arrived at a guest and have not run yet.
|
||||
pub fn pending_ticks(&self) -> usize {
|
||||
match &self.role {
|
||||
Role::Host(_) => 0,
|
||||
Role::Guest(guest) => guest.pending_ticks(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands a message to the session. A host learns from `peer` who sent
|
||||
/// it; a guest only hears from its host.
|
||||
pub fn receive(&mut self, peer: Peer, bytes: &[u8]) -> Result<Arrival, GuestError> {
|
||||
self.guarded(|role| match role {
|
||||
Role::Host(host) => {
|
||||
host.receive(peer, bytes);
|
||||
Ok(Arrival::Nothing)
|
||||
}
|
||||
Role::Guest(guest) => guest.receive(bytes),
|
||||
})
|
||||
.unwrap_or(Ok(Arrival::Nothing))
|
||||
}
|
||||
|
||||
/// A guest connected to the host as `peer`.
|
||||
pub fn connect(&mut self, peer: Peer) {
|
||||
if let Role::Host(host) = &mut self.role {
|
||||
host.connect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The connection `peer` of the host closed.
|
||||
pub fn disconnect(&mut self, peer: Peer) {
|
||||
if let Role::Host(host) = &mut self.role {
|
||||
host.disconnect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages to send, as (peer, bytes). A guest's messages go to
|
||||
/// [`HOST_PEER`].
|
||||
pub fn take_messages(&mut self) -> Vec<(Peer, Vec<u8>)> {
|
||||
match &mut self.role {
|
||||
Role::Host(host) => host.take_outbox(),
|
||||
Role::Guest(guest) => guest
|
||||
.take_outbox()
|
||||
.into_iter()
|
||||
.map(|bytes| (HOST_PEER, bytes))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connections the host has given up.
|
||||
pub fn take_closing(&mut self) -> Vec<Peer> {
|
||||
match &mut self.role {
|
||||
Role::Host(host) => host.take_closing(),
|
||||
Role::Guest(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Divergences the host's guests reported.
|
||||
pub fn take_divergences(&mut self) -> Vec<Divergence> {
|
||||
match &mut self.role {
|
||||
Role::Host(host) => host.take_divergences(),
|
||||
Role::Guest(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoded recording of a guest's world that diverged.
|
||||
pub fn take_diverged_recording(&mut self) -> Option<Vec<u8>> {
|
||||
match &mut self.role {
|
||||
Role::Host(_) => None,
|
||||
Role::Guest(guest) => guest.take_diverged_recording(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of the host's accepted guests.
|
||||
pub fn guest_count(&self) -> usize {
|
||||
match &self.role {
|
||||
Role::Host(host) => host.guest_count(),
|
||||
Role::Guest(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The player of the host's guest at `peer`.
|
||||
pub fn player_of(&self, peer: Peer) -> Option<PlayerId> {
|
||||
match &self.role {
|
||||
Role::Host(host) => host.player_of(peer),
|
||||
Role::Guest(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world as a save. Only the host saves: the world belongs to the
|
||||
/// host, and a guest must never overwrite its own save with it.
|
||||
pub fn save(&self) -> Option<Vec<u8>> {
|
||||
match &self.role {
|
||||
Role::Host(host) if !self.broken => Some(host.world().to_snapshot()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The recording of this machine's session.
|
||||
pub fn recording_bytes(&self) -> Option<Vec<u8>> {
|
||||
if self.broken {
|
||||
return None;
|
||||
}
|
||||
match &self.role {
|
||||
Role::Host(host) => Some(host.recording().to_bytes(host.world())),
|
||||
Role::Guest(guest) => Some(guest.recording()?.to_bytes(guest.world()?)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_host_saves_and_records_its_world() {
|
||||
let mut driver = Driver::host(World::new(1));
|
||||
assert!(driver.join_local([1; 16], "Ada"));
|
||||
assert!(driver.step().is_some());
|
||||
assert!(driver.local_player().is_some());
|
||||
assert!(driver.save().is_some());
|
||||
assert!(driver.recording_bytes().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_never_saves_the_world_of_its_host() {
|
||||
let mut driver = Driver::guest([2; 16], "Grace");
|
||||
assert!(!driver.join_local([2; 16], "Grace"));
|
||||
assert_eq!(driver.take_messages().len(), 1, "the greeting");
|
||||
assert!(driver.take_messages().is_empty());
|
||||
assert_eq!(driver.step(), None, "no tick has arrived");
|
||||
assert_eq!(driver.save(), None);
|
||||
assert_eq!(driver.recording_bytes(), None, "no world yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_breaks_the_session_for_good() {
|
||||
// The test harness keeps the message of this expected panic to itself
|
||||
// and shows it only if the test fails.
|
||||
let mut driver = Driver::host(World::new(1));
|
||||
let result = driver.guarded(|_| -> u32 { panic!("an internal error") });
|
||||
|
||||
assert_eq!(result, None);
|
||||
assert!(driver.is_broken());
|
||||
assert_eq!(driver.guarded(|_| 7), None, "nothing runs any more");
|
||||
assert_eq!(driver.step(), None);
|
||||
assert_eq!(driver.save(), None);
|
||||
assert_eq!(driver.recording_bytes(), None);
|
||||
assert!(driver.world().is_some(), "the world can still be shown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Turns events of the core into dictionaries that GDScript can read.
|
||||
|
||||
use godot::prelude::*;
|
||||
use grovewright_core::world::Event;
|
||||
|
||||
/// One event as a dictionary with a `type` entry and the event's fields.
|
||||
pub fn to_dictionary(event: &Event) -> VarDictionary {
|
||||
match event {
|
||||
Event::PlayerJoined { player } => vdict! {
|
||||
"type" => "player_joined",
|
||||
"player" => i64::from(player.0),
|
||||
},
|
||||
Event::PlayerLeft { player } => vdict! {
|
||||
"type" => "player_left",
|
||||
"player" => i64::from(player.0),
|
||||
},
|
||||
Event::JoinRefused { key } => vdict! {
|
||||
"type" => "join_refused",
|
||||
"key" => &PackedByteArray::from(key.as_slice()),
|
||||
},
|
||||
Event::TileHit { player, pos } => vdict! {
|
||||
"type" => "tile_hit",
|
||||
"player" => i64::from(player.0),
|
||||
"x" => i64::from(pos.x),
|
||||
"y" => i64::from(pos.y),
|
||||
},
|
||||
Event::TileChanged { pos } => vdict! {
|
||||
"type" => "tile_changed",
|
||||
"x" => i64::from(pos.x),
|
||||
"y" => i64::from(pos.y),
|
||||
},
|
||||
Event::ItemGained {
|
||||
player,
|
||||
item,
|
||||
count,
|
||||
} => vdict! {
|
||||
"type" => "item_gained",
|
||||
"player" => i64::from(player.0),
|
||||
"item" => i64::from(item.0),
|
||||
"count" => i64::from(*count),
|
||||
},
|
||||
Event::InventoryFull { player } => vdict! {
|
||||
"type" => "inventory_full",
|
||||
"player" => i64::from(player.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! GDExtension that makes the deterministic core callable from Godot.
|
||||
//!
|
||||
//! The bridge only translates. Every rule lives in `grovewright-core`, and
|
||||
//! everything that is shown, heard or typed lives in the Godot project.
|
||||
|
||||
mod driver;
|
||||
mod events;
|
||||
mod session;
|
||||
|
||||
use godot::prelude::*;
|
||||
|
||||
struct GrovewrightExtension;
|
||||
|
||||
// SAFETY: godot-rust requires this marker to register the extension's entry
|
||||
// point. The implementation keeps every default and adds no code.
|
||||
#[gdextension]
|
||||
unsafe impl ExtensionLibrary for GrovewrightExtension {}
|
||||
@@ -0,0 +1,499 @@
|
||||
//! A game session for GDScript, as host or as guest of a shared world.
|
||||
|
||||
use godot::prelude::*;
|
||||
use grovewright_core::guest::Arrival;
|
||||
use grovewright_core::host::Peer;
|
||||
use grovewright_core::inventory::ItemId;
|
||||
use grovewright_core::map::{Pos, Terrain};
|
||||
use grovewright_core::mapgen::spawn_pos;
|
||||
use grovewright_core::player::{PlayerId, PlayerKey};
|
||||
use grovewright_core::protocol::{Action, Refusal};
|
||||
use grovewright_core::world::World;
|
||||
|
||||
use crate::driver::{Driver, HOST_PEER};
|
||||
use crate::events;
|
||||
|
||||
/// A session. The host runs the ticks and GDScript calls
|
||||
/// [`GwSession::step`] once per tick; a guest runs the ticks its host sends,
|
||||
/// one [`GwSession::step`] per tick that arrived. GDScript carries the
|
||||
/// messages between the machines and reads the world back for display.
|
||||
#[derive(GodotClass)]
|
||||
#[class(base = RefCounted, init)]
|
||||
pub struct GwSession {
|
||||
driver: Option<Driver>,
|
||||
last_error: GString,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl GwSession {
|
||||
/// Simulation ticks per second.
|
||||
#[constant]
|
||||
const TICKS_PER_SECOND: i64 = grovewright_core::world::TICKS_PER_SECOND as i64;
|
||||
/// Ticks a player waits between two pickaxe hits.
|
||||
#[constant]
|
||||
const DIG_COOLDOWN_TICKS: i64 = grovewright_core::world::DIG_COOLDOWN_TICKS as i64;
|
||||
#[constant]
|
||||
const TERRAIN_GROUND: i64 = Terrain::Ground as i64;
|
||||
#[constant]
|
||||
const TERRAIN_SOFT_ROCK: i64 = Terrain::SoftRock as i64;
|
||||
#[constant]
|
||||
const TERRAIN_HARD_ROCK: i64 = Terrain::HardRock as i64;
|
||||
#[constant]
|
||||
const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64;
|
||||
#[constant]
|
||||
const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64;
|
||||
/// Most players online at the same time.
|
||||
#[constant]
|
||||
const MAX_ONLINE_PLAYERS: i64 = grovewright_core::player::MAX_ONLINE_PLAYERS as i64;
|
||||
/// The connection number of the host, as a guest sees it.
|
||||
#[constant]
|
||||
const HOST_PEER: i64 = HOST_PEER as i64;
|
||||
/// Reasons a host turns a guest away, as in `refused` notices.
|
||||
#[constant]
|
||||
const REFUSED_OTHER_PROTOCOL: i64 = Refusal::OtherProtocol as i64;
|
||||
#[constant]
|
||||
const REFUSED_OTHER_RULES: i64 = Refusal::OtherRules as i64;
|
||||
#[constant]
|
||||
const REFUSED_WORLD_FULL: i64 = Refusal::WorldFull as i64;
|
||||
#[constant]
|
||||
const REFUSED_TOO_MANY_PLAYERS: i64 = Refusal::TooManyPlayers as i64;
|
||||
#[constant]
|
||||
const REFUSED_ALREADY_PLAYING: i64 = Refusal::AlreadyPlaying as i64;
|
||||
|
||||
/// Hosts a fresh world from `seed`, replacing any running session.
|
||||
#[func]
|
||||
fn start_new(&mut self, seed: i64) {
|
||||
self.driver = Some(Driver::host(World::new(seed as u64)));
|
||||
self.last_error = GString::new();
|
||||
}
|
||||
|
||||
/// Hosts a saved world. Returns false and keeps any running session if
|
||||
/// the save cannot be used; [`GwSession::last_error`] then says why.
|
||||
#[func]
|
||||
fn start_loaded(&mut self, save: PackedByteArray) -> bool {
|
||||
match World::load(save.as_slice()) {
|
||||
Ok(world) => {
|
||||
self.driver = Some(Driver::host(world));
|
||||
self.last_error = GString::new();
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
self.fail(&error.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Becomes the guest of a host as the player `key` (16 bytes) named
|
||||
/// `name`, replacing any running session. The greeting waits in
|
||||
/// [`GwSession::take_messages`]; the world comes with the host's answer.
|
||||
#[func]
|
||||
fn start_guest(&mut self, key: PackedByteArray, name: GString) -> bool {
|
||||
let Some(key) = self.player_key(&key) else {
|
||||
return false;
|
||||
};
|
||||
self.driver = Some(Driver::guest(key, &name.to_string()));
|
||||
self.last_error = GString::new();
|
||||
true
|
||||
}
|
||||
|
||||
/// Why the last call failed, or an empty text.
|
||||
#[func]
|
||||
fn last_error(&self) -> GString {
|
||||
self.last_error.clone()
|
||||
}
|
||||
|
||||
/// True once there is a world: at once for a host, for a guest after
|
||||
/// the host's world arrived.
|
||||
#[func]
|
||||
fn is_running(&self) -> bool {
|
||||
self.world().is_some()
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn is_host(&self) -> bool {
|
||||
self.driver.as_ref().is_some_and(Driver::is_host)
|
||||
}
|
||||
|
||||
/// True after the core stopped with an internal error.
|
||||
#[func]
|
||||
fn is_broken(&self) -> bool {
|
||||
self.driver.as_ref().is_some_and(Driver::is_broken)
|
||||
}
|
||||
|
||||
/// Queues the entry of the host's own player for the next tick. `key`
|
||||
/// identifies the player across sessions and must have 16 bytes. A
|
||||
/// guest enters with [`GwSession::start_guest`] instead.
|
||||
#[func]
|
||||
fn join(&mut self, key: PackedByteArray, name: GString) -> bool {
|
||||
let Some(key) = self.player_key(&key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(driver) = self.driver.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
driver.join_local(key, &name.to_string())
|
||||
}
|
||||
|
||||
/// A pickaxe hit of this machine's player on tile (`x`, `y`): the host
|
||||
/// queues it for the next tick, a guest sends it to the host. Does
|
||||
/// nothing before the player is in the world.
|
||||
#[func]
|
||||
fn dig(&mut self, x: i64, y: i64) {
|
||||
let (Some(driver), Some(pos)) = (self.driver.as_mut(), to_pos(x, y)) else {
|
||||
return;
|
||||
};
|
||||
driver.act(Action::Dig { pos });
|
||||
}
|
||||
|
||||
/// Runs one tick and returns what happened, as dictionaries with a
|
||||
/// `type` entry. A host runs its next tick; a guest runs the oldest tick
|
||||
/// that arrived, or nothing if none is waiting.
|
||||
#[func]
|
||||
fn step(&mut self) -> Array<VarDictionary> {
|
||||
let mut happened = Array::new();
|
||||
let Some(driver) = self.driver.as_mut() else {
|
||||
return happened;
|
||||
};
|
||||
if let Some(events) = driver.step() {
|
||||
for event in &events {
|
||||
happened.push(&events::to_dictionary(event));
|
||||
}
|
||||
}
|
||||
if driver.is_broken() {
|
||||
self.fail("the simulation stopped after an internal error");
|
||||
}
|
||||
happened
|
||||
}
|
||||
|
||||
/// Ticks that arrived at a guest and have not run yet.
|
||||
#[func]
|
||||
fn pending_ticks(&self) -> i64 {
|
||||
self.driver
|
||||
.as_ref()
|
||||
.map_or(0, |driver| driver.pending_ticks() as i64)
|
||||
}
|
||||
|
||||
/// Hands over a message that arrived from `peer`. A guest answers with
|
||||
/// notices: `world_received` (entry `resync`), `refused` (entry
|
||||
/// `reason`, a `REFUSED_*` constant) or `protocol_error`, after which
|
||||
/// [`GwSession::last_error`] says what went wrong.
|
||||
#[func]
|
||||
fn receive(&mut self, peer: i64, bytes: PackedByteArray) -> Array<VarDictionary> {
|
||||
let mut notices = Array::new();
|
||||
let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) else {
|
||||
return notices;
|
||||
};
|
||||
match driver.receive(peer, bytes.as_slice()) {
|
||||
Ok(Arrival::Nothing) => {}
|
||||
Ok(Arrival::World { replaced }) => notices.push(&vdict! {
|
||||
"type" => "world_received",
|
||||
"resync" => replaced,
|
||||
}),
|
||||
Ok(Arrival::Refused(refusal)) => notices.push(&vdict! {
|
||||
"type" => "refused",
|
||||
"reason" => i64::from(refusal.code()),
|
||||
}),
|
||||
Err(error) => {
|
||||
self.fail(&error.to_string());
|
||||
notices.push(&vdict! { "type" => "protocol_error" });
|
||||
}
|
||||
}
|
||||
if self.is_broken() {
|
||||
self.fail("the simulation stopped after an internal error");
|
||||
}
|
||||
notices
|
||||
}
|
||||
|
||||
/// The host learned that a guest connected as `peer`.
|
||||
#[func]
|
||||
fn connect_peer(&mut self, peer: i64) {
|
||||
if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) {
|
||||
driver.connect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host learned that the connection `peer` closed.
|
||||
#[func]
|
||||
fn disconnect_peer(&mut self, peer: i64) {
|
||||
if let (Some(driver), Some(peer)) = (self.driver.as_mut(), to_peer(peer)) {
|
||||
driver.disconnect(peer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages to send, oldest first, as dictionaries with the entries
|
||||
/// `peer` and `bytes`. They must arrive reliably and in this order.
|
||||
#[func]
|
||||
fn take_messages(&mut self) -> Array<VarDictionary> {
|
||||
let mut messages = Array::new();
|
||||
if let Some(driver) = self.driver.as_mut() {
|
||||
for (peer, bytes) in driver.take_messages() {
|
||||
messages.push(&vdict! {
|
||||
"peer" => i64::from(peer),
|
||||
"bytes" => &PackedByteArray::from(bytes.as_slice()),
|
||||
});
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
/// Connections the host has given up. Close them after sending the
|
||||
/// messages that are waiting for them.
|
||||
#[func]
|
||||
fn take_closing(&mut self) -> PackedInt64Array {
|
||||
let peers: Vec<i64> = self
|
||||
.driver
|
||||
.as_mut()
|
||||
.map(|driver| driver.take_closing().into_iter().map(i64::from).collect())
|
||||
.unwrap_or_default();
|
||||
PackedInt64Array::from(peers.as_slice())
|
||||
}
|
||||
|
||||
/// Divergences the host's guests reported, as dictionaries with the
|
||||
/// entries `peer`, `tick` and `hash` (16 hexadecimal digits).
|
||||
#[func]
|
||||
fn take_divergences(&mut self) -> Array<VarDictionary> {
|
||||
let mut reports = Array::new();
|
||||
if let Some(driver) = self.driver.as_mut() {
|
||||
for divergence in driver.take_divergences() {
|
||||
reports.push(&vdict! {
|
||||
"peer" => i64::from(divergence.peer),
|
||||
"tick" => divergence.tick as i64,
|
||||
"hash" => format!("{:016x}", divergence.hash),
|
||||
});
|
||||
}
|
||||
}
|
||||
reports
|
||||
}
|
||||
|
||||
/// The recording of a guest's world that diverged from the host's, or
|
||||
/// empty. Taking it empties it.
|
||||
#[func]
|
||||
fn take_diverged_recording(&mut self) -> PackedByteArray {
|
||||
self.driver
|
||||
.as_mut()
|
||||
.and_then(Driver::take_diverged_recording)
|
||||
.map_or_else(PackedByteArray::new, |bytes| {
|
||||
PackedByteArray::from(bytes.as_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// Number of the host's guests.
|
||||
#[func]
|
||||
fn guest_count(&self) -> i64 {
|
||||
self.driver
|
||||
.as_ref()
|
||||
.map_or(0, |driver| driver.guest_count() as i64)
|
||||
}
|
||||
|
||||
/// The player of the host's guest at `peer`, or -1.
|
||||
#[func]
|
||||
fn player_of_peer(&self, peer: i64) -> i64 {
|
||||
let (Some(driver), Some(peer)) = (self.driver.as_ref(), to_peer(peer)) else {
|
||||
return -1;
|
||||
};
|
||||
driver
|
||||
.player_of(peer)
|
||||
.map_or(-1, |player| i64::from(player.0))
|
||||
}
|
||||
|
||||
/// Number of ticks the world has run.
|
||||
#[func]
|
||||
fn tick(&self) -> i64 {
|
||||
self.world().map_or(0, |world| world.tick() as i64)
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn map_width(&self) -> i64 {
|
||||
self.world()
|
||||
.map_or(0, |world| i64::from(world.map().width()))
|
||||
}
|
||||
|
||||
#[func]
|
||||
fn map_height(&self) -> i64 {
|
||||
self.world()
|
||||
.map_or(0, |world| i64::from(world.map().height()))
|
||||
}
|
||||
|
||||
/// The terrain of every tile, row by row (`TERRAIN_*` constants).
|
||||
#[func]
|
||||
fn terrain_codes(&self) -> PackedByteArray {
|
||||
self.tile_bytes(|terrain, _| terrain as u8)
|
||||
}
|
||||
|
||||
/// The hits every tile has taken, row by row.
|
||||
#[func]
|
||||
fn damage_codes(&self) -> PackedByteArray {
|
||||
self.tile_bytes(|_, damage| damage)
|
||||
}
|
||||
|
||||
/// The terrain of tile (`x`, `y`), or -1 outside the map.
|
||||
#[func]
|
||||
fn terrain_at(&self, x: i64, y: i64) -> i64 {
|
||||
self.tile(x, y).map_or(-1, |(terrain, _)| terrain as i64)
|
||||
}
|
||||
|
||||
/// Whether a figure can stand on tile (`x`, `y`).
|
||||
#[func]
|
||||
fn is_walkable(&self, x: i64, y: i64) -> bool {
|
||||
self.tile(x, y)
|
||||
.is_some_and(|(terrain, _)| terrain.is_walkable())
|
||||
}
|
||||
|
||||
/// The tile where figures appear.
|
||||
#[func]
|
||||
fn spawn_point(&self) -> Vector2i {
|
||||
self.world().map_or(Vector2i::ZERO, |world| {
|
||||
let spawn = spawn_pos(world.map());
|
||||
Vector2i::new(spawn.x, spawn.y)
|
||||
})
|
||||
}
|
||||
|
||||
/// Index of this machine's player in the world, or -1 before it is in.
|
||||
#[func]
|
||||
fn local_player(&self) -> i64 {
|
||||
self.driver
|
||||
.as_ref()
|
||||
.and_then(Driver::local_player)
|
||||
.map_or(-1, |id| i64::from(id.0))
|
||||
}
|
||||
|
||||
/// The players who are online, by index.
|
||||
#[func]
|
||||
fn online_players(&self) -> PackedInt64Array {
|
||||
let online: Vec<i64> = self.world().map_or_else(Vec::new, |world| {
|
||||
world
|
||||
.players()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, player)| player.online)
|
||||
.map(|(index, _)| index as i64)
|
||||
.collect()
|
||||
});
|
||||
PackedInt64Array::from(online.as_slice())
|
||||
}
|
||||
|
||||
/// The name of player `player`, or an empty text.
|
||||
#[func]
|
||||
fn player_name(&self, player: i64) -> GString {
|
||||
let name = u8::try_from(player)
|
||||
.ok()
|
||||
.and_then(|index| self.world()?.player(PlayerId(index)))
|
||||
.map_or("", |player| player.name.as_str());
|
||||
GString::from(name)
|
||||
}
|
||||
|
||||
/// How many of `item` the local player carries.
|
||||
#[func]
|
||||
fn item_count(&self, item: i64) -> i64 {
|
||||
let (Some(world), Some(player), Ok(item)) = (
|
||||
self.world(),
|
||||
self.driver.as_ref().and_then(Driver::local_player),
|
||||
u16::try_from(item),
|
||||
) else {
|
||||
return 0;
|
||||
};
|
||||
world
|
||||
.player(player)
|
||||
.map_or(0, |player| i64::from(player.inventory.count(ItemId(item))))
|
||||
}
|
||||
|
||||
/// The world as a save, or empty. Only a healthy host saves.
|
||||
#[func]
|
||||
fn save(&self) -> PackedByteArray {
|
||||
self.driver
|
||||
.as_ref()
|
||||
.and_then(Driver::save)
|
||||
.map_or_else(PackedByteArray::new, |bytes| {
|
||||
PackedByteArray::from(bytes.as_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// The recording of this machine's session, or empty if there is no
|
||||
/// healthy session with a world.
|
||||
#[func]
|
||||
fn recording_bytes(&self) -> PackedByteArray {
|
||||
self.driver
|
||||
.as_ref()
|
||||
.and_then(Driver::recording_bytes)
|
||||
.map_or_else(PackedByteArray::new, |bytes| {
|
||||
PackedByteArray::from(bytes.as_slice())
|
||||
})
|
||||
}
|
||||
|
||||
/// The checksum of the world state, as 16 hexadecimal digits.
|
||||
#[func]
|
||||
fn state_hash_hex(&self) -> GString {
|
||||
self.world().map_or_else(GString::new, |world| {
|
||||
GString::from(format!("{:016x}", world.state_hash()).as_str())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl GwSession {
|
||||
fn world(&self) -> Option<&World> {
|
||||
self.driver.as_ref()?.world()
|
||||
}
|
||||
|
||||
fn fail(&mut self, reason: &str) {
|
||||
self.last_error = GString::from(reason);
|
||||
}
|
||||
|
||||
/// A player key from GDScript bytes, or `None` with a reason.
|
||||
fn player_key(&mut self, bytes: &PackedByteArray) -> Option<PlayerKey> {
|
||||
match PlayerKey::try_from(bytes.as_slice()) {
|
||||
Ok(key) => Some(key),
|
||||
Err(_) => {
|
||||
self.fail("a player key has 16 bytes");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
|
||||
let tile = self.world()?.map().get(to_pos(x, y)?)?;
|
||||
Some((tile.terrain, tile.damage))
|
||||
}
|
||||
|
||||
fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray {
|
||||
let Some(world) = self.world() else {
|
||||
return PackedByteArray::new();
|
||||
};
|
||||
let bytes: Vec<u8> = world
|
||||
.map()
|
||||
.tiles()
|
||||
.iter()
|
||||
.map(|tile| byte(tile.terrain, tile.damage))
|
||||
.collect();
|
||||
PackedByteArray::from(bytes.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile position from GDScript integers, or `None` if it cannot exist.
|
||||
fn to_pos(x: i64, y: i64) -> Option<Pos> {
|
||||
Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?))
|
||||
}
|
||||
|
||||
/// A connection number from GDScript, or `None` if it cannot exist.
|
||||
fn to_peer(peer: i64) -> Option<Peer> {
|
||||
Peer::try_from(peer).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn positions_outside_the_i32_range_are_refused() {
|
||||
assert_eq!(to_pos(3, -4), Some(Pos::new(3, -4)));
|
||||
assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None);
|
||||
assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_numbers_outside_the_u32_range_are_refused() {
|
||||
assert_eq!(to_peer(1), Some(1));
|
||||
assert_eq!(to_peer(-1), None);
|
||||
assert_eq!(to_peer(i64::from(u32::MAX) + 1), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Paths the deterministic core must never use. Clippy resolves paths, so a
|
||||
# listed path is caught under an alias or in a grouped import as well, for
|
||||
# example `fs::read` after `use std::{fs, env}`. Only the paths listed below
|
||||
# are checked. Everything else is the job of the text search in
|
||||
# core/tests/forbidden_apis.rs, which forbids grouped imports from `std`
|
||||
# because they hide module names from it.
|
||||
|
||||
disallowed-types = [
|
||||
{ path = "std::collections::HashMap", reason = "iteration order differs between runs; use BTreeMap or Vec" },
|
||||
{ path = "std::collections::HashSet", reason = "iteration order differs between runs; use BTreeSet or Vec" },
|
||||
{ path = "std::hash::RandomState", reason = "seeded differently in every run" },
|
||||
{ path = "std::hash::DefaultHasher", reason = "seeded differently in every run" },
|
||||
{ path = "std::time::Instant", reason = "the core must not read the clock" },
|
||||
{ path = "std::time::SystemTime", reason = "the core must not read the clock" },
|
||||
]
|
||||
|
||||
disallowed-methods = [
|
||||
{ path = "std::env::var", reason = "the core must not depend on the environment" },
|
||||
{ path = "std::env::args", reason = "the core must not depend on the environment" },
|
||||
{ path = "std::thread::spawn", reason = "the core must stay single-threaded" },
|
||||
{ path = "std::fs::read", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::read_to_string", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::write", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::File::open", reason = "the core must not touch files" },
|
||||
{ path = "std::fs::File::create", reason = "the core must not touch files" },
|
||||
{ path = "std::process::exit", reason = "the core must not end the process" },
|
||||
]
|
||||
@@ -0,0 +1,231 @@
|
||||
//! A guest's side of a shared world. The guest gets the host's world once
|
||||
//! and then runs every tick the host sends, with the same commands, so that
|
||||
//! both worlds stay the same. Checksums from the host show whether they do.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::DecodeError;
|
||||
use crate::command::Command;
|
||||
use crate::player::{clamp_name, PlayerId, PlayerKey};
|
||||
use crate::protocol::{Action, Refusal, ToGuest, ToHost};
|
||||
use crate::replay::Recording;
|
||||
use crate::rules::rules_id;
|
||||
use crate::snapshot::SnapshotError;
|
||||
use crate::world::{Event, World};
|
||||
|
||||
/// Reason a guest cannot go on with the session.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum GuestError {
|
||||
/// A message from the host could not be read.
|
||||
Unreadable(DecodeError),
|
||||
/// The world the host sent could not be restored.
|
||||
BadWorld(SnapshotError),
|
||||
/// The host sent a tick before it sent its world.
|
||||
TickBeforeWorld,
|
||||
}
|
||||
|
||||
impl fmt::Display for GuestError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
GuestError::Unreadable(error) => write!(f, "unreadable message from the host: {error}"),
|
||||
GuestError::BadWorld(error) => write!(f, "the host's world is unusable: {error}"),
|
||||
GuestError::TickBeforeWorld => write!(f, "the host sent a tick before its world"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GuestError {}
|
||||
|
||||
/// What a message from the host changed, apart from queued ticks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Arrival {
|
||||
/// Nothing the shell has to react to right away.
|
||||
Nothing,
|
||||
/// The host's world arrived. `replaced` is true when it replaced a world
|
||||
/// that had diverged.
|
||||
World { replaced: bool },
|
||||
/// The host turned the guest away.
|
||||
Refused(Refusal),
|
||||
}
|
||||
|
||||
/// A tick from the host that has not run yet.
|
||||
struct PendingTick {
|
||||
tick: u64,
|
||||
commands: Vec<Command>,
|
||||
check: Option<u64>,
|
||||
}
|
||||
|
||||
/// The world the guest follows, with the recording of everything it ran.
|
||||
struct Following {
|
||||
world: World,
|
||||
recording: Recording,
|
||||
}
|
||||
|
||||
/// The guest side of a shared world.
|
||||
pub struct Guest {
|
||||
key: PlayerKey,
|
||||
following: Option<Following>,
|
||||
/// True after a divergence, until the host's world arrives again.
|
||||
diverged: bool,
|
||||
pending: VecDeque<PendingTick>,
|
||||
outbox: Vec<Vec<u8>>,
|
||||
diverged_recording: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Guest {
|
||||
/// A guest that greets the host as the player `key` named `name`. The
|
||||
/// greeting waits in the outbox.
|
||||
pub fn new(key: PlayerKey, name: &str) -> Self {
|
||||
let hello = ToHost::Hello {
|
||||
rules: rules_id(),
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
};
|
||||
Self {
|
||||
key,
|
||||
following: None,
|
||||
diverged: false,
|
||||
pending: VecDeque::new(),
|
||||
outbox: vec![hello.encode()],
|
||||
diverged_recording: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The world, once the host has sent it.
|
||||
pub fn world(&self) -> Option<&World> {
|
||||
self.following.as_ref().map(|following| &following.world)
|
||||
}
|
||||
|
||||
/// The recording of every tick this guest ran since the host's world
|
||||
/// last arrived.
|
||||
pub fn recording(&self) -> Option<&Recording> {
|
||||
self.following
|
||||
.as_ref()
|
||||
.map(|following| &following.recording)
|
||||
}
|
||||
|
||||
/// The guest's player, once its join has run.
|
||||
pub fn local_player(&self) -> Option<PlayerId> {
|
||||
let world = self.world()?;
|
||||
let id = world.find_player(&self.key)?;
|
||||
world.player(id)?.online.then_some(id)
|
||||
}
|
||||
|
||||
/// Handles a message from the host.
|
||||
pub fn receive(&mut self, bytes: &[u8]) -> Result<Arrival, GuestError> {
|
||||
match ToGuest::decode(bytes).map_err(GuestError::Unreadable)? {
|
||||
ToGuest::World { snapshot } => {
|
||||
let world = World::from_snapshot(&snapshot).map_err(GuestError::BadWorld)?;
|
||||
let replaced = self.diverged;
|
||||
if let Some(old) = self.following.take() {
|
||||
if replaced {
|
||||
self.diverged_recording = Some(old.recording.to_bytes(&old.world));
|
||||
}
|
||||
}
|
||||
// Ticks sent before this world are part of it already.
|
||||
let tick = world.tick();
|
||||
self.pending.retain(|pending| pending.tick >= tick);
|
||||
self.following = Some(Following {
|
||||
recording: Recording::start(&world),
|
||||
world,
|
||||
});
|
||||
self.diverged = false;
|
||||
Ok(Arrival::World { replaced })
|
||||
}
|
||||
ToGuest::Refused(refusal) => Ok(Arrival::Refused(refusal)),
|
||||
ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
} => {
|
||||
if self.following.is_none() {
|
||||
return Err(GuestError::TickBeforeWorld);
|
||||
}
|
||||
self.pending.push_back(PendingTick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
});
|
||||
Ok(Arrival::Nothing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks that arrived and have not run yet.
|
||||
pub fn pending_ticks(&self) -> usize {
|
||||
self.pending.len()
|
||||
}
|
||||
|
||||
/// Runs the oldest tick that arrived and returns what happened, or
|
||||
/// `None` if there is none to run. After a divergence no tick runs until
|
||||
/// the host's world arrives again.
|
||||
pub fn step(&mut self) -> Option<Vec<Event>> {
|
||||
if self.diverged {
|
||||
return None;
|
||||
}
|
||||
let following = self.following.as_mut()?;
|
||||
while let Some(next) = self.pending.pop_front() {
|
||||
let expected = following.world.tick();
|
||||
if next.tick < expected {
|
||||
continue;
|
||||
}
|
||||
if next.tick > expected {
|
||||
// A tick is missing: this world can no longer follow.
|
||||
self.diverge();
|
||||
return None;
|
||||
}
|
||||
let events = following
|
||||
.recording
|
||||
.step(&mut following.world, &next.commands);
|
||||
if next
|
||||
.check
|
||||
.is_some_and(|check| check != following.world.state_hash())
|
||||
{
|
||||
self.diverge();
|
||||
}
|
||||
return Some(events);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Reports the current state to the host and stops running ticks until
|
||||
/// the host's world arrives again.
|
||||
fn diverge(&mut self) {
|
||||
let Some(world) = self.world() else {
|
||||
return;
|
||||
};
|
||||
let report = ToHost::Diverged {
|
||||
tick: world.tick(),
|
||||
hash: world.state_hash(),
|
||||
};
|
||||
self.outbox.push(report.encode());
|
||||
self.diverged = true;
|
||||
}
|
||||
|
||||
/// True after a divergence, until the host's world arrives again.
|
||||
pub fn has_diverged(&self) -> bool {
|
||||
self.diverged
|
||||
}
|
||||
|
||||
/// Asks the host to carry out an action of the guest's player. Does
|
||||
/// nothing before the player is in the world or while the world is
|
||||
/// diverged.
|
||||
pub fn act(&mut self, action: Action) {
|
||||
if self.diverged || self.local_player().is_none() {
|
||||
return;
|
||||
}
|
||||
self.outbox.push(ToHost::Act(action).encode());
|
||||
}
|
||||
|
||||
/// Messages for the host, in the order they were made.
|
||||
pub fn take_outbox(&mut self) -> Vec<Vec<u8>> {
|
||||
std::mem::take(&mut self.outbox)
|
||||
}
|
||||
|
||||
/// The encoded recording of the world that diverged last, kept so that
|
||||
/// the divergence can be replayed. Empty after it has been taken.
|
||||
pub fn take_diverged_recording(&mut self) -> Option<Vec<u8>> {
|
||||
self.diverged_recording.take()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! The host's side of a shared world: the world, its recording and the
|
||||
//! guests connected to it.
|
||||
//!
|
||||
//! The host runs the ticks. Its own player's actions and the actions its
|
||||
//! guests send become commands of the next tick, and every tick goes out to
|
||||
//! every accepted guest. A solo game is a host without guests.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
|
||||
use crate::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS};
|
||||
use crate::replay::Recording;
|
||||
use crate::rules::rules_id;
|
||||
use crate::world::{Event, World};
|
||||
|
||||
/// Identifies a connection. The shell chooses the numbers.
|
||||
pub type Peer = u32;
|
||||
|
||||
/// Most actions one player may take per tick. Further actions in the same
|
||||
/// tick are dropped, so that no guest can flood the world with commands.
|
||||
pub const MAX_ACTIONS_PER_TICK: u32 = 4;
|
||||
|
||||
/// A guest reported that its state differs from the host's checksum.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Divergence {
|
||||
pub peer: Peer,
|
||||
/// The guest's tick when it noticed.
|
||||
pub tick: u64,
|
||||
/// The hash of the guest's state at that tick.
|
||||
pub hash: u64,
|
||||
}
|
||||
|
||||
/// Where the connection of a guest stands.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Link {
|
||||
/// Connected, but the guest has not greeted yet.
|
||||
Greeting,
|
||||
/// Accepted. The guest has the world, and its join runs in the next tick.
|
||||
Joining { key: PlayerKey },
|
||||
/// In the world as `player`. `actions` counts its actions in this tick.
|
||||
/// `held` is the newest action that arrived while the player's cooldown
|
||||
/// still ran; it goes into the first tick after the cooldown.
|
||||
Playing {
|
||||
player: PlayerId,
|
||||
actions: u32,
|
||||
held: Option<Action>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The host of a shared world.
|
||||
pub struct Host {
|
||||
world: World,
|
||||
recording: Recording,
|
||||
rules: u64,
|
||||
local_key: Option<PlayerKey>,
|
||||
local_actions: u32,
|
||||
/// Commands for the next tick, in the order they arrived.
|
||||
inbox: Vec<Command>,
|
||||
links: BTreeMap<Peer, Link>,
|
||||
outbox: Vec<(Peer, Vec<u8>)>,
|
||||
closing: Vec<Peer>,
|
||||
divergences: Vec<Divergence>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Hosts `world`, new or loaded. Nobody is in it yet.
|
||||
pub fn new(world: World) -> Self {
|
||||
Self {
|
||||
recording: Recording::start(&world),
|
||||
world,
|
||||
rules: rules_id(),
|
||||
local_key: None,
|
||||
local_actions: 0,
|
||||
inbox: Vec::new(),
|
||||
links: BTreeMap::new(),
|
||||
outbox: Vec::new(),
|
||||
closing: Vec::new(),
|
||||
divergences: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn world(&self) -> &World {
|
||||
&self.world
|
||||
}
|
||||
|
||||
/// The recording of everything that happened since the world was hosted.
|
||||
pub fn recording(&self) -> &Recording {
|
||||
&self.recording
|
||||
}
|
||||
|
||||
/// Queues the entry of the host's own player for the next tick.
|
||||
pub fn join_local(&mut self, key: PlayerKey, name: &str) {
|
||||
self.local_key = Some(key);
|
||||
self.inbox.push(Command::Join {
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
});
|
||||
}
|
||||
|
||||
/// The host's own player, once it is in the world.
|
||||
pub fn local_player(&self) -> Option<PlayerId> {
|
||||
online_player(&self.world, &self.local_key?)
|
||||
}
|
||||
|
||||
/// Queues an action of the host's own player for the next tick. Does
|
||||
/// nothing before the player is in the world.
|
||||
pub fn act_local(&mut self, action: Action) {
|
||||
let Some(player) = self.local_player() else {
|
||||
return;
|
||||
};
|
||||
if self.local_actions < MAX_ACTIONS_PER_TICK {
|
||||
self.local_actions += 1;
|
||||
self.inbox.push(action.command(player));
|
||||
}
|
||||
}
|
||||
|
||||
/// A guest connected as `peer`. Its first message must be a greeting.
|
||||
pub fn connect(&mut self, peer: Peer) {
|
||||
self.links.entry(peer).or_insert(Link::Greeting);
|
||||
}
|
||||
|
||||
/// The connection `peer` closed. A guest in the world leaves it in the
|
||||
/// next tick; an accepted guest whose join has not run yet never enters.
|
||||
pub fn disconnect(&mut self, peer: Peer) {
|
||||
match self.links.remove(&peer) {
|
||||
Some(Link::Joining { key }) => self.inbox.retain(
|
||||
|command| !matches!(command, Command::Join { key: joining, .. } if *joining == key),
|
||||
),
|
||||
Some(Link::Playing { player, .. }) => self.inbox.push(Command::Leave { player }),
|
||||
Some(Link::Greeting) | None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a message that arrived from `peer`. A guest that breaks the
|
||||
/// protocol is closed; see [`Host::take_closing`].
|
||||
pub fn receive(&mut self, peer: Peer, bytes: &[u8]) {
|
||||
let Some(link) = self.links.get(&peer).cloned() else {
|
||||
return;
|
||||
};
|
||||
match (link, ToHost::decode(bytes)) {
|
||||
(Link::Greeting, Ok(ToHost::Hello { rules, key, name })) => {
|
||||
self.greet(peer, rules, key, &name);
|
||||
}
|
||||
(Link::Greeting, Ok(ToHost::ForeignHello { .. })) => {
|
||||
self.refuse(peer, Refusal::OtherProtocol);
|
||||
}
|
||||
(
|
||||
Link::Playing {
|
||||
player,
|
||||
actions,
|
||||
held,
|
||||
},
|
||||
Ok(ToHost::Act(action)),
|
||||
) => {
|
||||
if actions < MAX_ACTIONS_PER_TICK {
|
||||
// A guest counts the cooldown in its own ticks, which
|
||||
// lag the host's by the round trip. An action that
|
||||
// arrives too early would be lost, so it waits.
|
||||
let wait = held.is_some() || cooling_down(&self.world, player);
|
||||
if !wait {
|
||||
self.inbox.push(action.command(player));
|
||||
}
|
||||
self.links.insert(
|
||||
peer,
|
||||
Link::Playing {
|
||||
player,
|
||||
actions: actions + 1,
|
||||
held: if wait { Some(action) } else { None },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// The guest acted before its join ran. It cannot know its player
|
||||
// yet, so the action is dropped.
|
||||
(Link::Joining { .. }, Ok(ToHost::Act(_))) => {}
|
||||
(Link::Joining { .. } | Link::Playing { .. }, Ok(ToHost::Diverged { tick, hash })) => {
|
||||
self.divergences.push(Divergence { peer, tick, hash });
|
||||
self.send(
|
||||
peer,
|
||||
&ToGuest::World {
|
||||
snapshot: self.world.to_snapshot(),
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
self.disconnect(peer);
|
||||
self.closing.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn greet(&mut self, peer: Peer, rules: u64, key: PlayerKey, name: &str) {
|
||||
if let Err(refusal) = self.admission(rules, &key) {
|
||||
self.refuse(peer, refusal);
|
||||
return;
|
||||
}
|
||||
self.send(
|
||||
peer,
|
||||
&ToGuest::World {
|
||||
snapshot: self.world.to_snapshot(),
|
||||
},
|
||||
);
|
||||
self.inbox.push(Command::Join {
|
||||
key,
|
||||
name: clamp_name(name),
|
||||
});
|
||||
self.links.insert(peer, Link::Joining { key });
|
||||
}
|
||||
|
||||
/// Whether a guest with `rules` and `key` may join now. Joins that are
|
||||
/// queued for the next tick count as if they had run.
|
||||
fn admission(&self, rules: u64, key: &PlayerKey) -> Result<(), Refusal> {
|
||||
if rules != self.rules {
|
||||
return Err(Refusal::OtherRules);
|
||||
}
|
||||
let queued: Vec<&PlayerKey> = self
|
||||
.inbox
|
||||
.iter()
|
||||
.filter_map(|command| match command {
|
||||
Command::Join { key, .. } => Some(key),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if online_player(&self.world, key).is_some() || queued.contains(&key) {
|
||||
return Err(Refusal::AlreadyPlaying);
|
||||
}
|
||||
let online = self
|
||||
.world
|
||||
.players()
|
||||
.iter()
|
||||
.filter(|player| player.online)
|
||||
.count();
|
||||
let entering = queued
|
||||
.iter()
|
||||
.filter(|queued| online_player(&self.world, queued).is_none())
|
||||
.count();
|
||||
if online + entering >= MAX_ONLINE_PLAYERS {
|
||||
return Err(Refusal::WorldFull);
|
||||
}
|
||||
let new = queued
|
||||
.iter()
|
||||
.filter(|queued| self.world.find_player(queued).is_none())
|
||||
.count();
|
||||
if self.world.find_player(key).is_none()
|
||||
&& self.world.players().len() + new >= MAX_KNOWN_PLAYERS
|
||||
{
|
||||
return Err(Refusal::TooManyPlayers);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refuse(&mut self, peer: Peer, refusal: Refusal) {
|
||||
self.send(peer, &ToGuest::Refused(refusal));
|
||||
self.links.remove(&peer);
|
||||
self.closing.push(peer);
|
||||
}
|
||||
|
||||
fn send(&mut self, peer: Peer, message: &ToGuest) {
|
||||
self.outbox.push((peer, message.encode()));
|
||||
}
|
||||
|
||||
/// Runs the next tick with every queued command, sends it to every
|
||||
/// accepted guest and returns what happened.
|
||||
pub fn step(&mut self) -> Vec<Event> {
|
||||
self.release_held_actions();
|
||||
let commands = std::mem::take(&mut self.inbox);
|
||||
let tick = self.world.tick();
|
||||
let events = self.recording.step(&mut self.world, &commands);
|
||||
let check = self
|
||||
.world
|
||||
.tick()
|
||||
.is_multiple_of(CHECK_INTERVAL_TICKS)
|
||||
.then(|| self.world.state_hash());
|
||||
let message = ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
}
|
||||
.encode();
|
||||
for (peer, link) in &self.links {
|
||||
if *link != Link::Greeting {
|
||||
self.outbox.push((*peer, message.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
self.local_actions = 0;
|
||||
let mut refused = Vec::new();
|
||||
for (peer, link) in self.links.iter_mut() {
|
||||
match link {
|
||||
Link::Joining { key } => match online_player(&self.world, key) {
|
||||
Some(player) => {
|
||||
*link = Link::Playing {
|
||||
player,
|
||||
actions: 0,
|
||||
held: None,
|
||||
}
|
||||
}
|
||||
None => refused.push(*peer),
|
||||
},
|
||||
Link::Playing { actions, .. } => *actions = 0,
|
||||
Link::Greeting => {}
|
||||
}
|
||||
}
|
||||
// The admission check keeps the world from refusing an accepted
|
||||
// guest. Should it happen anyway, the guest learns why.
|
||||
for peer in refused {
|
||||
self.refuse(peer, Refusal::WorldFull);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Queues the held actions whose player's cooldown is over.
|
||||
fn release_held_actions(&mut self) {
|
||||
for link in self.links.values_mut() {
|
||||
let Link::Playing { player, held, .. } = link else {
|
||||
continue;
|
||||
};
|
||||
if held.is_some() && !cooling_down(&self.world, *player) {
|
||||
if let Some(action) = held.take() {
|
||||
self.inbox.push(action.command(*player));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of accepted guests, including those whose join has not run.
|
||||
pub fn guest_count(&self) -> usize {
|
||||
self.links
|
||||
.values()
|
||||
.filter(|link| **link != Link::Greeting)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The player of the guest connected as `peer`, once it is in the world.
|
||||
pub fn player_of(&self, peer: Peer) -> Option<PlayerId> {
|
||||
match self.links.get(&peer) {
|
||||
Some(Link::Playing { player, .. }) => Some(*player),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Messages to send, as (peer, bytes), in the order they were made.
|
||||
pub fn take_outbox(&mut self) -> Vec<(Peer, Vec<u8>)> {
|
||||
std::mem::take(&mut self.outbox)
|
||||
}
|
||||
|
||||
/// Connections the host has given up. The shell closes them after it
|
||||
/// has sent the messages for them.
|
||||
pub fn take_closing(&mut self) -> Vec<Peer> {
|
||||
std::mem::take(&mut self.closing)
|
||||
}
|
||||
|
||||
/// Divergences the guests reported since the last call.
|
||||
pub fn take_divergences(&mut self) -> Vec<Divergence> {
|
||||
std::mem::take(&mut self.divergences)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `player` still waits for its cooldown in the next tick of
|
||||
/// `world`, so that an action then would be dropped.
|
||||
fn cooling_down(world: &World, player: PlayerId) -> bool {
|
||||
world
|
||||
.player(player)
|
||||
.is_some_and(|state| world.tick() < state.next_action_tick)
|
||||
}
|
||||
|
||||
/// The player with `key`, if it is online in `world`.
|
||||
fn online_player(world: &World, key: &PlayerKey) -> Option<PlayerId> {
|
||||
let id = world.find_player(key)?;
|
||||
world.player(id)?.online.then_some(id)
|
||||
}
|
||||
@@ -8,12 +8,16 @@
|
||||
|
||||
pub mod codec;
|
||||
pub mod command;
|
||||
pub mod guest;
|
||||
pub mod hash;
|
||||
pub mod host;
|
||||
pub mod inventory;
|
||||
pub mod map;
|
||||
pub mod mapgen;
|
||||
pub mod player;
|
||||
pub mod protocol;
|
||||
pub mod replay;
|
||||
pub mod rng;
|
||||
pub mod rules;
|
||||
pub mod snapshot;
|
||||
pub mod world;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Messages between the host of a shared world and its guests.
|
||||
//!
|
||||
//! Every machine simulates the whole world. The host decides which commands
|
||||
//! run in which tick and sends every tick to every guest; a guest sends the
|
||||
//! host only what its player wants to do. The shell carries these messages
|
||||
//! as they are, and it must deliver the host's messages to a guest reliably
|
||||
//! and in the order they were sent.
|
||||
//!
|
||||
//! The tag and the version at the start of a greeting, and the whole refusal
|
||||
//! message, never change. That way games of every version can still tell
|
||||
//! each other that they do not fit together.
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::command::Command;
|
||||
use crate::map::Pos;
|
||||
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES};
|
||||
use crate::world::TICKS_PER_SECOND;
|
||||
|
||||
/// Version of the messages. Raise it whenever a message changes.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
/// Longest message a guest may send, in bytes. The longest one is a greeting
|
||||
/// with a name of full length, which needs 65 bytes. Greetings of every
|
||||
/// future version must fit as well: a longer one is rejected unread instead
|
||||
/// of being recognised as another version.
|
||||
pub const MAX_GUEST_MESSAGE_BYTES: usize = 128;
|
||||
/// Ticks between two state checksums in the stream of ticks: one second.
|
||||
pub const CHECK_INTERVAL_TICKS: u64 = TICKS_PER_SECOND as u64;
|
||||
|
||||
// Never changes, see the module documentation.
|
||||
const TO_HOST_HELLO: u8 = 1;
|
||||
const TO_HOST_ACT: u8 = 2;
|
||||
const TO_HOST_DIVERGED: u8 = 3;
|
||||
|
||||
const TO_GUEST_WORLD: u8 = 1;
|
||||
// Never changes, see the module documentation.
|
||||
const TO_GUEST_REFUSED: u8 = 2;
|
||||
const TO_GUEST_TICK: u8 = 3;
|
||||
|
||||
const ACTION_DIG: u8 = 1;
|
||||
|
||||
/// Something a player wants to do. The host turns it into a command for the
|
||||
/// player of the connection it came from, so that no guest can act for
|
||||
/// another player.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Hit the tile at `pos` with the pickaxe.
|
||||
Dig { pos: Pos },
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// The command that carries out this action for `player`.
|
||||
pub fn command(self, player: PlayerId) -> Command {
|
||||
match self {
|
||||
Action::Dig { pos } => Command::Dig { player, pos },
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(&self, w: &mut Writer) {
|
||||
match self {
|
||||
Action::Dig { pos } => {
|
||||
w.u8(ACTION_DIG);
|
||||
w.i32(pos.x);
|
||||
w.i32(pos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
|
||||
match r.u8()? {
|
||||
ACTION_DIG => {
|
||||
let x = r.i32()?;
|
||||
let y = r.i32()?;
|
||||
Ok(Action::Dig {
|
||||
pos: Pos::new(x, y),
|
||||
})
|
||||
}
|
||||
_ => Err(DecodeError::InvalidValue("action tag")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a host turned a guest away. The codes never change, because games of
|
||||
/// other versions read them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// The guest's game speaks another version of these messages.
|
||||
OtherProtocol = 1,
|
||||
/// The guest's game has other rules than the host's.
|
||||
OtherRules = 2,
|
||||
/// Four players are online already.
|
||||
WorldFull = 3,
|
||||
/// The world remembers as many players as it can and does not know this
|
||||
/// one.
|
||||
TooManyPlayers = 4,
|
||||
/// A player with the same key is in the world already.
|
||||
AlreadyPlaying = 5,
|
||||
}
|
||||
|
||||
impl Refusal {
|
||||
/// The number that stands for this refusal in a message.
|
||||
pub fn code(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
|
||||
pub fn from_code(code: u8) -> Option<Self> {
|
||||
match code {
|
||||
1 => Some(Refusal::OtherProtocol),
|
||||
2 => Some(Refusal::OtherRules),
|
||||
3 => Some(Refusal::WorldFull),
|
||||
4 => Some(Refusal::TooManyPlayers),
|
||||
5 => Some(Refusal::AlreadyPlaying),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from a guest to the host.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ToHost {
|
||||
/// The first message of every guest.
|
||||
Hello {
|
||||
rules: u64,
|
||||
key: PlayerKey,
|
||||
name: String,
|
||||
},
|
||||
/// A greeting in another version of the messages. Nothing after the
|
||||
/// version can be read.
|
||||
ForeignHello { protocol: u32 },
|
||||
/// The guest's player wants to do something.
|
||||
Act(Action),
|
||||
/// After `tick` ticks the guest's state had the hash `hash`, which
|
||||
/// differs from the host's checksum. The host answers with its world.
|
||||
Diverged { tick: u64, hash: u64 },
|
||||
}
|
||||
|
||||
impl ToHost {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
match self {
|
||||
ToHost::Hello { rules, key, name } => {
|
||||
w.u8(TO_HOST_HELLO);
|
||||
w.u32(PROTOCOL_VERSION);
|
||||
w.u64(*rules);
|
||||
w.raw(key);
|
||||
w.str(&clamp_name(name));
|
||||
}
|
||||
ToHost::ForeignHello { protocol } => {
|
||||
w.u8(TO_HOST_HELLO);
|
||||
w.u32(*protocol);
|
||||
}
|
||||
ToHost::Act(action) => {
|
||||
w.u8(TO_HOST_ACT);
|
||||
action.encode(&mut w);
|
||||
}
|
||||
ToHost::Diverged { tick, hash } => {
|
||||
w.u8(TO_HOST_DIVERGED);
|
||||
w.u64(*tick);
|
||||
w.u64(*hash);
|
||||
}
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes a whole message. Messages longer than
|
||||
/// [`MAX_GUEST_MESSAGE_BYTES`] are rejected unread.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
||||
if bytes.len() > MAX_GUEST_MESSAGE_BYTES {
|
||||
return Err(DecodeError::InvalidValue("message length"));
|
||||
}
|
||||
let mut r = Reader::new(bytes);
|
||||
let message = match r.u8()? {
|
||||
TO_HOST_HELLO => {
|
||||
let protocol = r.u32()?;
|
||||
if protocol != PROTOCOL_VERSION {
|
||||
return Ok(ToHost::ForeignHello { protocol });
|
||||
}
|
||||
let rules = r.u64()?;
|
||||
let mut key = [0u8; 16];
|
||||
key.copy_from_slice(r.raw(16)?);
|
||||
let name = r.string()?;
|
||||
if name.len() > MAX_NAME_BYTES {
|
||||
return Err(DecodeError::InvalidValue("player name"));
|
||||
}
|
||||
ToHost::Hello { rules, key, name }
|
||||
}
|
||||
TO_HOST_ACT => ToHost::Act(Action::decode(&mut r)?),
|
||||
TO_HOST_DIVERGED => ToHost::Diverged {
|
||||
tick: r.u64()?,
|
||||
hash: r.u64()?,
|
||||
},
|
||||
_ => return Err(DecodeError::InvalidValue("message tag")),
|
||||
};
|
||||
r.finish()?;
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from the host to a guest.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ToGuest {
|
||||
/// The world as it is now. The host sends it when it accepts a guest and
|
||||
/// again after the guest reported a divergence. The ticks that follow
|
||||
/// start at this world's tick.
|
||||
World { snapshot: Vec<u8> },
|
||||
/// The host turned the guest away and closes the connection.
|
||||
Refused(Refusal),
|
||||
/// The commands of tick `tick` in the order the host ran them. Every
|
||||
/// [`CHECK_INTERVAL_TICKS`] ticks, `check` holds the hash of the host's
|
||||
/// state after the tick.
|
||||
Tick {
|
||||
tick: u64,
|
||||
commands: Vec<Command>,
|
||||
check: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToGuest {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
match self {
|
||||
ToGuest::World { snapshot } => {
|
||||
w.u8(TO_GUEST_WORLD);
|
||||
w.bytes(snapshot);
|
||||
}
|
||||
ToGuest::Refused(refusal) => {
|
||||
w.u8(TO_GUEST_REFUSED);
|
||||
w.u8(refusal.code());
|
||||
}
|
||||
ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
} => {
|
||||
w.u8(TO_GUEST_TICK);
|
||||
w.u64(*tick);
|
||||
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
|
||||
for command in commands {
|
||||
command.encode(&mut w);
|
||||
}
|
||||
match check {
|
||||
Some(hash) => {
|
||||
w.bool(true);
|
||||
w.u64(*hash);
|
||||
}
|
||||
None => w.bool(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes a whole message.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let message = match r.u8()? {
|
||||
TO_GUEST_WORLD => ToGuest::World {
|
||||
snapshot: r.bytes()?.to_vec(),
|
||||
},
|
||||
TO_GUEST_REFUSED => ToGuest::Refused(
|
||||
Refusal::from_code(r.u8()?).ok_or(DecodeError::InvalidValue("refusal"))?,
|
||||
),
|
||||
TO_GUEST_TICK => {
|
||||
let tick = r.u64()?;
|
||||
let count = r.u16()?;
|
||||
let mut commands = Vec::new();
|
||||
for _ in 0..count {
|
||||
commands.push(Command::decode(&mut r)?);
|
||||
}
|
||||
let check = if r.bool()? { Some(r.u64()?) } else { None };
|
||||
ToGuest::Tick {
|
||||
tick,
|
||||
commands,
|
||||
check,
|
||||
}
|
||||
}
|
||||
_ => return Err(DecodeError::InvalidValue("message tag")),
|
||||
};
|
||||
r.finish()?;
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
+70
-7
@@ -1,16 +1,28 @@
|
||||
//! Recording of a session: the starting state and every command, tick by
|
||||
//! tick. Replaying a recording reproduces the session exactly.
|
||||
//!
|
||||
//! Layout: the marker `GRWR`, the format version, the identity of the rules
|
||||
//! that made the recording, the hash of the state the session ended in, the
|
||||
//! starting snapshot, and the commands of every tick that had any.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::command::Command;
|
||||
use crate::rules::rules_id;
|
||||
use crate::snapshot::SnapshotError;
|
||||
use crate::world::World;
|
||||
use crate::world::{Event, World, TICKS_PER_SECOND};
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWR";
|
||||
/// Version of the recording format.
|
||||
pub const RECORDING_VERSION: u32 = 1;
|
||||
/// Version of the recording format. Raise it whenever the layout of a
|
||||
/// recording changes, including the snapshot inside it. Recordings made with
|
||||
/// other rules are told apart by the rules id that follows this number, not
|
||||
/// by the number itself.
|
||||
pub const RECORDING_VERSION: u32 = 3;
|
||||
/// Most ticks a recording may hold when it is read: thirty days of play.
|
||||
/// Longer recordings are refused, so that a damaged or crafted file cannot
|
||||
/// keep [`Recording::verify`] busy for hours.
|
||||
pub const MAX_RECORDING_TICKS: u32 = 30 * 24 * 60 * 60 * TICKS_PER_SECOND;
|
||||
|
||||
/// Reason a recording could not be read or replayed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -19,6 +31,9 @@ pub enum RecordingError {
|
||||
NotARecording,
|
||||
/// The recording was written by an incompatible version of the game.
|
||||
UnsupportedVersion(u32),
|
||||
/// The recording was made with other rules than this build has, so
|
||||
/// replaying it would not show what happened.
|
||||
OtherRules { recorded: u64, current: u64 },
|
||||
/// The recording is damaged.
|
||||
Corrupt(DecodeError),
|
||||
/// The starting state inside the recording could not be restored.
|
||||
@@ -34,6 +49,10 @@ impl fmt::Display for RecordingError {
|
||||
RecordingError::UnsupportedVersion(version) => {
|
||||
write!(f, "unsupported recording version {version}")
|
||||
}
|
||||
RecordingError::OtherRules { recorded, current } => write!(
|
||||
f,
|
||||
"recorded with rules {recorded:016x}, this build has rules {current:016x}"
|
||||
),
|
||||
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
|
||||
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
|
||||
RecordingError::Diverged { expected, actual } => write!(
|
||||
@@ -56,6 +75,7 @@ impl From<DecodeError> for RecordingError {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Recording {
|
||||
start: Vec<u8>,
|
||||
start_tick: u64,
|
||||
tick_count: u32,
|
||||
/// Ticks that had commands, as (tick index, commands), in rising order.
|
||||
busy_ticks: Vec<(u32, Vec<Command>)>,
|
||||
@@ -66,18 +86,33 @@ impl Recording {
|
||||
pub fn start(world: &World) -> Self {
|
||||
Self {
|
||||
start: world.to_snapshot(),
|
||||
start_tick: world.tick(),
|
||||
tick_count: 0,
|
||||
busy_ticks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the commands of one tick. Call it once for every
|
||||
/// [`World::step`], with the same commands.
|
||||
pub fn push_tick(&mut self, commands: &[Command]) {
|
||||
/// Advances `world` by one tick with `commands` and records them. This is
|
||||
/// the only way to add to a recording, so it can never hold other
|
||||
/// commands than the ones the world executed.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `world` is not at [`Recording::end_tick`], which means it is
|
||||
/// not the world this recording follows.
|
||||
pub fn step(&mut self, world: &mut World, commands: &[Command]) -> Vec<Event> {
|
||||
assert_eq!(
|
||||
world.tick(),
|
||||
self.end_tick(),
|
||||
"the world is not at the tick this recording has reached"
|
||||
);
|
||||
if !commands.is_empty() {
|
||||
self.busy_ticks.push((self.tick_count, commands.to_vec()));
|
||||
}
|
||||
self.tick_count += 1;
|
||||
self.tick_count = self
|
||||
.tick_count
|
||||
.checked_add(1)
|
||||
.expect("a recording holds fewer than 2^32 ticks");
|
||||
world.step(commands)
|
||||
}
|
||||
|
||||
/// Number of recorded ticks.
|
||||
@@ -85,6 +120,11 @@ impl Recording {
|
||||
self.tick_count
|
||||
}
|
||||
|
||||
/// Tick of the recorded world after the last recorded tick.
|
||||
pub fn end_tick(&self) -> u64 {
|
||||
self.start_tick + u64::from(self.tick_count)
|
||||
}
|
||||
|
||||
/// Restores the starting state and applies every recorded tick.
|
||||
pub fn replay(&self) -> Result<World, RecordingError> {
|
||||
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
|
||||
@@ -105,10 +145,19 @@ impl Recording {
|
||||
|
||||
/// Encodes the recording together with the state that replaying it must
|
||||
/// reach. `end` is the world after the last recorded tick.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `end` is not at [`Recording::end_tick`].
|
||||
pub fn to_bytes(&self, end: &World) -> Vec<u8> {
|
||||
assert_eq!(
|
||||
end.tick(),
|
||||
self.end_tick(),
|
||||
"the end state is not at the tick this recording has reached"
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(RECORDING_VERSION);
|
||||
w.u64(rules_id());
|
||||
w.u64(end.state_hash());
|
||||
w.bytes(&self.start);
|
||||
w.u32(self.tick_count);
|
||||
@@ -124,6 +173,8 @@ impl Recording {
|
||||
}
|
||||
|
||||
/// Decodes a recording and the state hash that replaying it must reach.
|
||||
/// Fails with [`RecordingError::OtherRules`] before anything else is
|
||||
/// decoded if the recording was made with other rules.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
@@ -136,9 +187,20 @@ impl Recording {
|
||||
if version != RECORDING_VERSION {
|
||||
return Err(RecordingError::UnsupportedVersion(version));
|
||||
}
|
||||
let recorded = r.u64()?;
|
||||
let current = rules_id();
|
||||
if recorded != current {
|
||||
return Err(RecordingError::OtherRules { recorded, current });
|
||||
}
|
||||
let expected_hash = r.u64()?;
|
||||
let start = r.bytes()?.to_vec();
|
||||
let start_tick = World::from_snapshot(&start)
|
||||
.map_err(RecordingError::BadStart)?
|
||||
.tick();
|
||||
let tick_count = r.u32()?;
|
||||
if tick_count > MAX_RECORDING_TICKS {
|
||||
return Err(DecodeError::InvalidValue("tick count").into());
|
||||
}
|
||||
let busy_count = r.u32()?;
|
||||
let mut busy_ticks = Vec::new();
|
||||
let mut first_free_tick = 0;
|
||||
@@ -162,6 +224,7 @@ impl Recording {
|
||||
Ok((
|
||||
Self {
|
||||
start,
|
||||
start_tick,
|
||||
tick_count,
|
||||
busy_ticks,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Identity of the rules. Machines can share a world only if their rules
|
||||
//! produce the same results, and a recording can only be replayed by the
|
||||
//! rules it was made with.
|
||||
//!
|
||||
//! The identity is derived from fixed sessions: each runs a frozen script of
|
||||
//! commands on a fresh world, and the identity combines the state hashes they
|
||||
//! end in. A change to a rule that one of the scripts touches changes the
|
||||
//! identity, without anybody having to raise a number by hand. A new rule
|
||||
//! needs a new script in [`SCRIPTS`], so that the identity covers it too.
|
||||
|
||||
use crate::codec::Writer;
|
||||
use crate::command::Command;
|
||||
use crate::hash::fnv1a64;
|
||||
use crate::map::Pos;
|
||||
use crate::mapgen::outcrop_origin;
|
||||
use crate::player::PlayerId;
|
||||
use crate::world::World;
|
||||
|
||||
/// Seed of the world every frozen script runs on.
|
||||
pub const RULES_SEED: u64 = 2026;
|
||||
|
||||
/// A frozen script: the commands of every tick of a fixed session.
|
||||
pub type Script = fn(&World) -> Vec<Vec<Command>>;
|
||||
|
||||
/// Every frozen script the rules identity covers, in a fixed order.
|
||||
pub const SCRIPTS: [Script; 2] = [frozen_script, frozen_offline_script];
|
||||
|
||||
/// The commands of a session of 60 ticks. It is built from literal values
|
||||
/// and the outcrop origin of the map, not from constants such as
|
||||
/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not
|
||||
/// the script.
|
||||
///
|
||||
/// Never change or extend this script. To cover more, add a new script to
|
||||
/// [`SCRIPTS`] instead.
|
||||
pub fn frozen_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let right_of_origin = Pos::new(origin.x + 1, origin.y);
|
||||
// Pairs of (tick, command). Commands of the same tick are applied in the
|
||||
// order listed here.
|
||||
let scripted = [
|
||||
// Two players join in the same tick.
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "Grace".to_owned(),
|
||||
},
|
||||
),
|
||||
// Player 0 hits the outcrop origin three times, five ticks apart.
|
||||
// The third hit breaks the rock and gives a stone.
|
||||
(
|
||||
2,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
7,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
12,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
// Player 1 hits the tile to the right of the origin once.
|
||||
(
|
||||
20,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: right_of_origin,
|
||||
},
|
||||
),
|
||||
// Player 1 leaves and returns with a name of 40 bytes, which the
|
||||
// world shortens to 32.
|
||||
(
|
||||
30,
|
||||
Command::Leave {
|
||||
player: PlayerId(1),
|
||||
},
|
||||
),
|
||||
(
|
||||
40,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "ä".repeat(20),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 60];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// The commands of a second session of 30 ticks that ends with one player
|
||||
/// offline, so that the encoding of offline players is covered as well.
|
||||
///
|
||||
/// Never change or extend this script either.
|
||||
pub fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let scripted = [
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [7; 16],
|
||||
name: "Linus".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
1,
|
||||
Command::Join {
|
||||
key: [8; 16],
|
||||
name: "Margaret".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
3,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
10,
|
||||
Command::Leave {
|
||||
player: PlayerId(0),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 30];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// Runs `script` on a fresh world from [`RULES_SEED`] and returns the hash
|
||||
/// of the state it ends in.
|
||||
pub fn end_hash(script: Script) -> u64 {
|
||||
let mut world = World::new(RULES_SEED);
|
||||
for commands in script(&world) {
|
||||
world.step(&commands);
|
||||
}
|
||||
world.state_hash()
|
||||
}
|
||||
|
||||
/// The identity of the rules of this build: one hash over the end states of
|
||||
/// all [`SCRIPTS`]. Computing it runs every script, which takes about a
|
||||
/// millisecond, so callers that need it often keep the value.
|
||||
pub fn rules_id() -> u64 {
|
||||
let mut w = Writer::new();
|
||||
for script in SCRIPTS {
|
||||
w.u64(end_hash(script));
|
||||
}
|
||||
fnv1a64(&w.into_bytes())
|
||||
}
|
||||
+74
-8
@@ -1,18 +1,26 @@
|
||||
//! Complete, versioned encoding of a world. Saves, joining players and state
|
||||
//! checksums all use it.
|
||||
//!
|
||||
//! Layout: the marker `GRWS`, the format version, the encoded state, and an
|
||||
//! FNV-1a checksum over everything before it.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::hash::fnv1a64;
|
||||
use crate::map::Map;
|
||||
use crate::player::{Player, MAX_KNOWN_PLAYERS};
|
||||
use crate::world::World;
|
||||
use crate::player::{Player, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
|
||||
use crate::world::{World, DIG_COOLDOWN_TICKS};
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWS";
|
||||
/// Version of the snapshot format. Raise it whenever the encoded state
|
||||
/// changes.
|
||||
pub const SNAPSHOT_VERSION: u32 = 1;
|
||||
pub const SNAPSHOT_VERSION: u32 = 2;
|
||||
/// Length of the checksum at the end of a snapshot.
|
||||
const CHECKSUM_BYTES: usize = 8;
|
||||
/// Highest tick a restored world may have. No real session gets near it,
|
||||
/// and adding to it can never overflow.
|
||||
pub const MAX_RESTORABLE_TICK: u64 = u64::MAX / 2;
|
||||
|
||||
/// Reason a snapshot could not be restored.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -21,8 +29,13 @@ pub enum SnapshotError {
|
||||
NotASnapshot,
|
||||
/// The snapshot was written by an incompatible version of the game.
|
||||
UnsupportedVersion(u32),
|
||||
/// The checksum does not match: the bytes changed after they were
|
||||
/// written.
|
||||
ChecksumMismatch,
|
||||
/// The snapshot is damaged.
|
||||
Corrupt(DecodeError),
|
||||
/// The bytes decode, but describe a state the rules can never produce.
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for SnapshotError {
|
||||
@@ -32,7 +45,9 @@ impl fmt::Display for SnapshotError {
|
||||
SnapshotError::UnsupportedVersion(version) => {
|
||||
write!(f, "unsupported snapshot version {version}")
|
||||
}
|
||||
SnapshotError::ChecksumMismatch => write!(f, "snapshot checksum does not match"),
|
||||
SnapshotError::Corrupt(error) => write!(f, "damaged snapshot: {error}"),
|
||||
SnapshotError::Invalid(what) => write!(f, "impossible state in snapshot: {what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,16 +80,20 @@ impl World {
|
||||
}
|
||||
|
||||
/// Encodes the complete world behind a header that names format and
|
||||
/// version.
|
||||
/// version, followed by a checksum.
|
||||
pub fn to_snapshot(&self) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(SNAPSHOT_VERSION);
|
||||
self.encode_state(&mut w);
|
||||
w.into_bytes()
|
||||
let mut bytes = w.into_bytes();
|
||||
let checksum = fnv1a64(&bytes);
|
||||
bytes.extend_from_slice(&checksum.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Restores a world from bytes written by [`World::to_snapshot`].
|
||||
/// Restores a world exactly as it was encoded, including which players
|
||||
/// were online. Joining players receive the host's world this way.
|
||||
pub fn from_snapshot(bytes: &[u8]) -> Result<World, SnapshotError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
@@ -87,6 +106,17 @@ impl World {
|
||||
if version != SNAPSHOT_VERSION {
|
||||
return Err(SnapshotError::UnsupportedVersion(version));
|
||||
}
|
||||
if r.remaining() < CHECKSUM_BYTES {
|
||||
return Err(DecodeError::UnexpectedEnd.into());
|
||||
}
|
||||
let (body, trailer) = bytes.split_at(bytes.len() - CHECKSUM_BYTES);
|
||||
let mut checksum = [0u8; CHECKSUM_BYTES];
|
||||
checksum.copy_from_slice(trailer);
|
||||
if fnv1a64(body) != u64::from_le_bytes(checksum) {
|
||||
return Err(SnapshotError::ChecksumMismatch);
|
||||
}
|
||||
|
||||
let mut r = Reader::new(&body[MAGIC.len() + 4..]);
|
||||
let seed = r.u64()?;
|
||||
let tick = r.u64()?;
|
||||
let map = Map::decode(&mut r)?;
|
||||
@@ -99,11 +129,47 @@ impl World {
|
||||
players.push(Player::decode(&mut r)?);
|
||||
}
|
||||
r.finish()?;
|
||||
Ok(World {
|
||||
|
||||
let world = World {
|
||||
seed,
|
||||
tick,
|
||||
map,
|
||||
players,
|
||||
})
|
||||
};
|
||||
world.check_restorable()?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Restores a saved world for a new session. Like
|
||||
/// [`World::from_snapshot`], but every player starts offline, because
|
||||
/// nobody has joined the new session yet.
|
||||
pub fn load(bytes: &[u8]) -> Result<World, SnapshotError> {
|
||||
let mut world = World::from_snapshot(bytes)?;
|
||||
world.set_all_offline();
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Rejects states that the rules can never produce, so that a damaged or
|
||||
/// crafted snapshot fails here instead of breaking a later tick.
|
||||
fn check_restorable(&self) -> Result<(), SnapshotError> {
|
||||
if self.tick > MAX_RESTORABLE_TICK {
|
||||
return Err(SnapshotError::Invalid("tick"));
|
||||
}
|
||||
let online = self.players.iter().filter(|player| player.online).count();
|
||||
if online > MAX_ONLINE_PLAYERS {
|
||||
return Err(SnapshotError::Invalid("online players"));
|
||||
}
|
||||
for (index, player) in self.players.iter().enumerate() {
|
||||
if self.players[..index]
|
||||
.iter()
|
||||
.any(|other| other.key == player.key)
|
||||
{
|
||||
return Err(SnapshotError::Invalid("duplicate player key"));
|
||||
}
|
||||
if player.next_action_tick > self.tick + DIG_COOLDOWN_TICKS {
|
||||
return Err(SnapshotError::Invalid("next action tick"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -95,9 +95,11 @@ impl World {
|
||||
.map(|index| PlayerId(index as u8))
|
||||
}
|
||||
|
||||
/// Marks every player as offline. Used after loading a save from disk,
|
||||
/// when nobody has joined yet.
|
||||
pub fn set_all_offline(&mut self) {
|
||||
/// Marks every player as offline. Only [`World::load`] uses it: a loaded
|
||||
/// save starts a new session, which nobody has joined yet. Called during
|
||||
/// a running session on one machine, it would split the worlds of the
|
||||
/// players apart.
|
||||
pub(crate) fn set_all_offline(&mut self) {
|
||||
for player in &mut self.players {
|
||||
player.online = false;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,21 @@ fn an_overlong_name_is_shortened_when_encoded() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_longer_than_allowed_is_rejected_when_decoded() {
|
||||
// A join written by hand: the encoder would shorten the name. The tag of
|
||||
// a join is 1; the recording format pins the wire format of commands.
|
||||
let mut w = Writer::new();
|
||||
w.u8(1);
|
||||
w.raw(&[1; 16]);
|
||||
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
|
||||
let bytes = w.into_bytes();
|
||||
assert_eq!(
|
||||
Command::decode(&mut Reader::new(&bytes)),
|
||||
Err(DecodeError::InvalidValue("player name"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_command_is_rejected() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -123,8 +123,7 @@ fn a_recorded_session_replays_to_the_same_hash() {
|
||||
let mut world = World::new(8);
|
||||
let mut recording = Recording::start(&world);
|
||||
for commands in &script {
|
||||
recording.push_tick(commands);
|
||||
world.step(commands);
|
||||
recording.step(&mut world, commands);
|
||||
}
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let replayed = Recording::verify(&bytes).expect("the recording replays cleanly");
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
//! Guards the lockstep rules: the core must not use anything whose result
|
||||
//! can differ between machines or between runs.
|
||||
//!
|
||||
//! Clippy checks the same rules on resolved paths (see `core/clippy.toml`),
|
||||
//! which also catches aliases and grouped imports. This text search adds
|
||||
//! what Clippy cannot express, such as floating point types and global
|
||||
//! mutable state, and it runs even when Clippy does not.
|
||||
|
||||
// Tests may read files. The rules of core/clippy.toml apply to core/src.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -29,9 +37,41 @@ const FORBIDDEN: &[(&str, &str)] = &[
|
||||
"std::net",
|
||||
"the core must not open connections; the shell does that",
|
||||
),
|
||||
(
|
||||
"std::io",
|
||||
"the core must not read or write streams; the shell does that",
|
||||
),
|
||||
("std::process", "the core must not start or end processes"),
|
||||
(
|
||||
"std::{",
|
||||
"grouped std imports hide forbidden modules from this search; import each path on its own line",
|
||||
),
|
||||
("static mut", "global mutable state can differ between runs"),
|
||||
(
|
||||
"thread_local",
|
||||
"global mutable state can differ between runs",
|
||||
),
|
||||
("Atomic", "global mutable state can differ between runs"),
|
||||
("Mutex", "global mutable state can differ between runs"),
|
||||
("RwLock", "global mutable state can differ between runs"),
|
||||
("OnceLock", "global mutable state can differ between runs"),
|
||||
("LazyLock", "global mutable state can differ between runs"),
|
||||
("unsafe", "the core forbids unsafe code"),
|
||||
];
|
||||
|
||||
/// Every forbidden term in `text`, as (line number, term, reason).
|
||||
fn violations(text: &str) -> Vec<(usize, &'static str, &'static str)> {
|
||||
let mut found = Vec::new();
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
for &(needle, reason) in FORBIDDEN {
|
||||
if line.contains(needle) {
|
||||
found.push((index + 1, needle, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
for entry in fs::read_dir(dir).expect("source directory is readable") {
|
||||
let path = entry.expect("directory entry is readable").path();
|
||||
@@ -58,16 +98,8 @@ fn core_source_uses_no_forbidden_apis() {
|
||||
let mut problems = Vec::new();
|
||||
for file in &files {
|
||||
let text = fs::read_to_string(file).expect("source file is readable");
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
for (needle, reason) in FORBIDDEN {
|
||||
if line.contains(needle) {
|
||||
problems.push(format!(
|
||||
"{}:{}: `{needle}`: {reason}",
|
||||
file.display(),
|
||||
number + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
for (line, needle, reason) in violations(&text) {
|
||||
problems.push(format!("{}:{line}: `{needle}`: {reason}", file.display()));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
@@ -76,3 +108,23 @@ fn core_source_uses_no_forbidden_apis() {
|
||||
problems.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_forbidden_term_is_detected() {
|
||||
for &(needle, _) in FORBIDDEN {
|
||||
let text = format!("pub fn fine() {{}}\nlet x = {needle};\n");
|
||||
let found = violations(&text);
|
||||
assert!(
|
||||
found
|
||||
.iter()
|
||||
.any(|&(line, term, _)| line == 2 && term == needle),
|
||||
"`{needle}` was not detected: {found:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_code_has_no_violations() {
|
||||
let text = "pub fn add(a: u32, b: u32) -> u32 {\n a.wrapping_add(b)\n}\n";
|
||||
assert!(violations(text).is_empty());
|
||||
}
|
||||
|
||||
+163
-104
@@ -1,111 +1,31 @@
|
||||
//! Pinned values for everything that must not change unnoticed.
|
||||
//!
|
||||
//! Every other test compares two computations inside the same build, so a
|
||||
//! Most other tests compare two computations inside the same build, so a
|
||||
//! change that affects both sides alike stays green. This file compares
|
||||
//! against values recorded once. If one of them fails, rules or encodings
|
||||
//! changed, on purpose or by accident. If saves or recordings are affected,
|
||||
//! raise the version constant named in the failure message, then update the
|
||||
//! value here deliberately.
|
||||
|
||||
use grovewright_core::codec::Writer;
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::hash::fnv1a64;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::map::{Map, Pos, Terrain, Tile};
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, PROTOCOL_VERSION};
|
||||
use grovewright_core::replay::Recording;
|
||||
use grovewright_core::rules::{
|
||||
end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
|
||||
};
|
||||
use grovewright_core::world::World;
|
||||
|
||||
const SEED: u64 = 2026;
|
||||
|
||||
/// The commands of a session of 60 ticks. It is built from literal values
|
||||
/// and the outcrop origin of the map, not from constants such as
|
||||
/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not
|
||||
/// the script.
|
||||
///
|
||||
/// Never change or extend this script. To pin more, add a new script and a
|
||||
/// new pinned value instead.
|
||||
fn frozen_script(world: &World) -> Vec<Vec<Command>> {
|
||||
let origin = outcrop_origin(world.map());
|
||||
let right_of_origin = Pos::new(origin.x + 1, origin.y);
|
||||
// Pairs of (tick, command). Commands of the same tick are applied in the
|
||||
// order listed here.
|
||||
let scripted = [
|
||||
// Two players join in the same tick.
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "Grace".to_owned(),
|
||||
},
|
||||
),
|
||||
// Player 0 hits the outcrop origin three times, five ticks apart.
|
||||
// The third hit breaks the rock and gives a stone.
|
||||
(
|
||||
2,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
7,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
(
|
||||
12,
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: origin,
|
||||
},
|
||||
),
|
||||
// Player 1 hits the tile to the right of the origin once.
|
||||
(
|
||||
20,
|
||||
Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: right_of_origin,
|
||||
},
|
||||
),
|
||||
// Player 1 leaves and returns with a name of 40 bytes, which the
|
||||
// world shortens to 32.
|
||||
(
|
||||
30,
|
||||
Command::Leave {
|
||||
player: PlayerId(1),
|
||||
},
|
||||
),
|
||||
(
|
||||
40,
|
||||
Command::Join {
|
||||
key: [2; 16],
|
||||
name: "ä".repeat(20),
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut ticks = vec![Vec::new(); 60];
|
||||
for (tick, command) in scripted {
|
||||
ticks[tick].push(command);
|
||||
}
|
||||
ticks
|
||||
}
|
||||
|
||||
/// Covers the map generator, the map encoding (terrain numbers, byte order of
|
||||
/// width and height) and the snapshot header.
|
||||
/// Covers the map generator, the map encoding and the snapshot header and
|
||||
/// checksum.
|
||||
#[test]
|
||||
fn the_starting_snapshot_is_unchanged() {
|
||||
let actual = fnv1a64(&World::new(SEED).to_snapshot());
|
||||
let actual = fnv1a64(&World::new(RULES_SEED).to_snapshot());
|
||||
assert_eq!(
|
||||
actual, 0x1b78_d996_1586_fbf1,
|
||||
actual, 0x6812_937b_f2b2_19ae,
|
||||
"the starting world or the snapshot format changed; if saves are \
|
||||
affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \
|
||||
update this value"
|
||||
@@ -116,32 +36,171 @@ fn the_starting_snapshot_is_unchanged() {
|
||||
/// and the encoding of players.
|
||||
#[test]
|
||||
fn the_frozen_session_ends_in_the_same_state() {
|
||||
let mut world = World::new(SEED);
|
||||
for commands in frozen_script(&world) {
|
||||
world.step(&commands);
|
||||
}
|
||||
assert_eq!(
|
||||
world.state_hash(),
|
||||
end_hash(frozen_script),
|
||||
0xc304_43df_fc74_dcfc,
|
||||
"the rules or the snapshot format changed; old recordings no longer \
|
||||
verify; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||
"the rules or the snapshot format changed, and with them the rules \
|
||||
id; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||
core/src/snapshot.rs; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the encoding of a player who is offline.
|
||||
#[test]
|
||||
fn the_frozen_offline_session_ends_in_the_same_state() {
|
||||
assert_eq!(
|
||||
end_hash(frozen_offline_script),
|
||||
0x904f_10e1_7c52_8be4,
|
||||
"the rules or the snapshot format changed, and with them the rules \
|
||||
id; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||
core/src/snapshot.rs; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers how the identity of the rules is combined from the end states of
|
||||
/// the frozen scripts.
|
||||
#[test]
|
||||
fn the_rules_id_is_unchanged() {
|
||||
assert_eq!(
|
||||
rules_id(),
|
||||
0x04f1_1b1e_16c5_feff,
|
||||
"the rules id changed: builds with other rules can no longer play \
|
||||
together, and older recordings report other rules; if the tests \
|
||||
above fail as well, deal with those first; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the recording header and layout and the wire format of commands.
|
||||
#[test]
|
||||
fn the_recording_format_is_unchanged() {
|
||||
let mut world = World::new(SEED);
|
||||
let mut world = World::new(RULES_SEED);
|
||||
let mut recording = Recording::start(&world);
|
||||
for commands in frozen_script(&world) {
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
recording.step(&mut world, &commands);
|
||||
}
|
||||
assert_eq!(
|
||||
fnv1a64(&recording.to_bytes(&world)),
|
||||
0x2312_5a7d_d8ef_5ae0,
|
||||
"the recording or command format changed; raise RECORDING_VERSION in \
|
||||
core/src/replay.rs; then update this value"
|
||||
0xf203_b229_8dad_7c53,
|
||||
"the recording or command format changed; if other tests in this \
|
||||
file fail as well, deal with those first, because a recording \
|
||||
contains a snapshot and an end state; otherwise raise \
|
||||
RECORDING_VERSION in core/src/replay.rs; then update this value"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the byte layout of a map: width before height, little endian,
|
||||
/// terrain numbers and damage. The map is not square, so a swap of width
|
||||
/// and height shows.
|
||||
#[test]
|
||||
fn the_map_encoding_is_unchanged() {
|
||||
let mut map = Map::filled(3, 2, Terrain::Ground);
|
||||
map.set(Pos::new(1, 0), Tile::new(Terrain::Bedrock));
|
||||
map.set(Pos::new(0, 1), Tile::new(Terrain::HardRock));
|
||||
map.set(
|
||||
Pos::new(2, 1),
|
||||
Tile {
|
||||
terrain: Terrain::SoftRock,
|
||||
damage: 1,
|
||||
},
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
map.encode(&mut w);
|
||||
assert_eq!(
|
||||
w.into_bytes(),
|
||||
vec![
|
||||
3, 0, 0, 0, // width
|
||||
2, 0, 0, 0, // height
|
||||
0, 0, 3, 0, 0, 0, // row 0: ground, bedrock, ground
|
||||
2, 0, 0, 0, 1, 1, // row 1: hard rock, ground, soft rock hit once
|
||||
],
|
||||
"the map encoding changed; raise SNAPSHOT_VERSION in \
|
||||
core/src/snapshot.rs; then update these bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers every message between host and guests, built from literal values.
|
||||
#[test]
|
||||
fn the_protocol_format_is_unchanged() {
|
||||
let to_host = [
|
||||
ToHost::Hello {
|
||||
rules: 0x0102_0304_0506_0708,
|
||||
key: [3; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
ToHost::Act(Action::Dig {
|
||||
pos: Pos::new(-2, 9),
|
||||
}),
|
||||
ToHost::Diverged { tick: 40, hash: 77 },
|
||||
];
|
||||
let to_guest = [
|
||||
ToGuest::World {
|
||||
snapshot: vec![5, 6, 7],
|
||||
},
|
||||
ToGuest::Refused(Refusal::WorldFull),
|
||||
ToGuest::Tick {
|
||||
tick: 12,
|
||||
commands: vec![Command::Dig {
|
||||
player: PlayerId(1),
|
||||
pos: Pos::new(3, 4),
|
||||
}],
|
||||
check: Some(99),
|
||||
},
|
||||
ToGuest::Tick {
|
||||
tick: 13,
|
||||
commands: vec![],
|
||||
check: None,
|
||||
},
|
||||
];
|
||||
let mut bytes = Vec::new();
|
||||
for message in &to_host {
|
||||
bytes.extend(message.encode());
|
||||
}
|
||||
for message in &to_guest {
|
||||
bytes.extend(message.encode());
|
||||
}
|
||||
assert_eq!(
|
||||
fnv1a64(&bytes),
|
||||
0xc263_69c0_f892_247b,
|
||||
"the messages between host and guests changed; raise \
|
||||
PROTOCOL_VERSION in core/src/protocol.rs; then update this value. \
|
||||
The start of a greeting and the refusal must never change, see \
|
||||
the_greeting_start_and_the_refusal_never_change"
|
||||
);
|
||||
}
|
||||
|
||||
/// Covers the promise that games of every version can still tell each other
|
||||
/// that they do not fit: a greeting starts with its tag and the version, and
|
||||
/// a refusal is its tag and the code of its reason. Unlike every other part
|
||||
/// of the messages, these stay the same when PROTOCOL_VERSION is raised.
|
||||
#[test]
|
||||
fn the_greeting_start_and_the_refusal_never_change() {
|
||||
let hello = ToHost::Hello {
|
||||
rules: 1,
|
||||
key: [3; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}
|
||||
.encode();
|
||||
let mut start = vec![1];
|
||||
start.extend(PROTOCOL_VERSION.to_le_bytes());
|
||||
assert_eq!(
|
||||
hello[..start.len()],
|
||||
start[..],
|
||||
"a greeting starts with the tag 1 and the version; this never \
|
||||
changes, not even with a new PROTOCOL_VERSION"
|
||||
);
|
||||
let refusals = [
|
||||
(Refusal::OtherProtocol, 1),
|
||||
(Refusal::OtherRules, 2),
|
||||
(Refusal::WorldFull, 3),
|
||||
(Refusal::TooManyPlayers, 4),
|
||||
(Refusal::AlreadyPlaying, 5),
|
||||
];
|
||||
for (refusal, code) in refusals {
|
||||
assert_eq!(
|
||||
ToGuest::Refused(refusal).encode(),
|
||||
vec![2, code],
|
||||
"a refusal is the tag 2 and the code of its reason; this never \
|
||||
changes, not even with a new PROTOCOL_VERSION"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Host and guest driven by hand, message by message. These tests produce
|
||||
//! what the in-memory network of `session.rs` never does, because it lets
|
||||
//! every guest run each tick before the next one arrives: a guest that lags
|
||||
//! behind, a tick that gets lost, and joins that wait at the limits.
|
||||
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::guest::{Arrival, Guest};
|
||||
use grovewright_core::host::{Host, Peer, MAX_ACTIONS_PER_TICK};
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::{PlayerId, PlayerKey};
|
||||
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS};
|
||||
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
|
||||
|
||||
const HOST_KEY: PlayerKey = [100; 16];
|
||||
|
||||
/// A host whose own player is in a new world.
|
||||
fn started_host() -> Host {
|
||||
let mut host = Host::new(World::new(5));
|
||||
host.join_local(HOST_KEY, "Host");
|
||||
host.step();
|
||||
host
|
||||
}
|
||||
|
||||
/// Connects a guest with a key made of `key` as `peer` and hands its
|
||||
/// greeting to the host.
|
||||
fn greet(host: &mut Host, peer: Peer, key: u8) -> Guest {
|
||||
host.connect(peer);
|
||||
let mut guest = Guest::new([key; 16], "Guest");
|
||||
for bytes in guest.take_outbox() {
|
||||
host.receive(peer, &bytes);
|
||||
}
|
||||
guest
|
||||
}
|
||||
|
||||
/// Takes the host's whole outbox and keeps what is for `peer`.
|
||||
fn messages_for(host: &mut Host, peer: Peer) -> Vec<Vec<u8>> {
|
||||
host.take_outbox()
|
||||
.into_iter()
|
||||
.filter(|(to, _)| *to == peer)
|
||||
.map(|(_, bytes)| bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The host's answer to a greeting from `peer`: `None` for its world, or
|
||||
/// the reason it turned the guest away.
|
||||
fn answer_to(host: &mut Host, peer: Peer) -> Option<Refusal> {
|
||||
let messages = messages_for(host, peer);
|
||||
match ToGuest::decode(&messages[0]) {
|
||||
Ok(ToGuest::World { .. }) => None,
|
||||
Ok(ToGuest::Refused(refusal)) => Some(refusal),
|
||||
other => panic!("unexpected answer {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A host and the guest Ada at peer 2, in the same state.
|
||||
fn host_with_ada() -> (Host, Guest) {
|
||||
let mut host = started_host();
|
||||
let mut ada = greet(&mut host, 2, 1);
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
host.step();
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
while ada.step().is_some() {}
|
||||
assert!(ada.local_player().is_some());
|
||||
assert_eq!(
|
||||
ada.world().map(World::state_hash),
|
||||
Some(host.world().state_hash())
|
||||
);
|
||||
(host, ada)
|
||||
}
|
||||
|
||||
/// A world that knows players with keys made of 1 to `count`, all offline.
|
||||
fn world_with_offline_players(count: u8) -> World {
|
||||
let mut world = World::new(5);
|
||||
for key in 1..=count {
|
||||
world.step(&[Command::Join {
|
||||
key: [key; 16],
|
||||
name: "Visitor".to_owned(),
|
||||
}]);
|
||||
world.step(&[Command::Leave {
|
||||
player: PlayerId(key - 1),
|
||||
}]);
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hosts_own_queued_join_counts_for_the_online_limit() {
|
||||
let mut host = Host::new(World::new(5));
|
||||
host.join_local(HOST_KEY, "Host");
|
||||
// No tick has run: the host's join is still queued.
|
||||
let mut answers = Vec::new();
|
||||
for key in 1..=4 {
|
||||
let peer = Peer::from(key) + 1;
|
||||
greet(&mut host, peer, key);
|
||||
answers.push(answer_to(&mut host, peer));
|
||||
}
|
||||
assert_eq!(answers, [None, None, None, Some(Refusal::WorldFull)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_new_keys_count_for_the_limit_of_known_players_but_known_ones_do_not() {
|
||||
let mut host = Host::new(world_with_offline_players(15));
|
||||
greet(&mut host, 2, 3);
|
||||
assert_eq!(answer_to(&mut host, 2), None, "a returning player");
|
||||
greet(&mut host, 3, 200);
|
||||
assert_eq!(
|
||||
answer_to(&mut host, 3),
|
||||
None,
|
||||
"a new player fits as the 16th"
|
||||
);
|
||||
greet(&mut host, 4, 201);
|
||||
assert_eq!(
|
||||
answer_to(&mut host, 4),
|
||||
Some(Refusal::TooManyPlayers),
|
||||
"a second new player does not"
|
||||
);
|
||||
|
||||
let events = host.step();
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|event| matches!(event, Event::JoinRefused { .. })));
|
||||
assert_eq!(host.world().players().len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_queued_join_of_a_player_who_is_online_already_counts_once() {
|
||||
let mut world = World::new(5);
|
||||
let joins = [HOST_KEY, [1; 16], [2; 16]].map(|key| Command::Join {
|
||||
key,
|
||||
name: "Online".to_owned(),
|
||||
});
|
||||
world.step(&joins);
|
||||
let mut host = Host::new(world);
|
||||
// The host's player is online already; its queued join changes nothing.
|
||||
host.join_local(HOST_KEY, "Host");
|
||||
greet(&mut host, 2, 3);
|
||||
assert_eq!(answer_to(&mut host, 2), None, "the fourth player fits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_count_of_a_guests_actions_starts_again_in_every_tick() {
|
||||
let (mut host, mut ada) = host_with_ada();
|
||||
for tick in 0..3 {
|
||||
for x in 0..10 {
|
||||
ada.act(Action::Dig {
|
||||
pos: Pos::new(x, 1),
|
||||
});
|
||||
}
|
||||
for bytes in ada.take_outbox() {
|
||||
host.receive(2, &bytes);
|
||||
}
|
||||
host.step();
|
||||
let counts: Vec<usize> = messages_for(&mut host, 2)
|
||||
.iter()
|
||||
.filter_map(|bytes| match ToGuest::decode(bytes) {
|
||||
Ok(ToGuest::Tick { commands, .. }) => Some(commands.len()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(counts, [MAX_ACTIONS_PER_TICK as usize], "tick {tick}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_carries_the_number_of_the_tick_before_it_runs() {
|
||||
let (mut host, mut ada) = host_with_ada();
|
||||
let before = host.world().tick();
|
||||
host.step();
|
||||
let messages = messages_for(&mut host, 2);
|
||||
let Ok(ToGuest::Tick { tick, .. }) = ToGuest::decode(&messages[0]) else {
|
||||
panic!("not a tick");
|
||||
};
|
||||
assert_eq!(tick, before);
|
||||
for bytes in messages {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
ada.step().unwrap();
|
||||
assert_eq!(ada.world().map(World::tick), Some(before + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lagging_guest_that_diverges_runs_nothing_until_the_world_comes_and_drops_older_ticks() {
|
||||
let (mut host, mut ada) = host_with_ada();
|
||||
|
||||
// The host's player hits the rock, but Ada's copy of that tick loses the
|
||||
// command, as if her machine had run it differently.
|
||||
host.act_local(Action::Dig {
|
||||
pos: outcrop_origin(host.world().map()),
|
||||
});
|
||||
host.step();
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
let bytes = match ToGuest::decode(&bytes) {
|
||||
Ok(ToGuest::Tick { tick, check, .. }) => ToGuest::Tick {
|
||||
tick,
|
||||
commands: vec![],
|
||||
check,
|
||||
}
|
||||
.encode(),
|
||||
_ => bytes,
|
||||
};
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
while ada.step().is_some() {}
|
||||
assert!(!ada.has_diverged());
|
||||
|
||||
// Thirty more ticks arrive before Ada runs any of them.
|
||||
for _ in 0..30 {
|
||||
host.step();
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(ada.pending_ticks() >= 30);
|
||||
|
||||
// She catches up and trips over the next checksum.
|
||||
while ada.step().is_some() && !ada.has_diverged() {}
|
||||
assert!(ada.has_diverged());
|
||||
let waiting = ada.pending_ticks();
|
||||
assert!(waiting > 0, "newer ticks are still waiting");
|
||||
assert!(ada.step().is_none(), "no tick runs after a divergence");
|
||||
assert_eq!(ada.pending_ticks(), waiting, "and none is used up");
|
||||
|
||||
// Her report names her tick after the step that showed the divergence.
|
||||
let reports = ada.take_outbox();
|
||||
assert_eq!(reports.len(), 1);
|
||||
let Ok(ToHost::Diverged { tick, hash }) = ToHost::decode(&reports[0]) else {
|
||||
panic!("not a divergence report");
|
||||
};
|
||||
assert_eq!(Some(tick), ada.world().map(World::tick));
|
||||
assert_eq!(tick % CHECK_INTERVAL_TICKS, 0);
|
||||
host.receive(2, &reports[0]);
|
||||
let divergences = host.take_divergences();
|
||||
assert_eq!(divergences.len(), 1);
|
||||
assert_eq!((divergences[0].tick, divergences[0].hash), (tick, hash));
|
||||
|
||||
// The host has gone on meanwhile. Its world comes first, then later ticks.
|
||||
let world_tick = host.world().tick();
|
||||
let answer = messages_for(&mut host, 2);
|
||||
assert_eq!(answer.len(), 1);
|
||||
host.step();
|
||||
host.step();
|
||||
let later = messages_for(&mut host, 2);
|
||||
assert_eq!(later.len(), 2);
|
||||
|
||||
assert_eq!(
|
||||
ada.receive(&answer[0]),
|
||||
Ok(Arrival::World { replaced: true })
|
||||
);
|
||||
assert_eq!(ada.pending_ticks(), 0, "the older ticks are dropped");
|
||||
assert_eq!(ada.world().map(World::tick), Some(world_tick));
|
||||
assert!(!ada.has_diverged());
|
||||
for bytes in later {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
while ada.step().is_some() {}
|
||||
assert_eq!(
|
||||
ada.world().map(World::state_hash),
|
||||
Some(host.world().state_hash())
|
||||
);
|
||||
assert!(ada.take_diverged_recording().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_tick_makes_the_guest_ask_for_the_world() {
|
||||
let (mut host, mut ada) = host_with_ada();
|
||||
host.step();
|
||||
// This tick never arrives.
|
||||
messages_for(&mut host, 2);
|
||||
host.step();
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
assert!(ada.step().is_none());
|
||||
assert!(ada.has_diverged());
|
||||
let reports = ada.take_outbox();
|
||||
assert_eq!(reports.len(), 1);
|
||||
|
||||
host.receive(2, &reports[0]);
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
assert_eq!(ada.receive(&bytes), Ok(Arrival::World { replaced: true }));
|
||||
}
|
||||
host.step();
|
||||
for bytes in messages_for(&mut host, 2) {
|
||||
ada.receive(&bytes).unwrap();
|
||||
}
|
||||
while ada.step().is_some() {}
|
||||
assert_eq!(
|
||||
ada.world().map(World::state_hash),
|
||||
Some(host.world().state_hash())
|
||||
);
|
||||
}
|
||||
|
||||
/// Hands a pickaxe hit of `guest` at `pos` to the host as from `peer`.
|
||||
fn dig(host: &mut Host, peer: Peer, guest: &mut Guest, pos: Pos) {
|
||||
guest.act(Action::Dig { pos });
|
||||
for bytes in guest.take_outbox() {
|
||||
host.receive(peer, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiles that pickaxe hits landed on, without breaking them.
|
||||
fn hits(events: &[Event]) -> Vec<Pos> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::TileHit { pos, .. } => Some(*pos),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guests_hit_that_comes_during_the_cooldown_waits_for_it() {
|
||||
let (mut host, mut ada) = host_with_ada();
|
||||
let rock = outcrop_origin(host.world().map());
|
||||
let next = Pos::new(rock.x + 1, rock.y);
|
||||
dig(&mut host, 2, &mut ada, rock);
|
||||
let first = host.world().tick();
|
||||
assert_eq!(hits(&host.step()), [rock], "the first hit lands at once");
|
||||
|
||||
// Two more hits come during the cooldown, as if the round trip had
|
||||
// become shorter. The newer one waits for the end of the cooldown.
|
||||
dig(&mut host, 2, &mut ada, rock);
|
||||
dig(&mut host, 2, &mut ada, next);
|
||||
let mut landed = Vec::new();
|
||||
for _ in 0..DIG_COOLDOWN_TICKS {
|
||||
let tick = host.world().tick();
|
||||
for pos in hits(&host.step()) {
|
||||
landed.push((tick, pos));
|
||||
}
|
||||
}
|
||||
assert_eq!(landed, [(first + DIG_COOLDOWN_TICKS, next)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_hit_is_dropped_when_its_guest_leaves() {
|
||||
let (mut host, mut ada) = host_with_ada();
|
||||
let rock = outcrop_origin(host.world().map());
|
||||
dig(&mut host, 2, &mut ada, rock);
|
||||
host.step();
|
||||
dig(&mut host, 2, &mut ada, rock);
|
||||
host.disconnect(2);
|
||||
for _ in 0..DIG_COOLDOWN_TICKS {
|
||||
assert_eq!(hits(&host.step()), [], "nobody hits");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The core builds from the standard library alone. A foreign crate could
|
||||
//! bring floating point, hash maps or threads in through the back door.
|
||||
|
||||
// Tests may read files. The rules of core/clippy.toml apply to core/src.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Every manifest section that may add a dependency, with the lines it
|
||||
/// declares. Development dependencies are allowed, because they never reach
|
||||
/// the game.
|
||||
fn dependency_sections(manifest: &str) -> Vec<(String, Vec<String>)> {
|
||||
let mut sections: Vec<(String, Vec<String>)> = Vec::new();
|
||||
let mut inside = false;
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
inside = line.contains("dependencies") && !line.contains("dev-dependencies");
|
||||
if inside {
|
||||
sections.push((line.to_owned(), Vec::new()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if inside && !line.is_empty() && !line.starts_with('#') {
|
||||
if let Some((_, entries)) = sections.last_mut() {
|
||||
entries.push(line.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
sections
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_core_has_no_dependencies() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||
let manifest = fs::read_to_string(&path).expect("the manifest is readable");
|
||||
for (header, entries) in dependency_sections(&manifest) {
|
||||
assert!(
|
||||
!header.contains("dependencies."),
|
||||
"the core declares a dependency table: {header}"
|
||||
);
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"{header} of the core must stay empty, found: {entries:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_sections_are_found_with_their_entries() {
|
||||
let manifest = "[package]\nname = \"x\"\n\n[dependencies]\n# a comment\nserde = \"1\"\n\n\
|
||||
[dev-dependencies]\nproptest = \"1\"\n\n[dependencies.rand]\nversion = \"0.8\"\n";
|
||||
assert_eq!(
|
||||
dependency_sections(manifest),
|
||||
vec![
|
||||
(
|
||||
"[dependencies]".to_owned(),
|
||||
vec!["serde = \"1\"".to_owned()]
|
||||
),
|
||||
(
|
||||
"[dependencies.rand]".to_owned(),
|
||||
vec!["version = \"0.8\"".to_owned()]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use grovewright_core::codec::{DecodeError, Writer};
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::player::{PlayerId, MAX_NAME_BYTES};
|
||||
use grovewright_core::protocol::{
|
||||
Action, Refusal, ToGuest, ToHost, MAX_GUEST_MESSAGE_BYTES, PROTOCOL_VERSION,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn every_message_to_the_host_survives_encoding() {
|
||||
let messages = [
|
||||
ToHost::Hello {
|
||||
rules: 0x0123_4567_89ab_cdef,
|
||||
key: [9; 16],
|
||||
name: "Ada".to_owned(),
|
||||
},
|
||||
ToHost::Act(Action::Dig {
|
||||
pos: Pos::new(-3, 70),
|
||||
}),
|
||||
ToHost::Diverged {
|
||||
tick: 1_234,
|
||||
hash: u64::MAX,
|
||||
},
|
||||
];
|
||||
for message in &messages {
|
||||
assert_eq!(&ToHost::decode(&message.encode()).unwrap(), message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_message_to_a_guest_survives_encoding() {
|
||||
let messages = [
|
||||
ToGuest::World {
|
||||
snapshot: vec![1, 2, 3],
|
||||
},
|
||||
ToGuest::Refused(Refusal::TooManyPlayers),
|
||||
ToGuest::Tick {
|
||||
tick: 99,
|
||||
commands: vec![
|
||||
Command::Leave {
|
||||
player: PlayerId(2),
|
||||
},
|
||||
Command::Dig {
|
||||
player: PlayerId(0),
|
||||
pos: Pos::new(4, 5),
|
||||
},
|
||||
],
|
||||
check: Some(7),
|
||||
},
|
||||
ToGuest::Tick {
|
||||
tick: 100,
|
||||
commands: vec![],
|
||||
check: None,
|
||||
},
|
||||
];
|
||||
for message in &messages {
|
||||
assert_eq!(&ToGuest::decode(&message.encode()).unwrap(), message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_action_becomes_a_command_for_the_given_player() {
|
||||
let action = Action::Dig {
|
||||
pos: Pos::new(1, 2),
|
||||
};
|
||||
assert_eq!(
|
||||
action.command(PlayerId(3)),
|
||||
Command::Dig {
|
||||
player: PlayerId(3),
|
||||
pos: Pos::new(1, 2)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_greeting_in_another_protocol_version_can_still_be_recognised() {
|
||||
let mut w = Writer::new();
|
||||
w.u8(1); // the tag of a greeting, which never changes
|
||||
w.u32(PROTOCOL_VERSION + 1);
|
||||
w.raw(b"whatever the other version sends after its version");
|
||||
assert_eq!(
|
||||
ToHost::decode(&w.into_bytes()),
|
||||
Ok(ToHost::ForeignHello {
|
||||
protocol: PROTOCOL_VERSION + 1
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_refusal_keeps_its_code() {
|
||||
let codes = [
|
||||
(Refusal::OtherProtocol, 1),
|
||||
(Refusal::OtherRules, 2),
|
||||
(Refusal::WorldFull, 3),
|
||||
(Refusal::TooManyPlayers, 4),
|
||||
(Refusal::AlreadyPlaying, 5),
|
||||
];
|
||||
for (refusal, code) in codes {
|
||||
assert_eq!(refusal.code(), code);
|
||||
assert_eq!(Refusal::from_code(code), Some(refusal));
|
||||
}
|
||||
assert_eq!(Refusal::from_code(0), None);
|
||||
assert_eq!(Refusal::from_code(6), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_greeting_shortens_an_overlong_name() {
|
||||
let hello = ToHost::Hello {
|
||||
rules: 1,
|
||||
key: [1; 16],
|
||||
name: "x".repeat(MAX_NAME_BYTES + 5),
|
||||
};
|
||||
assert_eq!(
|
||||
ToHost::decode(&hello.encode()),
|
||||
Ok(ToHost::Hello {
|
||||
rules: 1,
|
||||
key: [1; 16],
|
||||
name: "x".repeat(MAX_NAME_BYTES),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_greeting_with_an_overlong_name_written_by_hand_is_rejected() {
|
||||
let mut w = Writer::new();
|
||||
w.u8(1);
|
||||
w.u32(PROTOCOL_VERSION);
|
||||
w.u64(1);
|
||||
w.raw(&[1; 16]);
|
||||
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
|
||||
assert_eq!(
|
||||
ToHost::decode(&w.into_bytes()),
|
||||
Err(DecodeError::InvalidValue("player name"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_messages_are_rejected() {
|
||||
assert_eq!(ToHost::decode(&[]), Err(DecodeError::UnexpectedEnd));
|
||||
assert_eq!(
|
||||
ToHost::decode(&[0xEE]),
|
||||
Err(DecodeError::InvalidValue("message tag"))
|
||||
);
|
||||
assert_eq!(
|
||||
ToHost::decode(&[2, 0xEE]),
|
||||
Err(DecodeError::InvalidValue("action tag"))
|
||||
);
|
||||
assert_eq!(
|
||||
ToHost::decode(&[2; MAX_GUEST_MESSAGE_BYTES + 1]),
|
||||
Err(DecodeError::InvalidValue("message length"))
|
||||
);
|
||||
let mut act = ToHost::Act(Action::Dig {
|
||||
pos: Pos::new(0, 0),
|
||||
})
|
||||
.encode();
|
||||
act.push(0);
|
||||
assert_eq!(ToHost::decode(&act), Err(DecodeError::TrailingBytes));
|
||||
|
||||
assert_eq!(
|
||||
ToGuest::decode(&[0xEE]),
|
||||
Err(DecodeError::InvalidValue("message tag"))
|
||||
);
|
||||
assert_eq!(
|
||||
ToGuest::decode(&[2, 99]),
|
||||
Err(DecodeError::InvalidValue("refusal"))
|
||||
);
|
||||
let tick = ToGuest::Tick {
|
||||
tick: 1,
|
||||
commands: vec![],
|
||||
check: Some(5),
|
||||
}
|
||||
.encode();
|
||||
assert_eq!(
|
||||
ToGuest::decode(&tick[..tick.len() - 1]),
|
||||
Err(DecodeError::UnexpectedEnd)
|
||||
);
|
||||
}
|
||||
+114
-11
@@ -3,10 +3,15 @@ use grovewright_core::command::Command;
|
||||
use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
|
||||
use grovewright_core::replay::{Recording, RecordingError, MAX_RECORDING_TICKS, RECORDING_VERSION};
|
||||
use grovewright_core::rules::rules_id;
|
||||
use grovewright_core::snapshot::SnapshotError;
|
||||
use grovewright_core::world::World;
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
/// Offset of the starting snapshot in an encoded recording: marker, version,
|
||||
/// rules identity, expected hash and the snapshot's length come first.
|
||||
const START: usize = 4 + 4 + 8 + 8 + 4;
|
||||
|
||||
/// Commands of tick `tick` in a short scripted session.
|
||||
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
|
||||
@@ -31,12 +36,28 @@ fn recorded_session() -> (Recording, World) {
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 0..200 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
recording.step(&mut world, &commands);
|
||||
}
|
||||
(recording, world)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepping_through_a_recording_gives_the_events_of_the_world() {
|
||||
let mut plain = World::new(11);
|
||||
let mut recorded = World::new(11);
|
||||
let mut recording = Recording::start(&recorded);
|
||||
for tick in 0..30 {
|
||||
let commands = commands_for(&plain, tick);
|
||||
assert_eq!(
|
||||
recording.step(&mut recorded, &commands),
|
||||
plain.step(&commands),
|
||||
"tick {tick}"
|
||||
);
|
||||
}
|
||||
assert_eq!(recorded, plain);
|
||||
assert_eq!(recording.end_tick(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaying_reaches_the_same_state() {
|
||||
let (recording, world) = recorded_session();
|
||||
@@ -62,14 +83,15 @@ fn verify_accepts_a_faithful_recording() {
|
||||
|
||||
#[test]
|
||||
fn verify_reports_a_replay_that_ends_in_another_state() {
|
||||
let (recording, mut world) = recorded_session();
|
||||
// One tick more than was recorded: the claimed end state is unreachable.
|
||||
world.step(&[]);
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let (recording, world) = recorded_session();
|
||||
let mut bytes = recording.to_bytes(&world);
|
||||
// The expected state hash follows the marker, the version and the rules
|
||||
// identity.
|
||||
bytes[16] ^= 0x01;
|
||||
match Recording::verify(&bytes) {
|
||||
Err(RecordingError::Diverged { expected, actual }) => {
|
||||
assert_eq!(expected, world.state_hash());
|
||||
assert_ne!(actual, expected);
|
||||
assert_eq!(actual, world.state_hash());
|
||||
assert_ne!(expected, actual);
|
||||
}
|
||||
other => panic!("expected a divergence, got {other:?}"),
|
||||
}
|
||||
@@ -85,10 +107,17 @@ fn a_recording_can_start_in_the_middle_of_a_session() {
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 50..120 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
recording.step(&mut world, &commands);
|
||||
}
|
||||
assert_eq!(recording.tick_count(), 70);
|
||||
assert_eq!(recording.end_tick(), 120);
|
||||
|
||||
// Decoding derives the start tick from the stored start world. The
|
||||
// decoded recording must end at the same tick and equal the original.
|
||||
let (decoded, _) = Recording::from_bytes(&recording.to_bytes(&world)).unwrap();
|
||||
assert_eq!(decoded.end_tick(), 120);
|
||||
assert_eq!(decoded, recording);
|
||||
|
||||
assert_eq!(recording.replay(), Ok(world));
|
||||
}
|
||||
|
||||
@@ -100,6 +129,23 @@ fn an_empty_recording_replays_to_its_starting_state() {
|
||||
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "the world is not at the tick this recording has reached")]
|
||||
fn a_recording_refuses_a_world_it_does_not_follow() {
|
||||
let mut world = World::new(3);
|
||||
let mut recording = Recording::start(&world);
|
||||
world.step(&[]);
|
||||
recording.step(&mut world, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "the end state is not at the tick this recording has reached")]
|
||||
fn a_recording_refuses_an_end_state_at_another_tick() {
|
||||
let (recording, mut world) = recorded_session();
|
||||
world.step(&[]);
|
||||
recording.to_bytes(&world);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_recordings_are_rejected() {
|
||||
let (recording, world) = recorded_session();
|
||||
@@ -133,3 +179,60 @@ fn damaged_recordings_are_rejected() {
|
||||
Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_with_a_damaged_start_is_rejected() {
|
||||
let (recording, world) = recorded_session();
|
||||
let mut bytes = recording.to_bytes(&world);
|
||||
// The snapshot starts after marker, version, rules identity, expected
|
||||
// hash and its own length: 4 + 4 + 8 + 8 + 4 bytes.
|
||||
bytes[START + 10] ^= 0x01;
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&bytes),
|
||||
Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_of_other_rules_is_refused_before_replaying() {
|
||||
let (recording, world) = recorded_session();
|
||||
let mut bytes = recording.to_bytes(&world);
|
||||
// The rules identity follows the marker and the version.
|
||||
bytes[8] ^= 0x01;
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&bytes),
|
||||
Err(RecordingError::OtherRules {
|
||||
recorded: rules_id() ^ 0x01,
|
||||
current: rules_id()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// The encoding of `recording` with its tick count replaced by `ticks`. The
|
||||
/// tick count follows the starting snapshot.
|
||||
fn with_tick_count(recording: &Recording, world: &World, ticks: u32) -> Vec<u8> {
|
||||
let mut bytes = recording.to_bytes(world);
|
||||
let length = u32::from_le_bytes(bytes[START - 4..START].try_into().unwrap()) as usize;
|
||||
let at = START + length;
|
||||
bytes[at..at + 4].copy_from_slice(&ticks.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_longer_than_thirty_days_is_refused() {
|
||||
let world = World::new(3);
|
||||
let recording = Recording::start(&world);
|
||||
let (longest, _) =
|
||||
Recording::from_bytes(&with_tick_count(&recording, &world, MAX_RECORDING_TICKS)).unwrap();
|
||||
assert_eq!(longest.tick_count(), MAX_RECORDING_TICKS);
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&with_tick_count(
|
||||
&recording,
|
||||
&world,
|
||||
MAX_RECORDING_TICKS + 1
|
||||
)),
|
||||
Err(RecordingError::Corrupt(DecodeError::InvalidValue(
|
||||
"tick count"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
//! A host and its guests, connected in memory instead of over a network.
|
||||
//! They exchange the same bytes the shell sends.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use grovewright_core::guest::{Arrival, Guest};
|
||||
use grovewright_core::host::{Host, Peer, MAX_ACTIONS_PER_TICK};
|
||||
use grovewright_core::inventory::ITEM_STONE;
|
||||
use grovewright_core::map::{Pos, Terrain};
|
||||
use grovewright_core::mapgen::{outcrop_origin, TEST_MAP_SIZE};
|
||||
use grovewright_core::player::{PlayerKey, MAX_KNOWN_PLAYERS};
|
||||
use grovewright_core::protocol::{
|
||||
Action, Refusal, ToGuest, ToHost, CHECK_INTERVAL_TICKS, PROTOCOL_VERSION,
|
||||
};
|
||||
use grovewright_core::replay::Recording;
|
||||
use grovewright_core::rng::Rng;
|
||||
use grovewright_core::rules::rules_id;
|
||||
use grovewright_core::world::{World, DIG_COOLDOWN_TICKS};
|
||||
|
||||
const HOST_KEY: PlayerKey = [100; 16];
|
||||
|
||||
/// A host and its guests in one process.
|
||||
struct Lan {
|
||||
host: Host,
|
||||
guests: BTreeMap<Peer, Guest>,
|
||||
/// Guests whose connection the host closed, with the refusal they got.
|
||||
closed: BTreeMap<Peer, Option<Refusal>>,
|
||||
next_peer: Peer,
|
||||
}
|
||||
|
||||
impl Lan {
|
||||
/// A host whose own player is in a new world from `seed`.
|
||||
fn new(seed: u64) -> Self {
|
||||
let mut host = Host::new(World::new(seed));
|
||||
host.join_local(HOST_KEY, "Host");
|
||||
host.step();
|
||||
Self {
|
||||
host,
|
||||
guests: BTreeMap::new(),
|
||||
closed: BTreeMap::new(),
|
||||
next_peer: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects a guest with a key made of `key` and lets it greet.
|
||||
fn connect(&mut self, key: u8, name: &str) -> Peer {
|
||||
let peer = self.next_peer;
|
||||
self.next_peer += 1;
|
||||
self.host.connect(peer);
|
||||
self.guests.insert(peer, Guest::new([key; 16], name));
|
||||
self.deliver();
|
||||
peer
|
||||
}
|
||||
|
||||
/// Closes the connection of `peer`, as a guest that quits does.
|
||||
fn disconnect(&mut self, peer: Peer) {
|
||||
self.guests.remove(&peer);
|
||||
self.host.disconnect(peer);
|
||||
}
|
||||
|
||||
/// Carries messages both ways until none are left.
|
||||
fn deliver(&mut self) {
|
||||
self.deliver_changed(|_, bytes| bytes);
|
||||
}
|
||||
|
||||
/// Like [`Lan::deliver`], but `change` may alter what the host sends.
|
||||
fn deliver_changed(&mut self, mut change: impl FnMut(Peer, Vec<u8>) -> Vec<u8>) {
|
||||
loop {
|
||||
let mut moved = false;
|
||||
for (peer, guest) in self.guests.iter_mut() {
|
||||
for bytes in guest.take_outbox() {
|
||||
self.host.receive(*peer, &bytes);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
for (peer, bytes) in self.host.take_outbox() {
|
||||
let bytes = change(peer, bytes);
|
||||
if let Some(guest) = self.guests.get_mut(&peer) {
|
||||
let arrival = guest
|
||||
.receive(&bytes)
|
||||
.expect("the host's message is readable");
|
||||
if let Arrival::Refused(refusal) = arrival {
|
||||
self.closed.insert(peer, Some(refusal));
|
||||
}
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
for peer in self.host.take_closing() {
|
||||
self.guests.remove(&peer);
|
||||
self.closed.entry(peer).or_insert(None);
|
||||
// The connection closes on both ends, and the host hears it.
|
||||
self.host.disconnect(peer);
|
||||
}
|
||||
if !moved {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one tick on the host and lets every guest run what arrived.
|
||||
fn tick(&mut self) {
|
||||
self.host.step();
|
||||
self.deliver();
|
||||
for guest in self.guests.values_mut() {
|
||||
while guest.step().is_some() {}
|
||||
}
|
||||
self.deliver();
|
||||
}
|
||||
|
||||
fn ticks(&mut self, count: u64) {
|
||||
for _ in 0..count {
|
||||
self.tick();
|
||||
}
|
||||
}
|
||||
|
||||
fn guest(&mut self, peer: Peer) -> &mut Guest {
|
||||
self.guests.get_mut(&peer).expect("the guest is connected")
|
||||
}
|
||||
|
||||
/// Asserts that every guest is in exactly the state of the host.
|
||||
fn assert_in_lockstep(&self) {
|
||||
let expected = self.host.world().state_hash();
|
||||
for (peer, guest) in &self.guests {
|
||||
assert_eq!(
|
||||
guest.world().map(World::state_hash),
|
||||
Some(expected),
|
||||
"guest {peer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rock right beside the spawn clearing.
|
||||
fn rock(&self) -> Pos {
|
||||
outcrop_origin(self.host.world().map())
|
||||
}
|
||||
}
|
||||
|
||||
/// The commands of the next tick as one guest receives them. Takes the
|
||||
/// host's outbox, so the guests miss this tick.
|
||||
fn commands_sent_to(host: &mut Host, peer: Peer) -> usize {
|
||||
host.take_outbox()
|
||||
.into_iter()
|
||||
.filter(|(to, _)| *to == peer)
|
||||
.find_map(|(_, bytes)| match ToGuest::decode(&bytes) {
|
||||
Ok(ToGuest::Tick { commands, .. }) => Some(commands.len()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a tick was sent")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_gets_the_world_and_follows_every_tick() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.ticks(3 * CHECK_INTERVAL_TICKS);
|
||||
|
||||
lan.assert_in_lockstep();
|
||||
let guest = lan.guest(ada);
|
||||
assert!(!guest.has_diverged());
|
||||
let me = guest.local_player().expect("the guest is in the world");
|
||||
let world = guest.world().unwrap();
|
||||
assert_eq!(world.player(me).unwrap().name, "Ada");
|
||||
assert_eq!(
|
||||
world
|
||||
.players()
|
||||
.iter()
|
||||
.filter(|player| player.online)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_joins_a_running_world_at_its_current_tick() {
|
||||
let mut lan = Lan::new(5);
|
||||
lan.ticks(100);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let tick = lan.host.world().tick();
|
||||
assert_eq!(lan.guest(ada).world().unwrap().tick(), tick);
|
||||
assert_eq!(
|
||||
lan.guest(ada).local_player(),
|
||||
None,
|
||||
"its join has not run yet"
|
||||
);
|
||||
|
||||
lan.tick();
|
||||
assert!(lan.guest(ada).local_player().is_some());
|
||||
assert_eq!(lan.host.player_of(ada), lan.guest(ada).local_player());
|
||||
lan.assert_in_lockstep();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_actions_of_a_guest_change_the_world_of_everyone() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let grace = lan.connect(2, "Grace");
|
||||
lan.tick();
|
||||
let rock = lan.rock();
|
||||
for _ in 0..3 {
|
||||
lan.guest(ada).act(Action::Dig { pos: rock });
|
||||
lan.ticks(DIG_COOLDOWN_TICKS + 1);
|
||||
}
|
||||
|
||||
lan.assert_in_lockstep();
|
||||
let ada_player = lan.host.player_of(ada).unwrap();
|
||||
let grace_player = lan.host.player_of(grace).unwrap();
|
||||
let seen_by_grace = lan.guest(grace).world().unwrap().clone();
|
||||
assert_eq!(
|
||||
seen_by_grace.map().get(rock).unwrap().terrain,
|
||||
Terrain::Ground
|
||||
);
|
||||
assert_eq!(
|
||||
seen_by_grace
|
||||
.player(ada_player)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.count(ITEM_STONE),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
seen_by_grace
|
||||
.player(grace_player)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.count(ITEM_STONE),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_takes_at_most_four_actions_per_player_and_tick() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
for x in 0..10 {
|
||||
lan.guest(ada).act(Action::Dig {
|
||||
pos: Pos::new(x, 1),
|
||||
});
|
||||
lan.host.act_local(Action::Dig {
|
||||
pos: Pos::new(x, 2),
|
||||
});
|
||||
}
|
||||
lan.deliver();
|
||||
lan.host.step();
|
||||
assert_eq!(
|
||||
commands_sent_to(&mut lan.host, ada),
|
||||
2 * MAX_ACTIONS_PER_TICK as usize
|
||||
);
|
||||
|
||||
// The count starts again in the next tick.
|
||||
lan.host.act_local(Action::Dig {
|
||||
pos: Pos::new(1, 3),
|
||||
});
|
||||
lan.host.step();
|
||||
assert_eq!(commands_sent_to(&mut lan.host, ada), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_of_another_protocol_version_is_told_so() {
|
||||
let mut lan = Lan::new(5);
|
||||
let peer = 9;
|
||||
lan.host.connect(peer);
|
||||
let foreign = ToHost::ForeignHello {
|
||||
protocol: PROTOCOL_VERSION + 1,
|
||||
};
|
||||
lan.host.receive(peer, &foreign.encode());
|
||||
assert_eq!(
|
||||
lan.host.take_outbox(),
|
||||
vec![(peer, ToGuest::Refused(Refusal::OtherProtocol).encode())]
|
||||
);
|
||||
assert_eq!(lan.host.take_closing(), vec![peer]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_with_other_rules_is_refused() {
|
||||
let mut lan = Lan::new(5);
|
||||
let peer = 9;
|
||||
lan.host.connect(peer);
|
||||
let hello = ToHost::Hello {
|
||||
rules: rules_id() ^ 1,
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
};
|
||||
lan.host.receive(peer, &hello.encode());
|
||||
assert_eq!(
|
||||
lan.host.take_outbox(),
|
||||
vec![(peer, ToGuest::Refused(Refusal::OtherRules).encode())]
|
||||
);
|
||||
assert_eq!(lan.host.take_closing(), vec![peer]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fifth_player_is_refused_even_before_the_others_have_joined() {
|
||||
let mut lan = Lan::new(5);
|
||||
let guests = [1, 2, 3].map(|key| lan.connect(key, "Guest"));
|
||||
let fifth = lan.connect(4, "Fifth");
|
||||
assert_eq!(lan.closed.get(&fifth), Some(&Some(Refusal::WorldFull)));
|
||||
|
||||
lan.tick();
|
||||
let late = lan.connect(5, "Late");
|
||||
assert_eq!(lan.closed.get(&late), Some(&Some(Refusal::WorldFull)));
|
||||
assert_eq!(lan.host.guest_count(), 3);
|
||||
|
||||
// Once a guest has left, there is room again.
|
||||
lan.disconnect(guests[0]);
|
||||
lan.tick();
|
||||
let next = lan.connect(6, "Next");
|
||||
lan.tick();
|
||||
assert!(lan.host.player_of(next).is_some());
|
||||
lan.assert_in_lockstep();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_that_knows_sixteen_players_refuses_a_new_one_but_not_an_old_one() {
|
||||
let mut lan = Lan::new(5);
|
||||
// The host and fifteen guests who come and go.
|
||||
for key in 1..MAX_KNOWN_PLAYERS as u8 {
|
||||
let peer = lan.connect(key, "Visitor");
|
||||
lan.tick();
|
||||
lan.disconnect(peer);
|
||||
lan.tick();
|
||||
}
|
||||
assert_eq!(lan.host.world().players().len(), MAX_KNOWN_PLAYERS);
|
||||
|
||||
let stranger = lan.connect(200, "Stranger");
|
||||
assert_eq!(
|
||||
lan.closed.get(&stranger),
|
||||
Some(&Some(Refusal::TooManyPlayers))
|
||||
);
|
||||
let regular = lan.connect(3, "Visitor");
|
||||
lan.tick();
|
||||
assert!(lan.host.player_of(regular).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_is_in_the_world_already_is_refused() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let twin = lan.connect(1, "Ada");
|
||||
assert_eq!(lan.closed.get(&twin), Some(&Some(Refusal::AlreadyPlaying)));
|
||||
lan.tick();
|
||||
let impostor = lan.connect(HOST_KEY[0], "Host");
|
||||
assert_eq!(
|
||||
lan.closed.get(&impostor),
|
||||
Some(&Some(Refusal::AlreadyPlaying))
|
||||
);
|
||||
assert!(lan.host.player_of(ada).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_leaves_is_offline_everywhere_and_keeps_its_bag() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
let grace = lan.connect(2, "Grace");
|
||||
lan.tick();
|
||||
let rock = lan.rock();
|
||||
for _ in 0..3 {
|
||||
lan.guest(ada).act(Action::Dig { pos: rock });
|
||||
lan.ticks(DIG_COOLDOWN_TICKS + 1);
|
||||
}
|
||||
let player = lan.host.player_of(ada).unwrap();
|
||||
|
||||
lan.disconnect(ada);
|
||||
lan.tick();
|
||||
assert!(!lan.host.world().player(player).unwrap().online);
|
||||
assert!(
|
||||
!lan.guest(grace)
|
||||
.world()
|
||||
.unwrap()
|
||||
.player(player)
|
||||
.unwrap()
|
||||
.online
|
||||
);
|
||||
lan.assert_in_lockstep();
|
||||
|
||||
let back = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
assert_eq!(lan.host.player_of(back), Some(player));
|
||||
assert_eq!(lan.guest(back).local_player(), Some(player));
|
||||
assert_eq!(
|
||||
lan.host
|
||||
.world()
|
||||
.player(player)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.count(ITEM_STONE),
|
||||
1
|
||||
);
|
||||
lan.assert_in_lockstep();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_goes_before_its_join_ran_never_enters() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.disconnect(ada);
|
||||
lan.tick();
|
||||
assert_eq!(lan.host.world().find_player(&[1; 16]), None);
|
||||
assert_eq!(lan.host.guest_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_breaks_the_protocol_is_closed() {
|
||||
let mut lan = Lan::new(5);
|
||||
let act = ToHost::Act(Action::Dig {
|
||||
pos: Pos::new(1, 1),
|
||||
})
|
||||
.encode();
|
||||
let broken = [vec![], vec![0xEE], vec![2; 200], act.clone()];
|
||||
for (index, bytes) in broken.iter().enumerate() {
|
||||
let peer = 50 + index as Peer;
|
||||
lan.host.connect(peer);
|
||||
lan.host.receive(peer, bytes);
|
||||
assert_eq!(lan.host.take_closing(), vec![peer], "message {index}");
|
||||
}
|
||||
|
||||
// Messages from a connection the host does not know are ignored.
|
||||
lan.host.receive(77, &act);
|
||||
assert!(lan.host.take_outbox().is_empty());
|
||||
assert!(lan.host.take_closing().is_empty());
|
||||
|
||||
// A second greeting from a guest in the world closes it, and its
|
||||
// player leaves.
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
let player = lan.host.player_of(ada).unwrap();
|
||||
let hello = ToHost::Hello {
|
||||
rules: rules_id(),
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
};
|
||||
lan.host.receive(ada, &hello.encode());
|
||||
assert_eq!(lan.host.take_closing(), vec![ada]);
|
||||
lan.host.step();
|
||||
assert!(!lan.host.world().player(player).unwrap().online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_guest_that_diverges_gets_the_world_again_and_keeps_the_evidence() {
|
||||
let mut lan = Lan::new(5);
|
||||
let ada = lan.connect(1, "Ada");
|
||||
lan.tick();
|
||||
// The host's player hits the rock, but Ada's copy of that tick loses the
|
||||
// command, as if her machine had run it differently.
|
||||
lan.host.act_local(Action::Dig { pos: lan.rock() });
|
||||
lan.host.step();
|
||||
lan.deliver_changed(|_, bytes| match ToGuest::decode(&bytes) {
|
||||
Ok(ToGuest::Tick { tick, check, .. }) => ToGuest::Tick {
|
||||
tick,
|
||||
commands: vec![],
|
||||
check,
|
||||
}
|
||||
.encode(),
|
||||
_ => bytes,
|
||||
});
|
||||
while lan.guest(ada).step().is_some() {}
|
||||
assert!(
|
||||
!lan.guest(ada).has_diverged(),
|
||||
"nobody notices before the next checksum"
|
||||
);
|
||||
|
||||
lan.ticks(CHECK_INTERVAL_TICKS);
|
||||
lan.assert_in_lockstep();
|
||||
assert!(!lan.guest(ada).has_diverged());
|
||||
let divergences = lan.host.take_divergences();
|
||||
assert_eq!(divergences.len(), 1);
|
||||
assert_eq!(divergences[0].peer, ada);
|
||||
|
||||
let evidence = lan
|
||||
.guest(ada)
|
||||
.take_diverged_recording()
|
||||
.expect("the recording of the diverged world is kept");
|
||||
let replayed = Recording::verify(&evidence).expect("the evidence replays");
|
||||
assert_eq!(replayed.state_hash(), divergences[0].hash);
|
||||
assert_eq!(lan.guest(ada).take_diverged_recording(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_busy_session_stays_in_lockstep() {
|
||||
let mut lan = Lan::new(77);
|
||||
let mut rng = Rng::new(9);
|
||||
let mut peers: Vec<Peer> = [1, 2, 3].map(|key| lan.connect(key, "Busy")).to_vec();
|
||||
let area = TEST_MAP_SIZE / 3;
|
||||
let random_pos = |rng: &mut Rng| {
|
||||
Pos::new(
|
||||
(area + rng.below(area)) as i32,
|
||||
(area + rng.below(area)) as i32,
|
||||
)
|
||||
};
|
||||
for tick in 0..2_000 {
|
||||
for &peer in &peers {
|
||||
if rng.below(3) == 0 {
|
||||
let pos = random_pos(&mut rng);
|
||||
lan.guest(peer).act(Action::Dig { pos });
|
||||
}
|
||||
}
|
||||
if rng.below(2) == 0 {
|
||||
let pos = random_pos(&mut rng);
|
||||
lan.host.act_local(Action::Dig { pos });
|
||||
}
|
||||
if tick == 700 {
|
||||
lan.disconnect(peers.remove(1));
|
||||
}
|
||||
if tick == 1_100 {
|
||||
peers.push(lan.connect(2, "Busy"));
|
||||
}
|
||||
lan.tick();
|
||||
}
|
||||
|
||||
lan.assert_in_lockstep();
|
||||
assert!(lan.host.take_divergences().is_empty());
|
||||
for guest in lan.guests.values() {
|
||||
let replayed = guest.recording().unwrap().replay().unwrap();
|
||||
assert_eq!(&replayed, guest.world().unwrap());
|
||||
}
|
||||
}
|
||||
+183
-6
@@ -1,10 +1,11 @@
|
||||
use grovewright_core::codec::DecodeError;
|
||||
use grovewright_core::codec::{DecodeError, Writer};
|
||||
use grovewright_core::command::Command;
|
||||
use grovewright_core::map::Pos;
|
||||
use grovewright_core::hash::fnv1a64;
|
||||
use grovewright_core::map::{Map, Pos, Terrain};
|
||||
use grovewright_core::mapgen::outcrop_origin;
|
||||
use grovewright_core::player::PlayerId;
|
||||
use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION};
|
||||
use grovewright_core::world::World;
|
||||
use grovewright_core::player::{Player, PlayerId, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS};
|
||||
use grovewright_core::snapshot::{SnapshotError, MAX_RESTORABLE_TICK, SNAPSHOT_VERSION};
|
||||
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
|
||||
@@ -27,6 +28,29 @@ fn played_world() -> World {
|
||||
world
|
||||
}
|
||||
|
||||
/// Builds snapshot bytes by hand: header, state and checksum. This lets tests
|
||||
/// describe states that the rules would never produce.
|
||||
fn snapshot_of(seed: u64, tick: u64, map: &Map, players: &[Player]) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(b"GRWS");
|
||||
w.u32(SNAPSHOT_VERSION);
|
||||
w.u64(seed);
|
||||
w.u64(tick);
|
||||
map.encode(&mut w);
|
||||
w.u8(players.len() as u8);
|
||||
for player in players {
|
||||
player.encode(&mut w);
|
||||
}
|
||||
let mut bytes = w.into_bytes();
|
||||
let checksum = fnv1a64(&bytes);
|
||||
bytes.extend_from_slice(&checksum.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn player(key: u8) -> Player {
|
||||
Player::new([key; 16], "P")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_restores_an_identical_world() {
|
||||
let world = played_world();
|
||||
@@ -35,6 +59,13 @@ fn a_snapshot_restores_an_identical_world() {
|
||||
assert_eq!(restored.state_hash(), world.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_is_header_then_state_then_checksum() {
|
||||
let world = played_world();
|
||||
let by_hand = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
|
||||
assert_eq!(world.to_snapshot(), by_hand);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_state_hash_covers_every_part_of_the_state() {
|
||||
let base = played_world();
|
||||
@@ -81,6 +112,35 @@ fn a_restored_world_continues_exactly_like_the_original() {
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_snapshot_keeps_online_players_online() {
|
||||
let restored = World::from_snapshot(&played_world().to_snapshot()).unwrap();
|
||||
assert!(restored.player(ADA).unwrap().online);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_save_starts_with_every_player_offline() {
|
||||
let world = played_world();
|
||||
let loaded = World::load(&world.to_snapshot()).unwrap();
|
||||
assert!(loaded.players().iter().all(|player| !player.online));
|
||||
assert_eq!(loaded.tick(), world.tick());
|
||||
assert_eq!(loaded.map(), world.map());
|
||||
assert_eq!(
|
||||
loaded.player(ADA).unwrap().inventory,
|
||||
world.player(ADA).unwrap().inventory
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_returning_player_can_join_a_loaded_world() {
|
||||
let mut loaded = World::load(&played_world().to_snapshot()).unwrap();
|
||||
let events = loaded.step(&[Command::Join {
|
||||
key: [3; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}]);
|
||||
assert_eq!(events, vec![Event::PlayerJoined { player: ADA }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_not_a_snapshot_are_rejected() {
|
||||
assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot));
|
||||
@@ -104,18 +164,135 @@ fn snapshots_of_another_version_are_rejected() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_changed_byte_is_detected() {
|
||||
let bytes = played_world().to_snapshot();
|
||||
for index in [8, bytes.len() / 2, bytes.len() - 9, bytes.len() - 1] {
|
||||
let mut changed = bytes.clone();
|
||||
changed[index] ^= 0x01;
|
||||
assert_eq!(
|
||||
World::from_snapshot(&changed),
|
||||
Err(SnapshotError::ChecksumMismatch),
|
||||
"byte {index}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_off_and_padded_snapshots_are_rejected() {
|
||||
let bytes = played_world().to_snapshot();
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes[..bytes.len() - 1]),
|
||||
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
Err(SnapshotError::ChecksumMismatch)
|
||||
);
|
||||
|
||||
let mut padded = bytes.clone();
|
||||
padded.push(0);
|
||||
assert_eq!(
|
||||
World::from_snapshot(&padded),
|
||||
Err(SnapshotError::ChecksumMismatch)
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes[..10]),
|
||||
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_content_behind_a_valid_checksum_is_rejected() {
|
||||
let world = played_world();
|
||||
let mut bytes = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
|
||||
bytes.truncate(bytes.len() - 8);
|
||||
bytes.push(0);
|
||||
let checksum = fnv1a64(&bytes);
|
||||
bytes.extend_from_slice(&checksum.to_le_bytes());
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes),
|
||||
Err(SnapshotError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_the_rules_never_produce_are_rejected() {
|
||||
let map = Map::filled(4, 3, Terrain::Ground);
|
||||
let too_many_online: Vec<Player> = (0..=MAX_ONLINE_PLAYERS as u8).map(player).collect();
|
||||
let mut offline_twin = player(1);
|
||||
offline_twin.online = false;
|
||||
let mut waiting_too_long = player(1);
|
||||
waiting_too_long.next_action_tick = 5 + DIG_COOLDOWN_TICKS + 1;
|
||||
|
||||
let cases = [
|
||||
(snapshot_of(1, MAX_RESTORABLE_TICK + 1, &map, &[]), "tick"),
|
||||
(snapshot_of(1, 5, &map, &too_many_online), "online players"),
|
||||
(
|
||||
snapshot_of(1, 5, &map, &[player(1), offline_twin]),
|
||||
"duplicate player key",
|
||||
),
|
||||
(
|
||||
snapshot_of(1, 5, &map, &[waiting_too_long]),
|
||||
"next action tick",
|
||||
),
|
||||
];
|
||||
for (bytes, what) in cases {
|
||||
assert_eq!(
|
||||
World::from_snapshot(&bytes),
|
||||
Err(SnapshotError::Invalid(what)),
|
||||
"{what}"
|
||||
);
|
||||
assert_eq!(
|
||||
World::load(&bytes),
|
||||
Err(SnapshotError::Invalid(what)),
|
||||
"{what}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_right_at_the_limits_are_accepted() {
|
||||
let map = Map::filled(4, 3, Terrain::Ground);
|
||||
let mut players: Vec<Player> = (0..MAX_ONLINE_PLAYERS as u8).map(player).collect();
|
||||
players[0].next_action_tick = MAX_RESTORABLE_TICK + DIG_COOLDOWN_TICKS;
|
||||
let bytes = snapshot_of(1, MAX_RESTORABLE_TICK, &map, &players);
|
||||
assert!(World::from_snapshot(&bytes).is_ok());
|
||||
}
|
||||
|
||||
/// A world that knows `MAX_KNOWN_PLAYERS` players, built through commands.
|
||||
/// They join in groups of `MAX_ONLINE_PLAYERS`, and each group but the last
|
||||
/// leaves before the next one joins, so only the last group is online.
|
||||
fn world_with_a_full_roster() -> World {
|
||||
let mut world = World::new(7);
|
||||
let groups = MAX_KNOWN_PLAYERS / MAX_ONLINE_PLAYERS;
|
||||
for group in 0..groups {
|
||||
let first = group * MAX_ONLINE_PLAYERS;
|
||||
for index in first..first + MAX_ONLINE_PLAYERS {
|
||||
world.step(&[Command::Join {
|
||||
key: [index as u8; 16],
|
||||
name: format!("P{index}"),
|
||||
}]);
|
||||
}
|
||||
if group + 1 < groups {
|
||||
for index in first..first + MAX_ONLINE_PLAYERS {
|
||||
world.step(&[Command::Leave {
|
||||
player: PlayerId(index as u8),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_roster_with_most_players_offline_is_accepted() {
|
||||
let world = world_with_a_full_roster();
|
||||
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
|
||||
let online = world.players().iter().filter(|player| player.online);
|
||||
assert_eq!(online.count(), MAX_ONLINE_PLAYERS);
|
||||
|
||||
let restored = World::from_snapshot(&world.to_snapshot()).unwrap();
|
||||
assert_eq!(restored, world);
|
||||
|
||||
let loaded = World::load(&world.to_snapshot()).unwrap();
|
||||
assert_eq!(loaded.players().len(), MAX_KNOWN_PLAYERS);
|
||||
assert!(loaded.players().iter().all(|player| !player.online));
|
||||
}
|
||||
|
||||
@@ -176,14 +176,6 @@ fn the_world_remembers_a_limited_number_of_players() {
|
||||
assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_all_offline_marks_every_player_offline() {
|
||||
let mut world = World::new(1);
|
||||
world.step(&[join(1), join(2)]);
|
||||
world.set_all_offline();
|
||||
assert!(world.players().iter().all(|player| !player.online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_hits_break_soft_rock_and_give_one_stone() {
|
||||
let (mut world, id, rock) = world_with_player();
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Probelauf M1b: Brücke und Hülle
|
||||
|
||||
M1b ist noch kein Spiel, sondern das Fundament zum Anfassen: Du läufst über die Testkarte und schlägst Stein. Farben und Formen sind Platzhalter; den Grafikstil wählst du in M1d aus drei Stilproben.
|
||||
|
||||
## So startest du
|
||||
|
||||
1. Doppelklick auf `play.bat` im Projektordner. Beim ersten Start baut Rust die Brücke, das dauert einige Minuten.
|
||||
2. Steuerung:
|
||||
- WASD: laufen
|
||||
- Linke Maustaste: Stein schlagen. Ein grün umrandetes Feld ist in Reichweite, ein rot umrandetes nicht.
|
||||
- F5: speichern, F9: gespeicherten Stand laden
|
||||
- Esc: Pause
|
||||
3. Beim Schließen des Fensters wird gespeichert. Beim nächsten Start geht es dort weiter, und die Figur steht wieder am Startpunkt.
|
||||
|
||||
## Fragen
|
||||
|
||||
1. Fühlt sich das Laufen gut an: Tempo, Halten an Wänden, Gleiten an Wänden entlang?
|
||||
2. Passt die Reichweite der Spitzhacke: zu kurz, zu weit oder genau richtig?
|
||||
3. Fühlt sich der Rhythmus des Schlagens gut an? Weicher Fels braucht drei Schläge, höchstens vier Schläge gehen pro Sekunde. Harter Fels lässt sich noch nicht abbauen: Schläge darauf bewirken nichts, auch wenn das Feld grün umrandet ist.
|
||||
4. Ist auf einen Blick klar, was Boden, weicher Fels, harter Fels und der Rand der Karte ist?
|
||||
5. Ist dir etwas Merkwürdiges passiert? Die Aufzeichnungen deiner Sitzungen liegen unter `%APPDATA%\Godot\app_userdata\Grovewright\recordings`. Eine Aufzeichnung entsteht nur, wenn du das Fenster schließt oder mit F9 einen Stand lädst. Schick mir die neueste Datei, dann spiele ich die Sitzung exakt nach. Nach einem Absturz oder nach der Meldung „Die Simulation wurde angehalten“ gibt es von dieser Sitzung keine Aufzeichnung. Schick mir dann stattdessen den Ordner `%APPDATA%\Godot\app_userdata\Grovewright\logs`, und zwar sofort, bevor du das Spiel erneut startest, denn jeder Start verdrängt ältere Logdateien.
|
||||
|
||||
Antworte formlos im Chat oder als Kommentar in Gitea.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Probelauf M1c: Gleichschritt im Netz
|
||||
|
||||
Mit M1c teilen sich bis zu vier Spieler eine Welt. Jeder Rechner rechnet die ganze Welt selbst, übers Netz gehen nur die Befehle. Der Host besitzt die Welt und speichert sie; Gäste kommen und gehen, wann sie wollen. Farben und Formen sind weiter Platzhalter.
|
||||
|
||||
## Was jeder Rechner braucht
|
||||
|
||||
Eine fertig gepackte Fassung des Spiels gibt es noch nicht. Jeder Rechner, der mitspielt, braucht deshalb dasselbe wie deiner:
|
||||
|
||||
- Git für Windows und eine Kopie des Archivs auf demselben Stand wie beim Host. Unterscheiden sich die Stände in Regeln oder Nachrichten, lehnt der Host den Gast ab.
|
||||
- Rust über rustup (https://rustup.rs). rustup bietet an, die Microsoft C++ Build Tools mitzuinstallieren; die braucht es.
|
||||
- Godot 4.7.2, etwa mit `winget install GodotEngine.GodotEngine --version 4.7.2`, sodass der Befehl `godot` in einer Eingabeaufforderung funktioniert.
|
||||
|
||||
Der erste Start mit `play.bat` baut die Brücke zwischen Spiel und Kern und dauert einige Minuten. Danach geht es schnell.
|
||||
|
||||
## So startest du
|
||||
|
||||
1. Doppelklick auf `play.bat`. Im Menü trägst du deinen Namen ein.
|
||||
2. **Allein:** „Allein spielen“ setzt deinen gespeicherten Stand fort.
|
||||
3. **Als Host:** „Welt öffnen“ setzt deinen Stand fort und lässt Gäste zu. Oben rechts steht dann die Statuszeile mit dem Port, etwa „Welt offen auf Port 24680 · Spieler: 1“. Beim ersten Mal fragt Windows, ob Godot im Netzwerk erreichbar sein darf. Erlaube es für private und für öffentliche Netzwerke, denn Windows stuft viele Heimnetze und virtuelle Netze als öffentlich ein. Nur der Host braucht diese Freigabe.
|
||||
4. **Als Gast:** Trag die Adresse des Hosts ein und klick auf „Beitreten“.
|
||||
- Im selben Heimnetz ist die Adresse die IPv4-Adresse des Host-Rechners, etwa `192.168.178.20`; der Host findet sie mit dem Befehl `ipconfig`.
|
||||
- Übers Internet geht es am einfachsten mit einem virtuellen Netz wie Tailscale oder ZeroTier; dann gilt die Adresse des Hosts aus diesem Netz.
|
||||
- Ohne virtuelles Netz braucht der Host eine Portfreigabe für UDP-Port 24680 in seinem Router, und der Gast trägt die öffentliche IP-Adresse des Hosts ein. Bei Anschlüssen ohne eigene öffentliche IPv4-Adresse, etwa mit DS-Lite, geht das nicht.
|
||||
5. **Zwei Fenster auf einem Rechner:** Starte mit `play.bat` und klick auf „Welt öffnen“. Dann Doppelklick auf `play-zweites-fenster.bat`, einen anderen Namen eintragen, Adresse `127.0.0.1`, „Beitreten“. Das zweite Fenster hat ein eigenes Profil und ist deshalb ein zweiter Spieler. Holst du zwischendurch eine neue Version des Spiels, schließ vorher alle Fenster, sonst scheitert der Start mit „Zugriff verweigert“.
|
||||
6. Steuerung wie in M1b: WASD laufen, linke Maustaste Stein schlagen. Nur der Host speichert (F5 und automatisch). Esc pausiert nur, solange keine Gäste da sind; kommt ein Gast, endet die Pause. F9 lädt nur nach „Allein spielen“.
|
||||
|
||||
## Fragen
|
||||
|
||||
1. Hat der Beitritt geklappt? Wie lange dauerte es vom Klick auf „Beitreten“, bis die Welt zu sehen war?
|
||||
2. Wirken deine Schläge als Gast spürbar später als beim Host? Stört das?
|
||||
3. Bewegen sich die Figuren der anderen gleichmäßig, oder ruckeln und springen sie?
|
||||
4. Erkennst du an den Namen über den Figuren, wer wer ist?
|
||||
5. Erschien bei einem Gast irgendwann die Meldung „Welt mit dem Host abgeglichen“? Nur Gäste sehen sie. Wenn ja: Was habt ihr kurz davor getan? Diese Meldung bedeutet, dass zwei Welten auseinandergelaufen sind. Das ist ein Fehler, den ich finden muss.
|
||||
6. Wenn ein Gast die Welt verlässt und später wiederkommt: Hat er seine Steine noch?
|
||||
7. Ist dir etwas Merkwürdiges passiert? Dann schick mir die neuesten Dateien aus dem Ordner `recordings`; mit ihnen spiele ich die Sitzung exakt nach.
|
||||
- Für ein Fenster aus `play.bat` liegt er unter `%APPDATA%\Godot\app_userdata\Grovewright\recordings`, für das zweite Fenster unter `%APPDATA%\Godot\app_userdata\Grovewright\profiles\zweites-fenster\recordings`.
|
||||
- Eine Aufzeichnung entsteht erst, wenn du das Fenster schließt oder mit F9 einen Stand lädst, beim Gast auch, wenn seine Sitzung endet, etwa weil der Host die Welt schließt. Schließ das Fenster also, bevor du die Datei suchst.
|
||||
- Nach der Meldung „Welt mit dem Host abgeglichen“ legt der Gast dort zusätzlich die Aufzeichnung der abgewichenen Welt ab.
|
||||
- Nach einem Absturz oder nach der Meldung „Die Simulation wurde angehalten“ gibt es von dieser Sitzung keine Aufzeichnung. Schick mir dann den Ordner `%APPDATA%\Godot\app_userdata\Grovewright\logs`, und zwar sofort, bevor du das Spiel erneut startest, denn jeder Start verdrängt ältere Logdateien. Alle Fenster eines Rechners schreiben in diesen einen Ordner.
|
||||
|
||||
Antworte formlos im Chat oder als Kommentar in Gitea.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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.
|
||||
5. Mitspieler brauchen weder Git noch Rust noch Godot: Doppelklick auf `paket.bat` im Hauptordner baut `dist\Grovewright-<Commit>-windows.zip`. Die ZIP-Datei enthält das Spiel und eine Anleitung (`LIESMICH.txt`). Du selbst kannst weiter mit `play.bat` spielen, solange du auf demselben Stand bist wie das Paket.
|
||||
|
||||
So sehen die drei Proben aus:
|
||||
|
||||
| Warm | Pastell | Glimmen |
|
||||
|---|---|---|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|
||||
## Fragen
|
||||
|
||||
1. Bleibt es bei Warm, oder gefällt dir im Spiel ein anderer Stil besser?
|
||||
2. Was gefällt dir an deinem Favoriten am besten, und was stört dich daran?
|
||||
3. Gibt es an den anderen beiden etwas, das du übernehmen möchtest, etwa das Leuchten des Lebendigen aus Glimmen?
|
||||
4. Erkennst du auf einen Blick, was Boden, weicher Fels, 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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
[preset.0]
|
||||
|
||||
name="Windows"
|
||||
platform="Windows Desktop"
|
||||
runnable=true
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter="i18n/*.csv"
|
||||
exclude_filter="tests/*, tools/*"
|
||||
export_path="../dist/Grovewright/Grovewright.exe"
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
encrypt_pck=false
|
||||
encrypt_directory=false
|
||||
|
||||
[preset.0.options]
|
||||
|
||||
binary_format/embed_pck=false
|
||||
application/modify_resources=true
|
||||
application/product_name="Grovewright"
|
||||
application/file_description="Grovewright"
|
||||
@@ -0,0 +1,8 @@
|
||||
[configuration]
|
||||
entry_symbol = "gdext_rust_init"
|
||||
compatibility_minimum = 4.7
|
||||
reloadable = true
|
||||
|
||||
[libraries]
|
||||
windows.debug.x86_64 = "res://../target/debug/grovewright_bridge.dll"
|
||||
windows.release.x86_64 = "res://../target/x86_64-pc-windows-msvc/release/grovewright_bridge.dll"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cftoabe1brb4d
|
||||
@@ -0,0 +1,53 @@
|
||||
keys,de,en
|
||||
STONE_COUNT,Stein: %d,Stone: %d
|
||||
SAVED,Gespeichert,Saved
|
||||
SAVE_FAILED,Speichern fehlgeschlagen,Saving failed
|
||||
LOADED,Spielstand geladen,Save loaded
|
||||
LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
|
||||
INVENTORY_FULL,Die Tasche ist voll,Your bag is full
|
||||
PAUSED,Pause,Paused
|
||||
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
|
||||
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F2: Stil wechseln · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F2: change the style · F5: save · F9: load · Esc: pause
|
||||
DEFAULT_NAME,Spieler,Player
|
||||
MENU_NAME,Dein Name,Your name
|
||||
MENU_SOLO,Allein spielen,Play alone
|
||||
MENU_HOST,Welt öffnen,Open your world
|
||||
MENU_ADDRESS,Adresse des Hosts,Address of the host
|
||||
MENU_JOIN,Beitreten,Join
|
||||
CONNECTING,Verbinde mit %s …,Connecting to %s …
|
||||
CONNECT_FAILED,Keine Verbindung zu %s,Could not reach %s
|
||||
ADDRESS_INVALID,Das ist keine Adresse: %s,That is not an address: %s
|
||||
HOST_LEFT,Der Host hat die Welt geschlossen,The host closed the world
|
||||
PROTOCOL_ERROR,Die Verbindung zum Host ist gestört,The connection to the host is broken
|
||||
REFUSED_OTHER_PROTOCOL,Der Host hat eine andere Version des Spiels,The host has another version of the game
|
||||
REFUSED_OTHER_RULES,Der Host hat andere Spielregeln. Ihr braucht denselben Stand des Spiels.,The host has other rules. You need the same version of the game.
|
||||
REFUSED_WORLD_FULL,Die Welt ist voll: Vier Spieler sind schon da,The world is full: four players are in it
|
||||
REFUSED_TOO_MANY_PLAYERS,Diese Welt kennt schon 16 Spieler und nimmt keine neuen mehr auf,This world knows 16 players already and takes no new ones
|
||||
REFUSED_ALREADY_PLAYING,Ein Spieler mit deinem Schlüssel ist schon in der Welt,A player with your key is in the world already
|
||||
STATUS_HOST,Welt offen auf Port %d · Spieler: %d,World open on port %d · players: %d
|
||||
STATUS_GUEST,Gast bei %s · Spieler: %d,Guest of %s · players: %d
|
||||
OPEN_FAILED,Port %d lässt sich nicht öffnen,Port %d cannot be opened
|
||||
PLAYER_JOINED,%s ist da,%s joined
|
||||
PLAYER_LEFT,%s ist gegangen,%s left
|
||||
RESYNCED,Welt mit dem Host abgeglichen,World matched with the host again
|
||||
PAUSE_ONLY_ALONE,Pause gibt es nur ohne Gäste,Pausing only works without guests
|
||||
LOAD_ONLY_ALONE,Laden gibt es nur beim Spielen allein,Loading only works when playing alone
|
||||
HOST_SAVES,Nur der Host speichert,Only the host saves
|
||||
KEY_UNREADABLE,Dein Spielerschlüssel lässt sich gerade nicht lesen,Your player key cannot be read right now
|
||||
SAVE_UNREADABLE,Der Spielstand lässt sich gerade nicht lesen,The save cannot be read right now
|
||||
STYLE_WARM,Warm,Warm
|
||||
STYLE_PASTEL,Pastell,Pastel
|
||||
STYLE_GLOW,Glimmen,Glow
|
||||
STYLE_SWITCHED,Stil: %s,Style: %s
|
||||
MENU_SHOWCASE,Stilproben ansehen,See the style samples
|
||||
SHOWCASE_TITLE,Stilprobe: %s,Style sample: %s
|
||||
SHOWCASE_BACK,Zurück,Back
|
||||
SHOWCASE_ROCK,Weicher und harter Fels,Soft and hard rock
|
||||
SHOWCASE_DEAD_SOIL,Toter Boden,Dead soil
|
||||
SHOWCASE_LIVING_SOIL,Lebendiger Boden,Living soil
|
||||
SHOWCASE_MOSS,Lebensmoos,Life moss
|
||||
SHOWCASE_PLANT,Faserpflanze wächst,A fibre plant grows
|
||||
SHOWCASE_GRAZER,Weidetier,Grazer
|
||||
SHOWCASE_VINE,Ranke trägt Steine,A vine carries stones
|
||||
SHOWCASE_RUIN,Ruine erwacht,A ruin wakes
|
||||
SHOWCASE_FIGURES,Spielfiguren,Player figures
|
||||
|
@@ -0,0 +1,3 @@
|
||||
[remap]
|
||||
|
||||
importer="keep"
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
|
||||
[node name="Main" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -0,0 +1,23 @@
|
||||
; Engine configuration file.
|
||||
; Input actions are registered in code (scripts/input_setup.gd), so this file
|
||||
; holds no serialized input events.
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Grovewright"
|
||||
run/main_scene="res://main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1280
|
||||
window/size/viewport_height=720
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
textures/canvas_textures/default_texture_filter=0
|
||||
2d/snap/snap_2d_transforms_to_pixel=true
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxmii13634y22
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://o2dqirvunvw4
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce6e46e8mwi8f
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dr2fry8j3yai8
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://jejn3txd0eor
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfj1ix7h3wky2
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
uid://7kb2jlqpvvoe
|
||||
@@ -0,0 +1,64 @@
|
||||
class_name Avatar
|
||||
extends Node2D
|
||||
## The local player's figure. It moves outside the lockstep simulation; only
|
||||
## its pickaxe hits become commands. `art` draws it, stepping while it walks
|
||||
## and facing the way it went last.
|
||||
|
||||
## Walking speed: five tiles per second.
|
||||
const SPEED := 5.0 * GridMotion.TILE_SIZE
|
||||
const RADIUS := 5.0
|
||||
## Longest frame that moves the figure in one go. Longer frames are cut, so
|
||||
## that the figure cannot pass through a wall after a stall.
|
||||
const MAX_STEP_SECONDS := 0.1
|
||||
## How far the step of a walking figure turns per pixel, in radians: about
|
||||
## two steps per second at walking speed.
|
||||
const STEP_PER_PIXEL := 0.16
|
||||
|
||||
## Callable(x: int, y: int) -> bool that says whether a tile can be walked on.
|
||||
var is_walkable: Callable
|
||||
var art: Art
|
||||
## The number of the player the figure belongs to, for its colour, or -1
|
||||
## before the player is in the world.
|
||||
var player := -1
|
||||
## 1.0 while the figure faces right, -1.0 while it faces left.
|
||||
var facing := 1.0
|
||||
## Grows while the figure walks; the styles swing the steps by it.
|
||||
var step := 0.0
|
||||
## True while the figure walked in the last frame.
|
||||
var moving := false
|
||||
|
||||
|
||||
## Where a figure standing at `at` is drawn: a tile wide and one and a half
|
||||
## tiles tall, with its feet just below its body, so that it stands upright
|
||||
## in its tile.
|
||||
static func figure_rect(at: Vector2) -> Rect2:
|
||||
var size := GridMotion.TILE_SIZE
|
||||
return Rect2(at.x - size / 2.0, at.y + RADIUS + 1.0 - size * 1.5, size, size * 1.5)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
walk(Input.get_vector("move_left", "move_right", "move_up", "move_down"), delta)
|
||||
|
||||
|
||||
## Moves the figure in `direction` for `delta` seconds, sliding along walls.
|
||||
func walk(direction: Vector2, delta: float) -> void:
|
||||
var was_moving := moving
|
||||
moving = false
|
||||
if direction == Vector2.ZERO or not is_walkable.is_valid():
|
||||
if was_moving:
|
||||
queue_redraw()
|
||||
return
|
||||
var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS)
|
||||
var before := position
|
||||
position = GridMotion.move(position, motion, RADIUS, is_walkable)
|
||||
var walked := position.distance_to(before)
|
||||
moving = walked > 0.0
|
||||
step += walked * STEP_PER_PIXEL
|
||||
if direction.x != 0.0:
|
||||
facing = signf(direction.x)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if art != null:
|
||||
art.draw(self, Art.FIGURE, figure_rect(Vector2.ZERO), {"player": player, "facing": facing, "step": step, "moving": moving})
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpltng2n5f61g
|
||||
@@ -0,0 +1,92 @@
|
||||
class_name Figures
|
||||
extends Node2D
|
||||
## The figures of the other players, with their names, drawn by `art`. A new
|
||||
## position comes over the network about ten times a second; in between,
|
||||
## each figure glides toward the latest one, stepping and facing the way it
|
||||
## goes.
|
||||
|
||||
## How quickly a figure closes the gap to its latest position, per second.
|
||||
const GLIDE := 15.0
|
||||
## Pixels per frame below which a gliding figure counts as standing.
|
||||
const STANDING := 0.05
|
||||
## Size and width of the names, in pixels of the playing field. The names
|
||||
## are drawn at the size they have on screen, so that they stay sharp.
|
||||
const NAME_SIZE := 8
|
||||
const NAME_WIDTH := 80.0
|
||||
const NAME_OUTLINE := Color(0.05, 0.05, 0.06)
|
||||
|
||||
var session: GwSession
|
||||
var art: Art
|
||||
var _shown: Dictionary[int, Vector2] = {}
|
||||
var _latest: Dictionary[int, Vector2] = {}
|
||||
var _facing: Dictionary[int, float] = {}
|
||||
var _step: Dictionary[int, float] = {}
|
||||
var _moving: Dictionary[int, bool] = {}
|
||||
|
||||
|
||||
## Moves the figure of `player` toward `position`. A figure that was not
|
||||
## shown yet appears there at once.
|
||||
func place(player: int, position: Vector2) -> void:
|
||||
if not _shown.has(player):
|
||||
_shown[player] = position
|
||||
_latest[player] = position
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Removes the figure of `player`.
|
||||
func forget(player: int) -> void:
|
||||
for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]:
|
||||
figures.erase(player)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func clear() -> void:
|
||||
for figures: Dictionary in [_shown, _latest, _facing, _step, _moving]:
|
||||
figures.clear()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Where the figure of `player` is shown, or Vector2.INF if it is not.
|
||||
func shown_at(player: int) -> Vector2:
|
||||
return _shown.get(player, Vector2.INF)
|
||||
|
||||
|
||||
## 1.0 if the figure of `player` faces right, -1.0 if it faces left.
|
||||
func facing_of(player: int) -> float:
|
||||
return _facing.get(player, 1.0)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _shown.is_empty():
|
||||
return
|
||||
var weight := minf(1.0, GLIDE * delta)
|
||||
for player in _shown:
|
||||
var before := _shown[player]
|
||||
_shown[player] = before.lerp(_latest[player], weight)
|
||||
var moved := _shown[player] - before
|
||||
if absf(moved.x) > STANDING:
|
||||
_facing[player] = signf(moved.x)
|
||||
_step[player] = _step.get(player, 0.0) + moved.length() * Avatar.STEP_PER_PIXEL
|
||||
_moving[player] = moved.length() > STANDING
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if art == null:
|
||||
return
|
||||
var font := ThemeDB.fallback_font
|
||||
# The camera enlarges the field. Text drawn at its size on screen and then
|
||||
# shrunk stays sharp; text drawn small and enlarged would blur.
|
||||
var zoom := maxf(1.0, get_global_transform_with_canvas().get_scale().x)
|
||||
for player in _shown:
|
||||
var at := _shown[player]
|
||||
var state := {"player": player, "facing": facing_of(player), "step": _step.get(player, 0.0), "moving": _moving.get(player, false)}
|
||||
art.draw(self, Art.FIGURE, Avatar.figure_rect(at), state)
|
||||
if session != null:
|
||||
var label := Vector2(at.x - NAME_WIDTH / 2.0, Avatar.figure_rect(at).position.y - 2.0)
|
||||
var text := session.player_name(player)
|
||||
var size := roundi(NAME_SIZE * zoom)
|
||||
draw_set_transform(label, 0.0, Vector2.ONE / zoom)
|
||||
draw_string_outline(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size, maxi(2, roundi(zoom * 1.5)), NAME_OUTLINE)
|
||||
draw_string(font, Vector2.ZERO, text, HORIZONTAL_ALIGNMENT_CENTER, NAME_WIDTH * zoom, size)
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
@@ -0,0 +1 @@
|
||||
uid://0krucvyguufx
|
||||
@@ -0,0 +1,44 @@
|
||||
class_name GridMotion
|
||||
extends RefCounted
|
||||
## Movement of a body over the tile grid. Figures move outside the lockstep
|
||||
## simulation, so this code may use floating point.
|
||||
|
||||
## Edge length of a tile, in pixels.
|
||||
const TILE_SIZE := 16.0
|
||||
|
||||
|
||||
## The tile that contains `point`.
|
||||
static func tile_of(point: Vector2) -> Vector2i:
|
||||
return Vector2i(floori(point.x / TILE_SIZE), floori(point.y / TILE_SIZE))
|
||||
|
||||
|
||||
## The centre of `tile`, in pixels.
|
||||
static func tile_center(tile: Vector2i) -> Vector2:
|
||||
return (Vector2(tile) + Vector2(0.5, 0.5)) * TILE_SIZE
|
||||
|
||||
|
||||
## True if a square body of half width `radius` around `center` touches
|
||||
## only walkable tiles. `is_walkable` is a Callable(x: int, y: int) -> bool.
|
||||
static func fits(center: Vector2, radius: float, is_walkable: Callable) -> bool:
|
||||
var first := tile_of(center - Vector2(radius, radius))
|
||||
# The far edge lies just inside its tile, so a body that ends exactly on
|
||||
# a tile border does not count the next tile.
|
||||
var last := tile_of(center + Vector2(radius, radius) - Vector2(0.001, 0.001))
|
||||
for y in range(first.y, last.y + 1):
|
||||
for x in range(first.x, last.x + 1):
|
||||
if not is_walkable.call(x, y):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## Moves a body by `motion`, one axis after the other. An axis that would
|
||||
## end in a wall is skipped, so a body pushed into a wall slides along it.
|
||||
static func move(center: Vector2, motion: Vector2, radius: float, is_walkable: Callable) -> Vector2:
|
||||
var result := center
|
||||
var along_x := result + Vector2(motion.x, 0.0)
|
||||
if fits(along_x, radius, is_walkable):
|
||||
result = along_x
|
||||
var along_y := result + Vector2(0.0, motion.y)
|
||||
if fits(along_y, radius, is_walkable):
|
||||
result = along_y
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn0m8pxkigqig
|
||||
@@ -0,0 +1,77 @@
|
||||
class_name Hud
|
||||
extends CanvasLayer
|
||||
## Text over the game: the stone count, short messages, a status line for
|
||||
## playing together and a help line.
|
||||
|
||||
const MESSAGE_SECONDS := 2.5
|
||||
|
||||
var _stone := Label.new()
|
||||
var _message := Label.new()
|
||||
var _status := Label.new()
|
||||
var _help := Label.new()
|
||||
var _message_left := 0.0
|
||||
var _paused := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_stone.position = Vector2(16, 12)
|
||||
_stone.add_theme_font_size_override("font_size", 22)
|
||||
_message.position = Vector2(16, 48)
|
||||
_message.add_theme_font_size_override("font_size", 18)
|
||||
_status.add_theme_font_size_override("font_size", 14)
|
||||
_status.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT, Control.PRESET_MODE_MINSIZE, 16)
|
||||
_status.grow_horizontal = Control.GROW_DIRECTION_BEGIN
|
||||
_help.text = tr("HELP")
|
||||
_help.add_theme_font_size_override("font_size", 14)
|
||||
for label: Label in [_stone, _message, _status, _help]:
|
||||
label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
label.add_theme_constant_override("outline_size", 4)
|
||||
add_child(label)
|
||||
_help.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT, Control.PRESET_MODE_MINSIZE, 16)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _message_left <= 0.0:
|
||||
return
|
||||
_message_left -= delta
|
||||
if _message_left <= 0.0:
|
||||
_message.text = tr("PAUSED") if _paused else ""
|
||||
|
||||
|
||||
func show_stone(count: int) -> void:
|
||||
_stone.text = tr("STONE_COUNT") % count
|
||||
|
||||
|
||||
## Shows the text for `key` for a few seconds, with `values` filled into
|
||||
## its placeholders.
|
||||
func flash(key: String, values: Array = []) -> void:
|
||||
_message.text = tr(key) % values if not values.is_empty() else tr(key)
|
||||
_message_left = MESSAGE_SECONDS
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
## Shows `text` in the top right corner until it is replaced.
|
||||
func show_status(text: String) -> void:
|
||||
_status.text = text
|
||||
|
||||
|
||||
## The text currently shown in the status line. Tests read it.
|
||||
func status_text() -> String:
|
||||
return _status.text
|
||||
|
||||
|
||||
## The text currently shown as a message. Tests read it.
|
||||
func message() -> String:
|
||||
return _message.text
|
||||
|
||||
|
||||
## The text currently shown as the stone count. Tests read it.
|
||||
func stone_text() -> String:
|
||||
return _stone.text
|
||||
@@ -0,0 +1 @@
|
||||
uid://cosq3mrn0ve5v
|
||||
@@ -0,0 +1,41 @@
|
||||
class_name I18n
|
||||
extends RefCounted
|
||||
## Loads the game's texts from a CSV file: the first column holds the key,
|
||||
## every further column one language, named in the first row.
|
||||
##
|
||||
## The file is read at run time. Its import setting is "keep", so Godot
|
||||
## generates no translation files from it.
|
||||
|
||||
const TEXTS_FILE := "res://i18n/texts.csv"
|
||||
|
||||
static var _installed := false
|
||||
|
||||
|
||||
## Adds the translations of TEXTS_FILE to the TranslationServer, once.
|
||||
static func install() -> void:
|
||||
if _installed:
|
||||
return
|
||||
for translation in load_translations(TEXTS_FILE):
|
||||
TranslationServer.add_translation(translation)
|
||||
_installed = true
|
||||
|
||||
|
||||
## One Translation per language column of the CSV file at `path`.
|
||||
static func load_translations(path: String) -> Array[Translation]:
|
||||
var translations: Array[Translation] = []
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("texts not found: %s" % path)
|
||||
return translations
|
||||
var header := file.get_csv_line()
|
||||
for column in range(1, header.size()):
|
||||
var translation := Translation.new()
|
||||
translation.locale = header[column]
|
||||
translations.append(translation)
|
||||
while not file.eof_reached():
|
||||
var row := file.get_csv_line()
|
||||
if row.size() < header.size() or row[0].is_empty():
|
||||
continue
|
||||
for column in range(1, header.size()):
|
||||
translations[column - 1].add_message(row[0], row[column])
|
||||
return translations
|
||||
@@ -0,0 +1 @@
|
||||
uid://dy10r7pfqy63v
|
||||
@@ -0,0 +1,40 @@
|
||||
class_name InputSetup
|
||||
extends RefCounted
|
||||
## Registers the game's input actions in code, so that project.godot holds no
|
||||
## serialized input events. Keys are physical positions, so WASD stays in
|
||||
## place on German keyboards too.
|
||||
|
||||
|
||||
static func install() -> void:
|
||||
_add_keys("move_up", [KEY_W, KEY_UP])
|
||||
_add_keys("move_down", [KEY_S, KEY_DOWN])
|
||||
_add_keys("move_left", [KEY_A, KEY_LEFT])
|
||||
_add_keys("move_right", [KEY_D, KEY_RIGHT])
|
||||
_add_keys("save_game", [KEY_F5])
|
||||
_add_keys("load_game", [KEY_F9])
|
||||
_add_keys("toggle_pause", [KEY_ESCAPE])
|
||||
_add_keys("next_style", [KEY_F2])
|
||||
_add_keys("back", [KEY_ESCAPE])
|
||||
_add_keys("style_1", [KEY_1])
|
||||
_add_keys("style_2", [KEY_2])
|
||||
_add_keys("style_3", [KEY_3])
|
||||
_add_mouse_button("dig", MOUSE_BUTTON_LEFT)
|
||||
|
||||
|
||||
static func _add_keys(action: StringName, keys: Array[Key]) -> void:
|
||||
if InputMap.has_action(action):
|
||||
return
|
||||
InputMap.add_action(action)
|
||||
for key in keys:
|
||||
var event := InputEventKey.new()
|
||||
event.physical_keycode = key
|
||||
InputMap.action_add_event(action, event)
|
||||
|
||||
|
||||
static func _add_mouse_button(action: StringName, button: MouseButton) -> void:
|
||||
if InputMap.has_action(action):
|
||||
return
|
||||
InputMap.add_action(action)
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = button
|
||||
InputMap.action_add_event(action, event)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dvckt6ss6k4xi
|
||||
@@ -0,0 +1,83 @@
|
||||
class_name LaunchOptions
|
||||
extends RefCounted
|
||||
## What the command line asks for. Godot passes the arguments after "--":
|
||||
## --profile=NAME keeps key, save and recordings in a folder of their own,
|
||||
## so that two windows on one machine are two players
|
||||
## --name=NAME the player's name; like a name typed in the menu, it is
|
||||
## remembered for the next start
|
||||
## --solo starts playing alone at once, without the menu
|
||||
## --host opens the world for guests at once
|
||||
## --port=PORT the port an open world listens on
|
||||
## --join=ADDRESS joins a host at once; ADDRESS may end in ":PORT"
|
||||
## --style=NAME draws the playing field in the style NAME: warm,
|
||||
## pastel or glow
|
||||
|
||||
## Longest profile name.
|
||||
const MAX_PROFILE_LENGTH := 32
|
||||
## Characters a profile name may contain.
|
||||
const PROFILE_CHARACTERS := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
|
||||
|
||||
var profile := ""
|
||||
var player_name := ""
|
||||
## How to start without the menu: "", "solo", "host" or "join".
|
||||
var start := ""
|
||||
var port := NetLink.DEFAULT_PORT
|
||||
var address := ""
|
||||
## The style to draw in, or empty for the one in the settings.
|
||||
var style := &""
|
||||
## Arguments that were not understood and are ignored.
|
||||
var ignored := PackedStringArray()
|
||||
|
||||
|
||||
## Reads `arguments`. Unknown arguments and bad values end up in `ignored`.
|
||||
static func parse(arguments: PackedStringArray) -> LaunchOptions:
|
||||
var options := LaunchOptions.new()
|
||||
for argument in arguments:
|
||||
var value := argument.get_slice("=", 1) if argument.contains("=") else ""
|
||||
match argument.get_slice("=", 0):
|
||||
"--profile":
|
||||
if is_valid_profile(value):
|
||||
options.profile = value
|
||||
else:
|
||||
options.ignored.append(argument)
|
||||
"--name":
|
||||
options.player_name = value
|
||||
"--solo":
|
||||
options.start = "solo"
|
||||
"--host":
|
||||
options.start = "host"
|
||||
"--port":
|
||||
if value.is_valid_int() and value.to_int() >= 0 and value.to_int() <= 65535:
|
||||
options.port = value.to_int()
|
||||
else:
|
||||
options.ignored.append(argument)
|
||||
"--join":
|
||||
options.start = "join"
|
||||
options.address = value
|
||||
"--style":
|
||||
if Art.style_keys().has(StringName(value)):
|
||||
options.style = StringName(value)
|
||||
else:
|
||||
options.ignored.append(argument)
|
||||
_:
|
||||
options.ignored.append(argument)
|
||||
return options
|
||||
|
||||
|
||||
## Whether `name` can name a profile folder: up to 32 of the letters A to Z,
|
||||
## digits, "-" and "_".
|
||||
static func is_valid_profile(name: String) -> bool:
|
||||
if name.is_empty() or name.length() > MAX_PROFILE_LENGTH:
|
||||
return false
|
||||
for character in name:
|
||||
if not PROFILE_CHARACTERS.contains(character):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
## The folder for the player's files. The default profile uses the user
|
||||
## folder itself, so that saves of earlier versions stay where they are.
|
||||
func storage_dir() -> String:
|
||||
if profile.is_empty():
|
||||
return "user://"
|
||||
return "user://profiles".path_join(profile)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkgt2ownscxuj
|
||||
@@ -0,0 +1,537 @@
|
||||
extends Node2D
|
||||
## Composition root. Shows the start menu, then runs a session as host or as
|
||||
## guest with the map view, the figures, the HUD and the files on disk.
|
||||
|
||||
## Seed of a new world until the slice ships its chosen seed.
|
||||
const NEW_WORLD_SEED := 2026
|
||||
## How far the pickaxe reaches, in tiles from the figure to the tile centre.
|
||||
const REACH_TILES := 2.5
|
||||
## Ticks between two automatic saves: two minutes.
|
||||
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
|
||||
const CAMERA_ZOOM := Vector2(3.0, 3.0)
|
||||
## Most ticks a guest runs in one frame. A guest that fell behind catches up
|
||||
## within a few frames without freezing.
|
||||
const MAX_GUEST_TICKS_PER_FRAME := 20
|
||||
## Seconds between two position updates of the own figure.
|
||||
const FIGURE_SECONDS := 0.1
|
||||
## The text for every reason a host gives for turning a guest away.
|
||||
const REFUSAL_TEXTS := {
|
||||
GwSession.REFUSED_OTHER_PROTOCOL: "REFUSED_OTHER_PROTOCOL",
|
||||
GwSession.REFUSED_OTHER_RULES: "REFUSED_OTHER_RULES",
|
||||
GwSession.REFUSED_WORLD_FULL: "REFUSED_WORLD_FULL",
|
||||
GwSession.REFUSED_TOO_MANY_PLAYERS: "REFUSED_TOO_MANY_PLAYERS",
|
||||
GwSession.REFUSED_ALREADY_PLAYING: "REFUSED_ALREADY_PLAYING",
|
||||
}
|
||||
|
||||
## Folder for the player key, the save and the recordings. If it is empty,
|
||||
## the command line decides; tests set a folder of their own.
|
||||
@export var storage_dir := ""
|
||||
## Network interface an open world listens on. Tests use the loopback one.
|
||||
var listen_address := "*"
|
||||
## The command line arguments after "--". Tools and tests may set their own
|
||||
## before the scene enters the tree.
|
||||
var command_line := OS.get_cmdline_user_args()
|
||||
|
||||
var options: LaunchOptions
|
||||
var store: SaveStore
|
||||
## The running session, or null in the menu. A guest's session has no world
|
||||
## until the host sent it.
|
||||
var session: GwSession
|
||||
## The network connection of an open world or a guest, or null.
|
||||
var link: SessionLink
|
||||
var clock: TickClock
|
||||
## The style the playing field is drawn in.
|
||||
var art: Art
|
||||
var menu: Menu
|
||||
var showcase: Showcase
|
||||
var map_view: MapView
|
||||
var marker: TileMarker
|
||||
var avatar: Avatar
|
||||
var figures: Figures
|
||||
var hud: Hud
|
||||
## How often a guest's world diverged and was replaced by the host's.
|
||||
var resyncs := 0
|
||||
|
||||
var _player_key: PackedByteArray
|
||||
var _next_dig_tick := 0
|
||||
## True while an unusable save could not be set aside. Saving is refused
|
||||
## then, so that the unusable save is never overwritten.
|
||||
var _save_blocked := false
|
||||
var _figure_wait := 0.0
|
||||
## The address a guest is joining, for messages.
|
||||
var _joining := ""
|
||||
## The host's accepted guests when _process last looked. A new one ends a
|
||||
## pause.
|
||||
var _known_guests := 0
|
||||
## 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:
|
||||
I18n.install()
|
||||
InputSetup.install()
|
||||
options = LaunchOptions.parse(command_line)
|
||||
for argument in options.ignored:
|
||||
push_warning("ignored command line argument: %s" % argument)
|
||||
if storage_dir.is_empty():
|
||||
storage_dir = options.storage_dir()
|
||||
store = SaveStore.new(storage_dir)
|
||||
_player_key = store.player_key()
|
||||
if _player_key.is_empty():
|
||||
push_warning("the player key cannot be read: %s" % error_string(store.last_read_error))
|
||||
clock = TickClock.new(GwSession.TICKS_PER_SECOND)
|
||||
art = Art.new()
|
||||
art.load_overrides(Art.OVERRIDE_DIR)
|
||||
if not art.select(options.style):
|
||||
art.select(StringName(store.style()))
|
||||
map_view = MapView.new()
|
||||
map_view.art = art
|
||||
add_child(map_view)
|
||||
marker = TileMarker.new()
|
||||
marker.art = art
|
||||
add_child(marker)
|
||||
figures = Figures.new()
|
||||
figures.art = art
|
||||
add_child(figures)
|
||||
avatar = Avatar.new()
|
||||
avatar.art = art
|
||||
add_child(avatar)
|
||||
var camera := Camera2D.new()
|
||||
camera.zoom = CAMERA_ZOOM
|
||||
avatar.add_child(camera)
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
menu = Menu.new()
|
||||
add_child(menu)
|
||||
menu.name_field.text = options.player_name if not options.player_name.is_empty() else store.player_name()
|
||||
menu.address_field.text = options.address if not options.address.is_empty() else store.last_address()
|
||||
art.changed.connect(_redraw_art)
|
||||
menu.play_chosen.connect(play)
|
||||
menu.join_chosen.connect(join)
|
||||
menu.showcase_chosen.connect(open_showcase)
|
||||
showcase = Showcase.new()
|
||||
showcase.art = art
|
||||
showcase.closed.connect(close_showcase)
|
||||
_hide_showcase()
|
||||
add_child(showcase)
|
||||
_show_menu("")
|
||||
match options.start:
|
||||
"solo":
|
||||
play(false)
|
||||
"host":
|
||||
play(true)
|
||||
"join":
|
||||
join(options.address)
|
||||
|
||||
|
||||
## Plays the saved world, or a new one, as its host. With `open`, guests may
|
||||
## join. Returns false if it could not start; the menu then says why.
|
||||
func play(open: bool) -> bool:
|
||||
if not _can_start():
|
||||
return false
|
||||
var next := GwSession.new()
|
||||
var set_aside := false
|
||||
if store.has_save():
|
||||
var save := store.read_save()
|
||||
if store.last_read_error != OK:
|
||||
_show_menu(tr("SAVE_UNREADABLE"))
|
||||
return false
|
||||
set_aside = not _load(next, save)
|
||||
if not next.is_running():
|
||||
next.start_new(NEW_WORLD_SEED)
|
||||
if open:
|
||||
var net := NetLink.new()
|
||||
if net.host(options.port, listen_address) != OK:
|
||||
_show_menu(tr("OPEN_FAILED") % options.port)
|
||||
return false
|
||||
link = SessionLink.new(next, net)
|
||||
session = next
|
||||
session.join(_player_key, _player_name())
|
||||
_run_tick()
|
||||
_show_world()
|
||||
if set_aside:
|
||||
hud.flash("LOAD_FAILED")
|
||||
return true
|
||||
|
||||
|
||||
## Starts joining the host at `address`, "host" or "host:port". The world
|
||||
## arrives later; until then the menu says that it is connecting.
|
||||
func join(address: String) -> void:
|
||||
if not _can_start():
|
||||
return
|
||||
var target := NetLink.split_address(address)
|
||||
if target.is_empty():
|
||||
_show_menu(tr("ADDRESS_INVALID") % address)
|
||||
return
|
||||
var net := NetLink.new()
|
||||
if net.join(target[0], target[1]) != OK:
|
||||
_show_menu(tr("CONNECT_FAILED") % address)
|
||||
return
|
||||
session = GwSession.new()
|
||||
session.start_guest(_player_key, _player_name())
|
||||
link = SessionLink.new(session, net)
|
||||
_joining = address
|
||||
store.set_last_address(address)
|
||||
_show_menu(tr("CONNECTING") % address)
|
||||
|
||||
|
||||
func _can_start() -> bool:
|
||||
if session != null:
|
||||
return false
|
||||
if _player_key.is_empty():
|
||||
_show_menu(tr("KEY_UNREADABLE"))
|
||||
return false
|
||||
store.set_player_name(menu.player_name())
|
||||
return true
|
||||
|
||||
|
||||
func _player_name() -> String:
|
||||
var typed := menu.player_name()
|
||||
return typed if not typed.is_empty() else tr("DEFAULT_NAME")
|
||||
|
||||
|
||||
## Starts `next` from `save`. If the save is unusable, it is set aside so
|
||||
## that the next save does not overwrite it, and false is returned.
|
||||
func _load(next: GwSession, save: PackedByteArray) -> bool:
|
||||
if next.start_loaded(save):
|
||||
_save_blocked = false
|
||||
return true
|
||||
var aside := store.set_aside_save()
|
||||
if aside.is_empty():
|
||||
_save_blocked = true
|
||||
push_warning("save not usable (%s) and could not be set aside; saving is off" % next.last_error())
|
||||
else:
|
||||
push_warning("save not usable (%s), kept as %s" % [next.last_error(), aside])
|
||||
return false
|
||||
|
||||
|
||||
func _show_menu(status: String) -> void:
|
||||
menu.show_status(status)
|
||||
menu.show()
|
||||
hud.hide()
|
||||
for node: Node2D in [avatar, marker, figures]:
|
||||
node.hide()
|
||||
|
||||
|
||||
## Shows the world of the session and puts the figure on the spawn.
|
||||
func _show_world() -> void:
|
||||
menu.hide()
|
||||
_hide_showcase()
|
||||
hud.show()
|
||||
for node: Node2D in [avatar, marker, figures]:
|
||||
node.show()
|
||||
map_view.session = session
|
||||
map_view.reload()
|
||||
figures.session = session
|
||||
avatar.player = session.local_player()
|
||||
figures.clear()
|
||||
avatar.is_walkable = session.is_walkable
|
||||
avatar.position = GridMotion.tile_center(session.spawn_point())
|
||||
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
||||
_update_status()
|
||||
_next_dig_tick = 0
|
||||
_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.
|
||||
func _update_status() -> void:
|
||||
if link == null:
|
||||
hud.show_status("")
|
||||
elif session.is_host():
|
||||
hud.show_status(tr("STATUS_HOST") % [link.link.local_port(), session.online_players().size()])
|
||||
else:
|
||||
hud.show_status(tr("STATUS_GUEST") % [_joining, session.online_players().size()])
|
||||
|
||||
|
||||
## Ends a guest's session and goes back to the menu with `message`.
|
||||
func _leave(message: String) -> void:
|
||||
if session == null:
|
||||
return
|
||||
_write_recording()
|
||||
if link != null:
|
||||
link.link.close()
|
||||
link = null
|
||||
session = null
|
||||
map_view.session = null
|
||||
map_view.reload()
|
||||
avatar.is_walkable = Callable()
|
||||
figures.clear()
|
||||
_own_hits.clear()
|
||||
_show_menu(message)
|
||||
|
||||
|
||||
## A broken session runs no more ticks. Instead of leaving the others
|
||||
## waiting, a host closes its world to guests and a guest leaves. The host's
|
||||
## status line says why its world stands still.
|
||||
func _stop_sharing() -> void:
|
||||
if not session.is_host():
|
||||
_leave(tr("SIMULATION_STOPPED"))
|
||||
return
|
||||
if link != null:
|
||||
link.link.close()
|
||||
link = null
|
||||
figures.clear()
|
||||
hud.show_status(tr("SIMULATION_STOPPED"))
|
||||
|
||||
|
||||
## Ends a pause once the host accepted a guest: the guest joins in the next
|
||||
## tick, and a paused world would keep it waiting. A connection that has not
|
||||
## greeted yet is no guest.
|
||||
func _end_pause_for_new_guests() -> void:
|
||||
var guests := session.guest_count()
|
||||
if guests > _known_guests and clock.paused:
|
||||
_set_paused(false)
|
||||
_known_guests = guests
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if link != null:
|
||||
for notice in link.pump(delta):
|
||||
_on_notice(notice)
|
||||
if session != null and session.is_broken():
|
||||
_stop_sharing()
|
||||
if session == null or not session.is_running() or session.is_broken():
|
||||
return
|
||||
_end_pause_for_new_guests()
|
||||
_update_marker()
|
||||
if Input.is_action_pressed("dig") and not clock.paused:
|
||||
_try_dig()
|
||||
if session.is_host():
|
||||
for i in clock.advance(delta):
|
||||
_run_tick()
|
||||
else:
|
||||
for i in mini(session.pending_ticks(), MAX_GUEST_TICKS_PER_FRAME):
|
||||
_apply(session.step())
|
||||
if link != null:
|
||||
link.flush()
|
||||
_figure_wait -= delta
|
||||
if _figure_wait <= 0.0:
|
||||
_figure_wait = FIGURE_SECONDS
|
||||
link.send_figure(avatar.position)
|
||||
|
||||
|
||||
func _on_notice(notice: Dictionary) -> void:
|
||||
# An earlier notice of the same batch may have ended the session.
|
||||
if session == null:
|
||||
return
|
||||
match notice["type"]:
|
||||
"world_received":
|
||||
if notice["resync"]:
|
||||
resyncs += 1
|
||||
_keep_diverged_recording()
|
||||
map_view.reload()
|
||||
_own_hits.clear()
|
||||
hud.show_stone(session.item_count(GwSession.ITEM_STONE))
|
||||
hud.flash("RESYNCED")
|
||||
else:
|
||||
_show_world()
|
||||
"refused":
|
||||
_leave(tr(REFUSAL_TEXTS.get(notice["reason"], "PROTOCOL_ERROR")))
|
||||
"protocol_error":
|
||||
push_warning("broken message from the host: %s" % session.last_error())
|
||||
_leave(tr("PROTOCOL_ERROR"))
|
||||
"host_lost":
|
||||
_leave(tr("HOST_LEFT"))
|
||||
"failed":
|
||||
_leave(tr("CONNECT_FAILED") % _joining)
|
||||
"divergence":
|
||||
push_warning("guest %d diverged at tick %d with state %s" % [notice["peer"], notice["tick"], notice["hash"]])
|
||||
"figure":
|
||||
figures.place(notice["player"], notice["position"])
|
||||
|
||||
|
||||
## Keeps the recording of a guest's world that diverged from the host's, so
|
||||
## that the divergence can be replayed.
|
||||
func _keep_diverged_recording() -> void:
|
||||
var path := store.write_recording(session.take_diverged_recording())
|
||||
push_warning("the world diverged from the host's; its recording is %s" % path)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if session == null or not session.is_running():
|
||||
return
|
||||
if event.is_action_pressed("save_game"):
|
||||
if session.is_host():
|
||||
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
|
||||
else:
|
||||
hud.flash("HOST_SAVES")
|
||||
elif event.is_action_pressed("load_game"):
|
||||
_reload()
|
||||
elif event.is_action_pressed("toggle_pause"):
|
||||
# Ending a pause is always allowed, starting one only alone.
|
||||
if clock.paused or (session.is_host() and session.guest_count() == 0):
|
||||
_set_paused(not clock.paused)
|
||||
else:
|
||||
hud.flash("PAUSE_ONLY_ALONE")
|
||||
elif event.is_action_pressed("next_style"):
|
||||
art.select_next()
|
||||
hud.flash("STYLE_SWITCHED", [tr(art.style.title_key())])
|
||||
|
||||
|
||||
## Goes back to the saved world. Only when playing alone: guests would no
|
||||
## longer have the host's world.
|
||||
func _reload() -> void:
|
||||
if link != null:
|
||||
hud.flash("LOAD_ONLY_ALONE")
|
||||
return
|
||||
var save := store.read_save()
|
||||
if store.last_read_error != OK:
|
||||
hud.flash("LOAD_FAILED")
|
||||
return
|
||||
var next := GwSession.new()
|
||||
if not _load(next, save):
|
||||
hud.flash("LOAD_FAILED")
|
||||
return
|
||||
_write_recording()
|
||||
session = next
|
||||
session.join(_player_key, _player_name())
|
||||
_run_tick()
|
||||
_show_world()
|
||||
hud.flash("LOADED")
|
||||
|
||||
|
||||
func _set_paused(paused: bool) -> void:
|
||||
clock.paused = paused
|
||||
avatar.set_process(not paused)
|
||||
hud.show_paused(paused)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what != NOTIFICATION_WM_CLOSE_REQUEST or session == null:
|
||||
return
|
||||
if session.is_running():
|
||||
if session.is_host() and not save_game():
|
||||
push_warning("the game could not be saved on close")
|
||||
_write_recording()
|
||||
if link != null:
|
||||
link.link.close()
|
||||
|
||||
|
||||
func _run_tick() -> void:
|
||||
_apply(session.step())
|
||||
if session.tick() % AUTOSAVE_TICKS == 0 and not save_game():
|
||||
hud.flash("SAVE_FAILED")
|
||||
push_warning("the automatic save failed")
|
||||
|
||||
|
||||
func _apply(events: Array[Dictionary]) -> void:
|
||||
var local := session.local_player()
|
||||
for event in events:
|
||||
match event["type"]:
|
||||
"tile_hit":
|
||||
var tile := Vector2i(event["x"], event["y"])
|
||||
map_view.refresh_tile(tile)
|
||||
# The own hits showed their burst when they were made.
|
||||
if event["player"] == local:
|
||||
_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))
|
||||
"inventory_full":
|
||||
if event["player"] == local:
|
||||
hud.flash("INVENTORY_FULL")
|
||||
"player_joined":
|
||||
if event["player"] != local:
|
||||
hud.flash("PLAYER_JOINED", [session.player_name(event["player"])])
|
||||
else:
|
||||
avatar.player = local
|
||||
avatar.queue_redraw()
|
||||
_update_status()
|
||||
"player_left":
|
||||
figures.forget(event["player"])
|
||||
hud.flash("PLAYER_LEFT", [session.player_name(event["player"])])
|
||||
_update_status()
|
||||
if session.is_broken():
|
||||
hud.flash("SIMULATION_STOPPED")
|
||||
push_error(session.last_error())
|
||||
|
||||
|
||||
func _update_marker() -> void:
|
||||
var tile := GridMotion.tile_of(get_global_mouse_position())
|
||||
var distance := avatar.position.distance_to(GridMotion.tile_center(tile))
|
||||
marker.show_tile(tile, distance <= REACH_TILES * GridMotion.TILE_SIZE)
|
||||
|
||||
|
||||
func _try_dig() -> void:
|
||||
if marker.in_reach:
|
||||
dig_at(marker.tile)
|
||||
|
||||
|
||||
## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its
|
||||
## burst of chips at once: the hit itself lands with a later tick, which for a
|
||||
## guest comes back from the host only after the round trip. The caller must
|
||||
## check the reach: figures move outside the lockstep, so the core does not
|
||||
## know where the figure is. The core checks the cooldown, the tile, its
|
||||
## terrain, the tool tier and the free space in the inventory.
|
||||
func dig_at(tile: Vector2i) -> void:
|
||||
if session.tick() < _next_dig_tick:
|
||||
return
|
||||
session.dig(tile.x, tile.y)
|
||||
_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS
|
||||
if 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
|
||||
## written: for a guest, whose world belongs to the host, and while an
|
||||
## unusable save could not be set aside.
|
||||
func save_game() -> bool:
|
||||
if _save_blocked or not session.is_host():
|
||||
return false
|
||||
return store.write_save(session.save())
|
||||
|
||||
|
||||
func _write_recording() -> void:
|
||||
store.write_recording(session.recording_bytes())
|
||||
@@ -0,0 +1 @@
|
||||
uid://by56f6vi542a1
|
||||
@@ -0,0 +1,182 @@
|
||||
class_name MapView
|
||||
extends Node2D
|
||||
## Draws the terrain of the shared world in the style of `art`: every tile to
|
||||
## fit its neighbours, the cracks of the hits it took, and a short burst of
|
||||
## chips where a pickaxe lands. The map is drawn in square blocks, so that a
|
||||
## hit draws only the blocks around its tile again.
|
||||
|
||||
## The terrain that rock masses and the edge of the map are made of. Tiles
|
||||
## beside it are drawn to fit it.
|
||||
const SOLID: Array[int] = [GwSession.TERRAIN_SOFT_ROCK, GwSession.TERRAIN_HARD_ROCK, GwSession.TERRAIN_BEDROCK]
|
||||
## Tiles along each side of a block.
|
||||
const BLOCK := 16
|
||||
## Seconds a burst of chips lasts.
|
||||
const HIT_SECONDS := 0.35
|
||||
|
||||
var session: GwSession
|
||||
var art: Art
|
||||
var _width := 0
|
||||
var _height := 0
|
||||
var _terrain := PackedByteArray()
|
||||
var _damage := PackedByteArray()
|
||||
var _solid := PackedByteArray()
|
||||
var _masks := PackedInt32Array()
|
||||
var _variants := PackedInt32Array()
|
||||
var _blocks: Array[MapBlock] = []
|
||||
var _bursts: Array[Dictionary] = []
|
||||
var _burst_layer := Node2D.new()
|
||||
|
||||
|
||||
## A square part of the map that draws itself.
|
||||
class MapBlock extends Node2D:
|
||||
var view: MapView
|
||||
var tiles: Rect2i
|
||||
|
||||
func _init(owner_view: MapView, block_tiles: Rect2i) -> void:
|
||||
view = owner_view
|
||||
tiles = block_tiles
|
||||
|
||||
func _draw() -> void:
|
||||
view.draw_block(self, tiles)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_burst_layer.draw.connect(_draw_bursts)
|
||||
add_child(_burst_layer)
|
||||
set_process(false)
|
||||
|
||||
|
||||
## The graphic of a terrain code.
|
||||
static func id_of(terrain: int) -> StringName:
|
||||
match terrain:
|
||||
GwSession.TERRAIN_SOFT_ROCK:
|
||||
return Art.SOFT_ROCK
|
||||
GwSession.TERRAIN_HARD_ROCK:
|
||||
return Art.HARD_ROCK
|
||||
GwSession.TERRAIN_BEDROCK:
|
||||
return Art.BEDROCK
|
||||
return Art.GROUND
|
||||
|
||||
|
||||
## Takes the whole map from `session` anew and draws it, or shows nothing if
|
||||
## no world runs. Call it when a world starts or was replaced.
|
||||
func reload() -> void:
|
||||
for block in _blocks:
|
||||
remove_child(block)
|
||||
block.free()
|
||||
_blocks.clear()
|
||||
_bursts.clear()
|
||||
_burst_layer.queue_redraw()
|
||||
if session == null or not session.is_running():
|
||||
_width = 0
|
||||
_height = 0
|
||||
return
|
||||
_width = session.map_width()
|
||||
_height = session.map_height()
|
||||
_terrain = session.terrain_codes()
|
||||
_damage = session.damage_codes()
|
||||
_solid = TileShape.flags(_terrain, SOLID)
|
||||
_masks.resize(_width * _height)
|
||||
_variants.resize(_width * _height)
|
||||
for y in _height:
|
||||
for x in _width:
|
||||
var tile := Vector2i(x, y)
|
||||
_masks[y * _width + x] = TileShape.mask(_solid, _width, _height, tile, true)
|
||||
_variants[y * _width + x] = TileShape.variant(tile)
|
||||
for top in range(0, _height, BLOCK):
|
||||
for left in range(0, _width, BLOCK):
|
||||
var block := MapBlock.new(self, Rect2i(left, top, mini(BLOCK, _width - left), mini(BLOCK, _height - top)))
|
||||
_blocks.append(block)
|
||||
add_child(block)
|
||||
move_child(_burst_layer, -1)
|
||||
|
||||
|
||||
## Draws everything again, for example in another style.
|
||||
func redraw() -> void:
|
||||
for block in _blocks:
|
||||
block.queue_redraw()
|
||||
|
||||
|
||||
## Takes `tile` anew from the session after it changed, and draws it and the
|
||||
## tiles around it again, which may have to fit it differently now.
|
||||
func refresh_tile(tile: Vector2i) -> void:
|
||||
if _blocks.is_empty() or not Rect2i(0, 0, _width, _height).has_point(tile):
|
||||
return
|
||||
_terrain = session.terrain_codes()
|
||||
_damage = session.damage_codes()
|
||||
var index := tile.y * _width + tile.x
|
||||
_solid[index] = 1 if SOLID.has(_terrain[index]) else 0
|
||||
var touched: Array[MapBlock] = []
|
||||
for y in range(tile.y - 1, tile.y + 2):
|
||||
for x in range(tile.x - 1, tile.x + 2):
|
||||
var around := Vector2i(x, y)
|
||||
if not Rect2i(0, 0, _width, _height).has_point(around):
|
||||
continue
|
||||
_masks[y * _width + x] = TileShape.mask(_solid, _width, _height, around, true)
|
||||
var block := _block_of(around)
|
||||
if not touched.has(block):
|
||||
touched.append(block)
|
||||
for block in touched:
|
||||
block.queue_redraw()
|
||||
|
||||
|
||||
## The block that draws `tile`.
|
||||
func _block_of(tile: Vector2i) -> MapBlock:
|
||||
var across := ceili(_width / float(BLOCK))
|
||||
@warning_ignore("integer_division")
|
||||
return _blocks[(tile.y / BLOCK) * across + tile.x / BLOCK]
|
||||
|
||||
|
||||
## Shows a burst of chips on `tile`, where a pickaxe just landed.
|
||||
func hit(tile: Vector2i) -> void:
|
||||
_bursts.append({"tile": tile, "age": 0.0})
|
||||
set_process(true)
|
||||
|
||||
|
||||
## The number of blocks the map is drawn in. Tests read it.
|
||||
func block_count() -> int:
|
||||
return _blocks.size()
|
||||
|
||||
|
||||
## The number of bursts still shown. Tests read it.
|
||||
func burst_count() -> int:
|
||||
return _bursts.size()
|
||||
|
||||
|
||||
## The neighbour mask a tile is drawn with. Tests read it.
|
||||
func mask_at(tile: Vector2i) -> int:
|
||||
return _masks[tile.y * _width + tile.x]
|
||||
|
||||
|
||||
## Draws the tiles `tiles` onto `block`, while the block draws.
|
||||
func draw_block(block: MapBlock, tiles: Rect2i) -> void:
|
||||
if art == null:
|
||||
return
|
||||
var size := GridMotion.TILE_SIZE
|
||||
for y in range(tiles.position.y, tiles.end.y):
|
||||
for x in range(tiles.position.x, tiles.end.x):
|
||||
var index := y * _width + x
|
||||
var rect := Rect2(x * size, y * size, size, size)
|
||||
var variant := _variants[index]
|
||||
art.draw(block, id_of(_terrain[index]), rect, {"solid": _masks[index], "variant": variant})
|
||||
if _damage[index] > 0:
|
||||
art.draw(block, Art.CRACKS, rect, {"hits": _damage[index], "variant": variant})
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
for burst in _bursts:
|
||||
burst["age"] += delta / HIT_SECONDS
|
||||
_bursts = _bursts.filter(func(burst: Dictionary) -> bool: return burst["age"] < 1.0)
|
||||
if _bursts.is_empty():
|
||||
set_process(false)
|
||||
_burst_layer.queue_redraw()
|
||||
|
||||
|
||||
func _draw_bursts() -> void:
|
||||
if art == null:
|
||||
return
|
||||
var size := GridMotion.TILE_SIZE
|
||||
for burst in _bursts:
|
||||
var tile: Vector2i = burst["tile"]
|
||||
var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
|
||||
art.draw(_burst_layer, Art.HIT, rect, {"age": burst["age"], "variant": TileShape.variant(tile)})
|
||||
@@ -0,0 +1 @@
|
||||
uid://dasl7vpyifxob
|
||||
@@ -0,0 +1,69 @@
|
||||
class_name Menu
|
||||
extends CanvasLayer
|
||||
## The start screen: the player's name, then playing alone, opening the world
|
||||
## for guests, or joining a host. Below, the way to the style samples.
|
||||
|
||||
signal play_chosen(open: bool)
|
||||
signal join_chosen(address: String)
|
||||
signal showcase_chosen
|
||||
|
||||
var name_field := LineEdit.new()
|
||||
var address_field := LineEdit.new()
|
||||
var solo_button := Button.new()
|
||||
var host_button := Button.new()
|
||||
var join_button := Button.new()
|
||||
var showcase_button := Button.new()
|
||||
var _status := Label.new()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
var panel := PanelContainer.new()
|
||||
center.add_child(panel)
|
||||
var box := VBoxContainer.new()
|
||||
box.custom_minimum_size = Vector2(380, 0)
|
||||
box.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(box)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Grovewright"
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
title.add_theme_font_size_override("font_size", 32)
|
||||
name_field.placeholder_text = tr("MENU_NAME")
|
||||
name_field.max_length = 32
|
||||
solo_button.text = tr("MENU_SOLO")
|
||||
host_button.text = tr("MENU_HOST")
|
||||
var row := HBoxContainer.new()
|
||||
for button in [solo_button, host_button]:
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(button)
|
||||
address_field.placeholder_text = tr("MENU_ADDRESS")
|
||||
join_button.text = tr("MENU_JOIN")
|
||||
showcase_button.text = tr("MENU_SHOWCASE")
|
||||
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_status.custom_minimum_size = Vector2(380, 0)
|
||||
for control: Control in [title, name_field, row, HSeparator.new(), address_field, join_button, HSeparator.new(), showcase_button, _status]:
|
||||
box.add_child(control)
|
||||
|
||||
solo_button.pressed.connect(func() -> void: play_chosen.emit(false))
|
||||
host_button.pressed.connect(func() -> void: play_chosen.emit(true))
|
||||
join_button.pressed.connect(func() -> void: join_chosen.emit(address_field.text.strip_edges()))
|
||||
address_field.text_submitted.connect(func(_text: String) -> void: join_button.pressed.emit())
|
||||
showcase_button.pressed.connect(func() -> void: showcase_chosen.emit())
|
||||
|
||||
|
||||
## Shows `text` below the buttons.
|
||||
func show_status(text: String) -> void:
|
||||
_status.text = text
|
||||
|
||||
|
||||
## The text shown below the buttons. Tests read it.
|
||||
func status() -> String:
|
||||
return _status.text
|
||||
|
||||
|
||||
## The name the player typed, without spaces around it.
|
||||
func player_name() -> String:
|
||||
return name_field.text.strip_edges()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dppcwwh67rtu6
|
||||
@@ -0,0 +1,187 @@
|
||||
class_name NetLink
|
||||
extends RefCounted
|
||||
## The network connection of a session, over ENet. A host listens for guests;
|
||||
## a guest connects to its host. Messages of the core travel on a reliable,
|
||||
## ordered channel. Positions of figures travel on a second channel that is
|
||||
## unreliable but ordered: a lost position is soon replaced by the next, and
|
||||
## an older one that arrives after a newer one is dropped.
|
||||
|
||||
## Port a host listens on unless told otherwise.
|
||||
const DEFAULT_PORT := 24680
|
||||
## Most guests that may connect to a host. More than can play, so that the
|
||||
## core can tell the extra ones why it turns them away.
|
||||
const MAX_CONNECTIONS := 8
|
||||
const CHANNEL_CORE := 0
|
||||
const CHANNEL_FIGURES := 1
|
||||
## Seconds a guest waits for its host to answer before it gives up.
|
||||
const CONNECT_SECONDS := 8.0
|
||||
## Milliseconds after which a connection whose packets stay unanswered counts
|
||||
## as lost. ENet's own limit of 30 seconds would leave a guest whose host
|
||||
## vanished in a standing world for that long.
|
||||
const TIMEOUT_MSEC := 8000
|
||||
|
||||
## Seconds this guest waits for its host. Tests shorten it.
|
||||
var connect_seconds := CONNECT_SECONDS
|
||||
|
||||
var _peer: ENetMultiplayerPeer
|
||||
var _peers: Dictionary[int, bool] = {}
|
||||
## Connections that close_peer is closing. Nothing more goes to them: Godot
|
||||
## reports an error for every packet to a connection in that state.
|
||||
var _closing: Dictionary[int, bool] = {}
|
||||
var _events: Array[Dictionary] = []
|
||||
var _connecting := false
|
||||
var _waited := 0.0
|
||||
|
||||
|
||||
## Splits "host" or "host:port" into [host, port], with DEFAULT_PORT if the
|
||||
## text names none. Returns an empty array if `text` is no such address.
|
||||
static func split_address(text: String) -> Array:
|
||||
var parts := text.strip_edges().split(":")
|
||||
if parts[0].is_empty() or parts.size() > 2:
|
||||
return []
|
||||
if parts.size() == 1:
|
||||
return [parts[0], DEFAULT_PORT]
|
||||
if not parts[1].is_valid_int() or parts[1].to_int() < 1 or parts[1].to_int() > 65535:
|
||||
return []
|
||||
return [parts[0], parts[1].to_int()]
|
||||
|
||||
|
||||
## Starts listening for guests on `port`, or on a free port that the system
|
||||
## chooses if `port` is 0; see local_port(). `address` limits listening to
|
||||
## one network interface, for example "127.0.0.1"; "*" listens on all.
|
||||
func host(port: int, address := "*") -> Error:
|
||||
_start()
|
||||
_peer.set_bind_ip(address)
|
||||
return _peer.create_server(port, MAX_CONNECTIONS, 2)
|
||||
|
||||
|
||||
## The port a host listens on, or 0.
|
||||
func local_port() -> int:
|
||||
if not is_open() or _peer.host == null:
|
||||
return 0
|
||||
return _peer.host.get_local_port()
|
||||
|
||||
|
||||
## Starts connecting to the host at `address` and `port`.
|
||||
func join(address: String, port: int) -> Error:
|
||||
_start()
|
||||
var error := _peer.create_client(address, port, 2)
|
||||
_connecting = error == OK
|
||||
_waited = 0.0
|
||||
return error
|
||||
|
||||
|
||||
func _start() -> void:
|
||||
close()
|
||||
_peer = ENetMultiplayerPeer.new()
|
||||
_peers.clear()
|
||||
_closing.clear()
|
||||
_peer.peer_connected.connect(_on_connected)
|
||||
_peer.peer_disconnected.connect(_on_disconnected)
|
||||
|
||||
|
||||
func _on_connected(peer: int) -> void:
|
||||
_peers[peer] = true
|
||||
var connection := _peer.get_peer(peer)
|
||||
# ENet slows unreliable packets down after a spike in the round trip and
|
||||
# can keep them at nothing for the rest of a session, so that the figures
|
||||
# of one guest stop. Positions are a few bytes ten times a second; they
|
||||
# are never slowed down.
|
||||
connection.throttle_configure(5000, ENetPacketPeer.PACKET_THROTTLE_SCALE, 0)
|
||||
connection.set_timeout(32, 5000, TIMEOUT_MSEC)
|
||||
_events.append({"type": "connected", "peer": peer})
|
||||
|
||||
|
||||
func _on_disconnected(peer: int) -> void:
|
||||
_peers.erase(peer)
|
||||
_closing.erase(peer)
|
||||
_events.append({"type": "disconnected", "peer": peer})
|
||||
|
||||
|
||||
## True while the connection is up or being made.
|
||||
func is_open() -> bool:
|
||||
return _peer != null and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED
|
||||
|
||||
|
||||
## True when a guest has reached its host, or a host is listening.
|
||||
func is_connected_to_peers() -> bool:
|
||||
return _peer != null and _peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED
|
||||
|
||||
|
||||
## Returns what happened since the last call, oldest first, as dictionaries
|
||||
## with a `type` entry: "connected" and "disconnected" (entry `peer`),
|
||||
## "message" and "figure" (entries `peer` and `bytes`), and "failed" when a
|
||||
## guest could not reach its host. `delta` is the time since the last call.
|
||||
func poll(delta: float) -> Array[Dictionary]:
|
||||
if is_open():
|
||||
_peer.poll()
|
||||
while _peer.get_available_packet_count() > 0:
|
||||
var from := _peer.get_packet_peer()
|
||||
var channel := _peer.get_packet_channel()
|
||||
var bytes := _peer.get_packet()
|
||||
var type := "message" if channel == CHANNEL_CORE else "figure"
|
||||
_events.append({"type": type, "peer": from, "bytes": bytes})
|
||||
if _connecting:
|
||||
_waited += delta
|
||||
if is_connected_to_peers():
|
||||
_connecting = false
|
||||
elif not is_open() or _waited > connect_seconds:
|
||||
_connecting = false
|
||||
close()
|
||||
_events.append({"type": "failed"})
|
||||
var events := _events
|
||||
_events = []
|
||||
return events
|
||||
|
||||
|
||||
## Sends a message of the core to `peer`, reliably and in order. A message
|
||||
## to a connection that is gone or being closed is dropped.
|
||||
func send(peer: int, bytes: PackedByteArray) -> void:
|
||||
_put(peer, bytes, CHANNEL_CORE, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
|
||||
|
||||
|
||||
## Sends the position of a figure to `peer`, to every connection if `peer`
|
||||
## is 0, or to every connection but one if `peer` is that one negated.
|
||||
func send_figure(peer: int, bytes: PackedByteArray) -> void:
|
||||
_put(peer, bytes, CHANNEL_FIGURES, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE_ORDERED)
|
||||
|
||||
|
||||
func _put(peer: int, bytes: PackedByteArray, channel: int, mode: MultiplayerPeer.TransferMode) -> void:
|
||||
if not is_connected_to_peers():
|
||||
return
|
||||
_peer.set_transfer_channel(channel)
|
||||
_peer.set_transfer_mode(mode)
|
||||
# One packet per connection, so that no packet goes to a connection that
|
||||
# is gone or being closed; Godot's own sending to all would.
|
||||
for target in _targets(peer):
|
||||
_peer.set_target_peer(target)
|
||||
_peer.put_packet(bytes)
|
||||
|
||||
|
||||
## The connections a packet for `peer` goes to: that one, every one if
|
||||
## `peer` is 0, or every one but -`peer` if `peer` is negative. Connections
|
||||
## that are being closed get nothing.
|
||||
func _targets(peer: int) -> Array[int]:
|
||||
var targets: Array[int] = []
|
||||
for open: int in _peers:
|
||||
if _closing.has(open):
|
||||
continue
|
||||
if peer == 0 or open == peer or (peer < 0 and open != -peer):
|
||||
targets.append(open)
|
||||
return targets
|
||||
|
||||
|
||||
## Closes the connection to `peer` once the messages waiting for it are out.
|
||||
func close_peer(peer: int) -> void:
|
||||
if _peers.has(peer) and not _closing.has(peer):
|
||||
_closing[peer] = true
|
||||
_peer.get_peer(peer).peer_disconnect_later()
|
||||
|
||||
|
||||
## Closes everything at once.
|
||||
func close() -> void:
|
||||
if is_open():
|
||||
_peer.close()
|
||||
_peers.clear()
|
||||
_closing.clear()
|
||||
_connecting = false
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmyy8qbtuhhdh
|
||||
@@ -0,0 +1,216 @@
|
||||
class_name SaveStore
|
||||
extends RefCounted
|
||||
## The game's files on the player's machine: the player key, the save, the
|
||||
## session recordings for bug reports and the player's settings.
|
||||
|
||||
const KEY_FILE := "player.key"
|
||||
const SAVE_FILE := "world.grw"
|
||||
## Holds the previous save while write_save replaces it.
|
||||
const BACKUP_FILE := SAVE_FILE + ".bak"
|
||||
const RECORDINGS_DIR := "recordings"
|
||||
const SETTINGS_FILE := "settings.cfg"
|
||||
## Recordings beyond this number are deleted, oldest first.
|
||||
const KEEP_RECORDINGS := 10
|
||||
const KEY_BYTES := 16
|
||||
|
||||
var base_dir: String
|
||||
## The result of the last attempt to read the key, the save or the settings:
|
||||
## OK, or why the file could not be read. A file that exists but cannot be
|
||||
## read right now, for example because another program holds it, is never
|
||||
## replaced.
|
||||
var last_read_error: Error = OK
|
||||
|
||||
|
||||
## Opens the folder and creates it if it is missing. If a crash interrupted a
|
||||
## save and left only the backup of the previous one (see write_save), the
|
||||
## backup becomes the save again.
|
||||
func _init(directory: String = "user://") -> void:
|
||||
base_dir = directory
|
||||
DirAccess.make_dir_recursive_absolute(base_dir)
|
||||
_restore_backup()
|
||||
|
||||
|
||||
## The key that identifies this installation in every world. It is created
|
||||
## on first use and kept from then on. Returns an empty key if the key file
|
||||
## exists but cannot be read; last_read_error then says why.
|
||||
func player_key() -> PackedByteArray:
|
||||
var path := base_dir.path_join(KEY_FILE)
|
||||
if FileAccess.file_exists(path):
|
||||
var stored := _read_file(path)
|
||||
if last_read_error != OK:
|
||||
return PackedByteArray()
|
||||
if stored.size() == KEY_BYTES:
|
||||
return stored
|
||||
var key := Crypto.new().generate_random_bytes(KEY_BYTES)
|
||||
if not _write(path, key):
|
||||
push_error("player key could not be stored at %s" % path)
|
||||
return key
|
||||
|
||||
|
||||
func has_save() -> bool:
|
||||
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE))
|
||||
|
||||
|
||||
## The bytes of the save. If it cannot be read, they are empty and
|
||||
## last_read_error says why.
|
||||
func read_save() -> PackedByteArray:
|
||||
return _read_file(base_dir.path_join(SAVE_FILE))
|
||||
|
||||
|
||||
## Writes the save through a temporary file, so that a crash or a failed
|
||||
## write never destroys the previous save. Returns false if the new save was
|
||||
## not stored completely; the previous save then stays as it was.
|
||||
##
|
||||
## The temporary file replaces the previous save in two moves: the previous
|
||||
## save becomes the backup, then the temporary file becomes the save. A single
|
||||
## move onto the previous save would not be safe, because on Windows Godot
|
||||
## deletes the target first and only then moves the file. If that move failed,
|
||||
## for example because a virus scanner holds the temporary file, the previous
|
||||
## save would already be gone. If the second move fails, the backup is moved
|
||||
## back. A crash between the two moves leaves only the backup; the next
|
||||
## SaveStore on the folder restores it.
|
||||
func write_save(bytes: PackedByteArray) -> bool:
|
||||
if bytes.is_empty():
|
||||
return false
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var temporary := path + ".tmp"
|
||||
var backup := base_dir.path_join(BACKUP_FILE)
|
||||
if not _write(temporary, bytes) or FileAccess.get_file_as_bytes(temporary) != bytes:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return false
|
||||
var has_previous := FileAccess.file_exists(path)
|
||||
if has_previous and DirAccess.rename_absolute(path, backup) != OK:
|
||||
DirAccess.remove_absolute(temporary)
|
||||
return false
|
||||
if DirAccess.rename_absolute(temporary, path) != OK:
|
||||
if has_previous:
|
||||
DirAccess.rename_absolute(backup, path)
|
||||
return false
|
||||
if has_previous:
|
||||
DirAccess.remove_absolute(backup)
|
||||
return true
|
||||
|
||||
|
||||
## Moves an unusable save out of the way, so that the next save does not
|
||||
## overwrite it. An older set-aside save is replaced. Returns the new path,
|
||||
## or an empty text if there was no save or it could not be moved.
|
||||
func set_aside_save() -> String:
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var aside := path + ".broken"
|
||||
if DirAccess.rename_absolute(path, aside) != OK:
|
||||
return ""
|
||||
return aside
|
||||
|
||||
|
||||
## Stores a session recording and deletes the oldest ones beyond
|
||||
## KEEP_RECORDINGS. Returns the path, or an empty text if nothing was written.
|
||||
func write_recording(bytes: PackedByteArray) -> String:
|
||||
if bytes.is_empty():
|
||||
return ""
|
||||
var directory := base_dir.path_join(RECORDINGS_DIR)
|
||||
DirAccess.make_dir_recursive_absolute(directory)
|
||||
# Microseconds since 1970, padded so that names sort by age.
|
||||
var stamp := int(Time.get_unix_time_from_system() * 1_000_000.0)
|
||||
var path := directory.path_join("session-%016d.grwr" % stamp)
|
||||
if not _write(path, bytes):
|
||||
return ""
|
||||
var names := recordings()
|
||||
while names.size() > KEEP_RECORDINGS:
|
||||
DirAccess.remove_absolute(directory.path_join(names[0]))
|
||||
names.remove_at(0)
|
||||
return path
|
||||
|
||||
|
||||
## File names of the stored recordings, oldest first.
|
||||
func recordings() -> PackedStringArray:
|
||||
var names := DirAccess.get_files_at(base_dir.path_join(RECORDINGS_DIR))
|
||||
names.sort()
|
||||
return names
|
||||
|
||||
|
||||
## The name the player gave last time, or an empty text.
|
||||
func player_name() -> String:
|
||||
return _setting("player", "name")
|
||||
|
||||
|
||||
func set_player_name(new_name: String) -> void:
|
||||
_set_setting("player", "name", new_name)
|
||||
|
||||
|
||||
## The address of the host the player joined last time, or an empty text.
|
||||
func last_address() -> String:
|
||||
return _setting("network", "address")
|
||||
|
||||
|
||||
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:
|
||||
return ""
|
||||
return str(settings.get_value(section, key, ""))
|
||||
|
||||
|
||||
## Changes one setting and keeps the others. Settings that cannot be read
|
||||
## right now stay as they are, because writing them would lose the others.
|
||||
func _set_setting(section: String, key: String, value: String) -> void:
|
||||
var path := base_dir.path_join(SETTINGS_FILE)
|
||||
var settings := _read_settings(path)
|
||||
if settings == null:
|
||||
push_warning("settings could not be read at %s and stay as they are: %s" % [path, error_string(last_read_error)])
|
||||
return
|
||||
settings.set_value(section, key, value)
|
||||
if settings.save(path) != OK:
|
||||
push_warning("settings could not be stored at %s" % path)
|
||||
|
||||
|
||||
## The settings on disk, or null if the file exists but cannot be read right
|
||||
## now; last_read_error then says why. A missing file gives empty settings.
|
||||
## Of a damaged file, Godot reports the error and keeps what it read before
|
||||
## it, and the next change of a setting writes the file anew.
|
||||
func _read_settings(path: String) -> ConfigFile:
|
||||
var settings := ConfigFile.new()
|
||||
if not FileAccess.file_exists(path):
|
||||
return settings
|
||||
var text := _read_file(path).get_string_from_utf8()
|
||||
if last_read_error != OK:
|
||||
return null
|
||||
settings.parse(text)
|
||||
return settings
|
||||
|
||||
|
||||
## Moves the backup back to the save if there is no save, see write_save.
|
||||
func _restore_backup() -> void:
|
||||
var path := base_dir.path_join(SAVE_FILE)
|
||||
var backup := base_dir.path_join(BACKUP_FILE)
|
||||
if FileAccess.file_exists(path) or not FileAccess.file_exists(backup):
|
||||
return
|
||||
if DirAccess.rename_absolute(backup, path) != OK:
|
||||
push_warning("the previous save could not be restored from %s" % backup)
|
||||
|
||||
|
||||
## Reads a whole file and records the result in last_read_error.
|
||||
func _read_file(path: String) -> PackedByteArray:
|
||||
var bytes := FileAccess.get_file_as_bytes(path)
|
||||
last_read_error = FileAccess.get_open_error()
|
||||
return bytes
|
||||
|
||||
|
||||
func _write(path: String, bytes: PackedByteArray) -> bool:
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
var stored := file.store_buffer(bytes)
|
||||
file.close()
|
||||
return stored
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbp4jkbdfyawm
|
||||
@@ -0,0 +1,109 @@
|
||||
class_name SessionLink
|
||||
extends RefCounted
|
||||
## Carries the messages of a GwSession over a NetLink, and the positions of
|
||||
## the figures, which are not part of the shared world. Tells the game what
|
||||
## happened on the way.
|
||||
|
||||
## Bytes of a position: x and y as 32-bit floats.
|
||||
const POSITION_BYTES := 8
|
||||
|
||||
var session: GwSession
|
||||
var link: NetLink
|
||||
|
||||
|
||||
func _init(game_session: GwSession, net_link: NetLink) -> void:
|
||||
session = game_session
|
||||
link = net_link
|
||||
|
||||
|
||||
## Moves messages in both directions and returns notices, oldest first, as
|
||||
## dictionaries with a `type` entry:
|
||||
## - "guest_arrived" and "guest_left" (entry `peer`), at the host
|
||||
## - "world_received" (entry `resync`), "refused" (entry `reason`),
|
||||
## "protocol_error", "host_lost" and "failed", at a guest
|
||||
## - "divergence" (entries `peer`, `tick`, `hash`), at the host
|
||||
## - "figure" (entries `player`, `position`), everywhere, for another player
|
||||
## who is online, with a position of real numbers
|
||||
## `delta` is the time since the last call.
|
||||
func pump(delta: float) -> Array[Dictionary]:
|
||||
var notices: Array[Dictionary] = []
|
||||
for event in link.poll(delta):
|
||||
match event["type"]:
|
||||
"connected":
|
||||
if session.is_host():
|
||||
session.connect_peer(event["peer"])
|
||||
notices.append({"type": "guest_arrived", "peer": event["peer"]})
|
||||
"disconnected":
|
||||
if session.is_host():
|
||||
session.disconnect_peer(event["peer"])
|
||||
notices.append({"type": "guest_left", "peer": event["peer"]})
|
||||
else:
|
||||
notices.append({"type": "host_lost"})
|
||||
"failed":
|
||||
notices.append({"type": "failed"})
|
||||
"message":
|
||||
notices.append_array(session.receive(event["peer"], event["bytes"]))
|
||||
"figure":
|
||||
_receive_figure(event["peer"], event["bytes"], notices)
|
||||
for divergence in session.take_divergences():
|
||||
divergence["type"] = "divergence"
|
||||
notices.append(divergence)
|
||||
flush()
|
||||
return notices
|
||||
|
||||
|
||||
## Sends what the session has for the network and closes the connections
|
||||
## the host gave up. A guest's messages wait until its host is reached.
|
||||
func flush() -> void:
|
||||
if not link.is_connected_to_peers():
|
||||
return
|
||||
for message in session.take_messages():
|
||||
link.send(message["peer"], message["bytes"])
|
||||
for peer in session.take_closing():
|
||||
link.close_peer(peer)
|
||||
|
||||
|
||||
## Tells the other machines where this machine's figure stands.
|
||||
func send_figure(position: Vector2) -> void:
|
||||
if session.is_host():
|
||||
var player := session.local_player()
|
||||
if player >= 0:
|
||||
link.send_figure(0, _figure_bytes(player, position))
|
||||
elif session.local_player() >= 0:
|
||||
link.send_figure(GwSession.HOST_PEER, _position_bytes(position))
|
||||
|
||||
|
||||
func _receive_figure(peer: int, bytes: PackedByteArray, notices: Array[Dictionary]) -> void:
|
||||
if session.is_host():
|
||||
# A guest sends only a position; the host knows whose it is and
|
||||
# passes it on to every other guest.
|
||||
var player := session.player_of_peer(peer)
|
||||
if player < 0 or bytes.size() != POSITION_BYTES:
|
||||
return
|
||||
var position := Vector2(bytes.decode_float(0), bytes.decode_float(4))
|
||||
# Not a number or infinite, as a broken or changed client may send it:
|
||||
# such a position would spoil the figure for good.
|
||||
if not position.is_finite():
|
||||
return
|
||||
notices.append({"type": "figure", "player": player, "position": position})
|
||||
link.send_figure(-peer, _figure_bytes(player, position))
|
||||
elif bytes.size() == 1 + POSITION_BYTES:
|
||||
var player := bytes.decode_u8(0)
|
||||
var position := Vector2(bytes.decode_float(1), bytes.decode_float(5))
|
||||
# A position can arrive after its player left the world.
|
||||
if player != session.local_player() and session.online_players().has(player) and position.is_finite():
|
||||
notices.append({"type": "figure", "player": player, "position": position})
|
||||
|
||||
|
||||
func _position_bytes(position: Vector2) -> PackedByteArray:
|
||||
var bytes := PackedByteArray()
|
||||
bytes.resize(POSITION_BYTES)
|
||||
bytes.encode_float(0, position.x)
|
||||
bytes.encode_float(4, position.y)
|
||||
return bytes
|
||||
|
||||
|
||||
func _figure_bytes(player: int, position: Vector2) -> PackedByteArray:
|
||||
var bytes := PackedByteArray([player])
|
||||
bytes.append_array(_position_bytes(position))
|
||||
return bytes
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpau4ls365tsj
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bscs3cghniuuq
|
||||
@@ -0,0 +1,29 @@
|
||||
class_name TickClock
|
||||
extends RefCounted
|
||||
## Turns frame time into a whole number of simulation ticks.
|
||||
|
||||
## Most ticks one frame may run. After a long stall the simulation catches
|
||||
## up only this far and drops the rest, instead of freezing to catch up.
|
||||
const MAX_TICKS_PER_FRAME := 5
|
||||
|
||||
var seconds_per_tick: float
|
||||
var paused := false
|
||||
|
||||
var _accumulated := 0.0
|
||||
|
||||
|
||||
func _init(ticks_per_second: int) -> void:
|
||||
seconds_per_tick = 1.0 / ticks_per_second
|
||||
|
||||
|
||||
## Returns how many ticks to run for a frame that took `delta` seconds.
|
||||
func advance(delta: float) -> int:
|
||||
if paused:
|
||||
return 0
|
||||
_accumulated += delta
|
||||
var ticks := int(_accumulated / seconds_per_tick)
|
||||
if ticks > MAX_TICKS_PER_FRAME:
|
||||
_accumulated = 0.0
|
||||
return MAX_TICKS_PER_FRAME
|
||||
_accumulated -= ticks * seconds_per_tick
|
||||
return ticks
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmrv5nfb0wu6r
|
||||
@@ -0,0 +1,24 @@
|
||||
class_name TileMarker
|
||||
extends Node2D
|
||||
## Marks the tile under the mouse, drawn by `art`: one way when the figure
|
||||
## can reach it, another when it is too far away.
|
||||
|
||||
var tile := Vector2i(-1, -1)
|
||||
var in_reach := false
|
||||
var art: Art
|
||||
|
||||
|
||||
## Moves the mark to `new_tile`. Redraws only when something changed.
|
||||
func show_tile(new_tile: Vector2i, reachable: bool) -> void:
|
||||
if new_tile == tile and reachable == in_reach:
|
||||
return
|
||||
tile = new_tile
|
||||
in_reach = reachable
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if art == null:
|
||||
return
|
||||
var size := GridMotion.TILE_SIZE
|
||||
art.draw(self, Art.MARKER, Rect2(Vector2(tile) * size, Vector2(size, size)), {"reachable": in_reach})
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjnucbgwr65ud
|
||||
@@ -0,0 +1,87 @@
|
||||
extends SceneTree
|
||||
## Runs every test script in res://tests whose file name starts with "test_".
|
||||
## Every method whose name starts with "test_" is one test. A test fails if a
|
||||
## check fails or if an error is reported while it runs, for example a call
|
||||
## on a null value. Exits with code 1 if any test failed or if no test ran,
|
||||
## so that a renamed test folder or misnamed tests cannot pass as a clean run.
|
||||
##
|
||||
## From the repository root:
|
||||
## godot --headless --path game -s res://tests/run_tests.gd
|
||||
|
||||
const TEST_DIR := "res://tests"
|
||||
|
||||
|
||||
## Counts the errors that are reported while tests run. Warnings do not count.
|
||||
class ErrorCounter extends Logger:
|
||||
var _mutex := Mutex.new()
|
||||
var _errors := 0
|
||||
|
||||
func errors() -> int:
|
||||
_mutex.lock()
|
||||
var count := _errors
|
||||
_mutex.unlock()
|
||||
return count
|
||||
|
||||
func _log_error(
|
||||
_function: String,
|
||||
_file: String,
|
||||
_line: int,
|
||||
_code: String,
|
||||
_rationale: String,
|
||||
_editor_notify: bool,
|
||||
error_type: int,
|
||||
_script_backtraces: Array[ScriptBacktrace]
|
||||
) -> void:
|
||||
if error_type == ERROR_TYPE_WARNING:
|
||||
return
|
||||
_mutex.lock()
|
||||
_errors += 1
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run_all.call_deferred()
|
||||
|
||||
|
||||
func _run_all() -> void:
|
||||
# Remove what runs that stopped early left behind, before the error
|
||||
# counter starts. Only if the folder exists, because Godot reports an
|
||||
# error when it lists a missing one.
|
||||
if DirAccess.dir_exists_absolute(TestFiles.ROOT):
|
||||
TestFiles.remove_dir(TestFiles.ROOT)
|
||||
var counter := ErrorCounter.new()
|
||||
OS.add_logger(counter)
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
var files := DirAccess.get_files_at(TEST_DIR)
|
||||
files.sort()
|
||||
for file in files:
|
||||
if not (file.begins_with("test_") and file.ends_with(".gd")):
|
||||
continue
|
||||
var script: GDScript = load(TEST_DIR.path_join(file))
|
||||
if script == null or not script.can_instantiate():
|
||||
failed += 1
|
||||
print("FAIL %s the script does not load; see the error above" % file)
|
||||
continue
|
||||
for method in script.get_script_method_list():
|
||||
var test_name: String = method["name"]
|
||||
if not test_name.begins_with("test_"):
|
||||
continue
|
||||
var test: TestCase = script.new()
|
||||
test.tree = self
|
||||
var errors_before := counter.errors()
|
||||
await test.call(test_name)
|
||||
var errors := counter.errors() - errors_before
|
||||
if errors > 0:
|
||||
test.failures.append("%d error(s) reported while the test ran; see above" % errors)
|
||||
if test.failures.is_empty():
|
||||
passed += 1
|
||||
print("ok %s %s" % [file, test_name])
|
||||
else:
|
||||
failed += 1
|
||||
print("FAIL %s %s" % [file, test_name])
|
||||
for failure in test.failures:
|
||||
print(" " + failure)
|
||||
OS.remove_logger(counter)
|
||||
print("%d passed, %d failed" % [passed, failed])
|
||||
quit(1 if failed > 0 or passed == 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1bhpsppk18k2
|
||||
@@ -0,0 +1,13 @@
|
||||
class_name HeldFileStore
|
||||
extends SaveStore
|
||||
## A SaveStore that cannot read the file named `unreadable`, as if another
|
||||
## program held it.
|
||||
|
||||
var unreadable := ""
|
||||
|
||||
|
||||
func _read_file(path: String) -> PackedByteArray:
|
||||
if path.get_file() == unreadable:
|
||||
last_read_error = ERR_FILE_CANT_READ
|
||||
return PackedByteArray()
|
||||
return super(path)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bprgpcfabdtn4
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user