From 936773c4e910793126ead6b7ab5e640398be2b1a Mon Sep 17 00:00:00 2001 From: Team3 Date: Wed, 16 Sep 2026 01:23:42 +0200 Subject: [PATCH] update --- README.md | 91 +++++----- scripts/bewohner.gd | 110 +++++++++++- scripts/dream_db.gd | 120 ++++++++++++- scripts/dream_world.gd | 397 +++++++++++++++++++++++++++-------------- scripts/earth_world.gd | 121 +++++++++---- scripts/game.gd | 1 + scripts/human_db.gd | 28 ++- scripts/main.gd | 52 ++++-- scripts/orte.gd | 48 +++++ scripts/player.gd | 204 +++++++++++++++------ scripts/prims.gd | 90 +++++++++- scripts/ui.gd | 2 +- tests/verify.gd | 350 +++++++++++++++++++++++++++++++++++- 13 files changed, 1300 insertions(+), 314 deletions(-) diff --git a/README.md b/README.md index 93b0cde..b1460ff 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,21 @@ sie allein nicht bewegt bekommen. Du wirst zu ihnen geschickt. godot --path . -Steuerung: **WASD** bewegen · **Maus** umsehen · **E** interagieren · -**F** folgen · **R** notieren · **J** Journal · **1/2/3** Tempo · -**Esc** Maus freigeben. +Steuerung: **WASD** bewegen · **Q** Kamera drehen · **Mausrad** Zoom · +**Klick** auf einen Menschen folgen · **E** interagieren · **F** folgen · +**R** notieren · **J** Journal · **1/2/3** Tempo. + +Die **Kamera** ist fest wie in Diablo: schräg von oben, diagonal über den +Bezirk, zwei Zoomstufen, Drehen in 90°-Schritten. Die Maus ist immer frei. +Menschen sind klein, Häuser groß — die Stadt soll groß wirken und der Geist +entrückt. **Die Oberfläche** ist um den Tag gebaut. Unten über die ganze Breite liegt die **Zeitleiste**: 24 Felder, eines je Stunde, die laufende steht heraus. Grau heißt unbekannt, gelb „hier stimmt etwas nicht, noch nicht benannt", rot ein benannter Blocker, grün ein gebautes System. Links darüber die Ursachen, rechts die Momente; bricht einer auf, pulsiert die laufende Stunde und rechts -läuft die Uhr. Ein Klick auf eine Stunde (Maus mit **Esc** freigeben) öffnet -ihre Karte direkt darüber — was passiert, Auslöser, Belohnung, welche Technik +läuft die Uhr. Ein Klick auf eine Stunde öffnet ihre Karte direkt darüber — was passiert, Auslöser, Belohnung, welche Technik passt. Das **Journal** (J) ist eine Seitenleiste rechts, die Welt bleibt sichtbar: @@ -30,8 +34,8 @@ hervorgehobene. Fenster in der Mitte gibt es nur an Orten: Brett, Hörsaal, Aushang schreiben. **Esc** geht eine Ebene zurück, nicht gleich ganz zu. Oben links stehen nur Zeit und Mission. Alles Weitere steht im Journal. -In der Traumwelt springt **Leertaste**; auf der Erde steigt sie und **Umschalt** -lässt sinken. +In der Traumwelt springt **Leertaste**; auf der Erde wechselt sie ein +Stockwerk nach oben, **Umschalt** eines nach unten. ## Spielschleife @@ -133,42 +137,25 @@ Haus wird ein neuer Weg gelehrt. Zu Beginn kennt das Dorf nur einen. Nach jedem Einsatz braucht eine Technik 30 Sekunden Ruhe. Ein Einbau gelingt nicht immer — Übung entscheidet. -**Traumwelt** — eine schwebende Insel mit einem Dorf, das wächst +**Traumwelt** — eine schwebende Insel, die ein Hügel ist -* 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 — 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). +* Fest sind nur drei Dinge: der **Hörsaal der Wege**, der **Dorfplatz** um den + leuchtenden **Traumbaum** und das **Tor zur Erde**. Alles andere entsteht + beim Wachsen. +* **Die Insel beginnt kahl** und eben. Ab dem Dorfplatz steigt sie an; jeder + erfüllte Traum bringt einen Bewohner mit seinem Haus, und wer später kommt, + wohnt weiter außen und damit höher. Das Dorf wächst den Hügel hinauf — vom + Platz aus sieht man, wie weit man gekommen ist. +* Jedes Haus steht auf einer **Terrasse** in Höhe seiner Tür, die als + Böschung in den Hang ausläuft. Dach, Laterne an der Tür, Licht innen. Die + Wege folgen dem Hang und tragen Laternen. Spieler und Bewohner laufen auf + dem Boden, den ein Höhenfeld trägt. +* Häuser suchen sich ihren Platz selbst — mit Nachbarn, Weganschluss und dem + Dorfplatz in erträglicher Nähe — und meiden das Tor. Ein Weg führt nie + durch ein Haus; findet die Suche keinen, wird feiner gesucht und notfalls + woanders angedockt. * **Schwarzes Brett** am Dorfplatz — hier hängen die Menschen aus, die nicht - 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. - -**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. + weiterkommen. Mission annehmen. **Erde** — ein Stadtbezirk nach japanischem Vorbild @@ -179,7 +166,17 @@ Abwehr. Am Ausgang ändert sich dadurch nichts, nur an der Wartezeit. * **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. +* **Die Stadt ist verschachtelt.** Wohnhäuser und Sonderbauten sind + geschlossene Häuser mit zwei bis vier Stockwerken, Treppenhaus neben der + Tür, Fenstern und Möbeln. Jeder Mensch hat seine Wohnung in einer Etage, + im Büro und an der Hochschule sitzt jeder auf seinem Stockwerk. Die + Bewohner gehen die Treppe hoch und runter. Alles andere steht als + geschlossener Klotz da. +* Du bist ein unsichtbarer Geist, schwebst und gehst durch Wände. Bist du + in einem Haus, greift der **Etagenschnitt**: Dach, Etagen über dir und + die Wände zur Kamera hin verschwinden, wie bei den Sims. Ein Haus, das + zwischen Kamera und dir steht, wird durchsichtig. Wer auf einer anderen + Etage ist, ist nicht im Fokus — durch die Decke sieht man nichts. * **F** hängt dich an einen Menschen: du treibst hinter ihm her, durch Türen und über den ganzen Tag, ohne ihm nachlaufen zu müssen. **F** noch einmal — oder ein Schritt mit **WASD** — lässt ihn wieder los. @@ -221,18 +218,18 @@ eintauschen könnte. Ein Erfolg bringt 2 EP, ein Fehlversuch 1 — auch der zäh 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 und Slots + scripts/orte.gd Orte, Türen, Wege, Etagen, Treppenhaus (beide Welten) + scripts/bewohner.gd Bewohner mit Tagesablauf, Hut nach Beruf, Haltung nach Klemme scripts/main.gd Weltenwechsel, Fokus, Interaktion - scripts/player.gd Läufer in der Traumwelt, Geist auf der Erde, Verfolgung + scripts/player.gd Geist: feste Kamera, Etagenwechsel, Schleier, Funken, Verfolgung scripts/dream_world.gd Insel mit Dorf - scripts/earth_world.gd Stadt mit Tag-Nacht-Wechsel + scripts/earth_world.gd Stadt mit Stockwerken, Treppen, Etagenschnitt, Tag-Nacht-Wechsel scripts/ursachen.gd Ursachen, blockierende Tätigkeiten, Belohnungen scripts/probleme.gd Psychische Probleme und die Momente, in denen sie aufbrechen scripts/woerter.gd Alltagswörter und was das Spiel daraus schließt scripts/aushaenge.gd Eigene Aushänge: speichern, laden, prüfen scripts/ui.gd HUD, Journal, Tagesablauf, Hörsaal, Brett, Panels - scripts/prims.gd Bausteine aus Primitiven + scripts/prims.gd Bausteine aus Primitiven, Etagenschnitt ## Prüfen diff --git a/scripts/bewohner.gd b/scripts/bewohner.gd index a6cab15..4d39c32 100644 --- a/scripts/bewohner.gd +++ b/scripts/bewohner.gd @@ -4,6 +4,7 @@ class_name Bewohner ## einer Technik erreicht werden -- oder eben nicht. const GEHTEMPO := 8.0 +const MASSSTAB := 0.8 # Menschen sind klein, die Stadt wirkt groß var data: Dictionary var idx: int = 0 @@ -16,6 +17,8 @@ var anker: Dictionary = {} var mit_mission := false var _visual: Node3D +var _ober: Node3D # Oberkörper: neigt sich, duckt sich, zappelt +var _arme: Array = [] var _label: Label3D var _koerper: MeshInstance3D var _kopf: MeshInstance3D @@ -23,6 +26,7 @@ var _aura: MeshInstance3D var _strahl: MeshInstance3D var _path: Array = [] var _letzte_aktivitaet: String = "" +var boden: Callable = Callable() # Höhe des Bodens (x, z) -> y; leer heißt eben var slot: int = 0 var _bob: float = 0.0 var _offset: Vector3 @@ -41,18 +45,30 @@ func setup(d: Dictionary, index: int, orte: Dictionary, _offset = Vector3(cos(winkel), 0, sin(winkel)) * 2.2 _visual = Node3D.new() + _visual.scale = Vector3.ONE * MASSSTAB add_child(_visual) var c: Color = d["farbe"] var leuchtet := bool(d.get("leuchtet", false)) var hoehe := 1.05 + float(index % 3) * 0.08 - _koerper = Prims.capsule(_visual, Vector3(0, hoehe * 0.62, 0), 0.32, hoehe, c) - _kopf = Prims.sphere(_visual, Vector3(0, hoehe * 1.18 + 0.1, 0), 0.24, c.lightened(0.45)) - Prims.box(_visual, Vector3(0, hoehe * 0.95, 0), Vector3(0.9, 0.14, 0.3), c.darkened(0.25), false) + _ober = Node3D.new() + _ober.position.y = hoehe * 0.45 + _visual.add_child(_ober) + var o := Vector3(0, -hoehe * 0.45, 0) + _koerper = Prims.capsule(_ober, o + Vector3(0, hoehe * 0.62, 0), 0.32, hoehe, c) + _kopf = Prims.sphere(_ober, o + Vector3(0, hoehe * 1.18 + 0.1, 0), 0.24, c.lightened(0.45)) + Prims.box(_ober, o + Vector3(0, hoehe * 0.95, 0), Vector3(0.9, 0.14, 0.3), c.darkened(0.25), false) + for sx: float in [-1.0, 1.0]: + var gelenk := Node3D.new() + gelenk.position = o + Vector3(sx * 0.46, hoehe * 0.92, 0) + _ober.add_child(gelenk) + Prims.capsule(gelenk, Vector3(0, -0.32, 0), 0.09, 0.62, c.darkened(0.1)) + _arme.append(gelenk) + _hut(o + Vector3(0, hoehe * 1.18 + 0.1, 0), c) Prims.box(_visual, Vector3(0, 0.06, 0), Vector3(0.5, 0.12, 0.7), c.darkened(0.45), false) if leuchtet: _koerper.material_override = Prims.mat(c, 0.9) _kopf.material_override = Prims.mat(c.lightened(0.45), 0.8) - var reif := Prims.cyl(_visual, Vector3(0, hoehe * 0.5, 0), 0.62, 0.05, + var reif := Prims.cyl(_ober, o + Vector3(0, hoehe * 0.5, 0), 0.62, 0.05, c.lightened(0.3), false, 1.6) reif.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF @@ -61,13 +77,85 @@ func setup(d: Dictionary, index: int, orte: Dictionary, _strahl = Prims.cyl(self, Vector3(0, 11.0, 0), 0.14, 16.0, Color(1, 0.85, 0.35, 0.22), false, 1.2) _strahl.visible = false - _label = Prims.label(self, Vector3(0, 2.9, 0), d["name"], 0.30) + # gestaffelt, damit sich Schilder in einem Raum nicht übereinanderlegen + _label = Prims.label(self, Vector3(0, 2.3 + float(index % 5) * 0.35, 0), d["name"], 0.36) var start := _routine_at(Game.time_h) place = start[0] activity = start[1] _letzte_aktivitaet = activity - global_position = Orte.center(places, place) + _offset + global_position = Orte.center(places, place) + _offset + Vector3(0, Orte.hoehe(etage(place)), 0) + + +## In welcher Etage dieser Mensch an einem Ort ist -- 0, wenn er dort nur unten ist. +func etage(ort: String) -> int: + return int((data.get("etage", {}) as Dictionary).get(ort, 0)) + + +## Wege führen durchs Treppenhaus: aus der Etage runter zur Tür, und am Ziel +## von der Tür hinauf in die Etage. +func _mit_etagen(pts: Array, von: String, nach: String, war_unterwegs: bool) -> Array: + if pts.is_empty(): + return pts + var e_von := etage(von) + if e_von > 0 and not war_unterwegs: + var runter: Array = [Orte.treppe(places, von, e_von)] + runter.append_array(Orte.treppen_weg(places, von, e_von, 0)) + runter.append(Orte.innen_tuer(places, von)) + runter.append_array(pts) + pts = runter + var e_nach := etage(nach) + if e_nach > 0: + pts.pop_back() + pts.append(Orte.innen_tuer(places, nach)) + pts.append(Orte.treppe(places, nach, 0)) + pts.append_array(Orte.treppen_weg(places, nach, 0, e_nach)) + pts.append(Orte.center(places, nach) + _offset + Vector3(0, Orte.hoehe(e_nach), 0)) + return pts + + +## Kopfbedeckung nach Tagestyp des Berufs -- so unterscheiden sich die Leute +## schon aus der Ferne. +func _hut(kopf: Vector3, c: Color) -> void: + var typ := str(HumanDB.BERUF_TAGESTYP.get(str(data.get("beruf", "")), "")) + var dunkel := c.darkened(0.4) + match typ: + "frueh": # Mütze + Prims.cyl(_ober, kopf + Vector3(0, 0.2, 0), 0.22, 0.14, dunkel, false) + "buero": # Hut mit Krempe + Prims.cyl(_ober, kopf + Vector3(0, 0.16, 0), 0.36, 0.04, dunkel, false) + Prims.cyl(_ober, kopf + Vector3(0, 0.3, 0), 0.18, 0.24, dunkel, false) + "studium": # Zipfel + var z := Prims.cyl(_ober, kopf + Vector3(0, 0.36, 0), 0.2, 0.4, dunkel, false) + (z.mesh as CylinderMesh).top_radius = 0.0 + "werkstatt": # Kappe mit Schirm + Prims.cyl(_ober, kopf + Vector3(0, 0.18, 0), 0.25, 0.1, dunkel, false) + Prims.box(_ober, kopf + Vector3(0, 0.14, -0.3), Vector3(0.3, 0.04, 0.2), dunkel, false) + _: + pass + + +## Angst duckt, Erschöpfung beugt, Unruhe zappelt -- solange die Klemme nicht +## überwunden ist. So sieht man Probleme beim Beobachten am Körper. +func _haltung(delta: float, geht: bool) -> void: + var id := str(data["id"]) + var offen: Dictionary = {} + for k in data.get("probleme", []): + if not Game.problem_ueberwunden(id, str(k)): + offen[str(k)] = true + var neigung := 0.38 if offen.has("erschoepfung") else 0.0 + var groesse := 0.86 if offen.has("angst") else 1.0 + var zappeln := 0.0 + if offen.has("unruhe") and not geht: + zappeln = sin(Time.get_ticks_msec() * 0.011) * 0.06 + _ober.rotation.x = lerpf(_ober.rotation.x, -neigung, 1.0 - pow(0.02, delta)) + _ober.rotation.z = lerpf(_ober.rotation.z, zappeln, 1.0 - pow(0.001, delta)) + _ober.scale = _ober.scale.lerp(Vector3.ONE * groesse, 1.0 - pow(0.02, delta)) + var schwung := sin(_bob) * 0.7 if geht else 0.0 + for i in range(_arme.size()): + var a: Node3D = _arme[i] + a.rotation.x = lerpf(a.rotation.x, schwung * (1.0 if i == 0 else -1.0), + 1.0 - pow(0.001, delta)) ## [Ort, Tätigkeit, Slotnummer] zur Stunde h. Steht in dem Slot ein System, @@ -95,6 +183,7 @@ func _process(roh: float) -> void: _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 + _path = _mit_etagen(_path, place, soll[0], war_unterwegs) place = soll[0] activity = "unterwegs" elif _path.is_empty() and activity != soll[1]: @@ -102,9 +191,9 @@ func _process(roh: float) -> void: if not _path.is_empty(): var ziel: Vector3 = _path[0] - ziel.y = 0.0 var hier := global_position - hier.y = 0.0 + if boden.is_valid(): + ziel.y = hier.y # das Gelände bestimmt die Höhe, nicht der Wegpunkt var d := ziel - hier if d.length() < 0.4: _path.remove_at(0) @@ -121,6 +210,9 @@ func _process(roh: float) -> void: if _path.is_empty(): _visual.position.y = lerpf(_visual.position.y, 0.0, 1.0 - pow(0.01, delta)) + _haltung(delta, not _path.is_empty()) + if boden.is_valid(): + global_position.y = float(boden.call(global_position.x, global_position.z)) _visual.rotation.z = lerp_angle(_visual.rotation.z, PI * 0.5 if activity == "schlaeft" else 0.0, 1.0 - pow(0.01, delta)) @@ -161,7 +253,7 @@ func beschriftung() -> String: ## Sichtbare Reaktion auf eine angewandte Technik. func reagiere(success: bool, text: String) -> void: - var l := Prims.label(self, Vector3(0, 3.5, 0), text, 0.34, + var l := Prims.label(self, Vector3(0, 3.0, 0), text, 0.4, Color(0.55, 1.0, 0.6) if success else Color(1, 0.55, 0.45)) var tw := l.create_tween() tw.set_parallel(true) diff --git a/scripts/dream_db.gd b/scripts/dream_db.gd index ca8f75d..302d69b 100644 --- a/scripts/dream_db.gd +++ b/scripts/dream_db.gd @@ -13,6 +13,13 @@ 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 +## Die Insel ist ein Hügel. Innen eben -- Dorfplatz, Hörsaal, Tor --, ab FLACH +## steigt sie an. Wer später einzieht, wohnt weiter außen und damit höher: +## das Dorf wächst den Hügel hinauf, und vom Platz aus sieht man, wie weit. +const FLACH := 24.0 +const STEIGUNG := 0.30 +const GIPFEL := 36.0 + static var FEST: Dictionary = { "hoersaal": { "name": "Hörsaal der Wege", "pos": [0.0, 34.0], "size": [16.0, 12.0], @@ -56,6 +63,72 @@ static var ABLAEUFE: Array = [ ] +## Höhe des nackten Hangs bei diesem Abstand von der Mitte. +static func hang(r: float) -> float: + return clampf((r - FLACH) * STEIGUNG, 0.0, GIPFEL) + + +## Jedes Gebäude steht auf einer ebenen Terrasse in Höhe seiner Tür -- so ist +## die Türseite ebenerdig, die Bergseite eingegraben. Das Tor hat keine Tür; +## seine Terrasse liegt auf Höhe der Dorfseite. +const TOR_FLAECHE := 12.0 +const TERRASSE_RAND := 2.6 # bis zur Türschwelle davor +const BOESCHUNG := 5.0 # so weit läuft die Terrasse in den Hang aus + +static func terrasse(key: String) -> float: + if not PLACES.has(key): + return 0.0 + if key == "tor": + var c := Orte.center(PLACES, key) + return hang(Vector2(c.x, c.z).length() - TOR_FLAECHE * 0.5) + var t := Orte.door(PLACES, key) + return hang(Vector2(t.x, t.z).length()) + + +## Höhe des Bodens an dieser Stelle: Terrasse in einem Gebäude, sonst Hang. +## Wo sich Terrassenränder überlappen, gilt das Gebäude, in dem der Punkt +## wirklich liegt -- erst danach der Rand. +static func hoehe_bei(x: float, z: float) -> float: + var h := hang(Vector2(x, z).length()) + var rand_treffer := "" + var boeschung_key := "" + var boeschung_t := 1.0 + var reihenfolge: Array = PLACES.keys() + reihenfolge.erase("tor") + reihenfolge.append("tor") + for key in reihenfolge: + var s: Array = PLACES[key]["size"] + var bx: float = float(s[0]) + var bz: float = float(s[1]) + if key == "tor": + bx = TOR_FLAECHE + bz = TOR_FLAECHE + if bx <= 0.0 or not Game.ort_offen(key): + continue + var c := Orte.center(PLACES, key) + var dx := absf(x - c.x) - bx * 0.5 + var dz := absf(z - c.z) - bz * 0.5 + if dx <= 0.0 and dz <= 0.0: + return terrasse(key) + if dx <= TERRASSE_RAND and dz <= TERRASSE_RAND: + if rand_treffer == "": + rand_treffer = key + continue + # außerhalb des Rands: die Terrasse läuft als Böschung in den Hang aus + var d := maxf(dx, dz) - TERRASSE_RAND + if d < BOESCHUNG: + var t := d / BOESCHUNG + if t < boeschung_t: + boeschung_t = t + boeschung_key = key + if rand_treffer != "": + return terrasse(rand_treffer) + if boeschung_key != "": + var tt := boeschung_t * boeschung_t * (3.0 - 2.0 * boeschung_t) # weich + return lerpf(terrasse(boeschung_key), h, tt) + return h + + static func haus_key(i: int) -> String: return "haus_%d" % i @@ -171,11 +244,16 @@ static func _platz_frei(p: Vector3, groesse: Vector2) -> bool: return false for key in PLACES: var s: Array = PLACES[key]["size"] - if float(s[0]) <= 0.0: + var bx: float = float(s[0]) + var bz: float = float(s[1]) + if key == "tor": + bx = TOR_FLAECHE + bz = TOR_FLAECHE + if bx <= 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 + var dx: float = absf(p.x - c.x) - (groesse.x + bx) * 0.5 + var dz: float = absf(p.z - c.z) - (groesse.y + bz) * 0.5 if dx < HAUS_ABSTAND and dz < HAUS_ABSTAND: return false for k in NETZ.kanten: @@ -249,6 +327,18 @@ static func _anschliessen(key: String) -> void: var an := NETZ.andocken(tuer) var von: Vector3 = an["punkt"] var linie := _route_um_haeuser(tuer, von, key) + if not _linie_frei(linie, key): + # Der nächste Punkt des Netzes liegt eingeklemmt. Dann den nächsten + # Knoten nehmen, zu dem ein sauberer Weg führt -- nie durch ein Haus. + var reihe: Array = range(NETZ.knoten.size()) + reihe.sort_custom(func(i, j) -> bool: + return NETZ.knoten[i].distance_to(tuer) < NETZ.knoten[j].distance_to(tuer)) + for idx in reihe.slice(0, 16): + var l2 := _route_um_haeuser(tuer, NETZ.knoten[idx], key) + if _linie_frei(l2, key): + linie = l2 + an = {"punkt": NETZ.knoten[idx], "knoten": idx} + break var vorher := -1 for i in range(linie.size()): var idx := NETZ.fuege_knoten(linie[i]) @@ -264,17 +354,33 @@ static func _anschliessen(key: String) -> void: _schleife(int(ANKER[key]), key) +## Schneidet diese Linie irgendwo ein Gebäude? +static func _linie_frei(linie: Array, ausser: String) -> bool: + for i in range(linie.size() - 1): + if not _weg_frei(linie[i], linie[i + 1], ausser): + return false + return true + + ## 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. +## Dijkstra über ein Raster -- erst grob, dann fein --, danach 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 + for schritt_start in [5.0, 2.5]: + var weg := _route_raster(a, b, ausser, schritt_start) + if not weg.is_empty(): + return weg + return [a, b] + + +static func _route_raster(a: Vector3, b: Vector3, ausser: String, schritt_start: float) -> Array: + var rand := 34.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 schritt := schritt_start var nx := int((max_x - min_x) / schritt) + 1 var nz := int((max_z - min_z) / schritt) + 1 while nx * nz > 2600: @@ -320,7 +426,7 @@ static func _route_um_haeuser(a: Vector3, b: Vector3, ausser: String) -> Array: if not offen.has(n): offen.append(n) if not gefunden: - return [a, b] + return [] var roh: Array = [] var cur: Vector2i = ziel diff --git a/scripts/dream_world.gd b/scripts/dream_world.gd index f48d447..8130c1c 100644 --- a/scripts/dream_world.gd +++ b/scripts/dream_world.gd @@ -1,18 +1,22 @@ extends Node3D class_name DreamWorld -## Die Traumwelt: eine schwebende Insel mit einem Dorf. Vier Wesen wohnen hier, -## jedes in seinem Haus, jedes mit eigenem Tagesablauf. +## Die Traumwelt: eine schwebende Insel, die ein Hügel ist. Unten der Dorfplatz +## um den Traumbaum, der Hörsaal und das Tor; hangaufwärts wächst das Dorf, +## ein Haus je erfülltem Traum, jedes eine Terrasse höher. var spawn := Vector3(0, 2.0, 20.0) var bewohner: Array = [] +var spieler: Node3D = null # setzt main, für den Etagenschnitt +var _bauten: Array = [] -var _deko: Array = [] +var _lichter: Array = [] var _freihalten: Array[Vector3] = [] func _ready() -> void: _environment() _insel() + _wege() _haeuser() _lehrpulte() _schwarzes_brett() @@ -22,146 +26,178 @@ func _ready() -> void: _wesen() +## Dämmerung: tiefes Blau oben, warmes Lavendel am Horizont, Nebel im Tal. func _environment() -> void: var we := WorldEnvironment.new() var env := Environment.new() env.background_mode = Environment.BG_SKY var sky := Sky.new() var mat := ProceduralSkyMaterial.new() - mat.sky_top_color = Color(0.10, 0.05, 0.24) - mat.sky_horizon_color = Color(0.42, 0.24, 0.55) - mat.ground_bottom_color = Color(0.06, 0.03, 0.14) - mat.ground_horizon_color = Color(0.32, 0.18, 0.45) - mat.sun_angle_max = 40.0 + mat.sky_top_color = Color(0.05, 0.05, 0.19) + mat.sky_horizon_color = Color(0.58, 0.34, 0.56) + mat.ground_bottom_color = Color(0.04, 0.03, 0.11) + mat.ground_horizon_color = Color(0.30, 0.19, 0.42) + mat.sun_angle_max = 30.0 sky.sky_material = mat env.sky = sky - env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY - env.ambient_light_energy = 2.1 + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.58, 0.50, 0.78) + env.ambient_light_energy = 1.5 env.glow_enabled = true - env.glow_intensity = 0.32 - env.glow_bloom = 0.05 + env.glow_intensity = 0.42 + env.glow_bloom = 0.08 env.fog_enabled = true - env.fog_light_color = Color(0.30, 0.18, 0.45) - env.fog_density = 0.0015 + env.fog_light_color = Color(0.46, 0.30, 0.56) + env.fog_density = 0.0011 we.environment = env add_child(we) var sun := DirectionalLight3D.new() - sun.rotation_degrees = Vector3(-46, 25, 0) - sun.light_energy = 1.5 - sun.light_color = Color(0.85, 0.80, 1.0) + sun.rotation_degrees = Vector3(-34, 40, 0) + sun.light_energy = 1.3 + sun.light_color = Color(1.0, 0.84, 0.74) sun.shadow_enabled = true - sun.shadow_blur = 2.0 + sun.shadow_blur = 2.5 add_child(sun) +# ------------------------------------------------------------------ Insel + +## Der Hügel als Höhenfeld: ein Gitter, das die Höhe von DreamDB abfragt -- +## innen eben, außen ansteigend, in jedem Gebäude eine Terrasse. Darauf läuft +## der Spieler (Kollision) und die Bewohner (Bodenhöhe je Bild). func _insel() -> void: var radius := Game.insel_radius() - Prims.cyl(self, Vector3(0, -1.03, 0), radius, 2.0, Color(0.48, 0.40, 0.64)) - 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) + var schritt := 1.5 + var n := int(ceil(radius * 2.0 / schritt)) + 7 + var halb := float(n - 1) * schritt * 0.5 + var daten := PackedFloat32Array() + daten.resize(n * n) + var hoehen: Array = [] + for iz in range(n): + var zeile: PackedFloat32Array = PackedFloat32Array() + zeile.resize(n) + for ix in range(n): + var x := -halb + float(ix) * schritt + var z := -halb + float(iz) * schritt + var r := Vector2(x, z).length() + var h: float + if r <= radius - 4.0: + h = DreamDB.hoehe_bei(x, z) + elif r <= radius: + # der Rand rundet sich ab, statt abzubrechen + var t := (r - (radius - 4.0)) / 4.0 + h = lerpf(DreamDB.hoehe_bei(x, z), -1.5, t * t) + else: + # die Kante fällt ab -- die Insel schwebt. Begrenzt, sonst hängt + # das quadratische Gitter in den Ecken als Vorhang herab. + h = maxf(-1.5 - (r - radius) * 2.4, -12.0) + zeile[ix] = h + daten[iz * n + ix] = h + hoehen.append(zeile) - _wege() + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + var tief := Color(0.40, 0.40, 0.62) + var hoch := Color(0.70, 0.60, 0.88) + var kante := Color(0.16, 0.12, 0.27) + var eck := func(ix: int, iz: int) -> void: + var x := -halb + float(ix) * schritt + var z := -halb + float(iz) * schritt + var h: float = hoehen[iz][ix] + var hx0: float = hoehen[iz][maxi(ix - 1, 0)] + var hx1: float = hoehen[iz][mini(ix + 1, n - 1)] + var hz0: float = hoehen[maxi(iz - 1, 0)][ix] + var hz1: float = hoehen[mini(iz + 1, n - 1)][ix] + var nrm := Vector3(-(hx1 - hx0) / (2.0 * schritt), 1.0, -(hz1 - hz0) / (2.0 * schritt)).normalized() + var c: Color = kante if h < 0.0 else tief.lerp(hoch, clampf(h / DreamDB.GIPFEL, 0.0, 1.0)) + st.set_color(c) + st.set_normal(nrm) + st.add_vertex(Vector3(x, h, z)) + for iz in range(n - 1): + for ix in range(n - 1): + var mx := -halb + (float(ix) + 0.5) * schritt + var mz := -halb + (float(iz) + 0.5) * schritt + if Vector2(mx, mz).length() > radius + 5.0: + continue # außerhalb der Kante nichts zeichnen + eck.call(ix, iz) + eck.call(ix, iz + 1) + eck.call(ix + 1, iz) + eck.call(ix + 1, iz) + eck.call(ix, iz + 1) + eck.call(ix + 1, iz + 1) + var mi := MeshInstance3D.new() + mi.mesh = st.commit() + var m := StandardMaterial3D.new() + m.vertex_color_use_as_albedo = true + m.roughness = 0.92 + m.cull_mode = BaseMaterial3D.CULL_DISABLED + mi.material_override = m + add_child(mi) - # schwebende Deko + var body := StaticBody3D.new() + var cs := CollisionShape3D.new() + var shape := HeightMapShape3D.new() + shape.map_width = n + shape.map_depth = n + shape.map_data = daten + cs.shape = shape + cs.scale = Vector3(schritt, 1.0, schritt) + body.add_child(cs) + add_child(body) + + # der Fels darunter -- klein, die Insel soll schweben, nicht stehen + Prims.cyl(self, Vector3(0, -12.0, 0), radius * 0.52, 5.0, Color(0.17, 0.13, 0.28), false) + Prims.cyl(self, Vector3(0, -17.0, 0), radius * 0.22, 6.0, Color(0.12, 0.09, 0.21), false) + + # treibende Lichter statt schwebender Würfel var rng := RandomNumberGenerator.new() rng.seed = 7 - for i in range(34): + for i in range(44): var a := rng.randf() * TAU - 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) - var wuerfel := Prims.box(self, p, Vector3(s, s, s), c, false, 1.1) - wuerfel.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - _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) + var r := rng.randf_range(10.0, maxf(24.0, radius - 4.0)) + var p := Vector3(cos(a) * r, 0, sin(a) * r) + p.y = DreamDB.hoehe_bei(p.x, p.z) + rng.randf_range(2.5, 9.0) + var c := Color(0.62 + rng.randf() * 0.3, 0.7 + rng.randf() * 0.25, 1.0) + var licht := Prims.sphere(self, p, rng.randf_range(0.14, 0.34), c, 2.4) + licht.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _lichter.append(licht) +## Die gewachsenen Wege folgen dem Hang: jedes Band von Knoten zu Knoten ist +## geneigt. An jedem zweiten Knoten eine Laterne. func _wege() -> void: - var farbe := Color(0.66, 0.58, 0.84) + var farbe := Color(0.70, 0.62, 0.86) for k in DreamDB.NETZ.kanten: var a: Vector3 = DreamDB.NETZ.knoten[k[0]] var b: Vector3 = DreamDB.NETZ.knoten[k[1]] + a.y = DreamDB.hoehe_bei(a.x, a.z) + 0.04 + b.y = DreamDB.hoehe_bei(b.x, b.z) + 0.04 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) + var band := Prims.box(self, (a + b) * 0.5, Vector3(2.6, 0.06, d.length()), farbe, false) + band.basis = Basis.looking_at(d.normalized(), Vector3.UP) + var nr := 0 for k in DreamDB.NETZ.knoten: - Prims.cyl(self, k + Vector3(0, 0.035, 0), 1.3, 0.06, farbe, false) + var p: Vector3 = k + p.y = DreamDB.hoehe_bei(p.x, p.z) + Prims.cyl(self, p + Vector3(0, 0.04, 0), 1.3, 0.06, farbe, false) + if nr % 2 == 0 and Vector2(p.x, p.z).length() > 12.0: + _laterne(p + Vector3(1.6, 0, 0)) + nr += 1 +## Eine Laterne: Pfahl und leuchtende Kugel. Kein eigenes Licht -- der Glow +## trägt sie, und so dürfen es viele sein. +func _laterne(p: Vector3) -> void: + Prims.cyl(self, p + Vector3(0, 1.1, 0), 0.09, 2.2, Color(0.36, 0.30, 0.48), false) + var k := Prims.sphere(self, p + Vector3(0, 2.35, 0), 0.24, Color(1.0, 0.86, 0.62), 2.6) + k.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + + +## Jedes Gebäude steht auf seiner Terrasse: Wände, Dach, Laterne an der Tür, +## Licht innen. Das Schild darüber. func _haeuser() -> void: for key in DreamDB.PLACES: var pl: Dictionary = DreamDB.PLACES[key] @@ -169,26 +205,29 @@ func _haeuser() -> void: if s[0] <= 0.0 or not Game.ort_offen(key): continue var c := Orte.center(DreamDB.PLACES, key) + c.y = DreamDB.terrasse(key) var groesse := Vector3(s[0], 3.2, s[1]) var farbe: Color = pl["color"] - Prims.room(self, c, groesse, farbe, farbe.darkened(0.35), + var waende: Array = Prims.room(self, c, groesse, farbe, farbe.darkened(0.35), Orte.DIR_VEC[pl["dir"]], 3.4, true) - Prims.label(self, c + Vector3(0, 5.0, 0), pl["name"], 0.6, Color(1, 0.95, 0.85)) + var dach := Prims.dach(self, c, groesse, farbe) + _bauten.append({"c": c, "size": Vector2(groesse.x, groesse.z), "hoch": groesse.y + 3.0, + "etagen": [], "dach": [dach], "waende": [waende]}) + Prims.label(self, c + Vector3(0, groesse.y + 4.2, 0), pl["name"], 0.6, Color(1, 0.95, 0.85)) _einrichtung(c, groesse, farbe, key) var licht := OmniLight3D.new() licht.position = c + Vector3(0, 2.4, 0) - licht.omni_range = 18.0 - licht.light_energy = 2.2 + licht.omni_range = 16.0 + licht.light_energy = 1.8 licht.light_color = farbe.lightened(0.5) add_child(licht) - # Weg von der Tür zum Ring var tuer := Orte.door(DreamDB.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.4, 0.06, weg.length()), Color(0.66, 0.58, 0.84), false) - b.rotation.y = atan2(weg.x, weg.z) + tuer.y = c.y + var seite: Vector3 = Orte.DIR_VEC[pl["dir"]] + var quer := Vector3(-seite.z, 0, seite.x) + _laterne(tuer - seite * 1.2 + quer * 2.4) func _einrichtung(c: Vector3, groesse: Vector3, farbe: Color, key: String) -> void: @@ -250,6 +289,7 @@ func _lehrpulte() -> void: var tief: float = float(pl["size"][1]) var innen: Vector3 = Orte.DIR_VEC[pl["dir"]] * -1.0 var c := Orte.center(DreamDB.PLACES, key) + c.y = DreamDB.terrasse(key) var pos := c + innen * (tief * 0.5 - 2.6) var farbe: Color = pl["color"] Prims.box(self, pos + Vector3(0, 0.55, 0), Vector3(2.4, 1.1, 0.9), farbe.darkened(0.35)) @@ -291,23 +331,48 @@ func _marke(pos: Vector3, kind: String, titel: String) -> Node3D: return n +## Der Dorfplatz: der Traumbaum in der Mitte, Bänke darum, Laternen am Rand. func _lichtung() -> void: var c := Orte.center(DreamDB.PLACES, "lichtung") Prims.cyl(self, c + Vector3(0, 0.04, 0), 11.0, 0.12, Color(0.60, 0.52, 0.78), false) - Prims.cyl(self, c + Vector3(0, 0.14, 0), 3.2, 0.2, Color(0.55, 0.80, 1.0, 0.5), false, 1.2) - for i in range(8): - var a := TAU * float(i) / 8.0 - var p := c + Vector3(cos(a) * 8.5, 0, sin(a) * 8.5) - Prims.cyl(self, p + Vector3(0, 1.6, 0), 0.28, 3.2, Color(0.72, 0.64, 0.88)) - Prims.sphere(self, p + Vector3(0, 3.6, 0), 0.42, Color(0.75, 0.85, 1.0), 2.0) - Prims.label(self, c + Vector3(0, 5.2, 0), str(DreamDB.PLACES["lichtung"]["name"]), 0.6, + Prims.cyl(self, c + Vector3(0, 0.14, 0), 3.4, 0.2, Color(0.30, 0.24, 0.40), false) + + # der Traumbaum + Prims.cyl(self, c + Vector3(0, 4.5, 0), 1.1, 9.0, Color(0.36, 0.26, 0.40)) + var kronen := [ + [Vector3(0, 9.6, 0), 3.6, Color(0.62, 0.55, 0.96)], + [Vector3(1.6, 11.4, -0.8), 2.7, Color(0.70, 0.62, 1.0)], + [Vector3(-1.4, 12.2, 1.0), 2.4, Color(0.55, 0.78, 1.0)], + [Vector3(0.3, 14.0, 0.2), 1.7, Color(0.80, 0.90, 1.0)], + ] + for k in kronen: + var s := Prims.sphere(self, c + k[0], k[1], k[2], 1.3) + s.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + var licht := OmniLight3D.new() + licht.position = c + Vector3(0, 11.0, 0) + licht.omni_range = 46.0 + licht.light_energy = 3.2 + licht.light_color = Color(0.72, 0.78, 1.0) + add_child(licht) + + for i in range(4): + var a := TAU * float(i) / 4.0 + 0.4 + var p := c + Vector3(cos(a) * 5.6, 0.42, sin(a) * 5.6) + var bank := Prims.box(self, p, Vector3(2.6, 0.16, 0.7), Color(0.46, 0.38, 0.58)) + bank.rotation.y = -a + Prims.box(self, p + Vector3(0, -0.2, 0), Vector3(0.4, 0.3, 0.6), Color(0.36, 0.30, 0.48), false) + for i in range(4): + var a := TAU * float(i) / 4.0 + _laterne(c + Vector3(cos(a) * 8.8, 0, sin(a) * 8.8)) + Prims.label(self, c + Vector3(0, 16.0, 0), str(DreamDB.PLACES["lichtung"]["name"]), 0.6, Color(1, 0.95, 0.85)) func _tor_zur_erde() -> void: var pos := Orte.center(DreamDB.PLACES, "tor") - # flach und ohne Kollision wie die Lichtung — eine 0,6 hohe Stufe hätte den - # Spieler vor dem Tor stehen lassen + pos.y = DreamDB.hoehe_bei(pos.x, pos.z) + # flach und ohne Kollision wie der Platz — eine Stufe hätte den Spieler + # vor dem Tor stehen lassen Prims.cyl(self, pos + Vector3(0, 0.05, 0), 5.5, 0.1, Color(0.35, 0.30, 0.50), false) for i in range(10): var a := PI * float(i) / 9.0 @@ -317,24 +382,96 @@ func _tor_zur_erde() -> void: Color(0.45, 0.75, 1.0, 0.28), false, 1.0) scheibe.rotation.x = PI * 0.5 Prims.label(self, pos + Vector3(0, 6.4, 0), "Tor zur Erde", 0.8, Color(0.7, 0.95, 1)) - _marke(pos + Vector3(0, 1.6, 0), "erde", "Tor zur Erde") +## 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) + p.y = DreamDB.hoehe_bei(p.x, p.z) + _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) + + ## Am Anfang ist das Dorf leer; mit jedem erfüllten Traum zieht ein Wesen ein. +## Jedes läuft auf dem Hügel -- der Boden sagt ihm, wie hoch. func _wesen() -> void: for i in range(DreamDB.LIST.size()): if not Game.wesen_eingezogen(i): continue var w := Bewohner.new() add_child(w) + w.boden = Callable(DreamDB, "hoehe_bei") w.setup(DreamDB.LIST[i], i, DreamDB.PLACES, DreamDB.NETZ, DreamDB.ANKER) bewohner.append(w) func _process(delta: float) -> void: - for i in range(_deko.size()): - var n: Node3D = _deko[i] - n.rotation.y += delta * (0.2 + 0.05 * (i % 5)) - n.rotation.x += delta * 0.13 - n.position.y += sin(Time.get_ticks_msec() * 0.0007 + i) * delta * 0.6 + if spieler != null: + Prims.schnitt(_bauten, spieler.global_position, spieler.cam.global_position) + var t := Time.get_ticks_msec() * 0.001 + for i in range(_lichter.size()): + var n: Node3D = _lichter[i] + n.position += Vector3(sin(t * 0.5 + i) * 0.35, cos(t * 0.7 + i * 0.3) * 0.25, + cos(t * 0.4 + i * 0.5) * 0.35) * delta diff --git a/scripts/earth_world.gd b/scripts/earth_world.gd index ae0b319..82a7ce2 100644 --- a/scripts/earth_world.gd +++ b/scripts/earth_world.gd @@ -2,8 +2,10 @@ extends Node3D class_name EarthWorld ## Die Erde: eine Kleinstadt mit vier Menschen, die ihren Tag leben. -var spawn := Vector3(0, 3.0, 12.0) +var spawn := Vector3(0, 1.4, 12.0) var humans: Array = [] +var spieler: Node3D = null # setzt main, für den Etagenschnitt +var _bauten: Array = [] # alles, was der Etagenschnitt aufschneidet var _sun: DirectionalLight3D var _sky: ProceduralSkyMaterial @@ -64,8 +66,8 @@ func _strassen() -> void: 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. +## Alles, was nicht Wohnung oder Sonderbau ist, steht als geschlossener Klotz +## mit ein bis vier Stockwerken da. Erst dadurch wird der Bezirk dicht. func _bebauung() -> void: var rng := RandomNumberGenerator.new() rng.seed = 8123 @@ -81,18 +83,26 @@ func _bebauung() -> void: var g: Vector2 = p["size"] if minf(g.x, g.y) < 3.0: continue - var hoehe := rng.randf_range(4.0, 9.0) + var n := 1 + rng.randi_range(0, 3) + var hoehe := Orte.hoehe(n) 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), + var bau := Node3D.new() + add_child(bau) + Prims.box(bau, c + Vector3(0, hoehe * 0.5, 0), Vector3(g.x, hoehe, g.y), ton) + for f in range(n): + _fenster(bau, c + Vector3(0, Orte.hoehe(f), 0), g, ton) + Prims.box(bau, 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), + Prims.box(bau, c + Vector3(0, hoehe + 0.9, 0), Vector3(g.x * 0.4, 1.0, g.y * 0.4), ton.darkened(0.1)) + _bauten.append({"c": c, "size": g, "hoch": hoehe, "etagen": [bau], "dach": [], + "waende": [], "voll": true}) -## Wohnungen und Sonderbauten stehen offen, damit der Geist von oben hineinsieht. +## Wohnungen und Sonderbauten: geschlossene Häuser mit Stockwerken, Treppenhaus +## und Möbeln. Der Geist gleitet hinein, der Etagenschnitt öffnet sie ihm. func _orte() -> void: for key in HumanDB.PLACES: var pl: Dictionary = HumanDB.PLACES[key] @@ -100,57 +110,100 @@ func _orte() -> void: if float(s[0]) <= 0.0: continue var c := Orte.center(HumanDB.PLACES, key) - var groesse := Vector3(float(s[0]), 2.3, float(s[1])) + var groesse := Vector3(float(s[0]), Orte.ETAGE, float(s[1])) var farbe: Color = pl["color"] - 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: float = maxf(1.6, minf(3.0, minf(groesse.x, groesse.z) - 3.0)) + var n := Orte.etagen(HumanDB.PLACES, key) + var etagen: Array = [] + var waende: Array = [] + for f in range(n): + var eb := Node3D.new() + add_child(eb) + var cf := c + Vector3(0, Orte.hoehe(f), 0) + var ton := farbe.lightened(0.06 * float(f)) + waende.append(Prims.room(eb, cf, groesse, ton, farbe.darkened(0.35), + Orte.DIR_VEC[pl["dir"]] if f == 0 else Vector3.ZERO, tuer, true)) + _fenster(eb, cf, Vector2(groesse.x, groesse.z), ton) + _einrichtung(eb, cf, groesse, farbe, key if f == 0 or HumanDB.ETAGEN_ARBEIT.has(key) + else "heim_") + if f + 1 < n: + _treppe(eb, key, f) + var licht := OmniLight3D.new() + licht.position = cf + Vector3(0, 2.2, 0) + licht.omni_range = 12.0 + licht.light_color = Color(1.0, 0.85, 0.6) + licht.light_energy = 0.0 + eb.add_child(licht) + _nachtlichter.append(licht) + etagen.append(eb) + var dach := Node3D.new() + add_child(dach) + Prims.box(dach, c + Vector3(0, Orte.hoehe(n) + 0.1, 0), + Vector3(groesse.x + 0.4, 0.2, groesse.z + 0.4), farbe.darkened(0.3)) + Prims.label(self, c + Vector3(0, Orte.hoehe(n) + 1.6, 0), str(pl["name"]), 0.55, + Color(1, 1, 0.9)) + _bauten.append({"c": c, "size": Vector2(groesse.x, groesse.z), "hoch": Orte.hoehe(n), + "etagen": etagen, "dach": [dach], "waende": waende}) - 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 Fensterband je Wand, damit man die Stockwerke von außen zählt. +func _fenster(parent: Node, cf: Vector3, g: Vector2, ton: Color) -> void: + var glas := ton.darkened(0.55).lerp(Color(0.35, 0.45, 0.6), 0.5) + for d: Vector3 in [Vector3.FORWARD, Vector3.BACK, Vector3.LEFT, Vector3.RIGHT]: + var quer := absf(d.z) > 0.0 + var laenge: float = (g.x if quer else g.y) - 2.4 + if laenge < 1.0: + continue + var off := Vector3(d.x * (g.x * 0.5 + 0.05), 1.75, d.z * (g.y * 0.5 + 0.05)) + Prims.box(parent, cf + off, Vector3(laenge, 0.9, 0.08) if quer else Vector3(0.08, 0.9, laenge), + glas, false) + + +## Eine Rampe von Podest zu Podest -- die Treppe, die die Bewohner hochgehen. +func _treppe(parent: Node, key: String, f: int) -> void: + var a := Orte.treppe(HumanDB.PLACES, key, f) + var b := Orte.treppe(HumanDB.PLACES, key, f + 1) + var d := b - a + var stufe := Prims.box(parent, (a + b) * 0.5, Vector3(1.2, 0.18, d.length()), + Color(0.55, 0.50, 0.46), false) + stufe.basis = Basis.looking_at(d.normalized(), Vector3.UP) + Prims.box(parent, b + Vector3(0, -0.09, 0), Vector3(1.4, 0.18, 1.4), Color(0.55, 0.50, 0.46), false) ## Ein paar Möbel, damit man sieht, wofür ein Raum da ist. -func _einrichtung(c: Vector3, groesse: Vector3, farbe: Color, key: String) -> void: +func _einrichtung(parent: Node, c: Vector3, groesse: Vector3, farbe: Color, key: String) -> void: 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), + Prims.box(parent, 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), + Prims.box(parent, 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), + Prims.box(parent, 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(-w * 0.5 + float(i) * w * 0.5, 0.4, 0.4), 0.7, 0.8, + Prims.cyl(parent, 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, -h * 0.7), Vector3(groesse.x - 3.0, 1.1, 0.8), + Prims.box(parent, 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(3): - Prims.box(self, c + Vector3(-w * 0.5 + float(i) * w * 0.5, 0.75, 0), + Prims.box(parent, 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, h * 0.5), Vector3(groesse.x - 3.0, 1.1, 1.0), + Prims.box(parent, 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, -h * 0.6), Vector3(2.6, 1.7, 1.2), + Prims.box(parent, 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, -h * 0.6 + float(i) * h * 0.6), + Prims.box(parent, 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), + Prims.box(parent, 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.6, 0), Vector3(groesse.x - 4.0, 1.2, 1.4), + Prims.box(parent, c + Vector3(0, 0.6, 0), Vector3(groesse.x - 4.0, 1.2, 1.4), Color(0.52, 0.48, 0.46)) @@ -209,6 +262,8 @@ func _menschen() -> void: func _process(_delta: float) -> void: _himmel_aktualisieren() + if spieler != null: + Prims.schnitt(_bauten, spieler.global_position, spieler.cam.global_position) func _himmel_aktualisieren() -> void: diff --git a/scripts/game.gd b/scripts/game.gd index ed62317..446c5a2 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -64,6 +64,7 @@ func _setup_input() -> void: "folgen": [KEY_F], "notieren": [KEY_R], "journal": [KEY_J, KEY_TAB], + "drehen": [KEY_Q], "tempo_1": [KEY_1, KEY_KP_1], "tempo_2": [KEY_2, KEY_KP_2], "tempo_3": [KEY_3, KEY_KP_3], diff --git a/scripts/human_db.gd b/scripts/human_db.gd index b0ce1ac..94afd9d 100644 --- a/scripts/human_db.gd +++ b/scripts/human_db.gd @@ -26,12 +26,15 @@ static var ANKER: Dictionary = {} ## 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)], + ["uni", "Hochschule", Color(0.72, 0.70, 0.66), 3], + ["cafe", "Café Nadel", Color(0.82, 0.62, 0.45), 2], + ["buero", "Büro Ostwind", Color(0.62, 0.66, 0.72), 4], + ["baeckerei", "Bäckerei Keller", Color(0.85, 0.72, 0.50), 2], + ["werkstatt", "Werkstatt Loos", Color(0.70, 0.58, 0.55), 1], ] +## Arbeitsorte, an denen die Leute auf verschiedenen Etagen sitzen. Café, +## Bäckerei und Werkstatt spielen sich unten ab. +static var ETAGEN_ARBEIT: Array = ["buero", "uni"] static var PLACES: Dictionary = {} static var LIST: Array = [] @@ -408,6 +411,7 @@ static func _sonderbauten_verteilen() -> void: "size": [p["size"].x, p["size"].y], "dir": p["dir"], "color": SONDER[s][2], + "etagen": int(SONDER[s][3]), } @@ -436,9 +440,21 @@ static func _wohnhaeuser_verteilen() -> void: "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), + "etagen": 2 + (i * 5 + 1) % 3, } +## In welcher Etage jemand wohnt und arbeitet. Wohnhäuser sind Mehrfamilien- +## häuser: die eigene Wohnung liegt irgendwo im Haus. +static func etagen_fuer(i: int, heim: String, arbeit: String) -> Dictionary: + var out: Dictionary = {} + if PLACES.has(heim): + out[heim] = (i * 7 + 3) % Orte.etagen(PLACES, heim) + if PLACES.has(arbeit) and ETAGEN_ARBEIT.has(arbeit): + out[arbeit] = i % Orte.etagen(PLACES, arbeit) + return out + + static func _anker_setzen() -> void: for key in PLACES: var s: Array = PLACES[key]["size"] @@ -572,6 +588,7 @@ static func mensch_aus_profil(pr: Dictionary, i: int) -> Dictionary: "eigen": true, "mission": true, "heim": heim, + "etage": etagen_fuer(i, heim, arbeit), "farbe": Color.from_hsv(fmod(0.02 + float(i) * 0.191, 1.0), 0.5, 0.8), "routine": _tag_belegen(TAGESTYPEN[typ], heim, arbeit, bild["blocker"], bild["ursachen"], i), @@ -614,6 +631,7 @@ static func _mensch(i: int) -> Dictionary: "verhalten": {}, "mission": true, "heim": heim, + "etage": etagen_fuer(i, heim, arbeit), "farbe": Color.from_hsv(fmod(0.02 + float(i) * 0.191, 1.0), 0.5, 0.8), "routine": _tagesablauf(i, heim, arbeit), } diff --git a/scripts/main.gd b/scripts/main.gd index dc0e1ad..313abb4 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -17,10 +17,8 @@ func _ready() -> void: ui = UI.new() add_child(ui) ui.panel_opened.connect(func() -> void: - Input.mouse_mode = Input.MOUSE_MODE_VISIBLE player.look_enabled = false) ui.panel_closed.connect(func() -> void: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED player.look_enabled = true) player = Player.new() @@ -31,7 +29,7 @@ func _ready() -> void: Color(0.8, 0.95, 1))) _traumwelt() - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE func _traumwelt() -> void: @@ -39,8 +37,7 @@ func _traumwelt() -> void: _setze_welt(DreamWorld.new()) player.set_mode(Player.Mode.TRAUM) player.global_position = (welt as DreamWorld).spawn - player.yaw = 0.0 - player.pitch = -0.18 + player.yaw = Player.CAM_YAW func _erde() -> void: @@ -48,8 +45,7 @@ func _erde() -> void: _setze_welt(EarthWorld.new()) player.set_mode(Player.Mode.GEIST) player.global_position = (welt as EarthWorld).spawn - player.yaw = 0.0 - player.pitch = -0.42 + player.yaw = Player.CAM_YAW func _setze_welt(neu: Node3D) -> void: @@ -59,6 +55,7 @@ func _setze_welt(neu: Node3D) -> void: welt = neu add_child(welt) move_child(welt, 0) + welt.set("spieler", player) # für den Etagenschnitt Game.teleport_lock = 1.0 _fokus = null _fokus_mensch = null @@ -92,7 +89,10 @@ func _fokus_suchen() -> void: if auf_erde: var bestm := REICHWEITE_MENSCH for m in (welt as EarthWorld).humans: - var d: float = p.distance_to((m as Node3D).global_position + Vector3(0, 1.0, 0)) + var mp: Vector3 = (m as Node3D).global_position + Vector3(0, 1.0, 0) + if absf(mp.y - p.y) > 2.0: + continue # andere Etage: durch die Decke sieht man nichts + var d: float = p.distance_to(mp) if d < bestm: bestm = d _fokus_mensch = m @@ -143,17 +143,11 @@ func _notiz_hinweis(mensch: Bewohner) -> String: func _unhandled_input(event: InputEvent) -> void: - if event.is_action_pressed("ui_cancel") and not ui.offen: - Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if \ - Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED - player.look_enabled = Input.mouse_mode == Input.MOUSE_MODE_CAPTURED - get_viewport().set_input_as_handled() - return if ui.offen: return - if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_VISIBLE: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED - player.look_enabled = true + if event is InputEventMouseButton and event.pressed \ + and event.button_index == MOUSE_BUTTON_LEFT: + _klick(event.position) return for stufe in [1, 2, 3]: if event.is_action_pressed("tempo_%d" % stufe): @@ -213,6 +207,30 @@ func _notieren() -> void: Game.notieren(id, int(ziel.slot)) +## Klick auf einen Menschen: an ihn hängen, noch ein Klick löst wieder. +func _klick(pos: Vector2) -> void: + if not auf_erde: + return + var bester: Bewohner = null + var beste := 48.0 + for m in (welt as EarthWorld).humans: + var w: Vector3 = (m as Node3D).global_position + Vector3(0, 0.8, 0) + if player.cam.is_position_behind(w): + continue + var d := player.cam.unproject_position(w).distance_to(pos) + if d < beste: + beste = d + bester = m + if bester == null: + return + if player.folgt == bester: + player.folgt = null + Game.notified.emit("Du lässt %s wieder los." % bester.data["name"], Color(0.8, 0.85, 1)) + else: + player.folgt = bester + Game.notified.emit("Du hängst dich an %s." % bester.data["name"], Color(0.7, 0.9, 1)) + + ## Als Geist an einen Menschen hängen und ihn durch seinen Tag begleiten. func _verfolgen() -> void: if not auf_erde: diff --git a/scripts/orte.gd b/scripts/orte.gd index f56aa07..66230e1 100644 --- a/scripts/orte.gd +++ b/scripts/orte.gd @@ -48,6 +48,54 @@ static func kennt(a: String) -> bool: return ACTIVITY_TEXT.has(a) or Ursachen.ist_blocker(a) +const ETAGE := 3.0 # Höhe eines Stockwerks + + +static func etagen(places: Dictionary, key: String) -> int: + return maxi(1, int(places[key].get("etagen", 1))) + + +static func hoehe(e: int) -> float: + return float(e) * ETAGE + + +## Treppenhaus: die Ecke neben der Tür. Die Podeste wechseln je Etage die +## Seite, so läuft die Treppe im Zickzack an der Türwand hoch. +static func treppe(places: Dictionary, key: String, e: int) -> Vector3: + var pl: Dictionary = places[key] + var c := center(places, key) + var d: Vector3 = DIR_VEC[pl["dir"]] + var size: Array = pl["size"] + var quer_ist := absf(d.z) > 0.0 + var halb_d: float = (float(size[1]) if quer_ist else float(size[0])) * 0.5 + var halb_q: float = (float(size[0]) if quer_ist else float(size[1])) * 0.5 + var quer := Vector3(-d.z, 0, d.x) + var p := c + d * (halb_d - 1.6) + quer * (halb_q - 1.4) - quer * (3.0 if e % 2 == 1 else 0.0) + p.y = hoehe(e) + return p + + +## Punkt gleich hinter der Tür, im Haus. +static func innen_tuer(places: Dictionary, key: String) -> Vector3: + var pl: Dictionary = places[key] + var c := center(places, key) + var d: Vector3 = DIR_VEC[pl["dir"]] + var size: Array = pl["size"] + var halb: float = (float(size[1]) if absf(d.z) > 0.0 else float(size[0])) * 0.5 + return c + d * (halb - 1.2) + + +## Wegpunkte durchs Treppenhaus von Etage a nach Etage b, ohne a selbst. +static func treppen_weg(places: Dictionary, key: String, a: int, b: int) -> Array: + var out: Array = [] + var schritt := 1 if b > a else -1 + var e := a + while e != b: + e += schritt + out.append(treppe(places, key, e)) + return out + + static func center(places: Dictionary, key: String) -> Vector3: var p: Array = places[key]["pos"] return Vector3(p[0], 0.0, p[1]) diff --git a/scripts/player.gd b/scripts/player.gd index 6018f26..69b97a3 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -9,22 +9,43 @@ const SPEED_TRAUM := 8.0 const SPEED_GEIST := 11.0 const GRAVITY := 24.0 const JUMP := 9.0 -const MAUS := 0.0026 -const FOLGE_ABSTAND := 3.0 +const FOLGE_ABSTAND := 2.2 const FOLGE_SPIEL := 1.5 +const PITCH := -0.96 # 55° von oben, fest wie in Diablo +const CAM_YAW := PI * 0.25 # Blick diagonal über den Bezirk +const ZOOM := [26.0, 40.0] # zwei Stufen, Mausrad +const FOV := 35.0 +const DREHSCHRITT := PI * 0.5 var mode: int = Mode.TRAUM -var yaw := 0.0 -var pitch := -0.25 -var look_enabled := true +var yaw := CAM_YAW # Kamera- und damit Steuerrichtung +var look_enabled := true # Steuerung an (aus, solange ein Panel offen ist) var folgt: Node3D = null # Mensch, an den sich der Geist gehängt hat var pivot: Node3D -var arm: SpringArm3D +var arm: Node3D var cam: Camera3D +var _dreh_rest := 0.0 # noch zu drehender Winkel (Q) +var _zoom_stufe := 0 +var _schwebe_ziel := -1.0 # Etage, zu der der Geist gerade gleitet var body: Node3D +var _schleier: MeshInstance3D var _kern: MeshInstance3D -var _ringe: Array = [] +var _kopf: MeshInstance3D +var _augen: Array = [] +var _funken: Array = [] # kreisende Lichter, eins je gelernter Technik +var _schleppe: Array = [] # Lichtschweif hinter dem Geist +var _schleppe_pos: Array = [] +var _farbe := Color(0.55, 0.85, 1.0) +var _schleier_mat: StandardMaterial3D +var _kern_mat: StandardMaterial3D +var _kopf_mat: StandardMaterial3D +var _kreis := 0.0 + +const FARBE_TRAUM := Color(0.55, 0.85, 1.0) +const FARBE_GEIST := Color(0.50, 0.92, 0.90) # kühl: beobachten +const FARBE_FOLGE := Color(1.0, 0.72, 0.38) # warm: verfolgen +const FUNKEN_MAX := 6 func _ready() -> void: @@ -38,32 +59,47 @@ func _ready() -> void: body = Node3D.new() add_child(body) - _kern = Prims.capsule(body, Vector3(0, 1.0, 0), 0.42, 1.6, Color(0.55, 0.85, 1.0)) - _kern.material_override = Prims.mat(Color(0.55, 0.85, 1.0), 0.9) - var kopf := Prims.sphere(body, Vector3(0, 2.0, 0), 0.28, Color(0.80, 0.92, 1.0), 0.6) - kopf.name = "Kopf" - for i in range(2): - var r := Prims.cyl(body, Vector3(0, 0.5 + i * 0.9, 0), 0.75 + i * 0.15, 0.06, - Color(0.6, 0.9, 1.0), false, 1.6) - r.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - _ringe.append(r) - # kleiner Blick nach vorn, damit man die Laufrichtung sieht - Prims.box(body, Vector3(0, 1.55, -0.36), Vector3(0.5, 0.12, 0.12), Color(1, 1, 1), false, 2.0) + # Schleier: oben schmal, unten weit, halb durchsichtig + _schleier = Prims.cyl(body, Vector3(0, 1.05, 0), 0.6, 1.7, _farbe, false) + (_schleier.mesh as CylinderMesh).top_radius = 0.24 + _schleier_mat = Prims.mat(Color(_farbe, 0.55), 0.35) + _schleier.material_override = _schleier_mat + _schleier.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + # Lichtkern im Schleier + _kern = Prims.sphere(body, Vector3(0, 1.15, 0), 0.26, _farbe, 2.2) + _kern_mat = _kern.material_override as StandardMaterial3D + _kern.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _kopf = Prims.sphere(body, Vector3(0, 2.0, 0), 0.3, _farbe.lightened(0.4), 0.6) + _kopf.name = "Kopf" + _kopf_mat = _kopf.material_override as StandardMaterial3D + for sx: float in [-1.0, 1.0]: + var auge := Prims.box(body, Vector3(sx * 0.11, 2.04, -0.27), Vector3(0.07, 0.11, 0.05), + Color(1, 1, 1), false, 3.0) + auge.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _augen.append(auge) + for i in range(FUNKEN_MAX): + var f := Prims.sphere(body, Vector3.ZERO, 0.08, _farbe.lightened(0.3), 2.6) + f.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + f.visible = false + _funken.append(f) + for i in range(3): + var k := Prims.sphere(self, Vector3.ZERO, 0.16 - i * 0.04, _farbe, 1.8) + k.top_level = true + k.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + k.visible = false + _schleppe.append(k) + _schleppe_pos.append(global_position) pivot = Node3D.new() - pivot.position.y = 1.5 + pivot.position.y = 1.0 add_child(pivot) - arm = SpringArm3D.new() - arm.spring_length = 6.5 - arm.margin = 0.2 - var arm_form := SphereShape3D.new() - arm_form.radius = 0.45 # Kugel statt Strahl: die Kamera klemmt nicht in Wänden - arm.shape = arm_form + arm = Node3D.new() + arm.rotation.x = PITCH pivot.add_child(arm) - arm.add_excluded_object(get_rid()) cam = Camera3D.new() - cam.position.x = 0.7 # Blick über die Schulter, am Ende des Arms - cam.fov = 74.0 + cam.position.z = ZOOM[0] + cam.fov = FOV + cam.near = 0.5 arm.add_child(cam) set_mode(Mode.TRAUM) @@ -74,44 +110,37 @@ func set_mode(m: int) -> void: if m == Mode.GEIST: collision_layer = 0 collision_mask = 0 - arm.collision_mask = 0 - arm.spring_length = 5.0 - pivot.position.y = 2.1 - body.scale = Vector3.ONE * 0.72 - _kern.material_override = Prims.mat(Color(0.65, 0.9, 1.0, 0.26), 0.5) - for r in _ringe: - r.material_override = Prims.mat(Color(0.7, 0.95, 1.0, 0.20), 0.8) - r.scale = Vector3(0.55, 1.0, 0.55) + body.scale = Vector3.ONE * 0.6 + _schleier_mat.albedo_color.a = 0.3 else: collision_layer = 1 collision_mask = 1 - arm.collision_mask = 1 - arm.spring_length = 6.5 - pivot.position.y = 1.5 - body.scale = Vector3.ONE - _kern.material_override = Prims.mat(Color(0.55, 0.85, 1.0), 0.9) - for r in _ringe: - r.material_override = Prims.mat(Color(0.6, 0.9, 1.0), 1.6) - r.scale = Vector3.ONE + body.scale = Vector3.ONE * 0.8 + _schleier_mat.albedo_color.a = 0.55 velocity = Vector3.ZERO + _schwebe_ziel = -1.0 func _unhandled_input(event: InputEvent) -> void: if not look_enabled: return - if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: - yaw -= event.relative.x * MAUS - pitch = clampf(pitch - event.relative.y * MAUS, -1.3, 0.6) + if event.is_action_pressed("drehen"): + _dreh_rest += DREHSCHRITT + elif event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + _zoom_stufe = 0 + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _zoom_stufe = 1 func _physics_process(delta: float) -> void: + var dreh := minf(_dreh_rest, delta * 4.0) + yaw += dreh + _dreh_rest -= dreh pivot.rotation.y = yaw - arm.rotation.x = pitch + cam.position.z = lerpf(cam.position.z, float(ZOOM[_zoom_stufe]), 1.0 - pow(0.01, delta)) - for i in range(_ringe.size()): - var r: MeshInstance3D = _ringe[i] - r.rotation.y += delta * (0.8 + 0.5 * i) - r.position.y = 0.5 + i * 0.9 + sin(Time.get_ticks_msec() * 0.001 * (1.2 + i * 0.4)) * 0.12 + _beleben(delta) var input := Vector2.ZERO if look_enabled: @@ -128,8 +157,9 @@ func _physics_process(delta: float) -> void: elif dir.length() > 0.01: folgt = null # eigene Bewegung löst die Verfolgung else: - var schulter := folgt.global_position + Vector3(0, 1.6, 0) \ - - blickrichtung() * FOLGE_ABSTAND + var rechts := Basis(Vector3.UP, yaw) * Vector3(1, 0, 0) + var schulter := folgt.global_position + Vector3(0, 1.4, 0) \ + + rechts * FOLGE_ABSTAND - blickrichtung() * 0.8 global_position = global_position.lerp(schulter, 1.0 - pow(0.004, delta)) # weich nachziehen, aber an der Leine: sonst reißt der Faden beim Gehen var ab := global_position - schulter @@ -141,12 +171,24 @@ func _physics_process(delta: float) -> void: if mode == Mode.GEIST: var v := dir * SPEED_GEIST + velocity.x = lerpf(velocity.x, v.x, 1.0 - pow(0.001, delta)) + velocity.z = lerpf(velocity.z, v.z, 1.0 - pow(0.001, delta)) + # Steigen und Sinken wechseln die Etage: der Geist gleitet ein Stockwerk if look_enabled: - if Input.is_action_pressed("steigen"): - v.y += SPEED_GEIST * 0.75 - if Input.is_action_pressed("sinken"): - v.y -= SPEED_GEIST * 0.75 - velocity = velocity.lerp(v, 1.0 - pow(0.001, delta)) + var von: float = _schwebe_ziel if _schwebe_ziel >= 0.0 else global_position.y + if Input.is_action_just_pressed("steigen"): + _schwebe_ziel = clampf(von + Orte.ETAGE, 0.4, 45.0) + if Input.is_action_just_pressed("sinken"): + _schwebe_ziel = clampf(von - Orte.ETAGE, 0.4, 45.0) + if _schwebe_ziel >= 0.0: + var dy := _schwebe_ziel - global_position.y + velocity.y = clampf(dy * 6.0, -SPEED_GEIST, SPEED_GEIST) + if absf(dy) < 0.05: + global_position.y = _schwebe_ziel + velocity.y = 0.0 + _schwebe_ziel = -1.0 + else: + velocity.y = 0.0 move_and_slide() if global_position.y < 0.4: global_position.y = 0.4 @@ -173,3 +215,47 @@ func _physics_process(delta: float) -> void: func blickrichtung() -> Vector3: return -Vector3(sin(yaw), 0, cos(yaw)) + + +## Schweben, Funken, Schweif und Modusfarbe -- der Zustand ist am Geist ablesbar. +func _beleben(delta: float) -> void: + var t := Time.get_ticks_msec() * 0.001 + var ziel := FARBE_TRAUM + if mode == Mode.GEIST: + ziel = FARBE_FOLGE if folgt != null else FARBE_GEIST + if not Game.situation.is_empty() and fmod(t, 0.5) < 0.12: + ziel = ziel.lightened(0.6) + _farbe = _farbe.lerp(ziel, 1.0 - pow(0.02, delta)) + _schleier_mat.albedo_color = Color(_farbe, _schleier_mat.albedo_color.a) + _schleier_mat.emission = _farbe + _kern_mat.albedo_color = _farbe + _kern_mat.emission = _farbe + _kopf_mat.albedo_color = _farbe.lightened(0.4) + _kopf_mat.emission = _farbe.lightened(0.4) + + body.position.y = sin(t * 1.6) * 0.08 + _kern.scale = Vector3.ONE * (1.0 + sin(t * 3.1) * 0.12) + var tempo_flach := Vector2(velocity.x, velocity.z).length() + var neigung := -0.16 * clampf(tempo_flach / SPEED_TRAUM, 0.0, 1.0) + body.rotation.x = lerpf(body.rotation.x, neigung, 1.0 - pow(0.01, delta)) + + var anzahl := clampi(Game.known.size(), 1, FUNKEN_MAX) + _kreis += delta * (0.9 + 0.7 * float(Game.tempo - 1)) + for i in range(FUNKEN_MAX): + var f: MeshInstance3D = _funken[i] + f.visible = i < anzahl + if not f.visible: + continue + var w := _kreis + float(i) * TAU / float(anzahl) + f.position = Vector3(cos(w) * 0.85, 1.2 + sin(w * 2.0 + float(i)) * 0.3, sin(w) * 0.85) + f.scale = Vector3.ONE * (0.8 + 0.3 * sin(t * 4.0 + float(i))) + + var kopf_welt := body.to_global(Vector3(0, 1.1, 0)) + var sichtbar := tempo_flach > 1.0 + for i in range(_schleppe.size()): + var k: MeshInstance3D = _schleppe[i] + var vorher: Vector3 = kopf_welt if i == 0 else _schleppe_pos[i - 1] + _schleppe_pos[i] = (_schleppe_pos[i] as Vector3).lerp(vorher, 1.0 - pow(0.0002 * (i + 1), delta)) + k.global_position = _schleppe_pos[i] + k.visible = sichtbar + k.material_override = _kern_mat diff --git a/scripts/prims.gd b/scripts/prims.gd index 70677fa..b551cd0 100644 --- a/scripts/prims.gd +++ b/scripts/prims.gd @@ -108,9 +108,10 @@ static func label(parent: Node, pos: Vector3, text: String, size: float = 0.35, ## door_dir: Vector3.FORWARD/BACK/LEFT/RIGHT -- auf welcher Seite die Lücke sitzt. static func room(parent: Node, center: Vector3, size: Vector3, wall_c: Color, floor_c: Color, door_dir: Vector3, door_w: float = 3.0, - with_floor: bool = true) -> void: + with_floor: bool = true) -> Array: var t := 0.4 var h := size.y + var waende: Array = [] if with_floor: box(parent, center + Vector3(0, -0.1, 0), Vector3(size.x, 0.2, size.z), floor_c) var seiten: Array[Vector3] = [Vector3.FORWARD, Vector3.BACK, Vector3.LEFT, Vector3.RIGHT] @@ -126,9 +127,90 @@ static func room(parent: Node, center: Vector3, size: Vector3, wall_c: Color, var seg_size: Vector3 = Vector3(seg, h, t) if quer else Vector3(t, h, seg) for s: float in [-1.0, 1.0]: var seg_pos: Vector3 = center + off + along * (s * (door_w * 0.5 + seg * 0.5)) - box(parent, seg_pos, seg_size, wall_c) + waende.append(_wand(box(parent, seg_pos, seg_size, wall_c), d)) if h >= 3.0: var sturz: Vector3 = Vector3(door_w, h * 0.3, t) if quer else Vector3(t, h * 0.3, door_w) - box(parent, center + off + Vector3(0, h * 0.35, 0), sturz, wall_c) + waende.append(_wand(box(parent, center + off + Vector3(0, h * 0.35, 0), sturz, wall_c), d)) else: - box(parent, center + off, voll, wall_c) + waende.append(_wand(box(parent, center + off, voll, wall_c), d)) + return waende + + +static func _meshes_von(b: Dictionary) -> Array: + var out: Array = [] + for n in b["etagen"]: + _sammle(n, out) + for n in b["dach"]: + _sammle(n, out) + for liste in b["waende"]: + for w in liste: + if not out.has(w): + out.append(w) + return out + + +static func _sammle(n: Node, out: Array) -> void: + if n is MeshInstance3D and not out.has(n): + out.append(n) + for k in n.get_children(): + _sammle(k, out) + + +static func _wand(mi: MeshInstance3D, normale: Vector3) -> MeshInstance3D: + mi.set_meta("normal", normale) + return mi + + +## Etagenschnitt wie bei den Sims: steht der Spieler in einem Bau, verschwinden +## das Dach, die Etagen über ihm und die beiden Wände, die zur Kamera zeigen. +## Ein Bau: {c, size, hoch, etagen, dach, waende, voll}. "voll" heißt: ein +## geschlossener Klotz, der ganz verschwindet. +## Ein Bau zwischen Kamera und Spieler wird durchsichtig, sonst verschwände +## der Spieler hinter jedem Nachbarhaus. +static func schnitt(bauten: Array, spieler: Vector3, kamera: Vector3) -> void: + for b in bauten: + var c: Vector3 = b["c"] + var g: Vector2 = b["size"] + var drin: bool = absf(spieler.x - c.x) < g.x * 0.5 + 0.3 \ + and absf(spieler.z - c.z) < g.y * 0.5 + 0.3 \ + and spieler.y > c.y - 0.5 and spieler.y < c.y + float(b["hoch"]) + 0.5 + var kasten := AABB(Vector3(c.x - g.x * 0.5, c.y, c.z - g.y * 0.5), + Vector3(g.x, float(b["hoch"]), g.y)) + var im_weg: bool = not drin \ + and kasten.intersects_segment(kamera, spieler + Vector3(0, 0.8, 0)) != null + if bool(b.get("im_weg", false)) != im_weg: + b["im_weg"] = im_weg + if not b.has("meshes"): + b["meshes"] = _meshes_von(b) + for mi in b["meshes"]: + (mi as GeometryInstance3D).transparency = 0.82 if im_weg else 0.0 + var etagen: Array = b["etagen"] + var pf := clampi(int(floor((spieler.y - c.y + 0.6) / Orte.ETAGE)), 0, maxi(0, etagen.size() - 1)) + for f in range(etagen.size()): + (etagen[f] as Node3D).visible = not drin or (f <= pf and not bool(b.get("voll", false))) + for d in b["dach"]: + (d as Node3D).visible = not drin + var waende: Array = b["waende"] + for f in range(waende.size()): + for w in waende[f]: + var n: Vector3 = w.get_meta("normal") + (w as Node3D).visible = not (drin and f == pf and n.dot(kamera - c) > 0.0) + + +## Ein Satteldach: ein geschlossenes Prisma über den Wänden. Der First läuft +## entlang der längeren Seite, das Dach steht ringsum 0,6 über. +static func dach(parent: Node, center: Vector3, size: Vector3, c: Color) -> MeshInstance3D: + var quer := size.x >= size.z # First entlang X + var spann: float = (size.z if quer else size.x) + 1.2 + var laenge: float = (size.x if quer else size.z) + 1.2 + var hoehe := spann * 0.5 * tan(0.62) + var mi := MeshInstance3D.new() + var pm := PrismMesh.new() + pm.size = Vector3(spann, hoehe, laenge) + mi.mesh = pm + mi.material_override = mat(c.darkened(0.3)) + mi.position = center + Vector3(0, size.y + hoehe * 0.5, 0) + if quer: + mi.rotation.y = PI * 0.5 + parent.add_child(mi) + return mi diff --git a/scripts/ui.gd b/scripts/ui.gd index 7a5548c..5909ea2 100644 --- a/scripts/ui.gd +++ b/scripts/ui.gd @@ -135,7 +135,7 @@ func _baue_hud() -> void: _root.add_child(_hud) var steuerung := Label.new() - steuerung.text = "J Journal · 1-3 Tempo · Esc" + steuerung.text = "J Journal · 1-3 Tempo · Q Kamera · Mausrad Zoom · Esc" steuerung.set_anchors_preset(Control.PRESET_TOP_RIGHT) steuerung.position = Vector2(-260, 16) steuerung.size = Vector2(240, 24) diff --git a/tests/verify.gd b/tests/verify.gd index 7f777ca..95f7ecd 100644 --- a/tests/verify.gd +++ b/tests/verify.gd @@ -26,6 +26,7 @@ func _ready() -> void: await test_journal() await test_traumdorf() await test_dorf_organisch() + await test_huegel() await test_haus_betreten() await test_hoersaal() await test_mission_annehmen() @@ -35,6 +36,8 @@ func _ready() -> void: await test_wege_machbar() await test_geist() await test_verfolgung() + await test_charakter() + await test_kamera_und_etagen() await test_gehen_bei_tempo() await test_diagnose() await test_notieren_im_spiel() @@ -166,6 +169,8 @@ func _druecke(anfang: String) -> bool: func laufe(von: Vector3, yaw: float, frames: int) -> Vector3: main.player.set_mode(Player.Mode.TRAUM) main.player.look_enabled = true + if welt() is DreamWorld: + von.y = maxf(von.y, DreamDB.hoehe_bei(von.x, von.z) + 1.0) # nie im Hügel starten main.player.global_position = von main.player.velocity = Vector3.ZERO main.player.yaw = yaw @@ -375,6 +380,103 @@ func test_traumdorf() -> void: await get_tree().process_frame +## Die Insel ist ein Hügel: innen eben, außen ansteigend, jedes Gebäude auf +## einer Terrasse. Spieler und Bewohner laufen darauf, statt hindurchzufallen. +func test_huegel() -> void: + abschnitt("Die Insel ist ein Hügel") + var uhr_vorher := Game.time_h + var tag_vorher := Game.day + p(is_equal_approx(DreamDB.hang(0.0), 0.0) and is_equal_approx(DreamDB.hang(DreamDB.FLACH), 0.0), + "Bis %.0f Einheiten ist es eben" % DreamDB.FLACH) + p(DreamDB.hang(DreamDB.FLACH + 20.0) > 3.0 and DreamDB.hang(200.0) <= DreamDB.GIPFEL, + "Dahinter steigt es an, bis zum Gipfel bei %.0f (bei +20: %.1f)" % + [DreamDB.GIPFEL, DreamDB.hang(DreamDB.FLACH + 20.0)]) + p(DreamDB.hoehe_bei(0.0, 2.0) == 0.0, "Der Dorfplatz liegt eben") + var tor_terr := DreamDB.terrasse("tor") + p(is_equal_approx(DreamDB.hoehe_bei(0.0, -34.0), tor_terr) + and is_equal_approx(DreamDB.hoehe_bei(0.0, -34.0 + DreamDB.TOR_FLAECHE * 0.5 + 2.0), tor_terr), + "Das Tor steht auf einer Terrasse, eben mit seiner Dorfseite (%.1f)" % tor_terr) + var stufe := absf(DreamDB.hoehe_bei(0.0, 25.4) - DreamDB.terrasse("hoersaal")) + p(stufe < 0.1, "Vor der Hörsaaltür ist keine Stufe (%.2f)" % stufe) + + # Ein Dorf mit vielen Häusern: manche stehen am Hang, jedes auf einer Terrasse + var merker: Array = Game.completed.duplicate() + Game.completed.clear() + for i in range(14): + Game.completed.append("huegel_%d" % i) + Game.welt_erzeugen() + var am_hang := 0 + var schief: Array = [] + for key in DreamDB.PLACES: + var s: Array = DreamDB.PLACES[key]["size"] + if float(s[0]) <= 0.0: + continue + var c := Orte.center(DreamDB.PLACES, key) + var terr := DreamDB.terrasse(key) + if terr > 0.5: + am_hang += 1 + for dx in [-1.0, 1.0]: + for dz in [-1.0, 1.0]: + var h := DreamDB.hoehe_bei(c.x + dx * float(s[0]) * 0.45, c.z + dz * float(s[1]) * 0.45) + if absf(h - terr) > 0.01: + schief.append(key) + p(am_hang >= 3, "Mit 14 Häusern stehen welche am Hang (%d)" % am_hang) + p(schief.is_empty(), "Jedes Gebäude steht innen eben auf seiner Terrasse%s" % + ("" if schief.is_empty() else " — nicht: " + ", ".join(schief))) + + main._traumwelt() + await get_tree().process_frame + await get_tree().process_frame + # Bewohner laufen einen Tag lang auf dem Boden, nicht darunter oder darüber + var abweichung := 0.0 + Game.time_h = 0.0 + for i in range(3000): + schritte(1) + for w in welt().bewohner: + var soll := DreamDB.hoehe_bei(w.global_position.x, w.global_position.z) + abweichung = maxf(abweichung, absf(w.global_position.y - soll)) + p(abweichung < 0.05, "%d Bewohner bleiben auf dem Boden (Abweichung %.2f)" % + [welt().bewohner.size(), abweichung]) + + # Der Spieler kommt den Hang hinauf und fällt nicht hindurch. Dafür eine + # Richtung, in der kein Gebäude im Weg steht. + var richtung := Vector3.ZERO + var wenigste := 999 + for k in range(24): + var a := TAU * float(k) / 24.0 + var d := Vector3(cos(a), 0, sin(a)) + var treffer := 0 + for r in range(int(DreamDB.FLACH), int(DreamDB.FLACH) + 24, 2): + var q := d * float(r) + for key in DreamDB.PLACES: + var s: Array = DreamDB.PLACES[key]["size"] + if float(s[0]) <= 0.0: + continue + var c := Orte.center(DreamDB.PLACES, key) + if absf(q.x - c.x) < float(s[0]) * 0.5 + 2.5 and absf(q.z - c.z) < float(s[1]) * 0.5 + 2.5: + treffer += 1 + if treffer < wenigste: + wenigste = treffer + richtung = d + p(wenigste == 0, "Eine freie Richtung den Hang hinauf gibt es (beste: %d Treffer)" % wenigste) + var start := richtung * (DreamDB.FLACH + 1.0) + Vector3(0, 1.0, 0) + var yaw := atan2(-richtung.x, -richtung.z) + var ende: Vector3 = await laufe(start, yaw, 140) + var boden_dort := DreamDB.hoehe_bei(ende.x, ende.z) + var weit := Vector2(ende.x, ende.z).length() - Vector2(start.x, start.z).length() + p(weit > 6.0, "Er läuft hangaufwärts (%.0f Einheiten weiter außen)" % weit) + p(ende.y > start.y + 1.0, "Und kommt dabei höher (y %.1f -> %.1f)" % [start.y, ende.y]) + p(ende.y >= boden_dort - 0.2 and ende.y < boden_dort + 2.5, + "Er steht auf dem Boden, nicht darunter (y %.1f, Boden %.1f)" % [ende.y, boden_dort]) + + Game.completed.assign(merker) + Game.time_h = uhr_vorher + Game.day = tag_vorher + Game.welt_erzeugen() + main._traumwelt() + await get_tree().process_frame + + func test_haus_betreten() -> void: abschnitt("Orte betreten") p(not Game.ort_offen(DreamDB.haus_key(0)), "Ohne Bewohner steht kein Haus") @@ -586,8 +688,8 @@ func test_tagesablauf() -> void: schritte(30, 0.05, 2.0) p(mensch.activity == "schlaeft", "Um 02:00 schläft er (%s)" % mensch.activity) - p(mensch.global_position.distance_to( - Orte.center(HumanDB.PLACES, str(mensch.data["heim"]))) < 5.0, + p(_xz(mensch.global_position).distance_to( + _xz(Orte.center(HumanDB.PLACES, str(mensch.data["heim"])))) < 5.0, "Und zwar zu Hause") # Jede Station des eigenen Tagesablaufs wird pünktlich erreicht @@ -704,6 +806,250 @@ func test_geist() -> void: p(lena.global_position.distance_to(davor) < 2.0, "Menschen nehmen den Geist nicht wahr") +func _farbabstand(a: Color, b: Color) -> float: + return Vector3(a.r, a.g, a.b).distance_to(Vector3(b.r, b.g, b.b)) + + +func test_charakter() -> void: + abschnitt("Der Geist und die Menschen sind am Körper lesbar") + ui().schliessen() + var sp: Player = main.player + sp.set_mode(Player.Mode.GEIST) + sp.look_enabled = true + sp.folgt = null + p(sp._augen.size() == 2 and sp._augen[0].position.z < 0.0, "Zwei Augen, vorn am Kopf") + p(sp._funken.size() == Player.FUNKEN_MAX, "%d Funken stehen bereit" % Player.FUNKEN_MAX) + for i in range(60): + await get_tree().physics_frame + var sichtbar := 0 + for f in sp._funken: + if f.visible: + sichtbar += 1 + var soll := clampi(Game.known.size(), 1, Player.FUNKEN_MAX) + p(sichtbar == soll, "Je gelernter Technik kreist ein Funke (%d bei %d Techniken)" % + [sichtbar, Game.known.size()]) + p(_farbabstand(sp._farbe, Player.FARBE_GEIST) < 0.1, "Beim Beobachten leuchtet er kühl") + var mensch: Bewohner = welt().humans[0] + sp.global_position = mensch.global_position + Vector3(0, 1.5, 2.0) + sp.folgt = mensch + for i in range(90): + await get_tree().physics_frame + p(_farbabstand(sp._farbe, Player.FARBE_FOLGE) < 0.1, "Beim Verfolgen leuchtet er warm") + sp.folgt = null + Input.action_press("vor") + for i in range(40): + await get_tree().physics_frame + Input.action_release("vor") + var kopf: Vector3 = sp.body.to_global(Vector3(0, 1.1, 0)) + var hinten := 0 + for k in sp._schleppe: + if k.visible and (k as Node3D).global_position.distance_to(kopf) > 0.3: + hinten += 1 + p(hinten >= 2, "Beim Fliegen zieht er einen Schweif hinter sich her (%d von 3)" % hinten) + for i in range(40): + await get_tree().physics_frame + var ruht := true + for k in sp._schleppe: + if k.visible: + ruht = false + p(ruht, "Im Stand verschwindet der Schweif") + + # Haltung der Menschen: Angst duckt, Erschöpfung beugt -- bis überwunden + var merker := Game.ueberwunden.duplicate(true) + var muede: Bewohner = null + var bang: Bewohner = null + var frei: Bewohner = null + for m in welt().humans: + var pr: Array = m.data.get("probleme", []) + if muede == null and pr.has("erschoepfung"): + muede = m + elif bang == null and pr.has("angst"): + bang = m + elif frei == null and not pr.has("angst") and not pr.has("erschoepfung"): + frei = m + p(muede != null and bang != null and frei != null, "Müde, ängstliche und freie Menschen sind da") + if muede != null: + Game.ueberwunden.erase(str(muede.data["id"])) + if bang != null: + Game.ueberwunden.erase(str(bang.data["id"])) + schritte(80) + p(muede != null and muede._ober.rotation.x < -0.25, + "Erschöpfung beugt den Oberkörper (%.2f)" % (muede._ober.rotation.x if muede else 0.0)) + p(bang != null and bang._ober.scale.x < 0.9, + "Angst macht klein (%.2f)" % (bang._ober.scale.x if bang else 0.0)) + p(frei != null and absf(frei._ober.rotation.x) < 0.05 and frei._ober.scale.x > 0.98, + "Ohne diese Klemmen steht man aufrecht") + if muede != null: + Game.ueberwunden[str(muede.data["id"])] = {"erschoepfung": true} + schritte(200) + p(muede._ober.rotation.x > -0.05, "Überwunden richtet er sich auf (%.2f)" % muede._ober.rotation.x) + Game.ueberwunden = merker + + # Hüte nach Beruf + var kappen: Dictionary = {} + for m in welt().humans: + var typ := str(HumanDB.BERUF_TAGESTYP.get(str(m.data.get("beruf", "")), "")) + kappen[typ] = m._ober.get_child_count() + p(kappen.has("heim") and kappen.has("frueh") and kappen.has("buero") + and int(kappen["frueh"]) > int(kappen["heim"]) and int(kappen["buero"]) > int(kappen["heim"]), + "Berufe tragen verschiedene Kopfbedeckungen (%s)" % str(kappen)) + p(mensch._arme.size() == 2, "Jeder hat zwei Arme") + sp.velocity = Vector3.ZERO + + +func _xz(v: Vector3) -> Vector2: + return Vector2(v.x, v.z) + + +func test_kamera_und_etagen() -> void: + abschnitt("Feste Kamera, kleine Menschen, Stadt mit Stockwerken") + ui().schliessen() + var sp: Player = main.player + sp.set_mode(Player.Mode.GEIST) + sp.look_enabled = true + sp.folgt = null + p(Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "Die Maus ist frei") + p(is_equal_approx(sp.arm.rotation.x, Player.PITCH) and sp.cam.get_parent() == sp.arm, + "Die Kamera schaut fest von schräg oben (%.0f°)" % rad_to_deg(-Player.PITCH)) + sp.yaw = Player.CAM_YAW + var yaw_vorher: float = sp.yaw + var dreh := InputEventAction.new() + dreh.action = "drehen" + dreh.pressed = true + Input.parse_input_event(dreh) + for i in range(80): + await get_tree().physics_frame + p(absf(angle_difference(yaw_vorher + Player.DREHSCHRITT, sp.yaw)) < 0.05, + "Q dreht die Kamera um 90° (%.0f°)" % rad_to_deg(sp.yaw - yaw_vorher)) + var rad := InputEventMouseButton.new() + rad.button_index = MOUSE_BUTTON_WHEEL_DOWN + rad.pressed = true + Input.parse_input_event(rad) + for i in range(150): + await get_tree().physics_frame + p(absf(sp.cam.position.z - float(Player.ZOOM[1])) < 0.5, + "Mausrad zoomt heraus (%.1f)" % sp.cam.position.z) + rad = InputEventMouseButton.new() + rad.button_index = MOUSE_BUTTON_WHEEL_UP + rad.pressed = true + Input.parse_input_event(rad) + for i in range(150): + await get_tree().physics_frame + p(absf(sp.cam.position.z - float(Player.ZOOM[0])) < 0.5, + "Und wieder heran (%.1f)" % sp.cam.position.z) + + var mensch: Bewohner = welt().humans[0] + p(mensch._visual.scale.x < 0.85 and sp.body.scale.x < 0.7, + "Menschen und Geist sind kleiner (%.2f / %.2f)" % [mensch._visual.scale.x, sp.body.scale.x]) + + # Stockwerke + var mehr := 0 + for key in HumanDB.PLACES: + if Orte.etagen(HumanDB.PLACES, key) > 1: + mehr += 1 + p(mehr >= 12, "%d Gebäude haben mehrere Stockwerke" % mehr) + var oben: Bewohner = null + for m in welt().humans: + if m.etage(str(m.data["heim"])) > 0: + oben = m + break + p(oben != null, "Jemand wohnt über dem Erdgeschoss") + var heim := str(oben.data["heim"]) + var e := oben.etage(heim) + var c := Orte.center(HumanDB.PLACES, heim) + var g: Array = HumanDB.PLACES[heim]["size"] + var t0 := Orte.treppe(HumanDB.PLACES, heim, 0) + var t1 := Orte.treppe(HumanDB.PLACES, heim, 1) + var im_haus: bool = absf(t0.x - c.x) < float(g[0]) * 0.5 and absf(t0.z - c.z) < float(g[1]) * 0.5 \ + and absf(t1.x - c.x) < float(g[0]) * 0.5 and absf(t1.z - c.z) < float(g[1]) * 0.5 + p(im_haus and is_equal_approx(t1.y, Orte.ETAGE), "Das Treppenhaus steht im Haus, ein Podest je Etage") + + var uhr := Game.time_h + var zuhause := false + for i in range(3000): + schritte(1, 0.05, 2.0) + if oben.place == heim and oben._path.is_empty(): + zuhause = true + break + p(zuhause and absf(oben.global_position.y - Orte.hoehe(e)) < 0.2, + "Nachts liegt %s in seiner Wohnung im %d. Stock (y = %.1f)" % + [oben.data["name"], e, oben.global_position.y]) + var weg_stunde := -1.0 + for r in oben.data["routine"]: + if str(r[1]) != heim: + weg_stunde = float(r[0]) + 0.5 + break + var draussen := false + var tief := 99.0 + for i in range(3000): + schritte(1, 0.05, weg_stunde) + tief = minf(tief, oben.global_position.y) + if _xz(oben.global_position).distance_to(_xz(c)) > float(g[0]) * 0.5 + 3.0: + draussen = absf(oben.global_position.y) < 0.3 + break + p(draussen, "Auf die Straße kommt er über die Treppe wieder nach unten (y = %.1f)" % + oben.global_position.y) + + # Etagenschnitt + var bau: Dictionary = {} + for b in welt()._bauten: + if (b["c"] as Vector3).distance_to(c) < 0.1: + bau = b + var n := Orte.etagen(HumanDB.PLACES, heim) + p(not bau.is_empty() and (bau["etagen"] as Array).size() == n, + "Sein Haus steht mit allen %d Etagen da" % n) + sp.global_position = c + Vector3(0, 1.4, 0) + sp.velocity = Vector3.ZERO + sp.yaw = Player.CAM_YAW + await get_tree().physics_frame + welt()._process(0.05) + var obere_weg := true + for f in range(1, n): + if (bau["etagen"][f] as Node3D).visible: + obere_weg = false + var wand_weg := 0 + for w in bau["waende"][0]: + if not (w as Node3D).visible: + wand_weg += 1 + p(obere_weg and not (bau["dach"][0] as Node3D).visible, + "Steht der Geist im Erdgeschoss, verschwinden Dach und obere Etagen") + p(wand_weg >= 2 and wand_weg < (bau["waende"][0] as Array).size(), + "Die Wände zur Kamera hin sind weg, die hinteren bleiben (%d von %d Teile)" % + [wand_weg, (bau["waende"][0] as Array).size()]) + Input.action_press("steigen") + for i in range(60): + await get_tree().physics_frame + Input.action_release("steigen") + p(absf(sp.global_position.y - (1.4 + Orte.ETAGE)) < 0.1, + "Leertaste hebt den Geist genau ein Stockwerk (y = %.1f)" % sp.global_position.y) + welt()._process(0.05) + p((bau["etagen"][0] as Node3D).visible and (bau["etagen"][1] as Node3D).visible + and (n < 3 or not (bau["etagen"][2] as Node3D).visible), + "Im ersten Stock sieht man Erdgeschoss und ersten Stock") + sp.global_position = c + Vector3(float(g[0]) + 4.0, 1.4, 0) + await get_tree().physics_frame + welt()._process(0.05) + var alles := (bau["dach"][0] as Node3D).visible + for f in range(n): + if not (bau["etagen"][f] as Node3D).visible: + alles = false + for w in bau["waende"][0]: + if not (w as Node3D).visible: + alles = false + p(alles, "Draußen steht das Haus wieder ganz") + + # Klick auf einen Menschen + sp.global_position = oben.global_position + Vector3(2.0, 1.4, 2.0) + sp.velocity = Vector3.ZERO + await get_tree().physics_frame + var schirm := sp.cam.unproject_position(oben.global_position + Vector3(0, 0.8, 0)) + main._klick(schirm) + p(sp.folgt == oben, "Ein Klick auf einen Menschen hängt den Geist an ihn") + main._klick(schirm) + p(sp.folgt == null, "Noch ein Klick löst ihn wieder") + Game.time_h = uhr + + ## Bei hohem Tempo wird der Schritt je Bild größer als die Ankunftsschwelle. ## Wer dann über den Wegpunkt hinausläuft, pendelt um ihn herum statt anzukommen. func test_gehen_bei_tempo() -> void: