Compare commits

..

40 Commits

Author SHA1 Message Date
Steven Wroblewski 446fad7b55 Paket für andere Rechner
paket.bat baut aus dem ausgecheckten Stand ein ZIP, das auf jedem
Windows 10 oder 11 mit 64 Bit ohne Git, Rust und Godot läuft: das
exportierte Spiel, die Brücke mit statisch gelinkter C-Laufzeit und eine
Anleitung für Mitspieler. Die Exportvoreinstellung lässt Tests und
Werkzeuge weg und nimmt die Texte mit; dist/ bleibt außerhalb des
Archivs. CLAUDE.md und der Fragebogen von M1d beschreiben das Paket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:09:48 +02:00
steven 27619b6927 M1d: Stilproben (#12)
Pixelgrafik aus Code im Stil klassischer Farmspiele, aber eigenständig,
in drei Abwandlungen: Warm, Pastell und Glimmen. Eine Vitrine zeigt jeden
Stil mit Dingen späterer Meilensteine, F2 wechselt den Stil im Spiel, und
das Spiel merkt sich die Wahl. Der eigene Schlag zeigt seine Splitter
sofort, auch als Gast, und die Namen über den Figuren bleiben scharf.
151 Rust-Tests (unverändert) und 154 GDScript-Tests; jede Task und der
ganze Branch geprüft.

Closes #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:01:52 +00:00
Steven Wroblewski 949d5d2489 Fragebogen M1d: Grundgestein bricht nie
Der Satz zu den Splittern sagte, harter Fels und Grundgestein ließen
sich mit der Steinspitzhacke noch nicht brechen. Harter Fels braucht
eine Kupferspitzhacke, Grundgestein bricht nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:50:00 +02:00
Steven Wroblewski 417cf30a9c Nachbesserungen aus der Gesamtprüfung von M1d
Das Lebensmoos bekommt links und rechts Platz für seinen Umriss, der
bei zwei von drei Formen abgeschnitten war; die Bilder der Vitrine sind
neu aufgenommen. Die Pausenanzeige kehrt zurück, wenn eine Meldung
während der Pause abläuft. Ein Test prüft die Splitter fremder
Schläge, und der Test ohne Bilderordner hängt nicht mehr davon ab, was
in res://art liegt. Der Fragebogen nennt die Felsproben und sagt, was
sofort kommt und was erst mit der Antwort des Hosts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 14:36:43 +02:00
Steven Wroblewski e53d296080 CLAUDE.md: was eine neue Grafik-Kennung braucht
Die Regel nannte nur Zeichnung und Farben. Eine neue Kennung braucht
auch eine Konstante in Art, einen Eintrag in Art.IDS und einen Zweig
mit Platzhalter in ArtStyle.draw. Eine fehlende Farbe führt zu einem
Fehler, nicht zu einem Platzhalter.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 14:12:26 +02:00
Steven Wroblewski 96db4cd052 Regeln, Fragebogen und Bilder für den Probelauf M1d
CLAUDE.md beschreibt, wie die Grafik entsteht: Kennungen, Pixelgrafik
aus Code, scharfe Pixel, Bilder, die Zeichnungen ersetzen, Blöcke und
Kachelformen. Das Bildschirmfoto-Werkzeug und --style=NAME stehen bei
ihren Befehlen. Eine stille Verbindung gilt nach etwa
NetLink.TIMEOUT_MSEC als verloren. Ein Fragebogen mit Bildern der drei
Stile begleitet den Probelauf.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 13:59:15 +02:00
Steven Wroblewski b4af22b8b9 Abwandlungen Pastell und Glimmen
Pastell zeichnet mit weichen, hellen Farben und Umrissen im dunkleren
Ton der Fläche. Glimmen zeigt eine gedämpfte, kühle tote Welt, in der
alles Lebendige kräftig ist und leuchtet. Beide haben dieselben
Farbnamen wie Warm und je acht Spielerfarben.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 13:47:39 +02:00
Steven Wroblewski deba758e87 Pixelgrafik für die Dinge der Vitrine
Toter Boden hat Risse, lebendiger Boden Gras, Blumen und einen
unregelmäßigen Rand. Moos funkelt, die Ranke trägt Blätter, die
Faserpflanze wächst in sechs Stufen bis zu den Fasern und wiegt sich,
das Weidetier zieht im Sprung die Beine an, und die Ruine leuchtet,
wenn sie erwacht.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 13:29:32 +02:00
Steven Wroblewski d189a358cd Kopf der Figur behält beim Laufen seinen Umriss
In zwei der vier Laufbilder hob sich der Körper um ein Pixel, und der
dunkle Umriss oben am Kopf fiel aus dem Bild. Laufende Figuren
flackerten dadurch am Kopf. Jetzt sinkt der Körper, solange die Beine
gespreizt sind, und über dem Kopf bleibt immer eine Zeile frei.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 13:21:08 +02:00
Steven Wroblewski e14ce3465b Pixelgrafik für Boden, Fels und Figuren
PixelStyle zeichnet jede Grafik Pixel für Pixel in ein kleines Bild,
16 mal 16 Pixel pro Kachel, und hebt es auf. Fels ist außen rund, innen
geschlossen und zeigt zum offenen Boden hin seine Vorderkante; Figuren
stehen aufrecht und haben vier Laufbilder. Warm bekommt als erster Stil
seine Palette und acht Spielerfarben. Die Texturfilterung steht auf
Nearest, damit die Pixel scharf bleiben.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 13:02:06 +02:00
Steven Wroblewski ab81f66ef0 Stil wählen, merken und fotografieren
F2 wechselt im Spiel den Stil und meldet ihn, --style=NAME wählt ihn
beim Start, und das Spiel merkt sich die Wahl in den Einstellungen.
Das Bildschirmfoto-Werkzeug nimmt Bilder in einem bestimmten Stil und
von der Vitrine auf und zeigt eine zweite Figur mit Namen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:49:40 +02:00
Steven Wroblewski 714d604c58 Vitrine weicht einer startenden Welt
Ein Gast kann die Stilproben öffnen, während er sich verbindet. Kam die
Welt dann an, lag sie unter der Vitrine, und „Zurück" legte das Menü
über das laufende Spiel. Jetzt schließt die Hauptszene die Vitrine,
sobald eine Welt erscheint.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:41:35 +02:00
Steven Wroblewski 9f65823f2d Vitrine der Stilproben
Das Startmenü führt zu einer Vitrine, die den gewählten Stil an einer
kleinen Szene zeigt: Fels, toter und lebendiger Boden, Lebensmoos, eine
Ranke, die Steine in eine erwachende Ruine trägt, eine wachsende
Faserpflanze, ein hüpfendes Weidetier und zwei Spielfiguren. Knöpfe,
die Tasten 1 bis 3 und F2 wechseln den Stil, Esc führt zurück ins Menü.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:27:26 +02:00
Steven Wroblewski 69b642cc60 Eigene Schläge pro Kachel zählen
Als Gast kann die Meldung eines Schlags erst nach dem nächsten Schlag
auf dieselbe Kachel ankommen. Ein Merker pro Kachel ging dabei verloren,
und der brechende Schlag zeigte seine Splitter doch zweimal. Die
Hauptszene zählt ihre noch nicht gemeldeten Schläge deshalb pro Kachel
und vergisst sie, wenn die Welt neu abgeglichen wird.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:19:01 +02:00
Steven Wroblewski 55c09b0f1f Eigener Schlag zeigt beim Brechen keine zweiten Splitter
Der Kern meldet einen Schlag, der Fels bricht, nur als geänderte Kachel
ohne Spieler. Die Hauptszene merkt sich deshalb die Kacheln ihrer
eigenen Schläge, bis der Kern sie meldet, und zeigt dann keinen zweiten
Splitterwurf. Als Gast kam der zweite eine Rundreise später und sah
wieder verzögert aus. Splitter gibt es nur noch auf Fels, nicht mehr
außerhalb der Karte.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 12:04:56 +02:00
Steven Wroblewski d2a7973022 Spielfeld über Art zeichnen
Karte, Figuren und Zielmarke zeichnet jetzt Art. Die Karte zeichnet in
Blöcken von 16 mal 16 Kacheln und nach einem Treffer nur die Blöcke um
die Kachel neu; jede Kachel kennt ihre Nachbarn. Figuren stehen
aufrecht in ihrer Kachel, schreiten und schauen in die Richtung, in die
sie gehen. Der eigene Schlag zeigt seine Splitter sofort, auch als Gast,
und die Namen über fremden Figuren werden in Bildschirmgröße gezeichnet
und bleiben scharf.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 11:49:18 +02:00
Steven Wroblewski 9921aba63a Kennungen, Kachelformen und Platzhalter für die Grafik
Jede Grafik auf dem Spielfeld bekommt eine Kennung. Art zeichnet sie im
gewählten Stil oder aus einem Bild gleichen Namens unter game/art.
ArtStyle zeichnet vorerst schlichte Platzhalter. TileShape beschreibt
die Nachbarn einer Kachel, damit Felsmassen außen rund und innen
geschlossen gezeichnet werden können. Die drei Stile Warm, Pastell und
Glimmen gibt es schon mit Namen, noch ohne eigene Zeichnungen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 11:24:45 +02:00
Steven Wroblewski 5e9836c64a Plan M1d auf Pixelgrafik umgestellt
Nach der ersten Vorschau soll das Spielfeld Pixelgrafik im Stil
klassischer Farmspiele zeigen, aus Code gezeichnet. Konzept 7.4 und 11
sagen das jetzt. Der Plan behält Kennungen, Kartenblöcke, Vitrine und
Stilwechsel und zeichnet statt der drei Vektorstile Pixelgrafik in drei
Abwandlungen: Warm, Pastell und Glimmen. Warm war nach der Probe der
Favorit. Jede Task-Grenze wurde erneut vorab in einer Kopie von main
geprüft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:00:41 +02:00
Steven Wroblewski 298bdf8c37 Plan M1d: Stilproben
Acht Tasks bringen das Spielfeld in drei Stilen auf den Bildschirm, alle
aus Code: Scherenschnitt, Kontur und Geometrisch. Jede Grafik hängt an
einer Kennung, eine Vitrine zeigt jeden Stil mit Dingen späterer
Meilensteine, F2 wechselt im Spiel, und das Spiel merkt sich die Wahl.
Dazu kommen die Antworten aus dem Probelauf von M1c: Der eigene Schlag
zeigt seine Splitter sofort, und die Namen über den Figuren bleiben
scharf. Jede Task-Grenze wurde vorab in einer Kopie von main geprüft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:45:16 +02:00
steven 22f87f1ff4 M1c: Gleichschritt im Netz (#9)
Koop für bis zu vier Spieler im Gleichschritt: Der Host bestimmt jeden
Takt, Gäste rechnen ihn nach, Prüfsummen decken Abweichungen auf, und ein
abgewichener Gast bekommt die Welt neu. Dazu Verbindung über ENet,
Startmenü, Profile, die Figuren der anderen und eine Statuszeile.
151 Rust-Tests in Debug und Release, 96 GDScript-Tests, ein Test mit zwei
Godot-Prozessen; jede Task und der ganze Branch geprüft.

Closes #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 06:41:01 +00:00
Steven Wroblewski be22e9f358 Abschlusskorrekturen M1c: Schläge der Gäste, Figuren und Fragebogen
- Der Host hält einen Schlag eines Gastes, der während der Abklingzeit
  ankommt, bis zum ersten Takt danach. Übers Internet ging sonst etwa
  jeder fünfte Schlag eines Gastes verloren.
- ENet drosselt die Figuren nie mehr; sonst konnten die Figuren eines
  von zwei Gästen dauerhaft stehen bleiben. Figuren gehen geordnet, und
  eine stille Verbindung gilt nach höchstens acht Sekunden als verloren.
- Positionen, die keine Zahlen sind, und Figuren von Spielern, die
  nicht online sind, werden verworfen.
- Ein Host mit angehaltener Simulation sagt das in der Statuszeile.
- Neue Tests: Schlag während der Abklingzeit, Zielauswahl von NetLink,
  verworfene Figuren, Neuabgleich über die Brücke.
- Der Fragebogen sagt, was jeder Rechner braucht, und die Freigabe in
  der Firewall gehört zum Host. CLAUDE.md beschreibt das Halten der
  Schläge, die Drosselung und die Zeitüberschreitung und ergänzt die
  Vertrauensregel um die Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 01:22:36 +02:00
Steven Wroblewski 7f4bbd9606 Fragebogen und Regeln an das Spiel angeglichen
Der Fragebogen nennt die Statuszeile oben rechts. Er sagt wieder, wann
eine Aufzeichnung entsteht, dass eine angehaltene Sitzung keine
hinterlässt und wo die Logs liegen, und er gibt bessere Hinweise für
Verbindungen übers Internet. CLAUDE.md beschreibt jetzt auch:
- die Übernahme der Host-Welt durch Gäste,
- den festen Anfang von Gruß und Ablehnung,
- die Pausenregel und das Anhalten bei einem Kernfehler,
- die erlaubten Profilnamen,
- die gesperrte Brücken-DLL bei laufendem Spiel.
play-zweites-fenster.bat gibt weitere Argumente an das Spiel weiter.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-02 00:07:20 +02:00
Steven Wroblewski 56101b15a5 Startskripte, Regeln und Probelauf für M1c
play.bat gibt Argumente an das Spiel weiter, und
play-zweites-fenster.bat startet ein zweites Fenster mit eigenem Profil.
CLAUDE.md beschreibt die gemeinsame Welt, das Vertrauensmodell der
Reichweite, die Netzwerkregeln und den Test zweier Instanzen. Ein
Fragebogen begleitet den Probelauf.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 23:39:39 +02:00
Steven Wroblewski 84e786e531 Zwei-Instanzen-Test unabhängig von der Startzeit, Bot endet immer
Die Uhr des Hosts wartet jetzt auf den Gast. So kann ein langsamer Start
des zweiten Prozesses die Takte bis zum Vergleich nicht mehr verbrauchen.
Der Bot endet auch dann, wenn er seinen Bericht nicht schreiben kann,
und überlebt den Test nie. Ein leeres --storage= führt ihn nicht mehr in
den Ordner des Spielers. Ein neuer Test prüft, dass der Bot ohne
schreibbaren Bericht mit Exit-Code 1 endet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 23:24:10 +02:00
Steven Wroblewski acce1ed435 Zwei Instanzen im Gleichschritt
Ein Test startet einen zweiten Godot-Prozess als Gast. Der Gast tritt
über das Netzwerk bei und schlägt Stein, beide Welten enden im selben
Zustand, ohne Abgleich unterwegs. Damit ist die Prüffrage von
Meilenstein 1 Teil der automatischen Prüfungen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 22:49:41 +02:00
Steven Wroblewski bfd237fd5c Defekte Sitzung gibt Mitspieler frei, nur echte Gäste beenden die Pause
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, statt dass die anderen
ohne Nachricht warten. Eine Pause endet erst, wenn der Host einen Gast
angenommen hat, nicht schon bei einer bloßen Verbindung. So kann ein
Gast nicht mehr in einer pausierten Welt festhängen, wenn der Host
zwischen Verbindung und Gruß pausiert. Eine Pause lässt sich immer
beenden. Meldungen, die nach dem Ende einer Sitzung im selben Stapel
folgen, bleiben unbeachtet. Vier neue Tests decken das ab.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 22:33:10 +02:00
Steven Wroblewski 53ef02ae75 Spielen allein, als Host oder als Gast
Die Hauptszene beginnt mit dem Startmenü oder mit dem Direktstart aus
der Befehlszeile. Gäste sehen die Welt des Hosts und die Figuren aller
Spieler. Nur der Host speichert, Pause gibt es nur ohne Gäste, Laden
nur beim Spielen allein. Ablehnungen, Abbrüche und Abgleiche erscheinen
als Meldung, und ein Spielstand, der sich gerade nicht lesen lässt,
bleibt unangetastet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 22:03:42 +02:00
Steven Wroblewski d2da661452 Startmenü, fremde Figuren und Statuszeile
Das Startmenü fragt nach dem Namen und bietet allein spielen, die Welt
öffnen oder beitreten. Die Figuren der anderen Spieler gleiten zu ihrer
neuesten Position und tragen ihren Namen. Die Anzeige füllt Werte in
Meldungen ein und hat eine Statuszeile. Die Texte für Menü und Netzwerk
gibt es auf Deutsch und Englisch.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 21:40:57 +02:00
Steven Wroblewski 51cd4572a8 Gesperrte Einstellungen nicht überschreiben
Wer eine Einstellung ändert, liest die Einstellungsdatei jetzt an
derselben Stelle wie Schlüssel und Speicherstand. Ist sie gerade nicht
lesbar, etwa weil ein anderes Programm sie hält, bleibt sie unverändert,
statt nur mit dem neuen Wert überschrieben zu werden. Von einer
beschädigten Datei bleibt, was Godot lesen konnte. Ein neuer Test deckt
die gehaltene Datei ab. Der Parameter von set_player_name heißt jetzt
new_name, damit er die Methode player_name nicht verdeckt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 21:23:49 +02:00
Steven Wroblewski d5debc5bcb Profile, Startoptionen und vorsichtiges Lesen
Die Befehlszeile wählt ein Profil, einen Namen und einen Direktstart.
Zwei Fenster auf einem Rechner sind so zwei Spieler. Ein Schlüssel oder
Spielstand, der sich gerade nicht lesen lässt, wird nicht mehr ersetzt.
Name und zuletzt genutzte Adresse bleiben gespeichert.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 20:57:03 +02:00
Steven Wroblewski a43ce66a89 Nichts an schließende oder fehlende Verbindungen senden
NetLink merkt sich die Verbindungen, die es gerade schließt, und
schickt ihnen nichts mehr. Pakete an alle oder an alle außer einem gehen
einzeln an die offenen Verbindungen, statt Godot ein Ziel zu nennen, das
es nicht mehr gibt. Beides löste sonst Fehler in Godot aus, etwa wenn
der Host Figuren weiterreicht, während er einen abgelehnten Gast
hinauswirft. Ein neuer Test mit zwei Gästen deckt das ab.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 20:27:07 +02:00
Steven Wroblewski 0917450014 Verbindung über ENet
NetLink verbindet Host und Gäste über ENet: Nachrichten des Kerns
zuverlässig und in Reihenfolge, Positionen der Figuren unzuverlässig.
Eine Verbindung schließt erst, wenn wartende Nachrichten draußen sind.
SessionLink trägt die Nachrichten einer Sitzung über die Verbindung und
reicht Positionen weiter. Die Tests verbinden zwei Seiten über die
Loopback-Schnittstelle.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 19:53:04 +02:00
Steven Wroblewski 6c7c5bffc2 Panik-Test ohne globalen Hook, begrenzte Gast-Schleifen
Der Test einer Panik im Kern tauscht den globalen Panik-Hook nicht mehr
aus. Das war unnötig, weil der Testläufer die Meldung einer erwarteten
Panik ohnehin zurückhält, und es konnte Meldungen anderer, gleichzeitig
scheiternder Tests verschlucken. Die Gast-Schleifen in den GDScript-Tests
der Brücke laufen höchstens so oft, wie Takte warten, damit ein
abgewichener Gast den Testlauf nicht anhält.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 19:41:38 +02:00
Steven Wroblewski 2faa3ccce3 Brücke für Host und Gast
GwSession führt eine Sitzung als Host oder als Gast und reicht die
Nachrichten des Kerns an GDScript weiter. Nur der Host speichert. Die
Behandlung interner Fehler liegt jetzt in einem reinen Rust-Teil und hat
einen Test, und das Ereignis join_refused nennt den Schlüssel.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 19:20:34 +02:00
Steven Wroblewski 155d90f3a5 Host und Gast von Hand getrieben testen
Neue Tests treiben Host und Gast Nachricht für Nachricht und erzeugen,
was das Netz im Speicher nie herstellt: einen Gast, der hinterherhängt,
einen verlorenen Takt und Beitritte, die an den Grenzen warten. Sie
halten fest, dass ein abgewichener Gast nichts rechnet, bis die Welt des
Hosts kommt, dass er dann ältere Takte verwirft, dass die Zahl der
Aktionen eines Gastes in jedem Takt neu beginnt und dass wartende
Beitritte bei beiden Spielergrenzen zählen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 19:05:07 +02:00
Steven Wroblewski b48556d1f2 Host und Gast im Kern
Der Host nimmt Gäste auf, macht aus ihren Aktionen Befehle für den
Spieler ihrer Verbindung, begrenzt sie pro Takt und schickt jeden Takt
an alle Gäste. Gäste rechnen jeden Takt nach, vergleichen die
Prüfsummen und holen sich nach einer Abweichung die Welt des Hosts neu.
Tests verbinden Host und Gäste im Speicher, auch über 2000 Takte mit
Gehen und Wiederkommen.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 18:38:10 +02:00
Steven Wroblewski b7d5383650 Gruß-Anfang und Ablehnung festschreiben
Ein Test hält fest, dass ein Gruß mit Kennung und Version beginnt und
eine Ablehnung aus Kennung und Grund besteht. Beides bleibt auch bei
einer neuen Protokollversion gleich, damit sich Spiele verschiedener
Versionen weiter sagen können, dass sie nicht zusammenpassen. Die
Meldung des festgeschriebenen Nachrichtenformats weist darauf hin.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 18:29:32 +02:00
Steven Wroblewski a0cb6b12b7 Nachrichten zwischen Host und Gästen
Gäste grüßen mit Protokollversion, Regel-Kennung, Schlüssel und Name
und schicken danach nur Aktionen ohne Spielernummer. Der Host schickt
seine Welt, Ablehnungen mit Grund und jeden Takt mit seinen Befehlen,
jede Sekunde mit einer Prüfsumme. Ein Gruß einer anderen Version bleibt
erkennbar, und das Format ist in den festgeschriebenen Werten
verankert.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 18:14:04 +02:00
Steven Wroblewski f2e4c91082 Regel-Kennung und Aufzeichnung Version 3
Die eingefrorenen Skripte ziehen von den festgeschriebenen Tests in den
Kern. Aus ihren Endzuständen leitet sich die Regel-Kennung ab, die sich
mit jeder Regel ändert, die ein Skript berührt. Aufzeichnungen tragen
sie und werden mit anderen Regeln abgelehnt, statt eine scheinbare
Abweichung zu melden. Fremde Aufzeichnungen dürfen höchstens 30 Tage
lang sein. Ein Test hält fest, dass zu lange Namen beim Dekodieren
abgelehnt werden.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-01 18:00:00 +02:00
Steven Wroblewski 760bea36fd Plan M1c: Gleichschritt im Netz
Zehn Tasks machen Grovewright zum Koop-Spiel für bis zu vier Spieler:
Regel-Kennung und Aufzeichnung Version 3, Nachrichten zwischen Host und
Gästen, Host und Gast im Kern, die Brücke für beide Rollen, die
Verbindung über ENet, Profile und Startoptionen, Startmenü und fremde
Figuren, die Hauptszene für Host und Gast, ein Test mit zwei echten
Spielinstanzen und zum Schluss Startskripte, Regeln und Fragebogen.
Jede Task-Grenze wurde vorab mit rotem und grünem Lauf nachgestellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:47:44 +02:00
114 changed files with 20978 additions and 467 deletions
+3
View File
@@ -3,3 +3,6 @@
# Godot # Godot
/game/.godot/ /game/.godot/
# Packages built by paket.bat
/dist/
+56 -4
View File
@@ -14,6 +14,7 @@ Das verbindliche Konzept steht in [docs/KONZEPT.md](docs/KONZEPT.md). Lies es, b
| `docs/KONZEPT.md` | Spielkonzept und Entscheidungen | | `docs/KONZEPT.md` | Spielkonzept und Entscheidungen |
| `docs/superpowers/plans/` | Umsetzungspläne | | `docs/superpowers/plans/` | Umsetzungspläne |
| `docs/playtests/` | Fragebögen für Playtests | | `docs/playtests/` | Fragebögen für Playtests |
| `paket/` | Anleitung, die dem Paket für andere Rechner beiliegt |
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. 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.
@@ -25,7 +26,7 @@ Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschr
- Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand. - Keine Uhr, keine Dateien, kein Netzwerk, keine Threads, keine Umgebungsvariablen, kein globaler veränderlicher Zustand.
- Zufall nur über `rng::Rng` mit fester Startzahl. - Zufall nur über `rng::Rng` mit fester Startzahl.
- Keine Sammlung mit wechselnder Reihenfolge. Statt einer Hash-Tabelle nimmst du `Vec`, `BTreeMap` oder `BTreeSet`. - 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 das Laden eines Speicherstands mit `World::load`, das alle Spieler abmeldet. - 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. - 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`. - 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`. - 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`.
@@ -33,6 +34,16 @@ Alle Spieler simulieren die Welt selbst und tauschen nur Befehle aus (Gleichschr
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. 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 ## 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/`. - Die Brücke übersetzt nur. Neue Spielregeln gehören in den Kern, nie in `bridge/` oder `game/`.
@@ -41,6 +52,20 @@ Drei Wächter prüfen das. `core/clippy.toml` sperrt Pfade wie `std::collections
- Alle Texte, die Spieler sehen, stehen in `game/i18n/texts.csv` (Deutsch und Englisch) und werden über `tr()` geholt. - 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. - 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. - 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 ## Prüfen
@@ -57,18 +82,45 @@ godot --headless --path game -s res://tests/run_tests.gd
Der erste Import in einem frischen Checkout stürzt mit Godot 4.7.2 und der Rust-Erweiterung beim Beenden ab (Exit-Code 139 in der Bash). Der Import ist dann trotzdem erfolgt; ein zweiter Aufruf läuft fehlerfrei durch. `play.bat` wiederholt den Import deshalb einmal. Der 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.
Schlägt `core/tests/golden.rs` fehl, haben sich Regeln oder ein Format geändert. Sind Speicherstände oder Aufzeichnungen betroffen, erhöhst du die Versionskonstante, die die Fehlermeldung nennt. Erst danach passt du den festgeschriebenen Wert bewusst an. 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.
Für Änderungen am Aussehen gibt es ein Werkzeug, das kurz ein Fenster öffnet und ein Bildschirmfoto speichert: 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 ```bash
godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei> godot --path game -s res://tools/screenshot.gd -- <Pfad der PNG-Datei> [--style=NAME] [--showcase]
``` ```
## Spielen ## 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`. `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 ## Sprache
- Code, Bezeichner und Kommentare im Code: Englisch. - Code, Bezeichner und Kommentare im Code: Englisch.
+269
View File
@@ -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");
}
}
+2 -1
View File
@@ -14,8 +14,9 @@ pub fn to_dictionary(event: &Event) -> VarDictionary {
"type" => "player_left", "type" => "player_left",
"player" => i64::from(player.0), "player" => i64::from(player.0),
}, },
Event::JoinRefused { .. } => vdict! { Event::JoinRefused { key } => vdict! {
"type" => "join_refused", "type" => "join_refused",
"key" => &PackedByteArray::from(key.as_slice()),
}, },
Event::TileHit { player, pos } => vdict! { Event::TileHit { player, pos } => vdict! {
"type" => "tile_hit", "type" => "tile_hit",
+1
View File
@@ -3,6 +3,7 @@
//! The bridge only translates. Every rule lives in `grovewright-core`, and //! The bridge only translates. Every rule lives in `grovewright-core`, and
//! everything that is shown, heard or typed lives in the Godot project. //! everything that is shown, heard or typed lives in the Godot project.
mod driver;
mod events; mod events;
mod session; mod session;
+300 -127
View File
@@ -1,56 +1,26 @@
//! A solo game session for GDScript: the shared world, its recording and the //! A game session for GDScript, as host or as guest of a shared world.
//! commands waiting for the next tick.
use std::panic::{catch_unwind, AssertUnwindSafe};
use godot::prelude::*; use godot::prelude::*;
use grovewright_core::command::Command; use grovewright_core::guest::Arrival;
use grovewright_core::host::Peer;
use grovewright_core::inventory::ItemId; use grovewright_core::inventory::ItemId;
use grovewright_core::map::{Pos, Terrain}; use grovewright_core::map::{Pos, Terrain};
use grovewright_core::mapgen::spawn_pos; use grovewright_core::mapgen::spawn_pos;
use grovewright_core::player::{PlayerId, PlayerKey}; use grovewright_core::player::{PlayerId, PlayerKey};
use grovewright_core::replay::Recording; use grovewright_core::protocol::{Action, Refusal};
use grovewright_core::world::World; use grovewright_core::world::World;
use crate::driver::{Driver, HOST_PEER};
use crate::events; use crate::events;
/// Everything a running session holds. /// A session. The host runs the ticks and GDScript calls
struct Game { /// [`GwSession::step`] once per tick; a guest runs the ticks its host sends,
world: World, /// one [`GwSession::step`] per tick that arrived. GDScript carries the
recording: Recording, /// messages between the machines and reads the world back for display.
/// Commands for the next tick, in the order they were queued.
pending: Vec<Command>,
local_key: Option<PlayerKey>,
local: Option<PlayerId>,
/// Set when the core panicked. The world may be half-updated, so the
/// session neither steps nor saves any more.
broken: bool,
}
impl Game {
fn new(world: World) -> Self {
let recording = Recording::start(&world);
Self {
world,
recording,
pending: Vec::new(),
local_key: None,
local: None,
broken: false,
}
}
fn local_player(&self) -> Option<&grovewright_core::player::Player> {
self.local.and_then(|id| self.world.player(id))
}
}
/// A solo session. GDScript queues commands, calls [`GwSession::step`] once
/// per tick and reads the world back for display.
#[derive(GodotClass)] #[derive(GodotClass)]
#[class(base = RefCounted, init)] #[class(base = RefCounted, init)]
pub struct GwSession { pub struct GwSession {
game: Option<Game>, driver: Option<Driver>,
last_error: GString, last_error: GString,
} }
@@ -72,133 +42,277 @@ impl GwSession {
const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64; const TERRAIN_BEDROCK: i64 = Terrain::Bedrock as i64;
#[constant] #[constant]
const ITEM_STONE: i64 = grovewright_core::inventory::ITEM_STONE.0 as i64; 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;
/// Starts a fresh world from `seed`, replacing any running game. /// Hosts a fresh world from `seed`, replacing any running session.
#[func] #[func]
fn start_new(&mut self, seed: i64) { fn start_new(&mut self, seed: i64) {
self.game = Some(Game::new(World::new(seed as u64))); self.driver = Some(Driver::host(World::new(seed as u64)));
self.last_error = GString::new(); self.last_error = GString::new();
} }
/// Starts from a save. Returns false and keeps any running game if the /// Hosts a saved world. Returns false and keeps any running session if
/// save cannot be used; [`GwSession::last_error`] then says why. /// the save cannot be used; [`GwSession::last_error`] then says why.
#[func] #[func]
fn start_loaded(&mut self, save: PackedByteArray) -> bool { fn start_loaded(&mut self, save: PackedByteArray) -> bool {
match World::load(save.as_slice()) { match World::load(save.as_slice()) {
Ok(world) => { Ok(world) => {
self.game = Some(Game::new(world)); self.driver = Some(Driver::host(world));
self.last_error = GString::new(); self.last_error = GString::new();
true true
} }
Err(error) => { Err(error) => {
self.last_error = GString::from(error.to_string().as_str()); self.fail(&error.to_string());
false 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. /// Why the last call failed, or an empty text.
#[func] #[func]
fn last_error(&self) -> GString { fn last_error(&self) -> GString {
self.last_error.clone() 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] #[func]
fn is_running(&self) -> bool { fn is_running(&self) -> bool {
self.game.is_some() 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. /// True after the core stopped with an internal error.
#[func] #[func]
fn is_broken(&self) -> bool { fn is_broken(&self) -> bool {
self.game.as_ref().is_some_and(|game| game.broken) self.driver.as_ref().is_some_and(Driver::is_broken)
} }
/// Queues the entry of the local player for the next tick. `key` /// Queues the entry of the host's own player for the next tick. `key`
/// identifies the player across sessions and must have 16 bytes. /// identifies the player across sessions and must have 16 bytes. A
/// guest enters with [`GwSession::start_guest`] instead.
#[func] #[func]
fn join(&mut self, key: PackedByteArray, name: GString) -> bool { fn join(&mut self, key: PackedByteArray, name: GString) -> bool {
let Some(game) = self.game.as_mut() else { let Some(key) = self.player_key(&key) else {
return false; return false;
}; };
let Ok(key) = PlayerKey::try_from(key.as_slice()) else { let Some(driver) = self.driver.as_mut() else {
self.last_error = GString::from("a player key has 16 bytes");
return false; return false;
}; };
game.local_key = Some(key); driver.join_local(key, &name.to_string())
game.local = None;
game.pending.push(Command::Join {
key,
name: name.to_string(),
});
true
} }
/// Queues a pickaxe hit of the local player on tile (`x`, `y`) for the /// A pickaxe hit of this machine's player on tile (`x`, `y`): the host
/// next tick. Does nothing before the local player has joined. /// queues it for the next tick, a guest sends it to the host. Does
/// nothing before the player is in the world.
#[func] #[func]
fn dig(&mut self, x: i64, y: i64) { fn dig(&mut self, x: i64, y: i64) {
let Some(game) = self.game.as_mut() else { let (Some(driver), Some(pos)) = (self.driver.as_mut(), to_pos(x, y)) else {
return; return;
}; };
let (Some(player), Some(pos)) = (game.local, to_pos(x, y)) else { driver.act(Action::Dig { pos });
return;
};
game.pending.push(Command::Dig { player, pos });
} }
/// Runs one tick with the queued commands and returns what happened, as /// Runs one tick and returns what happened, as dictionaries with a
/// dictionaries with a `type` entry. /// `type` entry. A host runs its next tick; a guest runs the oldest tick
/// that arrived, or nothing if none is waiting.
#[func] #[func]
fn step(&mut self) -> Array<VarDictionary> { fn step(&mut self) -> Array<VarDictionary> {
let mut happened = Array::new(); let mut happened = Array::new();
let Some(game) = self.game.as_mut() else { let Some(driver) = self.driver.as_mut() else {
return happened; return happened;
}; };
if game.broken { if let Some(events) = driver.step() {
return happened; for event in &events {
happened.push(&events::to_dictionary(event));
}
} }
let commands = std::mem::take(&mut game.pending); if driver.is_broken() {
let Game { self.fail("the simulation stopped after an internal error");
world, recording, ..
} = game;
match catch_unwind(AssertUnwindSafe(|| recording.step(world, &commands))) {
Ok(events) => {
for event in &events {
happened.push(&events::to_dictionary(event));
}
if game.local.is_none() {
game.local = game.local_key.and_then(|key| game.world.find_player(&key));
}
}
Err(_) => {
game.broken = true;
self.last_error = GString::from("the simulation stopped after an internal error");
}
} }
happened 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. /// Number of ticks the world has run.
#[func] #[func]
fn tick(&self) -> i64 { fn tick(&self) -> i64 {
self.game self.world().map_or(0, |world| world.tick() as i64)
.as_ref()
.map_or(0, |game| game.world.tick() as i64)
} }
#[func] #[func]
fn map_width(&self) -> i64 { fn map_width(&self) -> i64 {
self.game self.world()
.as_ref() .map_or(0, |world| i64::from(world.map().width()))
.map_or(0, |game| i64::from(game.world.map().width()))
} }
#[func] #[func]
fn map_height(&self) -> i64 { fn map_height(&self) -> i64 {
self.game self.world()
.as_ref() .map_or(0, |world| i64::from(world.map().height()))
.map_or(0, |game| i64::from(game.world.map().height()))
} }
/// The terrain of every tile, row by row (`TERRAIN_*` constants). /// The terrain of every tile, row by row (`TERRAIN_*` constants).
@@ -229,76 +343,123 @@ impl GwSession {
/// The tile where figures appear. /// The tile where figures appear.
#[func] #[func]
fn spawn_point(&self) -> Vector2i { fn spawn_point(&self) -> Vector2i {
self.game.as_ref().map_or(Vector2i::ZERO, |game| { self.world().map_or(Vector2i::ZERO, |world| {
let spawn = spawn_pos(game.world.map()); let spawn = spawn_pos(world.map());
Vector2i::new(spawn.x, spawn.y) Vector2i::new(spawn.x, spawn.y)
}) })
} }
/// Index of the local player in the world, or -1 before joining. /// Index of this machine's player in the world, or -1 before it is in.
#[func] #[func]
fn local_player(&self) -> i64 { fn local_player(&self) -> i64 {
self.game self.driver
.as_ref() .as_ref()
.and_then(|game| game.local) .and_then(Driver::local_player)
.map_or(-1, |id| i64::from(id.0)) .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. /// How many of `item` the local player carries.
#[func] #[func]
fn item_count(&self, item: i64) -> i64 { fn item_count(&self, item: i64) -> i64 {
let (Some(game), Ok(item)) = (self.game.as_ref(), u16::try_from(item)) else { 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; return 0;
}; };
game.local_player() world
.player(player)
.map_or(0, |player| i64::from(player.inventory.count(ItemId(item)))) .map_or(0, |player| i64::from(player.inventory.count(ItemId(item))))
} }
/// The world as a save, or empty if there is no healthy session. /// The world as a save, or empty. Only a healthy host saves.
#[func] #[func]
fn save(&self) -> PackedByteArray { fn save(&self) -> PackedByteArray {
match self.game.as_ref() { self.driver
Some(game) if !game.broken => { .as_ref()
PackedByteArray::from(game.world.to_snapshot().as_slice()) .and_then(Driver::save)
} .map_or_else(PackedByteArray::new, |bytes| {
_ => PackedByteArray::new(), PackedByteArray::from(bytes.as_slice())
} })
} }
/// The recording of this session, or empty if there is no healthy /// The recording of this machine's session, or empty if there is no
/// session. /// healthy session with a world.
#[func] #[func]
fn recording_bytes(&self) -> PackedByteArray { fn recording_bytes(&self) -> PackedByteArray {
match self.game.as_ref() { self.driver
Some(game) if !game.broken => { .as_ref()
PackedByteArray::from(game.recording.to_bytes(&game.world).as_slice()) .and_then(Driver::recording_bytes)
} .map_or_else(PackedByteArray::new, |bytes| {
_ => PackedByteArray::new(), PackedByteArray::from(bytes.as_slice())
} })
} }
/// The checksum of the world state, as 16 hexadecimal digits. /// The checksum of the world state, as 16 hexadecimal digits.
#[func] #[func]
fn state_hash_hex(&self) -> GString { fn state_hash_hex(&self) -> GString {
self.game.as_ref().map_or_else(GString::new, |game| { self.world().map_or_else(GString::new, |world| {
GString::from(format!("{:016x}", game.world.state_hash()).as_str()) GString::from(format!("{:016x}", world.state_hash()).as_str())
}) })
} }
} }
impl GwSession { 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)> { fn tile(&self, x: i64, y: i64) -> Option<(Terrain, u8)> {
let game = self.game.as_ref()?; let tile = self.world()?.map().get(to_pos(x, y)?)?;
let tile = game.world.map().get(to_pos(x, y)?)?;
Some((tile.terrain, tile.damage)) Some((tile.terrain, tile.damage))
} }
fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray { fn tile_bytes(&self, byte: impl Fn(Terrain, u8) -> u8) -> PackedByteArray {
let Some(game) = self.game.as_ref() else { let Some(world) = self.world() else {
return PackedByteArray::new(); return PackedByteArray::new();
}; };
let bytes: Vec<u8> = game let bytes: Vec<u8> = world
.world
.map() .map()
.tiles() .tiles()
.iter() .iter()
@@ -313,6 +474,11 @@ fn to_pos(x: i64, y: i64) -> Option<Pos> {
Some(Pos::new(i32::try_from(x).ok()?, i32::try_from(y).ok()?)) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -323,4 +489,11 @@ mod tests {
assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None); assert_eq!(to_pos(i64::from(i32::MAX) + 1, 0), None);
assert_eq!(to_pos(0, i64::from(i32::MIN) - 1), 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);
}
} }
+231
View File
@@ -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()
}
}
+372
View File
@@ -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)
}
+4
View File
@@ -8,12 +8,16 @@
pub mod codec; pub mod codec;
pub mod command; pub mod command;
pub mod guest;
pub mod hash; pub mod hash;
pub mod host;
pub mod inventory; pub mod inventory;
pub mod map; pub mod map;
pub mod mapgen; pub mod mapgen;
pub mod player; pub mod player;
pub mod protocol;
pub mod replay; pub mod replay;
pub mod rng; pub mod rng;
pub mod rules;
pub mod snapshot; pub mod snapshot;
pub mod world; pub mod world;
+281
View File
@@ -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)
}
}
+33 -4
View File
@@ -1,17 +1,28 @@
//! Recording of a session: the starting state and every command, tick by //! Recording of a session: the starting state and every command, tick by
//! tick. Replaying a recording reproduces the session exactly. //! 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 std::fmt;
use crate::codec::{DecodeError, Reader, Writer}; use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command; use crate::command::Command;
use crate::rules::rules_id;
use crate::snapshot::SnapshotError; use crate::snapshot::SnapshotError;
use crate::world::{Event, World}; use crate::world::{Event, World, TICKS_PER_SECOND};
const MAGIC: [u8; 4] = *b"GRWR"; const MAGIC: [u8; 4] = *b"GRWR";
/// Version of the recording format. Raise it whenever the encoded recording, /// Version of the recording format. Raise it whenever the layout of a
/// including the snapshot inside it, changes. /// recording changes, including the snapshot inside it. Recordings made with
pub const RECORDING_VERSION: u32 = 2; /// 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. /// Reason a recording could not be read or replayed.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@@ -20,6 +31,9 @@ pub enum RecordingError {
NotARecording, NotARecording,
/// The recording was written by an incompatible version of the game. /// The recording was written by an incompatible version of the game.
UnsupportedVersion(u32), 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. /// The recording is damaged.
Corrupt(DecodeError), Corrupt(DecodeError),
/// The starting state inside the recording could not be restored. /// The starting state inside the recording could not be restored.
@@ -35,6 +49,10 @@ impl fmt::Display for RecordingError {
RecordingError::UnsupportedVersion(version) => { RecordingError::UnsupportedVersion(version) => {
write!(f, "unsupported recording version {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::Corrupt(error) => write!(f, "damaged recording: {error}"),
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"), RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
RecordingError::Diverged { expected, actual } => write!( RecordingError::Diverged { expected, actual } => write!(
@@ -139,6 +157,7 @@ impl Recording {
let mut w = Writer::new(); let mut w = Writer::new();
w.raw(&MAGIC); w.raw(&MAGIC);
w.u32(RECORDING_VERSION); w.u32(RECORDING_VERSION);
w.u64(rules_id());
w.u64(end.state_hash()); w.u64(end.state_hash());
w.bytes(&self.start); w.bytes(&self.start);
w.u32(self.tick_count); w.u32(self.tick_count);
@@ -154,6 +173,8 @@ impl Recording {
} }
/// Decodes a recording and the state hash that replaying it must reach. /// 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> { pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
let mut r = Reader::new(bytes); let mut r = Reader::new(bytes);
let magic = r let magic = r
@@ -166,12 +187,20 @@ impl Recording {
if version != RECORDING_VERSION { if version != RECORDING_VERSION {
return Err(RecordingError::UnsupportedVersion(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 expected_hash = r.u64()?;
let start = r.bytes()?.to_vec(); let start = r.bytes()?.to_vec();
let start_tick = World::from_snapshot(&start) let start_tick = World::from_snapshot(&start)
.map_err(RecordingError::BadStart)? .map_err(RecordingError::BadStart)?
.tick(); .tick();
let tick_count = r.u32()?; let tick_count = r.u32()?;
if tick_count > MAX_RECORDING_TICKS {
return Err(DecodeError::InvalidValue("tick count").into());
}
let busy_count = r.u32()?; let busy_count = r.u32()?;
let mut busy_ticks = Vec::new(); let mut busy_ticks = Vec::new();
let mut first_free_tick = 0; let mut first_free_tick = 0;
+171
View File
@@ -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())
}
+15
View File
@@ -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] #[test]
fn an_unknown_command_is_rejected() { fn an_unknown_command_is_rejected() {
assert_eq!( assert_eq!(
+113 -144
View File
@@ -11,142 +11,19 @@ use grovewright_core::codec::Writer;
use grovewright_core::command::Command; use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64; use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile}; use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId; use grovewright_core::player::PlayerId;
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost, PROTOCOL_VERSION};
use grovewright_core::replay::Recording; 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; 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
}
/// The commands of a second session of 30 ticks that ends with one player
/// offline, so that the encoding of offline players is pinned as well.
///
/// Never change or extend this script either.
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
}
/// Covers the map generator, the map encoding and the snapshot header and /// Covers the map generator, the map encoding and the snapshot header and
/// checksum. /// checksum.
#[test] #[test]
fn the_starting_snapshot_is_unchanged() { 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!( assert_eq!(
actual, 0x6812_937b_f2b2_19ae, actual, 0x6812_937b_f2b2_19ae,
"the starting world or the snapshot format changed; if saves are \ "the starting world or the snapshot format changed; if saves are \
@@ -159,15 +36,11 @@ fn the_starting_snapshot_is_unchanged() {
/// and the encoding of players. /// and the encoding of players.
#[test] #[test]
fn the_frozen_session_ends_in_the_same_state() { 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!( assert_eq!(
world.state_hash(), end_hash(frozen_script),
0xc304_43df_fc74_dcfc, 0xc304_43df_fc74_dcfc,
"the rules or the snapshot format changed; old recordings no longer \ "the rules or the snapshot format changed, and with them the rules \
verify; if saves are affected, raise SNAPSHOT_VERSION in \ id; if saves are affected, raise SNAPSHOT_VERSION in \
core/src/snapshot.rs; then update this value" core/src/snapshot.rs; then update this value"
); );
} }
@@ -175,30 +48,39 @@ fn the_frozen_session_ends_in_the_same_state() {
/// Covers the encoding of a player who is offline. /// Covers the encoding of a player who is offline.
#[test] #[test]
fn the_frozen_offline_session_ends_in_the_same_state() { fn the_frozen_offline_session_ends_in_the_same_state() {
let mut world = World::new(SEED);
for commands in frozen_offline_script(&world) {
world.step(&commands);
}
assert_eq!( assert_eq!(
world.state_hash(), end_hash(frozen_offline_script),
0x904f_10e1_7c52_8be4, 0x904f_10e1_7c52_8be4,
"the rules or the snapshot format changed; old recordings no longer \ "the rules or the snapshot format changed, and with them the rules \
verify; if saves are affected, raise SNAPSHOT_VERSION in \ id; if saves are affected, raise SNAPSHOT_VERSION in \
core/src/snapshot.rs; then update this value" 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. /// Covers the recording header and layout and the wire format of commands.
#[test] #[test]
fn the_recording_format_is_unchanged() { 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); let mut recording = Recording::start(&world);
for commands in frozen_script(&world) { for commands in frozen_script(&world) {
recording.step(&mut world, &commands); recording.step(&mut world, &commands);
} }
assert_eq!( assert_eq!(
fnv1a64(&recording.to_bytes(&world)), fnv1a64(&recording.to_bytes(&world)),
0x1c24_8cc7_6bc8_846a, 0xf203_b229_8dad_7c53,
"the recording or command format changed; if other tests in this \ "the recording or command format changed; if other tests in this \
file fail as well, deal with those first, because a recording \ file fail as well, deal with those first, because a recording \
contains a snapshot and an end state; otherwise raise \ contains a snapshot and an end state; otherwise raise \
@@ -235,3 +117,90 @@ fn the_map_encoding_is_unchanged() {
core/src/snapshot.rs; then update these bytes" 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"
);
}
}
+351
View File
@@ -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");
}
}
+177
View File
@@ -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)
);
}
+55 -6
View File
@@ -3,11 +3,15 @@ use grovewright_core::command::Command;
use grovewright_core::inventory::ITEM_STONE; use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::mapgen::outcrop_origin; use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId; 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::snapshot::SnapshotError;
use grovewright_core::world::World; use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0); 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. /// Commands of tick `tick` in a short scripted session.
fn commands_for(world: &World, tick: u32) -> Vec<Command> { fn commands_for(world: &World, tick: u32) -> Vec<Command> {
@@ -81,8 +85,9 @@ fn verify_accepts_a_faithful_recording() {
fn verify_reports_a_replay_that_ends_in_another_state() { fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, world) = recorded_session(); let (recording, world) = recorded_session();
let mut bytes = recording.to_bytes(&world); let mut bytes = recording.to_bytes(&world);
// The expected state hash follows the marker and the version. // The expected state hash follows the marker, the version and the rules
bytes[8] ^= 0x01; // identity.
bytes[16] ^= 0x01;
match Recording::verify(&bytes) { match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => { Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(actual, world.state_hash()); assert_eq!(actual, world.state_hash());
@@ -179,11 +184,55 @@ fn damaged_recordings_are_rejected() {
fn a_recording_with_a_damaged_start_is_rejected() { fn a_recording_with_a_damaged_start_is_rejected() {
let (recording, world) = recorded_session(); let (recording, world) = recorded_session();
let mut bytes = recording.to_bytes(&world); let mut bytes = recording.to_bytes(&world);
// The snapshot starts after marker, version, expected hash and its own // The snapshot starts after marker, version, rules identity, expected
// length: 4 + 4 + 8 + 4 bytes. // hash and its own length: 4 + 4 + 8 + 8 + 4 bytes.
bytes[20 + 10] ^= 0x01; bytes[START + 10] ^= 0x01;
assert_eq!( assert_eq!(
Recording::from_bytes(&bytes), Recording::from_bytes(&bytes),
Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch)) 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"
)))
);
}
+516
View File
@@ -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());
}
}
+2 -2
View File
@@ -181,7 +181,7 @@ Die eigene Spielfigur bewegt sich sofort und läuft außerhalb des Gleichschritt
### 7.4 Grafik und Ton ### 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **Titel.** Eine Websuche fand kein Spiel namens „Grovewright". Vor einem Steam-Release ist eine Markenrecherche nötig.
+41
View File
@@ -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.
+41
View File
@@ -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 |
|---|---|---|
| ![Warm, Vitrine](m1d/warm-vitrine.png) | ![Pastell, Vitrine](m1d/pastell-vitrine.png) | ![Glimmen, Vitrine](m1d/glimmen-vitrine.png) |
| ![Warm, Spiel](m1d/warm-karte.png) | ![Pastell, Spiel](m1d/pastell-karte.png) | ![Glimmen, Spiel](m1d/glimmen-karte.png) |
## Fragen
1. Bleibt es bei Warm, oder gefällt dir im Spiel ein anderer Stil besser?
2. Was gefällt dir an deinem Favoriten am besten, und was stört dich daran?
3. Gibt es an den anderen beiden etwas, das du übernehmen möchtest, etwa das Leuchten des Lebendigen aus Glimmen?
4. Erkennst du auf einen Blick, was Boden, weicher Fels, harter Fels und Grundgestein ist? Und was lebendiger Boden ist?
5. Wirkt das Lebendige lebendig: das Moos, die Ranke, die wachsende Pflanze, das Tier, die erwachende Ruine? Oder wirkt es nüchtern?
6. Ist die Bewegung angenehm: Laufen, Wiegen, Wachsen und Hüpfen? Oder ist es zu viel oder zu wenig?
7. Findest du deine Figur sofort, auch neben anderen? Sind Figuren und Dinge groß genug?
8. Wirkt ein Schlag als Gast jetzt sofort, auch wenn Risse und Brechen erst mit der Antwort des Hosts kommen? Und sind die Namen über den Figuren scharf?
Antworte formlos im Chat oder als Kommentar in Gitea.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
+1 -1
View File
@@ -5,4 +5,4 @@ reloadable = true
[libraries] [libraries]
windows.debug.x86_64 = "res://../target/debug/grovewright_bridge.dll" windows.debug.x86_64 = "res://../target/debug/grovewright_bridge.dll"
windows.release.x86_64 = "res://../target/release/grovewright_bridge.dll" windows.release.x86_64 = "res://../target/x86_64-pc-windows-msvc/release/grovewright_bridge.dll"
+44 -1
View File
@@ -7,4 +7,47 @@ LOAD_FAILED,Der Spielstand ließ sich nicht laden,The save could not be loaded
INVENTORY_FULL,Die Tasche ist voll,Your bag is full INVENTORY_FULL,Die Tasche ist voll,Your bag is full
PAUSED,Pause,Paused PAUSED,Pause,Paused
SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped SIMULATION_STOPPED,Die Simulation wurde angehalten,The simulation stopped
HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause HELP,WASD: laufen · Linke Maustaste: Stein schlagen · F2: Stil wechseln · F5: speichern · F9: laden · Esc: Pause,WASD: walk · Left mouse button: break rock · F2: change the style · F5: save · F9: load · Esc: pause
DEFAULT_NAME,Spieler,Player
MENU_NAME,Dein Name,Your name
MENU_SOLO,Allein spielen,Play alone
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
1 keys de en
7 INVENTORY_FULL Die Tasche ist voll Your bag is full
8 PAUSED Pause Paused
9 SIMULATION_STOPPED Die Simulation wurde angehalten The simulation stopped
10 HELP WASD: laufen · Linke Maustaste: Stein schlagen · F5: speichern · F9: laden · Esc: Pause WASD: laufen · Linke Maustaste: Stein schlagen · F2: Stil wechseln · F5: speichern · F9: laden · Esc: Pause WASD: walk · Left mouse button: break rock · F5: save · F9: load · Esc: pause WASD: walk · Left mouse button: break rock · F2: change the style · F5: save · F9: load · Esc: pause
11 DEFAULT_NAME Spieler Player
12 MENU_NAME Dein Name Your name
13 MENU_SOLO Allein spielen Play alone
14 MENU_HOST Welt öffnen Open your world
15 MENU_ADDRESS Adresse des Hosts Address of the host
16 MENU_JOIN Beitreten Join
17 CONNECTING Verbinde mit %s … Connecting to %s …
18 CONNECT_FAILED Keine Verbindung zu %s Could not reach %s
19 ADDRESS_INVALID Das ist keine Adresse: %s That is not an address: %s
20 HOST_LEFT Der Host hat die Welt geschlossen The host closed the world
21 PROTOCOL_ERROR Die Verbindung zum Host ist gestört The connection to the host is broken
22 REFUSED_OTHER_PROTOCOL Der Host hat eine andere Version des Spiels The host has another version of the game
23 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.
24 REFUSED_WORLD_FULL Die Welt ist voll: Vier Spieler sind schon da The world is full: four players are in it
25 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
26 REFUSED_ALREADY_PLAYING Ein Spieler mit deinem Schlüssel ist schon in der Welt A player with your key is in the world already
27 STATUS_HOST Welt offen auf Port %d · Spieler: %d World open on port %d · players: %d
28 STATUS_GUEST Gast bei %s · Spieler: %d Guest of %s · players: %d
29 OPEN_FAILED Port %d lässt sich nicht öffnen Port %d cannot be opened
30 PLAYER_JOINED %s ist da %s joined
31 PLAYER_LEFT %s ist gegangen %s left
32 RESYNCED Welt mit dem Host abgeglichen World matched with the host again
33 PAUSE_ONLY_ALONE Pause gibt es nur ohne Gäste Pausing only works without guests
34 LOAD_ONLY_ALONE Laden gibt es nur beim Spielen allein Loading only works when playing alone
35 HOST_SAVES Nur der Host speichert Only the host saves
36 KEY_UNREADABLE Dein Spielerschlüssel lässt sich gerade nicht lesen Your player key cannot be read right now
37 SAVE_UNREADABLE Der Spielstand lässt sich gerade nicht lesen The save cannot be read right now
38 STYLE_WARM Warm Warm
39 STYLE_PASTEL Pastell Pastel
40 STYLE_GLOW Glimmen Glow
41 STYLE_SWITCHED Stil: %s Style: %s
42 MENU_SHOWCASE Stilproben ansehen See the style samples
43 SHOWCASE_TITLE Stilprobe: %s Style sample: %s
44 SHOWCASE_BACK Zurück Back
45 SHOWCASE_ROCK Weicher und harter Fels Soft and hard rock
46 SHOWCASE_DEAD_SOIL Toter Boden Dead soil
47 SHOWCASE_LIVING_SOIL Lebendiger Boden Living soil
48 SHOWCASE_MOSS Lebensmoos Life moss
49 SHOWCASE_PLANT Faserpflanze wächst A fibre plant grows
50 SHOWCASE_GRAZER Weidetier Grazer
51 SHOWCASE_VINE Ranke trägt Steine A vine carries stones
52 SHOWCASE_RUIN Ruine erwacht A ruin wakes
53 SHOWCASE_FIGURES Spielfiguren Player figures
+2
View File
@@ -19,3 +19,5 @@ window/size/viewport_height=720
renderer/rendering_method="gl_compatibility" renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility" renderer/rendering_method.mobile="gl_compatibility"
textures/canvas_textures/default_texture_filter=0
2d/snap/snap_2d_transforms_to_pixel=true
+105
View File
@@ -0,0 +1,105 @@
class_name Art
extends RefCounted
## Every graphic on the playing field has an id. Art draws it in the chosen
## style, or from a picture whose file name is the id, so that an artist can
## replace a graphic without changing code: res://art/terrain/ground.png
## replaces the drawing of GROUND.
## The style changed, or pictures were loaded. Everything drawn by Art has to
## be drawn again.
signal changed
const GROUND := &"terrain/ground"
const SOFT_ROCK := &"terrain/soft_rock"
const HARD_ROCK := &"terrain/hard_rock"
const BEDROCK := &"terrain/bedrock"
## The cracks of a tile that took hits, drawn over the tile.
const CRACKS := &"terrain/cracks"
## The burst where a pickaxe lands.
const HIT := &"effect/hit"
const FIGURE := &"figure"
## The outline of the tile under the mouse.
const MARKER := &"marker"
## Graphics of later milestones, shown in the showcase of the style samples.
const DEAD_SOIL := &"soil/dead"
const LIVING_SOIL := &"soil/living"
const MOSS := &"plant/moss"
const VINE := &"plant/vine"
const FIBER_PLANT := &"plant/fiber"
const STONE := &"item/stone"
const GRAZER := &"animal/grazer"
const RUIN := &"ruin"
## Every id, in the order the showcase and the checks go through them.
const IDS: Array[StringName] = [
GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, CRACKS, HIT, FIGURE, MARKER,
DEAD_SOIL, LIVING_SOIL, MOSS, VINE, FIBER_PLANT, STONE, GRAZER, RUIN,
]
## The folder where pictures that replace drawings are looked for.
const OVERRIDE_DIR := "res://art"
## The styles to choose from, in the order the game offers them.
var styles: Array[ArtStyle] = [WarmStyle.new(), PastelStyle.new(), GlowStyle.new()]
## The style everything is drawn in.
var style: ArtStyle = styles[0]
var _overrides: Dictionary[StringName, Texture2D] = {}
## The keys of the styles, as LaunchOptions and the settings name them.
static func style_keys() -> Array[StringName]:
return [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY]
## Draws everything in the style named `key` from now on. Returns false, and
## changes nothing, if no style has that key.
func select(key: StringName) -> bool:
for candidate in styles:
if candidate.key() == key:
if candidate != style:
style = candidate
changed.emit()
return true
return false
## Switches to the style after the current one, and from the last to the
## first.
func select_next() -> void:
style = styles[(styles.find(style) + 1) % styles.size()]
changed.emit()
## Uses the picture `<directory>/<id>.png` instead of the drawing, for every
## id that has one. Pictures loaded earlier are dropped.
func load_overrides(directory: String) -> void:
_overrides.clear()
for id in IDS:
var texture := _load_texture(directory.path_join(String(id) + ".png"))
if texture != null:
_overrides[id] = texture
changed.emit()
## True if a picture replaces the drawing of `id`.
func has_override(id: StringName) -> bool:
return _overrides.has(id)
## Draws the graphic `id` into `rect` on `canvas`. Call it only while
## `canvas` draws, from its _draw(). `state` carries what the graphic shows,
## for example how far a plant has grown; see the styles for the entries.
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary = {}) -> void:
var texture: Texture2D = _overrides.get(id)
if texture != null:
canvas.draw_texture_rect(texture, rect, false)
else:
style.draw(canvas, id, rect, state)
static func _load_texture(path: String) -> Texture2D:
if path.begins_with("res://"):
return load(path) as Texture2D if ResourceLoader.exists(path) else null
if not FileAccess.file_exists(path):
return null
var image := Image.load_from_file(path)
return ImageTexture.create_from_image(image) if image != null else null
+1
View File
@@ -0,0 +1 @@
uid://dxmii13634y22
+192
View File
@@ -0,0 +1,192 @@
class_name ArtStyle
extends RefCounted
## One look of the playing field. Draws every graphic that Art names, with
## code. This base class draws plain placeholders; each style overrides the
## drawing of every id.
##
## The entries of `state`, by id:
## - GROUND, SOFT_ROCK, HARD_ROCK, BEDROCK, DEAD_SOIL and LIVING_SOIL:
## `solid`, a TileShape mask of the neighbours that are rock or the edge of
## the map; `living`, for LIVING_SOIL, the mask of living neighbours;
## `variant`, from TileShape.variant
## - CRACKS: `hits`, 1 or more; `variant`
## - HIT: `age`, from 0 when the pickaxe lands to 1 when the burst is over;
## `variant`
## - FIGURE: `player`, the player's number or -1; `facing`, 1.0 to the
## right or -1.0 to the left; `step`, an angle that grows while the figure
## walks; `moving`
## - MARKER: `reachable`
## - MOSS: `variant`; `time`, in seconds
## - VINE: `points`, the path of the vine; `time`
## - FIBER_PLANT: `growth`, from 0 (a seed) to 1 (grown); `time`
## - GRAZER: `hop`, from 0 to 1 during one hop; `facing`
## - RUIN: `awake`, from 0 (asleep) to 1 (awake); `time`
## - STONE: nothing
## The name of the style on the command line and in the settings. The base
## class is no style to choose and has none.
func key() -> StringName:
return &""
## The key of the style's name in the texts, or an empty text for the base
## class.
func title_key() -> String:
return ""
## Draws the graphic `id` into `rect` on `canvas`, while `canvas` draws.
func draw(canvas: CanvasItem, id: StringName, rect: Rect2, state: Dictionary) -> void:
match id:
Art.GROUND:
_ground(canvas, rect, state)
Art.SOFT_ROCK:
_soft_rock(canvas, rect, state)
Art.HARD_ROCK:
_hard_rock(canvas, rect, state)
Art.BEDROCK:
_bedrock(canvas, rect, state)
Art.CRACKS:
_cracks(canvas, rect, state)
Art.HIT:
_hit(canvas, rect, state)
Art.FIGURE:
_figure(canvas, rect, state)
Art.MARKER:
_marker(canvas, rect, state)
Art.DEAD_SOIL:
_dead_soil(canvas, rect, state)
Art.LIVING_SOIL:
_living_soil(canvas, rect, state)
Art.MOSS:
_moss(canvas, rect, state)
Art.VINE:
_vine(canvas, rect, state)
Art.FIBER_PLANT:
_fiber_plant(canvas, rect, state)
Art.STONE:
_stone(canvas, rect, state)
Art.GRAZER:
_grazer(canvas, rect, state)
Art.RUIN:
_ruin(canvas, rect, state)
_:
push_error("no drawing for the graphic %s" % id)
## The colour of the figure of `player`. Players keep their colour; -1, a
## player not in the world yet, gets the first.
func player_color(player: int) -> Color:
return Color(0.95, 0.85, 0.6) if player <= 0 else Color(0.55, 0.8, 0.95)
func _ground(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.42, 0.36, 0.28))
func _soft_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.56, 0.55, 0.52))
func _hard_rock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.33, 0.33, 0.37))
func _bedrock(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.12, 0.12, 0.14))
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hits: int = state.get("hits", 1)
var inset := rect.grow(-3.0)
for line in hits:
var part := (line + 1.0) / (hits + 1.0)
var from := Vector2(inset.position.x, inset.position.y + inset.size.y * part)
var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - part))
canvas.draw_line(from, to, Color(0.1, 0.08, 0.06, 0.8), 1.0)
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var age: float = state.get("age", 0.0)
for chip in 4:
var direction := Vector2.RIGHT.rotated(TAU * chip / 4.0 + 0.4)
var at := rect.get_center() + direction * rect.size.x * 0.6 * age
canvas.draw_rect(Rect2(at - Vector2(1, 1), Vector2(2, 2)), Color(0.8, 0.8, 0.75, 1.0 - age))
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var radius := rect.size.x * 0.3
canvas.draw_circle(rect.get_center(), radius + 1.0, Color(0.15, 0.1, 0.05))
canvas.draw_circle(rect.get_center(), radius, player_color(state.get("player", -1)))
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var color := Color(0.4, 1.0, 0.4) if state.get("reachable", false) else Color(1.0, 0.4, 0.4)
canvas.draw_rect(rect, color, false, 1.0)
func _dead_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.45, 0.42, 0.38))
func _living_soil(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.35, 0.5, 0.25))
func _moss(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_circle(rect.get_center(), rect.size.x * 0.3, Color(0.3, 0.65, 0.3))
func _vine(canvas: CanvasItem, _rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
if points.size() >= 2:
canvas.draw_polyline(points, Color(0.25, 0.6, 0.25), 2.0)
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var growth: float = state.get("growth", 1.0)
var foot := Vector2(rect.get_center().x, rect.end.y)
canvas.draw_line(foot, foot - Vector2(0, rect.size.y * growth), Color(0.4, 0.7, 0.3), 2.0)
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
canvas.draw_circle(rect.get_center(), rect.size.x * 0.4, Color(0.6, 0.6, 0.58))
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var lift := sin(PI * float(state.get("hop", 0.0))) * rect.size.y * 0.3
canvas.draw_circle(rect.get_center() - Vector2(0, lift), rect.size.x * 0.35, Color(0.92, 0.9, 0.85))
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
canvas.draw_rect(rect, Color(0.5, 0.5, 0.48))
var awake: float = state.get("awake", 0.0)
canvas.draw_circle(rect.get_center(), rect.size.x * 0.2, Color(1.0, 0.85, 0.3, awake))
# Helpers for the styles.
## A share from 0 to 1 that is the same for the same `variant` and `salt`.
static func share(variant: int, salt: int) -> float:
return float(TileShape.variant(Vector2i(variant & 0xffff, salt)) % 10007) / 10007.0
## A gentle swing from -1 to 1, slow and different for every `variant`.
static func sway(time: float, variant: int, speed := 1.3) -> float:
return sin(time * speed + share(variant, 7) * TAU)
## The point at `part` of the way along `points`, from 0 to 1.
static func along(points: PackedVector2Array, part: float) -> Vector2:
var total := 0.0
for i in points.size() - 1:
total += points[i].distance_to(points[i + 1])
var left := clampf(part, 0.0, 1.0) * total
for i in points.size() - 1:
var length := points[i].distance_to(points[i + 1])
if left <= length:
return points[i].lerp(points[i + 1], left / length if length > 0.0 else 0.0)
left -= length
return points[points.size() - 1]
+1
View File
@@ -0,0 +1 @@
uid://o2dqirvunvw4
+50
View File
@@ -0,0 +1,50 @@
class_name GlowStyle
extends PixelStyle
## A dim, cool world in grey and slate, in which everything living shines in
## strong colours and glows: the dead and the living apart at a glance.
const KEY := &"glow"
func _init() -> void:
glow = 1.0
palette = {
"outline": Color("15171f"), "dust": Color("b9b5af"),
"ground": Color("6f6a68"), "ground_dark": Color("5f5a59"), "ground_light": Color("7f7a77"),
"pebble": Color("57534f"), "pebble_light": Color("8a8580"), "pebble_dark": Color("45413e"),
"dead_crack": Color("4c4846"),
"soft_top": Color("8d8a87"), "soft_top_dark": Color("7c7976"), "soft_top_light": Color("a3a09c"),
"soft_face": Color("636160"), "soft_face_dark": Color("514f4f"), "soft_lip": Color("b5b2ae"),
"hard_top": Color("4f5a75"), "hard_top_dark": Color("434d66"), "hard_top_light": Color("62708f"),
"hard_face": Color("39425a"), "hard_face_dark": Color("2e354a"), "hard_lip": Color("7a89aa"),
"hard_glint": Color("8ff0ff"),
"bed_top": Color("2e2b30"), "bed_top_dark": Color("262328"), "bed_top_light": Color("3a363c"),
"bed_face": Color("1e1b20"), "bed_face_dark": Color("171519"), "bed_lip": Color("48434a"),
"grass": Color("3fbf5a"), "grass_dark": Color("2e9c48"), "grass_light": Color("6ee887"),
"grass_edge": Color("1f7a3a"),
"flower_a": Color("fff27a"), "flower_b": Color("e8fff0"), "flower_c": Color("ff8ad0"),
"moss": Color("35c46a"), "moss_dark": Color("25a052"), "moss_light": Color("7cf5a5"), "spore": Color("fff59a"),
"vine": Color("3dbb63"), "vine_dark": Color("2a8f4a"), "leaf": Color("52d47a"), "leaf_dark": Color("34a85c"),
"stem": Color("45c068"), "bud": Color("a0f5b8"), "fibre": Color("f0fff6"), "fibre_shade": Color("c8e8d6"),
"seed": Color("8a6a4a"),
"stone": Color("8e8b88"), "stone_light": Color("aeaaa6"), "stone_dark": Color("6c6967"),
"wool": Color("eef2f0"), "wool_shade": Color("cfd6d3"), "wool_light": Color("ffffff"),
"face": Color("3a3438"), "hoof": Color("2a2629"),
"ruin": Color("7d7a80"), "ruin_dark": Color("68656b"), "ruin_light": Color("94919a"),
"ruin_moss": Color("3fbf6a"), "door": Color("121014"), "glow": Color("ffe066"), "glow_light": Color("fffbd0"),
"skin": Color("f0c4a0"), "skin_shade": Color("d39e7a"), "hair": Color("3d2a24"), "hair_light": Color("5a3e34"),
"eye": Color("1a1416"), "pants": Color("3b4466"), "pants_dark": Color("2e3552"), "boots": Color("3a2a22"),
"marker_near": Color("c8fff0"), "marker_far": Color("ff6a5a"),
}
players = [
Color("ff5f4f"), Color("4f9fff"), Color("ffc847"), Color("4fd06a"),
Color("b77aff"), Color("3fd8c8"), Color("ff7ac0"), Color("ff9a4a"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_GLOW"
+1
View File
@@ -0,0 +1 @@
uid://ce6e46e8mwi8f
+50
View File
@@ -0,0 +1,50 @@
class_name PastelStyle
extends PixelStyle
## Soft, light colours, and outlines in a darker shade of what they
## surround instead of one dark line.
const KEY := &"pastel"
func _init() -> void:
tinted_outlines = true
palette = {
"outline": Color("7a6670"), "dust": Color("fff6ea"),
"ground": Color("e2c7a6"), "ground_dark": Color("d2b591"), "ground_light": Color("eedac0"),
"pebble": Color("c2b6aa"), "pebble_light": Color("ddd4ca"), "pebble_dark": Color("a3978c"),
"dead_crack": Color("c3a585"),
"soft_top": Color("dcd5cf"), "soft_top_dark": Color("cbc3bc"), "soft_top_light": Color("eeeae6"),
"soft_face": Color("bab1ab"), "soft_face_dark": Color("a89f99"), "soft_lip": Color("f6f3f1"),
"hard_top": Color("abb6da"), "hard_top_dark": Color("99a4c9"), "hard_top_light": Color("c5ceea"),
"hard_face": Color("8e98bf"), "hard_face_dark": Color("7c86ad"), "hard_lip": Color("dde4f6"),
"hard_glint": Color("ffffff"),
"bed_top": Color("8d8292"), "bed_top_dark": Color("7e7383"), "bed_top_light": Color("9e93a3"),
"bed_face": Color("6f6574"), "bed_face_dark": Color("615866"), "bed_lip": Color("b0a6b5"),
"grass": Color("9dd28b"), "grass_dark": Color("86c177"), "grass_light": Color("bae4a7"),
"grass_edge": Color("74ae67"),
"flower_a": Color("fff2a8"), "flower_b": Color("ffffff"), "flower_c": Color("ffb6cf"),
"moss": Color("88c57b"), "moss_dark": Color("72ae67"), "moss_light": Color("abde9a"), "spore": Color("fff0a8"),
"vine": Color("80c077"), "vine_dark": Color("67a460"), "leaf": Color("94d086"), "leaf_dark": Color("76b16a"),
"stem": Color("88c37b"), "bud": Color("cae9b2"), "fibre": Color("fffdf6"), "fibre_shade": Color("ede7db"),
"seed": Color("b38d6c"),
"stone": Color("c6bfb8"), "stone_light": Color("ded9d4"), "stone_dark": Color("aaa39c"),
"wool": Color("fffaf2"), "wool_shade": Color("ede5d9"), "wool_light": Color("ffffff"),
"face": Color("8c7c76"), "hoof": Color("71635d"),
"ruin": Color("cfc8d2"), "ruin_dark": Color("b7b0bd"), "ruin_light": Color("e2dde5"),
"ruin_moss": Color("94c888"), "door": Color("62596d"), "glow": Color("ffe7a2"), "glow_light": Color("fffbe9"),
"skin": Color("fcddc3"), "skin_shade": Color("efc3a4"), "hair": Color("b27c5c"), "hair_light": Color("cb987c"),
"eye": Color("5c4852"), "pants": Color("91a2d2"), "pants_dark": Color("7c8dbf"), "boots": Color("a28068"),
"marker_near": Color("ffffff"), "marker_far": Color("ff9c8c"),
}
players = [
Color("f08c7c"), Color("7cb0ea"), Color("f2ce6c"), Color("88cb7c"),
Color("ba98e2"), Color("76d1c8"), Color("f2a2c6"), Color("f4af7a"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_PASTEL"
+1
View File
@@ -0,0 +1 @@
uid://dr2fry8j3yai8
+801
View File
@@ -0,0 +1,801 @@
class_name PixelStyle
extends ArtStyle
## Pixel art in the manner of classic farming games, drawn in code. A tile
## is 16 by 16 pixels, seen a little from the front: rock shows its face
## toward open ground, and figures, animals and plants stand upright. Every
## graphic is made once as a small image, pixel by pixel from the palette,
## and kept; the camera enlarges each pixel to a crisp square.
##
## The subclasses choose the palette and the outlines.
## Pixels along the edge of a tile.
const TILE := 16
## Rows of the face that rock shows toward open ground below it.
const FACE := 6
## Images of a burst of chips, from the hit to its end.
const HIT_FRAMES := 6
## Stages of a growing plant, from the seed to the fibres.
const GROWTH_STAGES := 6
## Size of a figure: a tile wide and one and a half tiles tall.
const FIGURE_SIZE := Vector2i(16, 24)
const CLEAR := Color(0, 0, 0, 0)
## Colours by name; the subclass fills them.
var palette: Dictionary[String, Color] = {}
## The colours of the players, in the order of their numbers.
var players: Array[Color] = []
## True for outlines in a darker shade of what they surround, false for one
## dark outline colour.
var tinted_outlines := false
## How strongly living things glow, from 0 (not at all) to 1; the subclass
## chooses it.
var glow := 0.0
var _textures: Dictionary[String, Texture2D] = {}
## A small image that the drawings put their pixels on.
class Pixels:
const SIDES: Array[Vector2i] = [Vector2i(1, 0), Vector2i(-1, 0), Vector2i(0, 1), Vector2i(0, -1)]
const CLEAR := Color(0, 0, 0, 0)
var image: Image
var size: Vector2i
func _init(width: int, height: int) -> void:
size = Vector2i(width, height)
image = Image.create_empty(width, height, false, Image.FORMAT_RGBA8)
func has(x: int, y: int) -> bool:
return x >= 0 and y >= 0 and x < size.x and y < size.y
func put(x: int, y: int, color: Color) -> void:
if has(x, y):
image.set_pixel(x, y, color)
## The colour at x, y, transparent outside the image.
func at(x: int, y: int) -> Color:
return image.get_pixel(x, y) if has(x, y) else CLEAR
func filled(x: int, y: int) -> bool:
return at(x, y).a > 0.0
## Lays the translucent `color` over the pixel at x, y.
func shade(x: int, y: int, color: Color) -> void:
if has(x, y):
image.set_pixel(x, y, image.get_pixel(x, y).blend(color))
func rect(x: int, y: int, width: int, height: int, color: Color) -> void:
for row in range(y, y + height):
for column in range(x, x + width):
put(column, row, color)
## Fills the ellipse around cx, cy with the half axes rx and ry.
func ellipse(cx: float, cy: float, rx: float, ry: float, color: Color) -> void:
for row in range(floori(cy - ry), ceili(cy + ry) + 1):
for column in range(floori(cx - rx), ceili(cx + rx) + 1):
var dx := (column + 0.5 - cx) / rx
var dy := (row + 0.5 - cy) / ry
if dx * dx + dy * dy <= 1.0:
put(column, row, color)
## A line of single pixels from x0, y0 to x1, y1.
func line(x0: int, y0: int, x1: int, y1: int, color: Color) -> void:
var dx := absi(x1 - x0)
var dy := -absi(y1 - y0)
var sx := 1 if x0 < x1 else -1
var sy := 1 if y0 < y1 else -1
var error := dx + dy
var x := x0
var y := y0
while true:
put(x, y, color)
if x == x1 and y == y1:
return
var twice := 2 * error
if twice >= dy:
error += dy
x += sx
if twice <= dx:
error += dx
y += sy
## Surrounds everything solid drawn so far with a line one pixel wide: in
## `color`, or, with `tinted`, in a darker shade of the pixel beside it.
func outline(color: Color, tinted: bool) -> void:
var found: Array[Vector3i] = []
var colors: Array[Color] = []
for y in size.y:
for x in size.x:
if filled(x, y):
continue
for side in SIDES:
var beside := at(x + side.x, y + side.y)
if beside.a >= 1.0:
found.append(Vector3i(x, y, 0))
colors.append(beside.darkened(0.5) if tinted else color)
break
for index in found.size():
put(found[index].x, found[index].y, colors[index])
func player_color(player: int) -> Color:
return players[maxi(0, player) % players.size()]
func _c(name: String) -> Color:
return palette[name]
## The dark line around things, or around a thing of colour `fill` when
## outlines are tinted.
func _ink(fill: Color) -> Color:
return fill.darkened(0.5) if tinted_outlines else _c("outline")
## A number from 0 to 1 that looks random and is the same for the same
## arguments.
static func _noise(x: int, y: int, salt: int) -> float:
return float(TileShape.variant(Vector2i(x * 31 + salt * 977, y * 17 - salt * 131)) % 1000) / 1000.0
## The texture made by `make` under `key`, made only the first time.
func _image(key: String, make: Callable) -> Texture2D:
var texture: Texture2D = _textures.get(key)
if texture == null:
var pixels: Pixels = make.call()
texture = ImageTexture.create_from_image(pixels.image)
_textures[key] = texture
return texture
## Draws `texture` with its top left corner at `at`, on whole pixels.
static func _put(canvas: CanvasItem, texture: Texture2D, at: Vector2, modulate := Color.WHITE) -> void:
canvas.draw_texture(texture, at.round(), modulate)
## The neighbours of `mask` that change how a tile looks: the four sides,
## and a corner only where both sides beside it are set.
static func _shape(mask: int) -> int:
var result := mask & (TileShape.N | TileShape.E | TileShape.S | TileShape.W)
var corners := [
[TileShape.NE, TileShape.N | TileShape.E], [TileShape.SE, TileShape.S | TileShape.E],
[TileShape.SW, TileShape.S | TileShape.W], [TileShape.NW, TileShape.N | TileShape.W],
]
for corner: Array in corners:
var sides: int = corner[1]
if (mask & corner[0]) != 0 and (mask & sides) == sides:
result |= corner[0]
return result
# Terrain.
func _ground(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
var variant: int = state.get("variant", 0) % 8
_put(canvas, _image("ground/%d/%d" % [shadow, variant], func() -> Pixels: return _earth(variant, shadow)), rect.position)
## The bare earth of the dead world: up to two soft patches of darker earth,
## a light grain or two, now and then a pebble.
func _earth(variant: int, shadow: int) -> Pixels:
var pixels := Pixels.new(TILE, TILE)
pixels.rect(0, 0, TILE, TILE, _c("ground"))
var patches: int = [0, 1, 2, 1, 0, 1, 1, 2][variant]
for patch in patches:
var at := _spot(patch, variant, 2, 11)
for dy in 2:
for dx in range(-1, 2):
if _noise(at.x + dx, at.y + dy, variant + 40) < 0.6:
pixels.put(at.x + dx, at.y + dy, _c("ground_dark"))
for grain in 1 + variant % 2:
var at := _spot(grain + 4, variant, 1, 14)
pixels.put(at.x, at.y, _c("ground_light"))
if variant % 4 == 1:
var at := _spot(9, variant, 3, 9)
_pebble(pixels, at.x, at.y)
_cast_shadow(pixels, shadow)
return pixels
## A point in the tile that is the same for the same `index` and `variant`,
## from `low` to `low + span - 1` on both axes.
static func _spot(index: int, variant: int, low: int, span: int) -> Vector2i:
return Vector2i(low + int(_noise(index, 1, variant) * span), low + int(_noise(index, 2, variant) * span))
func _pebble(pixels: Pixels, x: int, y: int) -> void:
pixels.put(x, y, _c("pebble_light"))
pixels.put(x + 1, y, _c("pebble"))
pixels.put(x, y + 1, _c("pebble"))
pixels.put(x + 1, y + 1, _c("pebble_dark"))
## Darkens the ground where rock to the north or west casts its shadow; the
## light falls from the top left.
func _cast_shadow(pixels: Pixels, shadow: int) -> void:
var deep := Color(0, 0, 0, 0.32)
var faint := Color(0, 0, 0, 0.16)
if (shadow & TileShape.N) != 0:
for x in TILE:
pixels.shade(x, 0, deep)
pixels.shade(x, 1, deep if x % 2 == 0 else faint)
pixels.shade(x, 2, faint if x % 2 == 1 else CLEAR)
if (shadow & TileShape.W) != 0:
for y in range(1 if (shadow & TileShape.N) != 0 else 0, TILE):
pixels.shade(0, y, faint)
elif (shadow & (TileShape.N | TileShape.NW)) == TileShape.NW:
pixels.shade(0, 0, deep)
func _soft_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
_rock(canvas, rect, state, "soft")
func _hard_rock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
_rock(canvas, rect, state, "hard")
func _bedrock(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
_rock(canvas, rect, state, "bed")
## Rock of `kind`: its top, and its face where the ground below is open.
## The ground shows where the outer corners are round.
func _rock(canvas: CanvasItem, rect: Rect2, state: Dictionary, kind: String) -> void:
var mask := _shape(state.get("solid", 0))
var variant: int = state.get("variant", 0) % 4
_ground(canvas, rect, {"variant": state.get("variant", 0)})
var key := "%s/%d/%d" % [kind, mask, variant]
_put(canvas, _image(key, func() -> Pixels: return _rock_image(kind, mask, variant)), rect.position)
func _rock_image(kind: String, mask: int, variant: int) -> Pixels:
var open_n := (mask & TileShape.N) == 0
var open_e := (mask & TileShape.E) == 0
var open_s := (mask & TileShape.S) == 0
var open_w := (mask & TileShape.W) == 0
var top_rows := TILE - FACE if open_s else TILE
var ink := _c("outline")
var pixels := Pixels.new(TILE, TILE)
for y in TILE:
for x in TILE:
var color := _c(kind + "_top")
if y >= top_rows:
color = _c(kind + "_face")
if y == top_rows or ((x + variant) % 4 == 0 and y < TILE - 2):
color = _c(kind + "_face_dark")
else:
if (open_n and y == 1) or (open_w and x == 1):
color = _c(kind + "_top_light")
elif open_e and x == TILE - 2:
color = _c(kind + "_top_dark")
if open_s and y == top_rows - 1:
color = _c(kind + "_lip")
pixels.put(x, y, color)
_rock_marks(pixels, kind, variant, top_rows)
if open_n:
pixels.rect(0, 0, TILE, 1, ink)
if open_w:
pixels.rect(0, 0, 1, TILE, ink)
if open_e:
pixels.rect(TILE - 1, 0, 1, TILE, ink)
if open_s:
pixels.rect(0, TILE - 1, TILE, 1, ink)
# Inner corners, where the mass turns around open ground.
if not open_n and not open_w and (mask & TileShape.NW) == 0:
pixels.put(0, 0, ink)
if not open_n and not open_e and (mask & TileShape.NE) == 0:
pixels.put(TILE - 1, 0, ink)
if not open_s and not open_w and (mask & TileShape.SW) == 0:
pixels.rect(0, TILE - FACE, 1, FACE, ink)
if not open_s and not open_e and (mask & TileShape.SE) == 0:
pixels.rect(TILE - 1, TILE - FACE, 1, FACE, ink)
# Round outer corners.
if open_n and open_w:
_round(pixels, Vector2i(0, 0), Vector2i(1, 1), ink)
if open_n and open_e:
_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), ink)
if open_s and open_w:
_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), ink)
if open_s and open_e:
_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), ink)
return pixels
## Cuts the corner at `corner` and closes the outline one pixel further in,
## in the direction `inward`.
static func _round(pixels: Pixels, corner: Vector2i, inward: Vector2i, ink: Color) -> void:
pixels.put(corner.x, corner.y, CLEAR)
pixels.put(corner.x + inward.x, corner.y, CLEAR)
pixels.put(corner.x, corner.y + inward.y, CLEAR)
pixels.put(corner.x + inward.x, corner.y + inward.y, ink)
## Small details on the top of rock: pores and chips on soft rock, glinting
## crystals in hard rock, layers in bedrock.
func _rock_marks(pixels: Pixels, kind: String, variant: int, top_rows: int) -> void:
var room := maxi(1, top_rows - 6)
for pore in 2:
var spot := Vector2i(3 + int(_noise(pore, 8, variant) * 10.0), 3 + int(_noise(pore, 9, variant) * room))
pixels.put(spot.x, spot.y, _c(kind + "_top_dark"))
var at := Vector2i(4 + int(_noise(7, 8, variant) * 7.0), 3 + int(_noise(7, 9, variant) * room))
match kind:
"soft":
if variant % 2 == 0:
pixels.put(at.x, at.y, _c("soft_top_dark"))
pixels.put(at.x + 1, at.y, _c("soft_top_dark"))
pixels.put(at.x + 1, at.y - 1, _c("soft_top_light"))
pixels.put(at.x + 2, at.y + 1, _c("soft_top_dark"))
"hard":
if variant % 2 == 1:
pixels.put(at.x, at.y, _c("hard_glint"))
pixels.put(at.x + 1, at.y, _c("hard_top_light"))
pixels.put(at.x, at.y + 1, _c("hard_top_light"))
"bed":
for row in range(4, top_rows - 1, 5):
for column in range(3, TILE - 3):
if _noise(column, row, variant) < 0.5:
pixels.put(column, row, _c("bed_top_dark"))
func _cracks(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var hits := clampi(state.get("hits", 1), 1, 3)
var variant: int = state.get("variant", 0) % 4
_put(canvas, _image("cracks/%d/%d" % [hits, variant], func() -> Pixels: return _crack_image(hits, variant)), rect.position)
func _crack_image(hits: int, variant: int) -> Pixels:
var pixels := Pixels.new(TILE, TILE)
var path: Array[Vector2i] = []
var at := Vector2i(5 + variant, 3)
for step in 2 + hits * 3:
path.append(at)
var turn := _noise(step, hits, variant)
at += Vector2i(-1 if turn < 0.3 else (1 if turn > 0.65 else 0), 1)
if hits >= 2:
var branch: Vector2i = path[path.size() >> 1]
for step in 3:
branch += Vector2i(1, 1 if step % 2 == 0 else 0)
path.append(branch)
for point in path:
pixels.put(point.x + 1, point.y, _c("soft_top_light"))
for point in path:
pixels.put(point.x, point.y, _c("outline"))
return pixels
func _hit(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var frame := clampi(int(float(state.get("age", 0.0)) * HIT_FRAMES), 0, HIT_FRAMES - 1)
var variant: int = state.get("variant", 0) % 4
_put(canvas, _image("hit/%d/%d" % [frame, variant], func() -> Pixels: return _hit_image(frame, variant)), rect.position - Vector2(8, 8))
## Chips that fly up and fall, and a puff of dust, in a square of two tiles
## around the tile.
func _hit_image(frame: int, variant: int) -> Pixels:
var pixels := Pixels.new(TILE * 2, TILE * 2)
var t := (frame + 1.0) / HIT_FRAMES
for chip in 5:
var angle := -PI / 2.0 + (chip - 2) * 0.6 + (variant - 1.5) * 0.15
var distance := 3.0 + t * 10.0
var x := roundi(TILE + cos(angle) * distance)
var y := roundi(TILE - 1 + sin(angle) * distance + t * t * 10.0)
pixels.put(x, y, _c("soft_top_light"))
if frame < 3 and chip % 2 == 0:
pixels.put(x + 1, y, _c("soft_top"))
pixels.put(x, y + 1, _c("soft_face"))
pixels.put(x + 1, y + 1, _c("soft_face_dark"))
if frame < 3:
pixels.outline(_c("outline"), tinted_outlines)
var radius := 3.0 + frame * 2.0
for puff in 8:
var angle := TAU * puff / 8.0 + variant
var dust := _c("dust")
dust.a = 0.75 - frame * 0.2
pixels.put(roundi(TILE + cos(angle) * radius), roundi(TILE - 1 + sin(angle) * radius * 0.6), dust)
return pixels
func _marker(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var reachable: bool = state.get("reachable", false)
_put(canvas, _image("marker/%s" % reachable, func() -> Pixels: return _marker_image(reachable)), rect.position - Vector2(1, 1))
## Four corners around the tile, with a dark rim that shows on any ground.
func _marker_image(reachable: bool) -> Pixels:
var pixels := Pixels.new(TILE + 2, TILE + 2)
var color := _c("marker_near") if reachable else _c("marker_far")
var last := TILE
for corner: Vector2i in [Vector2i(1, 1), Vector2i(last, 1), Vector2i(1, last), Vector2i(last, last)]:
var dx := 1 if corner.x == 1 else -1
var dy := 1 if corner.y == 1 else -1
for i in 4:
pixels.put(corner.x + dx * i, corner.y, color)
pixels.put(corner.x, corner.y + dy * i, color)
pixels.outline(_c("outline"), false)
return pixels
# Figures.
func _figure(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var frame := 0
if state.get("moving", false):
frame = 1 + int(fposmod(float(state.get("step", 0.0)), TAU) / TAU * 4.0) % 4
var colour := maxi(0, state.get("player", -1)) % players.size()
var left: bool = float(state.get("facing", 1.0)) < 0.0
var foot := Vector2(rect.get_center().x, rect.end.y)
_put(canvas, _image("shadow/12", func() -> Pixels: return _shadow_image(12)), foot - Vector2(6, 2))
var key := "figure/%d/%d/%s" % [colour, frame, left]
_put(canvas, _image(key, func() -> Pixels: return _figure_image(colour, frame, left)), foot - Vector2(FIGURE_SIZE.x / 2.0, FIGURE_SIZE.y))
## A soft shadow on the ground, `width` pixels wide.
func _shadow_image(width: int) -> Pixels:
var pixels := Pixels.new(width, 3)
pixels.ellipse(width / 2.0, 1.5, width / 2.0, 1.5, Color(0, 0, 0, 0.28))
return pixels
## A figure in walking frame `frame` (0 standing, 1 to 4 walking), facing
## right or, with `left`, left. The shirt has the colour of the player.
func _figure_image(colour: int, frame: int, left: bool) -> Pixels:
var pixels := Pixels.new(FIGURE_SIZE.x, FIGURE_SIZE.y)
var shirt := players[colour]
# The body sinks a pixel while the legs are apart, so the head always keeps
# a free row above it for its outline.
var bob := 1 if frame == 1 or frame == 3 else 0
# The far leg and the near leg, by frame.
var legs: Array[Vector2i] = [Vector2i(6, 9), Vector2i(5, 10), Vector2i(7, 8), Vector2i(10, 5), Vector2i(8, 7)]
for leg in 2:
var x := legs[frame].x if leg == 0 else legs[frame].y
var lifted := 1 if frame > 0 and frame % 2 == 1 and leg == 0 else 0
pixels.rect(x, 18, 2, 4 - lifted, _c("pants_dark") if leg == 0 else _c("pants"))
pixels.rect(x, 22 - lifted, 3, 2, _c("boots"))
pixels.rect(5, 11 + bob, 6, 8, shirt)
pixels.rect(5, 11 + bob, 1, 8, shirt.darkened(0.2))
pixels.rect(5, 18 + bob, 6, 1, shirt.darkened(0.35))
var swing: int = [0, 1, 0, -1, 0][frame]
pixels.rect(8 + swing, 12 + bob, 2, 5, shirt.darkened(0.15))
pixels.rect(8 + swing, 17 + bob, 2, 1, _c("skin"))
# The head: hair behind, the face in front, bangs above the eye.
pixels.ellipse(7.8, 5.8 + bob, 5.2, 5.0, _c("hair"))
pixels.ellipse(9.6, 7.4 + bob, 3.7, 3.9, _c("skin"))
pixels.rect(9, 3 + bob, 4, 2, _c("hair"))
pixels.put(6, 2 + bob, _c("hair_light"))
pixels.put(7, 2 + bob, _c("hair_light"))
pixels.put(8, 1 + bob, _c("hair_light"))
pixels.put(11, 6 + bob, _c("eye"))
pixels.put(11, 7 + bob, _c("eye"))
pixels.put(8, 8 + bob, _c("skin_shade"))
pixels.put(12, 9 + bob, _c("skin_shade"))
pixels.outline(_ink(shirt), tinted_outlines)
if left:
pixels.image.flip_x()
return pixels
# Things of later milestones, shown in the showcase.
func _dead_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var shadow: int = state.get("solid", 0) & (TileShape.N | TileShape.W | TileShape.NW)
var variant: int = state.get("variant", 0) % 8
var key := "dead/%d/%d" % [shadow, variant]
_put(canvas, _image(key, func() -> Pixels: return _crack_soil(_earth(variant, shadow), variant)), rect.position)
## Dry cracks in every other tile of dead soil. Returns `pixels`.
func _crack_soil(pixels: Pixels, variant: int) -> Pixels:
if variant % 2 == 0:
var at := _spot(12, variant, 3, 7)
for step in 5:
pixels.put(at.x, at.y, _c("dead_crack"))
if step == 2:
pixels.put(at.x, at.y + 1, _c("dead_crack"))
pixels.put(at.x - 1, at.y + 2, _c("dead_crack"))
at += Vector2i(1, -1 if _noise(step, 5, variant) < 0.4 else 0)
return pixels
func _living_soil(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var living := _shape(state.get("living", 0))
var variant: int = state.get("variant", 0) % 6
_ground(canvas, rect, {"variant": state.get("variant", 0)})
_put(canvas, _image("living/%d/%d" % [living, variant], func() -> Pixels: return _grass_image(living, variant)), rect.position)
## Grass with blades and a few flowers, with a soft edge toward the sides
## that are not living.
func _grass_image(living: int, variant: int) -> Pixels:
var open_n := (living & TileShape.N) == 0
var open_e := (living & TileShape.E) == 0
var open_s := (living & TileShape.S) == 0
var open_w := (living & TileShape.W) == 0
var pixels := Pixels.new(TILE, TILE)
pixels.rect(0, 0, TILE, TILE, _c("grass"))
for tuft in 6:
var at := _spot(tuft + 10, variant, 2, 12)
pixels.put(at.x, at.y, _c("grass_dark"))
pixels.put(at.x - 1, at.y - 1, _c("grass_dark"))
pixels.put(at.x + 1, at.y - 1, _c("grass_dark"))
for blade in 4:
var at := _spot(blade + 20, variant, 1, 14)
pixels.put(at.x, at.y, _c("grass_light"))
if variant % 3 == 0:
var x := 4 + variant % 7
var y := 5 + variant % 5
var petal := _c("flower_b") if variant % 2 == 0 else _c("flower_c")
for side in Pixels.SIDES:
pixels.put(x + side.x, y + side.y, petal)
pixels.put(x, y, _c("flower_a"))
if glow > 0.0:
for spark in 2:
var light := _c("spore")
light.a = glow * 0.8
pixels.put(2 + int(_noise(spark, 21, variant) * 12.0), 2 + int(_noise(spark, 22, variant) * 12.0), light)
# A ragged edge toward bare ground.
for i in TILE:
var ragged := 1 if _noise(i, 23, variant) < 0.45 else 0
if open_n:
_edge(pixels, Vector2i(i, 0), Vector2i(0, 1), ragged)
if open_s:
_edge(pixels, Vector2i(i, TILE - 1), Vector2i(0, -1), ragged)
if open_w:
_edge(pixels, Vector2i(0, i), Vector2i(1, 0), ragged)
if open_e:
_edge(pixels, Vector2i(TILE - 1, i), Vector2i(-1, 0), ragged)
if open_n and open_w:
_round(pixels, Vector2i(0, 0), Vector2i(1, 1), _c("grass_edge"))
if open_n and open_e:
_round(pixels, Vector2i(TILE - 1, 0), Vector2i(-1, 1), _c("grass_edge"))
if open_s and open_w:
_round(pixels, Vector2i(0, TILE - 1), Vector2i(1, -1), _c("grass_edge"))
if open_s and open_e:
_round(pixels, Vector2i(TILE - 1, TILE - 1), Vector2i(-1, -1), _c("grass_edge"))
return pixels
## Clears `cut` pixels at `at` and draws the darker edge of the grass behind
## them, one step further in the direction `inward`.
func _edge(pixels: Pixels, at: Vector2i, inward: Vector2i, cut: int) -> void:
for depth in cut:
var gone := at + inward * depth
pixels.put(gone.x, gone.y, CLEAR)
var edge := at + inward * cut
pixels.put(edge.x, edge.y, _c("grass_edge"))
func _moss(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var variant: int = state.get("variant", 0) % 3
var twinkle := int(float(state.get("time", 0.0)) * 2.5) % 4
var foot := Vector2(rect.get_center().x, rect.end.y)
_put(canvas, _image("moss/%d/%d" % [variant, twinkle], func() -> Pixels: return _moss_image(variant, twinkle)), foot - Vector2(10, 13))
## A cushion of moss whose spores twinkle one after another, with room on
## both sides for its outline.
func _moss_image(variant: int, twinkle: int) -> Pixels:
var pixels := Pixels.new(TILE + 2, 13)
var shift := variant - 1
for bump: Vector4 in [Vector4(6.5 + shift, 9.5, 4, 3.2), Vector4(13, 9.2, 4.3, 3.5), Vector4(9.8 - shift, 6.8, 3.8, 3.4)]:
pixels.ellipse(bump.x, bump.y, bump.z, bump.w, _c("moss"))
for y in pixels.size.y:
for x in pixels.size.x:
if not pixels.filled(x, y):
continue
if y >= 11:
pixels.put(x, y, _c("moss_dark"))
elif not pixels.filled(x, y - 1) or not pixels.filled(x, y - 2):
pixels.put(x, y, _c("moss_light"))
pixels.outline(_ink(_c("moss")), tinted_outlines)
var spores: Array[Vector2i] = [Vector2i(6 + shift, 8), Vector2i(10 - shift, 5), Vector2i(14, 8)]
for index in spores.size():
var at := spores[index]
var bright := index == twinkle
var spore := _c("spore") if bright else _c("spore").darkened(0.2)
pixels.put(at.x, at.y, spore)
pixels.put(at.x + 1, at.y, spore)
if bright:
pixels.put(at.x, at.y - 1, _c("spore"))
if glow > 0.0:
var halo := _c("spore")
halo.a = 0.45 * glow
for side in Pixels.SIDES:
pixels.shade(at.x + side.x, at.y + side.y + 1, halo)
return pixels
func _vine(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var points: PackedVector2Array = state.get("points", PackedVector2Array())
if points.size() < 2:
return
var origin := (rect.position - Vector2(6, 6)).floor()
var size := Vector2i(ceili(rect.size.x) + 13, ceili(rect.size.y) + 13)
var key := "vine/%d/%d/%d" % [hash(points), size.x, size.y]
_put(canvas, _image(key, func() -> Pixels: return _vine_image(points, origin, size)), origin)
## A vine two pixels thick along `points`, with leaves on alternate sides.
func _vine_image(points: PackedVector2Array, origin: Vector2, size: Vector2i) -> Pixels:
var pixels := Pixels.new(size.x, size.y)
for i in points.size() - 1:
var from := Vector2i((points[i] - origin).round())
var to := Vector2i((points[i + 1] - origin).round())
pixels.line(from.x, from.y - 1, to.x, to.y - 1, _c("leaf"))
pixels.line(from.x, from.y, to.x, to.y, _c("vine"))
pixels.line(from.x, from.y + 1, to.x, to.y + 1, _c("vine_dark"))
var length := 0.0
for i in points.size() - 1:
length += points[i].distance_to(points[i + 1])
var leaves := int(length / 9.0)
for leaf in leaves:
var at := Vector2i((along(points, (leaf + 0.5) / leaves) - origin).round())
var up := leaf % 2 == 0
var dy := -2 if up else 2
var tip := -1 if up else 1
pixels.put(at.x, at.y + dy, _c("leaf"))
pixels.put(at.x + 1, at.y + dy, _c("leaf"))
pixels.put(at.x + 1, at.y + dy + tip, _c("leaf"))
pixels.put(at.x + 2, at.y + dy + tip, _c("leaf_dark"))
pixels.outline(_ink(_c("vine")), tinted_outlines)
return pixels
func _fiber_plant(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var stage := clampi(int(float(state.get("growth", 1.0)) * GROWTH_STAGES), 0, GROWTH_STAGES - 1)
var lean := clampi(roundi(sway(state.get("time", 0.0), 3) * 1.4), -1, 1)
var foot := Vector2(rect.get_center().x, rect.end.y)
_put(canvas, _image("plant/%d/%d" % [stage, lean], func() -> Pixels: return _plant_image(stage, lean)), foot - Vector2(8, 28))
## A fibre plant at growth stage `stage`, its top leaning by `lean` pixels:
## a seed in a small mound, a sprout, a stalk with leaves, buds and at last
## white tufts of fibre.
func _plant_image(stage: int, lean: int) -> Pixels:
var pixels := Pixels.new(TILE, 28)
pixels.rect(5, 26, 6, 1, _c("ground_dark"))
pixels.rect(4, 27, 8, 1, _c("ground_dark"))
if stage == 0:
pixels.put(7, 25, _c("seed"))
pixels.put(8, 25, _c("seed"))
pixels.outline(_ink(_c("seed")), tinted_outlines)
return pixels
var heights: Array[int] = [0, 4, 8, 13, 17, 19]
var height := heights[stage]
var top := Vector2i(8, 25)
for i in height:
var x := 8 + roundi(lean * 2.0 * i / 19.0)
top = Vector2i(x, 25 - i)
pixels.put(top.x, top.y, _c("stem"))
@warning_ignore("integer_division")
for pair in range(1, height / 3 + 1):
var y := 26 - pair * 3
var x := 8 + roundi(lean * 2.0 * (25 - y) / 19.0)
var side := 1 if pair % 2 == 0 else -1
pixels.put(x + side, y, _c("leaf"))
pixels.put(x + side * 2, y, _c("leaf"))
pixels.put(x + side * 2, y - 1, _c("leaf"))
pixels.put(x + side * 3, y - 1, _c("leaf_dark"))
pixels.put(x - side, y - 1, _c("leaf_dark"))
if stage == 1:
pixels.put(top.x - 1, top.y, _c("leaf"))
pixels.put(top.x + 1, top.y, _c("leaf"))
if stage == 4:
pixels.ellipse(top.x + 0.5, top.y - 0.5, 1.6, 1.6, _c("bud"))
if stage == 5:
for tuft: Vector3 in [Vector3(0, -2, 2.3), Vector3(-3, 1, 1.8), Vector3(3, 2, 1.8)]:
pixels.ellipse(top.x + 0.5 + tuft.x, top.y + tuft.y, tuft.z, tuft.z, _c("fibre"))
pixels.put(top.x + int(tuft.x), top.y + int(tuft.y) + 1, _c("fibre_shade"))
pixels.outline(_ink(_c("stem")), tinted_outlines)
return pixels
func _stone(canvas: CanvasItem, rect: Rect2, _state: Dictionary) -> void:
_put(canvas, _image("stone", func() -> Pixels: return _stone_image()), Vector2(rect.get_center().x - 4, rect.end.y - 8))
func _stone_image() -> Pixels:
var pixels := Pixels.new(8, 8)
pixels.ellipse(4, 4.2, 3.2, 2.7, _c("stone"))
pixels.rect(2, 6, 4, 1, _c("stone_dark"))
pixels.put(2, 3, _c("stone_light"))
pixels.put(3, 2, _c("stone_light"))
pixels.outline(_ink(_c("stone")), tinted_outlines)
return pixels
func _grazer(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var height := roundi(sin(PI * clampf(state.get("hop", 0.0), 0.0, 1.0)) * 5.0)
var left: bool = float(state.get("facing", 1.0)) < 0.0
var foot := Vector2(rect.get_center().x, rect.end.y)
var width := 14 - height
_put(canvas, _image("shadow/%d" % width, func() -> Pixels: return _shadow_image(width)), foot - Vector2(width / 2.0, 2))
var tucked := height > 1
_put(canvas, _image("grazer/%s/%s" % [tucked, left], func() -> Pixels: return _grazer_image(tucked, left)), foot - Vector2(10, 16 + height))
## A woolly animal, facing right or, with `left`, left; in the air its legs
## are `tucked`.
func _grazer_image(tucked: bool, left: bool) -> Pixels:
var pixels := Pixels.new(20, 16)
for leg in [5, 7, 11, 13]:
pixels.rect(leg, 12, 1, 1 if tucked else 3, _c("hoof"))
for puff: Vector4 in [Vector4(9, 8, 6.5, 4.5), Vector4(5.5, 9.5, 4, 3.5), Vector4(12.5, 9.5, 4.5, 3.5), Vector4(8, 5.8, 4, 3), Vector4(11.5, 6.3, 3.5, 3)]:
pixels.ellipse(puff.x, puff.y, puff.z, puff.w, _c("wool"))
for y in pixels.size.y:
for x in pixels.size.x:
if pixels.at(x, y) == _c("wool"):
if y >= 11:
pixels.put(x, y, _c("wool_shade"))
elif not pixels.filled(x, y - 1) and _noise(x, y, 4) < 0.6:
pixels.put(x, y, _c("wool_light"))
pixels.ellipse(16.2, 8.0, 2.6, 3.2, _c("face"))
pixels.put(14, 5, _c("face"))
pixels.put(15, 5, _c("face"))
pixels.put(17, 7, _c("wool_light"))
pixels.outline(_ink(_c("wool")), tinted_outlines)
if left:
pixels.image.flip_x()
return pixels
func _ruin(canvas: CanvasItem, rect: Rect2, state: Dictionary) -> void:
var awake := clampf(state.get("awake", 0.0), 0.0, 1.0)
_put(canvas, _image("ruin", func() -> Pixels: return _ruin_image()), rect.position)
_put(canvas, _image("ruin_glow", func() -> Pixels: return _ruin_glow()), rect.position, Color(1, 1, 1, awake))
## A gate of old stone blocks, three tiles wide, with moss on top and a dark
## doorway.
func _ruin_image() -> Pixels:
var pixels := Pixels.new(TILE * 3, TILE * 3)
var stone := _c("ruin")
pixels.rect(3, 12, 12, 34, stone)
pixels.rect(33, 12, 12, 34, stone)
pixels.rect(1, 6, 46, 9, stone)
pixels.rect(1, 3, 46, 3, _c("ruin_light"))
pixels.rect(15, 15, 18, 31, _c("door"))
pixels.rect(9, 44, 30, 2, _c("ruin_dark"))
pixels.rect(7, 46, 34, 2, stone)
# Blocks: a joint every five rows, and between the blocks of each row.
for course in 8:
var y := 8 + course * 5
for x in range(1, 47):
if pixels.at(x, y) == stone:
pixels.put(x, y, _c("ruin_dark"))
var offset := 0 if course % 2 == 0 else 4
for x in range(3 + offset, 47, 8):
for row in range(y + 1, y + 5):
if pixels.at(x, row) == stone:
pixels.put(x, row, _c("ruin_dark"))
for x in range(1, 47):
if _noise(x, 40, 1) < 0.45:
pixels.put(x, 3, _c("ruin_moss"))
if _noise(x, 41, 1) < 0.25:
pixels.put(x, 4, _c("ruin_moss"))
for strand: Vector2i in [Vector2i(5, 4), Vector2i(12, 3), Vector2i(36, 5), Vector2i(43, 2)]:
pixels.rect(strand.x, 15, 1, strand.y, _c("ruin_moss"))
pixels.outline(_ink(stone), tinted_outlines)
return pixels
## The light in the doorway and the runes above it, shown as the ruin wakes.
func _ruin_glow() -> Pixels:
var pixels := Pixels.new(TILE * 3, TILE * 3)
var center := Vector2(24, 31)
for y in range(15, 46):
for x in range(15, 33):
var distance := Vector2(x + 0.5, y + 0.5).distance_to(center)
var light := _c("glow_light") if distance < 4.0 else _c("glow")
light.a = clampf(1.0 - distance / 13.0, 0.0, 1.0) * (0.8 + 0.2 * glow)
if light.a > 0.05:
pixels.put(x, y, light)
for rune in 3:
var x := 16 + rune * 7
for dot: Vector2i in [Vector2i(0, 0), Vector2i(1, 0), Vector2i(1, 1), Vector2i(0, 2), Vector2i(1, 2)]:
pixels.put(x + dot.x + (rune % 2), 8 + dot.y, _c("glow"))
return pixels
+1
View File
@@ -0,0 +1 @@
uid://jejn3txd0eor
+49
View File
@@ -0,0 +1,49 @@
class_name TileShape
extends RefCounted
## Neighbourhoods on the tile grid, so that a style can draw each tile to fit
## the tiles around it, and a stable number per tile for small variations.
## Bits of a neighbour mask, clockwise from north.
const N := 1
const NE := 2
const E := 4
const SE := 8
const S := 16
const SW := 32
const W := 64
const NW := 128
## Where the neighbour of each bit lies, in the order of the bits.
const OFFSETS: Array[Vector2i] = [
Vector2i(0, -1), Vector2i(1, -1), Vector2i(1, 0), Vector2i(1, 1),
Vector2i(0, 1), Vector2i(-1, 1), Vector2i(-1, 0), Vector2i(-1, -1),
]
## One byte per tile of `codes`: 1 if `members` holds its code, else 0.
static func flags(codes: PackedByteArray, members: Array[int]) -> PackedByteArray:
var result := PackedByteArray()
result.resize(codes.size())
for index in codes.size():
result[index] = 1 if members.has(codes[index]) else 0
return result
## The neighbours of `tile` whose flag is 1, as a mask of the bits above, on
## a map of `width` by `height` tiles. Beyond the edge of the map, every
## neighbour counts as set if `outside_set` is true.
static func mask(tile_flags: PackedByteArray, width: int, height: int, tile: Vector2i, outside_set: bool) -> int:
var result := 0
for bit in 8:
var at := tile + OFFSETS[bit]
var inside := at.x >= 0 and at.y >= 0 and at.x < width and at.y < height
if (inside and tile_flags[at.y * width + at.x] == 1) or (not inside and outside_set):
result |= 1 << bit
return result
## A number from 0 to 2147483647 that is the same for `tile` every time and
## looks random from one tile to the next.
static func variant(tile: Vector2i) -> int:
var h := (tile.x * 374761393 + tile.y * 668265263) & 0x7fffffff
h = ((h ^ (h >> 13)) * 1274126177) & 0x7fffffff
return h ^ (h >> 16)
+1
View File
@@ -0,0 +1 @@
uid://dfj1ix7h3wky2
+49
View File
@@ -0,0 +1,49 @@
class_name WarmStyle
extends PixelStyle
## Warm, full colours and dark outlines around figures and things, close to
## the classic farming games.
const KEY := &"warm"
func _init() -> void:
palette = {
"outline": Color("3a2820"), "dust": Color("e9dcc6"),
"ground": Color("b98a5e"), "ground_dark": Color("a07650"), "ground_light": Color("cba077"),
"pebble": Color("8e8072"), "pebble_light": Color("b8ab9a"), "pebble_dark": Color("6b5f54"),
"dead_crack": Color("8a6646"),
"soft_top": Color("bcb0a0"), "soft_top_dark": Color("a69a8a"), "soft_top_light": Color("d6ccbd"),
"soft_face": Color("8f8170"), "soft_face_dark": Color("776a5b"), "soft_lip": Color("e4dccf"),
"hard_top": Color("7c88a2"), "hard_top_dark": Color("68738c"), "hard_top_light": Color("9aa6c0"),
"hard_face": Color("596380"), "hard_face_dark": Color("454d66"), "hard_lip": Color("b6c2da"),
"hard_glint": Color("dff4ff"),
"bed_top": Color("51453e"), "bed_top_dark": Color("433832"), "bed_top_light": Color("62554c"),
"bed_face": Color("382e29"), "bed_face_dark": Color("2b231f"), "bed_lip": Color("6e6157"),
"grass": Color("64a83f"), "grass_dark": Color("4e8f33"), "grass_light": Color("8acb52"),
"grass_edge": Color("3f7a2c"),
"flower_a": Color("f7e25c"), "flower_b": Color("fbf6ea"), "flower_c": Color("ef7fa6"),
"moss": Color("4e9a3a"), "moss_dark": Color("3b7b2e"), "moss_light": Color("7ec552"), "spore": Color("ffe57e"),
"vine": Color("4f9439"), "vine_dark": Color("38702a"), "leaf": Color("68b545"), "leaf_dark": Color("4c8d35"),
"stem": Color("5c9c3c"), "bud": Color("acd97a"), "fibre": Color("f8f3e6"), "fibre_shade": Color("dcd3c0"),
"seed": Color("7b5433"),
"stone": Color("a29a8f"), "stone_light": Color("c8c1b6"), "stone_dark": Color("7c756d"),
"wool": Color("f5f0e6"), "wool_shade": Color("dbd2c2"), "wool_light": Color("ffffff"),
"face": Color("4b3a31"), "hoof": Color("35281f"),
"ruin": Color("aaa395"), "ruin_dark": Color("8a8376"), "ruin_light": Color("c6bfb2"),
"ruin_moss": Color("5f9f43"), "door": Color("2b231f"), "glow": Color("ffd560"), "glow_light": Color("fff4c6"),
"skin": Color("f4cba5"), "skin_shade": Color("dba67f"), "hair": Color("6e4128"), "hair_light": Color("8f5a37"),
"eye": Color("2a1b15"), "pants": Color("4b5b8d"), "pants_dark": Color("3a4672"), "boots": Color("5b3a26"),
"marker_near": Color("fff6c2"), "marker_far": Color("ff705c"),
}
players = [
Color("d9573f"), Color("3f7fd1"), Color("e9b23c"), Color("4ea84a"),
Color("9a62c9"), Color("39a9a0"), Color("e271a1"), Color("e8873b"),
]
func key() -> StringName:
return KEY
func title_key() -> String:
return "STYLE_WARM"
+1
View File
@@ -0,0 +1 @@
uid://7kb2jlqpvvoe
+36 -3
View File
@@ -1,7 +1,8 @@
class_name Avatar class_name Avatar
extends Node2D extends Node2D
## The local player's figure. It moves outside the lockstep simulation; only ## The local player's figure. It moves outside the lockstep simulation; only
## its pickaxe hits become commands. ## its pickaxe hits become commands. `art` draws it, stepping while it walks
## and facing the way it went last.
## Walking speed: five tiles per second. ## Walking speed: five tiles per second.
const SPEED := 5.0 * GridMotion.TILE_SIZE const SPEED := 5.0 * GridMotion.TILE_SIZE
@@ -9,9 +10,30 @@ const RADIUS := 5.0
## Longest frame that moves the figure in one go. Longer frames are cut, so ## 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. ## that the figure cannot pass through a wall after a stall.
const MAX_STEP_SECONDS := 0.1 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. ## Callable(x: int, y: int) -> bool that says whether a tile can be walked on.
var is_walkable: Callable 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: func _process(delta: float) -> void:
@@ -20,12 +42,23 @@ func _process(delta: float) -> void:
## Moves the figure in `direction` for `delta` seconds, sliding along walls. ## Moves the figure in `direction` for `delta` seconds, sliding along walls.
func walk(direction: Vector2, delta: float) -> void: func walk(direction: Vector2, delta: float) -> void:
var was_moving := moving
moving = false
if direction == Vector2.ZERO or not is_walkable.is_valid(): if direction == Vector2.ZERO or not is_walkable.is_valid():
if was_moving:
queue_redraw()
return return
var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS) var motion := direction * SPEED * minf(delta, MAX_STEP_SECONDS)
var before := position
position = GridMotion.move(position, motion, RADIUS, is_walkable) 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: func _draw() -> void:
draw_circle(Vector2.ZERO, RADIUS + 1.0, Color(0.15, 0.1, 0.05)) if art != null:
draw_circle(Vector2.ZERO, RADIUS, Color(0.95, 0.85, 0.6)) art.draw(self, Art.FIGURE, figure_rect(Vector2.ZERO), {"player": player, "facing": facing, "step": step, "moving": moving})
+92
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://0krucvyguufx
+26 -7
View File
@@ -1,13 +1,16 @@
class_name Hud class_name Hud
extends CanvasLayer extends CanvasLayer
## Text over the game: the stone count, short messages and a help line. ## Text over the game: the stone count, short messages, a status line for
## playing together and a help line.
const MESSAGE_SECONDS := 2.5 const MESSAGE_SECONDS := 2.5
var _stone := Label.new() var _stone := Label.new()
var _message := Label.new() var _message := Label.new()
var _status := Label.new()
var _help := Label.new() var _help := Label.new()
var _message_left := 0.0 var _message_left := 0.0
var _paused := false
func _ready() -> void: func _ready() -> void:
@@ -15,9 +18,12 @@ func _ready() -> void:
_stone.add_theme_font_size_override("font_size", 22) _stone.add_theme_font_size_override("font_size", 22)
_message.position = Vector2(16, 48) _message.position = Vector2(16, 48)
_message.add_theme_font_size_override("font_size", 18) _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.text = tr("HELP")
_help.add_theme_font_size_override("font_size", 14) _help.add_theme_font_size_override("font_size", 14)
for label: Label in [_stone, _message, _help]: for label: Label in [_stone, _message, _status, _help]:
label.add_theme_color_override("font_outline_color", Color.BLACK) label.add_theme_color_override("font_outline_color", Color.BLACK)
label.add_theme_constant_override("outline_size", 4) label.add_theme_constant_override("outline_size", 4)
add_child(label) add_child(label)
@@ -29,25 +35,38 @@ func _process(delta: float) -> void:
return return
_message_left -= delta _message_left -= delta
if _message_left <= 0.0: if _message_left <= 0.0:
_message.text = "" _message.text = tr("PAUSED") if _paused else ""
func show_stone(count: int) -> void: func show_stone(count: int) -> void:
_stone.text = tr("STONE_COUNT") % count _stone.text = tr("STONE_COUNT") % count
## Shows the text for `key` for a few seconds. ## Shows the text for `key` for a few seconds, with `values` filled into
func flash(key: String) -> void: ## its placeholders.
_message.text = tr(key) func flash(key: String, values: Array = []) -> void:
_message.text = tr(key) % values if not values.is_empty() else tr(key)
_message_left = MESSAGE_SECONDS _message_left = MESSAGE_SECONDS
## Shows or hides the pause notice. It stays until the game continues. ## Shows or hides the pause notice. It stays until the game continues and
## returns when a message shown in between ends.
func show_paused(paused: bool) -> void: func show_paused(paused: bool) -> void:
_paused = paused
_message.text = tr("PAUSED") if paused else "" _message.text = tr("PAUSED") if paused else ""
_message_left = 0.0 _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. ## The text currently shown as a message. Tests read it.
func message() -> String: func message() -> String:
return _message.text return _message.text
+5
View File
@@ -13,6 +13,11 @@ static func install() -> void:
_add_keys("save_game", [KEY_F5]) _add_keys("save_game", [KEY_F5])
_add_keys("load_game", [KEY_F9]) _add_keys("load_game", [KEY_F9])
_add_keys("toggle_pause", [KEY_ESCAPE]) _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) _add_mouse_button("dig", MOUSE_BUTTON_LEFT)
+83
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dkgt2ownscxuj
+431 -71
View File
@@ -1,122 +1,441 @@
extends Node2D extends Node2D
## Composition root of a solo session: connects the simulation core with the ## Composition root. Shows the start menu, then runs a session as host or as
## map view, the figure, the HUD and the files on disk. ## 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. ## Seed of a new world until the slice ships its chosen seed.
const NEW_WORLD_SEED := 2026 const NEW_WORLD_SEED := 2026
const PLAYER_NAME := "Spieler"
## How far the pickaxe reaches, in tiles from the figure to the tile centre. ## How far the pickaxe reaches, in tiles from the figure to the tile centre.
const REACH_TILES := 2.5 const REACH_TILES := 2.5
## Ticks between two automatic saves: two minutes. ## Ticks between two automatic saves: two minutes.
const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND const AUTOSAVE_TICKS := 2 * 60 * GwSession.TICKS_PER_SECOND
const CAMERA_ZOOM := Vector2(3.0, 3.0) 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. Tests use their ## Folder for the player key, the save and the recordings. If it is empty,
## own folder. ## the command line decides; tests set a folder of their own.
@export var storage_dir := "user://" @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 session: GwSession var options: LaunchOptions
var store: SaveStore 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 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 map_view: MapView
var marker: TileMarker var marker: TileMarker
var avatar: Avatar var avatar: Avatar
var figures: Figures
var hud: Hud 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 _player_key: PackedByteArray
var _next_dig_tick := 0 var _next_dig_tick := 0
## True while an unusable save could not be set aside. Saving is refused ## True while an unusable save could not be set aside. Saving is refused
## then, so that the unusable save is never overwritten. ## then, so that the unusable save is never overwritten.
var _save_blocked := false 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: func _ready() -> void:
I18n.install() I18n.install()
InputSetup.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) store = SaveStore.new(storage_dir)
_player_key = store.player_key() _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) 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 = MapView.new()
map_view.art = art
add_child(map_view) add_child(map_view)
marker = TileMarker.new() marker = TileMarker.new()
marker.art = art
add_child(marker) add_child(marker)
figures = Figures.new()
figures.art = art
add_child(figures)
avatar = Avatar.new() avatar = Avatar.new()
avatar.art = art
add_child(avatar) add_child(avatar)
var camera := Camera2D.new() var camera := Camera2D.new()
camera.zoom = CAMERA_ZOOM camera.zoom = CAMERA_ZOOM
avatar.add_child(camera) avatar.add_child(camera)
hud = Hud.new() hud = Hud.new()
add_child(hud) add_child(hud)
if not _start_from_save(): menu = Menu.new()
_start_new_world() 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)
func _process(delta: float) -> void: ## Plays the saved world, or a new one, as its host. With `open`, guests may
if session == null or session.is_broken(): ## join. Returns false if it could not start; the menu then says why.
return func play(open: bool) -> bool:
_update_marker() if not _can_start():
if Input.is_action_pressed("dig") and not clock.paused:
_try_dig()
for i in clock.advance(delta):
_run_tick()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("save_game"):
hud.flash("SAVED" if save_game() else "SAVE_FAILED")
elif event.is_action_pressed("load_game"):
_write_recording()
hud.flash("LOADED" if _start_from_save() else "LOAD_FAILED")
elif event.is_action_pressed("toggle_pause"):
clock.paused = not clock.paused
avatar.set_process(not clock.paused)
hud.show_paused(clock.paused)
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST and session != null:
if not save_game():
push_warning("the game could not be saved on close")
_write_recording()
func _start_new_world() -> void:
var fresh := GwSession.new()
fresh.start_new(NEW_WORLD_SEED)
_enter(fresh)
## Continues the saved world. Returns false if there is no usable save. An
## unusable save is set aside, so that the next save does not overwrite it.
func _start_from_save() -> bool:
if not store.has_save():
return false return false
var loaded := GwSession.new() var next := GwSession.new()
if not loaded.start_loaded(store.read_save()): var set_aside := false
var aside := store.set_aside_save() if store.has_save():
if aside.is_empty(): var save := store.read_save()
_save_blocked = true if store.last_read_error != OK:
push_warning("save not usable (%s) and could not be set aside; saving is off" % loaded.last_error()) _show_menu(tr("SAVE_UNREADABLE"))
else: return false
push_warning("save not usable (%s), kept as %s" % [loaded.last_error(), aside]) 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") hud.flash("LOAD_FAILED")
return false
_save_blocked = false
_enter(loaded)
return true return true
## Makes `next` the running session and lets the local player join it. ## Starts joining the host at `address`, "host" or "host:port". The world
func _enter(next: GwSession) -> void: ## arrives later; until then the menu says that it is connecting.
session = next func join(address: String) -> void:
session.join(_player_key, PLAYER_NAME) if not _can_start():
_run_tick() return
_next_dig_tick = 0 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.session = session
map_view.queue_redraw() map_view.reload()
figures.session = session
avatar.player = session.local_player()
figures.clear()
avatar.is_walkable = session.is_walkable avatar.is_walkable = session.is_walkable
avatar.position = GridMotion.tile_center(session.spawn_point()) avatar.position = GridMotion.tile_center(session.spawn_point())
hud.show_stone(session.item_count(GwSession.ITEM_STONE)) 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: func _run_tick() -> void:
@@ -130,14 +449,40 @@ func _apply(events: Array[Dictionary]) -> void:
var local := session.local_player() var local := session.local_player()
for event in events: for event in events:
match event["type"]: match event["type"]:
"tile_hit", "tile_changed": "tile_hit":
map_view.queue_redraw() 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": "item_gained":
if event["player"] == local: if event["player"] == local:
hud.show_stone(session.item_count(GwSession.ITEM_STONE)) hud.show_stone(session.item_count(GwSession.ITEM_STONE))
"inventory_full": "inventory_full":
if event["player"] == local: if event["player"] == local:
hud.flash("INVENTORY_FULL") 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(): if session.is_broken():
hud.flash("SIMULATION_STOPPED") hud.flash("SIMULATION_STOPPED")
push_error(session.last_error()) push_error(session.last_error())
@@ -154,7 +499,9 @@ func _try_dig() -> void:
dig_at(marker.tile) dig_at(marker.tile)
## Queues a pickaxe hit on `tile`, at most once per cooldown. The caller must ## Queues a pickaxe hit on `tile`, at most once per cooldown, and shows its
## burst of chips at once: the hit itself lands with a later tick, which for a
## guest comes back from the host only after the round trip. The caller must
## check the reach: figures move outside the lockstep, so the core does not ## 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 ## know where the figure is. The core checks the cooldown, the tile, its
## terrain, the tool tier and the free space in the inventory. ## terrain, the tool tier and the free space in the inventory.
@@ -163,12 +510,25 @@ func dig_at(tile: Vector2i) -> void:
return return
session.dig(tile.x, tile.y) session.dig(tile.x, tile.y)
_next_dig_tick = session.tick() + GwSession.DIG_COOLDOWN_TICKS _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 ## Writes the running world as the save. Returns false if nothing was
## written, also while an unusable save could not be set aside. ## written: for a guest, whose world belongs to the host, and while an
## unusable save could not be set aside.
func save_game() -> bool: func save_game() -> bool:
if _save_blocked: if _save_blocked or not session.is_host():
return false return false
return store.write_save(session.save()) return store.write_save(session.save())
+167 -33
View File
@@ -1,48 +1,182 @@
class_name MapView class_name MapView
extends Node2D extends Node2D
## Draws the terrain of the shared world. The colours are placeholders until ## Draws the terrain of the shared world in the style of `art`: every tile to
## a style is chosen from the samples of plan M1d. ## fit its neighbours, the cracks of the hits it took, and a short burst of
## chips where a pickaxe lands. The map is drawn in square blocks, so that a
## hit draws only the blocks around its tile again.
const CRACK_COLOR := Color(0.1, 0.08, 0.06, 0.8) ## The terrain that rock masses and the edge of the map are made of. Tiles
## beside it are drawn to fit it.
const SOLID: Array[int] = [GwSession.TERRAIN_SOFT_ROCK, GwSession.TERRAIN_HARD_ROCK, GwSession.TERRAIN_BEDROCK]
## Tiles along each side of a block.
const BLOCK := 16
## Seconds a burst of chips lasts.
const HIT_SECONDS := 0.35
var session: GwSession var session: GwSession
var art: Art
var _width := 0
var _height := 0
var _terrain := PackedByteArray()
var _damage := PackedByteArray()
var _solid := PackedByteArray()
var _masks := PackedInt32Array()
var _variants := PackedInt32Array()
var _blocks: Array[MapBlock] = []
var _bursts: Array[Dictionary] = []
var _burst_layer := Node2D.new()
func _draw() -> void: ## A square part of the map that draws itself.
class MapBlock extends Node2D:
var view: MapView
var tiles: Rect2i
func _init(owner_view: MapView, block_tiles: Rect2i) -> void:
view = owner_view
tiles = block_tiles
func _draw() -> void:
view.draw_block(self, tiles)
func _ready() -> void:
_burst_layer.draw.connect(_draw_bursts)
add_child(_burst_layer)
set_process(false)
## The graphic of a terrain code.
static func id_of(terrain: int) -> StringName:
match terrain:
GwSession.TERRAIN_SOFT_ROCK:
return Art.SOFT_ROCK
GwSession.TERRAIN_HARD_ROCK:
return Art.HARD_ROCK
GwSession.TERRAIN_BEDROCK:
return Art.BEDROCK
return Art.GROUND
## Takes the whole map from `session` anew and draws it, or shows nothing if
## no world runs. Call it when a world starts or was replaced.
func reload() -> void:
for block in _blocks:
remove_child(block)
block.free()
_blocks.clear()
_bursts.clear()
_burst_layer.queue_redraw()
if session == null or not session.is_running(): 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 return
var width := session.map_width()
var height := session.map_height()
var terrain := session.terrain_codes()
var damage := session.damage_codes()
var size := GridMotion.TILE_SIZE var size := GridMotion.TILE_SIZE
for y in height: for y in range(tiles.position.y, tiles.end.y):
for x in width: for x in range(tiles.position.x, tiles.end.x):
var index := y * width + x var index := y * _width + x
var rect := Rect2(x * size, y * size, size, size) var rect := Rect2(x * size, y * size, size, size)
draw_rect(rect, color_of(terrain[index])) var variant := _variants[index]
if damage[index] > 0: art.draw(block, id_of(_terrain[index]), rect, {"solid": _masks[index], "variant": variant})
_draw_cracks(rect, damage[index]) if _damage[index] > 0:
art.draw(block, Art.CRACKS, rect, {"hits": _damage[index], "variant": variant})
## The placeholder colour of a terrain code. func _process(delta: float) -> void:
static func color_of(terrain: int) -> Color: for burst in _bursts:
if terrain == GwSession.TERRAIN_GROUND: burst["age"] += delta / HIT_SECONDS
return Color(0.42, 0.36, 0.28) _bursts = _bursts.filter(func(burst: Dictionary) -> bool: return burst["age"] < 1.0)
if terrain == GwSession.TERRAIN_SOFT_ROCK: if _bursts.is_empty():
return Color(0.56, 0.55, 0.52) set_process(false)
if terrain == GwSession.TERRAIN_HARD_ROCK: _burst_layer.queue_redraw()
return Color(0.33, 0.33, 0.37)
if terrain == GwSession.TERRAIN_BEDROCK:
return Color(0.12, 0.12, 0.14)
return Color.MAGENTA
## One crack line for every hit the tile has taken. func _draw_bursts() -> void:
func _draw_cracks(rect: Rect2, hits: int) -> void: if art == null:
var inset := rect.grow(-3.0) return
for line in hits: var size := GridMotion.TILE_SIZE
var share := (line + 1.0) / (hits + 1.0) for burst in _bursts:
var from := Vector2(inset.position.x, inset.position.y + inset.size.y * share) var tile: Vector2i = burst["tile"]
var to := Vector2(inset.end.x, inset.position.y + inset.size.y * (1.0 - share)) var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
draw_line(from, to, CRACK_COLOR, 1.0) art.draw(_burst_layer, Art.HIT, rect, {"age": burst["age"], "variant": TileShape.variant(tile)})
+69
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dppcwwh67rtu6
+187
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dmyy8qbtuhhdh
+91 -9
View File
@@ -1,18 +1,24 @@
class_name SaveStore class_name SaveStore
extends RefCounted extends RefCounted
## The game's files on the player's machine: the player key, the save and ## The game's files on the player's machine: the player key, the save, the
## the session recordings for bug reports. ## session recordings for bug reports and the player's settings.
const KEY_FILE := "player.key" const KEY_FILE := "player.key"
const SAVE_FILE := "world.grw" const SAVE_FILE := "world.grw"
## Holds the previous save while write_save replaces it. ## Holds the previous save while write_save replaces it.
const BACKUP_FILE := SAVE_FILE + ".bak" const BACKUP_FILE := SAVE_FILE + ".bak"
const RECORDINGS_DIR := "recordings" const RECORDINGS_DIR := "recordings"
const SETTINGS_FILE := "settings.cfg"
## Recordings beyond this number are deleted, oldest first. ## Recordings beyond this number are deleted, oldest first.
const KEEP_RECORDINGS := 10 const KEEP_RECORDINGS := 10
const KEY_BYTES := 16 const KEY_BYTES := 16
var base_dir: String 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 ## Opens the folder and creates it if it is missing. If a crash interrupted a
@@ -25,14 +31,19 @@ func _init(directory: String = "user://") -> void:
## The key that identifies this installation in every world. It is created ## The key that identifies this installation in every world. It is created
## on first use and kept from then on. ## 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: func player_key() -> PackedByteArray:
var path := base_dir.path_join(KEY_FILE) var path := base_dir.path_join(KEY_FILE)
var key := FileAccess.get_file_as_bytes(path) if FileAccess.file_exists(path):
if key.size() != KEY_BYTES: var stored := _read_file(path)
key = Crypto.new().generate_random_bytes(KEY_BYTES) if last_read_error != OK:
if not _write(path, key): return PackedByteArray()
push_error("player key could not be stored at %s" % path) 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 return key
@@ -40,8 +51,10 @@ func has_save() -> bool:
return FileAccess.file_exists(base_dir.path_join(SAVE_FILE)) 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: func read_save() -> PackedByteArray:
return FileAccess.get_file_as_bytes(base_dir.path_join(SAVE_FILE)) return _read_file(base_dir.path_join(SAVE_FILE))
## Writes the save through a temporary file, so that a crash or a failed ## Writes the save through a temporary file, so that a crash or a failed
@@ -115,6 +128,68 @@ func recordings() -> PackedStringArray:
return names 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. ## Moves the backup back to the save if there is no save, see write_save.
func _restore_backup() -> void: func _restore_backup() -> void:
var path := base_dir.path_join(SAVE_FILE) var path := base_dir.path_join(SAVE_FILE)
@@ -125,6 +200,13 @@ func _restore_backup() -> void:
push_warning("the previous save could not be restored from %s" % backup) 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: func _write(path: String, bytes: PackedByteArray) -> bool:
var file := FileAccess.open(path, FileAccess.WRITE) var file := FileAccess.open(path, FileAccess.WRITE)
if file == null: if file == null:
+109
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dpau4ls365tsj
+268
View File
@@ -0,0 +1,268 @@
class_name Showcase
extends CanvasLayer
## The showcase of the style samples: one small scene in the chosen style,
## with the terrain of the test map and things of later milestones. Dead
## soil turns living, moss glows, a vine carries stones into a waking ruin, a
## plant grows and an animal hops. Buttons switch the style.
## The player wants to go back to the menu.
signal closed
## The scene, one letter per tile: B edge of the map, S soft rock, H hard
## rock, . dead soil, L living soil.
const GRID: Array[String] = [
"BBSSSHHH............",
"BSSSHHH.............",
"SS...H..............",
"..........LLLLLL....",
".......LLLLLLLLLLLL.",
".....LLLLLLLLLLLLLL.",
".....LLLLLLLLLLLLLL.",
".......LLLLLLLLLLL..",
".........LLLLLL.....",
"....................",
]
## Pixels of the scene per pixel on screen.
const ZOOM := 3.0
## Seconds from seed to grown plant, and the pause before it starts again.
const GROW_SECONDS := 6.0
const GROWN_SECONDS := 2.0
## Seconds between two hops, and how long a hop lasts.
const HOP_EVERY := 1.2
const HOP_SECONDS := 0.5
## How far stones travel along the vine per second, as a share of its length.
const STONE_SPEED := 0.1
const STONES := 3
## The path of the vine, in tiles: along the living soil into the ruin.
const VINE: Array[Vector2] = [
Vector2(6.8, 6.9), Vector2(9.0, 7.3), Vector2(11.0, 6.9),
Vector2(13.0, 7.1), Vector2(15.0, 6.4), Vector2(17.3, 5.7),
]
## Where things stand, in tiles: the middle of their foot; for the figures
## the middle of the tile they stand on.
const MOSS: Array[Vector2] = [Vector2(8.0, 4.6), Vector2(9.8, 3.3), Vector2(14.2, 3.4), Vector2(16.8, 6.8)]
const PLANT := Vector2(10.9, 4.4)
const GRAZER_HOME := Vector2(12.8, 5.2)
## The ruin covers three by three tiles from here.
const RUIN := Vector2i(16, 3)
## The figures of two players: the own one and one of another colour.
const FIGURES: Array[Vector2] = [Vector2(3.0, 5.0), Vector2(1.6, 6.6)]
var art: Art
## Seconds since the showcase opened.
var time := 0.0
var style_buttons: Array[Button] = []
var back_button := Button.new()
var _view := ShowcaseView.new()
var _title := Label.new()
var _captions: Array[Label] = []
var _grid_codes := PackedByteArray()
var _solid := PackedByteArray()
var _living := PackedByteArray()
## The node that draws the scene.
class ShowcaseView extends Node2D:
var showcase: Showcase
func _draw() -> void:
showcase.draw_scene(self)
func _ready() -> void:
_read_grid()
var background := ColorRect.new()
background.color = Color(0.08, 0.08, 0.09)
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(background)
_view.showcase = self
_view.scale = Vector2(ZOOM, ZOOM)
add_child(_view)
_title.add_theme_font_size_override("font_size", 28)
_title.position = Vector2(32, 20)
add_child(_title)
var row := HBoxContainer.new()
row.position = Vector2(32, 660)
row.add_theme_constant_override("separation", 12)
add_child(row)
for index in art.styles.size():
var button := Button.new()
button.text = "%d %s" % [index + 1, tr(art.styles[index].title_key())]
button.pressed.connect(func() -> void: art.select(art.styles[index].key()))
style_buttons.append(button)
row.add_child(button)
back_button.text = tr("SHOWCASE_BACK")
back_button.pressed.connect(func() -> void: closed.emit())
row.add_child(back_button)
_add_captions()
art.changed.connect(_on_style_changed)
_on_style_changed()
get_viewport().size_changed.connect(_layout)
_layout()
func _process(delta: float) -> void:
time += delta
_view.queue_redraw()
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
for index in mini(3, art.styles.size()):
if event.is_action_pressed("style_%d" % (index + 1)):
art.select(art.styles[index].key())
if event.is_action_pressed("next_style"):
art.select_next()
elif event.is_action_pressed("back"):
closed.emit()
## How far the plant has grown at `seconds`, from 0 (a seed) to 1, over and
## over again.
static func growth_at(seconds: float) -> float:
return clampf(fposmod(seconds, GROW_SECONDS + GROWN_SECONDS) / GROW_SECONDS, 0.0, 1.0)
## How far the animal is through its hop at `seconds`: from 0 when it takes
## off to 1 when it lands, then 1 while it rests.
static func hop_at(seconds: float) -> float:
return clampf(fposmod(seconds, HOP_EVERY) / HOP_SECONDS, 0.0, 1.0)
## Where the animal is at `seconds`, in tiles: it hops around its home.
static func grazer_at(seconds: float) -> Vector2:
var hops := floorf(seconds / HOP_EVERY) + hop_at(seconds)
return GRAZER_HOME + Vector2(cos(hops * 0.9), sin(hops * 0.9) * 0.4)
## How far each stone is along the vine at `seconds`, from 0 to 1.
static func stones_at(seconds: float) -> Array[float]:
var shares: Array[float] = []
for stone in STONES:
shares.append(fposmod(seconds * STONE_SPEED + float(stone) / STONES, 1.0))
return shares
## How awake the ruin is at `seconds`: it wakes and dozes in slow breaths.
static func awake_at(seconds: float) -> float:
return 0.65 + 0.35 * sin(seconds * 1.2)
## The texts under the things in the scene, as keys. Tests read them.
func caption_keys() -> PackedStringArray:
var keys := PackedStringArray()
for label in _captions:
keys.append(label.get_meta("key"))
return keys
## Draws the whole scene onto `canvas`, while it draws.
func draw_scene(canvas: CanvasItem) -> void:
if art == null:
return
var size := GridMotion.TILE_SIZE
var width := GRID[0].length()
var height := GRID.size()
for y in height:
for x in width:
var tile := Vector2i(x, y)
var rect := Rect2(Vector2(tile) * size, Vector2(size, size))
var code := _grid_codes[y * width + x]
var state := {
"solid": TileShape.mask(_solid, width, height, tile, false),
"living": TileShape.mask(_living, width, height, tile, false),
"variant": TileShape.variant(tile),
}
art.draw(canvas, _id_of(code), rect, state)
for at in MOSS:
art.draw(canvas, Art.MOSS, _tile_rect(at), {"variant": TileShape.variant(Vector2i(at)), "time": time})
var path := PackedVector2Array()
for point in VINE:
path.append(point * size)
art.draw(canvas, Art.VINE, _bounds(path), {"points": path, "time": time})
for share in stones_at(time):
var at := ArtStyle.along(path, share)
art.draw(canvas, Art.STONE, Rect2(at - Vector2(4, 5), Vector2(8, 8)))
art.draw(canvas, Art.FIBER_PLANT, _tile_rect(PLANT, Vector2(1.0, 1.8)), {"growth": growth_at(time), "time": time})
var grazer := grazer_at(time)
var heading := grazer_at(time + 0.1) - grazer
art.draw(canvas, Art.GRAZER, _tile_rect(grazer), {"hop": hop_at(time), "facing": 1.0 if heading.x >= 0.0 else -1.0})
var ruin := Rect2(Vector2(RUIN) * size, Vector2(3, 3) * size)
art.draw(canvas, Art.RUIN, ruin, {"awake": awake_at(time), "time": time})
for player in FIGURES.size():
var at := (FIGURES[player] + Vector2(0.5, 0.5)) * size
art.draw(canvas, Art.FIGURE, Avatar.figure_rect(at), {"player": player, "facing": 1.0, "step": time * 4.0 + player, "moving": true})
func _read_grid() -> void:
var codes := {".": 0, "L": 1, "S": 2, "H": 3, "B": 4}
_grid_codes.clear()
for line in GRID:
for letter in line:
_grid_codes.append(codes[letter])
_solid = TileShape.flags(_grid_codes, [2, 3, 4])
_living = TileShape.flags(_grid_codes, [1])
static func _id_of(code: int) -> StringName:
return [Art.DEAD_SOIL, Art.LIVING_SOIL, Art.SOFT_ROCK, Art.HARD_ROCK, Art.BEDROCK][code]
## A square of one tile, or of `size` tiles, whose bottom middle stands at
## `at` (in tiles) plus half a tile.
static func _tile_rect(at: Vector2, size := Vector2.ONE) -> Rect2:
var tile := GridMotion.TILE_SIZE
var foot := (at + Vector2(0.5, 1.0)) * tile
return Rect2(foot - Vector2(size.x * 0.5, size.y) * tile, size * tile)
static func _bounds(points: PackedVector2Array) -> Rect2:
var rect := Rect2(points[0], Vector2.ZERO)
for point in points:
rect = rect.expand(point)
return rect
func _add_captions() -> void:
var entries := [
["SHOWCASE_ROCK", Vector2(4.0, 3.1)],
["SHOWCASE_DEAD_SOIL", Vector2(2.6, 8.8)],
["SHOWCASE_LIVING_SOIL", Vector2(12.5, 9.15)],
["SHOWCASE_MOSS", Vector2(8.4, 5.6)],
["SHOWCASE_PLANT", Vector2(11.4, 2.3)],
["SHOWCASE_GRAZER", Vector2(13.3, 6.3)],
["SHOWCASE_VINE", Vector2(10.6, 7.7)],
["SHOWCASE_RUIN", Vector2(17.5, 1.9)],
["SHOWCASE_FIGURES", Vector2(2.6, 7.7)],
]
for entry: Array in entries:
var label := Label.new()
label.text = tr(entry[0])
label.set_meta("key", entry[0])
label.set_meta("at", entry[1])
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 13)
label.add_theme_color_override("font_outline_color", Color(0.05, 0.05, 0.06))
label.add_theme_constant_override("outline_size", 5)
_captions.append(label)
add_child(label)
## Puts the scene in the middle of the screen and the texts under their
## things.
func _layout() -> void:
var scene_size := Vector2(GRID[0].length(), GRID.size()) * GridMotion.TILE_SIZE * ZOOM
var screen := get_viewport().get_visible_rect().size
_view.position = ((screen - scene_size) / 2.0).floor()
for label in _captions:
var at: Vector2 = label.get_meta("at")
label.size = Vector2(160, 20)
label.position = _view.position + at * GridMotion.TILE_SIZE * ZOOM - Vector2(80, 0)
func _on_style_changed() -> void:
_title.text = tr("SHOWCASE_TITLE") % tr(art.style.title_key())
for index in style_buttons.size():
style_buttons[index].disabled = art.styles[index] == art.style
_view.queue_redraw()
+1
View File
@@ -0,0 +1 @@
uid://bscs3cghniuuq
+7 -8
View File
@@ -1,16 +1,14 @@
class_name TileMarker class_name TileMarker
extends Node2D extends Node2D
## Outlines the tile under the mouse: green when the figure can reach it, ## Marks the tile under the mouse, drawn by `art`: one way when the figure
## red when it is too far away. ## can reach it, another when it is too far away.
const REACHABLE_COLOR := Color(0.4, 1.0, 0.4)
const UNREACHABLE_COLOR := Color(1.0, 0.4, 0.4)
var tile := Vector2i(-1, -1) var tile := Vector2i(-1, -1)
var in_reach := false var in_reach := false
var art: Art
## Moves the outline to `new_tile`. Redraws only when something changed. ## Moves the mark to `new_tile`. Redraws only when something changed.
func show_tile(new_tile: Vector2i, reachable: bool) -> void: func show_tile(new_tile: Vector2i, reachable: bool) -> void:
if new_tile == tile and reachable == in_reach: if new_tile == tile and reachable == in_reach:
return return
@@ -20,6 +18,7 @@ func show_tile(new_tile: Vector2i, reachable: bool) -> void:
func _draw() -> void: func _draw() -> void:
if art == null:
return
var size := GridMotion.TILE_SIZE var size := GridMotion.TILE_SIZE
var color := REACHABLE_COLOR if in_reach else UNREACHABLE_COLOR art.draw(self, Art.MARKER, Rect2(Vector2(tile) * size, Vector2(size, size)), {"reachable": in_reach})
draw_rect(Rect2(Vector2(tile) * size, Vector2(size, size)), color, false, 1.0)
+13
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
extends TestCase
## Draws every graphic of `art` twice, with `state` and with nothing, while
## it draws.
class Probe extends Node2D:
var art: Art
var state: Dictionary
var drawn := 0
func _draw() -> void:
for id in Art.IDS:
var rect := Rect2(0, 0, 48, 48) if id == Art.RUIN else Rect2(16, 16, 16, 16)
art.draw(self, id, rect, state)
art.draw(self, id, rect, {})
drawn += 1
## Everything a graphic can be asked to show, so that each drawing runs
## through all its parts.
static func _full_state() -> Dictionary:
return {
"solid": TileShape.N | TileShape.W | TileShape.NE, "living": TileShape.E | TileShape.S,
"variant": 123456, "hits": 2, "age": 0.5, "player": 1, "facing": -1.0, "step": 1.0,
"moving": true, "reachable": true, "time": 2.5, "growth": 0.9, "hop": 0.5, "awake": 0.7,
"points": PackedVector2Array([Vector2(0, 8), Vector2(10, 4), Vector2(24, 9)]),
}
## Lets `art` draw every graphic once and returns how many were drawn.
func _draw_everything(art: Art) -> int:
var probe := Probe.new()
probe.art = art
probe.state = _full_state()
tree.root.add_child(probe)
await tree.process_frame
await tree.process_frame
var drawn := probe.drawn
tree.root.remove_child(probe)
probe.free()
return drawn
func test_every_style_draws_every_graphic() -> void:
var art := Art.new()
for style in art.styles:
art.select(style.key())
check_eq(await _draw_everything(art), Art.IDS.size(), "%s draws them all" % style.key())
art.style = ArtStyle.new()
check_eq(await _draw_everything(art), Art.IDS.size(), "the placeholders too")
func test_the_styles_are_offered_in_a_fixed_order() -> void:
var art := Art.new()
var keys: Array[StringName] = []
for style in art.styles:
keys.append(style.key())
check_eq(keys, Art.style_keys(), "the keys in the order of the styles")
check_eq(keys, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY], "warm, pastel, glow")
check_eq(art.style, art.styles[0], "the first one is chosen at first")
func test_a_style_is_chosen_by_its_key() -> void:
var art := Art.new()
var changes := [0]
art.changed.connect(func() -> void: changes[0] += 1)
check(art.select(PastelStyle.KEY), "the pastel style exists")
check_eq(art.style.key(), PastelStyle.KEY, "and is chosen")
check(art.select(PastelStyle.KEY), "choosing it again works")
check_eq(changes[0], 1, "only a real change is announced")
check(not art.select(&"watercolour"), "an unknown key is refused")
check_eq(art.style.key(), PastelStyle.KEY, "and changes nothing")
func test_the_next_style_follows_and_the_first_comes_after_the_last() -> void:
var art := Art.new()
var seen: Array[StringName] = []
for i in art.styles.size() + 1:
seen.append(art.style.key())
art.select_next()
check_eq(seen, [WarmStyle.KEY, PastelStyle.KEY, GlowStyle.KEY, WarmStyle.KEY], "round and round")
func test_every_style_has_a_name_in_every_language() -> void:
for translation in I18n.load_translations(I18n.TEXTS_FILE):
for style in Art.new().styles:
var title := String(translation.get_message(style.title_key()))
check(not title.is_empty(), "%s in %s" % [style.title_key(), translation.locale])
func test_a_picture_named_after_an_id_replaces_its_drawing() -> void:
var directory := TestFiles.temporary_dir("art")
DirAccess.make_dir_recursive_absolute(directory.path_join("terrain"))
var image := Image.create(16, 16, false, Image.FORMAT_RGBA8)
image.fill(Color.RED)
image.save_png(directory.path_join("terrain/ground.png"))
var art := Art.new()
art.load_overrides(directory)
check(art.has_override(Art.GROUND), "the ground has a picture")
check(not art.has_override(Art.SOFT_ROCK), "soft rock has none")
check_eq(await _draw_everything(art), Art.IDS.size(), "the picture is drawn like the rest")
art.load_overrides(directory.path_join("missing"))
check(not art.has_override(Art.GROUND), "loading again drops the picture")
TestFiles.remove_dir(directory)
func test_without_the_folder_of_pictures_everything_is_drawn() -> void:
var art := Art.new()
art.load_overrides("res://tests/no-pictures-here")
for id in Art.IDS:
check(not art.has_override(id), "no picture for %s" % id)
+1
View File
@@ -0,0 +1 @@
uid://dmuqgq1uvws3x
+26
View File
@@ -0,0 +1,26 @@
extends TestCase
## The helpers that every style draws with.
func test_a_point_can_be_found_along_a_path() -> void:
var path := PackedVector2Array([Vector2(0, 0), Vector2(10, 0), Vector2(10, 10)])
check_eq(ArtStyle.along(path, 0.0), Vector2(0, 0), "the start")
check_eq(ArtStyle.along(path, 0.5), Vector2(10, 0), "halfway is the bend")
check_eq(ArtStyle.along(path, 0.75), Vector2(10, 5), "three quarters")
check_eq(ArtStyle.along(path, 1.0), Vector2(10, 10), "the end")
check_eq(ArtStyle.along(path, 2.0), Vector2(10, 10), "never beyond the end")
func test_shares_lie_between_zero_and_one_and_stay_the_same() -> void:
for variant in [0, 1, 99, 123456, 2147483647]:
for salt in 4:
var share := ArtStyle.share(variant, salt)
check(share >= 0.0 and share < 1.0, "share of %d and %d: %f" % [variant, salt, share])
check_eq(ArtStyle.share(variant, salt), share, "the same again")
func test_a_sway_swings_gently_and_differs_between_things() -> void:
for tenth in 100:
var swing := ArtStyle.sway(tenth * 0.1, 7)
check(swing >= -1.0 and swing <= 1.0, "between left and right at %.1f s: %f" % [tenth * 0.1, swing])
check(ArtStyle.sway(0.0, 1) != ArtStyle.sway(0.0, 2), "two things do not swing alike")
+1
View File
@@ -0,0 +1 @@
uid://cbkbq72ph6wdn
+23
View File
@@ -31,3 +31,26 @@ func test_without_a_map_the_figure_stays() -> void:
avatar.walk(Vector2.LEFT, 0.1) avatar.walk(Vector2.LEFT, 0.1)
check_eq(avatar.position, Vector2.ZERO, "no walkability callable") check_eq(avatar.position, Vector2.ZERO, "no walkability callable")
avatar.free() avatar.free()
func test_a_walking_figure_steps_and_faces_the_way_it_goes() -> void:
var avatar := Avatar.new()
avatar.is_walkable = _open_field
avatar.position = GridMotion.tile_center(Vector2i(50, 50))
check(not avatar.moving, "standing at first")
avatar.walk(Vector2.LEFT, 0.1)
check(avatar.moving, "walking")
check_eq(avatar.facing, -1.0, "facing left")
check(avatar.step > 0.0, "the step turns")
avatar.walk(Vector2.UP, 0.1)
check_eq(avatar.facing, -1.0, "walking up keeps the way it faces")
avatar.walk(Vector2.ZERO, 0.1)
check(not avatar.moving, "standing again")
avatar.free()
func test_a_figure_stands_upright_in_its_tile() -> void:
var rect := Avatar.figure_rect(Vector2(100, 100))
check_eq(rect.size, Vector2(GridMotion.TILE_SIZE, GridMotion.TILE_SIZE * 1.5), "a tile wide, one and a half tall")
check_eq(rect.get_center().x, 100.0, "centred on the figure")
check_eq(rect.end.y, 100.0 + Avatar.RADIUS + 1.0, "its feet just below its body")
+44
View File
@@ -0,0 +1,44 @@
extends TestCase
func test_a_figure_appears_where_it_is_first_placed() -> void:
var figures := Figures.new()
check_eq(figures.shown_at(2), Vector2.INF, "nobody is shown at first")
figures.place(2, Vector2(10.0, 20.0))
check_eq(figures.shown_at(2), Vector2(10.0, 20.0), "at once at its first position")
figures.free()
func test_a_figure_glides_toward_its_latest_position() -> void:
var figures := Figures.new()
figures.place(1, Vector2.ZERO)
figures.place(1, Vector2(100.0, 0.0))
figures._process(0.02)
var halfway := figures.shown_at(1).x
check(halfway > 0.0 and halfway < 100.0, "part of the way after a frame: %s" % halfway)
for frame in 60:
figures._process(0.02)
check(figures.shown_at(1).distance_to(Vector2(100.0, 0.0)) < 0.01, "there after a second")
figures.free()
func test_figures_can_be_forgotten() -> void:
var figures := Figures.new()
figures.place(1, Vector2.ZERO)
figures.place(3, Vector2.ONE)
figures.forget(1)
check_eq(figures.shown_at(1), Vector2.INF, "one is gone")
check_eq(figures.shown_at(3), Vector2.ONE, "the other stays")
figures.clear()
check_eq(figures.shown_at(3), Vector2.INF, "all are gone")
figures.free()
func test_a_figure_faces_the_way_it_glides() -> void:
var figures := Figures.new()
figures.place(1, Vector2(100.0, 0.0))
check_eq(figures.facing_of(1), 1.0, "facing right at first")
figures.place(1, Vector2.ZERO)
figures._process(0.02)
check_eq(figures.facing_of(1), -1.0, "facing left while it glides to the left")
figures.free()
+1
View File
@@ -0,0 +1 @@
uid://x5pqgp2p1hku
+18
View File
@@ -0,0 +1,18 @@
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := GlowStyle.new()
var colours := {}
for player in 4:
colours[style.player_color(player)] = true
check_eq(colours.size(), 4, "four players, four colours")
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
func test_it_has_every_colour_the_warm_style_has() -> void:
var names := GlowStyle.new().palette.keys()
var warm := WarmStyle.new().palette.keys()
names.sort()
warm.sort()
check_eq(names, warm, "the same colours by name")
+1
View File
@@ -0,0 +1 @@
uid://bllgwrsoumvpl
+45
View File
@@ -0,0 +1,45 @@
extends TestCase
func _hud() -> Hud:
I18n.install()
var hud := Hud.new()
tree.root.add_child(hud)
return hud
func _free(hud: Hud) -> void:
tree.root.remove_child(hud)
hud.free()
func test_a_message_can_carry_values() -> void:
var hud := _hud()
hud.flash("PLAYER_JOINED", ["Grace"])
check_eq(hud.message(), tr("PLAYER_JOINED") % "Grace", "the name is filled in")
hud.flash("SAVED")
check_eq(hud.message(), tr("SAVED"), "a message without values")
_free(hud)
func test_the_status_line_stays_until_it_is_replaced() -> void:
var hud := _hud()
hud.show_status(tr("STATUS_HOST") % [24680, 2])
hud.flash("SAVED")
hud._process(10.0)
check_eq(hud.message(), "", "the message is gone after a while")
check_eq(hud.status_text(), tr("STATUS_HOST") % [24680, 2], "the status line is not")
_free(hud)
func test_the_pause_notice_returns_after_a_message() -> void:
var hud := _hud()
hud.show_paused(true)
hud.flash("STYLE_SWITCHED", [tr("STYLE_PASTEL")])
hud._process(Hud.MESSAGE_SECONDS + 0.1)
check_eq(hud.message(), tr("PAUSED"), "still paused, the notice is back")
hud.show_paused(false)
hud.flash("SAVED")
hud._process(Hud.MESSAGE_SECONDS + 0.1)
check_eq(hud.message(), "", "without a pause the message just goes")
_free(hud)
+1
View File
@@ -0,0 +1 @@
uid://8e4vlt0gnipa
+1 -1
View File
@@ -3,7 +3,7 @@ extends TestCase
func test_every_action_of_the_game_exists() -> void: func test_every_action_of_the_game_exists() -> void:
InputSetup.install() InputSetup.install()
for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause"]: for action in ["move_up", "move_down", "move_left", "move_right", "dig", "save_game", "load_game", "toggle_pause", "next_style", "back", "style_1", "style_2", "style_3"]:
check(InputMap.has_action(action), "action %s" % action) check(InputMap.has_action(action), "action %s" % action)
+38
View File
@@ -0,0 +1,38 @@
extends TestCase
func test_without_arguments_the_menu_shows_and_the_default_profile_is_used() -> void:
var options := LaunchOptions.parse(PackedStringArray())
check_eq(options.start, "", "no direct start")
check_eq(options.storage_dir(), "user://", "the user folder itself")
check_eq(options.port, NetLink.DEFAULT_PORT, "the default port")
func test_every_argument_is_read() -> void:
var options := LaunchOptions.parse(PackedStringArray(["--profile=second", "--name=Grace", "--host", "--port=25000"]))
check_eq(options.storage_dir(), "user://profiles/second", "a folder of its own")
check_eq(options.player_name, "Grace", "name")
check_eq(options.start, "host", "opens the world")
check_eq(options.port, 25000, "port")
var joining := LaunchOptions.parse(PackedStringArray(["--join=192.168.1.20:25000"]))
check_eq(joining.start, "join", "joins")
check_eq(joining.address, "192.168.1.20:25000", "address")
check_eq(LaunchOptions.parse(PackedStringArray(["--solo"])).start, "solo", "plays alone")
func test_profile_names_cannot_leave_the_profile_folder() -> void:
check(LaunchOptions.is_valid_profile("Zweiter_2-b"), "letters, digits, - and _")
for name in ["", "../save", "a/b", "a b", "x".repeat(33)]:
check(not LaunchOptions.is_valid_profile(name), "refused: '%s'" % name)
var options := LaunchOptions.parse(PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]))
check_eq(options.storage_dir(), "user://", "a bad profile is ignored")
check_eq(options.port, NetLink.DEFAULT_PORT, "a bad port is ignored")
check_eq(options.ignored, PackedStringArray(["--profile=../oops", "--port=70000", "--fly"]), "everything ignored is listed")
func test_a_style_can_be_chosen() -> void:
check_eq(LaunchOptions.parse(PackedStringArray(["--style=glow"])).style, GlowStyle.KEY, "a known style")
check_eq(LaunchOptions.parse(PackedStringArray()).style, &"", "none without the argument")
var unknown := LaunchOptions.parse(PackedStringArray(["--style=watercolour"]))
check_eq(unknown.style, &"", "an unknown style is not taken")
check_eq(unknown.ignored, PackedStringArray(["--style=watercolour"]), "but listed as ignored")
+1
View File
@@ -0,0 +1 @@
uid://xe7vqgyxi2wm
+412 -20
View File
@@ -1,76 +1,162 @@
extends TestCase extends TestCase
## Starts the real main scene with its own storage folder and runs a few ## Starts the real main scene with its own storage folder. The network tests
## frames. ## start two of them in this process: a host and a guest.
var _started: Array[Node] = []
func _start_main(directory: String) -> Node: func _start_main(directory: String) -> Node:
var main: Node = load("res://main.tscn").instantiate() var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = directory main.storage_dir = directory
tree.root.add_child(main) tree.root.add_child(main)
_started.append(main)
return main return main
func _stop_main(main: Node) -> void: func _stop_main(main: Node) -> void:
_started.erase(main)
tree.root.remove_child(main) tree.root.remove_child(main)
if main.link != null:
main.link.link.close()
main.free() main.free()
## Stops every main scene this test started and removes their folders.
func _clean_up(directories: Array[String]) -> void:
for main in _started.duplicate():
_stop_main(main)
for directory in directories:
TestFiles.remove_dir(directory)
## A main scene with an open world that listens on the loopback interface.
func _start_host(directory: String) -> Node:
var host := _start_main(directory)
host.options.port = 0
host.listen_address = "127.0.0.1"
host.menu.name_field.text = "Host"
check(host.play(true), "the world opens")
return host
## A main scene that joins `host` as "Grace".
func _start_guest(directory: String, host: Node) -> Node:
var guest := _start_main(directory)
guest.menu.name_field.text = "Grace"
guest.join("127.0.0.1:%d" % host.link.link.local_port())
return guest
## Lets frames pass until `condition` holds, at most `frames` of them.
func _wait_until(condition: Callable, frames := 600) -> bool:
for frame in frames:
if condition.call():
return true
await tree.process_frame
return condition.call()
func _joined(guest: Node) -> bool:
return guest.session != null and guest.session.local_player() >= 0
func _press(main: Node, action: String) -> void:
var event := InputEventAction.new()
event.action = action
event.pressed = true
main._unhandled_input(event)
func test_the_menu_comes_first_and_remembers_the_name() -> void:
var directory := TestFiles.temporary_dir("menu")
var main := _start_main(directory)
await tree.process_frame
check(main.menu.visible, "the menu shows")
check(main.session == null, "nothing runs yet")
main.menu.name_field.text = " Ada "
check(main.play(false), "playing alone starts")
check(not main.menu.visible, "the menu is gone")
check_eq(main.hud.status_text(), "", "no status line when playing alone")
check_eq(main.session.player_name(main.session.local_player()), "Ada", "the player has the name")
_stop_main(main)
var later := _start_main(directory)
check_eq(later.menu.name_field.text, "Ada", "the name is remembered")
_clean_up([directory])
func test_the_command_line_can_start_without_the_menu() -> void:
var directory := TestFiles.temporary_dir("command-line")
var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = directory
main.command_line = PackedStringArray(["--solo", "--name=Linus"])
tree.root.add_child(main)
_started.append(main)
check(main.session != null and main.session.is_running(), "a game runs at once")
check(not main.menu.visible, "without the menu")
check_eq(main.session.player_name(main.session.local_player()), "Linus", "with the name from the command line")
_clean_up([directory])
func test_a_new_game_starts_and_the_player_joins() -> void: func test_a_new_game_starts_and_the_player_joins() -> void:
var directory := TestFiles.temporary_dir("main") var directory := TestFiles.temporary_dir("main")
var main := _start_main(directory) var main := _start_main(directory)
main.play(false)
for frame in 5: for frame in 5:
await tree.process_frame await tree.process_frame
var session: GwSession = main.session var session: GwSession = main.session
check(session != null and session.is_running(), "a session runs") check(session != null and session.is_running(), "a session runs")
check(not session.is_broken(), "the simulation is healthy") check(not session.is_broken(), "the simulation is healthy")
check(session.is_host(), "playing alone is hosting without guests")
check_eq(session.local_player(), 0, "the local player has joined") check_eq(session.local_player(), 0, "the local player has joined")
check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn") check_eq(main.avatar.position, GridMotion.tile_center(session.spawn_point()), "the figure stands on the spawn")
check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones") check_eq(main.hud.stone_text(), tr("STONE_COUNT") % 0, "the HUD shows the stones")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_a_saved_game_is_continued_on_the_next_start() -> void: func test_a_saved_game_is_continued_on_the_next_start() -> void:
var directory := TestFiles.temporary_dir("continue") var directory := TestFiles.temporary_dir("continue")
var first := _start_main(directory) var first := _start_main(directory)
first.play(false)
await tree.process_frame await tree.process_frame
var tick_at_save: int = first.session.tick() var tick_at_save: int = first.session.tick()
check(first.save_game(), "the game was saved") check(first.save_game(), "the game was saved")
_stop_main(first) _stop_main(first)
var second := _start_main(directory) var second := _start_main(directory)
second.play(false)
await tree.process_frame await tree.process_frame
check(second.session.tick() > tick_at_save, "the saved world continues") check(second.session.tick() > tick_at_save, "the saved world continues")
check_eq(second.session.local_player(), 0, "the same player rejoins") check_eq(second.session.local_player(), 0, "the same player rejoins")
_stop_main(second) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_closing_the_window_saves_the_game_and_records_the_session() -> void: func test_closing_the_window_saves_the_game_and_records_the_session() -> void:
var directory := TestFiles.temporary_dir("close") var directory := TestFiles.temporary_dir("close")
var main := _start_main(directory) var main := _start_main(directory)
main.play(false)
await tree.process_frame await tree.process_frame
main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST) main.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
var store := SaveStore.new(directory) var store := SaveStore.new(directory)
check(store.has_save(), "the game was saved") check(store.has_save(), "the game was saved")
check_eq(store.recordings().size(), 1, "the session was recorded") check_eq(store.recordings().size(), 1, "the session was recorded")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_the_game_is_saved_automatically_every_two_minutes() -> void: func test_the_game_is_saved_automatically_every_two_minutes() -> void:
var directory := TestFiles.temporary_dir("autosave") var directory := TestFiles.temporary_dir("autosave")
var main := _start_main(directory) var main := _start_main(directory)
main.play(false)
var store := SaveStore.new(directory) var store := SaveStore.new(directory)
check(not store.has_save(), "there is no save at the start") check(not store.has_save(), "there is no save at the start")
# No frames are awaited, so only these ticks run. # No frames are awaited, so only these ticks run. The loop is counted, so
while main.session.tick() < main.AUTOSAVE_TICKS - 1: # that a session that stops ticking fails the test instead of hanging it.
for i in main.AUTOSAVE_TICKS - 1 - main.session.tick():
main._run_tick() main._run_tick()
check(not store.has_save(), "still no save one tick before the interval ends") check(not store.has_save(), "still no save one tick before the interval ends")
main._run_tick() main._run_tick()
check(store.has_save(), "saved when the interval ends") check(store.has_save(), "saved when the interval ends")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void: func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
@@ -78,13 +164,11 @@ func test_an_unusable_save_is_set_aside_and_a_new_world_starts() -> void:
var store := SaveStore.new(directory) var store := SaveStore.new(directory)
store.write_save(PackedByteArray([1, 2, 3])) store.write_save(PackedByteArray([1, 2, 3]))
var main := _start_main(directory) var main := _start_main(directory)
await tree.process_frame check(main.play(false), "a new world starts")
check(main.session.is_running(), "a new world runs") check(main.session.tick() <= 1, "it is new, tick %d" % main.session.tick())
check(main.session.tick() <= 5, "it is new, tick %d" % main.session.tick())
check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside") check(FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the unusable save is kept aside")
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told") check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
_stop_main(main) _clean_up([directory])
TestFiles.remove_dir(directory)
func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void: func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> void:
@@ -94,10 +178,318 @@ func test_an_unusable_save_that_cannot_be_set_aside_is_never_overwritten() -> vo
# A folder under the set-aside name keeps the unusable save from moving. # A folder under the set-aside name keeps the unusable save from moving.
DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken")) DirAccess.make_dir_absolute(directory.path_join(SaveStore.SAVE_FILE + ".broken"))
var main := _start_main(directory) var main := _start_main(directory)
await tree.process_frame check(main.play(false), "a new world starts")
check(main.session.is_running(), "a new world runs")
check(not main.save_game(), "saving is refused") check(not main.save_game(), "saving is refused")
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched") check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "the unusable save is untouched")
check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told") check_eq(main.hud.message(), tr("LOAD_FAILED"), "the player is told")
_clean_up([directory])
func test_a_save_that_cannot_be_read_right_now_is_left_alone() -> void:
var directory := TestFiles.temporary_dir("held")
SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
var main := _start_main(directory)
var held := HeldFileStore.new(directory)
held.unreadable = SaveStore.SAVE_FILE
main.store = held
check(not main.play(false), "nothing starts")
check_eq(main.menu.status(), tr("SAVE_UNREADABLE"), "the menu says why")
check(main.session == null, "no session")
check(not FileAccess.file_exists(directory.path_join(SaveStore.SAVE_FILE + ".broken")), "the save was not set aside")
_clean_up([directory])
func test_without_a_readable_key_the_game_stays_in_the_menu() -> void:
var directory := TestFiles.temporary_dir("no-key")
var main := _start_main(directory)
main._player_key = PackedByteArray()
check(not main.play(false), "playing alone does not start")
main.join("127.0.0.1")
check(main.session == null, "joining does not start either")
check_eq(main.menu.status(), tr("KEY_UNREADABLE"), "the menu says why")
_clean_up([directory])
func test_a_guest_joins_an_open_world_and_both_have_the_same_world() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var port: int = host.link.link.local_port()
check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 1], "the host sees the port")
var guest := _start_guest(guest_dir, host)
check_eq(guest.menu.status(), tr("CONNECTING") % ("127.0.0.1:%d" % host.link.link.local_port()), "the guest waits")
check(await _wait_until(_joined.bind(guest)), "the guest is in the world")
check(not guest.menu.visible, "the guest sees the world")
check_eq(guest.session.local_player(), 1, "the guest is player 1")
check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_JOINED") % "Grace"), "the host is told")
check_eq(host.hud.status_text(), tr("STATUS_HOST") % [port, 2], "and counts two players")
check_eq(guest.hud.status_text(), tr("STATUS_GUEST") % ["127.0.0.1:%d" % port, 2], "the guest sees whose guest it is")
# Stop the host's clock and let the guest catch up.
host.clock.paused = true
check(await _wait_until(func() -> bool: return guest.session.tick() == host.session.tick()), "the guest catches up")
check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
_clean_up([host_dir, guest_dir])
func test_the_figures_of_host_and_guest_appear_on_the_other_side() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var guest := _start_guest(guest_dir, host)
await _wait_until(_joined.bind(guest))
host.avatar.position += Vector2(16.0, 0.0)
var host_figure: Vector2 = host.avatar.position
check(await _wait_until(func() -> bool: return guest.figures.shown_at(0).distance_to(host_figure) < 0.5), "the guest sees the host's figure")
check(await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF), "the host sees the guest's figure")
_clean_up([host_dir, guest_dir])
func test_only_the_host_saves_and_only_alone_one_may_pause_or_load() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var guest := _start_guest(guest_dir, host)
await _wait_until(_joined.bind(guest))
_press(guest, "save_game")
check_eq(guest.hud.message(), tr("HOST_SAVES"), "a guest does not save")
_press(guest, "toggle_pause")
check_eq(guest.hud.message(), tr("PAUSE_ONLY_ALONE"), "a guest cannot pause")
_press(host, "toggle_pause")
check_eq(host.hud.message(), tr("PAUSE_ONLY_ALONE"), "nor can a host with a guest")
_press(host, "load_game")
check_eq(host.hud.message(), tr("LOAD_ONLY_ALONE"), "loading is for playing alone")
guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
var guest_store := SaveStore.new(guest_dir)
check(not guest_store.has_save(), "the guest wrote no save")
check_eq(guest_store.recordings().size(), 1, "but a recording of its session")
_clean_up([host_dir, guest_dir])
func test_an_accepted_guest_ends_a_pause_and_a_bare_connection_does_not() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
_press(host, "toggle_pause")
check(host.clock.paused, "the host pauses while alone")
# A connection that never greets is no guest.
var stranger := NetLink.new()
stranger.join("127.0.0.1", host.link.link.local_port())
var seen := func() -> bool:
stranger.poll(1.0 / 60.0)
return host.link.link._peers.size() == 1
check(await _wait_until(seen), "the host sees a bare connection")
check(host.clock.paused, "which leaves the pause alone")
var guest := _start_guest(guest_dir, host)
check(await _wait_until(_joined.bind(guest)), "a guest joins the paused world")
check(not host.clock.paused, "and ends the pause")
# Should a pause ever meet a guest, the host can still end it.
host._set_paused(true)
_press(host, "toggle_pause")
check(not host.clock.paused, "the host can always end a pause")
stranger.close()
_clean_up([host_dir, guest_dir])
func test_the_host_sees_a_guest_leave() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var guest := _start_guest(guest_dir, host)
await _wait_until(_joined.bind(guest))
await _wait_until(func() -> bool: return host.figures.shown_at(1) != Vector2.INF)
guest.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host is told")
check_eq(host.figures.shown_at(1), Vector2.INF, "the guest's figure is gone")
check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online")
_clean_up([host_dir, guest_dir])
func test_a_guest_goes_back_to_the_menu_when_the_host_closes() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var guest := _start_guest(guest_dir, host)
await _wait_until(_joined.bind(guest))
host.notification(Node.NOTIFICATION_WM_CLOSE_REQUEST)
check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends")
check(guest.menu.visible, "the menu is back")
check_eq(guest.menu.status(), tr("HOST_LEFT"), "and says why")
_clean_up([host_dir, guest_dir])
func test_a_host_that_stops_sharing_lets_its_guests_go() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var guest := _start_guest(guest_dir, host)
await _wait_until(_joined.bind(guest))
# An internal error of the core cannot be caused from here. For a broken
# session, _process calls what this test calls.
host._stop_sharing()
check(host.link == null, "the host no longer listens")
check_eq(host.hud.status_text(), tr("SIMULATION_STOPPED"), "its status line says why")
check(await _wait_until(func() -> bool: return guest.session == null), "the guest's session ends")
check_eq(guest.menu.status(), tr("HOST_LEFT"), "and the guest learns why")
_clean_up([host_dir, guest_dir])
func test_a_guest_that_stops_sharing_leaves() -> void:
var host_dir := TestFiles.temporary_dir("host")
var guest_dir := TestFiles.temporary_dir("guest")
var host := _start_host(host_dir)
var guest := _start_guest(guest_dir, host)
await _wait_until(_joined.bind(guest))
# As above, for a guest whose own core stopped.
guest._stop_sharing()
check(guest.session == null, "the guest's session ends")
check_eq(guest.menu.status(), tr("SIMULATION_STOPPED"), "the menu says why")
check(await _wait_until(func() -> bool: return host.hud.message() == tr("PLAYER_LEFT") % "Grace"), "the host sees the guest go")
_clean_up([host_dir, guest_dir])
func test_a_guest_that_is_turned_away_learns_why() -> void:
var host_dir := TestFiles.temporary_dir("host")
var host := _start_host(host_dir)
# A second window on the same profile has the same player key.
var twin := _start_guest(host_dir, host)
check(await _wait_until(func() -> bool: return twin.session == null), "the twin is turned away")
check_eq(twin.menu.status(), tr("REFUSED_ALREADY_PLAYING"), "the menu says why")
_clean_up([host_dir])
func test_notices_after_the_end_of_a_session_are_ignored() -> void:
var directory := TestFiles.temporary_dir("late")
var main := _start_main(directory)
# In one batch of notices, an earlier one may have ended the session.
main._on_notice({"type": "world_received", "resync": true})
main._on_notice({"type": "protocol_error"})
check(main.session == null, "no session")
check(main.menu.visible, "the menu stays")
_clean_up([directory])
func test_a_mistyped_address_is_reported() -> void:
var directory := TestFiles.temporary_dir("typo")
var main := _start_main(directory)
main.join("host:port")
check(main.session == null, "nothing starts")
check_eq(main.menu.status(), tr("ADDRESS_INVALID") % "host:port", "the menu says why")
_clean_up([directory])
func test_an_own_hit_shows_its_burst_at_once() -> void:
var directory := TestFiles.temporary_dir("burst")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_SOFT_ROCK, "rock beside the clearing")
main.dig_at(rock)
check_eq(main.map_view.burst_count(), 1, "the burst shows before the hit lands in a tick")
_clean_up([directory])
func test_an_own_hit_that_breaks_the_rock_shows_its_burst_only_once() -> void:
var directory := TestFiles.temporary_dir("break")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
for blow in 3:
main.dig_at(rock)
for tick in GwSession.DIG_COOLDOWN_TICKS + 1:
main._run_tick()
check_eq(main.session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three own hits break the rock")
check_eq(main.map_view.burst_count(), 3, "one burst per own hit, none more when the rock breaks")
_clean_up([directory])
func test_own_hits_reported_late_show_no_second_burst() -> void:
var directory := TestFiles.temporary_dir("late")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
main.dig_at(rock)
main._next_dig_tick = 0
main.dig_at(rock)
# As for a guest: the report of the first hit comes after the second hit,
# and the second hit breaks the rock.
var reports: Array[Dictionary] = [
{"type": "tile_hit", "player": main.session.local_player(), "x": rock.x, "y": rock.y},
{"type": "tile_changed", "x": rock.x, "y": rock.y},
]
main._apply(reports)
check_eq(main.map_view.burst_count(), 2, "two own hits, two bursts")
_clean_up([directory])
func test_the_style_samples_open_from_the_menu_and_lead_back_to_it() -> void:
var directory := TestFiles.temporary_dir("showcase")
var main := _start_main(directory)
main.menu.showcase_button.pressed.emit()
check(main.showcase.visible, "the samples show")
check(not main.menu.visible, "instead of the menu")
main.showcase.style_buttons[2].pressed.emit()
check_eq(main.art.style.key(), GlowStyle.KEY, "a button there chooses the style")
await tree.process_frame
main.showcase.back_button.pressed.emit()
check(main.menu.visible, "the menu is back")
check(not main.showcase.visible, "the samples are gone")
_clean_up([directory])
func test_a_world_that_starts_takes_the_place_of_the_style_samples() -> void:
var directory := TestFiles.temporary_dir("samples-world")
var main := _start_main(directory)
main.open_showcase()
check(main.showcase.visible, "the samples are open")
main.play(false)
check(not main.showcase.visible, "the world shows instead of the samples")
check_eq(main.showcase.process_mode, Node.PROCESS_MODE_DISABLED, "and the samples take no input")
check(not main.menu.visible, "the menu stays hidden")
_clean_up([directory])
func test_f2_switches_the_style_and_the_choice_is_kept() -> void:
var directory := TestFiles.temporary_dir("style")
var main := _start_main(directory)
main.play(false)
var first: StringName = main.art.style.key()
_press(main, "next_style")
var second: StringName = main.art.style.key()
check(second != first, "another style")
check_eq(main.hud.message(), tr("STYLE_SWITCHED") % tr(main.art.style.title_key()), "the player is told")
_stop_main(main) _stop_main(main)
TestFiles.remove_dir(directory)
var later := _start_main(directory)
check_eq(later.art.style.key(), second, "the next start draws in it again")
_clean_up([directory])
func test_the_command_line_chooses_the_style() -> void:
var directory := TestFiles.temporary_dir("style-option")
var main: Node = load("res://main.tscn").instantiate()
main.storage_dir = directory
main.command_line = PackedStringArray(["--style=pastel"])
tree.root.add_child(main)
_started.append(main)
check_eq(main.art.style.key(), PastelStyle.KEY, "the style of the command line")
_clean_up([directory])
func test_hits_of_other_players_show_their_bursts() -> void:
var directory := TestFiles.temporary_dir("other-hits")
var main := _start_main(directory)
main.play(false)
var rock: Vector2i = main.session.spawn_point() + Vector2i(7, -2)
var reports: Array[Dictionary] = [
{"type": "tile_hit", "player": main.session.local_player() + 1, "x": rock.x, "y": rock.y},
{"type": "tile_changed", "x": rock.x, "y": rock.y},
]
main._apply(reports)
check_eq(main.map_view.burst_count(), 2, "a hit and a breaking blow of another player")
_clean_up([directory])
+94
View File
@@ -0,0 +1,94 @@
extends TestCase
## The map view over a real world of the core.
var key := PackedByteArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16])
func _session() -> GwSession:
var session := GwSession.new()
session.start_new(7)
session.join(key, "Ada")
session.step()
return session
func _view(session: GwSession) -> MapView:
var view := MapView.new()
view.art = Art.new()
view.session = session
tree.root.add_child(view)
view.reload()
return view
func _free(view: MapView) -> void:
tree.root.remove_child(view)
view.free()
## The first tile of the soft rock beside the spawn clearing.
func _rock(session: GwSession) -> Vector2i:
return session.spawn_point() + Vector2i(7, -2)
func test_the_map_is_drawn_in_blocks_that_cover_it() -> void:
var session := _session()
var view := _view(session)
var across := ceili(session.map_width() / float(MapView.BLOCK))
var down := ceili(session.map_height() / float(MapView.BLOCK))
check_eq(view.block_count(), across * down, "blocks over the whole map")
for style in Art.style_keys():
view.art.select(style)
view.redraw()
await tree.process_frame
_free(view)
func test_each_tile_is_drawn_to_fit_the_rock_around_it() -> void:
var session := _session()
var view := _view(session)
var solid := TileShape.flags(session.terrain_codes(), MapView.SOLID)
for tile: Vector2i in [Vector2i(0, 0), session.spawn_point(), _rock(session)]:
var expected := TileShape.mask(solid, session.map_width(), session.map_height(), tile, true)
check_eq(view.mask_at(tile), expected, "the neighbours of %s" % tile)
_free(view)
func test_a_broken_rock_no_longer_shapes_its_neighbours() -> void:
var session := _session()
var view := _view(session)
var rock := _rock(session)
var west := rock + Vector2i(-1, 0)
check(view.mask_at(west) & TileShape.E != 0, "the rock lies east of its neighbour")
for hit in 3:
session.dig(rock.x, rock.y)
session.step()
for wait in GwSession.DIG_COOLDOWN_TICKS:
session.step()
check_eq(session.terrain_at(rock.x, rock.y), GwSession.TERRAIN_GROUND, "three hits break it")
view.refresh_tile(rock)
check(view.mask_at(west) & TileShape.E == 0, "now there is ground east of the neighbour")
await tree.process_frame
_free(view)
func test_a_burst_of_chips_shows_for_a_moment() -> void:
var view := _view(_session())
view.hit(Vector2i(3, 3))
check_eq(view.burst_count(), 1, "a burst")
view._process(MapView.HIT_SECONDS * 0.5)
check_eq(view.burst_count(), 1, "still in the air")
await tree.process_frame
view._process(MapView.HIT_SECONDS * 0.6)
check_eq(view.burst_count(), 0, "gone after its time")
_free(view)
func test_without_a_world_nothing_is_drawn() -> void:
var view := _view(_session())
view.session = null
view.reload()
check_eq(view.block_count(), 0, "no blocks")
view.refresh_tile(Vector2i(1, 1))
await tree.process_frame
_free(view)
+1
View File
@@ -0,0 +1 @@
uid://bp0vlwfwtlh7h
+47
View File
@@ -0,0 +1,47 @@
extends TestCase
func _menu() -> Menu:
I18n.install()
var menu := Menu.new()
tree.root.add_child(menu)
return menu
func _free(menu: Menu) -> void:
tree.root.remove_child(menu)
menu.free()
func test_the_buttons_say_what_the_player_chose() -> void:
var menu := _menu()
var chosen: Array = []
menu.play_chosen.connect(func(open: bool) -> void: chosen.append(["play", open]))
menu.join_chosen.connect(func(address: String) -> void: chosen.append(["join", address]))
menu.address_field.text = " 192.168.178.20:25000 "
menu.solo_button.pressed.emit()
menu.host_button.pressed.emit()
menu.join_button.pressed.emit()
menu.address_field.text_submitted.emit(menu.address_field.text)
check_eq(chosen, [["play", false], ["play", true], ["join", "192.168.178.20:25000"], ["join", "192.168.178.20:25000"]], "in order")
_free(menu)
func test_the_name_and_the_status_line() -> void:
var menu := _menu()
menu.name_field.text = " Ada "
check_eq(menu.player_name(), "Ada", "without the spaces around it")
menu.show_status("Verbinde …")
check_eq(menu.status(), "Verbinde …", "the status is shown")
check_eq(menu.solo_button.text, tr("MENU_SOLO"), "the labels come from the texts")
_free(menu)
func test_the_way_to_the_style_samples() -> void:
var menu := _menu()
var chosen := [0]
menu.showcase_chosen.connect(func() -> void: chosen[0] += 1)
check_eq(menu.showcase_button.text, tr("MENU_SHOWCASE"), "the button says where it leads")
menu.showcase_button.pressed.emit()
check_eq(chosen[0], 1, "it asks for the samples")
_free(menu)
+1
View File
@@ -0,0 +1 @@
uid://dh6uogkq28wa4
+215
View File
@@ -0,0 +1,215 @@
extends TestCase
## Two NetLinks in one process, connected over the loopback interface.
## Events per link, collected while frames pass.
var _events: Dictionary[NetLink, Array] = {}
## Polls `links` for `frames` frames and collects their events.
func _pump(links: Array[NetLink], frames: int) -> void:
for frame in frames:
for link in links:
if not _events.has(link):
_events[link] = []
_events[link].append_array(link.poll(1.0 / 60.0))
await tree.process_frame
func _types(link: NetLink) -> Array:
return _events.get(link, []).map(func(event: Dictionary) -> String: return event["type"])
func _first(link: NetLink, type: String) -> Dictionary:
for event: Dictionary in _events.get(link, []):
if event["type"] == type:
return event
return {}
## The bytes of every event of `type` that `link` got, each as an Array.
func _received(link: NetLink, type: String) -> Array:
return _events.get(link, []).filter(func(event: Dictionary) -> bool: return event["type"] == type).map(
func(event: Dictionary) -> Array: return Array(event["bytes"])
)
## A host and a guest that have connected.
func _connected_pair() -> Array[NetLink]:
var host := NetLink.new()
check_eq(host.host(0, "127.0.0.1"), OK, "the host listens")
check(host.local_port() > 0, "on a port the system chose")
var guest := NetLink.new()
check_eq(guest.join("127.0.0.1", host.local_port()), OK, "the guest starts connecting")
await _pump([host, guest], 10)
return [host, guest]
func test_a_guest_connects_and_both_sides_hear_of_it() -> void:
var links := await _connected_pair()
var host := links[0]
var guest := links[1]
check(guest.is_connected_to_peers(), "the guest is connected")
check_eq(_first(guest, "connected").get("peer"), GwSession.HOST_PEER, "the guest sees the host as peer 1")
check(_first(host, "connected").get("peer", 0) > 1, "the host sees the guest")
host.close()
guest.close()
func test_messages_arrive_in_order_on_both_channels() -> void:
var links := await _connected_pair()
var host := links[0]
var guest := links[1]
var guest_peer: int = _first(host, "connected")["peer"]
_events.clear()
var big := PackedByteArray()
big.resize(50000)
big.fill(7)
host.send(guest_peer, big)
host.send(guest_peer, PackedByteArray([1]))
guest.send(GwSession.HOST_PEER, PackedByteArray([2, 3]))
guest.send_figure(GwSession.HOST_PEER, PackedByteArray([4]))
await _pump([host, guest], 20)
var received := _events[guest].filter(func(event: Dictionary) -> bool: return event["type"] == "message")
check_eq(received.size(), 2, "two messages for the guest")
if received.size() == 2:
check_eq(received[0]["bytes"].size(), 50000, "the big one first")
check_eq(received[1]["bytes"], PackedByteArray([1]), "then the small one")
check_eq(_first(host, "message").get("bytes"), PackedByteArray([2, 3]), "the host got the guest's message")
check_eq(_first(host, "figure").get("bytes"), PackedByteArray([4]), "and the position on the other channel")
host.close()
guest.close()
func test_closing_a_guest_delivers_what_was_sent_before() -> void:
var links := await _connected_pair()
var host := links[0]
var guest := links[1]
var guest_peer: int = _first(host, "connected")["peer"]
_events.clear()
host.send(guest_peer, PackedByteArray([9, 9]))
host.close_peer(guest_peer)
await _pump([host, guest], 30)
check_eq(_types(guest), ["message", "disconnected"], "the message comes before the end")
check_eq(_types(host), ["disconnected"], "the host sees the guest go")
check(not guest.is_open(), "the guest's connection is closed")
host.close()
func test_a_guest_that_quits_is_seen_leaving() -> void:
var links := await _connected_pair()
var host := links[0]
var guest := links[1]
_events.clear()
guest.close()
await _pump([host, guest], 10)
check_eq(_types(host), ["disconnected"], "the host sees the guest go")
host.close()
## Godot reports an error for every packet to a connection that is gone or
## being closed; the test runner turns each such error into a failure.
func test_nothing_is_sent_to_connections_that_are_gone_or_closing() -> void:
var host := NetLink.new()
check_eq(host.host(0, "127.0.0.1"), OK, "the host listens")
var first := NetLink.new()
var second := NetLink.new()
first.join("127.0.0.1", host.local_port())
second.join("127.0.0.1", host.local_port())
await _pump([host, first, second], 10)
var peers: Array = _events[host].map(func(event: Dictionary) -> int: return event["peer"])
check_eq(peers.size(), 2, "two guests connected")
var leaving: int = peers[0]
var staying: int = peers[1]
# Connections that never existed.
host.send(12345, PackedByteArray([1]))
host.send_figure(12345, PackedByteArray([1]))
host.send_figure(-12345, PackedByteArray([1]))
host.close_peer(12345)
# A connection that is being closed, and everybody while it closes.
host.close_peer(leaving)
for frame in 10:
host.send(leaving, PackedByteArray([2]))
host.send_figure(leaving, PackedByteArray([2]))
host.send_figure(-staying, PackedByteArray([2]))
host.send_figure(0, PackedByteArray([2]))
await _pump([host, first, second], 1)
# The connection is gone now.
host.send(leaving, PackedByteArray([3]))
host.send_figure(-leaving, PackedByteArray([3]))
await _pump([host, first, second], 5)
check(_types(first).has("disconnected"), "the first guest was closed")
check(not _types(second).has("disconnected"), "the second guest stays")
for link in [host, first, second]:
link.close()
## A packet goes to the connection it is addressed to, to every connection
## for peer 0, and to every connection but one for a negative peer.
func test_packets_go_only_where_they_are_addressed() -> void:
var host := NetLink.new()
check_eq(host.host(0, "127.0.0.1"), OK, "the host listens")
var guests: Array[NetLink] = []
var everyone: Array[NetLink] = [host]
for i in 3:
var guest := NetLink.new()
guest.join("127.0.0.1", host.local_port())
guests.append(guest)
everyone.append(guest)
await _pump(everyone, 15)
# Each guest says who it is, so that the host's id for it is known.
for i in 3:
guests[i].send(GwSession.HOST_PEER, PackedByteArray([i]))
await _pump(everyone, 10)
var peer_of: Array[int] = [0, 0, 0]
for event: Dictionary in _events[host]:
if event["type"] == "message":
peer_of[event["bytes"][0]] = event["peer"]
check(not peer_of.has(0), "the host knows all three guests")
_events.clear()
# Repeated, because a position that gets lost is no failure of the link.
for repeat in 5:
host.send(peer_of[0], PackedByteArray([10]))
host.send_figure(peer_of[2], PackedByteArray([20]))
host.send_figure(-peer_of[1], PackedByteArray([30]))
host.send_figure(0, PackedByteArray([40]))
await _pump(everyone, 2)
await _pump(everyone, 10)
check_eq(_received(guests[0], "message").size(), 5, "the first guest got the messages to it")
check_eq(_received(guests[1], "message"), [], "the second guest got none")
check_eq(_received(guests[2], "message"), [], "the third guest got none")
var first := _received(guests[0], "figure")
var second := _received(guests[1], "figure")
var third := _received(guests[2], "figure")
check(not first.has([20]), "the first guest got nothing for the third")
check(first.has([30]) and first.has([40]), "the first guest got 'all but the second' and 'all'")
check(not second.has([20]) and not second.has([30]), "the second guest got neither the one for the third nor 'all but itself'")
check(second.has([40]), "the second guest got 'all'")
check(third.has([20]) and third.has([30]) and third.has([40]), "the third guest got all three kinds")
for link in everyone:
link.close()
func test_a_guest_gives_up_when_nobody_answers() -> void:
# A port that was free a moment ago and has nobody listening now.
var probe := NetLink.new()
probe.host(0, "127.0.0.1")
var port := probe.local_port()
probe.close()
var guest := NetLink.new()
guest.connect_seconds = 0.2
check_eq(guest.join("127.0.0.1", port), OK, "connecting starts")
await _pump([guest], 30)
check_eq(_types(guest), ["failed"], "the guest gives up")
check(not guest.is_open(), "and closes")
func test_addresses_are_split_into_host_and_port() -> void:
check_eq(NetLink.split_address("192.168.1.20"), ["192.168.1.20", NetLink.DEFAULT_PORT], "default port")
check_eq(NetLink.split_address(" example.net:25000 "), ["example.net", 25000], "with port")
for text in ["", ":25000", "host:", "host:0", "host:70000", "host:port", "a:1:2"]:
check_eq(NetLink.split_address(text), [], "refused: '%s'" % text)
+1
View File
@@ -0,0 +1 @@
uid://bmma6gn72s36v
+18
View File
@@ -0,0 +1,18 @@
extends TestCase
func test_four_players_have_four_colours() -> void:
var style := PastelStyle.new()
var colours := {}
for player in 4:
colours[style.player_color(player)] = true
check_eq(colours.size(), 4, "four players, four colours")
check_eq(style.player_color(-1), style.player_color(0), "a player not in the world yet looks like the first")
func test_it_has_every_colour_the_warm_style_has() -> void:
var names := PastelStyle.new().palette.keys()
var warm := WarmStyle.new().palette.keys()
names.sort()
warm.sort()
check_eq(names, warm, "the same colours by name")
+1
View File
@@ -0,0 +1 @@
uid://biq70tu64w7gu
+90
View File
@@ -0,0 +1,90 @@
extends TestCase
## The pixel art of the ground, the rock and the figures, which every pixel
## style draws.
## How many pixels of `image` are not transparent.
static func _opaque(image: Image) -> int:
var count := 0
for y in image.get_height():
for x in image.get_width():
if image.get_pixel(x, y).a > 0.0:
count += 1
return count
func test_the_field_is_drawn_in_sharp_pixels() -> void:
check_eq(ProjectSettings.get_setting("rendering/textures/canvas_textures/default_texture_filter"), 0, "each pixel a square, not blurred")
check(ProjectSettings.get_setting("rendering/2d/snap/snap_2d_transforms_to_pixel"), "on whole pixels of the screen")
func test_ground_fills_its_tile_and_lies_in_the_shadow_of_rock() -> void:
var style := WarmStyle.new()
var open := style._earth(3, 0).image
check_eq(open.get_size(), Vector2i(PixelStyle.TILE, PixelStyle.TILE), "a tile")
check_eq(_opaque(open), PixelStyle.TILE * PixelStyle.TILE, "no gaps")
var shaded := style._earth(3, TileShape.N).image
check(shaded.get_pixel(5, 0).get_luminance() < open.get_pixel(5, 0).get_luminance(), "darker below rock")
check_eq(shaded.get_pixel(5, 8), open.get_pixel(5, 8), "but only along the rock")
func test_a_lone_rock_is_round_and_shows_its_face() -> void:
var style := WarmStyle.new()
var lone := style._rock_image("soft", 0, 0).image
check_eq(lone.get_pixel(0, 0).a, 0.0, "a round corner, the ground shows there")
check_eq(lone.get_pixel(PixelStyle.TILE - 1, PixelStyle.TILE - 1).a, 0.0, "round at the bottom too")
check_eq(lone.get_pixel(8, 0), style.palette["outline"], "outlined at the top")
check_eq(lone.get_pixel(8, PixelStyle.TILE - 1), style.palette["outline"], "and where it meets the ground")
check_eq(lone.get_pixel(5, PixelStyle.TILE - 3), style.palette["soft_face"], "its face toward the open ground")
func test_rock_inside_a_mass_has_neither_edge_nor_face() -> void:
var style := WarmStyle.new()
var inside := style._rock_image("soft", 255, 0).image
check_eq(_opaque(inside), PixelStyle.TILE * PixelStyle.TILE, "no gaps")
for y in PixelStyle.TILE:
for x in PixelStyle.TILE:
var colour := inside.get_pixel(x, y)
check(colour != style.palette["outline"] and colour != style.palette["soft_face"], "inside at %d, %d" % [x, y])
func test_only_the_corners_that_matter_tell_tiles_apart() -> void:
check_eq(PixelStyle._shape(TileShape.NE), 0, "a corner alone does not count")
check_eq(PixelStyle._shape(TileShape.N | TileShape.E | TileShape.NE), TileShape.N | TileShape.E | TileShape.NE, "between two sides it does")
check_eq(PixelStyle._shape(255), 255, "all of them inside a mass")
func test_each_picture_is_made_only_once() -> void:
var style := WarmStyle.new()
var made := [0]
var make := func() -> PixelStyle.Pixels:
made[0] += 1
return PixelStyle.Pixels.new(1, 1)
var first := style._image("test/once", make)
var again := style._image("test/once", make)
check_eq(again, first, "the same picture")
check_eq(made[0], 1, "made once")
func test_a_figure_walks_in_four_steps_and_faces_both_ways() -> void:
var style := WarmStyle.new()
var frames: Array[PackedByteArray] = []
for frame in 5:
var image := style._figure_image(0, frame, false).image
check_eq(image.get_size(), PixelStyle.FIGURE_SIZE, "a tile wide, one and a half tall")
check(not frames.has(image.get_data()), "frame %d differs from the others" % frame)
frames.append(image.get_data())
var left := style._figure_image(0, 1, true).image
left.flip_x()
check_eq(left.get_data(), frames[1], "facing left mirrors facing right")
var other := style._figure_image(1, 0, false).image
check(other.get_data() != frames[0], "another player has another colour")
func test_the_head_keeps_its_outline_in_every_step() -> void:
var style := WarmStyle.new()
for frame in 5:
var image := style._figure_image(0, frame, false).image
for x in image.get_width():
var top := image.get_pixel(x, 0)
check(top.a == 0.0 or top == style.palette["outline"], "frame %d, column %d: %s" % [frame, x, top])
+1
View File
@@ -0,0 +1 @@
uid://dr01kkrpn22sy
+69
View File
@@ -0,0 +1,69 @@
extends TestCase
## The pixel art of the things of later milestones in the showcase.
## The highest row of `image` with a pixel that is not transparent.
static func _top(image: Image) -> int:
for y in image.get_height():
for x in image.get_width():
if image.get_pixel(x, y).a > 0.0:
return y
return image.get_height()
func test_a_plant_grows_taller_from_stage_to_stage() -> void:
var style := WarmStyle.new()
var tops: Array[int] = []
for stage in PixelStyle.GROWTH_STAGES:
tops.append(_top(style._plant_image(stage, 0).image))
for stage in range(1, tops.size()):
check(tops[stage] < tops[stage - 1], "stage %d is taller than stage %d: %s" % [stage, stage - 1, tops])
func test_grass_has_a_ragged_edge_only_toward_bare_ground() -> void:
var style := WarmStyle.new()
var inside := style._grass_image(255, 0).image
var lone := style._grass_image(0, 0).image
var tile := PixelStyle.TILE
var inside_gaps := 0
var lone_gaps := 0
for y in tile:
for x in tile:
inside_gaps += 1 if inside.get_pixel(x, y).a == 0.0 else 0
lone_gaps += 1 if lone.get_pixel(x, y).a == 0.0 else 0
check_eq(inside_gaps, 0, "living all around: no gaps")
check(lone_gaps > 4, "bare ground all around: a ragged, round edge (%d gaps)" % lone_gaps)
check_eq(lone.get_pixel(0, 0).a, 0.0, "round at the corner")
func test_dead_soil_is_the_bare_earth_with_cracks() -> void:
var style := WarmStyle.new()
var earth := style._earth(0, 0).image
var cracked := style._crack_soil(style._earth(0, 0), 0).image
check(cracked.get_data() != earth.get_data(), "cracks in every other tile")
check_eq(style._crack_soil(style._earth(1, 0), 1).image.get_data(), style._earth(1, 0).image.get_data(), "none in the others")
func test_an_animal_in_the_air_tucks_its_legs() -> void:
var style := WarmStyle.new()
var standing := style._grazer_image(false, false).image
var tucked := style._grazer_image(true, false).image
check(standing.get_pixel(5, 14).a > 0.0, "standing on its legs")
check_eq(tucked.get_pixel(5, 14).a, 0.0, "legs tucked in the air")
func test_the_ruin_glows_in_its_doorway() -> void:
var style := WarmStyle.new()
var light := style._ruin_glow().image
check(light.get_pixel(24, 31).a > 0.5, "bright in the middle of the doorway")
check_eq(light.get_pixel(3, 30).a, 0.0, "dark on the walls")
func test_moss_keeps_its_outline_on_both_sides() -> void:
var style := WarmStyle.new()
for variant in 3:
var image := style._moss_image(variant, 0).image
for y in image.get_height():
for x: int in [0, image.get_width() - 1]:
var pixel := image.get_pixel(x, y)
check(pixel.a == 0.0 or pixel == style.palette["outline"], "variant %d at %d, %d: %s" % [variant, x, y, pixel])
+1
View File
@@ -0,0 +1 @@
uid://cbm4wa0gxi4p1
+72
View File
@@ -126,3 +126,75 @@ func test_only_the_newest_recordings_are_kept() -> void:
check(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there") check(FileAccess.file_exists(paths[paths.size() - 1]), "the newest is there")
check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written") check_eq(store.write_recording(PackedByteArray()), "", "an empty recording is not written")
TestFiles.remove_dir(directory) TestFiles.remove_dir(directory)
func test_a_key_that_cannot_be_read_right_now_is_not_replaced() -> void:
var directory := TestFiles.temporary_dir("held-key")
var key := SaveStore.new(directory).player_key()
var store := HeldFileStore.new(directory)
store.unreadable = SaveStore.KEY_FILE
check_eq(store.player_key(), PackedByteArray(), "no key for now")
check_eq(store.last_read_error, ERR_FILE_CANT_READ, "the reason is kept")
check_eq(SaveStore.new(directory).player_key(), key, "the key on disk is the old one")
TestFiles.remove_dir(directory)
func test_a_damaged_key_is_replaced() -> void:
var directory := TestFiles.temporary_dir("damaged-key")
var file := FileAccess.open(directory.path_join(SaveStore.KEY_FILE), FileAccess.WRITE)
file.store_buffer(PackedByteArray([1, 2, 3]))
file.close()
var store := SaveStore.new(directory)
var key := store.player_key()
check_eq(key.size(), SaveStore.KEY_BYTES, "a new key")
check_eq(SaveStore.new(directory).player_key(), key, "which is kept")
TestFiles.remove_dir(directory)
func test_a_save_that_cannot_be_read_right_now_reports_why() -> void:
var directory := TestFiles.temporary_dir("held-save")
SaveStore.new(directory).write_save(PackedByteArray([1, 2, 3]))
var store := HeldFileStore.new(directory)
store.unreadable = SaveStore.SAVE_FILE
check_eq(store.read_save(), PackedByteArray(), "nothing read")
check_eq(store.last_read_error, ERR_FILE_CANT_READ, "the reason")
store.unreadable = ""
check_eq(store.read_save(), PackedByteArray([1, 2, 3]), "readable again later")
check_eq(store.last_read_error, OK, "no error now")
TestFiles.remove_dir(directory)
func test_the_settings_are_kept() -> void:
var directory := TestFiles.temporary_dir("settings")
var store := SaveStore.new(directory)
check_eq(store.player_name(), "", "no name at first")
store.set_player_name("Ada")
store.set_last_address("192.168.1.20")
var later := SaveStore.new(directory)
check_eq(later.player_name(), "Ada", "name")
check_eq(later.last_address(), "192.168.1.20", "address")
TestFiles.remove_dir(directory)
func test_settings_that_cannot_be_read_right_now_are_not_overwritten() -> void:
var directory := TestFiles.temporary_dir("held-settings")
var store := SaveStore.new(directory)
store.set_player_name("Ada")
store.set_last_address("192.168.1.20")
var held := HeldFileStore.new(directory)
held.unreadable = SaveStore.SETTINGS_FILE
check_eq(held.player_name(), "", "nothing read for now")
held.set_player_name("Grace")
var later := SaveStore.new(directory)
check_eq(later.player_name(), "Ada", "the name on disk is the old one")
check_eq(later.last_address(), "192.168.1.20", "the address is kept")
TestFiles.remove_dir(directory)
func test_the_chosen_style_is_kept() -> void:
var directory := TestFiles.temporary_dir("style")
var store := SaveStore.new(directory)
check_eq(store.style(), "", "no style at first")
store.set_style("pastel")
check_eq(SaveStore.new(directory).style(), "pastel", "kept for the next start")
TestFiles.remove_dir(directory)
+117
View File
@@ -138,3 +138,120 @@ func test_the_state_hash_changes_with_every_tick() -> void:
session.step() session.step()
check_eq(first.length(), 16, "sixteen hexadecimal digits") check_eq(first.length(), 16, "sixteen hexadecimal digits")
check(session.state_hash_hex() != first, "the tick is part of the state") check(session.state_hash_hex() != first, "the tick is part of the state")
var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
## Carries every waiting message between `host` and `guest`, which is
## connected to the host as peer 5, until none are left. Returns the notices
## the guest got.
func _carry(host: GwSession, guest: GwSession) -> Array[Dictionary]:
var notices: Array[Dictionary] = []
var moved := true
while moved:
moved = false
for message in guest.take_messages():
check_eq(message["peer"], GwSession.HOST_PEER, "a guest writes to its host")
host.receive(5, message["bytes"])
moved = true
for message in host.take_messages():
if message["peer"] == 5:
notices.append_array(guest.receive(GwSession.HOST_PEER, message["bytes"]))
moved = true
return notices
## A host whose player Ada is in the world, and the guest Grace at peer 5,
## whose join has run.
func _host_and_guest() -> Array[GwSession]:
var host := _joined_session()
host.connect_peer(5)
var guest := GwSession.new()
guest.start_guest(guest_key, "Grace")
_carry(host, guest)
host.step()
_carry(host, guest)
for i in guest.pending_ticks():
guest.step()
return [host, guest]
func test_a_guest_follows_its_host_through_the_bridge() -> void:
var host := _joined_session()
host.connect_peer(5)
var guest := GwSession.new()
check(guest.start_guest(guest_key, "Grace"), "the guest starts")
check(not guest.is_host(), "as a guest")
check(not guest.is_running(), "without a world before the host answers")
check_eq(_carry(host, guest), [{"type": "world_received", "resync": false}], "the host's world arrives")
check_eq(guest.tick(), host.tick(), "at the host's tick")
for tick in 25:
host.step()
_carry(host, guest)
check_eq(guest.pending_ticks(), 25, "the ticks wait until the guest runs them")
for i in guest.pending_ticks():
guest.step()
check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same")
check_eq(guest.local_player(), 1, "the guest is player 1")
check_eq(host.player_of_peer(5), 1, "the host knows whose connection it is")
check_eq(host.guest_count(), 1, "one guest")
check_eq(guest.player_name(0), "Ada", "the guest knows the host's player")
check_eq(guest.online_players(), PackedInt64Array([0, 1]), "two players online")
check_eq(guest.save().size(), 0, "a guest never saves the host's world")
check(guest.recording_bytes().size() > 0, "but it records its session")
func test_the_hit_of_a_guest_becomes_a_message_to_the_host() -> void:
var pair := _host_and_guest()
var host := pair[0]
var guest := pair[1]
var rock := _first_soft_rock(host)
guest.dig(rock.x, rock.y)
_carry(host, guest)
host.step()
_carry(host, guest)
var events: Array[Dictionary] = []
for i in guest.pending_ticks():
events.append_array(guest.step())
check_eq(events.map(func(event: Dictionary) -> String: return event["type"]), ["tile_hit"], "the guest sees its hit")
check_eq(host.damage_codes()[rock.y * host.map_width() + rock.x], 1, "and so does the host")
func test_a_guest_that_missed_a_tick_gets_the_world_again() -> void:
var pair := _host_and_guest()
var host := pair[0]
var guest := pair[1]
host.step()
# The message with this tick gets lost on the way.
host.take_messages()
host.step()
_carry(host, guest)
for i in guest.pending_ticks():
guest.step()
check_eq(_carry(host, guest), [{"type": "world_received", "resync": true}], "the guest gets the host's world again")
check_eq(host.take_divergences().size(), 1, "the host hears of it")
check(guest.take_diverged_recording().size() > 0, "the guest keeps the recording of the world that went astray")
host.step()
_carry(host, guest)
for i in guest.pending_ticks():
guest.step()
check_eq(guest.state_hash_hex(), host.state_hash_hex(), "both worlds are the same again")
func test_a_guest_learns_why_it_is_turned_away() -> void:
var host := _joined_session()
host.connect_peer(5)
var twin := GwSession.new()
twin.start_guest(key, "Ada")
check_eq(_carry(host, twin), [{"type": "refused", "reason": GwSession.REFUSED_ALREADY_PLAYING}], "Ada is in the world already")
check_eq(host.take_closing(), PackedInt64Array([5]), "the host gives up the connection")
func test_an_unreadable_message_from_the_host_is_reported() -> void:
var guest := GwSession.new()
guest.start_guest(guest_key, "Grace")
check_eq(guest.receive(GwSession.HOST_PEER, PackedByteArray([0xEE])), [{"type": "protocol_error"}], "a notice")
check(guest.last_error().contains("unreadable"), "with the reason: %s" % guest.last_error())
check(not guest.start_guest(PackedByteArray([1, 2]), "Grace"), "a short key is refused")
+158
View File
@@ -0,0 +1,158 @@
extends TestCase
## A host and guests in one process, each with its own GwSession and
## NetLink, connected over the loopback interface.
var host_key := PackedByteArray([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
var guest_key := PackedByteArray([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
var host: SessionLink
## Notices per SessionLink, collected while frames pass.
var notices: Dictionary[SessionLink, Array] = {}
func _start_host() -> void:
var session := GwSession.new()
session.start_new(7)
session.join(host_key, "Host")
session.step()
var link := NetLink.new()
check_eq(link.host(0, "127.0.0.1"), OK, "the host listens")
host = SessionLink.new(session, link)
notices[host] = []
func _start_guest(key: PackedByteArray, name: String) -> SessionLink:
var session := GwSession.new()
session.start_guest(key, name)
var link := NetLink.new()
link.join("127.0.0.1", host.link.local_port())
var guest := SessionLink.new(session, link)
notices[guest] = []
return guest
## Lets `frames` frames pass. The host runs a tick per frame if `ticking`;
## every guest runs what arrived.
func _play(guests: Array[SessionLink], frames: int, ticking := true) -> void:
for frame in frames:
notices[host].append_array(host.pump(1.0 / 60.0))
if ticking:
host.session.step()
host.flush()
for guest in guests:
notices[guest].append_array(guest.pump(1.0 / 60.0))
for i in guest.session.pending_ticks():
guest.session.step()
await tree.process_frame
func _types(link: SessionLink) -> Array:
return notices[link].map(func(notice: Dictionary) -> String: return notice["type"])
func _first(link: SessionLink, type: String) -> Dictionary:
for notice: Dictionary in notices[link]:
if notice["type"] == type:
return notice
return {}
func _close(links: Array[SessionLink]) -> void:
for link in links:
link.link.close()
func test_a_guest_joins_over_the_network_and_follows_the_host() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
await _play([guest], 40)
await _play([guest], 10, false)
check(_types(host).has("guest_arrived"), "the host sees the guest arrive")
check_eq(_first(guest, "world_received").get("resync"), false, "the guest got the host's world")
check_eq(guest.session.local_player(), 1, "the guest is player 1")
check_eq(guest.session.player_name(0), "Host", "the guest knows the host's name")
check_eq(guest.session.tick(), host.session.tick(), "the guest is at the host's tick")
check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
check_eq(guest.session.save().size(), 0, "a guest does not save the host's world")
_close([host, guest])
func test_a_hit_by_the_guest_reaches_the_host() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
await _play([guest], 30)
var rock := host.session.spawn_point() + Vector2i(7, -2)
guest.session.dig(rock.x, rock.y)
await _play([guest], 10)
await _play([guest], 10, false)
var index := rock.y * host.session.map_width() + rock.x
check_eq(host.session.damage_codes()[index], 1, "the host's rock took the hit")
check_eq(guest.session.state_hash_hex(), host.session.state_hash_hex(), "both worlds are the same")
_close([host, guest])
func test_figures_travel_to_the_other_machines() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
var other := _start_guest(PackedByteArray([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]), "Linus")
await _play([guest, other], 30)
host.send_figure(Vector2(10.0, 20.0))
guest.send_figure(Vector2(5.5, 6.5))
await _play([guest, other], 10)
check_eq(_first(host, "figure"), {"type": "figure", "player": 1, "position": Vector2(5.5, 6.5)}, "the host sees the guest")
var seen := notices[other].filter(func(notice: Dictionary) -> bool: return notice["type"] == "figure")
check(seen.has({"type": "figure", "player": 0, "position": Vector2(10.0, 20.0)}), "the other guest sees the host")
check(seen.has({"type": "figure", "player": 1, "position": Vector2(5.5, 6.5)}), "and the first guest, passed on by the host")
check(not notices[guest].any(func(notice: Dictionary) -> bool: return notice["type"] == "figure" and notice["player"] == 1), "nobody gets their own figure back")
_close([host, guest, other])
func test_figures_that_cannot_be_shown_are_dropped() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
var other := _start_guest(PackedByteArray([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]), "Linus")
await _play([guest, other], 30)
# A position that is no number, as a broken or changed client may send.
guest.link.send_figure(GwSession.HOST_PEER, guest._position_bytes(Vector2(NAN, 1.0)))
# A position of a player who is not online, as it can arrive late.
host.link.send_figure(0, host._figure_bytes(5, Vector2(1.0, 2.0)))
await _play([guest, other], 10)
check(not _types(host).has("figure"), "the host drops the position that is no number")
check(not _types(other).has("figure"), "passes nothing on, and nobody shows the player who is not online")
check(not _types(guest).has("figure"), "not even the guest that sent it")
_close([host, guest, other])
func test_a_guest_with_the_key_of_a_player_in_the_world_is_turned_away() -> void:
_start_host()
var twin := _start_guest(host_key, "Host")
await _play([twin], 30)
check_eq(_first(twin, "refused").get("reason"), GwSession.REFUSED_ALREADY_PLAYING, "the reason is given")
check(_types(twin).has("host_lost"), "then the host closes the connection")
check(not twin.session.is_running(), "the guest never got a world")
_close([host, twin])
func test_a_guest_that_quits_leaves_the_world() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
await _play([guest], 30)
check_eq(host.session.online_players(), PackedInt64Array([0, 1]), "two players online")
guest.link.close()
await _play([], 10)
check(_types(host).has("guest_left"), "the host sees the guest leave")
check_eq(host.session.online_players(), PackedInt64Array([0]), "only the host is online")
_close([host])
func test_a_guest_learns_when_the_host_is_gone() -> void:
_start_host()
var guest := _start_guest(guest_key, "Grace")
await _play([guest], 30)
host.link.close()
await _play([guest], 10, false)
check(_types(guest).has("host_lost"), "the guest notices")
_close([guest])
+1
View File
@@ -0,0 +1 @@
uid://c6tn270be6rbu
+61
View File
@@ -0,0 +1,61 @@
extends TestCase
## The showcase of the style samples: how its things move, and that it shows
## every style.
func test_the_plant_grows_from_a_seed_and_starts_again() -> void:
check_eq(Showcase.growth_at(0.0), 0.0, "a seed at first")
check(is_equal_approx(Showcase.growth_at(Showcase.GROW_SECONDS / 2.0), 0.5), "half grown halfway")
check_eq(Showcase.growth_at(Showcase.GROW_SECONDS), 1.0, "grown")
check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS - 0.01), 1.0, "it stays grown for a while")
check_eq(Showcase.growth_at(Showcase.GROW_SECONDS + Showcase.GROWN_SECONDS), 0.0, "then a seed again")
func test_the_animal_hops_then_rests_near_its_home() -> void:
check_eq(Showcase.hop_at(0.0), 0.0, "taking off")
check(is_equal_approx(Showcase.hop_at(Showcase.HOP_SECONDS / 2.0), 0.5), "in the air")
check_eq(Showcase.hop_at(Showcase.HOP_SECONDS), 1.0, "landed")
check_eq(Showcase.hop_at(Showcase.HOP_EVERY - 0.01), 1.0, "resting until the next hop")
for tenth in 100:
var at := Showcase.grazer_at(tenth * 0.1)
check(at.distance_to(Showcase.GRAZER_HOME) <= 1.01, "near home at %.1f s: %s" % [tenth * 0.1, at])
func test_stones_travel_along_the_vine_one_after_another() -> void:
var start := Showcase.stones_at(0.0)
check_eq(start.size(), Showcase.STONES, "every stone")
check_eq(start[0], 0.0, "the first one starts")
for stone in start:
check(stone >= 0.0 and stone < 1.0, "on the vine: %f" % stone)
var later := Showcase.stones_at(1.0)
check(is_equal_approx(later[0], Showcase.STONE_SPEED), "it moves on")
var round_trip := Showcase.stones_at(1.0 / Showcase.STONE_SPEED)
check(is_equal_approx(round_trip[0], 0.0) or is_equal_approx(round_trip[0], 1.0), "and comes round again")
func test_the_ruin_wakes_and_dozes() -> void:
for tenth in 100:
var awake := Showcase.awake_at(tenth * 0.1)
check(awake >= 0.29 and awake <= 1.0, "between dozing and awake: %f" % awake)
func test_the_showcase_shows_every_style_and_names_its_things() -> void:
I18n.install()
InputSetup.install()
var art := Art.new()
var showcase := Showcase.new()
showcase.art = art
tree.root.add_child(showcase)
for index in art.styles.size():
showcase.style_buttons[index].pressed.emit()
check_eq(art.style, art.styles[index], "button %d chooses its style" % (index + 1))
check(showcase.style_buttons[index].disabled, "the chosen one cannot be pressed again")
await tree.process_frame
for key in showcase.caption_keys():
check(tr(key) != key, "a text for %s" % key)
var closed := [0]
showcase.closed.connect(func() -> void: closed[0] += 1)
showcase.back_button.pressed.emit()
check_eq(closed[0], 1, "back leads away")
tree.root.remove_child(showcase)
showcase.free()

Some files were not shown because too many files have changed in this diff Show More