From 8b601645926f37f0737d3fadad98682f478420c2 Mon Sep 17 00:00:00 2001 From: Team3 Date: Fri, 4 Sep 2026 00:06:51 +0200 Subject: [PATCH] update --- README.md | 24 ++- anforderungen.md | 9 +- scripts/bewohner.gd | 4 +- scripts/game.gd | 66 +++++++-- scripts/main.gd | 99 ++++++++++--- scripts/player.gd | 21 +++ scripts/ui.gd | 189 ++++++++++++++++++++---- tests/verify.gd | 351 +++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 690 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index bcb5087..f513666 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,13 @@ sie allein nicht bewegt bekommen. Du wirst zu ihnen geschickt. godot --path . Steuerung: **WASD** bewegen · **Maus** umsehen · **E** interagieren · -**J** Journal · **1/2/3** Tempo · **Esc** Maus freigeben. +**F** folgen · **R** notieren · **J** Journal · **1/2/3** Tempo · +**Esc** Maus freigeben. + +Der Tagesablauf steht im **HUD**: 24 Felder, eines je Stunde, die laufende +hervorgehoben. Grau heißt unbekannt, gelb „hier stimmt etwas nicht, aber du +hast es noch nicht benannt", rot ein benannter Blocker, grün ein gebautes +System. Darunter steht, was gerade läuft. In der Traumwelt springt **Leertaste**; auf der Erde steigt sie und **Umschalt** lässt sinken. @@ -24,7 +30,9 @@ 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. * **Missionen** — die Menschen, für die du dich entschieden hast, jeweils mit - ihrem **Tagesablauf**. + 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 + Abschnitt nur ein Strich — der Tag ist leer, bis du dabei warst. **Diagnose und Systeme** — der Kern des Spiels @@ -32,6 +40,10 @@ 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 stehen nirgends — du findest sie nur, indem du dabeistehst. +Dabeisein füllt den Abschnitt im Ablauf: du siehst, dass sie um zehn aufräumt. +Ob das ihren Traum aufhält, entscheidest du — mit **R**. Bis dahin steht dort +ein Fragezeichen. + Sichtbar wird eine Ursache an den Tätigkeiten, die sie hervorbringt. Wer nie in Gang kommt, räumt auf statt anzufangen, recherchiert oder schreibt Später-Listen. Wer keine Kraft hat, schaut abends eine Serie und bleibt danach wach. Jede @@ -116,6 +128,14 @@ Abwehr. Am Ausgang ändert sich dadurch nichts, nur an der Wartezeit. ihren Tag: schlafen, arbeiten, grübeln, essen, spazieren. Wer wo wohnt, arbeitet in der Nähe; das Café liegt in der Mitte. * Du bist ein unsichtbarer Geist, schwebst und gehst durch Wände. +* **F** hängt dich an einen Menschen: du treibst hinter ihm her, durch Türen + und über den ganzen Tag, ohne ihm nachlaufen zu müssen. **F** noch einmal — + oder ein Schritt mit **WASD** — lässt ihn wieder los. +* **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 + wird bebaubar. Greifst du daneben, schaust du sechs Sekunden umsonst hin. + 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. * Die Säule im Park bringt dich zurück in die Traumwelt. diff --git a/anforderungen.md b/anforderungen.md index 412fd05..9e6b8c9 100644 --- a/anforderungen.md +++ b/anforderungen.md @@ -3,14 +3,15 @@ Kern um Menschen zu helfen, ihre Träume zu erfüllen. Loops -- Techniken lernen -> Systeme anpassen -> Probleme lösen - Träume erfüllen -> Welt vergrößeren -> Techniken entdecken +- Techniken entdecken -> Techniken lernen -> Missionen freischalten +- Menschen beobachten -> Probleme finden -> Techniken anwenden Funktionen -- Bewegung: Bewegen, Springen, Geist +- Bewegung: Bewegen, Springen, Geist, Anhängen - Journal: Gelernte Techniken, Akzetierte Missionen - Techniken: Lernen, Verbessern -- Missionen: Pipeline +- Missionen: Pipeline, Beobachten - Techniken: Cooldown, Fehlschlag -- Menschen: Tagesablauf, Tempo +- Menschen: Tagesablauf, Tempo, Verfolgung - Prozedurale Generierung: Dorf, Stadt diff --git a/scripts/bewohner.gd b/scripts/bewohner.gd index 948e502..6d708f1 100644 --- a/scripts/bewohner.gd +++ b/scripts/bewohner.gd @@ -111,7 +111,9 @@ func _process(roh: float) -> void: if _path.is_empty(): activity = soll[1] else: - var schritt := d.normalized() * GEHTEMPO * delta + # nie über den Wegpunkt hinaus -- sonst pendelt er bei hohem Tempo + # um ihn herum, statt anzukommen + var schritt := d.normalized() * minf(GEHTEMPO * delta, d.length()) global_position += schritt _visual.rotation.y = lerp_angle(_visual.rotation.y, atan2(-d.x, -d.z), 1.0 - pow(0.002, delta)) _bob += delta * 9.0 diff --git a/scripts/game.gd b/scripts/game.gd index b6f2ef6..47aad15 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -24,7 +24,8 @@ var progress: Dictionary = {} # human_id -> 0..100 var cooldown: Dictionary = {} # tech_id -> Restsekunden var systeme: Dictionary = {} # human_id -> {slot -> {tech, guete}} var erkannt_slots: Dictionary = {} # human_id -> {slot -> true} -var beobachtung: Dictionary = {} # "human:slot" -> Sekunden +var gesehen: Dictionary = {} # human_id -> {slot -> true}, wo man dabei war +var notiz_sperre: float = 0.0 # nach einem Fehlgriff kurz gesperrt var completed: Array = [] var time_h: float = 7.0 var day: int = 1 @@ -55,6 +56,8 @@ func _setup_input() -> void: "steigen": [KEY_SPACE], "sinken": [KEY_SHIFT], "interagieren": [KEY_E], + "folgen": [KEY_F], + "notieren": [KEY_R], "journal": [KEY_J, KEY_TAB], "tempo_1": [KEY_1, KEY_KP_1], "tempo_2": [KEY_2, KEY_KP_2], @@ -305,7 +308,7 @@ func _insel_waechst() -> void: const BASIS_TEMPO := 45.0 # Prozent je Tag, wenn nichts mehr bremst const GUETE_ERSATZ := 1.35 # Blocker mit gleicher Belohnung ersetzt const GUETE_NEU := 1.0 # System in einen freien Slot gesetzt -const ERKENNEN_SEK := 3.0 # so lange muss man dabeistehen +const NOTIZ_SPERRE := 6.0 # nach einem Fehlgriff schaut man erst wieder hin func slots(human_id: String) -> Array: @@ -313,6 +316,34 @@ func slots(human_id: String) -> Array: return h["routine"] if h.has("routine") else [] +## Welcher Slot deckt diese Stunde ab? Jeder Tag ist gleich, es gibt keine +## Wochentage -- der Ablauf 0-23 Uhr wiederholt sich. +func slot_bei_stunde(human_id: String, stunde: int) -> int: + var r := slots(human_id) + if r.is_empty(): + return -1 + var nr := r.size() - 1 + for i in range(r.size()): + if float(stunde) >= float(r[i][0]): + nr = i + return nr + + +## Dabeigewesen: die Stunde ist im Ablauf nicht mehr leer. Was dort schiefläuft, +## steht damit noch nicht fest -- das muss der Spieler benennen. +func gesehen_slot(human_id: String, nr: int) -> bool: + return gesehen.get(human_id, {}).has(nr) + + +func sehen(human_id: String, nr: int) -> void: + if gesehen_slot(human_id, nr): + return + if not gesehen.has(human_id): + gesehen[human_id] = {} + gesehen[human_id][nr] = true + state_changed.emit() + + func slot_grund(human_id: String, nr: int) -> String: var r := slots(human_id) return str(r[nr][2]) if nr >= 0 and nr < r.size() else "" @@ -339,23 +370,30 @@ func erkannt(human_id: String, nr: int) -> bool: return erkannt_slots.get(human_id, {}).has(nr) -## Dabeistehen deckt auf, was dort wirklich läuft -- und damit die Ursache. -func beobachten(human_id: String, nr: int, delta: float) -> bool: +## Zusehen allein deckt nichts auf. Der Spieler muss den Moment benennen: +## "hier läuft etwas schief". Trifft er, steht die Ursache im Journal. +## Greift er daneben, schaut er eine Weile umsonst hin. +## Grundlage bleibt awareness training -- nur ist das Bemerken jetzt ein Akt. +func notieren(human_id: String, nr: int) -> String: + if notiz_sperre > 0.0: + return "gesperrt" + var grund := slot_grund(human_id, nr) + if not Ursachen.ist_blocker(grund): + notiz_sperre = NOTIZ_SPERRE + notified.emit("Hier läuft nichts schief — %s ist einfach ihr Tag." % + Orte.text(grund), Color(1, 0.75, 0.55)) + return "daneben" if erkannt(human_id, nr): - return false - var schluessel := "%s:%d" % [human_id, nr] - var stand: float = float(beobachtung.get(schluessel, 0.0)) + delta - beobachtung[schluessel] = stand - if stand < ERKENNEN_SEK: - return false + notified.emit("Das hast du schon notiert: %s." % Orte.text(grund), + Color(0.8, 0.85, 1)) + return "bekannt" if not erkannt_slots.has(human_id): erkannt_slots[human_id] = {} erkannt_slots[human_id][nr] = true - var b := slot_grund(human_id, nr) - notified.emit("Erkannt: %s — %s" % [Ursachen.BLOCKER[b]["name"], - Ursachen.LISTE[Ursachen.ursache_von(b)]["name"]], Color(0.8, 0.95, 1)) + notified.emit("Erkannt: %s — %s" % [Ursachen.BLOCKER[grund]["name"], + Ursachen.LISTE[Ursachen.ursache_von(grund)]["name"]], Color(0.8, 0.95, 1)) state_changed.emit() - return true + return "treffer" ## Eine Ursache gilt als erkannt, sobald eine ihrer Tätigkeiten aufgedeckt ist. diff --git a/scripts/main.gd b/scripts/main.gd index e2bd149..dee7bc5 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -72,21 +72,12 @@ func _process(delta: float) -> void: if ui.offen: ui.setze_hinweis("") return + Game.notiz_sperre = maxf(0.0, Game.notiz_sperre - delta) _fokus_suchen() - _beobachten(dt) + _dabeisein() _hinweis_setzen() -## Dabeistehen deckt auf, was der Mensch gerade wirklich tut. -func _beobachten(dt: float) -> void: - if _fokus_mensch == null or not auf_erde: - return - var id: String = str(_fokus_mensch.data["id"]) - if id != Game.active_mission: - return - Game.beobachten(id, int(_fokus_mensch.slot), dt) - - func _fokus_suchen() -> void: _fokus = null _fokus_mensch = null @@ -111,14 +102,12 @@ func _fokus_suchen() -> void: func _hinweis_setzen() -> void: - if _fokus_mensch != null: - var mid: String = str(_fokus_mensch.data["name"]) - var mnr: int = int(_fokus_mensch.slot) - var mkey: String = str(_fokus_mensch.data["id"]) - if mkey == Game.active_mission and not Game.erkannt(mkey, mnr): - ui.setze_hinweis("%s — schau zu, was sie hier tut …" % mid) - else: - ui.setze_hinweis("[E] %s — %s" % [mid, Orte.text(_fokus_mensch.activity)]) + if player.folgt != null and is_instance_valid(player.folgt): + ui.setze_hinweis("[F] loslassen · %s — %s" % + [player.folgt.data["name"], _notiz_hinweis(player.folgt)]) + elif _fokus_mensch != null: + ui.setze_hinweis("[F] folgen · %s — %s" % + [_fokus_mensch.data["name"], _notiz_hinweis(_fokus_mensch)]) elif _fokus != null: var kind: String = _fokus.get_meta("kind") var titel: String = _fokus.get_meta("titel") @@ -137,6 +126,18 @@ func _hinweis_setzen() -> void: ui.setze_hinweis("") +## Was am Menschen gerade dransteht: notieren, ansprechen oder nur zusehen. +func _notiz_hinweis(mensch: Bewohner) -> String: + var tat := Orte.text(mensch.activity) + if str(mensch.data["id"]) != Game.active_mission: + return tat + if Game.notiz_sperre > 0.0: + return "%s (%.0f s)" % [tat, Game.notiz_sperre] + if Game.erkannt(str(mensch.data["id"]), int(mensch.slot)): + return "%s · [E] ansprechen" % tat + return "%s · [R] notieren, wenn hier etwas schiefläuft" % tat + + func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel") and not ui.offen: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if \ @@ -162,6 +163,66 @@ func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("interagieren"): _interagieren() get_viewport().set_input_as_handled() + return + if event.is_action_pressed("folgen"): + _verfolgen() + get_viewport().set_input_as_handled() + return + if event.is_action_pressed("notieren"): + _notieren() + get_viewport().set_input_as_handled() + + +## Wer dabeisteht, füllt die Stunde im Ablauf -- mehr nicht. Was dort +## schiefläuft, muss er selbst benennen. +func _dabeisein() -> void: + if _fokus_mensch == null or not auf_erde: + return + var id: String = str(_fokus_mensch.data["id"]) + if id == Game.active_mission: + Game.sehen(id, int(_fokus_mensch.slot)) + + +## Den Moment benennen: hier läuft etwas schief. Das ist der Kern des +## Beobachtens -- der Spieler entscheidet, wann er hinschaut. +func _notieren() -> void: + if not auf_erde: + return + var ziel: Bewohner = _fokus_mensch + if ziel == null and player.folgt != null and is_instance_valid(player.folgt): + ziel = player.folgt + if ziel == null: + Game.notified.emit("Geh näher an den Menschen heran, dem du hilfst.", + Color(1, 0.85, 0.5)) + return + var id: String = str(ziel.data["id"]) + if id != Game.active_mission: + Game.notified.emit("Für %s hast du keine Mission angenommen." % ziel.data["name"], + Color(1, 0.85, 0.5)) + return + if Game.notiz_sperre > 0.0: + Game.notified.emit("Du bist noch beim letzten Fehlgriff (%.0f s)." % + Game.notiz_sperre, Color(1, 0.75, 0.55)) + return + Game.notieren(id, int(ziel.slot)) + + +## Als Geist an einen Menschen hängen und ihn durch seinen Tag begleiten. +func _verfolgen() -> void: + if not auf_erde: + return + if player.folgt != null: + var name: String = player.folgt.data["name"] + player.folgt = null + Game.notified.emit("Du lässt %s wieder los." % name, Color(0.8, 0.85, 1)) + return + if _fokus_mensch == null: + Game.notified.emit("Geh näher an einen Menschen heran, um ihm zu folgen.", + Color(1, 0.85, 0.5)) + return + player.folgt = _fokus_mensch + Game.notified.emit("Du hängst dich an %s." % _fokus_mensch.data["name"], + Color(0.7, 0.9, 1)) func _interagieren() -> void: diff --git a/scripts/player.gd b/scripts/player.gd index fde90bf..6018f26 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -10,11 +10,14 @@ const SPEED_GEIST := 11.0 const GRAVITY := 24.0 const JUMP := 9.0 const MAUS := 0.0026 +const FOLGE_ABSTAND := 3.0 +const FOLGE_SPIEL := 1.5 var mode: int = Mode.TRAUM var yaw := 0.0 var pitch := -0.25 var look_enabled := true +var folgt: Node3D = null # Mensch, an den sich der Geist gehängt hat var pivot: Node3D var arm: SpringArm3D @@ -67,6 +70,7 @@ func _ready() -> void: func set_mode(m: int) -> void: mode = m + folgt = null if m == Mode.GEIST: collision_layer = 0 collision_mask = 0 @@ -118,6 +122,23 @@ func _physics_process(delta: float) -> void: var basis_yaw := Basis(Vector3.UP, yaw) dir = (basis_yaw * Vector3(input.x, 0, input.y)).normalized() + if mode == Mode.GEIST and folgt != null: + if not is_instance_valid(folgt): + folgt = null + elif dir.length() > 0.01: + folgt = null # eigene Bewegung löst die Verfolgung + else: + var schulter := folgt.global_position + Vector3(0, 1.6, 0) \ + - blickrichtung() * FOLGE_ABSTAND + global_position = global_position.lerp(schulter, 1.0 - pow(0.004, delta)) + # weich nachziehen, aber an der Leine: sonst reißt der Faden beim Gehen + var ab := global_position - schulter + if ab.length() > FOLGE_SPIEL: + global_position = schulter + ab.normalized() * FOLGE_SPIEL + velocity = Vector3.ZERO + body.rotation.y = lerp_angle(body.rotation.y, yaw, 1.0 - pow(0.001, delta)) + return + if mode == Mode.GEIST: var v := dir * SPEED_GEIST if look_enabled: diff --git a/scripts/ui.gd b/scripts/ui.gd index 1b54200..1a788c2 100644 --- a/scripts/ui.gd +++ b/scripts/ui.gd @@ -12,6 +12,12 @@ var _kind := "" var _hud: RichTextLabel var _hinweis: Label var _log: VBoxContainer +var _ablauf: HBoxContainer +var _ablauf_titel: Label +var _ablauf_jetzt: Label +var _ablauf_grund: ColorRect +var _zellen: Array = [] +var _ticks: Array = [] var _dim: ColorRect var _panel: PanelContainer var _titel: Label @@ -47,11 +53,14 @@ func _baue_hud() -> void: _hud.add_theme_font_size_override("bold_font_size", 17) add_child(_hud) + _baue_ablauf() + var steuerung := Label.new() - steuerung.text = "WASD Bewegen · Maus Umsehen · E Interagieren · J Journal · 1/2/3 Tempo · Esc Maus frei" + steuerung.text = "WASD Bewegen · Maus Umsehen · E Interagieren · F Folgen · " + \ + "R Notieren · J Journal · 1/2/3 Tempo · Esc Maus frei" steuerung.set_anchors_preset(Control.PRESET_TOP_RIGHT) - steuerung.position = Vector2(-640, 16) - steuerung.size = Vector2(620, 24) + steuerung.position = Vector2(-720, 16) + steuerung.size = Vector2(700, 24) steuerung.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT steuerung.add_theme_font_size_override("font_size", 14) steuerung.add_theme_color_override("font_color", Color(1, 1, 1, 0.65)) @@ -79,6 +88,111 @@ func _baue_hud() -> void: add_child(_log) +## Der Tagesablauf im HUD: 24 Felder, eines je Stunde, immer sichtbar. +const ABLAUF_X := 18.0 +const ABLAUF_Y := 126.0 +const ZELLE_BREIT := 14.0 +const ZELLE_LUECKE := 2.0 + +func _baue_ablauf() -> void: + var breite := 24.0 * (ZELLE_BREIT + ZELLE_LUECKE) - ZELLE_LUECKE + + _ablauf_grund = ColorRect.new() + _ablauf_grund.color = Color(0, 0, 0, 0.42) + _ablauf_grund.set_anchors_preset(Control.PRESET_TOP_LEFT) + _ablauf_grund.position = Vector2(ABLAUF_X - 8.0, ABLAUF_Y - 6.0) + _ablauf_grund.custom_minimum_size = Vector2(breite + 16.0, 88.0) + _ablauf_grund.size = _ablauf_grund.custom_minimum_size + _ablauf_grund.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_ablauf_grund) + + _ablauf_titel = Label.new() + _ablauf_titel.set_anchors_preset(Control.PRESET_TOP_LEFT) + _ablauf_titel.position = Vector2(ABLAUF_X, ABLAUF_Y) + _ablauf_titel.add_theme_font_size_override("font_size", 14) + _ablauf_titel.add_theme_color_override("font_color", Color(1, 1, 1, 0.7)) + _ablauf_titel.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_ablauf_titel) + + _ablauf = HBoxContainer.new() + _ablauf.set_anchors_preset(Control.PRESET_TOP_LEFT) + _ablauf.position = Vector2(ABLAUF_X, ABLAUF_Y + 22.0) + _ablauf.add_theme_constant_override("separation", int(ZELLE_LUECKE)) + _ablauf.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_ablauf) + for stunde in range(24): + var zelle := ColorRect.new() + zelle.custom_minimum_size = Vector2(ZELLE_BREIT, 16) + zelle.mouse_filter = Control.MOUSE_FILTER_IGNORE + _ablauf.add_child(zelle) + _zellen.append(zelle) + + # Stundenmarken unter dem Streifen + for stunde in [0, 6, 12, 18]: + var tick := Label.new() + tick.text = "%d" % stunde + tick.set_anchors_preset(Control.PRESET_TOP_LEFT) + tick.position = Vector2(ABLAUF_X + float(stunde) * (ZELLE_BREIT + ZELLE_LUECKE), + ABLAUF_Y + 42.0) + tick.add_theme_font_size_override("font_size", 11) + tick.add_theme_color_override("font_color", Color(1, 1, 1, 0.45)) + tick.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(tick) + _ticks.append(tick) + + _ablauf_jetzt = Label.new() + _ablauf_jetzt.set_anchors_preset(Control.PRESET_TOP_LEFT) + _ablauf_jetzt.position = Vector2(ABLAUF_X, ABLAUF_Y + 60.0) + _ablauf_jetzt.add_theme_font_size_override("font_size", 15) + _ablauf_jetzt.add_theme_color_override("font_color", Color(1, 0.95, 0.8)) + _ablauf_jetzt.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_ablauf_jetzt) + + +## Farbe je Stunde: unbekannt, gesehen, ungeklärt, benannt, gebaut. +func _ablauf_farbe(human_id: String, nr: int) -> Color: + if not Game.gesehen_slot(human_id, nr): + return Color(0.22, 0.23, 0.30) + if not Game.system_in(human_id, nr).is_empty(): + return Color(0.44, 0.78, 0.50) + var grund := Game.slot_grund(human_id, nr) + if Ursachen.ist_blocker(grund): + return Color(0.85, 0.66, 0.32) if not Game.erkannt(human_id, nr) \ + else Color(0.84, 0.42, 0.35) + if grund == "schlaeft": + return Color(0.30, 0.33, 0.44) + return Color(0.46, 0.50, 0.62) + + +func _ablauf_aktualisieren() -> void: + var id := Game.active_mission + var an := id != "" + _ablauf.visible = an + _ablauf_titel.visible = an + _ablauf_jetzt.visible = an + _ablauf_grund.visible = an + for t in _ticks: + (t as Label).visible = an + if not an: + return + var jetzt := int(Game.time_h) + var offen := 0 + 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 += 1 + if stunde == jetzt: + f = f.lightened(0.55) + var zelle: ColorRect = _zellen[stunde] + zelle.color = f + zelle.custom_minimum_size.y = 20 if stunde == jetzt else 16 + _ablauf_titel.text = "Ihr Tag 0 – 23 Uhr%s" % \ + ("" if offen == 0 else " · %d Stunden unbekannt" % offen) + var nr_jetzt := Game.slot_bei_stunde(id, jetzt) + _ablauf_jetzt.text = "%02d Uhr %s" % [jetzt, _stunden_text(id, nr_jetzt)] + + func _baue_panel() -> void: _dim = ColorRect.new() _dim.color = Color(0, 0, 0, 0.62) @@ -163,6 +277,7 @@ func _hud_aktualisieren() -> void: else: zeilen.append("[color=#aaaaaa]Keine Mission. Schwarzes Brett am Dorfplatz.[/color]") _hud.text = "\n".join(zeilen) + _ablauf_aktualisieren() func setze_hinweis(text: String) -> void: @@ -381,13 +496,11 @@ func systeme_panel(human_id: String) -> void: _text("[b]%s[/b] — %s %s" % [Ursachen.LISTE[name]["name"], Ursachen.LISTE[name]["kurz"], stand], 15) if offene > 0: - _text("[color=#aaaaaa]Steh dabei, wenn sie etwas tut, das du noch nicht " + - "kennst. Dann zeigt sich, warum.[/color]", 14) + _text("[color=#aaaaaa]Steh dabei, dann füllt sich ihr Tag. Und drücke [b]R[/b], " + + "wenn du meinst, dass eine Stunde den Traum aufhält.[/color]", 14) _trenner() - _text("[b]Ihr Tag[/b]", 17) - for nr in range(Game.slots(human_id).size()): - _slot_zeile(human_id, nr) + _tagesablauf(human_id) _trenner() _knopf("Zurück zum Journal", func() -> void: journal_panel("missionen")) _zeigen("systeme") @@ -395,27 +508,50 @@ func systeme_panel(human_id: String) -> void: _scroll_frames = 4 -func _slot_zeile(human_id: String, nr: int) -> void: - var slot: Array = Game.slots(human_id)[nr] - var stunde := "%02d:00" % int(slot[0]) - var grund := Game.slot_grund(human_id, nr) - if grund == "schlaeft": - _knopf("%s schläft" % stunde, func() -> void: pass, false) - return +## 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: + var offen := 0 + 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] [color=#8e93a8]0 – 23 Uhr, jeden Tag derselbe%s[/color]" % + ("" if offen == 0 else " · %d Stunden noch leer" % offen), 17) + + # eine Zeile je Abschnitt des Tages -- auch wenn noch keiner bekannt ist, + # steht der Tag vollständig da + var von := 0 + while von < 24: + var nr := Game.slot_bei_stunde(human_id, von) + var bis := von + while bis + 1 < 24 and Game.slot_bei_stunde(human_id, bis + 1) == nr: + bis += 1 + _stunden_zeile(human_id, nr, von, bis, _stunden_text(human_id, nr)) + von = bis + 1 + + +func _stunden_text(human_id: String, nr: int) -> String: + if not Game.gesehen_slot(human_id, nr): + return "—" var sys := Game.system_in(human_id, nr) if not sys.is_empty(): - _knopf("%s %s ← %s" % [stunde, Game.slot_aktivitaet(human_id, nr), - TechDB.by_id(str(sys["tech"]))["name"]], - func() -> void: slot_panel(human_id, nr)) - return + return "%s ← %s" % [Game.slot_aktivitaet(human_id, nr), + TechDB.by_id(str(sys["tech"]))["name"]] + var grund := Game.slot_grund(human_id, nr) if not Game.erkannt(human_id, nr): - _knopf("%s ?" % stunde, func() -> void: pass, false) - return - var text := Orte.text(grund) + return "?" if Ursachen.ist_blocker(grund): - text = "%s (%s)" % [Ursachen.BLOCKER[grund]["name"], + return "%s (%s)" % [Ursachen.BLOCKER[grund]["name"], Ursachen.LISTE[Ursachen.BLOCKER[grund]["ursache"]]["name"]] - _knopf("%s %s" % [stunde, text], func() -> void: slot_panel(human_id, nr)) + return Orte.text(grund) + + +func _stunden_zeile(human_id: String, nr: int, von: int, bis: int, text: String) -> void: + 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" + _knopf("%s %s" % [spanne, text], + func() -> void: slot_panel(human_id, nr), offen) ## Was soll in diesem Slot stehen? @@ -580,8 +716,9 @@ func anwenden_panel(mensch: Node3D) -> void: return var grund := Game.slot_grund(id, nr) if not Game.erkannt(id, nr): - _text("[color=#ffcc66]Was hier läuft, hast du noch nicht gesehen. " + - "Bleib stehen und schau zu.[/color]", 16) + _text("[color=#ffcc66]Was hier läuft, hast du noch nicht notiert. " + + "Sieh ihr zu und drücke [b]R[/b], wenn du meinst, hier läuft etwas " + + "schief.[/color]", 16) _zeigen("anwenden") return if Ursachen.ist_blocker(grund): diff --git a/tests/verify.gd b/tests/verify.gd index 7bf2879..fb98bd8 100644 --- a/tests/verify.gd +++ b/tests/verify.gd @@ -34,7 +34,12 @@ func _ready() -> void: await test_tagesablauf() await test_wege_machbar() await test_geist() + await test_verfolgung() + await test_gehen_bei_tempo() await test_diagnose() + await test_notieren_im_spiel() + await test_tagesablauf_anzeige() + await test_ablauf_im_hud() await test_systeme() await test_tempo_des_traums() await test_cooldown() @@ -691,6 +696,110 @@ func test_geist() -> void: p(lena.global_position.distance_to(davor) < 2.0, "Menschen nehmen den Geist nicht wahr") +## 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: + abschnitt("Gehen bleibt auch im Schnelllauf ruhig") + ui().schliessen() + var mensch: Bewohner = welt().humans[0] + var stufe_kehrt: Dictionary = {} + for stufe in [1, 3]: + Game.tempo_setzen(stufe) + Game.time_h = 12.9 + for i in range(400): + schritte(1, 1.0 / 60.0) + if not mensch._path.is_empty(): + break + var kehrt := 0 + var bewegt := 0 + var vorher: Vector3 = mensch.global_position + var richtung := Vector3.ZERO + for i in range(600): + schritte(1, 1.0 / 60.0) + if mensch._path.is_empty(): + continue + var d: Vector3 = mensch.global_position - vorher + d.y = 0.0 + if d.length() > 0.001: + bewegt += 1 + if richtung != Vector3.ZERO and d.normalized().dot(richtung) < -0.5: + kehrt += 1 + richtung = d.normalized() + vorher = mensch.global_position + stufe_kehrt[stufe] = kehrt + var weite := Bewohner.GEHTEMPO * (1.0 / 60.0) * Game.faktor_von(stufe) + p(kehrt <= 4, "Tempo %d (%d×, %.2f je Bild): %d Kehrtwenden in %d Bildern" % + [stufe, int(Game.faktor_von(stufe)), weite, kehrt, bewegt]) + p(int(stufe_kehrt[3]) <= int(stufe_kehrt[1]) + 4, + "Schnelllauf zappelt nicht mehr als Normaltempo (%d gegen %d)" % + [stufe_kehrt[3], stufe_kehrt[1]]) + Game.tempo_setzen(1) + + +## Sich an einen Menschen hängen: der Geist begleitet ihn durch seinen Tag, +## statt ihm hinterherzulaufen. Damit bleibt er die ganze Zeit beobachtbar. +func test_verfolgung() -> void: + abschnitt("Als Geist einen Menschen begleiten") + p(InputMap.has_action("folgen") and not InputMap.action_get_events("folgen").is_empty(), + "Eigene Taste fürs Folgen") + ui().schliessen() + main.player.set_mode(Player.Mode.GEIST) + main.player.look_enabled = true + main.player.yaw = 0.0 + var mensch: Bewohner = welt().humans[0] + Game.active_mission = str(mensch.data["id"]) + main.player.global_position = mensch.global_position + Vector3(0, 1.5, 2.0) + main.player.velocity = Vector3.ZERO + main._process(0.05) + p(main._fokus_mensch == mensch, "Der Mensch ist im Fokus") + + main._verfolgen() + p(main.player.folgt == mensch, "F hängt den Geist an ihn") + + # Er lebt seinen Tag weiter, der Geist bleibt dran + var weiteste := 0.0 + var gelaufen := false + var im_fokus := 0 + for i in range(900): + schritte(1) + await get_tree().physics_frame + if not mensch._path.is_empty(): + gelaufen = true + if main._fokus_mensch == mensch: + im_fokus += 1 + weiteste = maxf(weiteste, + main.player.global_position.distance_to(mensch.global_position)) + p(gelaufen, "Er wechselt in dieser Zeit den Ort") + p(weiteste < 6.0, "Der Geist bleibt die ganze Zeit dicht dabei (höchstens %.1f)" % weiteste) + p(im_fokus > 850, "Und behält ihn im Fokus (%d von 900 Schritten)" % im_fokus) + p(main._fokus_mensch == mensch or main.player.folgt == mensch, + "Unterwegs bleibt er notierbar") + + # Eigene Bewegung löst die Verfolgung + Input.action_press("vor") + for i in range(10): + await get_tree().physics_frame + Input.action_release("vor") + p(main.player.folgt == null, "Wer selbst losgeht, lässt ihn los") + + # Die Taste löst ebenfalls + main.player.global_position = mensch.global_position + Vector3(0, 1.5, 2.0) + main.player.velocity = Vector3.ZERO + main._process(0.05) + main._verfolgen() + p(main.player.folgt == mensch, "Wieder angehängt") + main._verfolgen() + p(main.player.folgt == null, "F lässt wieder los") + + # In der Traumwelt gibt es niemanden zu verfolgen + main._traumwelt() + await get_tree().process_frame + main._verfolgen() + p(main.player.folgt == null, "In der Traumwelt hängt sich der Läufer an niemanden") + main._erde() + await get_tree().process_frame + + ## Woran der Traum scheitert, steht nirgends. Man findet es, indem man ## dabeisteht -- awareness training, bevor irgendetwas ersetzt wird. func test_diagnose() -> void: @@ -699,7 +808,7 @@ func test_diagnose() -> void: Game.active_mission = id Game.systeme.clear() Game.erkannt_slots.clear() - Game.beobachtung.clear() + Game.notiz_sperre = 0.0 var urs: Array = Game.ursachen_von(id) p(urs.size() >= 2 and urs.size() <= 3, @@ -739,11 +848,19 @@ func test_diagnose() -> void: p(not Game.ursache_bekannt(id, ursache), "Und die Ursache ist noch unbekannt") # Solange man nicht lange genug dabeisteht, passiert nichts - Game.beobachten(id, blocker_nr, Game.ERKENNEN_SEK * 0.4) - p(not Game.erkannt(id, blocker_nr), "Kurz hinschauen reicht nicht") - Game.beobachten(id, blocker_nr, Game.ERKENNEN_SEK) - p(Game.erkannt(id, blocker_nr), "Nach %.0f Sekunden dabeistehen zeigt es sich" % - Game.ERKENNEN_SEK) + # Zusehen allein deckt nichts auf -- der Spieler muss den Moment benennen + for i in range(60): + schritte(1) + p(not Game.erkannt(id, blocker_nr), "Bloßes Dabeistehen deckt nichts auf") + p(Game.notieren(id, neutral_nr) == "daneben", + "Im freien Slot geht die Notiz daneben") + p(Game.notiz_sperre > 0.0, "Ein Fehlgriff sperrt kurz (%.0f s)" % Game.notiz_sperre) + p(Game.notieren(id, blocker_nr) == "gesperrt", "Solange nimmt er keine Notiz an") + Game.notiz_sperre = 0.0 + p(Game.notieren(id, blocker_nr) == "treffer", "Im Blocker-Slot sitzt sie") + p(Game.erkannt(id, blocker_nr), "Und deckt auf, was dort läuft") + p(is_equal_approx(Game.notiz_sperre, 0.0), "Ein Treffer sperrt nicht") + p(Game.notieren(id, blocker_nr) == "bekannt", "Zweimal notieren bringt nichts Neues") p(Game.ursache_bekannt(id, ursache), "Und damit die Ursache: %s" % Ursachen.LISTE[ursache]["name"]) @@ -761,6 +878,226 @@ func test_diagnose() -> void: ## Gegen eine Ursache baut man ein System. Passt die Belohnung, hält es besser. +## Der Ablauf steht auch im HUD, ohne dass man ein Panel öffnen muss. +func test_ablauf_im_hud() -> void: + abschnitt("Der Ablauf steht im HUD") + var id := "mensch_0" + Game.gesehen.clear() + Game.erkannt_slots.clear() + Game.systeme.clear() + Game.active_mission = "" + ui().schliessen() + ui()._hud_aktualisieren() + p(not ui()._ablauf.visible, "Ohne Mission zeigt das HUD keinen Ablauf") + + Game.active_mission = id + Game.time_h = 9.5 + ui()._hud_aktualisieren() + p(ui()._ablauf.visible, "Mit Mission schon") + p(ui()._zellen.size() == 24, "Ein Feld je Stunde (%d)" % ui()._zellen.size()) + p(ui()._ablauf_titel.text.contains("0 – 23"), "Beschriftet von 0 bis 23 Uhr") + p(ui()._ablauf_titel.text.contains("24 Stunden unbekannt"), + "Am Anfang ist alles unbekannt: %s" % ui()._ablauf_titel.text) + + var unbekannt: Color = ui()._ablauf_farbe(id, Game.slot_bei_stunde(id, 9)) + var nr := Game.slot_bei_stunde(id, 9) + Game.sehen(id, nr) + ui()._hud_aktualisieren() + p(ui()._ablauf_farbe(id, nr) != unbekannt, "Eine gesehene Stunde färbt sich um") + p(not ui()._ablauf_titel.text.contains("24 Stunden"), + "Und der Zähler geht runter: %s" % ui()._ablauf_titel.text) + p(ui()._ablauf_jetzt.text.begins_with("09 Uhr"), + "Darunter steht die laufende Stunde: %s" % ui()._ablauf_jetzt.text) + + # Die laufende Stunde ist hervorgehoben + var jetzt: ColorRect = ui()._zellen[9] + var andere: ColorRect = ui()._zellen[8] + p(jetzt.custom_minimum_size.y > andere.custom_minimum_size.y, + "Das Feld der laufenden Stunde steht heraus") + + # Ein benannter Blocker sieht anders aus als eine harmlose Stunde + var blocker_nr := -1 + var frei_nr := -1 + for n in range(Game.slots(id).size()): + var g := Game.slot_grund(id, n) + if Ursachen.ist_blocker(g) and blocker_nr < 0: + blocker_nr = n + elif g != "schlaeft" and not Ursachen.ist_blocker(g) and frei_nr < 0: + frei_nr = n + Game.sehen(id, blocker_nr) + Game.sehen(id, frei_nr) + var vor_notiz: Color = ui()._ablauf_farbe(id, blocker_nr) + Game.notieren(id, blocker_nr) + p(ui()._ablauf_farbe(id, blocker_nr) != vor_notiz, + "Notiert wechselt der Blocker noch einmal die Farbe") + p(ui()._ablauf_farbe(id, blocker_nr) != ui()._ablauf_farbe(id, frei_nr), + "Und hebt sich von einer harmlosen Stunde ab") + Game.notiz_sperre = 0.0 + + +## Der Ablauf 0-23 Uhr: am Anfang leer, füllt sich durchs Dabeisein. +func test_tagesablauf_anzeige() -> void: + abschnitt("Der Tag steht als Ablauf von 0 bis 23 Uhr") + var id := "mensch_0" + Game.active_mission = id + Game.gesehen.clear() + Game.erkannt_slots.clear() + Game.systeme.clear() + Game.notiz_sperre = 0.0 + + # Jede Stunde des Tages hat einen Slot, und jeder Tag ist derselbe + var luecken := 0 + for stunde in range(24): + if Game.slot_bei_stunde(id, stunde) < 0: + luecken += 1 + p(luecken == 0, "Alle 24 Stunden sind belegt") + Game.day = 1 + var tag_eins: Array = [] + for stunde in range(24): + tag_eins.append(Game.slot_bei_stunde(id, stunde)) + Game.day = 9 + var tag_neun: Array = [] + for stunde in range(24): + tag_neun.append(Game.slot_bei_stunde(id, stunde)) + p(tag_eins == tag_neun, "Tag 9 sieht aus wie Tag 1 — es gibt keine Wochentage") + Game.day = 1 + + ui().systeme_panel(id) + var zeilen := _knopf_texte() + p(zeilen.contains("00 – "), "Der Ablauf beginnt um 00 Uhr") + p(zeilen.contains(" – 24"), "Und reicht bis 24 Uhr") + var leer := 0 + for stunde in range(24): + if not Game.gesehen_slot(id, Game.slot_bei_stunde(id, stunde)): + leer += 1 + p(leer == 24, "Am Anfang ist jede der 24 Stunden unbekannt (%d)" % leer) + p(zeilen.contains("—") and not zeilen.contains("schläft"), + "Und im Ablauf steht nur ein Strich") + ui().schliessen() + + # Dabeistehen füllt genau die Stunde, in der man steht + var mensch: Bewohner = null + for m in welt().humans: + if m.data["id"] == id: + mensch = m + var frei_nr := -1 + for nr in range(Game.slots(id).size()): + var g := Game.slot_grund(id, nr) + if g != "schlaeft" and not Ursachen.ist_blocker(g): + frei_nr = nr + break + await _hin_zu(mensch, float(Game.slots(id)[frei_nr][0]) + 0.5) + main._process(0.05) + p(Game.gesehen_slot(id, frei_nr), "Wer dabeisteht, füllt die Stunde") + ui().systeme_panel(id) + p(_knopf_texte().contains(Orte.text(Game.slot_grund(id, frei_nr))), + "Sie steht jetzt im Ablauf: %s" % Orte.text(Game.slot_grund(id, frei_nr))) + ui().schliessen() + + # Ein Blocker bleibt ein Fragezeichen, bis er benannt ist + var blocker_nr := -1 + for nr in range(Game.slots(id).size()): + if Ursachen.ist_blocker(Game.slot_grund(id, nr)): + blocker_nr = nr + break + Game.sehen(id, blocker_nr) + ui().systeme_panel(id) + p(_knopf_texte().contains("?"), "Was schiefläuft, steht erst als Fragezeichen da") + ui().schliessen() + Game.notieren(id, blocker_nr) + ui().systeme_panel(id) + var b: Dictionary = Ursachen.BLOCKER[Game.slot_grund(id, blocker_nr)] + p(_knopf_texte().contains(str(b["name"])), + "Nach dem Notieren steht dort, was sie tut") + p(_knopf_texte().contains(str(Ursachen.LISTE[b["ursache"]]["name"])), + "Und warum: %s" % Ursachen.LISTE[b["ursache"]]["name"]) + ui().schliessen() + + +## Der ganze Weg: dabeistehen, den Moment einschätzen, R drücken. +func test_notieren_im_spiel() -> void: + abschnitt("Notieren ist eine Entscheidung, kein Timer") + var id := "mensch_0" + Game.active_mission = id + Game.erkannt_slots.clear() + Game.notiz_sperre = 0.0 + ui().schliessen() + main.player.set_mode(Player.Mode.GEIST) + main.player.look_enabled = true + + var blocker_nr := -1 + var frei_nr := -1 + for nr in range(Game.slots(id).size()): + var g := Game.slot_grund(id, nr) + if Ursachen.ist_blocker(g) and blocker_nr < 0: + blocker_nr = nr + elif g != "schlaeft" and not Ursachen.ist_blocker(g) and frei_nr < 0: + frei_nr = nr + p(blocker_nr >= 0 and frei_nr >= 0, "Ihr Tag hat einen Blocker und einen freien Slot") + + var mensch: Bewohner = null + for m in welt().humans: + if m.data["id"] == id: + mensch = m + + # Im freien Slot danebengegriffen + await _hin_zu(mensch, float(Game.slots(id)[frei_nr][0]) + 0.5) + p(mensch.slot == frei_nr, "Sie steht im freien Slot (%d)" % mensch.slot) + main._notieren() + p(not Game.erkannt(id, blocker_nr), "Hier gibt es nichts zu notieren") + p(Game.notiz_sperre > 0.0, "Der Fehlgriff kostet eine Sperre") + + # Sperre läuft in Echtzeit ab + for i in range(200): + main._process(0.05) + p(is_equal_approx(Game.notiz_sperre, 0.0), "Sie läuft von allein ab") + + # Im Blocker-Slot getroffen + await _hin_zu(mensch, float(Game.slots(id)[blocker_nr][0]) + 0.5) + p(mensch.slot == blocker_nr, "Jetzt steht sie im Blocker-Slot (%d)" % mensch.slot) + p(main._fokus_mensch == mensch, "Und ist in Reichweite") + main._notieren() + p(Game.erkannt(id, blocker_nr), "R notiert, was hier schiefläuft: %s" % + Orte.text(Game.slot_grund(id, blocker_nr))) + + # Ein fremder Mensch geht niemanden etwas an + Game.erkannt_slots.clear() + Game.notiz_sperre = 0.0 + var fremd: Bewohner = null + for m in welt().humans: + if m.data["id"] != id: + fremd = m + main.player.folgt = null + main.player.global_position = fremd.global_position + Vector3(0, 1.5, 1.5) + main._process(0.05) + main._notieren() + p(Game.erkannt_slots.is_empty(), + "Ohne angenommene Mission notiert er bei %s nichts" % fremd.data["name"]) + p(is_equal_approx(Game.notiz_sperre, 0.0), "Und das kostet auch keine Sperre") + + # Aus der Verfolgung heraus geht es ebenfalls + Game.erkannt_slots.clear() + await _hin_zu(mensch, float(Game.slots(id)[blocker_nr][0]) + 0.5) + main._verfolgen() + p(main.player.folgt == mensch, "An sie gehängt") + main._notieren() + p(Game.erkannt(id, blocker_nr), "Auch im Vorbeischweben lässt sich notieren") + main.player.folgt = null + + +## Stellt den Geist zur genannten Stunde neben den Menschen. +func _hin_zu(mensch: Bewohner, stunde: float) -> void: + for i in range(600): + schritte(1, 0.05, stunde) + if mensch._path.is_empty(): + break + main.player.folgt = null + main.player.global_position = mensch.global_position + Vector3(0, 1.5, 1.5) + main.player.velocity = Vector3.ZERO + await get_tree().process_frame + main._process(0.05) + + func test_systeme() -> void: abschnitt("Systeme — gegen die Ursache, nicht gegen die Tätigkeit") var id := "mensch_0" @@ -1612,7 +1949,7 @@ func _erster_traum_tage(saat: int) -> int: Game.known.clear() Game.systeme.clear() Game.erkannt_slots.clear() - Game.beobachtung.clear() + Game.notiz_sperre = 0.0 Game.progress.clear() Game.completed.clear() Game.active_mission = ""