From ef2ecc08f92b168221a97f6cfd4b03f49964af24 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 31 Aug 2026 11:07:01 +0200 Subject: [PATCH] update --- .godot/editor/filesystem_cache10 | 41 +- .godot/global_script_class_cache.cfg | 8 + .godot/uid_cache.bin | Bin 500 -> 565 bytes README.md | 114 ++-- anforderungen.md | 14 +- scripts/bewohner.gd | 22 +- scripts/dream_db.gd | 365 +++++++++++-- scripts/dream_world.gd | 101 +++- scripts/earth_world.gd | 174 +++--- scripts/game.gd | 187 +++++-- scripts/human_db.gd | 383 +++++++++++-- scripts/main.gd | 7 +- scripts/netz.gd | 141 +++++ scripts/netz.gd.uid | 1 + scripts/orte.gd | 15 +- scripts/technique_db.gd | 44 +- scripts/ui.gd | 144 ++--- tests/shots.gd | 1 - tests/verify.gd | 770 ++++++++++++++++++++++++--- 19 files changed, 2094 insertions(+), 438 deletions(-) create mode 100644 scripts/netz.gd create mode 100644 scripts/netz.gd.uid diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 732fa46..ce2aa2f 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,24 +1,27 @@ 78098b89c88a74ebe8c20117b8754620 -::res://::1787996624 -anforderungen.md::TextFile::-1::1788038502::0::1::::<><><>0<>0<><>:: -README.md::TextFile::-1::1788038596::0::1::::<><><>0<>0<><>:: +::res://::1788113016 +anforderungen.md::TextFile::-1::1788165575::0::1::::<><><>0<>0<><>:: +README.md::TextFile::-1::1788165599::0::1::::<><><>0<>0<><>:: ::res://scenes/::1787989294 main.tscn::PackedScene::-1::1787989294::0::1::::<><><>0<>0<><>::res://scripts/main.gd -::res://scripts/::1788039082 -bewohner.gd::GDScript::2125278938812678811::1788039082::0::1::::Bewohner<>Node3D<><>0<>0<><>:: -dream_db.gd::GDScript::4217518896355437099::1788039046::0::1::::DreamDB<>RefCounted<><>0<>0<><>:: -dream_world.gd::GDScript::8279158278942867258::1788039140::0::1::::DreamWorld<>Node3D<><>0<>0<><>:: -earth_world.gd::GDScript::6086818627272428934::1788039160::0::1::::EarthWorld<>Node3D<><>0<>0<><>:: -game.gd::GDScript::3600922541500868814::1788038470::0::1::::<>Node<><>0<>0<><>:: -human_db.gd::GDScript::412604892032291296::1788039057::0::1::::HumanDB<>RefCounted<><>0<>0<><>:: -main.gd::GDScript::1504009331368977934::1788039160::0::1::::<>Node3D<><>0<>0<><>:: -orte.gd::GDScript::5536340784294498671::1788039046::0::1::::Orte<>RefCounted<><>0<>0<><>:: -player.gd::GDScript::8724108548430371398::1787990898::0::1::::Player<>CharacterBody3D<><>0<>0<><>:: -prims.gd::GDScript::322632462553361584::1787990855::0::1::::Prims<>RefCounted<><>0<>0<><>:: -technique_db.gd::GDScript::4543286561661639701::1788039160::0::1::::TechDB<>RefCounted<><>0<>0<><>:: -ui.gd::GDScript::843471304695966286::1788039160::0::1::::UI<>CanvasLayer<><>0<>0<><>:: -::res://tests/::1787990698 -shots.gd::GDScript::5999651346126871724::1788038553::0::1::::<>Node<><>0<>0<><>:: +::res://scripts/::1788163668 +bewohner.gd::GDScript::2125278938812678811::1788165374::0::1::::Bewohner<>Node3D<><>0<>0<><>:: +dream_db.gd::GDScript::4217518896355437099::1788164505::0::1::::DreamDB<>RefCounted<><>0<>0<><>:: +dream_world.gd::GDScript::8279158278942867258::1788164873::0::1::::DreamWorld<>Node3D<><>0<>0<><>:: +earth_world.gd::GDScript::6086818627272428934::1788165412::0::1::::EarthWorld<>Node3D<><>0<>0<><>:: +game.gd::GDScript::3600922541500868814::1788165125::0::1::::<>Node<><>0<>0<><>:: +human_db.gd::GDScript::412604892032291296::1788165374::0::1::::HumanDB<>RefCounted<><>0<>0<><>:: +main.gd::GDScript::1504009331368977934::1788121650::0::1::::<>Node3D<><>0<>0<><>:: +netz.gd::GDScript::8422943321868648339::1788163667::0::1::::Netz<>RefCounted<><>0<>0<><>:: +orte.gd::GDScript::5536340784294498671::1788163839::0::1::::Orte<>RefCounted<><>0<>0<><>:: +player.gd::GDScript::8724108548430371398::1788039718::0::1::::Player<>CharacterBody3D<><>0<>0<><>:: +prims.gd::GDScript::322632462553361584::1788039797::0::1::::Prims<>RefCounted<><>0<>0<><>:: +technique_db.gd::GDScript::4543286561661639701::1788120975::0::1::::TechDB<>RefCounted<><>0<>0<><>:: +ui.gd::GDScript::843471304695966286::1788122559::0::1::::UI<>CanvasLayer<><>0<>0<><>:: +::res://tests/::1788164088 +probe.gd::GDScript::5788011518062194748::1788165350::0::1::::<>Node<><>0<>0<><>:: +probe.tscn::PackedScene::-1::1788163874::0::1::::<><><>0<>0<><>::res://tests/probe.gd +shots.gd::GDScript::5999651346126871724::1788118441::0::1::::<>Node<><>0<>0<><>:: shots.tscn::PackedScene::-1::1787990528::0::1::::<><><>0<>0<><>::res://tests/shots.gd -verify.gd::GDScript::4392606218392155269::1788038533::0::1::::<>Node<><>0<>0<><>:: +verify.gd::GDScript::4392606218392155269::1788165669::0::1::::<>Node<><>0<>0<><>:: verify.tscn::PackedScene::-1::1787989983::0::1::::<><><>0<>0<><>::res://tests/verify.gd diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg index 3de2cd2..896b4a7 100644 --- a/.godot/global_script_class_cache.cfg +++ b/.godot/global_script_class_cache.cfg @@ -40,6 +40,14 @@ list=[{ "path": "res://scripts/human_db.gd" }, { "base": &"RefCounted", +"class": &"Netz", +"icon": "", +"is_abstract": false, +"is_tool": false, +"language": &"GDScript", +"path": "res://scripts/netz.gd" +}, { +"base": &"RefCounted", "class": &"Orte", "icon": "", "is_abstract": false, diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin index cf5925821b644915db5a2d888f9ed237a1ef6c41..9d2cea1f8e182a5228c26694262be4df432378df 100644 GIT binary patch delta 79 zcmeyuyp@GfU?U?7quk{ER^q{aPfA1?7#NCDi>>tai<65o3rdRh^HNKyCNnW^l(i}N bZh2lcI6wrhq$IT%sGy)IKPh$c21XYE=C2v? delta 18 acmdnW@`ag^ZzCfM<773)b(6m^IsyPRLItP* diff --git a/README.md b/README.md index 572c657..9c7fc8f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ sie allein nicht bewegt bekommen. Du wirst zu ihnen geschickt. godot --path . Steuerung: **WASD** bewegen · **Maus** umsehen · **E** interagieren · -**J** Journal · **Esc** Maus freigeben. +**J** Journal · **1/2/3** Tempo · **Esc** Maus freigeben. In der Traumwelt springt **Leertaste**; auf der Erde steigt sie und **Umschalt** lässt sinken. @@ -21,7 +21,8 @@ lässt sinken. Das Journal führt Buch — es lehrt und vergibt nichts. -* **Techniken** — was du gelernt hast, mit Stufe und Erfahrung. +* **Techniken** — was du gelernt hast, mit Stufe und Erfahrung. Besser werden + sie nur draußen. * **Missionen** — die Menschen, für die du dich entschieden hast, jeweils mit ihrem **Regelwerk**. @@ -29,18 +30,29 @@ Das Journal führt Buch — es lehrt und vergibt nichts. Jede Tätigkeit im Tag eines Menschen kann eine Technik auslösen: *wenn Lena zögert, dann Pomodoro*. Wechselt sie in die Tätigkeit, greift die Regel von -allein, solange du auf der Erde bist. Neben jeder Wahl steht die Erfolgschance -für genau diesen Moment — du siehst also vorher, ob eine Regel taugt. +allein, solange du auf der Erde bist. -Jede Technik hält nur einen Posten — sie an einen neuen Moment zu hängen nimmt -sie vom alten weg. Mehr Momente abzudecken heißt also, mehr Techniken zu -kennen, und die gibt es nur auf der gewachsenen Insel. +**Ob eine Regel taugt, sagt dir niemand.** Keine Prozentzahl, kein „passt zum +Thema", keine Liste guter Momente. Du bekommst nur die Technik selbst: was sie +tut, wann sie passt, warum sie hilft. Und den Menschen: seinen Traum und das, +was im Weg steht. Beides zusammenzubringen ist das Spiel. + +Eine Technik darf an beliebig vielen Tätigkeiten hängen. Sie überall +hinzuhängen ist trotzdem falsch: im falschen Moment greift sie kaum und wirft +den Traum um 14 % zurück. Nach jedem Einsatz braucht sie außerdem 30 Sekunden +Ruhe — ein Fehlgriff verbrennt also den Platz, den ein guter Moment gehabt +hätte. Mehr Momente sauber abzudecken heißt, mehr Techniken zu kennen, und die +gibt es nur auf der gewachsenen Insel. So laufen die zwei Schleifen: Techniken lernen → Regelwerk anpassen → Probleme lösen Träume erfüllen → Dorf wächst → neue Wege entdecken +Eine Regel greift immer wieder, solange der Moment anhält — im Takt des +Cooldowns. Wie viel ein Tag einbringt, hängt also daran, wie lange ein Mensch +in der passenden Tätigkeit bleibt. + Ein erfüllter Traum bringt ein Wesen mit seinem Haus ins Dorf, und in diesem Haus wird ein neuer Weg gelehrt. Zu Beginn kennt das Dorf nur einen. @@ -48,42 +60,74 @@ Schlafende sind unerreichbar, dieselbe Technik wirkt bei Wiederholung schwächer, ein Fehlversuch macht kurz dicht. Von Hand geht es weiter: nah herangehen und **E** wirkt sofort. -**Traumwelt** — eine schwebende Insel mit einem Dorf, das erzeugt wird +**Traumwelt** — eine schwebende Insel mit einem Dorf, das wächst * Fest sind nur drei Dinge: der **Hörsaal der Wege**, der **Dorfplatz** und das **Tor zur Erde**. Alles andere entsteht beim Wachsen. * **Die Insel beginnt kahl.** Ein Gebäude, kein Bewohner, ein einziger - gelehrter Weg. -* Jeder erfüllte Traum bringt einen Bewohner mit seinem Haus — Lage, Größe, - Farbe, Türrichtung, Einrichtung und Tagesablauf werden erzeugt, ringförmig - nach außen. **In jedem Haus wird ein Weg gelehrt, den es vorher nirgends - gab.** Ein Mensch, ein Haus, eine Technik. -* Die Insel dehnt sich mit, damit jedes Haus festen Grund hat. Das Dorf hört - nicht auf zu wachsen: die Kleinstadt schickt immer neue Menschen. + gelehrter Weg — und den bringt dein Wesen schon mit. Am Brett hängt also vom + ersten Moment an eine Mission. +* Jeder erfüllte Traum bringt einen Bewohner mit seinem Haus. **In jedem Haus + wird ein Weg gelehrt, den es vorher nirgends gab.** +* Das Haus setzt sich nicht auf einen Ring, sondern **sucht sich seinen Platz**: + gewertet werden Nachbarschaft, Nähe zu einem Weg, Nähe zum Dorfplatz und wie + leer die Himmelsrichtung noch ist. Der beste von 64 Probepunkten gewinnt. +* Der Weg dorthin **dockt an der nächsten Stelle des bestehenden Netzes an** — + nicht an der Dorfmitte. Kreuzt die Gerade ein Haus, sucht ein Raster einen Weg + darum herum. Liegt ein Knoten nah, ist aber im Netz weit weg, kommt eine + Abkürzung dazu. Daraus entstehen Gabeln, Schleifen und krumme Gassen. +* Die Insel dehnt sich mit, Bäume füllen nach Poisson-Regel die freie Fläche. + Vorbild: Emilien et al., *Procedural Generation of Villages* (2012). * **Schwarzes Brett** am Dorfplatz — hier hängen die Menschen aus, die nicht - weiterkommen. Mission annehmen. + weiterkommen. **Ausgehängt wird nur, wem du mit dem, was du kannst, auch + helfen kannst:** mindestens eine gelernte Technik muss ihr Thema treffen und + in ihrem Tag einen Moment dafür finden. Die anderen bleiben verdeckt, bis du + den passenden Weg gelernt hast. -**Erde** — eine Kleinstadt, ein Tag dauert 6 Minuten +**Tempo** — **1**, **2**, **3** +Drei Stufen wie in Die Sims. Die Stufe ist nicht der Faktor: + + Stufe 1 1× ein Tag in 6:00 Minuten + Stufe 2 3× ein Tag in 2:00 Minuten + Stufe 3 7× ein Tag in 0:51 Minuten + +Über der Nacht des Menschen, dem du gerade hilfst, springt das Spiel von allein +auf Stufe 3 und fällt beim Aufwachen auf deine zurück — Schlafende erreicht +ohnehin keine Technik. Beschleunigt wird die ganze Simulation samt Cooldown und +Abwehr. Am Ausgang ändert sich dadurch nichts, nur an der Wartezeit. + +**Erde** — ein Stadtbezirk nach japanischem Vorbild + +* **Arterien** in einem groben, leicht versetzten Raster spannen Blöcke auf. + Am Blockrand stehen die Häuser direkt an der Straße, ohne Vorgarten. Ins + Blockinnere führt eine schmale Sackgasse — *roji* —, an der die hinteren + Parzellen hängen. Der mittlere Block bleibt Park. +* **Hier wächst nichts.** Alle 16 Menschen sind von Anfang an da und leben + ihren Tag: schlafen, arbeiten, grübeln, essen, spazieren. Wer wo wohnt, + arbeitet in der Nähe; das Café liegt in der Mitte. * Du bist ein unsichtbarer Geist, schwebst und gehst durch Wände. -* Vier Menschen leben ihren Tagesablauf: schlafen, arbeiten, grübeln, essen, - spazieren. -* Nah herangehen, **E** drücken, Technik wählen. Das Panel zeigt für jede - Technik die Erfolgschance in genau dieser Situation. +* Nah herangehen, **E** drücken, Technik wählen. Das Panel nennt für jede + Technik nur, ob Thema und Moment passen — keine Zahl. * Die Säule im Park bringt dich zurück in die Traumwelt. ## Erfolgschance - 8 % + 11 % je Fertigkeitsstufe - + 25 % wenn die Technik das Thema des Menschen trifft - + 22 % wenn der Moment passt (z. B. Box-Atmung, während jemand angespannt ist) + 5 % + 9 % je Fertigkeitsstufe + + 20 % wenn die Technik das Thema des Menschen trifft + + 35 % wenn der Moment passt (z. B. Box-Atmung, während jemand angespannt ist) − 30 % wenn der Mensch gerade abblockt begrenzt auf 5 … 95 % Schlafende sind nicht erreichbar. Erfolg bringt Fortschritt und 2 EP, ein -Fehlversuch 1 EP und eine kurze Sperre. Dieselbe Technik wirkt bei Wiederholung -schwächer — es lohnt sich, mehrere zu lernen. 100 % erfüllen den Traum und -bringen 3 Einsicht zurück. +Fehlversuch 1 EP, −14 % Fortschritt und 18 Sekunden Abwehr. Jeder Einsatz sperrt +die Technik für 30 Sekunden. Dieselbe Technik wirkt bei Wiederholung schwächer — +es lohnt sich, mehrere zu lernen. 100 % erfüllen den Traum. + +**Eine Technik wird nur besser, indem du sie anwendest.** Der Lehrort gibt sie +dir, mehr nicht: kein Üben, kein Trainieren, keine Währung, die man dagegen +eintauschen könnte. Wer eine Technik auf Stufe 5 will, muss sie draußen bei +Menschen einsetzen — auch Fehlversuche zählen. ## Aufbau @@ -91,8 +135,9 @@ bringen 3 Einsicht zurück. scenes/main.tscn Einstieg scripts/game.gd Zustand, Regelwerk, wachsende Welt, Uhr (Autoload) scripts/technique_db.gd die zwölf Techniken mit ihren Hinweisen - scripts/human_db.gd Kleinstadt, erzeugte Wohnhäuser und Menschen - scripts/dream_db.gd Traumdorf: erzeugt Häuser, Bewohner und Lehrorte + scripts/human_db.gd Bezirk: Arterien, Blöcke, roji, Parzellen, Menschen + scripts/dream_db.gd Traumdorf: wachsende Häuser, Wege, Bewohner, Lehrorte + scripts/netz.gd Wegenetz: Knoten, Kanten, Andocken, kürzester Weg scripts/orte.gd Orte, Türen, Wege, Tätigkeiten (beide Welten) scripts/bewohner.gd Bewohner mit Tagesablauf, löst Regeln aus scripts/main.gd Weltenwechsel, Fokus, Interaktion @@ -108,15 +153,18 @@ bringen 3 Einsicht zurück. godot --path . tests/shots.tscn # 28 Screenshots nach user://shots/ `verify` fährt das echte Spiel: leeres Traumdorf, einziehende Wesen mit ihren -Tagesabläufen, -Häuser betreten, Journal in beiden Welten, im Hörsaal lernen und vertiefen, +Tagesabläufen, gewachsenes Dorf und erzeugter Bezirk, +Häuser betreten, Journal in beiden Welten, im Hörsaal lernen, Verbessern allein durchs Anwenden, Mission am Schwarzen Brett annehmen, Regelwerk bauen und einen Tag unbeaufsichtigt laufen lassen, zur Erde reisen, -Tagesabläufe über einen ganzen Tag, Erfolgschancen, Mission abschließen, +Tagesabläufe über einen ganzen Tag, Wirkung der Technikwahl, Cooldown, +verdeckte Erfolgschance, keine Passungshinweise, Mehrfachbelegung von Regeln, +Tempo und Nachtsprung, +nur lösbare Aushänge, Mission abschließen, Insel wachsen lassen, Tastensteuerung. Beendet sich mit Code 1, wenn etwas fehlt. -Drei Prüfungen messen statt zu behaupten. Über 24 Ausbaustufen darf sich kein +Mehrere Prüfungen messen statt zu behaupten. Über 24 Ausbaustufen darf sich kein erzeugtes Gebäude mit einem anderen berühren und keines über den Inselrand ragen; jeder Weg eines Bewohners muss in sein Zeitfenster passen — auch in einem Dorf mit 24 Häusern und einer Stadt mit 27 Menschen. Und der erste Traum diff --git a/anforderungen.md b/anforderungen.md index 0823389..afdbf19 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -9,7 +9,15 @@ Loops Funktionen - Bewegung: Bewegen, Springen, Geist - Journal: Gelernte Techniken, Akzetierte Missionen -- Techniken: Lernen, Verbessern -- Missionen: Pipeline +- Techniken: Lernen am Lehrort, Verbessern durch Anwenden +- Missionen: Pipeline, nur lösbare werden angeboten +- Techniken: Cooldown, Fehlschlag kostet Fortschritt +- Erfolgschance und Passung bleiben verdeckt +- Eine Technik darf an mehreren Tätigkeiten hängen - Menschen: Tagesablauf -- Prozedurale Generierung: Dorf, Erde +- Tempo: Stufe 1, 2, 3 wie in Die Sims (1×, 3×, 7×) +- Im Schlaf des Menschen automatisch Stufe 3 +- Prozedurale Generierung + - Dorf: wächst Haus für Haus, verwinkelte Wege, grün und gemütlich + - Erde: Stadtbezirk nach japanischem Vorbild, Arterien und roji + - Auf der Erde sind alle Menschen von Anfang an da diff --git a/scripts/bewohner.gd b/scripts/bewohner.gd index 27d103e..34149be 100644 --- a/scripts/bewohner.gd +++ b/scripts/bewohner.gd @@ -3,7 +3,7 @@ class_name Bewohner ## Bewohner einer Welt: läuft seinen Tagesablauf ab. Auf der Erde kann er von ## einer Technik erreicht werden -- oder eben nicht. -const GEHTEMPO := 6.0 +const GEHTEMPO := 8.0 var data: Dictionary var idx: int = 0 @@ -11,6 +11,8 @@ var place: String = "" var activity: String = "" var resist_left: float = 0.0 var places: Dictionary = {} +var netz: Netz = null +var anker: Dictionary = {} var mit_mission := false var _visual: Node3D @@ -21,14 +23,18 @@ var _aura: MeshInstance3D var _strahl: MeshInstance3D var _path: Array = [] var _letzte_aktivitaet: String = "" +var _seit_regel: float = 0.0 var _bob: float = 0.0 var _offset: Vector3 -func setup(d: Dictionary, index: int, orte: Dictionary) -> void: +func setup(d: Dictionary, index: int, orte: Dictionary, + wegenetz: Netz = null, wegeanker: Dictionary = {}) -> void: data = d idx = index places = orte + netz = wegenetz + anker = wegeanker mit_mission = bool(d.get("mission", false)) name = "Bewohner_" + str(d["id"]) var winkel := float(index) * TAU / 4.0 @@ -73,13 +79,14 @@ func _routine_at(h: float) -> Array: return [cur[1], cur[2]] -func _process(delta: float) -> void: +func _process(roh: float) -> void: + var delta := roh * Game.zeit_faktor() resist_left = maxf(0.0, resist_left - delta) var soll := _routine_at(Game.time_h) if soll[0] != place: var war_unterwegs := not _path.is_empty() - _path = Orte.route(places, place, soll[0], _offset) + _path = Orte.route(places, place, soll[0], _offset, netz, anker) if war_unterwegs and _path.size() > 1: _path.remove_at(0) # steht schon draußen, alte Tür überspringen place = soll[0] @@ -110,8 +117,15 @@ func _process(delta: float) -> void: _visual.rotation.z = lerp_angle(_visual.rotation.z, PI * 0.5 if activity == "schlaeft" else 0.0, 1.0 - pow(0.01, delta)) + # Solange der Moment anhält, greift die Regel erneut, sobald ihre Technik + # wieder frei ist. Der Cooldown gibt damit den Takt vor. + _seit_regel += delta if activity != _letzte_aktivitaet: _letzte_aktivitaet = activity + _seit_regel = 0.0 + _regel_pruefen() + elif _seit_regel >= 1.0: + _seit_regel = 0.0 _regel_pruefen() var erledigt: bool = mit_mission and Game.completed.has(data["id"]) diff --git a/scripts/dream_db.gd b/scripts/dream_db.gd index dd48fb2..ca8f75d 100644 --- a/scripts/dream_db.gd +++ b/scripts/dream_db.gd @@ -1,15 +1,17 @@ class_name DreamDB ## Die Traumwelt: ein Dorf, das mit jedem erfüllten Traum wächst. -## Fest sind nur Hörsaal, Dorfplatz und Tor zur Erde. Jedes Haus und sein -## Bewohner werden erzeugt — der Reihe nach, immer weiter nach außen. +## Fest sind nur Hörsaal, Dorfplatz und Tor zur Erde. Jedes weitere Haus sucht +## sich seinen Platz selbst: es wird dort gebaut, wo es Nachbarn, einen Weg und +## den Dorfplatz in erträglicher Nähe hat. Der Weg dorthin dockt an der +## nächsten Stelle des bestehenden Netzes an. Weil Wiederverwendung gratis ist, +## gabeln sich die Wege und werden verwinkelt, statt sternförmig zu laufen. +## Vorbild: Emilien et al., "Procedural Generation of Villages" (2012). -const HAEUSER_JE_RING := 6 -const RING_ERSTER := 30.0 -const RING_ABSTAND := 24.0 - -## Häuser sitzen auf Achtelrichtungen. Die Achsen 0 (Hörsaal) und 4 (Tor) -## bleiben frei, damit der Weg vom Dorfplatz zu beiden offen bleibt. -const SLOTS := [1, 2, 3, 5, 6, 7] +const PLATZ_FREI := 22.0 # Radius um den Dorfplatz, der leer bleibt +const HAUS_ABSTAND := 4.0 # Mindestluft zwischen zwei Gebäuden +const WEG_ABSTAND := 3.0 # Mindestluft zwischen Haus und Weg +const KANDIDATEN := 64 # Probepunkte je Haus +const SPINE_SCHRITT := 8.0 # Abstand der Knoten auf der Hauptachse static var FEST: Dictionary = { "hoersaal": { @@ -29,6 +31,8 @@ static var FEST: Dictionary = { ## Wird von erzeuge() gefüllt und wächst mit dem Dorf. static var PLACES: Dictionary = FEST.duplicate(true) static var LIST: Array = [] +static var NETZ: Netz = Netz.new() +static var ANKER: Dictionary = {} static var FESTE_NAMEN: Array = ["Ailen", "Morr", "Ivo", "Sela"] static var FESTE_ROLLEN: Array = ["Traumweberin", "Erinnerungssammler", "Bote", @@ -56,47 +60,346 @@ static func haus_key(i: int) -> String: return "haus_%d" % i -## Baut Orte und Bewohner für ein Dorf mit so vielen Häusern neu auf. +## Baut Orte, Wegenetz und Bewohner für ein Dorf mit so vielen Häusern neu auf. +## Deterministisch: Haus i landet immer am selben Fleck. static func erzeuge(haeuser: int) -> void: PLACES = FEST.duplicate(true) LIST = [] + NETZ = Netz.new() + ANKER = {} + _hauptachse() for i in range(maxi(haeuser, 0)): - PLACES[haus_key(i)] = _haus(i) + var haus := _platz_suchen(i) + PLACES[haus_key(i)] = haus + _anschliessen(haus_key(i)) LIST.append(_bewohner(i)) -## Wie weit reicht das Dorf hinaus? -static func aeusserster_ring(haeuser: int) -> float: - if haeuser <= 0: - return 0.0 - var ring := (haeuser - 1) / HAEUSER_JE_RING - return RING_ERSTER + float(ring) * RING_ABSTAND +## Die feste Achse Hörsaal – Dorfplatz – Tor. Sie ist von Anfang an da und +## trägt genug Knoten, damit Häuser überall andocken können. +static func _hauptachse() -> void: + var punkte: Array[Vector3] = [] + var a := Orte.door(PLACES, "hoersaal") + var m := Orte.center(PLACES, "lichtung") + var b := Orte.door(PLACES, "tor") + for paar in [[a, m], [m, b]]: + var von: Vector3 = paar[0] + var nach: Vector3 = paar[1] + var n := maxi(1, int(round(von.distance_to(nach) / SPINE_SCHRITT))) + for k in range(n + 1): + var p := von.lerp(nach, float(k) / float(n)) + if punkte.is_empty() or punkte[punkte.size() - 1].distance_to(p) > 0.6: + punkte.append(p) + var vorher := -1 + for p in punkte: + var idx := NETZ.fuege_knoten(p) + if vorher >= 0: + NETZ.verbinde(vorher, idx) + vorher = idx + ANKER["hoersaal"] = NETZ.fuege_knoten(a) + ANKER["lichtung"] = NETZ.fuege_knoten(m) + ANKER["tor"] = NETZ.fuege_knoten(b) -static func _haus(i: int) -> Dictionary: - var ring := i / HAEUSER_JE_RING - var winkel := float(SLOTS[i % HAEUSER_JE_RING]) * TAU / 8.0 - var r := RING_ERSTER + float(ring) * RING_ABSTAND - var x := sin(winkel) * r - var z := cos(winkel) * r +# ------------------------------------------------------------ Platzsuche + +## Wählt unter vielen Probepunkten den mit dem höchsten Interesse. +static func _platz_suchen(i: int) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = 4711 + i * 7919 var breite := 12.0 + float(i % 2) * 2.0 var tiefe := 10.0 + float(i % 3) + var groesse := Vector2(breite, tiefe) + # Die Suchfläche wächst mit der Zahl der Häuser, nicht mit dem weitesten + # Ausreißer. So bleibt die Dichte gleich, statt zu zerfasern. + var aussen := maxf(_wachstumsradius() + 8.0, + sqrt(PLATZ_FREI * PLATZ_FREI + 240.0 * float(i + 1))) + + var bester := Vector3.ZERO + var bestes := -1.0 + for k in range(KANDIDATEN): + var winkel := rng.randf() * TAU + var r := PLATZ_FREI + rng.randf() * maxf(6.0, aussen - PLATZ_FREI) + var p := Vector3(cos(winkel) * r, 0.0, sin(winkel) * r) + if not _platz_frei(p, groesse): + continue + var wert := _interesse(p) + if wert > bestes: + bestes = wert + bester = p + if bestes < 0.0: + # Notnagel: irgendwo weit außen, damit das Dorf nie steckenbleibt + bester = _notplatz(i, groesse) + return { "name": "%s Haus" % _besitz(_name(i)), - "pos": [x, z], + "pos": [bester.x, bester.z], "size": [breite, tiefe], - "dir": _tuer_zur_mitte(x, z), + "dir": _tuer_zum_netz(bester, groesse), "color": Color.from_hsv(fmod(0.60 + float(i) * 0.137, 1.0), 0.34, 0.86), } -## Die Tür zeigt zur Dorfmitte — auf die Seite, die ihr am nächsten liegt. -static func _tuer_zur_mitte(x: float, z: float) -> String: - if absf(x) > absf(z): - return "left" if x > 0.0 else "right" - return "forward" if z > 0.0 else "back" +## Wie weit reichen die Häuser? Danach richtet sich, wo neu gesucht wird -- +## nicht nach Hörsaal und Tor, sonst zerfasert das Dorf sofort. +static func _wachstumsradius() -> float: + var r := PLATZ_FREI + 8.0 + for key in PLACES: + if not key.begins_with("haus_"): + continue + var s: Array = PLACES[key]["size"] + var c := Orte.center(PLACES, key) + r = maxf(r, Vector2(absf(c.x) + float(s[0]) * 0.5, + absf(c.z) + float(s[1]) * 0.5).length()) + return r +static func _notplatz(i: int, groesse: Vector2) -> Vector3: + var r := aussenradius() + 20.0 + for k in range(72): + var a := TAU * float((i * 29 + k) % 72) / 72.0 + var p := Vector3(cos(a) * r, 0.0, sin(a) * r) + if _platz_frei(p, groesse): + return p + return Vector3(0, 0, r) + + +## Harte Bedingungen: nicht auf dem Dorfplatz, nicht in einem anderen Gebäude, +## nicht auf einem Weg. +static func _platz_frei(p: Vector3, groesse: Vector2) -> bool: + if p.length() - maxf(groesse.x, groesse.y) * 0.5 < PLATZ_FREI: + return false + for key in PLACES: + var s: Array = PLACES[key]["size"] + if float(s[0]) <= 0.0: + continue + var c := Orte.center(PLACES, key) + var dx: float = absf(p.x - c.x) - (groesse.x + float(s[0])) * 0.5 + var dz: float = absf(p.z - c.z) - (groesse.y + float(s[1])) * 0.5 + if dx < HAUS_ABSTAND and dz < HAUS_ABSTAND: + return false + for k in NETZ.kanten: + if Netz.trifft_rechteck(NETZ.knoten[k[0]], NETZ.knoten[k[1]], p, groesse, WEG_ABSTAND): + return false + return true + + +## Wie gut liegt dieser Punkt? Nachbarschaft, Weganschluss, Nähe zum Platz. +static func _interesse(p: Vector3) -> float: + var sozial := 0.0 + var n := 0 + for key in PLACES: + if not key.begins_with("haus_"): + continue + var d := Orte.center(PLACES, key).distance_to(p) + sozial += _glocke(d, 16.0, 21.0, 46.0) + n += 1 + if n > 0: + sozial = sozial / sqrt(float(n)) + + var an := NETZ.andocken(p) + var weg := _glocke(float(an["dist"]), 3.0, 9.0, 34.0) + var platz := _glocke(p.length(), PLATZ_FREI - 4.0, PLATZ_FREI + 6.0, 110.0) + return 1.0 * sozial + 1.3 * weg + 0.5 * platz + 0.8 * _freiraum(p) + + +## Richtungen, in denen schon viel steht, sind weniger interessant. Ohne das +## wächst das Dorf einseitig davon und die halbe Insel bleibt leer. +static func _freiraum(p: Vector3) -> float: + var w := atan2(p.z, p.x) + var nah := 0.0 + var alle := 0.0 + for key in PLACES: + if not key.begins_with("haus_"): + continue + var c := Orte.center(PLACES, key) + alle += 1.0 + if absf(angle_difference(atan2(c.z, c.x), w)) < PI / 3.0: + nah += 1.0 + if alle < 1.0: + return 1.0 + return clampf(1.0 - 3.0 * nah / alle, 0.0, 1.0) + + +## Dreiecksglocke: 0 unter lo, 1 bei spitze, 0 über hi. +static func _glocke(d: float, lo: float, spitze: float, hi: float) -> float: + if d <= lo or d >= hi: + return 0.0 + if d < spitze: + return (d - lo) / maxf(0.001, spitze - lo) + return (hi - d) / maxf(0.001, hi - spitze) + + +## Die Tür zeigt auf die Seite, die dem Wegenetz am nächsten liegt. +static func _tuer_zum_netz(p: Vector3, groesse: Vector2) -> String: + var ziel: Vector3 = NETZ.andocken(p)["punkt"] + var d := ziel - p + if absf(d.x) * groesse.y > absf(d.z) * groesse.x: + return "left" if d.x < 0.0 else "right" + return "forward" if d.z < 0.0 else "back" + + +# ------------------------------------------------------------ Weganschluss + +## Verbindet ein neues Haus mit dem Netz. Der Weg geht um Häuser herum, statt +## durch sie hindurch. Weil er am nächsten Punkt des Netzes andockt und nicht +## in der Dorfmitte, gabeln sich die Wege und werden verwinkelt. +static func _anschliessen(key: String) -> void: + var tuer := Orte.door(PLACES, key) + var an := NETZ.andocken(tuer) + var von: Vector3 = an["punkt"] + var linie := _route_um_haeuser(tuer, von, key) + var vorher := -1 + for i in range(linie.size()): + var idx := NETZ.fuege_knoten(linie[i]) + if vorher >= 0: + NETZ.verbinde(vorher, idx) + vorher = idx + if i == 0: + ANKER[key] = idx + if vorher >= 0: + NETZ.verbinde(vorher, int(an["knoten"])) + if not ANKER.has(key): + ANKER[key] = NETZ.fuege_knoten(tuer) + _schleife(int(ANKER[key]), key) + + +## Weg von a nach b, der kein Haus schneidet. Frei? Dann die Gerade. Sonst +## Dijkstra über ein grobes Raster, danach auf wenige Knicke geglättet. +static func _route_um_haeuser(a: Vector3, b: Vector3, ausser: String) -> Array: + if _weg_frei(a, b, ausser): + return [a, b] + var rand := 30.0 + var min_x := minf(a.x, b.x) - rand + var max_x := maxf(a.x, b.x) + rand + var min_z := minf(a.z, b.z) - rand + var max_z := maxf(a.z, b.z) + rand + var schritt := 5.0 + var nx := int((max_x - min_x) / schritt) + 1 + var nz := int((max_z - min_z) / schritt) + 1 + while nx * nz > 2600: + schritt *= 1.5 + nx = int((max_x - min_x) / schritt) + 1 + nz = int((max_z - min_z) / schritt) + 1 + + var start := Vector2i(clampi(int(round((a.x - min_x) / schritt)), 0, nx - 1), + clampi(int(round((a.z - min_z) / schritt)), 0, nz - 1)) + var ziel := Vector2i(clampi(int(round((b.x - min_x) / schritt)), 0, nx - 1), + clampi(int(round((b.z - min_z) / schritt)), 0, nz - 1)) + + var kosten: Dictionary = {start: 0.0} + var her: Dictionary = {} + var offen: Array = [start] + var gefunden := false + while not offen.is_empty(): + var best := 0 + for i in range(1, offen.size()): + if float(kosten[offen[i]]) < float(kosten[offen[best]]): + best = i + var c: Vector2i = offen[best] + offen.remove_at(best) + if c == ziel: + gefunden = true + break + var pa := Vector3(min_x + float(c.x) * schritt, 0.0, min_z + float(c.y) * schritt) + for dx in [-1, 0, 1]: + for dz in [-1, 0, 1]: + if dx == 0 and dz == 0: + continue + var n := Vector2i(c.x + dx, c.y + dz) + if n.x < 0 or n.y < 0 or n.x >= nx or n.y >= nz: + continue + var pb := Vector3(min_x + float(n.x) * schritt, 0.0, + min_z + float(n.y) * schritt) + if not _weg_frei(pa, pb, ausser): + continue + var neu: float = float(kosten[c]) + pa.distance_to(pb) + if not kosten.has(n) or neu < float(kosten[n]) - 0.001: + kosten[n] = neu + her[n] = c + if not offen.has(n): + offen.append(n) + if not gefunden: + return [a, b] + + var roh: Array = [] + var cur: Vector2i = ziel + while cur != start: + roh.push_front(Vector3(min_x + float(cur.x) * schritt, 0.0, + min_z + float(cur.y) * schritt)) + cur = her[cur] + roh.push_front(a) + roh.append(b) + return _glaetten(roh, ausser) + + +## Zieht die Rasterspur auf wenige Knicke zusammen: immer so weit springen, +## wie die Sicht frei ist. +static func _glaetten(linie: Array, ausser: String) -> Array: + var out: Array = [linie[0]] + var i := 0 + while i < linie.size() - 1: + var j := linie.size() - 1 + while j > i + 1 and not _weg_frei(linie[i], linie[j], ausser): + j -= 1 + out.append(linie[j]) + i = j + return out + + +## Ein Umweg über das halbe Dorf ist unschön. Liegt ein Knoten nah, ist aber im +## Netz weit weg, wird abgekürzt. Daraus entstehen Schleifen statt Sackgassen. +static func _schleife(neu: int, key: String) -> void: + var p: Vector3 = NETZ.knoten[neu] + var bester := -1 + var beste := INF + for i in range(NETZ.knoten.size()): + if i == neu: + continue + var d := NETZ.knoten[i].distance_to(p) + if d > 52.0 or d < 11.0 or d >= beste: + continue + if NETZ.weglaenge(neu, i) < d * 1.9: + continue + if not _weg_frei(p, NETZ.knoten[i], key): + continue + beste = d + bester = i + if bester >= 0: + NETZ.verbinde(neu, bester) + + +## Kein Weg darf durch ein Gebäude laufen -- auch nicht durch das eigene. +## Am eigenen Haus reicht knappe Luft, sonst käme die Tür nie frei. +static func _weg_frei(a: Vector3, b: Vector3, ausser: String) -> bool: + for key in PLACES: + var s: Array = PLACES[key]["size"] + if float(s[0]) <= 0.0: + continue + var c := Orte.center(PLACES, key) + var rand := 0.2 if key == ausser else 1.0 + if Netz.trifft_rechteck(a, b, c, Vector2(float(s[0]), float(s[1])), rand): + return false + return true + + +# ------------------------------------------------------------ Maße + +## Wie weit reicht das Dorf hinaus? +static func aussenradius() -> float: + var r := 0.0 + for key in PLACES: + var s: Array = PLACES[key]["size"] + var c := Orte.center(PLACES, key) + var ecke := Vector2(absf(c.x) + float(s[0]) * 0.5, absf(c.z) + float(s[1]) * 0.5) + r = maxf(r, ecke.length()) + for k in NETZ.knoten: + r = maxf(r, k.length() + 2.0) + return r + + +# ------------------------------------------------------------ Bewohner + static func _bewohner(i: int) -> Dictionary: var haus := haus_key(i) var ablauf: Array = [] @@ -120,7 +423,7 @@ static func _name(i: int) -> String: return str(SILBE_A[j % SILBE_A.size()]) + str(SILBE_B[(j / SILBE_A.size()) % SILBE_B.size()]) -## Genitiv: "Ailens", aber "Morrs" und "Esas'" — Apostroph nur nach Zischlaut. +## Genitiv: "Ailens", aber "Esas'" -- Apostroph nur nach Zischlaut. static func _besitz(n: String) -> String: var letzter := n.substr(n.length() - 1).to_lower() return n + "'" if ["s", "z", "x", "ß"].has(letzter) else n + "s" diff --git a/scripts/dream_world.gd b/scripts/dream_world.gd index 2762fed..f48d447 100644 --- a/scripts/dream_world.gd +++ b/scripts/dream_world.gd @@ -7,6 +7,7 @@ var spawn := Vector3(0, 2.0, 20.0) var bewohner: Array = [] var _deko: Array = [] +var _freihalten: Array[Vector3] = [] func _ready() -> void: @@ -17,6 +18,7 @@ func _ready() -> void: _schwarzes_brett() _lichtung() _tor_zur_erde() + _gruen() _wesen() @@ -59,18 +61,14 @@ func _insel() -> void: Prims.cyl(self, Vector3(0, -4.0, 0), radius * 0.77, 4.0, Color(0.34, 0.26, 0.50), false) Prims.cyl(self, Vector3(0, -9.0, 0), radius * 0.42, 6.0, Color(0.24, 0.17, 0.38), false) - # Ringweg, auf dem die Wesen gehen - for i in range(36): - var a := TAU * float(i) / 36.0 - Prims.box(self, Vector3(cos(a) * 15.0, 0.03, sin(a) * 15.0), - Vector3(2.6, 0.06, 2.6), Color(0.66, 0.58, 0.84), false) + _wege() # schwebende Deko var rng := RandomNumberGenerator.new() rng.seed = 7 for i in range(34): var a := rng.randf() * TAU - var r := rng.randf_range(18.0, Game.insel_radius() - 4.0) + var r := rng.randf_range(18.0, maxf(24.0, Game.insel_radius() - 4.0)) var p := Vector3(cos(a) * r, rng.randf_range(5.0, 24.0), sin(a) * r) var s := rng.randf_range(0.6, 2.0) var c := Color(0.6 + rng.randf() * 0.3, 0.5 + rng.randf() * 0.4, 1.0) @@ -79,6 +77,91 @@ func _insel() -> void: _deko.append(wuerfel) +## Die gewachsenen Wege: je Kante ein flaches Band, an den Knoten ein Fleck, +## damit Ecken nicht ausfransen. +## Begrünung nach Regel, nicht von Hand: Poisson-Scheibe über der freien +## Fläche. Nichts wächst auf einem Weg, in einem Haus oder auf dem Dorfplatz. +func _gruen() -> void: + _freihalten.append(Vector3(spawn.x, 0.0, spawn.z)) + var radius := Game.insel_radius() - 4.0 + var rng := RandomNumberGenerator.new() + rng.seed = 91 + var gesetzt: Array[Vector3] = [] + var abstand := 9.0 + for versuch in range(1400): + var a := rng.randf() * TAU + var r := sqrt(rng.randf()) * radius + var p := Vector3(cos(a) * r, 0.0, sin(a) * r) + if p.length() < 20.0: + continue + var frei := true + for g in gesetzt: + if g.distance_to(p) < abstand: + frei = false + break + if not frei: + continue + if not _freie_stelle(p, 3.2): + continue + gesetzt.append(p) + _baum(p, rng) + + +func _freie_stelle(p: Vector3, luft: float) -> bool: + for frei in _freihalten: + if p.distance_to(frei) < 7.0: + return false + for key in DreamDB.PLACES: + var s: Array = DreamDB.PLACES[key]["size"] + if float(s[0]) <= 0.0 or not Game.ort_offen(key): + continue + var c := Orte.center(DreamDB.PLACES, key) + if absf(p.x - c.x) < float(s[0]) * 0.5 + luft \ + and absf(p.z - c.z) < float(s[1]) * 0.5 + luft: + return false + for k in DreamDB.NETZ.kanten: + if Netz.trifft_rechteck(DreamDB.NETZ.knoten[k[0]], DreamDB.NETZ.knoten[k[1]], + p, Vector2(luft * 2.0, luft * 2.0), 0.6): + return false + return true + + +## Traumbäume: schlanker Stamm, zwei versetzte Kronen, leuchtende Spitze. +func _baum(p: Vector3, rng: RandomNumberGenerator) -> void: + var hoehe := rng.randf_range(3.0, 6.0) + # Grün gegen den lila Grund -- das macht die Insel erst gemütlich. + var ton := Color.from_hsv(0.26 + rng.randf() * 0.12, 0.48 + rng.randf() * 0.18, + 0.58 + rng.randf() * 0.26) + Prims.cyl(self, p + Vector3(0, hoehe * 0.5, 0), 0.24, hoehe, + Color(0.34, 0.26, 0.30)) + var k1 := Prims.sphere(self, p + Vector3(0, hoehe + 0.4, 0), + rng.randf_range(1.1, 2.0), ton) + k1.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + if rng.randf() < 0.6: + var k2 := Prims.sphere(self, p + Vector3(rng.randf_range(-0.8, 0.8), + hoehe - 0.7, rng.randf_range(-0.8, 0.8)), rng.randf_range(0.8, 1.5), + ton.lightened(0.12)) + k2.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + if rng.randf() < 0.3: + Prims.sphere(self, p + Vector3(0, hoehe + 1.6, 0), 0.28, + Color(0.9, 0.95, 1.0), 1.6) + + +func _wege() -> void: + var farbe := Color(0.66, 0.58, 0.84) + for k in DreamDB.NETZ.kanten: + var a: Vector3 = DreamDB.NETZ.knoten[k[0]] + var b: Vector3 = DreamDB.NETZ.knoten[k[1]] + var d := b - a + if d.length() < 0.2: + continue + var band := Prims.box(self, (a + b) * 0.5 + Vector3(0, 0.03, 0), + Vector3(2.6, 0.06, d.length()), farbe, false) + band.rotation.y = atan2(d.x, d.z) + for k in DreamDB.NETZ.knoten: + Prims.cyl(self, k + Vector3(0, 0.035, 0), 1.3, 0.06, farbe, false) + + func _haeuser() -> void: for key in DreamDB.PLACES: var pl: Dictionary = DreamDB.PLACES[key] @@ -194,8 +277,10 @@ func _schwarzes_brett() -> void: _marke(pos + Vector3(0, 1.6, 1.2), "brett", "Schwarzes Brett") -## Unsichtbarer Punkt, an dem die Interaktion hängt. +## Unsichtbarer Punkt, an dem die Interaktion hängt. Um jede Marke bleibt ein +## Stück frei, damit später kein Baum davorsteht. func _marke(pos: Vector3, kind: String, titel: String) -> Node3D: + _freihalten.append(Vector3(pos.x, 0.0, pos.z)) var n := Node3D.new() n.position = pos add_child(n) @@ -243,7 +328,7 @@ func _wesen() -> void: continue var w := Bewohner.new() add_child(w) - w.setup(DreamDB.LIST[i], i, DreamDB.PLACES) + w.setup(DreamDB.LIST[i], i, DreamDB.PLACES, DreamDB.NETZ, DreamDB.ANKER) bewohner.append(w) diff --git a/scripts/earth_world.gd b/scripts/earth_world.gd index 51f055a..ae0b319 100644 --- a/scripts/earth_world.gd +++ b/scripts/earth_world.gd @@ -14,6 +14,7 @@ var _nachtlichter: Array = [] func _ready() -> void: _environment() _boden() + _bebauung() _orte() _park() _saeule() @@ -44,107 +45,142 @@ func _environment() -> void: func _boden() -> void: - var weite := 240.0 + Prims.box(self, Vector3(0, -0.53, 0), Vector3(300, 1, 300), Color(0.42, 0.48, 0.40)) + _strassen() + + +## Arterien breit, roji schmal -- beide als flaches Band mit Belag. +func _strassen() -> void: + for st in HumanDB.STRASSEN: + var a: Vector3 = st["a"] + var b: Vector3 = st["b"] + var d := b - a + if d.length() < 0.2: + continue + var breit := float(st["breite"]) + var farbe := Color(0.62, 0.60, 0.58) if breit > 5.0 else Color(0.55, 0.53, 0.52) + var band := Prims.box(self, (a + b) * 0.5 + Vector3(0, 0.02, 0), + Vector3(breit, 0.06, d.length() + breit), farbe, false) + band.rotation.y = atan2(d.x, d.z) + + +## Alles, was nicht Wohnung oder Sonderbau ist, steht als geschlossener Bau da. +## Erst dadurch wird der Bezirk dicht. +func _bebauung() -> void: + var rng := RandomNumberGenerator.new() + rng.seed = 8123 + var belegt: Dictionary = {} for key in HumanDB.PLACES: - var pl: Dictionary = HumanDB.PLACES[key] - var p: Array = pl["pos"] - var s2: Array = pl["size"] - weite = maxf(weite, (absf(float(p[0])) + float(s2[0])) * 2.6) - weite = maxf(weite, (absf(float(p[1])) + float(s2[1])) * 2.6) - Prims.box(self, Vector3(0, -0.53, 0), Vector3(weite, 1, weite), Color(0.36, 0.52, 0.30)) - # Ringweg, auf dem die Menschen gehen - for i in range(36): - var a := TAU * float(i) / 36.0 - Prims.box(self, Vector3(cos(a) * 15.0, 0.03, sin(a) * 15.0), - Vector3(2.8, 0.06, 2.8), Color(0.62, 0.58, 0.50), false) + var s: Array = HumanDB.PLACES[key]["size"] + if float(s[0]) > 0.0: + belegt[Orte.center(HumanDB.PLACES, key).snapped(Vector3.ONE * 0.1)] = true + for p in HumanDB.PARZELLEN: + var c: Vector3 = p["pos"] + if belegt.has(c.snapped(Vector3.ONE * 0.1)): + continue + var g: Vector2 = p["size"] + if minf(g.x, g.y) < 3.0: + continue + var hoehe := rng.randf_range(4.0, 9.0) + var ton := Color.from_hsv(fmod(0.07 + rng.randf() * 0.12, 1.0), + 0.10 + rng.randf() * 0.14, 0.62 + rng.randf() * 0.26) + Prims.box(self, c + Vector3(0, hoehe * 0.5, 0), Vector3(g.x, hoehe, g.y), ton) + Prims.box(self, c + Vector3(0, hoehe + 0.2, 0), + Vector3(g.x + 0.5, 0.4, g.y + 0.5), ton.darkened(0.25)) + if rng.randf() < 0.35: + Prims.box(self, c + Vector3(0, hoehe + 0.9, 0), + Vector3(g.x * 0.4, 1.0, g.y * 0.4), ton.darkened(0.1)) +## Wohnungen und Sonderbauten stehen offen, damit der Geist von oben hineinsieht. func _orte() -> void: for key in HumanDB.PLACES: var pl: Dictionary = HumanDB.PLACES[key] - if float(pl["size"][0]) <= 0.0: + var s: Array = pl["size"] + if float(s[0]) <= 0.0: continue var c := Orte.center(HumanDB.PLACES, key) - var s: Array = pl["size"] - var groesse := Vector3(s[0], 2.3, s[1]) + var groesse := Vector3(float(s[0]), 2.3, float(s[1])) var farbe: Color = pl["color"] - var richtung: Vector3 = Orte.DIR_VEC[pl["dir"]] - Prims.room(self, c, groesse, farbe, farbe.darkened(0.35), richtung, 3.4, true) - Prims.label(self, c + Vector3(0, 5.0, 0), pl["name"], 0.6, Color(1, 1, 0.9)) + var tuer: float = minf(3.0, minf(groesse.x, groesse.z) - 3.0) + Prims.room(self, c, groesse, farbe, farbe.darkened(0.35), + Orte.DIR_VEC[pl["dir"]], maxf(1.6, tuer), true) + Prims.label(self, c + Vector3(0, 4.4, 0), str(pl["name"]), 0.55, Color(1, 1, 0.9)) _einrichtung(c, groesse, farbe, key) - var tuer := Orte.door(HumanDB.PLACES, key) - var weg := tuer.normalized() * 15.0 - tuer - var b := Prims.box(self, (tuer + tuer.normalized() * 15.0) * 0.5 + Vector3(0, 0.03, 0), - Vector3(2.6, 0.06, weg.length()), Color(0.62, 0.58, 0.50), false) - b.rotation.y = atan2(weg.x, weg.z) - - var l := OmniLight3D.new() - l.position = c + Vector3(0, 2.0, 0) - l.omni_range = 20.0 - l.light_color = Color(1.0, 0.85, 0.6) - l.light_energy = 0.0 - add_child(l) - _nachtlichter.append(l) + var licht := OmniLight3D.new() + licht.position = c + Vector3(0, 2.0, 0) + licht.omni_range = 16.0 + licht.light_color = Color(1.0, 0.85, 0.6) + licht.light_energy = 0.0 + add_child(licht) + _nachtlichter.append(licht) +## Ein paar Möbel, damit man sieht, wofür ein Raum da ist. func _einrichtung(c: Vector3, groesse: Vector3, farbe: Color, key: String) -> void: - var h := groesse.z * 0.5 - 1.6 - var w := groesse.x * 0.5 - 1.6 - if key.begins_with("heim"): - Prims.box(self, c + Vector3(-w + 1.0, 0.35, -h + 1.2), Vector3(2.0, 0.7, 3.4), - Color(0.85, 0.85, 0.90)) # Bett - Prims.box(self, c + Vector3(w - 1.0, 0.75, 0), Vector3(1.6, 0.15, 2.6), - farbe.darkened(0.5)) # Schreibtisch - Prims.box(self, c + Vector3(w - 1.0, 1.05, 0), Vector3(0.9, 0.6, 0.1), - Color(0.2, 0.25, 0.3)) # Bildschirm + var w := maxf(0.5, groesse.x * 0.5 - 2.0) + var h := maxf(0.5, groesse.z * 0.5 - 2.0) + if key.begins_with("heim_"): + Prims.box(self, c + Vector3(-w * 0.6, 0.35, -h * 0.5), Vector3(1.9, 0.7, 3.0), + Color(0.85, 0.85, 0.90)) + Prims.box(self, c + Vector3(w * 0.6, 0.75, 0), Vector3(1.5, 0.15, 2.2), + farbe.darkened(0.5)) + Prims.box(self, c + Vector3(w * 0.6, 1.05, 0), Vector3(0.8, 0.55, 0.1), + Color(0.2, 0.25, 0.3)) elif key == "cafe": for i in range(3): - Prims.cyl(self, c + Vector3(-3.0 + i * 3.0, 0.4, 0.5), 0.8, 0.8, + Prims.cyl(self, c + Vector3(-w * 0.5 + float(i) * w * 0.5, 0.4, 0.4), 0.7, 0.8, Color(0.55, 0.40, 0.30)) - Prims.box(self, c + Vector3(0, 0.55, -3.2), Vector3(groesse.x - 3.0, 1.1, 0.8), + Prims.box(self, c + Vector3(0, 0.55, -h * 0.7), Vector3(groesse.x - 3.0, 1.1, 0.8), Color(0.45, 0.32, 0.24)) elif key == "buero": - for i in range(4): - Prims.box(self, c + Vector3(-4.5 + i * 3.0, 0.75, -1.0), Vector3(2.2, 0.15, 1.4), - Color(0.75, 0.75, 0.78)) + for i in range(3): + Prims.box(self, c + Vector3(-w * 0.5 + float(i) * w * 0.5, 0.75, 0), + Vector3(1.6, 0.15, 1.2), Color(0.75, 0.75, 0.78)) elif key == "baeckerei": - Prims.box(self, c + Vector3(0, 0.55, 2.0), Vector3(groesse.x - 3.0, 1.1, 1.0), + Prims.box(self, c + Vector3(0, 0.55, h * 0.5), Vector3(groesse.x - 3.0, 1.1, 1.0), Color(0.70, 0.55, 0.35)) - Prims.box(self, c + Vector3(0, 0.85, -3.0), Vector3(3.0, 1.7, 1.5), Color(0.4, 0.4, 0.42)) + Prims.box(self, c + Vector3(0, 0.85, -h * 0.6), Vector3(2.6, 1.7, 1.2), + Color(0.4, 0.4, 0.42)) elif key == "uni": for i in range(3): - Prims.box(self, c + Vector3(0, 0.5, -2.0 + i * 2.2), Vector3(groesse.x - 4.0, 0.2, 0.8), - Color(0.66, 0.62, 0.58)) - Prims.box(self, c + Vector3(0, 1.1, -4.0), Vector3(5.0, 1.6, 0.15), Color(0.15, 0.25, 0.20)) + Prims.box(self, c + Vector3(0, 0.5, -h * 0.6 + float(i) * h * 0.6), + Vector3(groesse.x - 3.0, 0.2, 0.7), Color(0.66, 0.62, 0.58)) + Prims.box(self, c + Vector3(0, 1.1, -h), Vector3(groesse.x * 0.55, 1.6, 0.15), + Color(0.15, 0.25, 0.20)) elif key == "werkstatt": - Prims.box(self, c + Vector3(0, 0.55, 0), Vector3(groesse.x - 4.0, 1.1, 1.4), - Color(0.55, 0.50, 0.48)) - for i in range(3): - Prims.box(self, c + Vector3(-2.0 + i * 2.0, 1.5, -h + 0.6), Vector3(0.5, 0.9, 0.15), - Color(0.72, 0.68, 0.60), false) + Prims.box(self, c + Vector3(0, 0.6, 0), Vector3(groesse.x - 4.0, 1.2, 1.4), + Color(0.52, 0.48, 0.46)) +## Der mittlere Block bleibt frei: Rasen, Teich, Bäume, Bänke. Der Park ist +## der einzige Ort im Bezirk, an dem nicht gebaut wird. func _park() -> void: var rng := RandomNumberGenerator.new() rng.seed = 3 - Prims.cyl(self, Vector3(-6, 0.05, 2), 4.0, 0.12, Color(0.30, 0.50, 0.72), false) - for i in range(14): - var a := rng.randf() * TAU - var r := rng.randf_range(5.0, 12.0) - var p := Vector3(cos(a) * r, 0, sin(a) * r + 2.0) - if p.length() < 4.0: + var halb: float = (float(HumanDB.ACHSEN[2]) - float(HumanDB.ACHSEN[1])) * 0.5 \ + - HumanDB.ARTERIE * 0.5 + Prims.box(self, Vector3(0, 0.01, 0), Vector3(halb * 2.0, 0.05, halb * 2.0), + Color(0.40, 0.58, 0.34), false) + Prims.cyl(self, Vector3(-halb * 0.4, 0.06, halb * 0.25), halb * 0.28, 0.1, + Color(0.30, 0.50, 0.72), false) + for i in range(26): + var p := Vector3(rng.randf_range(-halb, halb), 0, rng.randf_range(-halb, halb)) + if p.distance_to(Vector3(-halb * 0.4, 0, halb * 0.25)) < halb * 0.38: continue - var hoehe := rng.randf_range(2.4, 4.2) + if p.distance_to(Vector3(0, 0, 12.0)) < 6.0: + continue + var hoehe := rng.randf_range(2.6, 5.0) Prims.cyl(self, p + Vector3(0, hoehe * 0.5, 0), 0.3, hoehe, Color(0.35, 0.26, 0.18)) - Prims.sphere(self, p + Vector3(0, hoehe + 0.8, 0), rng.randf_range(1.2, 1.9), - Color(0.25, 0.5 + rng.randf() * 0.2, 0.22)) - for i in range(4): - var a := TAU * float(i) / 4.0 + 0.4 - var p := Vector3(cos(a) * 8.0, 0.45, sin(a) * 8.0 + 2.0) + Prims.sphere(self, p + Vector3(0, hoehe + 0.9, 0), rng.randf_range(1.3, 2.2), + Color(0.24, 0.48 + rng.randf() * 0.22, 0.22)) + for i in range(5): + var a := TAU * float(i) / 5.0 + 0.4 + var p := Vector3(cos(a) * halb * 0.62, 0.45, sin(a) * halb * 0.62) var b := Prims.box(self, p, Vector3(2.4, 0.15, 0.7), Color(0.55, 0.40, 0.28)) b.rotation.y = -a - Prims.label(self, Vector3(0, 5.0, 10.0), "Stadtpark", 0.55, Color(1, 1, 0.9)) + Prims.label(self, Vector3(0, 5.0, halb - 3.0), "Stadtpark", 0.6, Color(1, 1, 0.9)) func _saeule() -> void: @@ -167,7 +203,7 @@ func _menschen() -> void: for i in range(HumanDB.LIST.size()): var m := Bewohner.new() add_child(m) - m.setup(HumanDB.LIST[i], i, HumanDB.PLACES) + m.setup(HumanDB.LIST[i], i, HumanDB.PLACES, HumanDB.NETZ, HumanDB.ANKER) humans.append(m) diff --git a/scripts/game.gd b/scripts/game.gd index c538d7c..b2d4a1a 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -9,22 +9,40 @@ const DAY_SECONDS := 360.0 # eine Erdenrunde in Echtzeit-Sekunden const XP_LEVELS := [0, 4, 10, 18, 28] const MISSION_ZIEL := 100.0 const RESIST_SEKUNDEN := 18.0 +## Drei Stufen wie in Die Sims: normal, schnell, sehr schnell. Die Stufe ist +## nicht der Faktor — Stufe 2 läuft dreifach, Stufe 3 siebenfach. +const TEMPO_FAKTOR := [1.0, 3.0, 7.0] +const TECHNIK_COOLDOWN := 30.0 # eine Technik braucht Luft, bevor sie wieder greift +const FEHLVERSUCH_VERLUST := 14.0 # ein Fehlschlag wirft den Traum zurück +# Der Verlust ist mit Absicht so hoch, dass sich nur eine Technik lohnt, die +# Thema UND Moment trifft. Wer eine Regel blind auf jede Tätigkeit hängt, +# verliert unterm Strich — die Wahl muss aus der Technik gelesen werden. -var einsicht: int = 3 var known: Dictionary = {} # tech_id -> xp var active_mission: String = "" # human_id oder "" var progress: Dictionary = {} # human_id -> 0..100 var used: Dictionary = {} # "human:tech" -> Anzahl +var cooldown: Dictionary = {} # tech_id -> Restsekunden var pipeline: Dictionary = {} # human_id -> {activity -> tech_id} var completed: Array = [] var time_h: float = 7.0 var day: int = 1 var teleport_lock: float = 0.0 +var tempo: int = 1 # 1, 2 oder 3 -- vom Spieler gewählt func _ready() -> void: _setup_input() welt_erzeugen() + _startweg_lernen() + + +## Den ersten Weg bringt das Wesen schon mit. Ohne ihn hinge am Schwarzen Brett +## nichts aus, und der erste Gang wäre eine Pflichtübung. +func _startweg_lernen() -> void: + for id in DreamDB.techniken_von("hoersaal"): + if not known.has(str(id)): + known[str(id)] = 0 func _setup_input() -> void: @@ -37,6 +55,9 @@ func _setup_input() -> void: "sinken": [KEY_SHIFT], "interagieren": [KEY_E], "journal": [KEY_J, KEY_TAB], + "tempo_1": [KEY_1, KEY_KP_1], + "tempo_2": [KEY_2, KEY_KP_2], + "tempo_3": [KEY_3, KEY_KP_3], } for action in binds: if not InputMap.has_action(action): @@ -47,7 +68,47 @@ func _setup_input() -> void: InputMap.action_add_event(action, ev) +## Schläft der Mensch, dem du gerade hilfst? Dann ist ohnehin nichts zu holen. +func schlaeft_ziel() -> bool: + if active_mission == "": + return false + var h := HumanDB.by_id(active_mission) + if h.is_empty(): + return false + var r: Array = h["routine"] + var cur: Array = r[r.size() - 1] + for e in r: + if time_h >= float(e[0]): + cur = e + return not erreichbar(str(cur[2])) + + +## Um wie viel schneller läuft die Welt gerade? Über der Nacht des Menschen +## läuft sie immer auf der höchsten Stufe -- warten bringt dort nichts. +func zeit_faktor() -> float: + return faktor_von(3 if schlaeft_ziel() else tempo) + + +static func faktor_von(stufe: int) -> float: + return float(TEMPO_FAKTOR[clampi(stufe, 1, 3) - 1]) + + +func tempo_setzen(stufe: int) -> void: + var neu := clampi(stufe, 1, 3) + if neu == tempo: + return + tempo = neu + notified.emit("Tempo %d — %d×" % [tempo, int(faktor_von(tempo))], Color(0.8, 0.9, 1)) + state_changed.emit() + + func advance_time(delta: float) -> void: + for id in cooldown.keys(): + var rest: float = float(cooldown[id]) - delta + if rest <= 0.0: + cooldown.erase(id) + else: + cooldown[id] = rest time_h += delta * 24.0 / DAY_SECONDS while time_h >= 24.0: time_h -= 24.0 @@ -75,7 +136,6 @@ func time_string() -> String: # lehrt eine Technik, die es vorher nirgends gab. const INSEL_GRUND := 46.0 -const OFFENE_MISSIONEN := 3 func haeuser() -> int: @@ -83,14 +143,13 @@ func haeuser() -> int: func insel_radius() -> float: - var ring := DreamDB.aeusserster_ring(haeuser()) - return maxf(INSEL_GRUND, ring + 14.0) + return maxf(INSEL_GRUND, DreamDB.aussenradius() + 12.0) ## Baut Traumdorf und Menschheit auf den Stand der erfüllten Träume. func welt_erzeugen() -> void: DreamDB.erzeuge(haeuser()) - HumanDB.erzeuge(completed.size() + OFFENE_MISSIONEN) + HumanDB.erzeuge() ## Das Traumdorf beginnt leer. Mit jedem erfüllten Traum zieht ein Wesen ein. @@ -117,7 +176,21 @@ func offene_techniken() -> int: return n +# ------------------------------------------------------------------- Cooldown +# Jede Anwendung sperrt ihre Technik für eine Weile. Wer einen ganzen Tag +# abdecken will, braucht deshalb mehrere Wege — einer allein kommt nicht nach. + +func cooldown_rest(tech_id: String) -> float: + return float(cooldown.get(tech_id, 0.0)) + + +func bereit(tech_id: String) -> bool: + return cooldown_rest(tech_id) <= 0.0 + + # ---------------------------------------------------------------- Fertigkeiten +# Gelernt wird an einem Lehrort, besser wird eine Technik nur durch Anwendung. +# Es gibt keinen Weg, sie am Schreibtisch zu trainieren. func knows(tech_id: String) -> bool: return known.has(tech_id) @@ -149,10 +222,6 @@ func learn(tech_id: String) -> bool: notified.emit("Diesen Weg kennt im Dorf noch niemand. Erfülle einen Traum.", Color(1, 0.8, 0.5)) return false - if einsicht < 1: - notified.emit("Dafür fehlt dir Einsicht. Hilf erst einem Menschen.", Color(1, 0.6, 0.4)) - return false - einsicht -= 1 known[tech_id] = 0 var t := TechDB.by_id(tech_id) notified.emit("Technik gelernt: %s" % t["name"], Color(0.6, 1, 0.7)) @@ -171,18 +240,6 @@ func add_xp(tech_id: String, amount: int) -> void: state_changed.emit() -func deepen(tech_id: String) -> bool: - if not known.has(tech_id): - return false - if einsicht < 1: - notified.emit("Keine Einsicht übrig.", Color(1, 0.6, 0.4)) - return false - einsicht -= 1 - add_xp(tech_id, 3) - notified.emit("Vertieft: %s (+3 EP)" % TechDB.by_id(tech_id)["name"], Color(0.7, 0.9, 1)) - return true - - # ------------------------------------------------------------------ Missionen func accept_mission(human_id: String) -> bool: @@ -204,6 +261,40 @@ func angenommen(human_id: String) -> bool: return progress.has(human_id) +## Eine Mission ist machbar, wenn mindestens eine gelernte Technik das Thema +## des Menschen trifft und in seinem Tag auch einen Moment dafür findet. +## Nur solche werden am Schwarzen Brett überhaupt ausgehängt. +func machbar(human_id: String) -> bool: + var h := HumanDB.by_id(human_id) + if h.is_empty(): + return false + var tag_liste: Array = HumanDB.aktivitaeten(human_id) + for id in known: + var t := TechDB.by_id(id) + var thema := false + for tag in t["tags"]: + if (h["tags"] as Array).has(tag): + thema = true + break + if not thema: + continue + for a in t["momente"]: + if tag_liste.has(a) and erreichbar(a): + return true + return false + + +## Die Menschen, die gerade am Brett hängen können. +func offene_missionen() -> Array: + var out: Array = [] + for h in HumanDB.LIST: + var id: String = h["id"] + if completed.has(id) or not machbar(id): + continue + out.append(id) + return out + + func mission_progress(human_id: String) -> float: return progress.get(human_id, 0.0) @@ -242,17 +333,17 @@ func regel_anzahl(human_id: String) -> int: return regeln(human_id).size() -## Wo hängt diese Technik im Regelwerk gerade? "" wenn nirgends. -func regel_moment(human_id: String, tech_id: String) -> String: +## An welchen Tätigkeiten hängt diese Technik gerade? +func regel_momente(human_id: String, tech_id: String) -> Array: + var out: Array = [] for activity in regeln(human_id): if regeln(human_id)[activity] == tech_id: - return activity - return "" + out.append(activity) + return out -## Leere tech_id löscht die Regel. Eine Technik hält immer nur einen Posten — -## sie an einen neuen Moment zu hängen nimmt sie vom alten weg. Das zwingt zur -## Wahl und macht das Lernen weiterer Techniken erst wertvoll. +## Leere tech_id löscht die Regel. Eine Technik darf an beliebig vielen +## Tätigkeiten hängen — ob das klug ist, muss der Spieler aus ihr herauslesen. func regel_setzen(human_id: String, activity: String, tech_id: String) -> void: if not pipeline.has(human_id): pipeline[human_id] = {} @@ -260,23 +351,20 @@ func regel_setzen(human_id: String, activity: String, tech_id: String) -> void: if tech_id == "": r.erase(activity) else: - var vorher := regel_moment(human_id, tech_id) - if vorher != "" and vorher != activity: - r.erase(vorher) - notified.emit("%s hängt jetzt an »%s« statt an »%s«." % - [TechDB.by_id(tech_id)["name"], Orte.text(activity), Orte.text(vorher)], - Color(0.85, 0.9, 1)) r[activity] = tech_id state_changed.emit() -## Der Mensch wechselt in eine Tätigkeit: greift dafür eine Regel? +## Greift für diese Tätigkeit eine Regel? Wird nicht nur beim Wechsel gefragt, +## sondern immer wieder — solange der Moment anhält und die Technik frei ist. func regel_ausloesen(human_id: String, activity: String, resisting: bool) -> Dictionary: if human_id != active_mission: return {} var tech_id := regel_fuer(human_id, activity) if tech_id == "" or not known.has(tech_id): return {} + if not bereit(tech_id): + return {} # still: die Technik greift wieder, sobald ihre Sperre fällt var res := apply_technique(tech_id, human_id, activity, resisting) if not res["ok"]: return {} @@ -287,8 +375,8 @@ func regel_ausloesen(human_id: String, activity: String, resisting: bool) -> Dic [t["name"], Orte.text(activity), int(round(float(res["gain"])))], Color(0.6, 1, 0.7)) else: - notified.emit("Regel läuft ins Leere — %s bei »%s« (%d %% Chance)" % - [t["name"], Orte.text(activity), int(round(float(res["chance"]) * 100.0))], + notified.emit("Regel läuft ins Leere — %s bei »%s« (−%d %%)" % + [t["name"], Orte.text(activity), int(round(float(res["verlust"])))], Color(1, 0.7, 0.5)) return res @@ -324,11 +412,13 @@ func evaluate(tech_id: String, human_id: String, activity: String, resisting: bo break res["moment"] = t["momente"].has(activity) - var c := 0.08 + 0.11 * float(skill) + # Der Moment wiegt am schwersten: eine Technik zur falschen Zeit greift kaum, + # egal wie gut sie zum Menschen passt. Genau das steht in ihrem "Wann". + var c := 0.05 + 0.09 * float(skill) if res["tag"]: - c += 0.25 + c += 0.20 if res["moment"]: - c += 0.22 + c += 0.35 if resisting: c -= 0.30 res["grund"] = "Der Mensch macht gerade dicht." @@ -338,9 +428,13 @@ func evaluate(tech_id: String, human_id: String, activity: String, resisting: bo ## Wendet die Technik wirklich an: würfelt, verteilt EP und Fortschritt. func apply_technique(tech_id: String, human_id: String, activity: String, resisting: bool) -> Dictionary: + if not bereit(tech_id): + return {"ok": false, "grund": "%s ist noch nicht wieder einsatzbereit." % + TechDB.by_id(tech_id)["name"]} var ev := evaluate(tech_id, human_id, activity, resisting) if ev["blocked"]: return {"ok": false, "grund": ev["grund"]} + cooldown[tech_id] = TECHNIK_COOLDOWN var roll := randf() var success: bool = roll < float(ev["chance"]) @@ -349,6 +443,7 @@ func apply_technique(tech_id: String, human_id: String, activity: String, resist used[key] = wiederholung + 1 var gain := 0.0 + var verlust := 0.0 if success: var base := 8.0 + 2.0 * float(ev["skill"]) if ev["tag"]: @@ -358,25 +453,25 @@ func apply_technique(tech_id: String, human_id: String, activity: String, resist gain = base / (1.0 + 0.6 * float(wiederholung)) add_xp(tech_id, 2) else: + verlust = FEHLVERSUCH_VERLUST add_xp(tech_id, 1) var done := false - if human_id == active_mission and gain > 0.0: - var p: float = progress.get(human_id, 0.0) + gain - progress[human_id] = minf(p, MISSION_ZIEL) + if human_id == active_mission: + var p: float = progress.get(human_id, 0.0) + gain - verlust + progress[human_id] = clampf(p, 0.0, MISSION_ZIEL) if progress[human_id] >= MISSION_ZIEL and not completed.has(human_id): completed.append(human_id) active_mission = "" - einsicht += 3 done = true mission_done.emit(human_id) - notified.emit("%s hat es geschafft. (+3 Einsicht)" % HumanDB.by_id(human_id)["name"], + notified.emit("%s hat es geschafft." % HumanDB.by_id(human_id)["name"], Color(1, 0.85, 0.4)) _insel_waechst() state_changed.emit() return { "ok": true, "success": success, "chance": ev["chance"], "gain": gain, - "wiederholung": wiederholung, "mission_done": done, + "verlust": verlust, "wiederholung": wiederholung, "mission_done": done, "tag": ev["tag"], "moment": ev["moment"], } diff --git a/scripts/human_db.gd b/scripts/human_db.gd index a8dcd3c..b1d9879 100644 --- a/scripts/human_db.gd +++ b/scripts/human_db.gd @@ -1,40 +1,38 @@ class_name HumanDB -## Die Kleinstadt auf der Erde. Die Gebäude im Ring um den Park stehen fest; -## Wohnhäuser und ihre Bewohner kommen dazu, solange Menschen Hilfe brauchen. +## Die Erde: ein Stadtbezirk nach japanischem Vorbild. Weite Arterien in einem +## groben Raster spannen Blöcke auf. Am Blockrand stehen die Häuser direkt an +## der Straße, ohne Vorgarten. Ins Blockinnere führen schmale Sackgassen -- +## roji --, an denen die hinteren Parzellen hängen. So entsteht die typische +## Mischung aus klarer Ordnung außen und Gewinkel innen. +## +## Anders als das Traumdorf wächst hier nichts: alle Menschen sind von Anfang +## an da und leben ihren Tag. -const HAEUSER_JE_RING := 8 -const RING_ERSTER := 62.0 -const RING_ABSTAND := 16.0 +const EINWOHNER := 16 +const ACHSEN := [-72.0, -24.0, 24.0, 72.0] # Arterien in x wie in z +const ARTERIE := 7.0 # Breite einer Arterie +const ROJI := 3.2 # Breite einer Gasse +const RAND_TIEFE := 11.0 # Tiefe der Parzellen an der Straße +const PARZELLE_MIN := 8.0 # schmalste Parzelle +const PARZELLE_MAX := 12.0 -## Gebäude liegen im Ring um den Park. Alle Türen zeigen zur Mitte. -static var FEST: Dictionary = { - "uni": { - "name": "Hochschule", "pos": [-28.0, -27.0], "size": [14.0, 10.0], - "dir": "back", "color": Color(0.72, 0.70, 0.66), - }, - "cafe": { - "name": "Café Nadel", "pos": [0.0, -27.0], "size": [12.0, 10.0], - "dir": "back", "color": Color(0.82, 0.62, 0.45), - }, - "buero": { - "name": "Büro Ostwind", "pos": [28.0, -27.0], "size": [14.0, 10.0], - "dir": "back", "color": Color(0.62, 0.66, 0.72), - }, - "baeckerei": { - "name": "Bäckerei Keller", "pos": [-31.0, 0.0], "size": [10.0, 12.0], - "dir": "right", "color": Color(0.85, 0.72, 0.50), - }, - "werkstatt": { - "name": "Werkstatt Loos", "pos": [31.0, 0.0], "size": [10.0, 12.0], - "dir": "left", "color": Color(0.70, 0.58, 0.55), - }, - "park": { - "name": "Stadtpark", "pos": [0.0, 4.0], "size": [0.0, 0.0], - "dir": "none", "color": Color(0.45, 0.65, 0.38), - }, -} +## Alle Parzellen des Bezirks: {pos, size, dir, strasse} +static var PARZELLEN: Array = [] +## Alle Straßenbänder: {a, b, breite} +static var STRASSEN: Array = [] +static var NETZ: Netz = Netz.new() +static var ANKER: Dictionary = {} -static var PLACES: Dictionary = FEST.duplicate(true) +## Die fünf Sonderbauten. Sie bekommen eine Parzelle an einer Arterie. +static var SONDER: Array = [ + ["uni", "Hochschule", Color(0.72, 0.70, 0.66)], + ["cafe", "Café Nadel", Color(0.82, 0.62, 0.45)], + ["buero", "Büro Ostwind", Color(0.62, 0.66, 0.72)], + ["baeckerei", "Bäckerei Keller", Color(0.85, 0.72, 0.50)], + ["werkstatt", "Werkstatt Loos", Color(0.70, 0.58, 0.55)], +] + +static var PLACES: Dictionary = {} static var LIST: Array = [] ## Arbeitsplätze, die ein erzeugter Mensch bekommen kann. @@ -125,46 +123,299 @@ static var ABLAEUFE: Array = [ ] +## Zu jeder Technik ein Anliegen und ein Tagesablauf, bei dem sie greift: +## [Anliegen, Ablauf]. Mensch i bekommt den Eintrag zu Technik i — und Technik i +## ist genau dann entdeckt, wenn i Träume erfüllt sind. So hängt am Schwarzen +## Brett immer mindestens ein Mensch, dem mit dem Gelernten zu helfen ist. +static var PASSEND: Array = [ + [0, 0], # Pomodoro -> aufschieben, zögert + [6, 4], # Zwei-Minuten -> Werkstatt, räumt auf + [4, 3], # Wenn-Dann -> Gewohnheit, Morgenroutine + [1, 1], # Gewohnheiten -> Klavier, isst / Abendroutine + [9, 0], # Zerlegen -> Überforderung, grübelt + [0, 4], # Umgebung -> Ablenkung, räumt auf + [8, 0], # Umdeuten -> Selbstzweifel, grübelt + [7, 2], # Box-Atmung -> Angst, angespannt + [8, 2], # Selbstmitgefühl -> Rückschlag, grübelt + [4, 1], # Schlaffenster -> Erschöpfung, ruht + [5, 2], # Hilfe bitten -> Einsamkeit, wartet + [3, 3], # Nein sagen -> Grenzen, arbeitet +] + + static func heim_key(i: int) -> String: return "heim_%d" % i -## Baut Wohnhäuser und Menschen für so viele Bewohner neu auf. -static func erzeuge(menschen: int) -> void: - PLACES = FEST.duplicate(true) +## Baut den ganzen Bezirk neu auf: Straßen, Parzellen, Sonderbauten, Wohnhäuser. +## Die Zahl der Menschen liegt fest -- sie sind alle von Anfang an da. +static func erzeuge(_ungenutzt: int = 0) -> void: + _bezirk() + PLACES = {} LIST = [] - for i in range(maxi(menschen, 0)): - PLACES[heim_key(i)] = _heim(i) + _park_eintragen() + _sonderbauten_verteilen() + _wohnhaeuser_verteilen() + _anker_setzen() + for i in range(EINWOHNER): LIST.append(_mensch(i)) -static func _heim(i: int) -> Dictionary: - var ring := i / HAEUSER_JE_RING - var winkel := float(i % HAEUSER_JE_RING) * TAU / float(HAEUSER_JE_RING) + 0.39 - var r := RING_ERSTER + float(ring) * RING_ABSTAND - var x := sin(winkel) * r - var z := cos(winkel) * r - return { - "name": "%s %s" % [_nachname(i), "Weg %d" % (i + 1)], - "pos": [x, z], - "size": [12.0 + float(i % 3) * 2.0, 10.0], - "dir": _tuer_zur_mitte(x, z), - "color": Color.from_hsv(fmod(0.08 + float(i) * 0.113, 1.0), 0.28, 0.78), +# ------------------------------------------------------------ Bezirk + +## Arterien im Raster, Blöcke dazwischen, roji ins Blockinnere, Parzellen +## an jeder Straße. Danach steht das Straßennetz, auf dem alle laufen. +static func _bezirk() -> void: + PARZELLEN = [] + STRASSEN = [] + NETZ = Netz.new() + ANKER = {} + var rng := RandomNumberGenerator.new() + rng.seed = 31337 + + # Arterien: die Achsen leicht versetzt, damit kein reines Schachbrett steht + var xs: Array = [] + var zs: Array = [] + for i in range(ACHSEN.size()): + xs.append(float(ACHSEN[i]) + rng.randf_range(-3.0, 3.0)) + zs.append(float(ACHSEN[i]) + rng.randf_range(-3.0, 3.0)) + var von: float = float(ACHSEN[0]) - 6.0 + var bis: float = float(ACHSEN[ACHSEN.size() - 1]) + 6.0 + for x in xs: + _strasse(Vector3(x, 0, von), Vector3(x, 0, bis), ARTERIE) + for z in zs: + _strasse(Vector3(von, 0, z), Vector3(bis, 0, z), ARTERIE) + + # Blöcke zwischen den Arterien + for ix in range(xs.size() - 1): + for iz in range(zs.size() - 1): + var x0: float = float(xs[ix]) + ARTERIE * 0.5 + var x1: float = float(xs[ix + 1]) - ARTERIE * 0.5 + var z0: float = float(zs[iz]) + ARTERIE * 0.5 + var z1: float = float(zs[iz + 1]) - ARTERIE * 0.5 + if ix == 1 and iz == 1: + continue # die Mitte bleibt Park + _block(Vector3((x0 + x1) * 0.5, 0, (z0 + z1) * 0.5), + Vector2(x1 - x0, z1 - z0), rng) + _netz_bauen() + + +static func _strasse(a: Vector3, b: Vector3, breite: float) -> void: + STRASSEN.append({"a": a, "b": b, "breite": breite}) + + +## Aus den Straßenbändern ein zusammenhängendes Netz machen: an jeder Kreuzung +## sitzt ein Knoten, dazwischen alle 16 Meter einer. +static func _netz_bauen() -> void: + for i in range(STRASSEN.size()): + var a: Vector3 = STRASSEN[i]["a"] + var b: Vector3 = STRASSEN[i]["b"] + var laenge := a.distance_to(b) + if laenge < 0.5: + continue + var stellen: Array = [0.0, 1.0] + for j in range(STRASSEN.size()): + if j == i: + continue + var t := _kreuzung(a, b, STRASSEN[j]["a"], STRASSEN[j]["b"]) + if t >= 0.0: + stellen.append(t) + stellen.sort() + var fein: Array = [float(stellen[0])] + for k in range(1, stellen.size()): + var von: float = float(fein[fein.size() - 1]) + var bis: float = float(stellen[k]) + if bis - von < 0.001: + continue + var n := maxi(1, int((bis - von) * laenge / 16.0)) + for m in range(1, n + 1): + fein.append(von + (bis - von) * float(m) / float(n)) + var vorher := -1 + for t2 in fein: + var idx := NETZ.fuege_knoten(a.lerp(b, float(t2))) + if vorher >= 0: + NETZ.verbinde(vorher, idx) + vorher = idx + + +## Parameter t auf der Strecke a-b, an dem sie c-d schneidet. -1 wenn nicht. +static func _kreuzung(a: Vector3, b: Vector3, c: Vector3, d: Vector3) -> float: + var r := b - a + var s2 := d - c + var nenner := r.x * s2.z - r.z * s2.x + if absf(nenner) < 0.0001: + return -1.0 + var t := ((c.x - a.x) * s2.z - (c.z - a.z) * s2.x) / nenner + var u := ((c.x - a.x) * r.z - (c.z - a.z) * r.x) / nenner + if t < 0.0 or t > 1.0 or u < 0.0 or u > 1.0: + return -1.0 + return t + + +## Ein Block: außen ein Kranz von Parzellen an den Arterien, innen eine Gasse +## mit den hinteren Parzellen daran. Die Nord- und Südreihe laufen über die +## volle Breite, die Ost- und Westreihe nur dazwischen -- sonst überlappen die +## Ecken. +static func _block(mitte: Vector3, groesse: Vector2, rng: RandomNumberGenerator) -> void: + var tiefe := minf(RAND_TIEFE, minf(groesse.x, groesse.y) * 0.34) + var seiten := [ + ["back", Vector3(0, 0, 1), groesse.x, groesse.y], + ["forward", Vector3(0, 0, -1), groesse.x, groesse.y], + ["right", Vector3(1, 0, 0), groesse.y - tiefe * 2.0, groesse.x], + ["left", Vector3(-1, 0, 0), groesse.y - tiefe * 2.0, groesse.x], + ] + for eintrag in seiten: + var dir: String = eintrag[0] + var n: Vector3 = eintrag[1] + var laenge: float = eintrag[2] + var quer_mass: float = eintrag[3] + if laenge < PARZELLE_MIN: + continue + var quer := Vector3(absf(n.z), 0, absf(n.x)) + var anzahl := maxi(1, int(round(laenge / rng.randf_range(PARZELLE_MIN + 1.0, + PARZELLE_MAX)))) + var breite := laenge / float(anzahl) + var rand := mitte + n * (quer_mass * 0.5 - tiefe * 0.5) + for k in range(anzahl): + var versatz := (float(k) + 0.5 - float(anzahl) * 0.5) * breite + var quer_ist := absf(n.z) > 0.0 + PARZELLEN.append({ + "pos": rand + quer * versatz, + "size": Vector2(breite - 0.8, tiefe - 0.8) if quer_ist + else Vector2(tiefe - 0.8, breite - 0.8), + "dir": dir, + }) + + # Blockinneres: eine Gasse von der längeren Seite hinein + var innen := groesse - Vector2(tiefe, tiefe) * 2.0 + if innen.x < 12.0 or innen.y < 12.0: + return + var laengs := innen.y >= innen.x + var richtung := Vector3(0, 0, -1) if laengs else Vector3(-1, 0, 0) + # Die Gasse setzt an der Arterie an, sonst hinge sie in der Luft + var aussen: float = (groesse.y if laengs else groesse.x) * 0.5 + ARTERIE * 0.5 + var start := mitte - richtung * aussen + var ende := mitte + richtung * ((innen.y if laengs else innen.x) * 0.5 - 2.0) + _strasse(start, ende, ROJI) + var flanke := Vector3(1, 0, 0) if laengs else Vector3(0, 0, 1) + var p_breite := (innen.x if laengs else innen.y) * 0.5 - ROJI * 0.5 - 0.8 + if p_breite < PARZELLE_MIN * 0.8: + return + # Nur der Teil im Blockinneren trägt Parzellen + var innen_start := mitte - richtung * ((innen.y if laengs else innen.x) * 0.5) + var laenge_g := innen_start.distance_to(ende) + var anzahl_g := maxi(1, int(round(laenge_g / 13.0))) + var tief_g := laenge_g / float(anzahl_g) - 0.8 + for seite: float in [1.0, -1.0]: + for k in range(anzahl_g): + var t := (float(k) + 0.5) / float(anzahl_g) + var pos := innen_start.lerp(ende, t) + flanke * (seite * (ROJI * 0.5 + p_breite * 0.5)) + PARZELLEN.append({ + "pos": pos, + "size": Vector2(p_breite, tief_g) if laengs else Vector2(tief_g, p_breite), + "dir": ("left" if seite > 0.0 else "right") if laengs + else ("forward" if seite > 0.0 else "back"), + }) + + +# ------------------------------------------------------------ Belegung + +static func _park_eintragen() -> void: + PLACES["park"] = { + "name": "Stadtpark", "pos": [0.0, 0.0], "size": [0.0, 0.0], + "dir": "none", "color": Color(0.45, 0.65, 0.38), } -static func _tuer_zur_mitte(x: float, z: float) -> String: - if absf(x) > absf(z): - return "left" if x > 0.0 else "right" - return "forward" if z > 0.0 else "back" +## Die Sonderbauten bekommen Parzellen an einer Arterie, gut verteilt. +static func _sonderbauten_verteilen() -> void: + var kandidaten: Array = [] + for i in range(PARZELLEN.size()): + var p: Dictionary = PARZELLEN[i] + if minf(p["size"].x, p["size"].y) >= 10.0: + kandidaten.append(i) + for s in range(SONDER.size()): + if kandidaten.is_empty(): + break + # Das Café gehört in die Mitte -- jeder Tagesablauf führt dorthin. + # Die übrigen Sonderbauten verteilen sich rundum. + var wunsch := Vector3.ZERO + if str(SONDER[s][0]) != "cafe": + var w := TAU * float(s) / float(SONDER.size() - 1) + wunsch = Vector3(cos(w), 0, sin(w)) * 42.0 + var idx: int = -1 + var beste := INF + for k in kandidaten: + var q: Dictionary = PARZELLEN[k] + if q.has("belegt"): + continue + var d: float = (q["pos"] as Vector3).distance_to(wunsch) + if d < beste: + beste = d + idx = int(k) + if idx < 0: + continue + var p: Dictionary = PARZELLEN[idx] + p["belegt"] = true + PLACES[str(SONDER[s][0])] = { + "name": str(SONDER[s][1]), + "pos": [p["pos"].x, p["pos"].z], + "size": [p["size"].x, p["size"].y], + "dir": p["dir"], + "color": SONDER[s][2], + } + + +## Die Wohnhäuser: gleichmäßig über den Bezirk verteilt, damit die Wege +## nicht alle in derselben Ecke enden. +static func _wohnhaeuser_verteilen() -> void: + var frei: Array = [] + for i in range(PARZELLEN.size()): + if not PARZELLEN[i].has("belegt"): + frei.append(i) + if frei.is_empty(): + return + for i in range(EINWOHNER): + var idx: int = int(frei[(i * 11 + 5) % frei.size()]) + var p: Dictionary = PARZELLEN[idx] + if p.has("belegt"): + for versuch in range(frei.size()): + idx = int(frei[(i * 11 + 5 + versuch) % frei.size()]) + p = PARZELLEN[idx] + if not p.has("belegt"): + break + p["belegt"] = true + PLACES[heim_key(i)] = { + "name": "%s %d" % [_nachname(i), i + 1], + "pos": [p["pos"].x, p["pos"].z], + "size": [p["size"].x, p["size"].y], + "dir": p["dir"], + "color": Color.from_hsv(fmod(0.08 + float(i) * 0.113, 1.0), 0.28, 0.78), + } + + +static func _anker_setzen() -> void: + for key in PLACES: + var s: Array = PLACES[key]["size"] + var tuer := Orte.door(PLACES, key) + if float(s[0]) <= 0.0: + tuer = Orte.center(PLACES, key) + ANKER[key] = int(NETZ.andocken(tuer)["knoten"]) static func _mensch(i: int) -> Dictionary: - var anliegen: Dictionary = ANLIEGEN[i % ANLIEGEN.size()] - var arbeit: String = str(ARBEIT[i % ARBEIT.size()]) + # Bis alle Wege entdeckt sind, folgt jeder Mensch dem Weg gleicher Nummer. + # Danach mischt sich das Feld wieder. + var a_nr: int = i % ANLIEGEN.size() + var b_nr: int = i % ABLAEUFE.size() + if i < PASSEND.size(): + a_nr = int(PASSEND[i][0]) + b_nr = int(PASSEND[i][1]) + var anliegen: Dictionary = ANLIEGEN[a_nr] var heim := heim_key(i) + var arbeit := _naechster_arbeitsplatz(heim, i) var ablauf: Array = [] - for e in ABLAEUFE[i % ABLAEUFE.size()]: + for e in ABLAEUFE[b_nr]: var ort: String = str(e[1]) if ort == "heim": ort = heim @@ -186,6 +437,24 @@ static func _mensch(i: int) -> Dictionary: } +## Wer wo wohnt, arbeitet in der Nähe. Sonst laufen alle quer durch den Bezirk +## und kommen nie an. +static func _naechster_arbeitsplatz(heim: String, i: int) -> String: + if not PLACES.has(heim): + return str(ARBEIT[i % ARBEIT.size()]) + var von := Orte.center(PLACES, heim) + var beste := INF + var ziel := str(ARBEIT[i % ARBEIT.size()]) + for a in ARBEIT: + if not PLACES.has(a): + continue + var d := Orte.center(PLACES, str(a)).distance_to(von) + if d < beste: + beste = d + ziel = str(a) + return ziel + + static func _vorname(i: int) -> String: return str(VORNAMEN[i % VORNAMEN.size()]) diff --git a/scripts/main.gd b/scripts/main.gd index 8e84917..6894550 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -65,7 +65,7 @@ func _setze_welt(neu: Node3D) -> void: func _process(delta: float) -> void: - Game.advance_time(delta) + Game.advance_time(delta * Game.zeit_faktor()) Game.teleport_lock = maxf(0.0, Game.teleport_lock - delta) if ui.offen: ui.setze_hinweis("") @@ -132,6 +132,11 @@ func _unhandled_input(event: InputEvent) -> void: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED player.look_enabled = true return + for stufe in [1, 2, 3]: + if event.is_action_pressed("tempo_%d" % stufe): + Game.tempo_setzen(stufe) + get_viewport().set_input_as_handled() + return if event.is_action_pressed("journal"): ui.journal_panel() get_viewport().set_input_as_handled() diff --git a/scripts/netz.gd b/scripts/netz.gd new file mode 100644 index 0000000..e10b08d --- /dev/null +++ b/scripts/netz.gd @@ -0,0 +1,141 @@ +class_name Netz +extends RefCounted +## Ein Wegenetz: Knoten und Kanten in der Ebene. Wege wachsen daran, indem +## neue Punkte an die nächste Stelle des bestehenden Netzes angebunden werden. +## Wiederverwendung ist gratis -- deshalb bündeln sich Wege und gabeln sich, +## statt sternförmig zur Mitte zu laufen. + +var knoten: Array[Vector3] = [] +var kanten: Array = [] # [a, b] Indexpaare + + +func fuege_knoten(p: Vector3) -> int: + var q := Vector3(p.x, 0.0, p.z) + for i in range(knoten.size()): + if knoten[i].distance_to(q) < 0.6: + return i + knoten.append(q) + return knoten.size() - 1 + + +func verbinde(a: int, b: int) -> void: + if a == b: + return + for k in kanten: + if (k[0] == a and k[1] == b) or (k[0] == b and k[1] == a): + return + kanten.append([a, b]) + + +func nachbarn(i: int) -> Array: + var out: Array = [] + for k in kanten: + if k[0] == i: + out.append(k[1]) + elif k[1] == i: + out.append(k[0]) + return out + + +## Nächster Punkt auf einer Kante -- gibt {knoten, dist} zurück und teilt die +## Kante, falls der Fußpunkt zwischen zwei Knoten liegt. +func andocken(p: Vector3) -> Dictionary: + var q := Vector3(p.x, 0.0, p.z) + var beste := INF + var best_i := -1 + var best_k := -1 + var best_p := Vector3.ZERO + for i in range(knoten.size()): + var d := knoten[i].distance_to(q) + if d < beste: + beste = d + best_i = i + best_k = -1 + for ki in range(kanten.size()): + var a: Vector3 = knoten[kanten[ki][0]] + var b: Vector3 = knoten[kanten[ki][1]] + var f := Geometry3D.get_closest_point_to_segment(q, a, b) + var d := f.distance_to(q) + if d < beste - 0.01 and f.distance_to(a) > 1.5 and f.distance_to(b) > 1.5: + beste = d + best_k = ki + best_p = f + if best_k < 0: + return {"knoten": best_i, "dist": beste, "punkt": knoten[best_i]} + # Kante teilen + var alt: Array = kanten[best_k] + kanten.remove_at(best_k) + var neu := fuege_knoten(best_p) + verbinde(alt[0], neu) + verbinde(neu, alt[1]) + return {"knoten": neu, "dist": beste, "punkt": knoten[neu]} + + +## Kürzester Weg als Knotenfolge, leer wenn es keinen gibt. +func pfad(start: int, ziel: int) -> Array: + if start == ziel: + return [start] + var offen: Array = [start] + var kosten: Dictionary = {start: 0.0} + var her: Dictionary = {} + while not offen.is_empty(): + var best := 0 + for i in range(1, offen.size()): + if float(kosten[offen[i]]) < float(kosten[offen[best]]): + best = i + var k: int = offen[best] + offen.remove_at(best) + if k == ziel: + break + for n in nachbarn(k): + var neu: float = float(kosten[k]) + knoten[k].distance_to(knoten[n]) + if not kosten.has(n) or neu < float(kosten[n]) - 0.001: + kosten[n] = neu + her[n] = k + if not offen.has(n): + offen.append(n) + if not kosten.has(ziel): + return [] + var weg: Array = [ziel] + var cur: int = ziel + while cur != start: + cur = int(her[cur]) + weg.push_front(cur) + return weg + + +func weglaenge(start: int, ziel: int) -> float: + var w := pfad(start, ziel) + if w.size() < 2: + return 0.0 if w.size() == 1 else INF + var l := 0.0 + for i in range(w.size() - 1): + l += knoten[w[i]].distance_to(knoten[w[i + 1]]) + return l + + +## Hängen alle Knoten zusammen? +func zusammenhaengend() -> bool: + if knoten.is_empty(): + return true + var gesehen: Dictionary = {0: true} + var stapel: Array = [0] + while not stapel.is_empty(): + var k: int = stapel.pop_back() + for n in nachbarn(k): + if not gesehen.has(n): + gesehen[n] = true + stapel.append(n) + return gesehen.size() == knoten.size() + + +## Schneidet die Strecke a-b ein Rechteck (Mittelpunkt c, Größe s, mit Rand)? +static func trifft_rechteck(a: Vector3, b: Vector3, c: Vector3, s: Vector2, rand: float) -> bool: + var hx := s.x * 0.5 + rand + var hz := s.y * 0.5 + rand + var schritte := int(maxf(2.0, a.distance_to(b) / 1.5)) + for i in range(schritte + 1): + var p := a.lerp(b, float(i) / float(schritte)) + if absf(p.x - c.x) <= hx and absf(p.z - c.z) <= hz: + return true + return false diff --git a/scripts/netz.gd.uid b/scripts/netz.gd.uid new file mode 100644 index 0000000..5554327 --- /dev/null +++ b/scripts/netz.gd.uid @@ -0,0 +1 @@ +uid://dsaev7wupa8ex diff --git a/scripts/orte.gd b/scripts/orte.gd index 282ed85..c14001f 100644 --- a/scripts/orte.gd +++ b/scripts/orte.gd @@ -58,11 +58,22 @@ static func door(places: Dictionary, key: String) -> Vector3: return c + d * (half + 2.5) -## Route von Ort a nach Ort b: aus der Tür, über den freien Platz, wieder hinein. -static func route(places: Dictionary, a: String, b: String, offset: Vector3) -> Array: +## Route von Ort a nach Ort b. Gibt es ein Wegenetz, wird darauf gelaufen -- +## sonst über den freien Platz in der Mitte. +static func route(places: Dictionary, a: String, b: String, offset: Vector3, + netz: Netz = null, anker: Dictionary = {}) -> Array: var pts: Array = [] if a == b: return pts + if netz != null and anker.has(a) and anker.has(b): + var weg := netz.pfad(int(anker[a]), int(anker[b])) + if not weg.is_empty(): + for idx in weg: + var k: Vector3 = netz.knoten[idx] + if pts.is_empty() or pts[pts.size() - 1].distance_to(k) > 0.6: + pts.append(k) + pts.append(center(places, b) + offset) + return pts var da := door(places, a) var db := door(places, b) pts.append(da) diff --git a/scripts/technique_db.gd b/scripts/technique_db.gd index 58b7a76..7cf85d4 100644 --- a/scripts/technique_db.gd +++ b/scripts/technique_db.gd @@ -9,6 +9,7 @@ static var LIST: Array = [ "was": "25 Minuten am Stück an einer Sache arbeiten, dann 5 Minuten Pause. Nach vier Runden eine lange Pause.", "wann": "Wenn eine Aufgabe zu groß wirkt und der Anfang immer wieder verschoben wird.", "warum": "Ein klar begrenzter Zeitraum senkt die Einstiegshürde. Der Kopf muss nur 25 Minuten durchhalten, nicht den ganzen Tag.", + "woran": "jemandem, der zögert — oder der arbeitet und trotzdem nicht vorankommt.", "tags": ["prokrastination", "fokus"], "momente": ["arbeitet", "zoegert"], "farbe": Color(0.95, 0.45, 0.30), @@ -19,6 +20,7 @@ static var LIST: Array = [ "was": "Was unter zwei Minuten dauert, sofort erledigen. Große Vorhaben auf einen Zwei-Minuten-Anfang schrumpfen.", "wann": "Bei einem Berg von Kleinkram oder wenn schon der erste Schritt blockiert.", "warum": "Der Start kostet die meiste Energie. Zwei Minuten sind zu klein zum Ablehnen und ziehen meist die ganze Aufgabe nach sich.", + "woran": "jemandem, der zögert, lieber aufräumt als anzufangen, oder beim Arbeiten im Kleinkram hängt.", "tags": ["prokrastination", "ueberforderung"], "momente": ["zoegert", "aufraeumen", "arbeitet"], "farbe": Color(0.98, 0.72, 0.25), @@ -29,6 +31,7 @@ static var LIST: Array = [ "was": "Den Vorsatz an einen konkreten Auslöser binden: \"Wenn ich X tue, dann mache ich Y.\"", "wann": "Wenn ein Vorhaben im Alltag jedes Mal untergeht.", "warum": "Der Plan hängt an einer Situation statt an Willenskraft. Der Auslöser erinnert von allein.", + "woran": "der Morgenroutine und der Pause — den festen Punkten, an die sich ein Vorsatz hängen lässt.", "tags": ["gewohnheit", "prokrastination"], "momente": ["morgenroutine", "pause"], "farbe": Color(0.45, 0.80, 0.55), @@ -39,6 +42,7 @@ static var LIST: Array = [ "was": "Eine neue, kleine Gewohnheit direkt an eine bestehende anhängen.", "wann": "Wenn eine neue Routine nie einen Platz im Tag findet.", "warum": "Die alte Gewohnheit ist der Anker. Es muss kein neuer Zeitpunkt erfunden werden.", + "woran": "den Ankern des Tages: Morgenroutine, Essen, Abendroutine.", "tags": ["gewohnheit"], "momente": ["morgenroutine", "essen", "abendroutine"], "farbe": Color(0.40, 0.70, 0.85), @@ -49,6 +53,7 @@ static var LIST: Array = [ "was": "Ein großes Ziel in benennbare Einzelschritte teilen, bis der nächste Schritt trivial ist.", "wann": "Wenn jemand vor einem Vorhaben erstarrt und nicht weiß, wo er anfangen soll.", "warum": "Unklarheit lähmt stärker als Aufwand. Ein sichtbarer nächster Schritt macht Handeln wieder möglich.", + "woran": "jemandem, der grübelt statt anzufangen, oder der arbeitet und sich im Vorhaben verliert.", "tags": ["ueberforderung", "prokrastination", "ziel"], "momente": ["gruebelt", "arbeitet"], "farbe": Color(0.75, 0.55, 0.95), @@ -59,6 +64,7 @@ static var LIST: Array = [ "was": "Das gewünschte Verhalten leichter machen, das störende schwerer. Handy in einen anderen Raum, Instrument sichtbar aufstellen.", "wann": "Wenn gute Vorsätze regelmäßig an Ablenkung scheitern.", "warum": "Verhalten folgt dem geringsten Widerstand. Die Umgebung wirkt zuverlässiger als Disziplin.", + "woran": "jemandem, der aufräumt, arbeitet oder sich ausruht — dann ist der Raum um ihn herum greifbar.", "tags": ["ablenkung", "gewohnheit"], "momente": ["aufraeumen", "arbeitet", "ruht"], "farbe": Color(0.55, 0.85, 0.40), @@ -69,6 +75,7 @@ static var LIST: Array = [ "was": "Einen belastenden Gedanken prüfen und durch eine ebenso wahre, aber tragfähigere Sicht ersetzen.", "wann": "Wenn jemand in einem Gedanken feststeckt, der ihn klein hält.", "warum": "Nicht das Ereignis belastet, sondern seine Deutung. Eine andere Deutung verändert Gefühl und Handlung.", + "woran": "jemandem, der grübelt, angespannt ist oder sich ausruht und den Gedanken doch nicht loswird.", "tags": ["selbstzweifel", "angst"], "momente": ["gruebelt", "ruht", "angespannt"], "farbe": Color(0.95, 0.55, 0.75), @@ -79,6 +86,7 @@ static var LIST: Array = [ "was": "Vier Sekunden einatmen, vier halten, vier ausatmen, vier halten. Mehrere Runden lang.", "wann": "Bei akuter Anspannung, kurz vor einem gefürchteten Gespräch oder Auftritt.", "warum": "Langes Ausatmen bremst den Alarmzustand des Körpers. Der Puls sinkt, der Kopf wird wieder klar.", + "woran": "jemandem, der angespannt ist, wartet oder in der Pause nicht runterkommt.", "tags": ["angst", "stress"], "momente": ["angespannt", "wartet", "pause"], "farbe": Color(0.40, 0.85, 0.95), @@ -89,6 +97,7 @@ static var LIST: Array = [ "was": "Mit sich selbst so sprechen wie mit einem guten Freund in derselben Lage.", "wann": "Nach einem Fehler oder Rückschlag, wenn die innere Kritik lauter wird als der Vorsatz.", "warum": "Harte Selbstkritik erhöht die Vermeidung. Freundlichkeit hält handlungsfähig und senkt die Abbruchquote.", + "woran": "jemandem, der nach einem Fehler grübelt, angespannt ist oder sich ausruht, ohne zur Ruhe zu kommen.", "tags": ["selbstzweifel", "rueckschlag"], "momente": ["gruebelt", "ruht", "angespannt"], "farbe": Color(1.0, 0.80, 0.60), @@ -99,6 +108,7 @@ static var LIST: Array = [ "was": "Feste Aufsteh- und Schlafenszeit, die letzte Stunde ohne Bildschirm, Zimmer dunkel und kühl.", "wann": "Bei Dauermüdigkeit, Reizbarkeit und wegbrechender Konzentration.", "warum": "Der innere Rhythmus braucht feste Anker. Erholter Schlaf ist die Grundlage für jede andere Veränderung.", + "woran": "der Abendroutine, an jemandem, der müde ist oder sich mitten am Tag ausruht.", "tags": ["erschoepfung", "gesundheit"], "momente": ["abendroutine", "muede", "ruht"], "farbe": Color(0.45, 0.50, 0.90), @@ -109,6 +119,7 @@ static var LIST: Array = [ "was": "Eine klare, kleine Bitte an eine konkrete Person richten, statt allgemein zu hoffen.", "wann": "Wenn jemand allein an etwas hängen bleibt, das andere leicht lösen könnten.", "warum": "Vage Bitten werden überhört. Menschen helfen gern, wenn sie genau wissen, was zu tun ist.", + "woran": "jemandem, der wartet, Pause macht oder unterwegs ist — da ist Luft für eine Bitte.", "tags": ["einsamkeit", "ueberforderung"], "momente": ["wartet", "pause", "unterwegs"], "farbe": Color(0.90, 0.90, 0.45), @@ -119,6 +130,7 @@ static var LIST: Array = [ "was": "Absagen und zugleich benennen, was ginge: \"Heute nicht, am Donnerstag gern.\"", "wann": "Wenn der eigene Kalender von fremden Anliegen aufgebraucht wird.", "warum": "Ein nacktes Nein fühlt sich riskant an. Mit Alternative bleibt die Beziehung heil und die Zeit geschützt.", + "woran": "jemandem, der arbeitet, angespannt ist oder unterwegs zwischen Terminen hängt.", "tags": ["stress", "grenzen"], "momente": ["arbeitet", "angespannt", "unterwegs"], "farbe": Color(0.85, 0.35, 0.45), @@ -126,38 +138,6 @@ static var LIST: Array = [ ] -static var TAG_TEXT: Dictionary = { - "prokrastination": "Aufschieben", - "fokus": "Konzentration", - "ueberforderung": "Überforderung", - "gewohnheit": "Gewohnheiten", - "ziel": "Ziele", - "ablenkung": "Ablenkung", - "selbstzweifel": "Selbstzweifel", - "angst": "Angst", - "stress": "Stress", - "rueckschlag": "Rückschläge", - "erschoepfung": "Erschöpfung", - "gesundheit": "Gesundheit", - "einsamkeit": "Einsamkeit", - "grenzen": "Grenzen setzen", -} - - -static func tags_text(tags: Array) -> String: - var out: Array = [] - for t in tags: - out.append(TAG_TEXT.get(t, t)) - return ", ".join(out) - - -static func momente_text(momente: Array) -> String: - var out: Array = [] - for m in momente: - out.append(Orte.text(m)) - return ", ".join(out) - - static func by_id(id: String) -> Dictionary: for t in LIST: if t["id"] == id: diff --git a/scripts/ui.gd b/scripts/ui.gd index b9a2c9f..5a18220 100644 --- a/scripts/ui.gd +++ b/scripts/ui.gd @@ -48,7 +48,7 @@ func _baue_hud() -> void: add_child(_hud) var steuerung := Label.new() - steuerung.text = "WASD Bewegen · Maus Umsehen · E Interagieren · J Journal · Esc Maus frei" + steuerung.text = "WASD Bewegen · Maus Umsehen · E Interagieren · J Journal · 1/2/3 Tempo · Esc Maus frei" steuerung.set_anchors_preset(Control.PRESET_TOP_RIGHT) steuerung.position = Vector2(-640, 16) steuerung.size = Vector2(620, 24) @@ -144,9 +144,14 @@ func _process(_delta: float) -> void: func _hud_aktualisieren() -> void: var zeilen: Array = [] - zeilen.append("[b]%s[/b]" % Game.time_string()) - zeilen.append("Einsicht: [b]%d[/b] Techniken: [b]%d / %d[/b]" % - [Game.einsicht, Game.known.size(), TechDB.LIST.size()]) + var takt := ("[color=#aaaaaa]Tempo %d (%d×)[/color]" + % [Game.tempo, int(Game.faktor_von(Game.tempo))]) + if Game.schlaeft_ziel(): + takt = ("[color=#9fb6ff]Tempo 3 (%d×) · Nachtsprung[/color]" + % int(Game.faktor_von(3))) + zeilen.append("[b]%s[/b] %s" % [Game.time_string(), takt]) + zeilen.append("Techniken: [b]%d[/b] gelernt · [b]%d[/b] entdeckt · %d im Spiel" % + [Game.known.size(), Game.offene_techniken(), TechDB.LIST.size()]) if Game.active_mission != "": var h := HumanDB.by_id(Game.active_mission) zeilen.append("Mission: [color=#ffdd77]%s[/color] — %d %% · %d Regeln" % @@ -246,8 +251,9 @@ func schliessen() -> void: func _hinweis_block(t: Dictionary) -> String: - return ("[b]Was:[/b] %s\n[b]Wann:[/b] %s\n[b]Warum:[/b] %s" % - [t["was"], t["wann"], t["warum"]]) + return ("[b]Was:[/b] %s\n[b]Wann:[/b] %s\n[b]Warum:[/b] %s\n" + + "[color=#9fb6ff][b]Zu sehen an:[/b] %s[/color]") % \ + [t["was"], t["wann"], t["warum"], t["woran"]] # -------------------------------------------------------- Journal @@ -294,18 +300,17 @@ func _reiter() -> void: if Game.active_mission != "": mission = "%s (%d %%)" % [HumanDB.by_id(Game.active_mission)["name"], int(Game.mission_progress(Game.active_mission))] - _text("[color=#aaaaaa]Einsicht: [b]%d[/b] · Mission: %s[/color]" % - [Game.einsicht, mission], 15) + _text("[color=#aaaaaa]Mission: %s[/color]" % mission, 15) _trenner() func _journal_techniken() -> void: if Game.known.is_empty(): _text("[color=#ffcc66]Du hast noch keine Technik gelernt.[/color]\n" + - "Im [b]Hörsaal der Wege[/b] hinter dem Dorfplatz wird der erste Weg gelehrt.", 16) + "Im [b]Hörsaal der Wege[/b] hinter dem Dorfplatz werden sie gelehrt.", 16) return - _text("Was du gelernt hast. Jede Anwendung draußen macht eine Technik von allein " + - "besser; vertieft wird im Hörsaal.", 15) + _text("Was du gelernt hast. Besser wird eine Technik nur, indem du sie draußen " + + "anwendest — Üben lässt sie sich nicht.", 15) _trenner() for id in TechDB.ids(): if not Game.knows(id): @@ -314,8 +319,6 @@ func _journal_techniken() -> void: _text("[b][color=#ffe3a0]%s[/color][/b] · Stufe %d (%s)" % [t["name"], Game.skill_of(id), Game.xp_to_next(id)], 18) _text(_hinweis_block(t), 15) - _text("[color=#9fb6ff]Hilft bei:[/color] %s [color=#9fb6ff]Momente:[/color] %s" % - [TechDB.tags_text(t["tags"]), TechDB.momente_text(t["momente"])], 14) _trenner() @@ -335,7 +338,6 @@ func _journal_missionen() -> void: [h["name"], h["alter"], h["beruf"]], 18) _text("[b]Traum:[/b] %s" % h["traum"], 16) _text("[b]Was im Weg steht:[/b] %s" % h["problem"], 15) - _text("[color=#9fb6ff]Themen:[/color] %s" % TechDB.tags_text(h["tags"]), 14) if Game.completed.has(id): _text("[color=#88ff99]★ Der Traum ist erfüllt.[/color]", 15) elif Game.active_mission == id: @@ -359,7 +361,8 @@ func pipeline_panel(human_id: String) -> void: var h := HumanDB.by_id(human_id) _leeren("Regelwerk — %s" % h["name"]) _text("Jede Tätigkeit im Tag von %s kann eine Technik auslösen. " % h["name"] + - "Wechselt sie hinein, greift die Regel von allein — solange du auf der Erde bist.", 15) + "Wechselt sie hinein, greift die Regel von allein — solange du auf der Erde bist.\n" + + "Nach jedem Einsatz braucht eine Technik Ruhe, bevor sie wieder anschlägt.", 15) _text("[color=#aaaaaa]Fortschritt: [b]%d %%[/b] · Regeln: [b]%d[/b][/color]" % [int(Game.mission_progress(human_id)), Game.regel_anzahl(human_id)], 15) _trenner() @@ -371,9 +374,9 @@ func pipeline_panel(human_id: String) -> void: var tech: String = Game.regel_fuer(human_id, activity) var beschriftung := "%s → keine Regel" % tat if tech != "": - var ev := Game.evaluate(tech, human_id, activity, false) - beschriftung = "%s → %s · %d %%" % [tat, TechDB.by_id(tech)["name"], - int(round(float(ev["chance"]) * 100.0))] + beschriftung = "%s → %s" % [tat, TechDB.by_id(tech)["name"]] + if not Game.bereit(tech): + beschriftung += " (noch gesperrt)" _knopf(beschriftung, func() -> void: regel_panel(human_id, activity)) _trenner() _knopf("Zurück zum Journal", func() -> void: journal_panel("missionen")) @@ -392,38 +395,32 @@ func regel_panel(human_id: String, activity: String) -> void: _knopf("Zurück", func() -> void: pipeline_panel(human_id)) _zeigen("regel") return - _text("Was soll greifen, wenn %s %s? Die Erfolgschance gilt für genau diesen Moment. " % + _text("Was soll greifen, wenn %s %s? Ob eine Technik hier taugt, steht in ihr " % [h["name"], Orte.text(activity)] + - "Jede Technik hält nur einen Posten — sie hier einzusetzen nimmt sie anderswo weg.", 15) + "selbst — nachzulesen im Journal. Ein Fehlgriff wirft den Traum zurück.", 15) _trenner() for id in TechDB.ids(): if not Game.knows(id): continue var t := TechDB.by_id(id) var ev := Game.evaluate(id, human_id, activity, false) - var marker: Array = [] - if ev["tag"]: - marker.append("[color=#88ff99]passt zum Thema[/color]") - if ev["moment"]: - marker.append("[color=#88ff99]passender Moment[/color]") - if ev["blocked"]: - marker.append("[color=#ff8877]%s[/color]" % ev["grund"]) - if marker.is_empty(): - marker.append("[color=#aaaaaa]weder Thema noch Moment passen[/color]") + var wo: Array = Game.regel_momente(human_id, id) var gewaehlt := "" - var haengt := Game.regel_moment(human_id, id) - if haengt == activity: - gewaehlt = " [color=#ffdd77](hier eingestellt)[/color]" - elif haengt != "": - gewaehlt = " [color=#ffaa66](hängt an »%s«)[/color]" % Orte.text(haengt) - _text("[b]%s[/b] · Stufe %d%s\n[color=#cccccc]%s[/color]\n%s" % - [t["name"], Game.skill_of(id), gewaehlt, t["wann"], " · ".join(marker)], 16) - var beschriftung := "Blockiert" if ev["blocked"] else \ - "Diese Regel — %d %% Erfolgschance" % int(round(float(ev["chance"]) * 100.0)) + if wo.has(activity): + gewaehlt = " — hier eingestellt" + elif not wo.is_empty(): + var namen: Array = [] + for a in wo: + namen.append("»%s«" % Orte.text(a)) + gewaehlt = " — hängt an %s" % ", ".join(namen) + var beschriftung := "%s · Stufe %d" % [t["name"], Game.skill_of(id)] + if ev["blocked"]: + beschriftung += " — " + str(ev["grund"]) + elif gewaehlt != "": + beschriftung += gewaehlt _knopf(beschriftung, func() -> void: Game.regel_setzen(human_id, activity, id) pipeline_panel(human_id), not ev["blocked"]) - _trenner() _knopf("Keine Regel für diese Tätigkeit", func() -> void: Game.regel_setzen(human_id, activity, "") pipeline_panel(human_id)) @@ -441,8 +438,8 @@ func lehre_panel(ort: String = "hoersaal") -> void: var pl: Dictionary = DreamDB.PLACES[ort] var ids: Array = DreamDB.techniken_von(ort) _leeren(str(pl["name"])) - _text("[color=#aaaaaa]Einsicht: [b]%d[/b] · Gelernt: [b]%d / %d[/b] von %d entdeckten[/color]" % - [Game.einsicht, Game.known.size(), TechDB.LIST.size(), Game.offene_techniken()], 15) + _text("[color=#aaaaaa]Gelernt: [b]%d[/b] von %d entdeckten Wegen · %d im Spiel[/color]" % + [Game.known.size(), Game.offene_techniken(), TechDB.LIST.size()], 15) _trenner() if ids.is_empty(): _text("[color=#ffcc66]Hier wird nichts gelehrt.[/color]", 16) @@ -454,16 +451,13 @@ func lehre_panel(ort: String = "hoersaal") -> void: else: _text("[b][color=#9aa0b8]%s[/color][/b] · noch nicht gelernt" % t["name"], 18) _text(_hinweis_block(t), 15) - _text("[color=#9fb6ff]Hilft bei:[/color] %s [color=#9fb6ff]Momente:[/color] %s" % - [TechDB.tags_text(t["tags"]), TechDB.momente_text(t["momente"])], 14) if Game.knows(id): - _knopf("Vertiefen (1 Einsicht)", func() -> void: - if Game.deepen(id): - lehre_panel(ort), Game.einsicht >= 1) + _text("[color=#88ff99]Gelernt. Stufe %d steigt nur, wenn du diesen Weg " % + Game.skill_of(id) + "draußen anwendest — hier ist nichts mehr zu holen.[/color]", 15) else: - _knopf("Lernen (1 Einsicht)", func() -> void: + _knopf("Diesen Weg lernen", func() -> void: if Game.learn(id): - lehre_panel(ort), Game.einsicht >= 1) + lehre_panel(ort)) _trenner() _naechster_weg() _zeigen("lehre") @@ -489,19 +483,24 @@ func _naechster_weg() -> void: func brett_panel() -> void: var scroll_alt := _scroll.scroll_vertical if offen and _kind == "brett" else 0 _leeren("Schwarzes Brett") - _text("Menschen, deren Traum feststeckt. Nimm einen an — das Tor zur Erde " + - "öffnet sich nur mit einer Mission.", 15) + _text("Ausgehängt wird nur, wem du mit dem, was du kannst, auch helfen kannst. " + + "Das Tor zur Erde öffnet sich nur mit einer Mission.", 15) _trenner() + var gezeigt := 0 + var verdeckt := 0 for h in HumanDB.LIST: var id: String = h["id"] + if Game.completed.has(id): + continue + if Game.active_mission != id and not Game.machbar(id): + verdeckt += 1 + continue + gezeigt += 1 _text("[b][color=#ffe3a0]%s[/color][/b], %d — %s" % [h["name"], h["alter"], h["beruf"]], 18) _text("[b]Traum:[/b] %s" % h["traum"], 16) _text("[b]Was im Weg steht:[/b] %s" % h["problem"], 15) - _text("[color=#9fb6ff]Themen:[/color] %s" % TechDB.tags_text(h["tags"]), 14) - if Game.completed.has(id): - _text("[color=#88ff99]★ Der Traum ist erfüllt.[/color]", 15) - elif Game.active_mission == id: + if Game.active_mission == id: _text("[color=#ffdd77]Aktive Mission — %d %%[/color]" % int(Game.mission_progress(id)), 15) else: @@ -512,6 +511,12 @@ func brett_panel() -> void: Game.accept_mission(id) brett_panel()) _trenner() + if gezeigt == 0: + _text("[color=#ffcc66]Gerade hängt nichts aus, das zu deinen Wegen passt.[/color]\n" + + "Lerne im Hörsaal oder in einem Haus einen weiteren Weg.", 16) + if verdeckt > 0: + _text("[color=#aaaaaa]%d weitere Anliegen bleiben verdeckt. Keiner deiner Wege " % + verdeckt + "trifft ihr Thema in einem Moment ihres Tages.[/color]", 15) _zeigen("brett") _scroll_wunsch = scroll_alt _scroll_frames = 4 @@ -538,28 +543,22 @@ func anwenden_panel(mensch: Node3D) -> void: _text("[color=#ffcc66]Du kennst keine Technik.[/color]") _zeigen("anwenden") return + _text("[color=#aaaaaa]Was eine Technik tut, steht im Journal.[/color]", 15) for id in TechDB.ids(): if not Game.knows(id): continue var t := TechDB.by_id(id) var ev := Game.evaluate(id, h["id"], aktiv, mensch.resist_left > 0.0) - var marker: Array = [] - if ev["tag"]: - marker.append("[color=#88ff99]passt zum Thema[/color]") - if ev["moment"]: - marker.append("[color=#88ff99]passender Moment[/color]") - if ev["blocked"]: - marker.append("[color=#ff8877]%s[/color]" % ev["grund"]) + var gesperrt := not Game.bereit(id) + var beschriftung := "%s · Stufe %d" % [t["name"], Game.skill_of(id)] + if gesperrt: + beschriftung += " — noch %d s gesperrt" % int(ceil(Game.cooldown_rest(id))) + elif ev["blocked"]: + beschriftung += " — " + str(ev["grund"]) elif mensch.resist_left > 0.0: - marker.append("[color=#ff8877]macht gerade dicht[/color]") - if marker.is_empty(): - marker.append("[color=#aaaaaa]weder Thema noch Moment passen[/color]") - _text("[b]%s[/b] · Stufe %d\n[color=#cccccc]%s[/color]\n%s" % - [t["name"], Game.skill_of(id), t["wann"], " · ".join(marker)], 16) - var beschriftung := "Blockiert" if ev["blocked"] else "Anwenden — %d %% Erfolgschance" % int(round(float(ev["chance"]) * 100.0)) - _knopf(beschriftung, func() -> void: _anwenden(id), not ev["blocked"]) - _trenner() + beschriftung += " — macht gerade dicht" + _knopf(beschriftung, func() -> void: _anwenden(id), not ev["blocked"] and not gesperrt) _zeigen("anwenden") @@ -583,9 +582,10 @@ func _anwenden(id: String) -> void: Game.notified.emit("Immer dieselbe Technik wirkt schwächer. Probier eine andere.", Color(0.85, 0.85, 0.6)) else: - mensch.reagiere(false, "%s prallt ab" % t["name"]) - Game.notified.emit("%s dringt gerade nicht durch (%d %% Chance)." % - [t["name"], int(round(float(res["chance"]) * 100.0))], Color(1, 0.6, 0.5)) + mensch.reagiere(false, "%s prallt ab\n−%d %%" % [t["name"], + int(round(float(res["verlust"])))]) + Game.notified.emit("%s dringt nicht durch. (−%d %%)" % + [t["name"], int(round(float(res["verlust"])))], Color(1, 0.6, 0.5)) func _unhandled_input(event: InputEvent) -> void: diff --git a/tests/shots.gd b/tests/shots.gd index d3d18cd..bfeed22 100644 --- a/tests/shots.gd +++ b/tests/shots.gd @@ -193,7 +193,6 @@ func _ready() -> void: if not Game.completed.has("mensch_0"): Game.completed.append("mensch_0") Game.active_mission = "" - Game.einsicht = 3 Game.welt_erzeugen() main._traumwelt() await warte(20) diff --git a/tests/verify.gd b/tests/verify.gd index 1d5fa66..efc28f3 100644 --- a/tests/verify.gd +++ b/tests/verify.gd @@ -25,14 +25,18 @@ func _ready() -> void: await test_start() await test_journal() await test_traumdorf() + await test_dorf_organisch() await test_haus_betreten() await test_hoersaal() await test_mission_annehmen() await test_reise_zur_erde() + await test_bezirk() await test_tagesablauf() await test_wege_machbar() await test_geist() await test_technik_anwenden() + await test_cooldown() + await test_chance_verdeckt() await test_pipeline() await test_mission_abschliessen() await test_welt_waechst() @@ -40,6 +44,9 @@ func _ready() -> void: await test_journal_ueberall() await test_steuerung() await test_erster_traum_schaffbar() + await test_brett_nie_leer() + await test_lesen_lohnt_sich() + await test_tempo() print("\n=========================================") print("Fehlende Prüfungen: %d" % fehler) @@ -79,6 +86,16 @@ func _journal_text() -> String: return _panel_text() +func _lehre_text() -> String: + return _panel_text() + + +## Hebt alle Technik-Sperren auf. Nur für Messschleifen, die viele Anwendungen +## in Folge brauchen — der Cooldown selbst wird eigens geprüft. +func _frei() -> void: + Game.cooldown.clear() + + ## Welche Gebäude stehen wirklich in der Traumwelt? Erkennbar an ihren Schildern. ## Was der Hörsaal von Anfang an lehrt. func _basiswege() -> Array: @@ -163,8 +180,13 @@ func test_start() -> void: arten[n.get_meta("kind")] = true p(arten.has("erde") and arten.has("lehre") and arten.has("brett"), "Traumwelt bietet Tor, Hörsaal und Schwarzes Brett (%s)" % ", ".join(arten.keys())) - p(Game.einsicht == 3, "Startkapital 3 Einsicht") - p(Game.known.is_empty(), "Noch keine Technik gelernt") + p(Game.known.size() == 1, "Ein Weg ist von Anfang an gelernt (%d)" % Game.known.size()) + p(Game.knows(str(_basiswege()[0])), + "Und zwar der des Hörsaals: %s" % TechDB.by_id(str(_basiswege()[0]))["name"]) + p(Game.skill_of(str(_basiswege()[0])) == 1, "Auf Stufe 1, wie frisch gelernt") + p(not Game.offene_missionen().is_empty(), + "Damit hängt vom ersten Moment an eine Mission aus (%d)" % + Game.offene_missionen().size()) p((welt() as DreamWorld).bewohner.is_empty(), "Das Traumdorf steht am Anfang leer (%d Wesen)" % (welt() as DreamWorld).bewohner.size()) @@ -182,17 +204,49 @@ func test_start() -> void: func test_journal() -> void: abschnitt("Journal") for t in TechDB.LIST: - var vollstaendig: bool = t["was"] != "" and t["wann"] != "" and t["warum"] != "" + var vollstaendig: bool = t["was"] != "" and t["wann"] != "" and t["warum"] != "" \ + and t["woran"] != "" if not vollstaendig: - p(false, "Technik %s hat alle drei Hinweise" % t["id"]) - p(true, "Alle %d Techniken tragen Was/Wann/Warum" % TechDB.LIST.size()) + p(false, "Technik %s hat alle vier Hinweise" % t["id"]) + p(true, "Alle %d Techniken tragen Was/Wann/Warum/Woran" % TechDB.LIST.size()) + + # Der Woran-Hinweis muss jeden Moment der Technik wirklich benennen — + # sonst ist die Technik im Spiel nicht lesbar. + var stamm := { + "schlaeft": "schläf", "morgenroutine": "morgenroutine", "arbeitet": "arbeit", + "pause": "pause", "essen": "essen", "gruebelt": "grübel", "ruht": "ausruh", + "angespannt": "angespannt", "abendroutine": "abendroutine", + "aufraeumen": "aufräum", "wartet": "wartet", "muede": "müde", + "unterwegs": "unterwegs", "zoegert": "zöger", + } + var luecken: Array = [] + for t in TechDB.LIST: + var text: String = str(t["woran"]).to_lower() + for m in t["momente"]: + if not stamm.has(m): + luecken.append("%s: %s unbekannt" % [t["id"], m]) + elif not text.contains(str(stamm[m])): + luecken.append("%s nennt »%s« nicht" % [t["id"], m]) + p(luecken.is_empty(), "Jeder Woran-Hinweis benennt alle Momente seiner Technik%s" % + ("" if luecken.is_empty() else " — " + ", ".join(luecken))) + + # Aber er verrät nicht mehr als die Momente + var zuviel: Array = [] + for t in TechDB.LIST: + var text: String = str(t["woran"]).to_lower() + for m in stamm: + if (t["momente"] as Array).has(m): + continue + if text.contains(str(stamm[m])): + zuviel.append("%s nennt fremdes »%s«" % [t["id"], m]) + p(zuviel.is_empty(), "Und keinen Moment, der nicht dazugehört%s" % + ("" if zuviel.is_empty() else " — " + ", ".join(zuviel))) ui().journal_panel("techniken") p(ui().offen and ui()._kind == "journal", "Journal öffnet sich") - p(_journal_text().contains("Hörsaal"), - "Ohne gelernte Technik verweist das Journal auf den Hörsaal") - p(not _knopf_da("Lernen") and not _knopf_da("Vertiefen"), - "Das Journal lehrt und vertieft nicht selbst") + p(_journal_text().contains(str(TechDB.by_id(str(_basiswege()[0]))["name"])), + "Das Journal führt den mitgebrachten Weg") + p(not _knopf_da("Diesen Weg"), "Das Journal lehrt nicht selbst") ui().journal_panel("missionen") p(_journal_text().contains("Schwarzen Brett"), "Ohne Mission verweist das Journal auf das Schwarze Brett") @@ -202,10 +256,10 @@ func test_journal() -> void: var erster := str(_basiswege()[0]) p(Game.offene_techniken() == 1, "Am Anfang ist genau ein Weg entdeckt (%d)" % Game.offene_techniken()) - p(Game.learn(erster), "Der erste Weg ist gelernt: %s" % TechDB.by_id(erster)["name"]) - p(Game.einsicht == 2, "Das kostet 1 von 3 Einsicht (%d)" % Game.einsicht) + p(Game.knows(erster), "Er ist schon gelernt: %s" % TechDB.by_id(erster)["name"]) + p(not Game.learn(erster), "Zweimal lernen geht nicht") p(not Game.learn(str(TechDB.LIST[1]["id"])), - "Der zweite Weg ist trotz Einsicht nirgends zu lernen") + "Der zweite Weg ist nirgends zu lernen — er ist noch nicht entdeckt") p(Game.skill_of(erster) == 1, "Frisch gelernte Technik ist Stufe 1") Game.add_xp(erster, 4) p(Game.skill_of(erster) == 2, "4 EP heben auf Stufe 2") @@ -369,37 +423,37 @@ func test_hoersaal() -> void: p(basis_da, "Die %d Basiswege werden gelehrt" % 1) var lernbar := 0 for c in ui()._content.get_children(): - if c is Button and (c as Button).text.begins_with("Lernen"): + if c is Button and (c as Button).text.begins_with("Diesen Weg"): lernbar += 1 p(lernbar <= 1, "Und höchstens einer davon ist zu lernen (%d)" % lernbar) p(seite.contains(str(TechDB.LIST[1]["name"])), "Der Hörsaal nennt den nächsten noch unentdeckten Weg") p(not Game.learn("box_atmung"), "Eine unentdeckte Technik lässt sich nicht lernen") - Game.einsicht = 1 Game.known.clear() ui().lehre_panel() var vorher := Game.known.size() - p(_druecke("Lernen"), "Ein Lernen-Knopf steht bereit") + p(_druecke("Diesen Weg"), "Ein Lern-Knopf steht bereit") p(Game.known.size() == vorher + 1, "Im Hörsaal gelernt (%d -> %d)" % [vorher, Game.known.size()]) - p(Game.einsicht == 0, "Lernen kostet 1 Einsicht") - Game.einsicht = 1 + # Gelernt heißt fertig: der Lehrort bietet danach nichts mehr an ui().lehre_panel() var xp_summe := 0 for id in Game.known: xp_summe += int(Game.known[id]) - p(_druecke("Vertiefen"), "Ein Vertiefen-Knopf steht bereit") + p(not _knopf_da("Vertiefen") and not _knopf_da("Üben") and not _knopf_da("Trainieren"), + "Kein Knopf trainiert eine gelernte Technik") + p(not _druecke("Diesen Weg"), "Auch der Lern-Knopf ist verschwunden") var xp_neu := 0 for id in Game.known: xp_neu += int(Game.known[id]) - p(xp_neu == xp_summe + 3, "Vertiefen bringt 3 EP (%d -> %d)" % [xp_summe, xp_neu]) - p(Game.einsicht == 0, "Vertiefen kostet 1 Einsicht") + p(xp_neu == xp_summe, "Der Besuch im Hörsaal bringt keine Erfahrung (%d)" % xp_neu) + p(_lehre_text().contains("anwendest"), + "Stattdessen verweist der Hörsaal aufs Anwenden") ui().schliessen() # Ausgangslage für die folgenden Prüfungen wiederherstellen Game.known = {"pomodoro": 0, "zerlegen": 0, "umgebung": 0} - Game.einsicht = 0 func test_mission_annehmen() -> void: @@ -419,11 +473,39 @@ func test_mission_annehmen() -> void: ui().schliessen() main._interagieren() p(ui().offen and ui()._kind == "brett", "Das Brett öffnet sich") - var alle_aus := true + # Nur wer mit dem Gelernten zu schaffen ist, hängt aus. + var alle_wege: Dictionary = Game.known.duplicate() + Game.known = {str(_basiswege()[0]): 0} + ui().brett_panel() + var aushang := _panel_text() + var gezeigt := 0 + var fehlend := 0 for h in HumanDB.LIST: - if not _panel_text().contains(str(h["name"])): - alle_aus = false - p(alle_aus, "Alle %d Menschen hängen aus" % HumanDB.LIST.size()) + var id: String = h["id"] + if aushang.contains(str(h["name"])): + gezeigt += 1 + if not Game.machbar(id): + p(false, "%s hängt aus, obwohl kein Weg greift" % h["name"]) + elif Game.machbar(id): + p(false, "%s wäre lösbar, hängt aber nicht aus" % h["name"]) + else: + fehlend += 1 + p(gezeigt > 0, "Mit einem Weg hängt mindestens ein Aushang (%d von %d)" % + [gezeigt, HumanDB.LIST.size()]) + p(fehlend > 0, "Und mindestens einer bleibt verdeckt (%d)" % fehlend) + p(aushang.contains("verdeckt"), "Das Brett sagt, dass etwas verdeckt bleibt") + p(Game.offene_missionen().size() == gezeigt, + "Brett und offene_missionen() sind sich einig (%d)" % gezeigt) + + # Ohne jede Technik bleibt das Brett leer, mit Hinweis auf den Hörsaal + Game.known.clear() + ui().brett_panel() + p(Game.offene_missionen().is_empty(), "Ohne Technik ist nichts zu holen") + p(_panel_text().contains("Hörsaal"), "Und das Brett schickt in den Hörsaal") + Game.known = alle_wege + ui().brett_panel() + p(Game.offene_missionen().size() >= gezeigt, + "Mehr Wege heißt mehr Aushänge (%d)" % Game.offene_missionen().size()) p(Game.active_mission == "", "Vorher keine Mission") var gedrueckt := _druecke("Mission annehmen") @@ -547,11 +629,12 @@ func test_tagesablauf() -> void: func test_wege_machbar() -> void: abschnitt("Wege passen in die Zeitfenster") - _wege_pruefen("Erde", HumanDB.PLACES, HumanDB.LIST) - _wege_pruefen("Traumwelt", DreamDB.PLACES, DreamDB.LIST) + _wege_pruefen("Erde", HumanDB.PLACES, HumanDB.LIST, HumanDB.NETZ, HumanDB.ANKER) + _wege_pruefen("Traumwelt", DreamDB.PLACES, DreamDB.LIST, DreamDB.NETZ, DreamDB.ANKER) -func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array) -> void: +func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, + netz: Netz = null, anker: Dictionary = {}) -> void: var sek_pro_stunde := Game.DAY_SECONDS / 24.0 var alles_ok := true var engste := 999.0 @@ -563,7 +646,7 @@ func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array) -> void: var nach: String = String(r[i][1]) if von == nach: continue - var dauer := _weg_laenge(places, von, nach) / Bewohner.GEHTEMPO + var dauer := _weg_laenge(places, von, nach, netz, anker) / Bewohner.GEHTEMPO var slot_h: float = (float(r[i + 1][0]) - float(r[i][0])) if i + 1 < r.size() \ else (24.0 - float(r[i][0])) var fenster := slot_h * sek_pro_stunde @@ -578,8 +661,9 @@ func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array) -> void: p(alles_ok, "%s — jeder Weg passt, engster Fall: %s" % [welt_name, engste_text]) -func _weg_laenge(places: Dictionary, von: String, nach: String) -> float: - var pts: Array = Orte.route(places, von, nach, Vector3.ZERO) +func _weg_laenge(places: Dictionary, von: String, nach: String, + netz: Netz = null, anker: Dictionary = {}) -> float: + var pts: Array = Orte.route(places, von, nach, Vector3.ZERO, netz, anker) var l := 0.0 var pos := Orte.center(places, von) for pt in pts: @@ -657,20 +741,26 @@ func test_technik_anwenden() -> void: p(Game.evaluate("nein_sagen", "mensch_0", "zoegert", false)["blocked"], "Ungelernte Techniken lassen sich nicht anwenden") + _frei() + Game.progress["mensch_0"] = 50.0 var vorher_p: float = Game.mission_progress("mensch_0") var res := Game.apply_technique("pomodoro", "mensch_0", "zoegert", false) p(res["ok"], "Anwendung wird ausgeführt") if res["success"]: p(Game.mission_progress("mensch_0") > vorher_p, "Erfolg bringt Missionsfortschritt") else: - p(Game.mission_progress("mensch_0") == vorher_p, "Misserfolg bringt keinen Fortschritt") + p(Game.mission_progress("mensch_0") < vorher_p, + "Fehlversuch kostet Fortschritt (%.0f -> %.0f)" % + [vorher_p, Game.mission_progress("mensch_0")]) p(Game.known["pomodoro"] > 0, "Jede Anwendung bringt Erfahrung") + _frei() Game.progress["mensch_0"] = 0.0 Game.used.clear() Game.known["pomodoro"] = 28 var ertraege: Array = [] for i in range(6): + _frei() var r := Game.apply_technique("pomodoro", "mensch_0", "zoegert", false) if r["success"]: ertraege.append(r["gain"]) @@ -713,8 +803,8 @@ func test_pipeline() -> void: p(not schlaf_aktiv, "Und lässt sich gar nicht erst anklicken") ui().regel_panel("mensch_0", "zoegert") p(ui()._kind == "regel", "Die Technikwahl für »zögert« öffnet sich") - p(_panel_text().contains("Pomodoro-Technik"), "Gelernte Techniken stehen zur Wahl") - p(_druecke("Diese Regel"), "Eine Technik lässt sich zuordnen") + p(_knopf_texte().contains("Pomodoro-Technik"), "Gelernte Techniken stehen zur Wahl") + p(_druecke("Pomodoro-Technik"), "Eine Technik lässt sich zuordnen") p(Game.regel_fuer("mensch_0", "zoegert") != "", "Regel steht: zögert -> %s" % Game.regel_fuer("mensch_0", "zoegert")) p(ui()._kind == "pipeline", "Danach ist wieder das Regelwerk offen") @@ -724,15 +814,20 @@ func test_pipeline() -> void: Game.regel_setzen("mensch_0", "aufraeumen", "umgebung") p(Game.regel_anzahl("mensch_0") == 3, "Drei Regeln im Werk (%d)" % Game.regel_anzahl("mensch_0")) - # Eine Technik hält nur einen Posten + # Eine Technik darf an mehreren Tätigkeiten hängen Game.regel_setzen("mensch_0", "arbeitet", "zerlegen") - p(Game.regel_anzahl("mensch_0") == 3, - "Dieselbe Technik anderswo einzusetzen fügt keine Regel hinzu (%d)" % + p(Game.regel_anzahl("mensch_0") == 4, + "Dieselbe Technik anderswo einzusetzen kommt hinzu (%d)" % Game.regel_anzahl("mensch_0")) - p(Game.regel_fuer("mensch_0", "gruebelt") == "" and Game.regel_fuer("mensch_0", "arbeitet") == "zerlegen", - "Sie ist umgezogen statt sich zu verdoppeln") + p(Game.regel_fuer("mensch_0", "gruebelt") == "zerlegen" + and Game.regel_fuer("mensch_0", "arbeitet") == "zerlegen", + "Sie hängt jetzt an beiden Tätigkeiten") + p(Game.regel_momente("mensch_0", "zerlegen").size() == 2, + "Und das Spiel weiß von beiden Posten (%d)" % + Game.regel_momente("mensch_0", "zerlegen").size()) Game.regel_setzen("mensch_0", "arbeitet", "") - Game.regel_setzen("mensch_0", "gruebelt", "zerlegen") + p(Game.regel_momente("mensch_0", "zerlegen").size() == 1, + "Einzeln löschen lässt den anderen Posten stehen") # Ohne Zutun einen Tag laufen lassen var lena: Bewohner = null @@ -787,18 +882,19 @@ func test_mission_abschliessen() -> void: abschnitt("Mission abschließen") Game.progress["mensch_0"] = 0.0 Game.used.clear() - var einsicht_vorher := Game.einsicht var runden := 0 while Game.mission_progress("mensch_0") < Game.MISSION_ZIEL and runden < 400: + _frei() Game.apply_technique("pomodoro", "mensch_0", "zoegert", false) + _frei() Game.apply_technique("zerlegen", "mensch_0", "gruebelt", false) + _frei() Game.apply_technique("umgebung", "mensch_0", "aufraeumen", false) runden += 1 p(Game.mission_progress("mensch_0") >= Game.MISSION_ZIEL, "Lenas Traum erreicht 100 %% (nach %d Runden)" % runden) p(Game.completed.has("mensch_0"), "Mission gilt als abgeschlossen") p(Game.active_mission == "", "Aktive Mission wurde freigegeben") - p(Game.einsicht == einsicht_vorher + 3, "Abschluss bringt 3 Einsicht") p(DreamDB.LIST.size() == Game.completed.size(), "Der Abschluss lässt das Dorf wachsen (%d Wesen)" % DreamDB.LIST.size()) schritte(3) @@ -836,13 +932,12 @@ func test_welt_waechst() -> void: p(Game.insel_radius() >= Game.INSEL_GRUND, "Die Insel trägt das Dorf (%.0f)" % Game.insel_radius()) - Game.einsicht = 3 var neu_id := str(TechDB.LIST[1]["id"]) ui().lehre_panel(DreamDB.haus_key(0)) p(ui()._kind == "lehre", "Das neue Haus lehrt") p(_panel_text().contains(str(TechDB.by_id(neu_id)["name"])), "Und zwar den Weg, den sein Bewohner mitbrachte") - p(_druecke("Lernen"), "Er lässt sich lernen") + p(_druecke("Diesen Weg"), "Er lässt sich lernen") p(Game.knows(neu_id), "Gelernt: %s" % TechDB.by_id(neu_id)["name"]) ui().schliessen() p(not Game.technik_offen(str(TechDB.LIST[2]["id"])), @@ -853,7 +948,6 @@ func test_welt_waechst() -> void: # Ausgangslage für die folgenden Prüfungen Game.known.erase(neu_id) - Game.einsicht = 2 Game.welt_erzeugen() main._erde() await get_tree().process_frame @@ -910,35 +1004,49 @@ func _dorf_platz_pruefen() -> void: for i in range(stufen): Game.completed.append("stufe_%d" % i) Game.welt_erzeugen() - _wege_pruefen("Traumdorf mit %d Häusern" % stufen, DreamDB.PLACES, DreamDB.LIST) - _wege_pruefen("Stadt mit %d Menschen" % HumanDB.LIST.size(), HumanDB.PLACES, HumanDB.LIST) + _wege_pruefen("Traumdorf mit %d Häusern" % stufen, DreamDB.PLACES, DreamDB.LIST, + DreamDB.NETZ, DreamDB.ANKER) + _wege_pruefen("Stadt mit %d Menschen" % HumanDB.LIST.size(), HumanDB.PLACES, + HumanDB.LIST, HumanDB.NETZ, HumanDB.ANKER) Game.completed.assign(merker) Game.welt_erzeugen() func test_techniken_verbessern_sich() -> void: - abschnitt("Techniken verbessern sich durch Anwendung") - Game.einsicht = 2 + abschnitt("Techniken verbessern sich nur durch Anwendung") p(Game.learn("zwei_minuten"), "Technik gelernt") - p(Game.skill_of("zwei_minuten") == 1, "Start auf Stufe 1") - var einsicht_vorher := Game.einsicht + p(Game.skill_of("zwei_minuten") == 1, "Frisch gelernt heißt Stufe 1") + var anwendungen := 0 while Game.skill_of("zwei_minuten") < 3 and anwendungen < 60: + _frei() Game.apply_technique("zwei_minuten", "mensch_2", "gruebelt", false) anwendungen += 1 p(Game.skill_of("zwei_minuten") >= 3, "Nach %d Anwendungen von allein auf Stufe %d" % [anwendungen, Game.skill_of("zwei_minuten")]) - p(Game.einsicht == einsicht_vorher, "Das kostet keine Einsicht") var vor_xp: int = Game.known["zwei_minuten"] + _frei() var res := Game.apply_technique("zwei_minuten", "mensch_2", "essen", false) p(Game.known["zwei_minuten"] > vor_xp, "Auch ein %s bringt Erfahrung" % ("Erfolg" if res["success"] else "Fehlversuch")) - var xp_vor: int = Game.known["zwei_minuten"] - p(Game.deepen("zwei_minuten"), "Vertiefen funktioniert") - p(Game.known["zwei_minuten"] == xp_vor + 3, "Vertiefen bringt 3 EP") - p(Game.einsicht == einsicht_vorher - 1, "Vertiefen kostet 1 Einsicht") + # Es gibt keinen zweiten Weg nach oben + p(not Game.has_method("deepen"), "Kein gezieltes Vertiefen mehr im Spiel") + p(not ("einsicht" in Game), "Und keine Währung, die man dafür ausgeben könnte") + + var stand: int = Game.known["zwei_minuten"] + Game.learn("zwei_minuten") + ui().lehre_panel(DreamDB.ort_von_technik("zwei_minuten")) + var geknoepft := _druecke("Diesen Weg") or _druecke("Vertiefen") or _druecke("Üben") + ui().schliessen() + p(not geknoepft and Game.known["zwei_minuten"] == stand, + "Ein zweiter Besuch am Lehrort ändert nichts (%d EP)" % stand) + + # Zeitablauf allein bringt auch nichts + var vorher: int = Game.known["zwei_minuten"] + Game.advance_time(Game.DAY_SECONDS) + p(Game.known["zwei_minuten"] == vorher, "Und auch ein ganzer Tag bringt keine Erfahrung") func test_journal_ueberall() -> void: @@ -974,13 +1082,13 @@ func test_journal_ueberall() -> void: func test_steuerung() -> void: abschnitt("Steuerung") var aktionen := ["vor", "zurueck", "links", "rechts", "steigen", "sinken", - "interagieren", "journal"] + "interagieren", "journal", "tempo_1", "tempo_2", "tempo_3"] var alle := true for a in aktionen: if not InputMap.has_action(a) or InputMap.action_get_events(a).is_empty(): alle = false p(false, "Aktion '%s' ist belegt" % a) - p(alle, "Alle acht Steuerungsaktionen sind mit Tasten belegt") + p(alle, "Alle %d Steuerungsaktionen sind mit Tasten belegt" % aktionen.size()) ui().schliessen() main.player.look_enabled = true @@ -1030,7 +1138,7 @@ func test_steuerung() -> void: ## und zwar zuverlässig, nicht nur bei gutem Würfelglück. func _erster_traum_tage(saat: int) -> int: seed(saat) - Game.einsicht = 3 + Game.cooldown.clear() Game.known.clear() Game.used.clear() Game.pipeline.clear() @@ -1088,6 +1196,548 @@ func test_erster_traum_schaffbar() -> void: p(bester >= 2, "Nie geschenkt — mindestens %d Tage" % bester) p(schlimmster <= 8, "Nie zäh — höchstens %d Tage (von Hand geht es schneller)" % schlimmster) - p(Game.einsicht >= 3, "Der erfüllte Traum bringt 3 Einsicht (%d)" % Game.einsicht) p(Game.offene_techniken() > 1, "Und öffnet neue Wege: %d entdeckt" % Game.offene_techniken()) + + +## Nach jedem Einsatz braucht eine Technik Ruhe. Ein Weg allein deckt daher +## keinen ganzen Tag ab — das ist der Grund, mehrere zu lernen. +func test_cooldown() -> void: + abschnitt("Techniken haben einen Cooldown") + _frei() + Game.known = {"pomodoro": 28, "zerlegen": 28} + Game.active_mission = "mensch_0" + Game.progress["mensch_0"] = 50.0 + + p(Game.bereit("pomodoro"), "Frisch ist eine Technik einsatzbereit") + var erste := Game.apply_technique("pomodoro", "mensch_0", "zoegert", false) + p(erste["ok"], "Der erste Einsatz geht durch") + p(not Game.bereit("pomodoro"), "Danach ist sie gesperrt") + p(is_equal_approx(Game.cooldown_rest("pomodoro"), Game.TECHNIK_COOLDOWN), + "Und zwar %.0f Sekunden lang" % Game.TECHNIK_COOLDOWN) + + var zweite := Game.apply_technique("pomodoro", "mensch_0", "zoegert", false) + p(not zweite["ok"], "Ein zweiter Einsatz prallt an der Sperre ab") + p(str(zweite["grund"]).contains("einsatzbereit"), "Mit klarer Begründung") + + p(Game.bereit("zerlegen"), "Die Sperre gilt nur für diese eine Technik") + var andere := Game.apply_technique("zerlegen", "mensch_0", "gruebelt", false) + p(andere["ok"], "Eine andere Technik greift weiter") + + # Eine Regel bleibt still, solange die Technik gesperrt ist + Game.pipeline.clear() + Game.regel_setzen("mensch_0", "zoegert", "pomodoro") + var still := Game.regel_ausloesen("mensch_0", "zoegert", false) + p(still.is_empty(), "Auch eine Regel läuft nicht gegen die Sperre an") + + Game.advance_time(Game.TECHNIK_COOLDOWN * 0.5) + p(not Game.bereit("pomodoro"), "Nach der halben Zeit noch immer gesperrt") + Game.advance_time(Game.TECHNIK_COOLDOWN * 0.6) + p(Game.bereit("pomodoro"), "Nach Ablauf wieder frei") + p(Game.cooldown_rest("pomodoro") == 0.0, "Und der Zähler steht auf null") + var wieder := Game.regel_ausloesen("mensch_0", "zoegert", false) + p(not wieder.is_empty(), "Jetzt greift die Regel wieder") + + # Im Anwenden-Panel ist die Sperre sichtbar und der Knopf tot + var lena: Bewohner = null + for m in welt().humans: + if m.data["id"] == "mensch_0": + lena = m + ui().anwenden_panel(lena) + var gesperrt_sichtbar := _knopf_texte().contains("gesperrt") + var knopf_tot := true + for c in ui()._content.get_children(): + if c is Button and (c as Button).text.contains("gesperrt") \ + and not (c as Button).disabled: + knopf_tot = false + p(gesperrt_sichtbar, "Der Anwenden-Knopf weist die Sperre aus") + p(knopf_tot, "Und lässt sich nicht drücken") + ui().schliessen() + Game.pipeline.clear() + _frei() + + +## Wie gut eine Technik im Moment steht, sagt das Spiel nicht. Es nennt nur, +## was zum Thema und was zum Moment passt — entscheiden muss der Spieler. +func test_chance_verdeckt() -> void: + abschnitt("Die Erfolgschance bleibt verdeckt") + _frei() + Game.known = {"pomodoro": 28, "zerlegen": 28} + Game.active_mission = "mensch_0" + Game.pipeline.clear() + Game.regel_setzen("mensch_0", "zoegert", "pomodoro") + + ui().regel_panel("mensch_0", "zoegert") + var knoepfe := _knopf_texte() + p(not knoepfe.contains("%"), "Kein Knopf der Technikwahl nennt eine Prozentzahl") + p(not _panel_text().contains("Erfolgschance"), + "Und das Wort Erfolgschance kommt nicht vor") + var wahl := _panel_text() + p(not wahl.contains("passt zum Thema") and not wahl.contains("passender Moment") + and not wahl.contains("weder Thema"), + "Auch kein Hinweis, ob Thema oder Moment passen") + p(not wahl.contains("Wann:") and not wahl.contains("Zu sehen an:"), + "Die Auswahl zeigt nur die Namen, keine Beschreibung") + p(_knopf_texte().contains("Pomodoro-Technik · Stufe"), + "Jede Wahl steht als Name mit Stufe da") + ui().schliessen() + + ui().pipeline_panel("mensch_0") + var zeilen := _knopf_texte() + p(not zeilen.contains("%"), "Auch die Regelzeilen nennen keine Chance") + p(zeilen.contains("Pomodoro-Technik"), "Sie nennen nur die eingehängte Technik") + ui().schliessen() + + var lena: Bewohner = null + for m in welt().humans: + if m.data["id"] == "mensch_0": + lena = m + ui().anwenden_panel(lena) + var anwenden := _panel_text() + p(not _knopf_texte().contains("%"), "Und der Anwenden-Knopf nennt keine Chance") + p(_knopf_texte().contains("Pomodoro-Technik · Stufe"), + "Er trägt nur Name und Stufe") + p(not anwenden.contains("passt zum Thema") and not anwenden.contains("passender Moment"), + "Auch beim Anwenden steht nicht da, ob es passt") + p(not anwenden.contains("Hilft bei") and not anwenden.contains("Momente:"), + "Und keine Liste der passenden Themen oder Momente") + p(not anwenden.contains("Wann:") and not anwenden.contains("Zu sehen an:"), + "Auch dort nur Namen — die Beschreibung steht im Journal") + p(anwenden.contains("Journal"), "Und das Panel sagt, wo sie steht") + ui().schliessen() + + ui().journal_panel("techniken") + var buch := _panel_text() + p(not buch.contains("Hilft bei") and not buch.contains("Momente:"), + "Auch das Journal verrät die Passung nicht") + p(buch.contains("Wann:") and buch.contains("Zu sehen an:"), + "Das Journal dagegen führt Was, Wann, Warum und Zu sehen an") + ui().schliessen() + + ui().brett_panel() + p(not _panel_text().contains("Themen:"), + "Und das Brett listet die Themen des Menschen nicht auf") + ui().schliessen() + Game.pipeline.clear() + + +## Wenn nur lösbare Missionen aushängen, darf das Brett nie ganz leer werden — +## sonst hinge Loop 2 fest: ohne Traum kein neuer Weg, ohne Weg kein Aushang. +func test_brett_nie_leer() -> void: + abschnitt("Das Brett läuft nie leer") + var merk_c: Array = Game.completed.duplicate() + var merk_k: Dictionary = Game.known.duplicate() + var leer_bei := -1 + var kleinste := 99 + var stufen := 14 + for stufe in range(stufen): + # Ein Spieler, der alles lernt, was das Dorf hergibt + for t in TechDB.LIST: + if Game.technik_offen(str(t["id"])) and not Game.knows(str(t["id"])): + Game.known[str(t["id"])] = 0 + var offen: Array = Game.offene_missionen() + kleinste = mini(kleinste, offen.size()) + if offen.is_empty(): + leer_bei = stufe + break + Game.completed.append(str(offen[0])) + Game.welt_erzeugen() + p(leer_bei < 0, "Über %d Ausbaustufen hängt immer etwas aus%s" % + [stufen, "" if leer_bei < 0 else " — leer bei Stufe %d" % leer_bei]) + p(kleinste >= 1, "Im knappsten Fall bleibt %d Aushang übrig" % kleinste) + + # Und der jeweils neueste Mensch ist mit dem neuesten Weg zu schaffen + Game.completed.clear() + Game.known.clear() + Game.welt_erzeugen() + var passt := 0 + for i in range(TechDB.LIST.size()): + Game.completed.clear() + for k in range(i): + Game.completed.append("stufe_%d" % k) + Game.welt_erzeugen() + Game.known = {str(TechDB.LIST[i]["id"]): 0} + if Game.machbar("mensch_%d" % i): + passt += 1 + p(passt == TechDB.LIST.size(), + "Zu jedem der %d Wege gibt es den passenden Menschen (%d)" % + [TechDB.LIST.size(), passt]) + + Game.completed.assign(merk_c) + Game.known = merk_k + Game.welt_erzeugen() + + +## Die Probe auf den Kern: wer die Technik liest und sie nur dort einhängt, wo +## ihr "Wann" trifft, kommt schneller ans Ziel als wer sie überall hinhängt. +## Ohne diesen Abstand wäre das Lesen umsonst. +func _traum_tage(saat: int, alles: bool) -> int: + seed(saat) + Game.cooldown.clear() + Game.known.clear() + Game.used.clear() + Game.pipeline.clear() + Game.progress.clear() + Game.completed.clear() + Game.active_mission = "" + Game.welt_erzeugen() + var weg := str(_basiswege()[0]) + Game.learn(weg) + Game.accept_mission("mensch_0") + var t := TechDB.by_id(weg) + for a in HumanDB.aktivitaeten("mensch_0"): + if not Game.erreichbar(a): + continue + if alles or (t["momente"] as Array).has(a): + Game.regel_setzen("mensch_0", a, weg) + main._erde() + await get_tree().process_frame + main.player.global_position = Vector3(0, 30.0, 60.0) + Game.time_h = 7.0 + Game.day = 1 + for i in range(200000): + schritte(1) + if Game.completed.has("mensch_0") or Game.day > 14: + break + return Game.day if Game.completed.has("mensch_0") else 99 + + +func test_lesen_lohnt_sich() -> void: + abschnitt("Die Technik zu lesen zahlt sich aus") + var gezielt: Array = [] + var blind: Array = [] + for saat in [7, 555, 90210]: + gezielt.append(await _traum_tage(saat, false)) + blind.append(await _traum_tage(saat, true)) + var g := 0.0 + var b := 0.0 + for i in range(gezielt.size()): + g += float(gezielt[i]) + b += float(blind[i]) + g /= float(gezielt.size()) + b /= float(blind.size()) + p(b >= g * 1.25, + "Gezielt gesetzt geht es deutlich schneller: %.1f statt %.1f Tage (gezielt %s, blind %s)" % + [g, b, str(gezielt), str(blind)]) + var schlechter := 0 + for i in range(gezielt.size()): + if int(blind[i]) < int(gezielt[i]): + schlechter += 1 + p(schlechter == 0, + "Und blind gesetzt ist in keinem der %d Läufe besser (%d)" % [gezielt.size(), schlechter]) + + # Der Grund: ein falscher Moment ist unterm Strich ein Verlustgeschäft + Game.known = {"pomodoro": 0} + var gut := Game.evaluate("pomodoro", "mensch_0", "zoegert", false) + var falsch := Game.evaluate("pomodoro", "mensch_0", "gruebelt", false) + p(gut["moment"] and not falsch["moment"], "»zögert« ist Pomodoros Moment, »grübelt« nicht") + var ertrag_gut := (8.0 + 2.0) + 5.0 + 5.0 + var ertrag_falsch := (8.0 + 2.0) + 5.0 + var ev_gut: float = float(gut["chance"]) * ertrag_gut \ + - (1.0 - float(gut["chance"])) * Game.FEHLVERSUCH_VERLUST + var ev_falsch: float = float(falsch["chance"]) * ertrag_falsch \ + - (1.0 - float(falsch["chance"])) * Game.FEHLVERSUCH_VERLUST + p(ev_gut > 0.0, "Im richtigen Moment trägt sie (%.1f je Einsatz)" % ev_gut) + p(ev_falsch < 0.0, "Im falschen kostet sie (%.1f je Einsatz)" % ev_falsch) + + +## Warten soll nicht wehtun: 1, 2, 3 stellen das Tempo. Über der Nacht des +## Menschen springt es von allein auf drei — dort ist ohnehin nichts zu holen. +func test_tempo() -> void: + abschnitt("Tempo lässt sich stellen") + Game.completed.clear() + Game.known = {"pomodoro": 0} + Game.welt_erzeugen() + Game.active_mission = "" + Game.tempo = 1 + p(is_equal_approx(Game.zeit_faktor(), 1.0), "Ohne Zutun läuft die Welt einfach") + + # Wie in Die Sims: die Stufe ist nicht der Faktor + var erwartet := {1: 1.0, 2: 3.0, 3: 7.0} + for stufe in [2, 3, 1]: + Game.tempo_setzen(stufe) + p(Game.tempo == stufe and is_equal_approx(Game.zeit_faktor(), float(erwartet[stufe])), + "Stufe %d läuft %.0f-fach" % [stufe, Game.zeit_faktor()]) + Game.tempo_setzen(3) + p(not is_equal_approx(Game.zeit_faktor(), 3.0), + "Stufe 3 heißt gerade nicht dreifach (%.0f×)" % Game.zeit_faktor()) + Game.tempo_setzen(1) + Game.tempo_setzen(9) + p(Game.tempo == 3, "Höher als drei geht nicht (%d)" % Game.tempo) + Game.tempo_setzen(0) + p(Game.tempo == 1, "Und tiefer als eins auch nicht (%d)" % Game.tempo) + + # Über der Nacht des Menschen springt es von allein + Game.accept_mission("mensch_0") + var wach := -1.0 + var nacht := -1.0 + for stunde in range(24): + Game.time_h = float(stunde) + 0.5 + if Game.schlaeft_ziel(): + nacht = Game.zeit_faktor() + else: + wach = Game.zeit_faktor() + p(nacht > 0.0 and wach > 0.0, "Lenas Tag hat wache und schlafende Stunden") + p(is_equal_approx(nacht, Game.faktor_von(3)), + "Im Schlaf läuft die Welt auf der höchsten Stufe (%.0f×)" % nacht) + p(is_equal_approx(wach, 1.0), "Wach wieder auf der gewählten Stufe (%.0f)" % wach) + + Game.tempo_setzen(1) + Game.time_h = 2.0 + p(Game.schlaeft_ziel(), "Um 02:00 schläft sie") + p(is_equal_approx(Game.zeit_faktor(), Game.faktor_von(3)), + "Die eigene Wahl Stufe 1 gilt dann nicht — die Nacht springt") + + # Ohne Mission gibt es nichts zu überspringen + Game.active_mission = "" + p(not Game.schlaeft_ziel(), "Ohne Mission kein Nachtsprung") + p(is_equal_approx(Game.zeit_faktor(), 1.0), "Dann zählt nur die Wahl") + + # Der Faktor beschleunigt die Uhr wirklich + Game.tempo_setzen(3) + Game.time_h = 8.0 + Game.day = 1 + var vorher := Game.time_h + Game.advance_time(1.0 * Game.zeit_faktor()) + var schnell := Game.time_h - vorher + Game.tempo_setzen(1) + Game.time_h = 8.0 + vorher = Game.time_h + Game.advance_time(1.0 * Game.zeit_faktor()) + var langsam := Game.time_h - vorher + p(is_equal_approx(schnell, langsam * Game.faktor_von(3)), + "Auf Stufe 3 vergeht %.0f-mal so viel Zeit je Sekunde (%.4f statt %.4f h)" % + [Game.faktor_von(3), schnell, langsam]) + + # Das HUD sagt, woran man ist + Game.tempo_setzen(2) + ui().journal_panel() + ui().schliessen() + ui()._hud_aktualisieren() + p(ui()._hud.text.contains("Tempo 2 (3×)"), + "Das HUD nennt Stufe und Faktor getrennt") + Game.accept_mission("mensch_0") + Game.time_h = 2.0 + ui()._hud_aktualisieren() + p(ui()._hud.text.contains("Nachtsprung"), "Und weist den Nachtsprung aus") + Game.tempo_setzen(1) + Game.active_mission = "" + + +## Das Dorf wird nicht gesetzt, sondern wächst. Also muss über viele +## Ausbaustufen gelten: nichts überlappt, kein Weg führt durch ein Haus, alles +## hängt zusammen, und was einmal steht, bleibt stehen. +func test_dorf_organisch() -> void: + abschnitt("Das Dorf wächst organisch") + var merker: Array = Game.completed.duplicate() + var stufen := 24 + + var ueberlappung := "" + var wegtreffer := "" + var lose := "" + var ohne_anker := "" + var unerreichbar := "" + var sektoren_12 := 0 + var dichte := "" + for stufe in range(stufen + 1): + Game.completed.clear() + for i in range(stufe): + Game.completed.append("stufe_%d" % i) + Game.welt_erzeugen() + var netz: Netz = DreamDB.NETZ + var keys: Array = [] + for key in DreamDB.PLACES: + if float(DreamDB.PLACES[key]["size"][0]) > 0.0: + keys.append(key) + + # nichts steht ineinander + for a in range(keys.size()): + for b in range(a + 1, keys.size()): + var ca := Orte.center(DreamDB.PLACES, keys[a]) + var cb := Orte.center(DreamDB.PLACES, keys[b]) + var sa: Array = DreamDB.PLACES[keys[a]]["size"] + var sb: Array = DreamDB.PLACES[keys[b]]["size"] + if absf(ca.x - cb.x) < (float(sa[0]) + float(sb[0])) * 0.5 \ + and absf(ca.z - cb.z) < (float(sa[1]) + float(sb[1])) * 0.5 \ + and ueberlappung == "": + ueberlappung = ("%s und %s bei %d Häusern" + % [keys[a], keys[b], stufe]) + + # kein Weg führt durch ein Gebäude + for k in netz.kanten: + for key in keys: + var s2: Array = DreamDB.PLACES[key]["size"] + if Netz.trifft_rechteck(netz.knoten[k[0]], netz.knoten[k[1]], + Orte.center(DreamDB.PLACES, key), + Vector2(float(s2[0]), float(s2[1])), 0.0) and wegtreffer == "": + wegtreffer = "%s bei %d Häusern" % [key, stufe] + + if not netz.zusammenhaengend() and lose == "": + lose = "bei %d Häusern" % stufe + + # jedes Haus hängt am Netz und ist vom Dorfplatz aus zu erreichen + for i in range(stufe): + var key3 := DreamDB.haus_key(i) + if not DreamDB.ANKER.has(key3): + if ohne_anker == "": + ohne_anker = "%s bei %d Häusern" % [key3, stufe] + continue + if netz.weglaenge(int(DreamDB.ANKER["lichtung"]), int(DreamDB.ANKER[key3])) == INF \ + and unerreichbar == "": + unerreichbar = "%s bei %d Häusern" % [key3, stufe] + + if stufe == 12: + var sekt: Dictionary = {} + for i in range(stufe): + var c := Orte.center(DreamDB.PLACES, DreamDB.haus_key(i)) + sekt[int(floor((atan2(c.z, c.x) + PI) / (TAU / 8.0))) % 8] = true + sektoren_12 = sekt.size() + if stufe == stufens_probe(): + var flaeche := PI * pow(Game.insel_radius(), 2.0) + if flaeche / float(stufe) > 2200.0: + dichte = "%.0f m² je Haus" % (flaeche / float(stufe)) + + p(ueberlappung == "", "Über %d Ausbaustufen steht kein Gebäude im anderen%s" % + [stufen, "" if ueberlappung == "" else " — " + ueberlappung]) + p(wegtreffer == "", "Und kein Weg führt durch ein Gebäude%s" % + ["" if wegtreffer == "" else " — " + wegtreffer]) + p(lose == "", "Das Wegenetz hängt immer zusammen%s" % + ["" if lose == "" else " — " + lose]) + p(ohne_anker == "", "Jedes Haus hängt am Netz%s" % + ["" if ohne_anker == "" else " — " + ohne_anker]) + p(unerreichbar == "", "Und ist vom Dorfplatz aus erreichbar%s" % + ["" if unerreichbar == "" else " — " + unerreichbar]) + p(sektoren_12 >= 6, + "Bei 12 Häusern ist das Dorf rundum gewachsen (%d von 8 Richtungen)" % sektoren_12) + p(dichte == "", "Die Insel bleibt dicht besiedelt%s" % + ["" if dichte == "" else " — " + dichte]) + + # Verwinkelt heißt: Gabeln und Schleifen, nicht Speichen zur Mitte + Game.completed.clear() + for i in range(18): + Game.completed.append("stufe_%d" % i) + Game.welt_erzeugen() + var netz2: Netz = DreamDB.NETZ + var gabeln := 0 + for i in range(netz2.knoten.size()): + if netz2.nachbarn(i).size() >= 3: + gabeln += 1 + var schleifen: int = netz2.kanten.size() - netz2.knoten.size() + 1 + p(gabeln >= 6, "Bei 18 Häusern gabelt sich das Netz %d-mal" % gabeln) + p(schleifen >= 2, "Und trägt %d Schleifen statt lauter Sackgassen" % schleifen) + + # Was einmal steht, bleibt stehen + var vorher: Array = [] + for i in range(18): + vorher.append(Orte.center(DreamDB.PLACES, DreamDB.haus_key(i))) + Game.completed.append("stufe_18") + Game.welt_erzeugen() + var verrutscht := 0 + for i in range(18): + if vorher[i].distance_to(Orte.center(DreamDB.PLACES, DreamDB.haus_key(i))) > 0.01: + verrutscht += 1 + p(verrutscht == 0, "Das 19. Haus verschiebt keines der 18 alten (%d)" % verrutscht) + + Game.completed.assign(merker) + Game.welt_erzeugen() + + +func stufens_probe() -> int: + return 24 + + +## Die Erde ist ein erzeugter Bezirk. Also muss gelten: Parzellen liegen +## nebeneinander statt ineinander, jede hat Straßenanschluss, das Netz hängt +## zusammen -- und anders als im Dorf wächst hier nichts. +func test_bezirk() -> void: + abschnitt("Die Erde ist ein Bezirk") + var netz: Netz = HumanDB.NETZ + p(HumanDB.PARZELLEN.size() >= 60, + "Der Bezirk ist in %d Parzellen geteilt" % HumanDB.PARZELLEN.size()) + + var ueber := 0 + for i in range(HumanDB.PARZELLEN.size()): + for j in range(i + 1, HumanDB.PARZELLEN.size()): + var a: Dictionary = HumanDB.PARZELLEN[i] + var b: Dictionary = HumanDB.PARZELLEN[j] + if absf(a["pos"].x - b["pos"].x) < (a["size"].x + b["size"].x) * 0.5 - 0.05 \ + and absf(a["pos"].z - b["pos"].z) < (a["size"].y + b["size"].y) * 0.5 - 0.05: + ueber += 1 + p(ueber == 0, "Keine Parzelle steht in einer anderen (%d)" % ueber) + + var ohne := 0 + var weiteste := 0.0 + for pz in HumanDB.PARZELLEN: + var d := INF + for st in HumanDB.STRASSEN: + var f := Geometry3D.get_closest_point_to_segment(pz["pos"], st["a"], st["b"]) + d = minf(d, f.distance_to(pz["pos"]) - maxf(pz["size"].x, pz["size"].y) * 0.5 + - float(st["breite"]) * 0.5) + weiteste = maxf(weiteste, d) + if d > 2.0: + ohne += 1 + p(ohne == 0, "Jede Parzelle liegt an einer Straße (weiteste Lücke %.1f m)" % weiteste) + + p(netz.zusammenhaengend(), + "Das Straßennetz hängt zusammen (%d Knoten, %d Kanten)" % + [netz.knoten.size(), netz.kanten.size()]) + var unerreichbar := 0 + var keys: Array = HumanDB.PLACES.keys() + for i in range(keys.size()): + for j in range(i + 1, keys.size()): + if netz.weglaenge(int(HumanDB.ANKER[keys[i]]), int(HumanDB.ANKER[keys[j]])) == INF: + unerreichbar += 1 + p(unerreichbar == 0, "Jeder Ort ist von jedem anderen aus zu erreichen (%d Lücken)" % + unerreichbar) + + # Arterien im Raster, roji schmal und als Sackgasse ins Blockinnere + var arterien := 0 + var gassen := 0 + for st in HumanDB.STRASSEN: + if float(st["breite"]) >= HumanDB.ARTERIE: + arterien += 1 + else: + gassen += 1 + p(arterien == HumanDB.ACHSEN.size() * 2, + "%d Arterien spannen das Raster auf" % arterien) + p(gassen >= 4, "Und %d roji führen ins Blockinnere" % gassen) + p(HumanDB.ROJI < HumanDB.ARTERIE * 0.6, + "Eine roji ist mit %.1f m deutlich schmaler als eine Arterie (%.1f m)" % + [HumanDB.ROJI, HumanDB.ARTERIE]) + var sackgassen := 0 + for st in HumanDB.STRASSEN: + if float(st["breite"]) < HumanDB.ARTERIE: + var ende: Vector3 = st["b"] + if netz.nachbarn(netz.fuege_knoten(ende)).size() <= 1: + sackgassen += 1 + p(sackgassen >= gassen / 2, "Die meisten roji enden blind (%d von %d)" % + [sackgassen, gassen]) + + # Alle Menschen sind da, und sie werden nicht mehr + var vorher := HumanDB.LIST.size() + p(vorher == HumanDB.EINWOHNER, "Alle %d Menschen sind von Anfang an da" % vorher) + var merker: Array = Game.completed.duplicate() + for i in range(6): + Game.completed.append("stufe_%d" % i) + Game.welt_erzeugen() + p(HumanDB.LIST.size() == vorher, + "Sechs erfüllte Träume ändern daran nichts (%d)" % HumanDB.LIST.size()) + Game.completed.assign(merker) + Game.welt_erzeugen() + + # Jeder wohnt in einer eigenen Wohnung + var heime: Dictionary = {} + for m in HumanDB.LIST: + heime[str(m["heim"])] = true + p(heime.size() == HumanDB.LIST.size(), + "Jeder Mensch hat seine eigene Wohnung (%d)" % heime.size()) + + # Der mittlere Block bleibt frei + var im_park := 0 + var halb: float = (float(HumanDB.ACHSEN[2]) - float(HumanDB.ACHSEN[1])) * 0.5 \ + - HumanDB.ARTERIE + for pz in HumanDB.PARZELLEN: + var q: Vector3 = pz["pos"] + if absf(q.x) < halb and absf(q.z) < halb: + im_park += 1 + p(im_park == 0, "Im mittleren Block steht kein Haus — er ist der Park (%d)" % im_park)