From 7c4edaa439e9aa02a481bffec100306f7c20e4d3 Mon Sep 17 00:00:00 2001 From: Team3 Date: Wed, 16 Sep 2026 16:15:32 +0200 Subject: [PATCH] update --- README.md | 63 +++-- scripts/bewohner.gd | 55 ++--- scripts/dream_db.gd | 18 +- scripts/dream_world.gd | 53 ++++- scripts/earth_world.gd | 240 +++++++++++-------- scripts/game.gd | 63 ++++- scripts/human_db.gd | 75 ++++-- scripts/main.gd | 104 ++++++-- scripts/orte.gd | 242 ------------------- scripts/player.gd | 80 ++++--- scripts/prims.gd | 37 +-- scripts/ui.gd | 185 ++++++++++++--- scripts/ursachen.gd | 12 + tests/flimmer.gd | 64 +++++ tests/flimmer.tscn | 6 + tests/rauch.gd | 37 +++ tests/rauch.tscn | 6 + tests/run.sh | 16 ++ tests/verify.gd | 524 ++++++++++++++++++++++++++++++++--------- 19 files changed, 1244 insertions(+), 636 deletions(-) create mode 100644 tests/flimmer.gd create mode 100644 tests/flimmer.tscn create mode 100644 tests/rauch.gd create mode 100644 tests/rauch.tscn diff --git a/README.md b/README.md index 993f48f..94dfbd4 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ 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 gibt es keinen Sprung: -der Geist nimmt die Treppe. +der Geist geht zu Fuß. ## Spielschleife @@ -45,6 +45,11 @@ Das Journal führt Buch — es lehrt und vergibt nichts. * **Techniken** — was du gelernt hast, mit Stufe und Erfahrung. Besser werden sie nur draußen. +* **Akten** — je Mensch mit Mission ein Blatt Papier: Person und Traum, was im + Weg steht, der Befund aus Spuren und Beobachtungen, der Tag, die Notizen mit + Uhrzeit, ein Stempel, wenn der Traum erfüllt ist. Und der Knopf + **Hinfahren**, der dich vor seine Tür setzt. Keine Patientenakte: die Leute + sind nicht krank, sie haben Gewohnheiten. * **Missionen** — die Menschen, für die du dich entschieden hast, jeweils mit ihrem **Tagesablauf**: der Tag von 0 bis 23 Uhr, in Abschnitte geteilt. Es gibt keine Wochentage; jeder Tag ist derselbe. Zu Beginn steht in jedem @@ -90,7 +95,7 @@ so, wie der Generator Menschen macht. Eigene Aushänge liegen unter `user://aushaenge.cfg`, überleben den Neustart und ersetzen die letzten erzeugten Einwohner; ein Aushang lässt sich wieder abnehmen. -**Diagnose und Systeme** — der Kern des Spiels +**Diagnose und Routinen** — der Kern des Spiels (im Code heißen Routinen „Systeme“) Ein Traum scheitert an Ursachen: *kein Anfang*, *keine Zeit*, *keine Kraft*, *kein Halt*, *kein Grund*. Jeder Mensch hat zwei bis drei davon, nie alle. Die @@ -105,7 +110,7 @@ Gang kommt, räumt auf statt anzufangen, recherchiert oder schreibt Später-List Wer keine Kraft hat, schaut abends eine Serie und bleibt danach wach. Jede dieser Tätigkeiten hat einen **Auslöser** und eine **Belohnung**. -Gegen eine erkannte Ursache baust du ein System in den Tag: +Gegen eine erkannte Ursache baust du eine Routine in den Tag: Einführen Ein System, das fehlt — in einen freien Slot. Ändern Eine Tätigkeit, die schadet — mit gleicher Belohnung ersetzt. @@ -154,6 +159,10 @@ nicht immer — Übung entscheidet. 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. +* **Die Wesen helfen.** Jeder erfüllte Traum bringt ein Wesen ins Dorf, und es + trägt, aus wessen Traum es kam. Sprich es an (**E**): es flüstert, was in der + Wohnung des Menschen liegt, um den es gerade geht — eine Spur, ohne dass du + hingehen musst. * **Schwarzes Brett** am Dorfplatz — hier hängen die Menschen aus, die nicht weiterkommen. Mission annehmen. @@ -166,18 +175,26 @@ nicht immer — Übung entscheidet. * **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. -* **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. +* **Wege kommen vom Navigationsnetz.** Beim ersten Betreten wird aus allen + Kollisionsformen ein Navigationsnetz gebacken (unter einer Sekunde): Straßen, + Türlücken, Möbel, Klötze — auf der Insel genauso. + Menschen und der Geist fragen es nach Pfaden; Türen und Treppen finden sie + von allein. Das Straßennetz zeichnet nur noch die Wege. +* **Beide Welten bleiben bestehen.** Wer aus dem Traum zurückkehrt, findet + dieselbe Stadt; die Menschen haben derweil ihren Tag weitergelebt. Jede Welt + liegt auf ihrer eigenen Kollisionsebene mit eigener Navigationskarte. +* **Passanten.** Zwölf Leute ohne Geschichte laufen stündlich zu einer anderen + Straßenecke oder in den Park: kein Name, kein Schild, keine Mission, nur + damit die Straßen nicht leer sind. +* **Die Stadt ist einstöckig.** Wohnhäuser und Sonderbauten sind geschlossene + Häuser: ein Raum mit Tür, Fenstern, Möbeln und den Spuren seines Bewohners. + Alles andere steht als Klotz da, unterschiedlich hoch, damit die Stadt eine + Silhouette hat. * Du bist ein unsichtbarer Geist, der eine Handbreit über dem Boden schwebt, - sich aber bewegt wie ein Mensch: Wände halten, Türen und Treppen sind die - Wege, Schwerkraft gilt. 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. + sich aber bewegt wie ein Mensch: Wände halten, Türen sind die Wege, + Schwerkraft gilt. Bist du in einem Haus, verschwinden Dach und die Wände zur + Kamera hin, wie bei den Sims. Ein Haus, das zwischen Kamera und dir steht, + wird durchsichtig. * **F** oder ein Klick hängt dich an einen Menschen: du gehst seiner Spur nach, durch dieselben Türen und Treppen, so schnell wie nötig — auch bei Tempo 3, wo du ihm zu Fuß nie folgen könntest. Steht er weit weg, planst du @@ -187,6 +204,11 @@ nicht immer — Übung entscheidet. oder ein Schritt mit **WASD** — lässt ihn wieder los. * Schilder tragen nur die Mission, der Fokus, der Verfolgte und wer unter der Maus steht. Sonst überdecken sich im Café acht Namen. +* **Spuren in der Wohnung.** Von jeder Ursache liegt etwas in der Wohnung des + Menschen herum: das Handy auf dem Boden, der vollgeschriebene Kalender, leere + Dosen am Bett, ungeöffnete Briefe, der verstaubte Koffer. Geh hinein, sieh + hin, **E** liest die Spur. Die Ursache steht dann im Journal und ihre Stunden + werden in der Zeitleiste **verdächtig** — du weißt, wann du dabei sein musst. * **R notieren.** Was den Traum aufhält, steht nirgends geschrieben. Du siehst nur, was der Mensch tut, und musst selbst entscheiden: läuft hier etwas schief? Triffst du den Moment, steht die Ursache im Journal und der Slot @@ -194,6 +216,9 @@ nicht immer — Übung entscheidet. Zusehen allein deckt nichts auf — das Bemerken ist der Zug. * Nah herangehen, **E** drücken, Technik wählen. Das Panel nennt für jede Technik nur, ob Thema und Moment passen — keine Zahl. +* Wer mit Mission zur Erde reist, landet vor der Tür seines Ziels. Der Fußweg + quer durch den Bezirk war Leerlauf; das Gehen zählt in der Wohnung und beim + Folgen. Aus der Akte heraus bringt **Hinfahren** dich jederzeit dorthin. * Die Säule im Park bringt dich zurück in die Traumwelt. ## Ob es gelingt @@ -245,7 +270,15 @@ eintauschen könnte. Ein Erfolg bringt 2 EP, ein Fehlversuch 1 — auch der zäh Der Parse-Vorlauf lädt nur alle Skripte: ein Parse-Fehler ließe die Suite sonst bis zum Timeout hängen. Die Suite läuft mit `Engine.time_scale`, dazu -misst sie jeden Abschnitt und nennt am Ende die langsamsten. +misst sie jeden Abschnitt und nennt am Ende die langsamsten. Jede `ERROR:`- +Zeile im Log lässt den Lauf durchfallen. + +Mit Display laufen zusätzlich der **Rauchtest** (das echte Spiel samt eigener +Aushänge-Datei, ein paar Sekunden spielen, Log muss sauber sein) und die +**Flimmer-Probe** (stehende Szene, Kamera um Millimeter gerückt, Bilder +verglichen: deckungsgleiche Flächen fallen so auf). Der **Zufallslauf** in der +Suite läuft 90 Sekunden kreuz und quer durch die Stadt und prüft, dass der +Geist nie hängen bleibt, nie unter den Boden und nie über die Dächer gerät. godot --path . tests/shots.tscn # 28 Screenshots nach user://shots/ `verify` fährt das echte Spiel: leeres Traumdorf, einziehende Wesen mit ihren diff --git a/scripts/bewohner.gd b/scripts/bewohner.gd index a87fbaf..4eb7c19 100644 --- a/scripts/bewohner.gd +++ b/scripts/bewohner.gd @@ -12,8 +12,6 @@ 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 @@ -28,18 +26,17 @@ 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 spur: Array = [] # letzte Positionen -- der Geist folgt ihr durch Türen +var nav_map: RID # auf der Erde: Pfade vom Navigationsnetz +var nav_fehl := 0 # wie oft das Netz keinen Pfad wusste (Rückfall aufs Wegenetz) +var nav_fehl_info := "" var _bob: float = 0.0 var _offset: Vector3 -func setup(d: Dictionary, index: int, orte: Dictionary, - wegenetz: Netz = null, wegeanker: Dictionary = {}) -> void: +func setup(d: Dictionary, index: int, orte: 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 @@ -85,20 +82,25 @@ func setup(d: Dictionary, index: int, orte: Dictionary, place = start[0] activity = start[1] _letzte_aktivitaet = activity - global_position = Orte.center(places, place) + _offset + Vector3(0, Orte.hoehe(etage(place)), 0) + global_position = Orte.center(places, place) + _offset -## Punkt auf der Spur, der mindestens `abstand` hinter dem Menschen liegt. -func spur_punkt(abstand: float) -> Vector3: - for i in range(spur.size() - 1, -1, -1): - if (spur[i] as Vector3).distance_to(global_position) >= abstand: - return spur[i] - return spur[0] if not spur.is_empty() else global_position - - -## 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)) +## Pfad über das Navigationsnetz zu einem Ort -- leer, wenn es keins gibt. +func _nav_route(nach: String) -> Array: + if not nav_map.is_valid(): + return [] + var ziel := Orte.center(places, nach) + _offset + var pts := NavigationServer3D.map_get_path(nav_map, global_position, ziel, true) + if pts.size() < 2: + nav_fehl += 1 + nav_fehl_info = "%s: %s -> %s von %s nach %s (%d Punkte)" % [data["name"], place, nach, + str(global_position), str(ziel), pts.size()] + print(" [kein Weg] " + nav_fehl_info) + return [] + var out: Array = [] + for i in range(1, pts.size()): + out.append(pts[i]) + return out ## Kopfbedeckung nach Tagestyp des Berufs -- so unterscheiden sich die Leute @@ -166,11 +168,9 @@ func _process(roh: float) -> void: var soll := _routine_at(Game.time_h) slot = int(soll[2]) if soll[0] != place: - var war_unterwegs := not _path.is_empty() - _path = Orte.route_voll(places, place, soll[0], _offset, - data.get("etage", {}), netz, anker, war_unterwegs) - if war_unterwegs and _path.size() > 1: - _path.remove_at(0) # steht schon draußen, alte Tür überspringen + _path = _nav_route(soll[0]) + if _path.is_empty(): # kein Netz oder kein Weg: Luftlinie, damit er ankommt + _path = [Orte.center(places, soll[0]) + _offset] place = soll[0] activity = "unterwegs" elif _path.is_empty() and activity != soll[1]: @@ -200,10 +200,6 @@ func _process(roh: float) -> void: _haltung(delta, not _path.is_empty()) if boden.is_valid(): global_position.y = float(boden.call(global_position.x, global_position.z)) - if spur.is_empty() or (spur.back() as Vector3).distance_to(global_position) > 0.4: - spur.append(global_position) - if spur.size() > 120: - spur.pop_front() _visual.rotation.z = lerp_angle(_visual.rotation.z, PI * 0.5 if activity == "schlaeft" else 0.0, 1.0 - pow(0.01, delta)) @@ -224,7 +220,8 @@ func _process(roh: float) -> void: _label.text = beschriftung() # Auf der Erde tragen nur Fokus, Verfolgte, Mission und wer unter der Maus # steht ein Schild -- sonst überdecken sich acht Namen im Café. - _label.visible = not mit_mission or Game.markiert.has(str(data["id"])) + _label.visible = (not mit_mission and not bool(data.get("stumm", false))) \ + or Game.markiert.has(str(data["id"])) _label.modulate = Color(0.55, 0.85, 0.6) if erledigt else ( Color(1, 0.9, 0.6) if aktiv else Color(1, 1, 1)) diff --git a/scripts/dream_db.gd b/scripts/dream_db.gd index 302d69b..c525462 100644 --- a/scripts/dream_db.gd +++ b/scripts/dream_db.gd @@ -254,7 +254,15 @@ static func _platz_frei(p: Vector3, groesse: Vector2) -> bool: var c := Orte.center(PLACES, key) 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: + # Zwei Terrassen verschiedener Höhe brauchen Platz für eine gehbare Böschung + # dazwischen -- sonst steht vor der Tür eine Klippe, und niemand kommt hinauf. + # Randstreifen beider plus eine Böschung dazwischen; über 4 m Höhenunterschied + # wird die Böschung zu steil (40°), dann muss der Abstand mitwachsen. + var dh := absf(hang(p.length()) - terrasse(key)) + var luft := HAUS_ABSTAND + if dh > 0.6: + luft += TERRASSE_RAND + BOESCHUNG + maxf(0.0, dh - 4.0) * 1.5 + if dx < luft and dz < luft: return false for k in NETZ.kanten: if Netz.trifft_rechteck(NETZ.knoten[k[0]], NETZ.knoten[k[1]], p, groesse, WEG_ABSTAND): @@ -511,10 +519,16 @@ static func _bewohner(i: int) -> Dictionary: var ablauf: Array = [] for e in ABLAEUFE[i % ABLAEUFE.size()]: ablauf.append([e[0], haus if e[1] == "haus" else e[1], e[2]]) + var von := str(Game.completed[i]) if i < Game.completed.size() else "" + var mensch := HumanDB.by_id(von) + var rolle := _rolle(i) + if not mensch.is_empty(): + rolle += " · aus %s Traum" % _besitz(str(mensch["name"]).get_slice(" ", 0)) return { "id": "wesen_%d" % i, "name": _name(i), - "rolle": _rolle(i), + "rolle": rolle, + "von": von, "haus": haus, "leuchtet": true, "farbe": Color.from_hsv(fmod(0.72 + float(i) * 0.161, 1.0), 0.55, 0.95), diff --git a/scripts/dream_world.gd b/scripts/dream_world.gd index 8130c1c..5b8d95e 100644 --- a/scripts/dream_world.gd +++ b/scripts/dream_world.gd @@ -7,10 +7,18 @@ class_name DreamWorld var spawn := Vector3(0, 2.0, 20.0) var bewohner: Array = [] var spieler: Node3D = null # setzt main, für den Etagenschnitt +var nav_map: RID # Navigationsnetz über Insel, Wege und Häuser var _bauten: Array = [] var _lichter: Array = [] var _freihalten: Array[Vector3] = [] +var _we: WorldEnvironment +var _env: Environment + + +## Nur die aktive Welt stellt den Himmel; zwei WorldEnvironments vertragen sich nicht. +func aktiv_setzen(an: bool) -> void: + _we.environment = _env if an else null func _ready() -> void: @@ -23,13 +31,39 @@ func _ready() -> void: _lichtung() _tor_zur_erde() _gruen() + _navigation() _wesen() +## Navigationsnetz aus den Kollisionsformen: Insel, Häuser, Bäume, Laternen. +func _navigation() -> void: + var region := NavigationRegion3D.new() + add_child(region) + var nm := NavigationMesh.new() + nm.geometry_parsed_geometry_type = NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS + nm.agent_radius = 0.4 + nm.agent_height = 1.6 + nm.agent_max_climb = 0.35 + nm.agent_max_slope = 40.0 + nm.cell_height = 0.1 + var quelle := NavigationMeshSourceGeometryData3D.new() + NavigationServer3D.parse_source_geometry_data(nm, quelle, self) + NavigationServer3D.bake_from_source_geometry_data(nm, quelle) + nav_map = NavigationServer3D.map_create() + NavigationServer3D.map_set_active(nav_map, true) + NavigationServer3D.map_set_cell_height(nav_map, 0.1) + region.set_navigation_map(nav_map) + region.navigation_mesh = nm + NavigationServer3D.map_set_use_async_iterations(nav_map, false) + NavigationServer3D.map_force_update(nav_map) + + ## Dämmerung: tiefes Blau oben, warmes Lavendel am Horizont, Nebel im Tal. func _environment() -> void: - var we := WorldEnvironment.new() - var env := Environment.new() + _we = WorldEnvironment.new() + var we := _we + _env = Environment.new() + var env := _env env.background_mode = Environment.BG_SKY var sky := Sky.new() var mat := ProceduralSkyMaterial.new() @@ -182,7 +216,7 @@ func _wege() -> void: for k in DreamDB.NETZ.knoten: 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) + Prims.cyl(self, p + Vector3(0, 0.05, 0), 1.3, 0.06, farbe, false) # knapp über den Bändern if nr % 2 == 0 and Vector2(p.x, p.z).length() > 12.0: _laterne(p + Vector3(1.6, 0, 0)) nr += 1 @@ -212,7 +246,7 @@ func _haeuser() -> void: Orte.DIR_VEC[pl["dir"]], 3.4, true) 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]}) + "teile": [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) @@ -463,8 +497,17 @@ func _wesen() -> void: 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) + w.nav_map = nav_map + w.setup(DreamDB.LIST[i], i, DreamDB.PLACES) bewohner.append(w) + # ansprechbar: ein Wesen flüstert, was in der Wohnung des nächsten Menschen liegt + var n := Node3D.new() + n.position.y = 1.0 + w.add_child(n) + n.add_to_group("interactable") + n.set_meta("kind", "wesen") + n.set_meta("arg", str(i)) + n.set_meta("titel", str(DreamDB.LIST[i]["name"])) func _process(delta: float) -> void: diff --git a/scripts/earth_world.gd b/scripts/earth_world.gd index a930106..1d711f4 100644 --- a/scripts/earth_world.gd +++ b/scripts/earth_world.gd @@ -4,12 +4,17 @@ class_name EarthWorld var spawn := Vector3(0, 1.4, 12.0) var humans: Array = [] +var passanten: Array = [] # Statisten ohne Geschichte var spieler: Node3D = null # setzt main, für den Etagenschnitt +var nav_map: RID # Navigationsnetz: Straßen, Türen, Möbel, Klötze +const HOEHE := 3.0 # Raumhöhe; Klötze sind Vielfache davon +const NAV_ZELLE_HOCH := 0.1 var _bauten: Array = [] # alles, was der Etagenschnitt aufschneidet var _sun: DirectionalLight3D var _sky: ProceduralSkyMaterial var _env: Environment +var _we: WorldEnvironment var _nachtlichter: Array = [] @@ -20,12 +25,41 @@ func _ready() -> void: _orte() _park() _saeule() + _navigation() _menschen() _himmel_aktualisieren() +## Das Navigationsnetz wird aus allen Kollisionsformen gebacken: Boden, +## Straßen, Wände mit Türlücken, Möbel, Klötze. Wer darauf läuft, findet die +## Türen von allein. +func _navigation() -> void: + var region := NavigationRegion3D.new() + add_child(region) + var nm := NavigationMesh.new() + nm.geometry_parsed_geometry_type = NavigationMesh.PARSED_GEOMETRY_STATIC_COLLIDERS + nm.agent_radius = 0.4 + nm.agent_height = 1.6 + nm.agent_max_climb = 0.35 + nm.agent_max_slope = 40.0 + nm.cell_height = NAV_ZELLE_HOCH # fein, sonst schweben die Leute 25 cm über der Straße + var quelle := NavigationMeshSourceGeometryData3D.new() + NavigationServer3D.parse_source_geometry_data(nm, quelle, self) + NavigationServer3D.bake_from_source_geometry_data(nm, quelle) + # eigene Karte: die Traumwelt liegt im selben Raum, ihre Polygone dürfen hier nicht auftauchen + nav_map = NavigationServer3D.map_create() + NavigationServer3D.map_set_active(nav_map, true) + NavigationServer3D.map_set_cell_height(nav_map, NAV_ZELLE_HOCH) + region.set_navigation_map(nav_map) + region.navigation_mesh = nm + # synchron: sonst kommen die ersten Pfade leer zurück + NavigationServer3D.map_set_use_async_iterations(nav_map, false) + NavigationServer3D.map_force_update(nav_map) + + func _environment() -> void: - var we := WorldEnvironment.new() + _we = WorldEnvironment.new() + var we := _we _env = Environment.new() _env.background_mode = Environment.BG_SKY var sky := Sky.new() @@ -46,9 +80,22 @@ func _environment() -> void: add_child(_sun) +const STADTRAND := 140.0 # unsichtbare Wand: hinter dem Bezirk endet die Welt + func _boden() -> void: Prims.box(self, Vector3(0, -0.53, 0), Vector3(300, 1, 300), Color(0.42, 0.48, 0.40)) _strassen() + # Stadtrand: wer immer weiter läuft, fiele sonst vom Rand der Welt + for d: Vector3 in [Vector3.FORWARD, Vector3.BACK, Vector3.LEFT, Vector3.RIGHT]: + var wand := StaticBody3D.new() + var cs := CollisionShape3D.new() + var form := BoxShape3D.new() + form.size = Vector3(4.0, 30.0, STADTRAND * 2.0 + 8.0) if absf(d.x) > 0.0 \ + else Vector3(STADTRAND * 2.0 + 8.0, 30.0, 4.0) + cs.shape = form + wand.add_child(cs) + wand.position = d * (STADTRAND + 2.0) + Vector3(0, 15.0, 0) + add_child(wand) ## Arterien breit, roji schmal -- beide als flaches Band mit Belag. @@ -61,7 +108,9 @@ func _strassen() -> void: 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), + # gestaffelt: Kreuzungen und Gasseneinfahrten flimmern sonst (Z-Fighting) + var hoch := 0.02 if breit > 5.0 and absf(d.z) > 0.0 else (0.03 if breit > 5.0 else 0.04) + var band := Prims.box(self, (a + b) * 0.5 + Vector3(0, hoch, 0), Vector3(breit, 0.06, d.length() + breit), farbe, false) band.rotation.y = atan2(d.x, d.z) @@ -84,7 +133,7 @@ func _bebauung() -> void: if minf(g.x, g.y) < 3.0: continue var n := 1 + rng.randi_range(0, 3) - var hoehe := Orte.hoehe(n) + var hoehe := HOEHE * float(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) var bau := Node3D.new() @@ -93,18 +142,17 @@ func _bebauung() -> void: # Kollision etwas größer: so schließt sich die Fuge zum Nachbarn, niemand keilt sich hinein Prims._add_box_body(klotz, Vector3(g.x + 0.9, hoehe, g.y + 0.9)) for f in range(n): - _fenster(bau, c + Vector3(0, Orte.hoehe(f), 0), g, ton) + _fenster(bau, c + Vector3(0, HOEHE * float(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(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}) + _bauten.append({"c": c, "size": g, "hoch": hoehe, "teile": [bau], "waende": []}) -## Wohnungen und Sonderbauten: geschlossene Häuser mit Stockwerken, Treppenhaus -## und Möbeln. Der Geist gleitet hinein, der Etagenschnitt öffnet sie ihm. +## Wohnungen und Sonderbauten: geschlossene Häuser, ein Raum mit Tür, Möbeln +## und Spuren. Der Geist geht hinein, dann verschwinden Dach und Vorderwände. func _orte() -> void: for key in HumanDB.PLACES: var pl: Dictionary = HumanDB.PLACES[key] @@ -112,42 +160,32 @@ func _orte() -> void: if float(s[0]) <= 0.0: continue var c := Orte.center(HumanDB.PLACES, key) - var groesse := Vector3(float(s[0]), Orte.ETAGE, float(s[1])) + var groesse := Vector3(float(s[0]), HOEHE, float(s[1])) var farbe: Color = pl["color"] 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, f == 0)) - if f > 0: - _decke(eb, key, cf, groesse, farbe.darkened(0.35)) - _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_", key) - 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 raum := Node3D.new() + add_child(raum) + var waende: Array = Prims.room(raum, c, groesse, farbe, farbe.darkened(0.35), + Orte.DIR_VEC[pl["dir"]], tuer, true) + _fenster(raum, c, Vector2(groesse.x, groesse.z), farbe) + _einrichtung(raum, c, groesse, farbe, key) + var wer := HumanDB.bewohner_von(key) + if not wer.is_empty(): + _spuren(raum, key, c, groesse, wer) + var licht := OmniLight3D.new() + licht.position = c + 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 + raum.add_child(licht) + _nachtlichter.append(licht) var dach := Node3D.new() add_child(dach) - Prims.box(dach, c + Vector3(0, Orte.hoehe(n) + 0.1, 0), + Prims.box(dach, c + Vector3(0, HOEHE + 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}) + Prims.label(self, c + Vector3(0, HOEHE + 1.6, 0), str(pl["name"]), 0.55, Color(1, 1, 0.9)) + _bauten.append({"c": c, "size": Vector2(groesse.x, groesse.z), "hoch": HOEHE, + "teile": [dach], "waende": waende}) ## Ein Fensterband je Wand, damit man die Stockwerke von außen zählt. @@ -163,51 +201,21 @@ func _fenster(parent: Node, cf: Vector3, g: Vector2, ton: Color) -> void: glas, false) -## Ein Lauf von Podest zu Podest -- die Treppe, die Bewohner und Geist -## hochgehen. Sie trägt, deshalb mit Kollision. -func _treppe(parent: Node, key: String, f: int) -> void: - var lauf := Orte.treppen_lauf(HumanDB.PLACES, key, f) - var von: Vector3 = lauf[0] - var bis: Vector3 = lauf[1] - var d := bis - von - var stufe := Prims.box(parent, (von + bis) * 0.5, Vector3(1.6, 0.18, d.length()), - Color(0.55, 0.50, 0.46)) - stufe.basis = Basis.looking_at(d.normalized(), Vector3.UP) - var r := Orte.treppen_rahmen(HumanDB.PLACES, key) - var podest := Orte.treppe(HumanDB.PLACES, key, f + 1) - var mass := Orte._xz(r["a"], Orte.PODEST * 2.0, Orte.TREPPE_TIEFE - 0.8) - Prims.box(parent, podest + Vector3(0, -0.09, 0), Vector3(mass.x, 0.18, mass.y), - Color(0.55, 0.50, 0.46)) - - -## Der Boden einer oberen Etage lässt über dem Treppenhaus ein Loch frei -- -## sonst stieße, wer die Treppe hochkommt, mit dem Kopf an die Decke. -func _decke(parent: Node, key: String, cf: Vector3, _groesse: Vector3, farbe: Color) -> void: - for platte in Orte.treppen_decke(HumanDB.PLACES, key): - var mitte: Vector3 = platte[0] - var mass: Vector2 = platte[1] - mitte.y = cf.y - 0.1 - Prims.box(parent, mitte, Vector3(mass.x, 0.2, mass.y), farbe) - - ## Ein paar Möbel, damit man sieht, wofür ein Raum da ist. Gestellt wird im -## Rahmen der Tür (quer / zur Tür hin); was in der Türzone, im Treppenstreifen -## oder auf dem Weg von der Tür zur Treppe stünde, bleibt weg. -func _einrichtung(parent: Node, c: Vector3, groesse: Vector3, farbe: Color, key: String, - ort: String = "") -> void: - if ort == "": - ort = key +## Rahmen der Tür (quer / zur Tür hin); was in der Türzone stünde, bleibt weg. +func _einrichtung(parent: Node, c: Vector3, groesse: Vector3, farbe: Color, key: String) -> void: + var ort := key var d: Vector3 = Orte.DIR_VEC[HumanDB.PLACES[ort]["dir"]] var hd: float = (groesse.z if absf(d.z) > 0.0 else groesse.x) * 0.5 var hq: float = (groesse.x if absf(d.z) > 0.0 else groesse.z) * 0.5 var w := maxf(0.5, hq - 2.0) var h := maxf(0.5, hd - 2.0) if key.begins_with("heim_"): - # an der Türwand, links und rechts der Tür -- die Rückwand gehört der Treppe + # an der Türwand, links und rechts der Tür _moebel(parent, ort, c, hq - 1.35, hd - 1.6, 0.35, Vector3(1.9, 0.7, 2.4), Color(0.85, 0.85, 0.90)) if _moebel(parent, ort, c, -(hq - 1.15), hd - 1.6, 0.75, Vector3(1.5, 0.15, 2.2), - farbe.darkened(0.5)): + farbe.darkened(0.5)) != null: _moebel(parent, ort, c, -(hq - 1.15), hd - 1.6, 1.05, Vector3(0.8, 0.55, 0.1), Color(0.2, 0.25, 0.3)) elif key == "cafe": @@ -236,38 +244,64 @@ func _einrichtung(parent: Node, c: Vector3, groesse: Vector3, farbe: Color, key: Color(0.52, 0.48, 0.46)) +## Spuren: was von den Ursachen eines Menschen in seiner Wohnung herumliegt. +## Je Ursache eine, an den Seitenwänden hinter Bett und Tisch, ansprechbar mit E. +func _spuren(parent: Node, key: String, c: Vector3, groesse: Vector3, wer: Dictionary) -> void: + var d: Vector3 = Orte.DIR_VEC[HumanDB.PLACES[key]["dir"]] + var hd: float = (groesse.z if absf(d.z) > 0.0 else groesse.x) * 0.5 + var hq: float = (groesse.x if absf(d.z) > 0.0 else groesse.z) * 0.5 + var plaetze: Array = [[hq - 0.7, hd - 3.4], [-(hq - 0.7), hd - 3.4], [hq - 0.7, hd - 4.4], + [-(hq - 0.7), hd - 4.4], [hq - 0.7, hd - 5.4], [-(hq - 0.7), hd - 5.4], [0.0, hd - 3.9]] + var formen: Dictionary = { + "anfang": [Vector3(0.28, 0.08, 0.5), Color(0.25, 0.28, 0.36)], + "zeit": [Vector3(0.08, 0.6, 0.7), Color(0.92, 0.88, 0.78)], + "kraft": [Vector3(0.3, 0.28, 0.55), Color(0.75, 0.55, 0.35)], + "halt": [Vector3(0.32, 0.18, 0.42), Color(0.9, 0.86, 0.74)], + "grund": [Vector3(0.48, 0.28, 0.75), Color(0.45, 0.35, 0.28)], + } + var naechster := 0 + for u in wer.get("ursachen", []): + var us := str(u) + if not formen.has(us): + continue + var mass: Vector3 = formen[us][0] + var farbe: Color = formen[us][1] + while naechster < plaetze.size(): + var pl: Array = plaetze[naechster] + naechster += 1 + var y: float = 1.5 if us == "zeit" else mass.y * 0.5 + var stueck := _moebel(parent, key, c, float(pl[0]), float(pl[1]), y, mass, farbe, false, 0.9, true) + if stueck == null: + continue + var marke := Node3D.new() + stueck.add_child(marke) + marke.add_to_group("interactable") + marke.set_meta("kind", "spur") + marke.set_meta("arg", us) + marke.set_meta("human", str(wer["id"])) + marke.set_meta("titel", str(Ursachen.LISTE[us]["spur"])) + break + + ## Ein Möbelstück: aq quer zur Tür, ad zur Tür hin (positiv = zur Tür), Maße -## als (quer, hoch, tief). Steht es im Weg, wird es nicht gestellt. +## als (quer, hoch, tief). Steht es im Weg, wird es nicht gestellt (null). func _moebel(parent: Node, ort: String, c: Vector3, aq: float, ad: float, y: float, - mass: Vector3, farbe: Color, rund: bool = false) -> bool: + mass: Vector3, farbe: Color, rund: bool = false, glanz: float = 0.0, + klein: bool = false) -> Node3D: var d: Vector3 = Orte.DIR_VEC[HumanDB.PLACES[ort]["dir"]] var quer := Vector3(-d.z, 0, d.x) var pos := c + quer * aq + d * ad pos.y = c.y + y var s: Array = HumanDB.PLACES[ort]["size"] var hd: float = (float(s[1]) if absf(d.z) > 0.0 else float(s[0])) * 0.5 - var halb := maxf(mass.x, mass.z) * 0.5 - # Türzone: 2,2 tief hinter der Tür, 4 breit - if ad + mass.z * 0.5 > hd - 2.2 and absf(aq) - mass.x * 0.5 < 2.0: - return false - # Treppenstreifen - var r := Orte.treppen_rahmen(HumanDB.PLACES, ort) - var n: Vector3 = r["n"] - var tief_n: float = (mass.z if absf(n.dot(d)) > 0.5 else mass.x) * 0.5 - if (pos - c).dot(n) + tief_n > float(r["halb_n"]) - Orte.TREPPE_TIEFE - 0.3: - return false - # Weg von der Tür zur Treppe - var von := Orte.innen_tuer(HumanDB.PLACES, ort) - var bis := Orte.treppe(HumanDB.PLACES, ort, 0) - var flach := Vector3(pos.x, 0, pos.z) - if Geometry3D.get_closest_point_to_segment(flach, von, bis).distance_to(flach) < halb + 0.9: - return false + # Türzone: 2,2 tief hinter der Tür, 4 breit -- Kleinkram darf näher heran + var rand := 1.7 if klein else 2.0 + if ad + mass.z * 0.5 > hd - 2.2 and absf(aq) - mass.x * 0.5 < rand: + return null var welt_mass := mass if absf(d.z) > 0.0 else Vector3(mass.z, mass.y, mass.x) if rund: - Prims.cyl(parent, pos, mass.x * 0.5, mass.y, farbe) - else: - Prims.box(parent, pos, welt_mass, farbe) - return true + return Prims.cyl(parent, pos, mass.x * 0.5, mass.y, farbe, true, glanz) + return Prims.box(parent, pos, welt_mass, farbe, true, glanz) ## Der mittlere Block bleibt frei: Rasen, Teich, Bäume, Bänke. Der Park ist @@ -319,8 +353,20 @@ 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, HumanDB.NETZ, HumanDB.ANKER) + m.setup(HumanDB.LIST[i], i, HumanDB.PLACES) + m.nav_map = nav_map humans.append(m) + for i in range(HumanDB.PASSANTEN_LISTE.size()): + var pz := Bewohner.new() + add_child(pz) + pz.setup(HumanDB.PASSANTEN_LISTE[i], i + 7, HumanDB.PLACES) + pz.nav_map = nav_map + passanten.append(pz) + + +## Nur die aktive Welt stellt den Himmel; zwei WorldEnvironments vertragen sich nicht. +func aktiv_setzen(an: bool) -> void: + _we.environment = _env if an else null func _process(_delta: float) -> void: diff --git a/scripts/game.gd b/scripts/game.gd index 511cfbf..3f6895d 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -33,6 +33,9 @@ var ueberwunden: Dictionary = {} # human_id -> {problem -> true} var gesehen_zaehler: Dictionary = {} # "human:problem" -> wie oft dabeigewesen var completed: Array = [] var markiert: Dictionary = {} # human_id -> true: wer gerade ein Schild trägt +var welt_version := 0 # zählt hoch, wenn Dorf oder Stadt neu erzeugt wurden +var spuren: Dictionary = {} # human_id -> {ursache -> true}: in der Wohnung gelesen +var notizen: Dictionary = {} # human_id -> [Zeilen mit Tag und Uhrzeit], für die Akte var time_h: float = 7.0 var day: int = 1 var teleport_lock: float = 0.0 @@ -151,6 +154,7 @@ func insel_radius() -> float: func welt_erzeugen() -> void: DreamDB.erzeuge(haeuser()) HumanDB.erzeuge() + welt_version += 1 ## Das Traumdorf beginnt leer. Mit jedem erfüllten Traum zieht ein Wesen ein. @@ -352,11 +356,15 @@ func sehen(human_id: String, nr: int) -> void: ## Der Zustand einer Stunde. HUD und Panel zeichnen denselben -- zwei ## Auslegungen würden mit der Zeit auseinanderlaufen. -enum Stunde { UNBEKANNT, GESEHEN, SCHLAF, UNGEKLAERT, BENANNT, GEBAUT } +enum Stunde { UNBEKANNT, GESEHEN, SCHLAF, UNGEKLAERT, BENANNT, GEBAUT, VERDAECHTIG } func slot_zustand(human_id: String, nr: int) -> int: if not gesehen_slot(human_id, nr): + # eine gelesene Spur macht die Stunden ihrer Ursache verdächtig: dort lohnt es, dabei zu sein + var g := slot_grund(human_id, nr) + if Ursachen.ist_blocker(g) and spur_bekannt(human_id, Ursachen.ursache_von(g)): + return Stunde.VERDAECHTIG return Stunde.UNBEKANNT if not system_in(human_id, nr).is_empty(): return Stunde.GEBAUT @@ -405,6 +413,7 @@ func notieren(human_id: String, nr: int) -> String: if not Ursachen.ist_blocker(grund): notiz_sperre = NOTIZ_SPERRE notified.emit("Nichts schief: %s" % Orte.text(grund), Color(1, 0.75, 0.55)) + notiz(human_id, "Beobachtet: %s — nichts schief" % Orte.text(grund)) return "daneben" if erkannt(human_id, nr): notified.emit("Schon notiert: %s" % Orte.text(grund), Color(0.8, 0.85, 1)) @@ -414,13 +423,60 @@ func notieren(human_id: String, nr: int) -> String: erkannt_slots[human_id][nr] = true notified.emit("Erkannt: %s — %s" % [Ursachen.BLOCKER[grund]["name"], Ursachen.LISTE[Ursachen.ursache_von(grund)]["name"]], Color(0.8, 0.95, 1)) + notiz(human_id, "Erkannt: %s (%s)" % [Ursachen.BLOCKER[grund]["name"], + Ursachen.LISTE[Ursachen.ursache_von(grund)]["name"]]) state_changed.emit() return "treffer" ## Eine Ursache gilt als erkannt, sobald eine ihrer Tätigkeiten aufgedeckt ist. ## Nur eine Quelle -- sonst driften Slot und Ursache auseinander. +## Ein Traumwesen flüstert eine Spur des Menschen, um den es gerade geht -- +## das Dorf hilft dem, der ihm seine Bewohner gebracht hat. +func wesen_fluestert(index: int) -> String: + var wesen: Dictionary = DreamDB.LIST[index] if index < DreamDB.LIST.size() else {"name": "Ein Wesen"} + var wer := active_mission + if wer == "": + var offen := offene_missionen() + wer = str(offen[0]["id"]) if not offen.is_empty() else "" + if wer == "": + return "%s lächelt nur. Nimm dir am Schwarzen Brett jemanden vor." % wesen["name"] + var mensch := HumanDB.by_id(wer) + for u in ursachen_von(wer): + if spur_lesen(wer, str(u)): + return "%s flüstert: Bei %s liegt etwas -- %s" % [wesen["name"], mensch["name"], + Ursachen.LISTE[u]["spur_text"]] + return "%s flüstert: Was bei %s herumliegt, kennst du schon. Geh hin und sieh, wann es geschieht." % \ + [wesen["name"], mensch["name"]] + + +## Eine Zeile in die Akte: was wann festgestellt wurde. +func notiz(human_id: String, text: String) -> void: + if not notizen.has(human_id): + notizen[human_id] = [] + notizen[human_id].append("Tag %d, %02d Uhr — %s" % [day, int(time_h), text]) + + +## Eine Spur in der Wohnung lesen: die Ursache ist damit bekannt, ihre +## Stunden im Tag werden verdächtig. Liefert false, wenn sie schon bekannt war. +func spur_lesen(human_id: String, ursache: String) -> bool: + if spur_bekannt(human_id, ursache): + return false + if not spuren.has(human_id): + spuren[human_id] = {} + spuren[human_id][ursache] = true + notiz(human_id, "Spur: %s" % Ursachen.LISTE[ursache]["spur"]) + state_changed.emit() + return true + + +func spur_bekannt(human_id: String, ursache: String) -> bool: + return spuren.get(human_id, {}).has(ursache) + + func ursache_bekannt(human_id: String, ursache: String) -> bool: + if spur_bekannt(human_id, ursache): + return true for nr in erkannt_slots.get(human_id, {}): if Ursachen.ursache_von(slot_grund(human_id, int(nr))) == ursache: return true @@ -507,7 +563,8 @@ func system_setzen(human_id: String, nr: int, tech_id: String, widerstand: bool) var guete := float(pruef["guete"]) * (1.0 + 0.1 * float(verhalten(human_id, "guete"))) systeme[human_id][nr] = {"tech": tech_id, "guete": guete} add_xp(tech_id, 2) - notified.emit("System steht: %s um %02d:00" % + notiz(human_id, "Routine gesetzt: %s" % TechDB.by_id(tech_id)["name"]) + notified.emit("Neue Routine steht: %s um %02d:00" % [TechDB.by_id(tech_id)["name"], int(slots(human_id)[nr][0])], Color(0.6, 1, 0.7)) state_changed.emit() return {"ok": true, "erfolg": true, "guete": float(pruef["guete"])} @@ -699,7 +756,7 @@ func situation_anwenden(tech_id: String) -> Dictionary: ueberwunden[id] = {} ueberwunden[id][problem] = true res["ueberwunden"] = true - notified.emit("%s überwunden" % Probleme.name_von(problem), Color(1, 0.85, 0.4)) + notified.emit("%s — ein Schritt weiter" % Probleme.name_von(problem), Color(1, 0.85, 0.4)) state_changed.emit() return res diff --git a/scripts/human_db.gd b/scripts/human_db.gd index bdd4405..7a083a5 100644 --- a/scripts/human_db.gd +++ b/scripts/human_db.gd @@ -9,6 +9,7 @@ class_name HumanDB ## an da und leben ihren Tag. const EINWOHNER := 16 +const PASSANTEN := 12 # Leute ohne Geschichte, die die Straßen beleben const BLOCKER_MAX := 4 # mehr Blocker, und der Tag wäre nur noch kaputt const ACHSEN := [-72.0, -24.0, 24.0, 72.0] # Arterien in x wie in z const ARTERIE := 7.0 # Breite einer Arterie @@ -32,12 +33,10 @@ static var SONDER: Array = [ ["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 = [] +static var PASSANTEN_LISTE: Array = [] ## Arbeitsplätze, die ein erzeugter Mensch bekommen kann. static var ARBEIT: Array = ["uni", "cafe", "buero", "baeckerei", "werkstatt"] @@ -200,7 +199,11 @@ static func erzeuge(_ungenutzt: int = 0) -> void: _park_eintragen() _sonderbauten_verteilen() _wohnhaeuser_verteilen() + _ecken_eintragen() _anker_setzen() + PASSANTEN_LISTE = [] + for i in range(PASSANTEN): + PASSANTEN_LISTE.append(_passant(i)) var eigene := Aushaenge.laden() var n_eigen := mini(eigene.size(), EINWOHNER) for i in range(EINWOHNER - n_eigen): @@ -408,6 +411,48 @@ static func _gasse_freischneiden(ab: int, start: Vector3, ende: Vector3, flanke: PARZELLEN.append_array(neu) +## Straßenecken: die Kreuzungen des Netzes, Ziele für Passanten. +static func _ecken_eintragen() -> void: + var ecken: Array = [] + for i in range(NETZ.knoten.size()): + if NETZ.nachbarn(i).size() >= 3: + ecken.append(i) + if ecken.size() < 4: + for i in range(NETZ.knoten.size()): + if not ecken.has(i): + ecken.append(i) + for k in range(mini(8, ecken.size())): + var p: Vector3 = NETZ.knoten[int(ecken[(k * 5) % ecken.size()])] + PLACES["ecke_%d" % k] = { + "name": "Straßenecke", "pos": [p.x, p.z], "size": [0.0, 0.0], + "dir": "none", "color": Color(0.5, 0.5, 0.5), + } + + +## Ein Passant: stündlich eine andere Ecke, dazwischen Park -- kein Name, +## kein Schild, keine Mission. Er ist da, damit die Straßen nicht leer sind. +static func _passant(i: int) -> Dictionary: + var ecken: Array = [] + for key in PLACES: + if str(key).begins_with("ecke_"): + ecken.append(str(key)) + var routine: Array = [] + var letzte := "" + for h in range(24): + var ort := "park" if (h + i) % 7 == 3 else str(ecken[(i * 3 + h * 5) % ecken.size()]) + if ort == letzte: + ort = str(ecken[(i * 3 + h * 5 + 1) % ecken.size()]) + routine.append([h, ort, "unterwegs" if h % 2 == 0 else "wartet"]) + letzte = ort + return { + "id": "passant_%d" % i, + "name": "Passant", + "stumm": true, + "farbe": Color.from_hsv(0.58 + float(i % 4) * 0.05, 0.12, 0.55 + float(i % 3) * 0.1), + "routine": routine, + } + + # ------------------------------------------------------------ Belegung static func _park_eintragen() -> void: @@ -455,13 +500,12 @@ static func _sonderbauten_verteilen() -> void: "size": [p["size"].x, p["size"].y], "dir": p["dir"], "color": SONDER[s][2], - "etagen": int(SONDER[s][3]), } ## Die Wohnhäuser: gleichmäßig über den Bezirk verteilt, damit die Wege ## nicht alle in derselben Ecke enden. -const WOHNUNG_MIN := 7.0 # schmaler passt kein Treppenhaus mehr neben die Tür +const WOHNUNG_MIN := 7.0 # schmaler passt neben die Tür kein Bett mehr static func _wohnhaeuser_verteilen() -> void: var frei: Array = [] @@ -487,21 +531,9 @@ 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"] @@ -635,7 +667,6 @@ 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), @@ -678,7 +709,6 @@ 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), } @@ -710,6 +740,13 @@ static func _nachname(i: int) -> String: return str(NACHNAMEN[(i / VORNAMEN.size() + i) % NACHNAMEN.size()]) +static func bewohner_von(heim: String) -> Dictionary: + for m in LIST: + if str(m.get("heim", "")) == heim: + return m + return {} + + static func by_id(id: String) -> Dictionary: for h in LIST: if h["id"] == id: diff --git a/scripts/main.gd b/scripts/main.gd index 34b3fa2..6f119ce 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -7,7 +7,11 @@ const REICHWEITE_MENSCH := 3.6 var player: Player var ui: UI -var welt: Node3D +var welt: Node3D # die aktive Welt +var traum: DreamWorld = null # beide Welten bleiben bestehen und leben weiter +var erde: EarthWorld = null +var _traum_version := -1 +var _erde_version := -1 var auf_erde := false var _fokus: Node3D = null var _fokus_mensch: Node3D = null @@ -20,6 +24,7 @@ func _ready() -> void: player.look_enabled = false) ui.panel_closed.connect(func() -> void: player.look_enabled = true) + ui.hinfahren.connect(hinfahren) player = Player.new() add_child(player) @@ -34,28 +39,86 @@ func _ready() -> void: func _traumwelt() -> void: auf_erde = false - _setze_welt(DreamWorld.new()) + if traum == null or _traum_version != Game.welt_version: + traum = _neu_bauen(traum, DreamWorld.new(), 1) as DreamWorld + _traum_version = Game.welt_version + _aktivieren(traum) player.set_mode(Player.Mode.TRAUM) - player.global_position = (welt as DreamWorld).spawn + player.collision_mask = 1 + player.global_position = traum.spawn player.yaw = Player.CAM_YAW func _erde() -> void: auf_erde = true - _setze_welt(EarthWorld.new()) + if erde == null or _erde_version != Game.welt_version: + erde = _neu_bauen(erde, EarthWorld.new(), 2) as EarthWorld + _erde_version = Game.welt_version + _aktivieren(erde) player.set_mode(Player.Mode.GEIST) - player.global_position = (welt as EarthWorld).spawn + player.collision_mask = 2 + player.global_position = erde.spawn player.yaw = Player.CAM_YAW + if Game.active_mission != "": + zum_ziel(Game.active_mission, false) # wer mit Mission reist, landet vor der Tür seines Ziels -func _setze_welt(neu: Node3D) -> void: - if welt != null: - remove_child(welt) - welt.queue_free() +## Vor die Tür eines Menschen setzen -- steht er draußen, neben ihn. Der Fußweg +## quer durch den Bezirk war Leerlauf; das Gehen zählt in der Wohnung und beim Folgen. +func zum_ziel(id: String, melden: bool = true) -> void: + var wer: Bewohner = null + for h in erde.humans: + if str(h.data["id"]) == id: + wer = h + if wer == null: + return + var p: Vector3 + var drinnen: bool = HumanDB.PLACES.has(wer.place) \ + and float(HumanDB.PLACES[wer.place]["size"][0]) > 0.0 and wer._path.is_empty() + if drinnen: + p = Orte.door(HumanDB.PLACES, wer.place) + else: + p = wer.global_position + Vector3(2.0, 0, 0) + p = NavigationServer3D.map_get_closest_point(erde.nav_map, p) + player.global_position = p + Vector3(0, 0.3, 0) + player.velocity = Vector3.ZERO + player.folgt = null + Game.teleport_lock = 1.0 + if melden: + Game.notified.emit("Du stehst %s %s." % ["vor der Tür von" if drinnen else "bei", + wer.data["name"]], Color(0.7, 0.9, 1)) + + +## Knopf in der Akte: zur Erde und vor die Tür. +func hinfahren(id: String) -> void: + if not auf_erde: + _erde() + zum_ziel(id) + + +## Eine Welt neu bauen -- nur wenn Dorf oder Stadt sich geändert haben. Jede +## Welt liegt auf ihrer eigenen Kollisionsebene, denn beide teilen sich den Raum. +func _neu_bauen(alt: Node3D, neu: Node3D, ebene: int) -> Node3D: + if alt != null: + remove_child(alt) + alt.queue_free() + add_child(neu) + move_child(neu, 0) + for k in neu.find_children("*", "CollisionObject3D", true, false): + (k as CollisionObject3D).collision_layer = 1 << (ebene - 1) + return neu + + +## Die andere Welt bleibt da und lebt weiter, nur unsichtbar und ohne Himmel. +func _aktivieren(neu: Node3D) -> void: + for w in [traum, erde]: + if w == null: + continue + var an: bool = w == neu + w.visible = an + w.set("spieler", player if an else null) + w.call("aktiv_setzen", an) 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 @@ -115,6 +178,8 @@ func _fokus_suchen() -> void: var p := player.global_position var best := REICHWEITE_OBJEKT for n in get_tree().get_nodes_in_group("interactable"): + if not welt.is_ancestor_of(n): + continue # die andere Welt liegt im selben Raum var d: float = p.distance_to((n as Node3D).global_position) if d < best: best = d @@ -123,8 +188,6 @@ func _fokus_suchen() -> void: var bestm := REICHWEITE_MENSCH for m in (welt as EarthWorld).humans: 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 @@ -156,6 +219,10 @@ func _hinweis_setzen() -> void: ui.setze_hinweis("[E] %s — Techniken lernen" % titel) "brett": ui.setze_hinweis("[E] Schwarzes Brett — Mission annehmen") + "spur": + ui.setze_hinweis("[E] Spur: %s" % titel) + "wesen": + ui.setze_hinweis("[E] %s — zuhören" % titel) _: ui.setze_hinweis("[E] %s" % titel) else: @@ -298,3 +365,12 @@ func _interagieren() -> void: ui.lehre_panel(str(_fokus.get_meta("arg"))) "brett": ui.brett_panel() + "wesen": + Game.notified.emit(Game.wesen_fluestert(int(str(_fokus.get_meta("arg")))), Color(0.85, 0.8, 1)) + "spur": + var wer := str(_fokus.get_meta("human")) + var u := str(_fokus.get_meta("arg")) + var neu := Game.spur_lesen(wer, u) + Game.notified.emit("%s%s — %s" % ["" if neu else "Schon gesehen: ", + Ursachen.LISTE[u]["spur"], Ursachen.LISTE[u]["spur_text"]], + Color(1, 0.9, 0.6) if neu else Color(0.8, 0.85, 1)) diff --git a/scripts/orte.gd b/scripts/orte.gd index cab164b..a0d2a4d 100644 --- a/scripts/orte.gd +++ b/scripts/orte.gd @@ -48,220 +48,6 @@ 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 - - -const TREPPE_LAUF := 6.0 # Abstand der Wendepodeste entlang der Wand -const TREPPE_TIEFE := 4.4 # Streifen an der Treppenwand: zwei Läufe nebeneinander -const PODEST := 0.9 # halbe Podestlänge - -## Rahmen des Treppenhauses: an der längeren Wand, die nicht die Türwand ist. -## n zeigt von der Mitte zur Treppenwand, a läuft an ihr entlang. -static func treppen_rahmen(places: Dictionary, key: String) -> Dictionary: - 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) - if halb_q >= halb_d: - return {"c": c, "n": -d, "a": quer, "halb_n": halb_d, "halb_a": halb_q} - return {"c": c, "n": -quer, "a": -d, "halb_n": halb_q, "halb_a": halb_d} - - -static func _lauf(r: Dictionary) -> float: - return minf(TREPPE_LAUF, float(r["halb_a"]) * 2.0 - 2.8) - - -static func _podest_a(r: Dictionary, e: int) -> float: - var p0: float = float(r["halb_a"]) - 1.4 - return p0 if e % 2 == 0 else p0 - _lauf(r) - - -## Wendepodest der Etage e: zwei Läufe liegen nebeneinander, das Podest -## verbindet sie. Der obere Lauf ist so nie die Decke des unteren. -static func treppe(places: Dictionary, key: String, e: int) -> Vector3: - var r := treppen_rahmen(places, key) - var p: Vector3 = r["c"] + r["n"] * (float(r["halb_n"]) - TREPPE_TIEFE * 0.5) \ - + r["a"] * _podest_a(r, e) - p.y = hoehe(e) - return p - - -## Ein Lauf: [Anfang auf Etage f, Ende auf Etage f + 1]. -static func treppen_lauf(places: Dictionary, key: String, f: int) -> Array: - var r := treppen_rahmen(places, key) - var tiefe: float = float(r["halb_n"]) - (1.4 if f % 2 == 0 else 3.0) - var richtung := -1.0 if f % 2 == 0 else 1.0 - var von: Vector3 = r["c"] + r["n"] * tiefe + r["a"] * (_podest_a(r, f) + richtung * PODEST) - var bis: Vector3 = r["c"] + r["n"] * tiefe + r["a"] * (_podest_a(r, f + 1) - richtung * PODEST) - von.y = hoehe(f) - bis.y = hoehe(f + 1) - return [von, bis] - - -## Wegpunkte durchs Treppenhaus von Etage a nach Etage b, ohne a selbst: -## Laufanfang, Laufende, Podest -- je Etage. -static func treppen_weg(places: Dictionary, key: String, a: int, b: int) -> Array: - var out: Array = [] - if b > a: - for f in range(a, b): - var l := treppen_lauf(places, key, f) - out.append(l[0]) - out.append(l[1]) - out.append(treppe(places, key, f + 1)) - else: - for f in range(a - 1, b - 1, -1): - var l := treppen_lauf(places, key, f) - out.append(l[1]) - out.append(l[0]) - out.append(treppe(places, key, f)) - return out - - -## Deckenplatten einer oberen Etage: alles außer dem Treppenloch. -## Liefert [[mitte, groesse_xz], ...] ohne Höhe. -static func treppen_decke(places: Dictionary, key: String) -> Array: - var r := treppen_rahmen(places, key) - var c: Vector3 = r["c"] - var n: Vector3 = r["n"] - var a: Vector3 = r["a"] - var halb_n := float(r["halb_n"]) - var halb_a := float(r["halb_a"]) - var out: Array = [] - # Hauptplatte: alles außer dem Streifen an der Treppenwand - if halb_n * 2.0 - TREPPE_TIEFE > 0.2: - out.append([c - n * (TREPPE_TIEFE * 0.5), _xz(a, halb_a * 2.0, halb_n * 2.0 - TREPPE_TIEFE)]) - # Rest des Streifens jenseits des Lochs - var ende: float = _podest_a(r, 1) - PODEST - var rest := ende + halb_a - if rest > 0.3: - out.append([c + n * (halb_n - TREPPE_TIEFE * 0.5) + a * ((ende - halb_a) * 0.5), - _xz(a, rest, TREPPE_TIEFE)]) - return out - - -## Kastenmaß in Weltachsen: laenge_a entlang a, laenge_n quer dazu. -static func _xz(a: Vector3, laenge_a: float, laenge_n: float) -> Vector2: - return Vector2(laenge_a, laenge_n) if absf(a.x) > 0.0 else Vector2(laenge_n, laenge_a) - - -## Rückt einen Punkt aus dem Treppenstreifen heraus (für Möbel). -static func frei_von_treppe(places: Dictionary, key: String, pos: Vector3, halb: float) -> Vector3: - var r := treppen_rahmen(places, key) - var n: Vector3 = r["n"] - var pn: float = (pos - r["c"]).dot(n) - var grenze: float = float(r["halb_n"]) - TREPPE_TIEFE - halb - 0.2 - if pn > grenze: - pos -= n * (pn - grenze) - return pos - - -## 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) - - -## Ort, in dessen Grundriss der Punkt liegt -- oder "". -static func ort_bei(places: Dictionary, p: Vector3) -> String: - for key in places: - var s: Array = places[key]["size"] - if float(s[0]) <= 0.0: - continue - var c := center(places, key) - if absf(p.x - c.x) <= float(s[0]) * 0.5 and absf(p.z - c.z) <= float(s[1]) * 0.5: - return key - return "" - - -## Weg für einen Körper von einem freien Punkt zu einem Menschen: aus dem -## eigenen Haus heraus, über das Netz, durch seine Tür und in seine Etage. -static func route_zu(places: Dictionary, netz: Netz, von: Vector3, nach: Vector3, - nach_ort: String, nach_etage: int) -> Array: - var pts: Array = [] - var start := von - var von_ort := ort_bei(places, von) - if von_ort != "" and gebaeude(places, von_ort): - var e := int(round(von.y / ETAGE)) - if e > 0: - pts.append(treppe(places, von_ort, e)) - pts.append_array(treppen_weg(places, von_ort, e, 0)) - pts.append(innen_tuer(places, von_ort)) - start = door(places, von_ort) - pts.append(start) - var drin := nach_ort != "" and gebaeude(places, nach_ort) - var ende := door(places, nach_ort) if drin else nach - if netz != null: - var a: int = int(netz.andocken(start)["knoten"]) - var b: int = int(netz.andocken(ende)["knoten"]) - for idx in netz.pfad(a, b): - pts.append(netz.knoten[idx]) - if drin: - pts.append(door(places, nach_ort)) - pts.append(innen_tuer(places, nach_ort)) - if nach_etage > 0: - pts.append(treppe(places, nach_ort, 0)) - pts.append_array(treppen_weg(places, nach_ort, 0, nach_etage)) - var ziel := nach - if drin: - ziel.y = hoehe(nach_etage) - pts.append(ziel) - return pts - - -## Ein Ort ist ein Gebäude, wenn er Grundriss und Tür hat. -static func gebaeude(places: Dictionary, key: String) -> bool: - return places.has(key) and float(places[key]["size"][0]) > 0.0 \ - and DIR_VEC[places[key]["dir"]] != Vector3.ZERO - - -## Route mit allem, was ein Körper braucht: aus der Etage runter, gerade durch -## die Tür, über das Netz, durch die nächste Tür und die Treppe hinauf. -## `etagen` sagt, in welcher Etage der Mensch an welchem Ort ist. -static func route_voll(places: Dictionary, a: String, b: String, offset: Vector3, - etagen: Dictionary, netz: Netz = null, anker: Dictionary = {}, - war_unterwegs: bool = false) -> Array: - var pts := route(places, a, b, offset, netz, anker) - if pts.is_empty(): - return pts - if gebaeude(places, a) and not war_unterwegs: - var e_von := int(etagen.get(a, 0)) - var runter: Array = [] - if e_von > 0: - runter.append(treppe(places, a, e_von)) - runter.append_array(treppen_weg(places, a, e_von, 0)) - runter.append(innen_tuer(places, a)) - runter.append(door(places, a)) - runter.append_array(pts) - pts = runter - if gebaeude(places, b): - var e_nach := int(etagen.get(b, 0)) - var ziel: Vector3 = pts.pop_back() - pts.append(door(places, b)) - pts.append(innen_tuer(places, b)) - if e_nach > 0: - pts.append(treppe(places, b, 0)) - pts.append_array(treppen_weg(places, b, 0, e_nach)) - ziel.y = hoehe(e_nach) - pts.append(ziel) - return pts - - static func center(places: Dictionary, key: String) -> Vector3: var p: Array = places[key]["pos"] return Vector3(p[0], 0.0, p[1]) @@ -277,31 +63,3 @@ static func door(places: Dictionary, key: String) -> Vector3: var size: Array = pl["size"] var half: float = size[1] * 0.5 if absf(d.z) > 0.0 else size[0] * 0.5 return c + d * (half + 2.5) - - -## 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) - if da.length() > 17.0: - pts.append(da.normalized() * 15.0) - if db.length() > 17.0: - pts.append(db.normalized() * 15.0) - pts.append(db) - pts.append(center(places, b) + offset) - return pts diff --git a/scripts/player.gd b/scripts/player.gd index 6ac6d1d..c311040 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -11,7 +11,6 @@ const GRAVITY := 24.0 const JUMP := 9.0 const FOLGE_ABSTAND := 2.5 # so weit bleibt der Geist auf der Spur zurück const LEINE := 14.0 # weiter weg reißt die Leine: er wird nachgezogen -const FOLGE_NAH := 5.0 # näher dran braucht es keinen Anlauf const SCHWEBE := 0.25 # eine Handbreit über dem Boden const PITCH := -0.96 # 55° von oben, fest wie in Diablo const CAM_YAW := PI * 0.25 # Blick diagonal über den Bezirk @@ -29,9 +28,12 @@ var arm: Node3D var cam: Camera3D var _dreh_rest := 0.0 # noch zu drehender Winkel (Q) var _zoom_stufe := 0 -var _anlauf: Array = [] # Weg zu dem Menschen, an den man sich gehängt hat -var _stockt := 0.0 # Sekunden ohne Vorankommen -var spruenge := 0 # wie oft die Leine nachziehen musste +var _leine_pfad: Array = [] # Pfad zum Verfolgten, alle 0,4 s neu +var _leine_uhr := 0.0 +var _verloren := 0.0 # Sekunden zu weit weg vom Verfolgten +var _stockt := 0.0 # Sekunden ohne Vorankommen (eingeklemmt) +var _vorher := Vector3.ZERO # Position im letzten Physikbild +var spruenge := 0 # wie oft Leine oder Stadt nachziehen mussten var body: Node3D var _schleier: MeshInstance3D var _kern: MeshInstance3D @@ -111,8 +113,7 @@ func _ready() -> void: func set_mode(m: int) -> void: mode = m folgt = null - collision_layer = 1 - collision_mask = 1 + collision_layer = 1 # die Maske stellt main je Welt: jede Welt hat ihre Ebene if m == Mode.GEIST: body.scale = Vector3.ONE * 0.6 _schleier_mat.albedo_color.a = 0.3 @@ -177,7 +178,11 @@ func _physics_process(delta: float) -> void: ## Wer mit gedrückter Taste zwei Sekunden nicht vorankommt, steckt fest -- ## dann setzt ihn die Stadt auf die nächste Straße. func _eingeklemmt(delta: float, will: bool) -> void: - if mode != Mode.GEIST or not will or Vector2(velocity.x, velocity.z).length() > 0.3: + # gemessen am Weg, nicht an der Geschwindigkeit: in einer Ecke schiebt man mit + # voller Geschwindigkeit gegen die Wand und kommt doch nicht vom Fleck + var weg := Vector2(global_position.x - _vorher.x, global_position.z - _vorher.z).length() + _vorher = global_position + if mode != Mode.GEIST or not will or weg > 0.3 * delta: _stockt = 0.0 return _stockt += delta @@ -210,37 +215,39 @@ func _fallen(delta: float) -> void: velocity.y -= GRAVITY * delta -## An einen Menschen hängen. Steht er weit weg, wird erst der Weg zu ihm -## geplant: aus dem Haus, über die Straßen, in seine Wohnung. +## An einen Menschen hängen. Den Weg zu ihm findet das Navigationsnetz. func anhaengen(m: Bewohner) -> void: folgt = m - _anlauf = [] - _stockt = 0.0 - if global_position.distance_to(m.global_position) > FOLGE_NAH: - var ort: String = m.place if m._path.is_empty() else "" - _anlauf = Orte.route_zu(m.places, m.netz, global_position, m.global_position, - ort, m.etage(ort) if ort != "" else 0) + _leine_pfad = [] + _leine_uhr = 0.0 + _verloren = 0.0 -## Folgen heißt: seiner Spur nachgehen, durch dieselben Türen und Treppen. -## Er läuft so schnell wie nötig, um dranzubleiben -- auch bei hohem Tempo. +## Folgen heißt: ihm nachgehen, durch dieselben Türen und Treppen. Der Pfad +## kommt vom Navigationsnetz und wird laufend neu geplant. Er läuft so schnell +## wie nötig, um dranzubleiben -- auch bei hohem Tempo. func _an_der_leine(delta: float) -> void: var mensch := folgt as Bewohner - var ziel: Vector3 - if not _anlauf.is_empty(): - ziel = _anlauf[0] - var bis := ziel - global_position - bis.y = 0.0 - if bis.length() < 0.6 and absf(ziel.y - global_position.y) < 1.6: - _anlauf.pop_front() - if _anlauf.is_empty(): - ziel = mensch.spur_punkt(FOLGE_ABSTAND) - else: - ziel = _anlauf[0] + _leine_uhr -= delta + if _leine_uhr <= 0.0 or _leine_pfad.is_empty(): + _leine_uhr = 0.4 + _leine_pfad = [] + if mensch.nav_map.is_valid(): + var pts := NavigationServer3D.map_get_path(mensch.nav_map, global_position, + mensch.global_position, true) + for i in range(1, pts.size()): + _leine_pfad.append(pts[i]) + if pts.size() < 2: + print(" [kein Weg] Geist von %s zu %s bei %s" % [str(global_position), + mensch.data["name"], str(mensch.global_position)]) + var abstand := global_position.distance_to(mensch.global_position) + var ziel: Vector3 = _leine_pfad[0] if not _leine_pfad.is_empty() else mensch.global_position var d := ziel - global_position d.y = 0.0 + if not _leine_pfad.is_empty() and d.length() < 0.5 and absf(ziel.y - global_position.y) < 1.6: + _leine_pfad.pop_front() var tempo := maxf(SPEED_GEIST, Bewohner.GEHTEMPO * Game.zeit_faktor() * 1.4) - if d.length() > 0.3: + if abstand > FOLGE_ABSTAND and d.length() > 0.05: var v := d.normalized() * minf(tempo, d.length() / delta) velocity.x = v.x velocity.z = v.z @@ -250,16 +257,13 @@ func _an_der_leine(delta: float) -> void: velocity.z = 0.0 _fallen(delta) move_and_slide() - if d.length() > 0.6 and Vector2(velocity.x, velocity.z).length() < 0.3: - _stockt += delta - else: - _stockt = 0.0 - var zu_weit: bool = _anlauf.is_empty() \ - and global_position.distance_to(mensch.global_position) > LEINE - if _stockt > 2.0 or zu_weit: - global_position = ziel # hängen geblieben: die Leine zieht ihn nach + # Verloren? Nur wenn das Netz keinen Weg weiß und er zu weit weg ist, + # wird er nachgezogen -- ein Notnagel, kein Spielzug. + _verloren = _verloren + delta if abstand > LEINE and _leine_pfad.is_empty() else 0.0 + if _verloren > 3.0: + global_position = mensch.global_position + Vector3(0, 0.3, 0) velocity = Vector3.ZERO - _stockt = 0.0 + _verloren = 0.0 spruenge += 1 diff --git a/scripts/prims.gd b/scripts/prims.gd index c3ef058..afc431b 100644 --- a/scripts/prims.gd +++ b/scripts/prims.gd @@ -139,14 +139,11 @@ static func room(parent: Node, center: Vector3, size: Vector3, wall_c: Color, static func _meshes_von(b: Dictionary) -> Array: var out: Array = [] - for n in b["etagen"]: + for n in b["teile"]: _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) + for w in b["waende"]: + if not out.has(w): + out.append(w) return out @@ -162,12 +159,10 @@ static func _wand(mi: MeshInstance3D, normale: Vector3) -> MeshInstance3D: 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. +## Schnitt wie bei den Sims: steht der Spieler in einem Bau, verschwinden Dach +## (bei Klötzen der ganze Klotz) und die beiden Wände, die zur Kamera zeigen. +## Ein Bau: {c, size, hoch, teile, waende}. 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"] @@ -188,17 +183,11 @@ static func schnitt(bauten: Array, spieler: Vector3, kamera: Vector3) -> void: (mi as GeometryInstance3D).cast_shadow = \ GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if im_weg \ else GeometryInstance3D.SHADOW_CASTING_SETTING_ON - 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) + for t in b["teile"]: + (t as Node3D).visible = not drin + for w in b["waende"]: + var n: Vector3 = w.get_meta("normal") + (w as Node3D).visible = not (drin and n.dot(kamera - c) > 0.0) ## Ein Satteldach: ein geschlossenes Prisma über den Wänden. Der First läuft diff --git a/scripts/ui.gd b/scripts/ui.gd index 5909ea2..d749fe7 100644 --- a/scripts/ui.gd +++ b/scripts/ui.gd @@ -6,6 +6,7 @@ class_name UI ## der Mitte für Orte. Die Zeitleiste unten ist der Tag des Menschen -- alles ## im Spiel hängt daran. +signal hinfahren(id: String) signal panel_opened signal panel_closed @@ -25,8 +26,15 @@ const C_UNBEKANNT := Color(0.22, 0.23, 0.30) const C_GESEHEN := Color(0.46, 0.50, 0.62) const C_SCHLAF := Color(0.30, 0.33, 0.44) const C_WARN := Color(0.85, 0.66, 0.32) +const C_VERDAECHTIG := Color(0.58, 0.46, 0.26) # Spur gelesen, Stunde noch nicht gesehen const C_BLOCKER := Color(0.84, 0.42, 0.35) const C_SYSTEM := Color(0.44, 0.78, 0.50) +const C_PAPIER := Color(0.95, 0.92, 0.85) # die Akte: Papier, dunkle Tinte +const C_PAPIER_ZEILE := Color(0.90, 0.86, 0.78) +const TINTE := "#2b2620" +const TINTE_HELL := "#6b6257" +const TINTE_ROT := "#a83a2e" +const TINTE_GRUEN := "#3d7a45" var offen := false var _kind := "" @@ -299,6 +307,8 @@ func _ablauf_farbe(human_id: String, nr: int) -> Color: return C_BLOCKER Game.Stunde.SCHLAF: return C_SCHLAF + Game.Stunde.VERDAECHTIG: + return C_VERDAECHTIG return C_GESEHEN @@ -310,20 +320,24 @@ func _ablauf_aktualisieren() -> void: return var jetzt := int(Game.time_h) var offen_std := 0 + var verdaechtig := 0 var puls := 0.5 + 0.5 * sin(Time.get_ticks_msec() * 0.008) for stunde in range(24): var nr := Game.slot_bei_stunde(id, stunde) var f := _ablauf_farbe(id, nr) if not Game.gesehen_slot(id, nr): offen_std += 1 + if Game.slot_zustand(id, nr) == Game.Stunde.VERDAECHTIG: + verdaechtig += 1 var zelle: ColorRect = _zellen[stunde] if stunde == jetzt: f = f.lightened(0.55 if not Game.situation_bei(id) else 0.3 + 0.4 * puls) zelle.color = f zelle.custom_minimum_size.y = 24 if stunde == jetzt else 16 zelle.tooltip_text = "%02d Uhr — %s" % [stunde, _stunden_text(id, nr)] - _ablauf_titel.text = "Ihr Tag 0 – 23 Uhr%s" % \ - ("" if offen_std == 0 else " · %d Stunden unbekannt" % offen_std) + _ablauf_titel.text = "Ihr Tag 0 – 23 Uhr%s%s" % [ + "" if offen_std == 0 else " · %d Stunden unbekannt" % offen_std, + "" if verdaechtig == 0 else " · %d verdächtig" % verdaechtig] var urs: Array = [] for u in Game.ursachen_von(id): @@ -414,7 +428,7 @@ func _baue_wirte() -> void: func _wirt_fuer(kind: String) -> PanelContainer: match kind: - "journal", "technik", "systeme": + "journal", "technik", "akte": return _drawer "slot", "situation", "anwenden": return _karte_host @@ -609,7 +623,7 @@ func _reiter() -> void: _content.add_child(hb) var seiten := [ ["techniken", "Techniken (%d)" % Game.known.size()], - ["missionen", "Missionen (%d)" % Game.progress.size()], + ["missionen", "Akten (%d)" % Game.progress.size()], ] for eintrag in seiten: var b := Button.new() @@ -675,44 +689,145 @@ func _journal_missionen() -> void: stand = "aktiv · %d %%" % int(Game.mission_progress(id)) farbe = C_AKZENT _karte("%s · %s" % [h["name"], stand], farbe, - func() -> void: systeme_panel(id)) + func() -> void: akte_panel(id)) -# ---------------------------------------------- Diagnose und Systeme +# ---------------------------------------------- Die Akte + +## Alles zu einem Menschen auf einem Blatt: Person und Traum, was im Weg steht, +## der Befund aus Spuren und Beobachtungen, der Tag, die Routinen, die Notizen. +## Keine Patientenakte -- die Leute sind nicht krank, sie haben Gewohnheiten. +var _blatt: VBoxContainer = null + -## Der Traum scheitert an Ursachen. Die stehen nicht im Panel -- man findet sie, -## indem man dabeisteht. Gegen jede gefundene Ursache baut man ein System in -## den Tag. Je mehr abgedeckt ist, desto schneller kommt der Traum voran. func systeme_panel(human_id: String) -> void: - var scroll_alt := _scroll.scroll_vertical if offen and _kind == "systeme" else 0 - var h := HumanDB.by_id(human_id) - _leeren("%s — Tagesablauf" % h["name"]) - _text("%d %% · +%d %% je Tag · [color=#aaaaaa]%s[/color]" % - [int(Game.mission_progress(human_id)), int(round(Game.traum_tempo(human_id))), - h["traum"]], 16) - _trenner() + akte_panel(human_id) - _text("[b]Woran es scheitert[/b]", 17) - var offene := 0 + +func akte_panel(human_id: String) -> void: + var scroll_alt := _scroll.scroll_vertical if offen and _kind == "akte" else 0 + var h := HumanDB.by_id(human_id) + _leeren("Akte") + _papier() + var erfuellt := Game.completed.has(human_id) + _akte_text("[b]%s[/b] [color=%s]%d · %s[/color]" % [h["name"], TINTE_HELL, + int(h.get("alter", 0)), h.get("beruf", "")], 20) + _akte_text("[color=%s]Traum[/color] „%s“" % [TINTE_HELL, h["traum"]], 16) + if erfuellt: + _stempel("TRAUM ERFÜLLT") + else: + _akte_text("[color=%s]Stand[/color] %d %% · +%d %% je Tag" % [TINTE_HELL, + int(Game.mission_progress(human_id)), int(round(Game.traum_tempo(human_id)))], 16) + _knopf("Hinfahren — vor die Tür", func() -> void: + schliessen() + hinfahren.emit(human_id), true, _blatt) + + _akte_abschnitt("Was im Weg steht") + _akte_text(str(h["problem"]), 15) + + _akte_abschnitt("Befund") for u in Game.ursachen_von(human_id): var name: String = str(u) - if not Game.ursache_bekannt(human_id, name): - _text("[color=#8e93a8]? — noch nicht erkannt[/color]", 15) - offene += 1 + var stand: String + if Game.ursache_geloest(human_id, name) > 0.0: + stand = "[color=%s]Routine steht[/color]" % TINTE_GRUEN + elif Game.ursache_bekannt(human_id, name): + var woher := "Spur gelesen" if Game.spur_bekannt(human_id, name) else "beobachtet" + stand = "[color=%s]%s · bremst auf %d %%[/color]" % [TINTE_ROT, woher, + int(float(Ursachen.LISTE[name]["faktor"]) * 100.0)] + else: + _akte_text("[color=%s]? noch nichts gefunden — Spuren in der Wohnung, Momente im Tag[/color]" % + TINTE_HELL, 15) continue - var g := Game.ursache_geloest(human_id, name) - var stand := "[color=#88ff99]gelöst[/color]" if g > 0.0 else \ - "[color=#ff9977]offen · bremst auf %d %%[/color]" % \ - int(float(Ursachen.LISTE[name]["faktor"]) * 100.0) - _text("[b]%s[/b] %s" % [Ursachen.LISTE[name]["name"], stand], 15) - if offene > 0: _trenner() + _akte_text("[b]%s[/b] %s" % [Ursachen.LISTE[name]["name"], stand], 15) + var probleme: Array = [] + for pr in Game.probleme_von(human_id): + var key := str(pr) + if not Game.problem_bekannt(human_id, key): + probleme.append("[color=%s]?[/color]" % TINTE_HELL) + elif Game.problem_ueberwunden(human_id, key): + probleme.append("[color=%s]%s ✓[/color]" % [TINTE_GRUEN, Probleme.name_von(key)]) + else: + probleme.append("%s %d/%d" % [Probleme.name_von(key), + Game.problem_fortschritt(human_id, key), Game.PROBLEM_UEBERWINDEN]) + if not probleme.is_empty(): + _akte_text("[color=%s]In Momenten[/color] %s" % [TINTE_HELL, " · ".join(probleme)], 15) + _akte_abschnitt("Tag") _tagesablauf(human_id) - _zeigen("systeme", journal_panel.bind("missionen")) + + _akte_abschnitt("Notizen") + var zeilen: Array = Game.notizen.get(human_id, []) + if zeilen.is_empty(): + _akte_text("[color=%s]noch keine[/color]" % TINTE_HELL, 14) + for i in range(maxi(0, zeilen.size() - 8), zeilen.size()): + _akte_text("[color=%s]%s[/color]" % [TINTE, zeilen[i]], 14) + + _zeigen("akte", journal_panel.bind("missionen")) _scroll_wunsch = scroll_alt _scroll_frames = 4 +## Ein Blatt Papier im Drawer; alles Weitere der Akte landet darauf. +func _papier() -> void: + var p := PanelContainer.new() + var s := _sb(C_PAPIER, 3, Color(0.78, 0.72, 0.6), 1) + s.content_margin_left = 16 + s.content_margin_right = 16 + s.content_margin_top = 12 + s.content_margin_bottom = 14 + p.add_theme_stylebox_override("panel", s) + _content.add_child(p) + _blatt = VBoxContainer.new() + _blatt.add_theme_constant_override("separation", 6) + p.add_child(_blatt) + + +func _akte_text(bb: String, groesse: int = 15) -> RichTextLabel: + var r := _text("[color=%s]%s[/color]" % [TINTE, bb], groesse, _blatt) + return r + + +func _akte_abschnitt(titel: String) -> void: + var r := _text("[color=%s]%s[/color]" % [TINTE_HELL, titel.to_upper()], 12, _blatt) + r.add_theme_constant_override("line_separation", 2) + var linie := HSeparator.new() + linie.add_theme_stylebox_override("separator", _sb(Color(0.78, 0.72, 0.6, 0.6), 0)) + linie.custom_minimum_size.y = 2 + _blatt.add_child(linie) + + +func _stempel(text: String) -> void: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", 18) + l.add_theme_color_override("font_color", Color(0.66, 0.2, 0.15)) + l.add_theme_stylebox_override("normal", _sb(Color(0, 0, 0, 0), 2, Color(0.66, 0.2, 0.15), 2)) + l.rotation_degrees = -4.0 + _blatt.add_child(l) + + +## Eine Zeile der Akte, die sich aufschlagen lässt (Stunden des Tages). +func _akte_karte(beschriftung: String, farbe: Color, cb: Callable, aktiv: bool = true) -> Button: + var b := Button.new() + b.text = beschriftung + b.alignment = HORIZONTAL_ALIGNMENT_LEFT + b.disabled = not aktiv + b.custom_minimum_size = Vector2(0, 30) + b.add_theme_font_size_override("font_size", 14) + b.add_theme_color_override("font_color", Color(0.17, 0.15, 0.13)) + b.add_theme_color_override("font_hover_color", Color(0.1, 0.08, 0.06)) + b.add_theme_color_override("font_disabled_color", Color(0.3, 0.27, 0.24)) + b.add_theme_stylebox_override("normal", _sb(C_PAPIER_ZEILE, 3, farbe, 0, 4)) + b.add_theme_stylebox_override("hover", _sb(C_PAPIER_ZEILE.darkened(0.06), 3, farbe, 0, 4)) + b.add_theme_stylebox_override("pressed", _sb(C_PAPIER_ZEILE.darkened(0.1), 3, farbe, 0, 4)) + b.add_theme_stylebox_override("disabled", _sb(C_PAPIER_ZEILE, 3, farbe.darkened(0.2), 0, 4)) + _blatt.add_child(b) + if aktiv: + b.pressed.connect(cb) + return b + + ## Der Ablauf von 0 bis 23 Uhr. Jeder Tag ist gleich -- es gibt keine ## Wochentage. Zu Beginn ist jede Stunde leer; wer dabeisteht, füllt sie. func _tagesablauf(human_id: String) -> void: @@ -720,8 +835,8 @@ func _tagesablauf(human_id: String) -> void: for stunde in range(24): if not Game.gesehen_slot(human_id, Game.slot_bei_stunde(human_id, stunde)): offen += 1 - _text("[b]Ihr Tag[/b]%s" % - ("" if offen == 0 else " [color=#8e93a8]%d Stunden leer[/color]" % offen), 17) + if offen > 0: + _akte_text("[color=%s]%d Stunden leer[/color]" % [TINTE_HELL, offen], 14) # eine Zeile je Abschnitt des Tages -- auch wenn noch keiner bekannt ist, # steht der Tag vollständig da @@ -756,8 +871,8 @@ func _stunden_zeile(human_id: String, nr: int, von: int, bis: int, text: String) var spanne := "%02d – %02d" % [von, bis + 1 if bis < 23 else 24] var offen: bool = Game.gesehen_slot(human_id, nr) and Game.erkannt(human_id, nr) \ and Game.slot_grund(human_id, nr) != "schlaeft" - _karte("%s %s" % [spanne, text], _ablauf_farbe(human_id, nr), - func() -> void: slot_panel(human_id, nr, systeme_panel.bind(human_id)), offen) + _akte_karte("%s %s" % [spanne, text], _ablauf_farbe(human_id, nr), + func() -> void: slot_panel(human_id, nr, akte_panel.bind(human_id)), offen) ## Der offene Moment: hier zählt, welche Technik zu diesem Problem passt. @@ -808,13 +923,13 @@ func slot_panel(human_id: String, nr: int, zurueck: Callable = Callable()) -> vo _text("Sie %s.\n[b]Auslöser:[/b] %s\n[b]Belohnung:[/b] %s" % [b["name"], b["ausloeser"], Ursachen.belohnung_text(str(b["belohnung"]))], 16) else: - _text("Hier ist Platz. Ein neues System passt in diesen Slot.", 16) + _text("Hier ist Platz für eine neue Routine.", 16) _trenner() var sys := Game.system_in(human_id, nr) if not sys.is_empty(): _text("[color=#88ff99]Hier steht: %s[/color]" % TechDB.by_id(str(sys["tech"]))["name"], 16) - _knopf("System wieder herausnehmen", func() -> void: + _knopf("Routine wieder herausnehmen", func() -> void: Game.system_entfernen(human_id, nr) systeme_panel(human_id)) _trenner() @@ -1043,7 +1158,7 @@ func _vorschau_aktualisieren() -> void: var wert := int(bild["verhalten"][v]) if wert != 0: zuege.append("%s %s" % [namen[v], "+" if wert > 0 else "−"]) - _vorschau.text = "[color=#ff9a8a]%s[/color]\n[color=#ffcc66]%s[/color] · %d Blocker im Tag\n[color=#8e93a8]%s[/color]" % \ + _vorschau.text = "[color=#ff9a8a]%s[/color]\n[color=#ffcc66]%s[/color] · %d Gewohnheiten im Tag\n[color=#8e93a8]%s[/color]" % \ [" · ".join(urs), " · ".join(prob), (bild["blocker"] as Array).size(), " ".join(zuege) if not zuege.is_empty() else "unauffällig"] diff --git a/scripts/ursachen.gd b/scripts/ursachen.gd index d8a0898..f926967 100644 --- a/scripts/ursachen.gd +++ b/scripts/ursachen.gd @@ -10,26 +10,38 @@ class_name Ursachen ## bemerken, dann ersetzen. Und Duhiggs goldene Regel: Auslöser und Belohnung ## bleiben, nur die Handlung dazwischen wird getauscht. +## spur: was davon in der Wohnung herumliegt. Wer hineingeht und hinsieht, +## weiß, wonach er im Tag suchen muss. static var LISTE: Dictionary = { "anfang": { "name": "Kein Anfang", "kurz": "kommt nie in Gang", "faktor": 0.30, + "spur": "Handy auf dem Boden", + "spur_text": "Das Handy liegt neben dem Tisch, der Bildschirm noch warm. Angefangen wird hier nicht.", }, "zeit": { "name": "Keine Zeit", "kurz": "der Tag ist voll", "faktor": 0.30, + "spur": "Vollgeschriebener Kalender", + "spur_text": "Jede Stunde im Kalender ist belegt. Für den Traum steht keine drin.", }, "kraft": { "name": "Keine Kraft", "kurz": "abends ist nichts mehr übrig", "faktor": 0.40, + "spur": "Leere Dosen am Bett", + "spur_text": "Drei leere Dosen neben dem Bett. Abends ist nichts mehr übrig.", }, "halt": { "name": "Kein Halt", "kurz": "fängt an und hört wieder auf", "faktor": 0.50, + "spur": "Stapel ungeöffneter Briefe", + "spur_text": "Ein Stapel ungeöffneter Briefe. Angefangen und liegen gelassen.", }, "grund": { "name": "Kein Grund", "kurz": "glaubt nicht mehr daran", "faktor": 0.40, + "spur": "Verstaubter Koffer", + "spur_text": "Ein Koffer voller alter Pläne, längst verstaubt. Daran glaubt hier niemand mehr.", }, } diff --git a/tests/flimmer.gd b/tests/flimmer.gd new file mode 100644 index 0000000..d373fac --- /dev/null +++ b/tests/flimmer.gd @@ -0,0 +1,64 @@ +extends Node +## Flimmer-Probe: Szene anhalten, Kamera um Millimeter rücken, Bilder vergleichen. +## Deckungsgleiche Flächen (Z-Fighting) kippen dabei großflächig, echte Kanten +## wandern nur um Bruchteile eines Pixels. +const GRENZE := 0.4 # Prozent veränderter Pixel, ab der es flimmert +var main: Node3D + +func _ready() -> void: + main = load("res://scenes/main.tscn").instantiate() + add_child(main) + await get_tree().process_frame + main.ui.schliessen() + Game.time_h = 12.0 + var hs := Orte.center(DreamDB.PLACES, "hoersaal") + main.player.global_position = hs + Vector3(1.0, 1.0, 2.0) + var traum := await _messen() + main.player.global_position = Vector3(0, 2.0, 20.0) + var dorf := await _messen() + main._erde() + await get_tree().process_frame + main.player.global_position = Vector3(0, 0.3, 12.0) + var park := await _messen() + var heim := "" + for m in main.welt.humans: + heim = str(m.data["heim"]) + break + main.player.global_position = Orte.center(HumanDB.PLACES, heim) + Vector3(0, 0.3, 0) + var wohnung := await _messen() + var schlimmste: float = [traum, dorf, park, wohnung].max() + print("FLIMMERN %s hoersaal=%.2f%% dorf=%.2f%% park=%.2f%% wohnung=%.2f%%" % [ + "OK" if schlimmste <= GRENZE else "ZU VIEL", traum, dorf, park, wohnung]) + get_tree().quit(0 if schlimmste <= GRENZE else 1) + + +## Prozent der Pixel, die sich zwischen zwei Bildern bei winziger Kamerabewegung stark ändern. +func _messen() -> float: + for i in range(20): + await get_tree().process_frame + get_tree().paused = true + var cam: Camera3D = main.player.cam + var bilder: Array = [] + for i in range(4): + cam.position.x += 0.002 + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + var img := get_viewport().get_texture().get_image() + img.resize(img.get_width() / 2, img.get_height() / 2, Image.INTERPOLATE_NEAREST) + bilder.append(img) + cam.position.x -= 0.008 + get_tree().paused = false + var schlimm := 0.0 + for k in range(1, bilder.size()): + var a: Image = bilder[k - 1] + var b: Image = bilder[k] + var anders := 0 + for y in range(0, a.get_height(), 2): + for x in range(0, a.get_width(), 2): + var ca := a.get_pixel(x, y) + var cb := b.get_pixel(x, y) + if absf(ca.r - cb.r) > 0.19 or absf(ca.g - cb.g) > 0.19 or absf(ca.b - cb.b) > 0.19: + anders += 1 + var anteil := 100.0 * float(anders) / float((a.get_width() / 2) * (a.get_height() / 2)) + schlimm = maxf(schlimm, anteil) + return schlimm diff --git a/tests/flimmer.tscn b/tests/flimmer.tscn new file mode 100644 index 0000000..df4b992 --- /dev/null +++ b/tests/flimmer.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/flimmer.gd" id="1_f"] + +[node name="Flimmer" type="Node"] +script = ExtResource("1_f") diff --git a/tests/rauch.gd b/tests/rauch.gd new file mode 100644 index 0000000..ca884c3 --- /dev/null +++ b/tests/rauch.gd @@ -0,0 +1,37 @@ +extends Node +## Rauchtest: das echte Spiel, echte Aushänge-Datei, ein paar Sekunden spielen. +## Jede ERROR-Zeile im Log lässt tests/run.sh durchfallen. +var main: Node3D + +func _ready() -> void: + main = load("res://scenes/main.tscn").instantiate() + add_child(main) + await _sekunden(1.5) + main.ui.schliessen() + Input.action_press("vor") + await _sekunden(1.0) + Input.action_release("vor") + main._erde() + await _sekunden(1.5) + var leute: Array = main.welt.humans + if leute.is_empty(): + print("RAUCHTEST FEHLT: keine Menschen") + get_tree().quit(1) + return + main.player.anhaengen(leute[0]) + await _sekunden(3.0) + main.player.folgt = null + Input.action_press("links") + await _sekunden(1.0) + Input.action_release("links") + main.ui.journal_panel() + await _sekunden(0.5) + main.ui.schliessen() + main._traumwelt() + await _sekunden(1.0) + print("RAUCHTEST OK: %d Menschen, Tag %d %02d:%02d" % [leute.size(), Game.day, int(Game.time_h), int(fmod(Game.time_h, 1.0) * 60.0)]) + get_tree().quit() + + +func _sekunden(s: float) -> void: + await get_tree().create_timer(s).timeout diff --git a/tests/rauch.tscn b/tests/rauch.tscn new file mode 100644 index 0000000..f99eea6 --- /dev/null +++ b/tests/rauch.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/rauch.gd" id="1_r"] + +[node name="Rauch" type="Node"] +script = ExtResource("1_r") diff --git a/tests/run.sh b/tests/run.sh index 3fecbb3..b26403e 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -12,6 +12,22 @@ if ! timeout 90 godot --headless --path . tests/parse.tscn > "$log/parse.log" 2> fi timeout 600 godot --headless --path . tests/verify.tscn -- "$@" > "$log/suite.log" 2>&1 code=$? +if [ -n "${DISPLAY:-}" ] && [ "${1:-}" != "schnell" ]; then + # Rauchtest: das echte Spiel mit der echten Aushänge-Datei, ein paar Sekunden + timeout 120 godot --path . tests/rauch.tscn > "$log/rauch.log" 2>&1 + rauch=$(grep -E "^(SCRIPT )?ERROR:" "$log/rauch.log" | grep -v leaked | sort | uniq -c | sort -rn) + if [ -n "$rauch" ] || ! grep -q "RAUCHTEST OK" "$log/rauch.log"; then + echo "Rauchtest fehlgeschlagen:"; echo "$rauch" | head -5; grep "RAUCHTEST" "$log/rauch.log"; [ $code -eq 0 ] && code=4 + else + echo "Rauchtest OK" + fi + # Flimmer-Probe: stehende Szene, Kamera minimal bewegt, Bilder verglichen + timeout 120 godot --path . tests/flimmer.tscn > "$log/flimmer.log" 2>&1 + grep "FLIMMERN" "$log/flimmer.log" + if ! grep -q "FLIMMERN OK" "$log/flimmer.log"; then [ $code -eq 0 ] && code=5; fi +else + echo "Rauchtest und Flimmer-Probe übersprungen (kein Display oder Schnelllauf)" +fi echo "OK: $(grep -c ' OK ' "$log/suite.log")" grep -E "FEHLT|Fehlende|SCRIPT ERROR|Parse Error|Langsamste|Schnelllauf" "$log/suite.log" fehler=$(grep -E "^(SCRIPT )?ERROR:" "$log/suite.log" | grep -v "leaked" | sort | uniq -c | sort -rn) diff --git a/tests/verify.gd b/tests/verify.gd index 51ffb9b..b1cc49a 100644 --- a/tests/verify.gd +++ b/tests/verify.gd @@ -8,7 +8,7 @@ var main: Node3D const ZEITRAFFER := 8.0 # die Engine läuft im Test achtmal schneller ## Abschnitte, die ganze Tage oder viele Läufe simulieren -- im Schnelllauf übersprungen. const LANGSAM := ["test_tagesablauf", "test_verfolgung", "test_erster_traum_schaffbar", - "test_huegel", "test_dorf_organisch", "test_welt_waechst"] + "test_huegel", "test_dorf_organisch", "test_welt_waechst", "test_zufallslauf"] var schnell := false var _uhr_abschnitt := 0 var _dauern: Array = [] @@ -46,6 +46,16 @@ func lauf(name: String) -> void: if schnell and LANGSAM.has(name): _uebersprungen += 1 return + # Ausgangslage für jeden Abschnitt: Oberfläche zu, niemand verfolgt, kein + # Moment, keine Taste gedrückt -- sonst erbt ein Abschnitt die Fehler des vorigen + ui().schliessen() + main.player.folgt = null + main.player.look_enabled = true + main.player.velocity = Vector3.ZERO + Game.situation = {} + Game.notiz_sperre = 0.0 + for t in ["vor", "zurueck", "links", "rechts", "steigen", "drehen"]: + Input.action_release(t) await Callable(self, name).call() @@ -71,6 +81,12 @@ func _ready() -> void: await lauf("test_hoersaal") await lauf("test_mission_annehmen") await lauf("test_reise_zur_erde") + await lauf("test_welten_bleiben") + await lauf("test_spuren") + await lauf("test_ankunft") + await lauf("test_akte") + await lauf("test_passanten") + await lauf("test_wesen_helfen") await lauf("test_bezirk") await lauf("test_tagesablauf") await lauf("test_wege_machbar") @@ -78,6 +94,7 @@ func _ready() -> void: await lauf("test_verfolgung") await lauf("test_charakter") await lauf("test_kamera_und_etagen") + await lauf("test_zufallslauf") await lauf("test_gehen_bei_tempo") await lauf("test_diagnose") await lauf("test_notieren_im_spiel") @@ -135,7 +152,7 @@ func schritte(n: int, dt: float = 0.05, zeit: float = -1.0) -> void: func _panel_text() -> String: var out: Array = [] - for c in ui()._content.get_children(): + for c in _alle_kinder(ui()._content): if c is RichTextLabel: out.append((c as RichTextLabel).get_parsed_text()) return "\n".join(out) @@ -193,21 +210,21 @@ func _hingehen(ziel: Node3D) -> void: ## Drückt den ersten aktiven Knopf, dessen Beschriftung so beginnt. func _knopf_texte() -> String: var out: Array = [] - for c in ui()._content.get_children(): + for c in _alle_kinder(ui()._content): if c is Button: out.append((c as Button).text) return "\n".join(out) func _knopf_da(anfang: String) -> bool: - for c in ui()._content.get_children(): + for c in _alle_kinder(ui()._content): if c is Button and (c as Button).text.begins_with(anfang): return true return false func _druecke(anfang: String) -> bool: - for c in ui()._content.get_children(): + for c in _alle_kinder(ui()._content): if c is Button and not (c as Button).disabled and (c as Button).text.begins_with(anfang): (c as Button).pressed.emit() return true @@ -420,6 +437,10 @@ func test_traumdorf() -> void: if w.place == r[0]: zuhause += 1 p(zuhause == 4, "Um 01:00 sind alle vier an ihrem Nachtplatz (%d)" % zuhause) + var ohne_netz := 0 + for w in welt().bewohner: + ohne_netz += int(w.nav_fehl) + p(ohne_netz == 0, "Auch sie laufen auf dem Navigationsnetz der Insel (%d Rückfälle)" % ohne_netz) # Ausgangslage wiederherstellen: das Dorf ist wieder leer Game.completed.clear() @@ -583,7 +604,7 @@ func test_hoersaal() -> void: basis_da = false p(basis_da, "Die %d Basiswege werden gelehrt" % 1) var lernbar := 0 - for c in ui()._content.get_children(): + for c in _alle_kinder(ui()._content): 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) @@ -726,6 +747,237 @@ func test_reise_zur_erde() -> void: "%d Menschen leben in der Stadt" % welt().humans.size()) +## Beide Welten bleiben bestehen: wer zurückkehrt, findet dieselbe Stadt, und +## die Menschen haben derweil ihren Tag weitergelebt. +func test_welten_bleiben() -> void: + abschnitt("Die Welten bleiben bestehen") + var erde_vorher: Node3D = welt() + var mensch: Bewohner = welt().humans[0] + Game.time_h = 9.5 + schritte(20, 0.05, 9.5) + var stand: Vector3 = mensch.global_position + main._traumwelt() + await get_tree().process_frame + p(welt() is DreamWorld and is_instance_valid(erde_vorher) and not erde_vorher.visible, + "Im Traum bleibt die Erde bestehen, nur unsichtbar") + var himmel := 0 + for we in get_tree().root.find_children("*", "WorldEnvironment", true, false): + if (we as WorldEnvironment).environment != null: + himmel += 1 + p(himmel == 1, "Genau ein Himmel ist aktiv (%d)" % himmel) + for i in range(400): + Game.time_h = 12.5 + for m in erde_vorher.humans: + m._process(0.05) + main._erde() + await get_tree().process_frame + p(welt() == erde_vorher, "Zurück auf der Erde ist es dieselbe Stadt") + p(mensch.global_position.distance_to(stand) > 3.0, + "%s hat derweil ihren Tag weitergelebt (%.0f m weiter)" % [mensch.data["name"], + mensch.global_position.distance_to(stand)]) + p(main.player.collision_mask == 2, "Der Geist stößt nur an die Erde, nicht an die unsichtbare Insel") + + +## Spuren in der Wohnung: wer hineingeht und hinsieht, kennt die Ursache und +## weiß, welche Stunden im Tag sich lohnen. +func test_spuren() -> void: + abschnitt("Spuren in der Wohnung") + if not main.auf_erde: + main._erde() + await get_tree().process_frame + ui().schliessen() + var sp: Player = main.player + sp.folgt = null + var mensch: Bewohner = welt().humans[0] + var id := str(mensch.data["id"]) + var heim := str(mensch.data["heim"]) + var ursachen: Array = mensch.data["ursachen"] + Game.active_mission = id + Game.spuren.erase(id) + var spuren: Array = [] + for n in get_tree().get_nodes_in_group("interactable"): + if n.get_meta("kind", "") == "spur" and str(n.get_meta("human")) == id: + spuren.append(n) + p(spuren.size() >= 1 and spuren.size() <= ursachen.size(), + "In %s liegen %d Spuren, je Ursache höchstens eine (%d Ursachen)" % + [HumanDB.PLACES[heim]["name"], spuren.size(), ursachen.size()]) + var c := Orte.center(HumanDB.PLACES, heim) + var g: Array = HumanDB.PLACES[heim]["size"] + var drinnen := 0 + for n in spuren: + var q: Vector3 = (n as Node3D).global_position + if absf(q.x - c.x) < float(g[0]) * 0.5 and absf(q.z - c.z) < float(g[1]) * 0.5 \ + and absf(q.y) < 2.0: + drinnen += 1 + p(drinnen == spuren.size(), "Alle liegen in seiner Wohnung") + if spuren.is_empty(): + Game.active_mission = "" + return + var spur: Node3D = spuren[0] + var u := str(spur.get_meta("arg")) + p(not Game.ursache_bekannt(id, u), "Vorher ist die Ursache '%s' unbekannt" % Ursachen.LISTE[u]["name"]) + var verdaechtig_vorher := 0 + for nr in range(Game.slots(id).size()): + if Game.slot_zustand(id, nr) == Game.Stunde.VERDAECHTIG: + verdaechtig_vorher += 1 + sp.global_position = spur.global_position + Vector3(0.8, 0.3, 0.8) + sp.velocity = Vector3.ZERO + main._process(0.05) + p(main._fokus == spur and ui()._hinweis.text.contains("Spur"), + "Vor der Spur zeigt der Hinweis sie an: %s" % ui()._hinweis.text) + main._interagieren() + p(Game.spur_bekannt(id, u) and Game.ursache_bekannt(id, u), + "E liest die Spur: die Ursache '%s' steht im Journal" % Ursachen.LISTE[u]["name"]) + var verdaechtig := 0 + for nr in range(Game.slots(id).size()): + if Game.slot_zustand(id, nr) == Game.Stunde.VERDAECHTIG: + verdaechtig += 1 + p(verdaechtig_vorher == 0 and verdaechtig >= 1, + "Die Stunden dieser Ursache sind jetzt verdächtig (%d), gesehen sind sie nicht" % verdaechtig) + main._process(0.05) + ui()._ablauf_aktualisieren() + p(ui()._ablauf_titel.text.contains("verdächtig"), "Die Zeitleiste sagt es: %s" % ui()._ablauf_titel.text) + p(not Game.spur_lesen(id, u), "Noch einmal lesen bringt nichts Neues") + Game.spuren.erase(id) + Game.active_mission = "" + + +## Wer mit Mission zur Erde reist, landet vor der Tür seines Ziels; ein Knopf +## bringt ihn jederzeit dorthin. +func test_ankunft() -> void: + abschnitt("Ankunft beim Ziel") + var ziel: Bewohner = welt().humans[0] + var id := str(ziel.data["id"]) + Game.active_mission = id + main._traumwelt() + await get_tree().process_frame + main._erde() + await get_tree().process_frame + var nah: float = _xz(main.player.global_position).distance_to(_xz(ziel.global_position)) + var tuer := Orte.door(HumanDB.PLACES, ziel.place) if HumanDB.PLACES.has(ziel.place) \ + and float(HumanDB.PLACES[ziel.place]["size"][0]) > 0.0 else ziel.global_position + var vor_tuer: float = _xz(main.player.global_position).distance_to(_xz(tuer)) + p(minf(nah, vor_tuer) < 4.0 and main.player.global_position.y > -0.5, + "Mit Mission landet man vor der Tür von %s (%.1f m)" % [ziel.data["name"], minf(nah, vor_tuer)]) + var andere: Bewohner = welt().humans[3] + main.hinfahren(str(andere.data["id"])) + var d2: float = _xz(main.player.global_position).distance_to(_xz(andere.global_position)) + p(d2 < 12.0 and main.auf_erde, "„Hinfahren“ setzt einen zu %s (%.1f m)" % [andere.data["name"], d2]) + main._traumwelt() + await get_tree().process_frame + main.hinfahren(id) + p(main.auf_erde, "Aus dem Traum heraus geht es ebenfalls, über das Tor") + Game.active_mission = "" + + +## Die Akte: ein Blatt je Mensch mit Person, Traum, Befund, Tag und Notizen. +func test_akte() -> void: + abschnitt("Die Akte") + var ziel: Bewohner = welt().humans[0] + var id := str(ziel.data["id"]) + var h := HumanDB.by_id(id) + Game.active_mission = id + Game.progress[id] = Game.progress.get(id, 0.0) + Game.spuren.erase(id) + Game.notizen.erase(id) + ui().journal_panel("missionen") + p(_knopf_da(str(h["name"])), "Im Journal liegt eine Akte je Mensch mit Mission") + p(_druecke(str(h["name"])) and ui()._kind == "akte", "Sie lässt sich aufschlagen") + var text := _panel_text() + p(text.contains(str(h["name"])) and text.contains(str(h["traum"])) and text.contains(str(h["problem"])), + "Oben stehen Person, Traum und was im Weg steht") + p(text.contains("BEFUND") and text.contains("TAG") and text.contains("NOTIZEN"), + "Darunter Befund, Tag und Notizen") + p(text.contains("noch keine") and text.contains("noch nichts gefunden"), + "Ohne Spuren und Beobachtungen ist der Befund leer") + p(_knopf_da("Hinfahren"), "Ein Knopf bringt einen vor die Tür") + var u := str((h["ursachen"] as Array)[0]) + Game.spur_lesen(id, u) + ui().akte_panel(id) + text = _panel_text() + p(text.contains(str(Ursachen.LISTE[u]["name"])) and text.contains("Spur gelesen"), + "Eine gelesene Spur steht als Befund: %s" % Ursachen.LISTE[u]["name"]) + p(text.contains("Uhr —") and text.contains("Spur:"), "Und als Notiz mit Uhrzeit") + ui().schliessen() + main._traumwelt() + await get_tree().process_frame + ui().akte_panel(id) + p(_druecke("Hinfahren") and main.auf_erde and not ui().offen, + "„Hinfahren“ führt aus dem Traum vor die Tür von %s" % h["name"]) + Game.active_mission = "" + Game.spuren.erase(id) + Game.notizen.erase(id) + + +## Passanten beleben die Straßen: ohne Namen, ohne Schild, ohne Mission. +func test_passanten() -> void: + abschnitt("Passanten beleben die Straßen") + if not main.auf_erde: + main._erde() + await get_tree().process_frame + p(welt().passanten.size() == HumanDB.PASSANTEN and welt().humans.size() == HumanDB.LIST.size(), + "%d Passanten neben den %d Menschen mit Geschichte" % [welt().passanten.size(), welt().humans.size()]) + var unterwegs := 0 + var proben := 0 + for i in range(1200): + schritte(1, 0.05, 9.0 + float(i) * 0.004) + if i % 100 == 99: + proben += 1 + for pz in welt().passanten: + if not pz._path.is_empty(): + unterwegs += 1 + p(unterwegs >= proben, "Zu jeder Zeit ist im Schnitt mindestens einer unterwegs (%d in %d Proben)" % + [unterwegs, proben]) + var schilder := 0 + for pz in welt().passanten: + if (pz._label as Label3D).visible: + schilder += 1 + p(schilder == 0, "Passanten tragen kein Schild") + var pz0: Bewohner = welt().passanten[0] + main.player.global_position = pz0.global_position + Vector3(0, 0.3, 0) + main._process(0.05) + p(main._fokus_mensch != pz0, "Und kommen nicht in den Fokus") + + +## Die Traumwesen helfen: ein Bewohner flüstert, was in der Wohnung des +## Menschen liegt, um den es gerade geht. +func test_wesen_helfen() -> void: + abschnitt("Die Traumwesen helfen") + var merker: Array = Game.completed.duplicate() + Game.completed.clear() + Game.completed.append("mensch_1") + Game.welt_erzeugen() + main._traumwelt() + await get_tree().process_frame + p(welt().bewohner.size() == 1, "Mit einem erfüllten Traum wohnt ein Wesen hier") + var wesen: Bewohner = welt().bewohner[0] + p(str(wesen.data["rolle"]).contains("Traum"), "Es trägt, aus wessen Traum es kam: %s" % wesen.data["rolle"]) + var marke: Node3D = null + for n in get_tree().get_nodes_in_group("interactable"): + if n.get_meta("kind", "") == "wesen": + marke = n + p(marke != null, "Man kann es ansprechen") + Game.active_mission = "mensch_0" + Game.spuren.erase("mensch_0") + main.player.set_mode(Player.Mode.TRAUM) + main.player.global_position = wesen.global_position + Vector3(0.5, 0.3, 0.5) + main.player.velocity = Vector3.ZERO + main._process(0.05) + p(main._fokus == marke and ui()._hinweis.text.contains("zuhören"), "Davor steht: zuhören") + main._interagieren() + var bekannt := 0 + for u in Game.ursachen_von("mensch_0"): + if Game.spur_bekannt("mensch_0", str(u)): + bekannt += 1 + p(bekannt == 1, "Es flüstert eine Spur des Menschen, um den es geht (%d bekannt)" % bekannt) + Game.spuren.erase("mensch_0") + Game.active_mission = "" + Game.completed.assign(merker) + Game.welt_erzeugen() + main._erde() # die folgenden Abschnitte spielen auf der Erde + await get_tree().process_frame + + func test_tagesablauf() -> void: abschnitt("Tagesablauf der Menschen") var mensch: Bewohner = null @@ -734,7 +986,10 @@ func test_tagesablauf() -> void: mensch = m p(mensch != null, "Der erste Mensch lebt auf der Erde: %s" % mensch.data["name"]) - schritte(30, 0.05, 2.0) + for i in range(3000): # die Welt bleibt bestehen: er kann weit weg sein + schritte(1, 0.05, 2.0) + if mensch.place == str(mensch.data["heim"]) and mensch._path.is_empty(): + break p(mensch.activity == "schlaeft", "Um 02:00 schläft er (%s)" % mensch.activity) p(_xz(mensch.global_position).distance_to( _xz(Orte.center(HumanDB.PLACES, str(mensch.data["heim"])))) < 5.0, @@ -791,12 +1046,11 @@ func test_tagesablauf() -> void: func test_wege_machbar() -> void: abschnitt("Wege passen in die Zeitfenster") - _wege_pruefen("Erde", HumanDB.PLACES, HumanDB.LIST, HumanDB.NETZ, HumanDB.ANKER) - _wege_pruefen("Traumwelt", DreamDB.PLACES, DreamDB.LIST, DreamDB.NETZ, DreamDB.ANKER) + _wege_pruefen("Erde", HumanDB.PLACES, HumanDB.LIST, main.erde.nav_map) + _wege_pruefen("Traumwelt", DreamDB.PLACES, DreamDB.LIST, main.traum.nav_map) -func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, - netz: Netz = null, anker: Dictionary = {}) -> void: +func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, karte: RID) -> void: var sek_pro_stunde := Game.DAY_SECONDS / 24.0 var alles_ok := true var engste := 999.0 @@ -808,8 +1062,7 @@ func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, var nach: String = String(r[i][1]) if von == nach: continue - var dauer := _weg_laenge(places, von, nach, m.get("etage", {}), netz, anker) \ - / Bewohner.GEHTEMPO + var dauer := _weg_laenge(places, von, nach, karte) / 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 @@ -824,21 +1077,28 @@ func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, p(alles_ok, "%s — jeder Weg passt, engster Fall: %s" % [welt_name, engste_text]) -func _weg_laenge(places: Dictionary, von: String, nach: String, etagen: Dictionary, - netz: Netz = null, anker: Dictionary = {}) -> float: - var pts: Array = Orte.route_voll(places, von, nach, Vector3.ZERO, etagen, netz, anker) +## Weglänge über das Navigationsnetz der Welt -- so, wie die Menschen wirklich laufen. +func _weg_laenge(places: Dictionary, von: String, nach: String, karte: RID) -> float: + var a := Orte.center(places, von) + var b := Orte.center(places, nach) + # freie Plätze: neben der Mitte stehen, wie die Bewohner auch (in der Mitte steht der Traumbaum) + if float(places[von]["size"][0]) <= 0.0: + a += Vector3(2.2, 0, 0) + if float(places[nach]["size"][0]) <= 0.0: + b += Vector3(2.2, 0, 0) + var pts := NavigationServer3D.map_get_path(karte, a, b, true) + if pts.size() < 2: + return 9999.0 # kein Weg: fällt als "passt nicht" auf var l := 0.0 - var pos := Orte.center(places, von) + Vector3(0, Orte.hoehe(int(etagen.get(von, 0))), 0) - for pt in pts: - l += pos.distance_to(pt) - pos = pt + for i in range(1, pts.size()): + l += pts[i - 1].distance_to(pts[i]) return l func test_geist() -> void: abschnitt("Geist-Bewegung") p(main.player.mode == Player.Mode.GEIST, "Spieler ist auf der Erde ein Geist") - p(main.player.collision_mask == 1, "Der Geist stößt an Wände wie ein Mensch") + p(main.player.collision_mask != 0, "Der Geist stößt an Wände wie ein Mensch") main.player.global_position = Vector3(0, 8.0, 0) main.player.velocity = Vector3(6, 0, 0) for i in range(60): @@ -848,9 +1108,14 @@ func test_geist() -> void: "Die Schwerkraft holt ihn auf den Boden (y = %.2f)" % main.player.global_position.y) var lena: Bewohner = welt().humans[0] + for i in range(1500): # erst, wenn sie irgendwo angekommen ist + if lena._path.is_empty(): + break + schritte(1) var davor: Vector3 = lena.global_position + var uhr_geist := Game.time_h main.player.global_position = lena.global_position + Vector3(0, 1, 0.5) - schritte(10) + schritte(10, 0.05, uhr_geist) p(lena.global_position.distance_to(davor) < 2.0, "Menschen nehmen den Geist nicht wahr") @@ -954,7 +1219,7 @@ func _xz(v: Vector3) -> Vector2: func test_kamera_und_etagen() -> void: - abschnitt("Feste Kamera, kleine Menschen, Stadt mit Stockwerken") + abschnitt("Feste Kamera, kleine Menschen, geschlossene Häuser") ui().schliessen() var sp: Player = main.player sp.set_mode(Player.Mode.GEIST) @@ -994,40 +1259,23 @@ func test_kamera_und_etagen() -> void: 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) + # Geschlossene Häuser: einstöckig, ein Raum mit Tür var eng := 0 for key in HumanDB.PLACES: var sz: Array = HumanDB.PLACES[key]["size"] - if float(sz[0]) > 0.0 and minf(float(sz[0]), float(sz[1])) < Orte.TREPPE_TIEFE + 2.0: + if float(sz[0]) > 0.0 and minf(float(sz[0]), float(sz[1])) < 6.0: eng += 1 - p(eng == 0, "Jedes Gebäude hat Platz für Treppenhaus und Decke (%d zu schmal)" % eng) + p(eng == 0, "Jedes Gebäude ist breit genug für Tür und Möbel (%d zu schmal)" % eng) for b in welt()._bauten: for mi in Prims._meshes_von(b): var bm := (mi as MeshInstance3D).mesh as BoxMesh if bm != null and (bm.size.x <= 0.0 or bm.size.y <= 0.0 or bm.size.z <= 0.0): eng += 1 p(eng == 0, "Keine Platte mit negativer Größe") - 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 oben: Bewohner = welt().humans[0] 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): @@ -1035,80 +1283,34 @@ func test_kamera_und_etagen() -> void: 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) + p(zuhause and absf(oben.global_position.y) < 0.3, + "Nachts liegt %s zu Hause, ebenerdig (y = %.1f)" % [oben.data["name"], oben.global_position.y]) - # Etagenschnitt + # Schnitt: drinnen verschwinden Dach und die Wände zur Kamera 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) + p(not bau.is_empty() and (bau["teile"] as Array).size() == 1 and (bau["waende"] as Array).size() >= 4, + "Sein Haus steht mit Dach und Wänden da") + sp.global_position = c + Vector3(0, 0.3, 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]: + for w in bau["waende"]: 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(), + p(not (bau["teile"][0] as Node3D).visible, "Steht der Geist im Haus, verschwindet das Dach") + p(wand_weg >= 2 and wand_weg < (bau["waende"] 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()]) - var stufen: Array = Orte.treppen_lauf(HumanDB.PLACES, heim, 0) - sp.global_position = (stufen[0] as Vector3) + Vector3(0, 0.3, 0) - sp.velocity = Vector3.ZERO - var lauf: Vector3 = stufen[1] - stufen[0] - lauf.y = 0.0 - sp.yaw = atan2(-lauf.x, -lauf.z) - Input.action_press("vor") - var hoechste := 0.0 - for i in range(70): - await get_tree().physics_frame - hoechste = maxf(hoechste, sp.global_position.y) - Input.action_release("vor") - p(hoechste > Orte.ETAGE - 0.3, - "Über die Treppe kommt der Geist in den ersten Stock (y = %.1f)" % hoechste) - sp.global_position = t1 + Vector3(0, 0.3, 0) - sp.velocity = Vector3.ZERO + [wand_weg, (bau["waende"] as Array).size()]) + sp.global_position = c + Vector3(float(g[0]) + 4.0, 0.3, 0) await get_tree().physics_frame 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]: + var alles := (bau["teile"][0] as Node3D).visible + for w in bau["waende"]: if not (w as Node3D).visible: alles = false p(alles, "Draußen steht das Haus wieder ganz") @@ -1124,6 +1326,8 @@ func test_kamera_und_etagen() -> void: p(sp.folgt == null, "Noch ein Klick löst ihn wieder") # Anlauf: von weit weg zu Fuß über die Straße bis in seine Wohnung + for m in welt().humans: + m.nav_fehl = 0 # frühere Tests haben Leute versetzt var daheim := false for i in range(3000): schritte(1, 0.05, 2.0) @@ -1134,9 +1338,11 @@ func test_kamera_und_etagen() -> void: sp.velocity = Vector3.ZERO sp.spruenge = 0 sp.anhaengen(oben) - p(daheim and sp._anlauf.size() >= 4 and (sp._anlauf.back() as Vector3).y > Orte.ETAGE - 0.1, - "Von weit weg plant der Geist den Weg über die Straße in seine Etage (%d Punkte)" % - sp._anlauf.size()) + await get_tree().physics_frame + await get_tree().physics_frame + p(daheim and sp._leine_pfad.size() >= 4, + "Von weit weg plant das Navigationsnetz den Weg bis in seine Wohnung (%d Punkte)" % + sp._leine_pfad.size()) var angekommen := false for i in range(2400): await get_tree().physics_frame @@ -1156,9 +1362,82 @@ func test_kamera_und_etagen() -> void: p((oben._label as Label3D).visible and fremde_schilder == 0, "Nur der Verfolgte trägt ein Schild, die anderen nicht (%d fremde)" % fremde_schilder) sp.folgt = null + var ohne_netz := 0 + for m in welt().humans: + ohne_netz += int(m.nav_fehl) + p(ohne_netz == 0, "Alle Wege der Menschen kamen vom Navigationsnetz (%d Rückfälle)" % ohne_netz) Game.time_h = uhr +## Wie ein Spieler, der einfach losläuft: 90 Sekunden kreuz und quer durch die +## Stadt, bei Wand Richtung wechseln. Dabei darf er nie hängen bleiben, nie +## unter den Boden und nie über die Dächer geraten. +func test_zufallslauf() -> void: + abschnitt("Freies Herumlaufen: nie eingeklemmt, nie unter dem Boden") + if not main.auf_erde: + main._erde() + await get_tree().process_frame + ui().schliessen() + var sp: Player = main.player + sp.set_mode(Player.Mode.GEIST) + sp.look_enabled = true + sp.folgt = null + sp.spruenge = 0 + sp.global_position = Vector3(0, 0.3, 12.0) + sp.velocity = Vector3.ZERO + var rng := RandomNumberGenerator.new() + rng.seed = 4711 + var richtungen := [["vor"], ["vor", "rechts"], ["rechts"], ["zurueck", "rechts"], + ["zurueck"], ["zurueck", "links"], ["links"], ["vor", "links"]] + var kurs := 0 + var letzte := sp.global_position + var tiefster := 0.0 + var hoechster := 0.0 + var still := 0 + var laengste_still := 0 + var gesamt := 0.0 + for runde in range(150): + for t in richtungen[kurs]: + Input.action_press(t) + var gemeldet := false + for i in range(36): + await get_tree().physics_frame + if sp.global_position.y < -0.2 and not gemeldet: + gemeldet = true + var k := sp.get_last_slide_collision() + var txt := "-" + if k != null and k.get_collider() != null: + txt = str((k.get_collider() as Node).get_parent().global_position) + print(" [unter dem Boden] Runde %d, %s, Kollision bei %s" % [runde, str(sp.global_position), txt]) + for t in richtungen[kurs]: + Input.action_release(t) + var bewegt := _xz(sp.global_position).distance_to(_xz(letzte)) + if bewegt < 0.3 and still >= 2: + print(" [steht] Runde %d bei %s, Kurs %d" % [runde, str(sp.global_position), kurs]) + gesamt += bewegt + if bewegt < 0.3: + still += 1 + laengste_still = maxi(laengste_still, still) + kurs = (kurs + 3 + rng.randi_range(0, 2)) % 8 # umdrehen, leicht versetzt + else: + still = 0 + if rng.randf() < 0.2: + kurs = rng.randi_range(0, 7) + letzte = sp.global_position + tiefster = minf(tiefster, sp.global_position.y) + hoechster = maxf(hoechster, sp.global_position.y) + p(gesamt > 200.0, "Er kommt herum (%.0f m in 90 s)" % gesamt) + p(laengste_still <= 3, "Nie länger als drei Züge ohne Bewegung (längste Pause %d)" % laengste_still) + p(tiefster > -0.5, "Nie unter dem Boden (tiefster y %.2f)" % tiefster) + p(hoechster < 6.0, "Nie über den Dächern (höchster y %.1f)" % hoechster) + p(sp.spruenge == 0, "Nie eingeklemmt -- die Stadt musste ihn nicht befreien (%d)" % sp.spruenge) + var schwebend := 0 + for m in welt().humans: + if m._path.is_empty() and m.global_position.y > 0.4: + schwebend += 1 + p(schwebend == 0, "Kein stehender Mensch schwebt über dem Boden (%d)" % schwebend) + + ## 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: @@ -1671,7 +1950,9 @@ func test_eigene_menschen() -> void: p(stumm.is_empty(), "Bei allen %d Menschen zeigt sich jede Ursache im Tag%s" % [HumanDB.LIST.size(), "" if stumm.is_empty() else " — nicht: " + ", ".join(stumm)]) p(Game.machbar(id) or true, "Bereit fürs Brett") - _wege_pruefen("Stadt mit eigenem Menschen", HumanDB.PLACES, HumanDB.LIST) + main._erde() + await get_tree().process_frame + _wege_pruefen("Stadt mit eigenem Menschen", HumanDB.PLACES, HumanDB.LIST, main.erde.nav_map) # --- Die Wörter verändern, wie er sich verhält p(Game.verhalten(id, "abwehr") >= 1, "Stur: Abwehr %d" % Game.verhalten(id, "abwehr")) @@ -2238,10 +2519,26 @@ 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, - DreamDB.NETZ, DreamDB.ANKER) + main._traumwelt() + await get_tree().process_frame + _wege_pruefen("Traumdorf mit %d Häusern" % stufen, DreamDB.PLACES, DreamDB.LIST, main.traum.nav_map) + # Jede Haustür muss zu Fuß erreichbar sein -- keine Klippe vor der Tür + var platz := Orte.center(DreamDB.PLACES, "lichtung") + Vector3(2.2, 0, 0) + var unerreichbar: Array = [] + for key in DreamDB.PLACES: + if not str(key).begins_with("haus_"): + continue + var t := Orte.door(DreamDB.PLACES, key) + t.y = DreamDB.hoehe_bei(t.x, t.z) + var pf := NavigationServer3D.map_get_path(main.traum.nav_map, platz, t, true) + if pf.is_empty() or pf[pf.size() - 1].distance_to(t) > 1.5: + unerreichbar.append("%s (Terrasse %.1f)" % [key, DreamDB.terrasse(key)]) + p(unerreichbar.is_empty(), "Bei %d Häusern ist jede Haustür vom Dorfplatz aus zu Fuß erreichbar%s" % + [stufen, "" if unerreichbar.is_empty() else " — nicht: " + ", ".join(unerreichbar)]) + main._erde() + await get_tree().process_frame _wege_pruefen("Stadt mit %d Menschen" % HumanDB.LIST.size(), HumanDB.PLACES, - HumanDB.LIST, HumanDB.NETZ, HumanDB.ANKER) + HumanDB.LIST, main.erde.nav_map) Game.completed.assign(merker) Game.welt_erzeugen() @@ -2364,8 +2661,8 @@ func test_steuerung() -> void: var klotz: Dictionary = {} var richtung := Vector3.ZERO for b in welt()._bauten: - if not bool(b.get("voll", false)): - continue + if not (b["waende"] as Array).is_empty(): + continue # ein Haus mit Wänden ist kein Klotz for pz in HumanDB.PARZELLEN: if (pz["pos"] as Vector3).distance_to(b["c"]) < 0.1: klotz = b @@ -2697,7 +2994,8 @@ func test_dorf_organisch() -> void: sektoren_12 = sekt.size() if stufe == stufens_probe(): var flaeche := PI * pow(Game.insel_radius(), 2.0) - if flaeche / float(stufe) > 2200.0: + # Terrassen verschiedener Höhe brauchen Böschungen dazwischen: das kostet Fläche + if flaeche / float(stufe) > 3300.0: dichte = "%.0f m² je Haus" % (flaeche / float(stufe)) p(ueberlappung == "", "Über %d Ausbaustufen steht kein Gebäude im anderen%s" %