This commit is contained in:
Team3
2026-09-16 16:15:32 +02:00
parent e813b5f7aa
commit 7c4edaa439
19 changed files with 1244 additions and 636 deletions

View File

@@ -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. Oben links stehen nur Zeit und Mission. Alles Weitere steht im Journal.
In der Traumwelt springt **Leertaste**. Auf der Erde gibt es keinen Sprung: In der Traumwelt springt **Leertaste**. Auf der Erde gibt es keinen Sprung:
der Geist nimmt die Treppe. der Geist geht zu Fuß.
## Spielschleife ## 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 * **Techniken** — was du gelernt hast, mit Stufe und Erfahrung. Besser werden
sie nur draußen. 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 * **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 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 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 `user://aushaenge.cfg`, überleben den Neustart und ersetzen die letzten
erzeugten Einwohner; ein Aushang lässt sich wieder abnehmen. 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*, 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 *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 Wer keine Kraft hat, schaut abends eine Serie und bleibt danach wach. Jede
dieser Tätigkeiten hat einen **Auslöser** und eine **Belohnung**. 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. Einführen Ein System, das fehlt — in einen freien Slot.
Ändern Eine Tätigkeit, die schadet — mit gleicher Belohnung ersetzt. Ä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 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 durch ein Haus; findet die Suche keinen, wird feiner gesucht und notfalls
woanders angedockt. 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 * **Schwarzes Brett** am Dorfplatz — hier hängen die Menschen aus, die nicht
weiterkommen. Mission annehmen. 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 * **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, ihren Tag: schlafen, arbeiten, grübeln, essen, spazieren. Wer wo wohnt,
arbeitet in der Nähe; das Café liegt in der Mitte. arbeitet in der Nähe; das Café liegt in der Mitte.
* **Die Stadt ist verschachtelt.** Wohnhäuser und Sonderbauten sind * **Wege kommen vom Navigationsnetz.** Beim ersten Betreten wird aus allen
geschlossene Häuser mit zwei bis vier Stockwerken, Treppenhaus neben der Kollisionsformen ein Navigationsnetz gebacken (unter einer Sekunde): Straßen,
Tür, Fenstern und Möbeln. Jeder Mensch hat seine Wohnung in einer Etage, Türlücken, Möbel, Klötze — auf der Insel genauso.
im Büro und an der Hochschule sitzt jeder auf seinem Stockwerk. Die Menschen und der Geist fragen es nach Pfaden; Türen und Treppen finden sie
Bewohner gehen die Treppe hoch und runter. Alles andere steht als von allein. Das Straßennetz zeichnet nur noch die Wege.
geschlossener Klotz da. * **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, * 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 sich aber bewegt wie ein Mensch: Wände halten, Türen sind die Wege,
Wege, Schwerkraft gilt. Bist du in einem Haus, greift der **Etagenschnitt**: Dach, Etagen über dir und Schwerkraft gilt. Bist du in einem Haus, verschwinden Dach und die Wände zur
die Wände zur Kamera hin verschwinden, wie bei den Sims. Ein Haus, das Kamera hin, wie bei den Sims. Ein Haus, das zwischen Kamera und dir steht,
zwischen Kamera und dir steht, wird durchsichtig. Wer auf einer anderen wird durchsichtig.
Etage ist, ist nicht im Fokus — durch die Decke sieht man nichts.
* **F** oder ein Klick hängt dich an einen Menschen: du gehst seiner Spur * **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 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 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. oder ein Schritt mit **WASD** — lässt ihn wieder los.
* Schilder tragen nur die Mission, der Fokus, der Verfolgte und wer unter der * Schilder tragen nur die Mission, der Fokus, der Verfolgte und wer unter der
Maus steht. Sonst überdecken sich im Café acht Namen. 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 * **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 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 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. Zusehen allein deckt nichts auf — das Bemerken ist der Zug.
* Nah herangehen, **E** drücken, Technik wählen. Das Panel nennt für jede * Nah herangehen, **E** drücken, Technik wählen. Das Panel nennt für jede
Technik nur, ob Thema und Moment passen — keine Zahl. 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. * Die Säule im Park bringt dich zurück in die Traumwelt.
## Ob es gelingt ## 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 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 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/ godot --path . tests/shots.tscn # 28 Screenshots nach user://shots/
`verify` fährt das echte Spiel: leeres Traumdorf, einziehende Wesen mit ihren `verify` fährt das echte Spiel: leeres Traumdorf, einziehende Wesen mit ihren

View File

@@ -12,8 +12,6 @@ var place: String = ""
var activity: String = "" var activity: String = ""
var resist_left: float = 0.0 var resist_left: float = 0.0
var places: Dictionary = {} var places: Dictionary = {}
var netz: Netz = null
var anker: Dictionary = {}
var mit_mission := false var mit_mission := false
var _visual: Node3D var _visual: Node3D
@@ -28,18 +26,17 @@ var _path: Array = []
var _letzte_aktivitaet: String = "" var _letzte_aktivitaet: String = ""
var boden: Callable = Callable() # Höhe des Bodens (x, z) -> y; leer heißt eben var boden: Callable = Callable() # Höhe des Bodens (x, z) -> y; leer heißt eben
var slot: int = 0 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 _bob: float = 0.0
var _offset: Vector3 var _offset: Vector3
func setup(d: Dictionary, index: int, orte: Dictionary, func setup(d: Dictionary, index: int, orte: Dictionary) -> void:
wegenetz: Netz = null, wegeanker: Dictionary = {}) -> void:
data = d data = d
idx = index idx = index
places = orte places = orte
netz = wegenetz
anker = wegeanker
mit_mission = bool(d.get("mission", false)) mit_mission = bool(d.get("mission", false))
name = "Bewohner_" + str(d["id"]) name = "Bewohner_" + str(d["id"])
var winkel := float(index) * TAU / 4.0 var winkel := float(index) * TAU / 4.0
@@ -85,20 +82,25 @@ func setup(d: Dictionary, index: int, orte: Dictionary,
place = start[0] place = start[0]
activity = start[1] activity = start[1]
_letzte_aktivitaet = activity _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. ## Pfad über das Navigationsnetz zu einem Ort -- leer, wenn es keins gibt.
func spur_punkt(abstand: float) -> Vector3: func _nav_route(nach: String) -> Array:
for i in range(spur.size() - 1, -1, -1): if not nav_map.is_valid():
if (spur[i] as Vector3).distance_to(global_position) >= abstand: return []
return spur[i] var ziel := Orte.center(places, nach) + _offset
return spur[0] if not spur.is_empty() else global_position var pts := NavigationServer3D.map_get_path(nav_map, global_position, ziel, true)
if pts.size() < 2:
nav_fehl += 1
## In welcher Etage dieser Mensch an einem Ort ist -- 0, wenn er dort nur unten ist. nav_fehl_info = "%s: %s -> %s von %s nach %s (%d Punkte)" % [data["name"], place, nach,
func etage(ort: String) -> int: str(global_position), str(ziel), pts.size()]
return int((data.get("etage", {}) as Dictionary).get(ort, 0)) 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 ## 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) var soll := _routine_at(Game.time_h)
slot = int(soll[2]) slot = int(soll[2])
if soll[0] != place: if soll[0] != place:
var war_unterwegs := not _path.is_empty() _path = _nav_route(soll[0])
_path = Orte.route_voll(places, place, soll[0], _offset, if _path.is_empty(): # kein Netz oder kein Weg: Luftlinie, damit er ankommt
data.get("etage", {}), netz, anker, war_unterwegs) _path = [Orte.center(places, soll[0]) + _offset]
if war_unterwegs and _path.size() > 1:
_path.remove_at(0) # steht schon draußen, alte Tür überspringen
place = soll[0] place = soll[0]
activity = "unterwegs" activity = "unterwegs"
elif _path.is_empty() and activity != soll[1]: elif _path.is_empty() and activity != soll[1]:
@@ -200,10 +200,6 @@ func _process(roh: float) -> void:
_haltung(delta, not _path.is_empty()) _haltung(delta, not _path.is_empty())
if boden.is_valid(): if boden.is_valid():
global_position.y = float(boden.call(global_position.x, global_position.z)) 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, _visual.rotation.z = lerp_angle(_visual.rotation.z, PI * 0.5 if activity == "schlaeft" else 0.0,
1.0 - pow(0.01, delta)) 1.0 - pow(0.01, delta))
@@ -224,7 +220,8 @@ func _process(roh: float) -> void:
_label.text = beschriftung() _label.text = beschriftung()
# Auf der Erde tragen nur Fokus, Verfolgte, Mission und wer unter der Maus # Auf der Erde tragen nur Fokus, Verfolgte, Mission und wer unter der Maus
# steht ein Schild -- sonst überdecken sich acht Namen im Café. # 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 ( _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)) Color(1, 0.9, 0.6) if aktiv else Color(1, 1, 1))

View File

@@ -254,7 +254,15 @@ static func _platz_frei(p: Vector3, groesse: Vector2) -> bool:
var c := Orte.center(PLACES, key) var c := Orte.center(PLACES, key)
var dx: float = absf(p.x - c.x) - (groesse.x + bx) * 0.5 var dx: float = absf(p.x - c.x) - (groesse.x + bx) * 0.5
var dz: float = absf(p.z - c.z) - (groesse.y + bz) * 0.5 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 return false
for k in NETZ.kanten: for k in NETZ.kanten:
if Netz.trifft_rechteck(NETZ.knoten[k[0]], NETZ.knoten[k[1]], p, groesse, WEG_ABSTAND): 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 = [] var ablauf: Array = []
for e in ABLAEUFE[i % ABLAEUFE.size()]: for e in ABLAEUFE[i % ABLAEUFE.size()]:
ablauf.append([e[0], haus if e[1] == "haus" else e[1], e[2]]) 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 { return {
"id": "wesen_%d" % i, "id": "wesen_%d" % i,
"name": _name(i), "name": _name(i),
"rolle": _rolle(i), "rolle": rolle,
"von": von,
"haus": haus, "haus": haus,
"leuchtet": true, "leuchtet": true,
"farbe": Color.from_hsv(fmod(0.72 + float(i) * 0.161, 1.0), 0.55, 0.95), "farbe": Color.from_hsv(fmod(0.72 + float(i) * 0.161, 1.0), 0.55, 0.95),

View File

@@ -7,10 +7,18 @@ class_name DreamWorld
var spawn := Vector3(0, 2.0, 20.0) var spawn := Vector3(0, 2.0, 20.0)
var bewohner: Array = [] var bewohner: Array = []
var spieler: Node3D = null # setzt main, für den Etagenschnitt 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 _bauten: Array = []
var _lichter: Array = [] var _lichter: Array = []
var _freihalten: Array[Vector3] = [] 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: func _ready() -> void:
@@ -23,13 +31,39 @@ func _ready() -> void:
_lichtung() _lichtung()
_tor_zur_erde() _tor_zur_erde()
_gruen() _gruen()
_navigation()
_wesen() _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. ## Dämmerung: tiefes Blau oben, warmes Lavendel am Horizont, Nebel im Tal.
func _environment() -> void: func _environment() -> void:
var we := WorldEnvironment.new() _we = WorldEnvironment.new()
var env := Environment.new() var we := _we
_env = Environment.new()
var env := _env
env.background_mode = Environment.BG_SKY env.background_mode = Environment.BG_SKY
var sky := Sky.new() var sky := Sky.new()
var mat := ProceduralSkyMaterial.new() var mat := ProceduralSkyMaterial.new()
@@ -182,7 +216,7 @@ func _wege() -> void:
for k in DreamDB.NETZ.knoten: for k in DreamDB.NETZ.knoten:
var p: Vector3 = k var p: Vector3 = k
p.y = DreamDB.hoehe_bei(p.x, p.z) 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: if nr % 2 == 0 and Vector2(p.x, p.z).length() > 12.0:
_laterne(p + Vector3(1.6, 0, 0)) _laterne(p + Vector3(1.6, 0, 0))
nr += 1 nr += 1
@@ -212,7 +246,7 @@ func _haeuser() -> void:
Orte.DIR_VEC[pl["dir"]], 3.4, true) Orte.DIR_VEC[pl["dir"]], 3.4, true)
var dach := Prims.dach(self, c, groesse, farbe) var dach := Prims.dach(self, c, groesse, farbe)
_bauten.append({"c": c, "size": Vector2(groesse.x, groesse.z), "hoch": groesse.y + 3.0, _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)) 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) _einrichtung(c, groesse, farbe, key)
@@ -463,8 +497,17 @@ func _wesen() -> void:
var w := Bewohner.new() var w := Bewohner.new()
add_child(w) add_child(w)
w.boden = Callable(DreamDB, "hoehe_bei") 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) 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: func _process(delta: float) -> void:

View File

@@ -4,12 +4,17 @@ class_name EarthWorld
var spawn := Vector3(0, 1.4, 12.0) var spawn := Vector3(0, 1.4, 12.0)
var humans: Array = [] var humans: Array = []
var passanten: Array = [] # Statisten ohne Geschichte
var spieler: Node3D = null # setzt main, für den Etagenschnitt 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 _bauten: Array = [] # alles, was der Etagenschnitt aufschneidet
var _sun: DirectionalLight3D var _sun: DirectionalLight3D
var _sky: ProceduralSkyMaterial var _sky: ProceduralSkyMaterial
var _env: Environment var _env: Environment
var _we: WorldEnvironment
var _nachtlichter: Array = [] var _nachtlichter: Array = []
@@ -20,12 +25,41 @@ func _ready() -> void:
_orte() _orte()
_park() _park()
_saeule() _saeule()
_navigation()
_menschen() _menschen()
_himmel_aktualisieren() _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: func _environment() -> void:
var we := WorldEnvironment.new() _we = WorldEnvironment.new()
var we := _we
_env = Environment.new() _env = Environment.new()
_env.background_mode = Environment.BG_SKY _env.background_mode = Environment.BG_SKY
var sky := Sky.new() var sky := Sky.new()
@@ -46,9 +80,22 @@ func _environment() -> void:
add_child(_sun) add_child(_sun)
const STADTRAND := 140.0 # unsichtbare Wand: hinter dem Bezirk endet die Welt
func _boden() -> void: func _boden() -> void:
Prims.box(self, Vector3(0, -0.53, 0), Vector3(300, 1, 300), Color(0.42, 0.48, 0.40)) Prims.box(self, Vector3(0, -0.53, 0), Vector3(300, 1, 300), Color(0.42, 0.48, 0.40))
_strassen() _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. ## Arterien breit, roji schmal -- beide als flaches Band mit Belag.
@@ -61,7 +108,9 @@ func _strassen() -> void:
continue continue
var breit := float(st["breite"]) 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 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) Vector3(breit, 0.06, d.length() + breit), farbe, false)
band.rotation.y = atan2(d.x, d.z) band.rotation.y = atan2(d.x, d.z)
@@ -84,7 +133,7 @@ func _bebauung() -> void:
if minf(g.x, g.y) < 3.0: if minf(g.x, g.y) < 3.0:
continue continue
var n := 1 + rng.randi_range(0, 3) 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), 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) 0.10 + rng.randf() * 0.14, 0.62 + rng.randf() * 0.26)
var bau := Node3D.new() 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 # 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)) Prims._add_box_body(klotz, Vector3(g.x + 0.9, hoehe, g.y + 0.9))
for f in range(n): 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), 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)) Vector3(g.x + 0.5, 0.4, g.y + 0.5), ton.darkened(0.25))
if rng.randf() < 0.35: if rng.randf() < 0.35:
Prims.box(bau, c + Vector3(0, hoehe + 0.9, 0), Prims.box(bau, c + Vector3(0, hoehe + 0.9, 0),
Vector3(g.x * 0.4, 1.0, g.y * 0.4), ton.darkened(0.1)) 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": [], _bauten.append({"c": c, "size": g, "hoch": hoehe, "teile": [bau], "waende": []})
"waende": [], "voll": true})
## Wohnungen und Sonderbauten: geschlossene Häuser mit Stockwerken, Treppenhaus ## Wohnungen und Sonderbauten: geschlossene Häuser, ein Raum mit Tür, Möbeln
## und Möbeln. Der Geist gleitet hinein, der Etagenschnitt öffnet sie ihm. ## und Spuren. Der Geist geht hinein, dann verschwinden Dach und Vorderwände.
func _orte() -> void: func _orte() -> void:
for key in HumanDB.PLACES: for key in HumanDB.PLACES:
var pl: Dictionary = HumanDB.PLACES[key] var pl: Dictionary = HumanDB.PLACES[key]
@@ -112,42 +160,32 @@ func _orte() -> void:
if float(s[0]) <= 0.0: if float(s[0]) <= 0.0:
continue continue
var c := Orte.center(HumanDB.PLACES, key) 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 farbe: Color = pl["color"]
var tuer: float = maxf(1.6, minf(3.0, minf(groesse.x, groesse.z) - 3.0)) var tuer: float = maxf(1.6, minf(3.0, minf(groesse.x, groesse.z) - 3.0))
var n := Orte.etagen(HumanDB.PLACES, key) var raum := Node3D.new()
var etagen: Array = [] add_child(raum)
var waende: Array = [] var waende: Array = Prims.room(raum, c, groesse, farbe, farbe.darkened(0.35),
for f in range(n): Orte.DIR_VEC[pl["dir"]], tuer, true)
var eb := Node3D.new() _fenster(raum, c, Vector2(groesse.x, groesse.z), farbe)
add_child(eb) _einrichtung(raum, c, groesse, farbe, key)
var cf := c + Vector3(0, Orte.hoehe(f), 0) var wer := HumanDB.bewohner_von(key)
var ton := farbe.lightened(0.06 * float(f)) if not wer.is_empty():
waende.append(Prims.room(eb, cf, groesse, ton, farbe.darkened(0.35), _spuren(raum, key, c, groesse, wer)
Orte.DIR_VEC[pl["dir"]] if f == 0 else Vector3.ZERO, tuer, f == 0)) var licht := OmniLight3D.new()
if f > 0: licht.position = c + Vector3(0, 2.2, 0)
_decke(eb, key, cf, groesse, farbe.darkened(0.35)) licht.omni_range = 12.0
_fenster(eb, cf, Vector2(groesse.x, groesse.z), ton) licht.light_color = Color(1.0, 0.85, 0.6)
_einrichtung(eb, cf, groesse, farbe, key if f == 0 or HumanDB.ETAGEN_ARBEIT.has(key) licht.light_energy = 0.0
else "heim_", key) raum.add_child(licht)
if f + 1 < n: _nachtlichter.append(licht)
_treppe(eb, key, f)
var licht := OmniLight3D.new()
licht.position = cf + Vector3(0, 2.2, 0)
licht.omni_range = 12.0
licht.light_color = Color(1.0, 0.85, 0.6)
licht.light_energy = 0.0
eb.add_child(licht)
_nachtlichter.append(licht)
etagen.append(eb)
var dach := Node3D.new() var dach := Node3D.new()
add_child(dach) 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)) 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, Prims.label(self, c + Vector3(0, HOEHE + 1.6, 0), str(pl["name"]), 0.55, Color(1, 1, 0.9))
Color(1, 1, 0.9)) _bauten.append({"c": c, "size": Vector2(groesse.x, groesse.z), "hoch": HOEHE,
_bauten.append({"c": c, "size": Vector2(groesse.x, groesse.z), "hoch": Orte.hoehe(n), "teile": [dach], "waende": waende})
"etagen": etagen, "dach": [dach], "waende": waende})
## Ein Fensterband je Wand, damit man die Stockwerke von außen zählt. ## 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) 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 ## 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 ## Rahmen der Tür (quer / zur Tür hin); was in der Türzone stünde, bleibt weg.
## 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) -> void:
func _einrichtung(parent: Node, c: Vector3, groesse: Vector3, farbe: Color, key: String, var ort := key
ort: String = "") -> void:
if ort == "":
ort = key
var d: Vector3 = Orte.DIR_VEC[HumanDB.PLACES[ort]["dir"]] 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 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 hq: float = (groesse.x if absf(d.z) > 0.0 else groesse.z) * 0.5
var w := maxf(0.5, hq - 2.0) var w := maxf(0.5, hq - 2.0)
var h := maxf(0.5, hd - 2.0) var h := maxf(0.5, hd - 2.0)
if key.begins_with("heim_"): 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), _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)) 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), 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), _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)) Color(0.2, 0.25, 0.3))
elif key == "cafe": 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)) 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 ## 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, 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 d: Vector3 = Orte.DIR_VEC[HumanDB.PLACES[ort]["dir"]]
var quer := Vector3(-d.z, 0, d.x) var quer := Vector3(-d.z, 0, d.x)
var pos := c + quer * aq + d * ad var pos := c + quer * aq + d * ad
pos.y = c.y + y pos.y = c.y + y
var s: Array = HumanDB.PLACES[ort]["size"] 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 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 -- Kleinkram darf näher heran
# Türzone: 2,2 tief hinter der Tür, 4 breit 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 < 2.0: if ad + mass.z * 0.5 > hd - 2.2 and absf(aq) - mass.x * 0.5 < rand:
return false return null
# 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
var welt_mass := mass if absf(d.z) > 0.0 else Vector3(mass.z, mass.y, mass.x) var welt_mass := mass if absf(d.z) > 0.0 else Vector3(mass.z, mass.y, mass.x)
if rund: if rund:
Prims.cyl(parent, pos, mass.x * 0.5, mass.y, farbe) return Prims.cyl(parent, pos, mass.x * 0.5, mass.y, farbe, true, glanz)
else: return Prims.box(parent, pos, welt_mass, farbe, true, glanz)
Prims.box(parent, pos, welt_mass, farbe)
return true
## Der mittlere Block bleibt frei: Rasen, Teich, Bäume, Bänke. Der Park ist ## 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()): for i in range(HumanDB.LIST.size()):
var m := Bewohner.new() var m := Bewohner.new()
add_child(m) 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) 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: func _process(_delta: float) -> void:

View File

@@ -33,6 +33,9 @@ var ueberwunden: Dictionary = {} # human_id -> {problem -> true}
var gesehen_zaehler: Dictionary = {} # "human:problem" -> wie oft dabeigewesen var gesehen_zaehler: Dictionary = {} # "human:problem" -> wie oft dabeigewesen
var completed: Array = [] var completed: Array = []
var markiert: Dictionary = {} # human_id -> true: wer gerade ein Schild trägt 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 time_h: float = 7.0
var day: int = 1 var day: int = 1
var teleport_lock: float = 0.0 var teleport_lock: float = 0.0
@@ -151,6 +154,7 @@ func insel_radius() -> float:
func welt_erzeugen() -> void: func welt_erzeugen() -> void:
DreamDB.erzeuge(haeuser()) DreamDB.erzeuge(haeuser())
HumanDB.erzeuge() HumanDB.erzeuge()
welt_version += 1
## Das Traumdorf beginnt leer. Mit jedem erfüllten Traum zieht ein Wesen ein. ## 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 ## Der Zustand einer Stunde. HUD und Panel zeichnen denselben -- zwei
## Auslegungen würden mit der Zeit auseinanderlaufen. ## 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: func slot_zustand(human_id: String, nr: int) -> int:
if not gesehen_slot(human_id, nr): 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 return Stunde.UNBEKANNT
if not system_in(human_id, nr).is_empty(): if not system_in(human_id, nr).is_empty():
return Stunde.GEBAUT return Stunde.GEBAUT
@@ -405,6 +413,7 @@ func notieren(human_id: String, nr: int) -> String:
if not Ursachen.ist_blocker(grund): if not Ursachen.ist_blocker(grund):
notiz_sperre = NOTIZ_SPERRE notiz_sperre = NOTIZ_SPERRE
notified.emit("Nichts schief: %s" % Orte.text(grund), Color(1, 0.75, 0.55)) 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" return "daneben"
if erkannt(human_id, nr): if erkannt(human_id, nr):
notified.emit("Schon notiert: %s" % Orte.text(grund), Color(0.8, 0.85, 1)) 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 erkannt_slots[human_id][nr] = true
notified.emit("Erkannt: %s — %s" % [Ursachen.BLOCKER[grund]["name"], notified.emit("Erkannt: %s — %s" % [Ursachen.BLOCKER[grund]["name"],
Ursachen.LISTE[Ursachen.ursache_von(grund)]["name"]], Color(0.8, 0.95, 1)) 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() state_changed.emit()
return "treffer" return "treffer"
## Eine Ursache gilt als erkannt, sobald eine ihrer Tätigkeiten aufgedeckt ist. ## Eine Ursache gilt als erkannt, sobald eine ihrer Tätigkeiten aufgedeckt ist.
## Nur eine Quelle -- sonst driften Slot und Ursache auseinander. ## 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: 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, {}): for nr in erkannt_slots.get(human_id, {}):
if Ursachen.ursache_von(slot_grund(human_id, int(nr))) == ursache: if Ursachen.ursache_von(slot_grund(human_id, int(nr))) == ursache:
return true 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"))) var guete := float(pruef["guete"]) * (1.0 + 0.1 * float(verhalten(human_id, "guete")))
systeme[human_id][nr] = {"tech": tech_id, "guete": guete} systeme[human_id][nr] = {"tech": tech_id, "guete": guete}
add_xp(tech_id, 2) 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)) [TechDB.by_id(tech_id)["name"], int(slots(human_id)[nr][0])], Color(0.6, 1, 0.7))
state_changed.emit() state_changed.emit()
return {"ok": true, "erfolg": true, "guete": float(pruef["guete"])} return {"ok": true, "erfolg": true, "guete": float(pruef["guete"])}
@@ -699,7 +756,7 @@ func situation_anwenden(tech_id: String) -> Dictionary:
ueberwunden[id] = {} ueberwunden[id] = {}
ueberwunden[id][problem] = true ueberwunden[id][problem] = true
res["ueberwunden"] = 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() state_changed.emit()
return res return res

View File

@@ -9,6 +9,7 @@ class_name HumanDB
## an da und leben ihren Tag. ## an da und leben ihren Tag.
const EINWOHNER := 16 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 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 ACHSEN := [-72.0, -24.0, 24.0, 72.0] # Arterien in x wie in z
const ARTERIE := 7.0 # Breite einer Arterie 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], ["baeckerei", "Bäckerei Keller", Color(0.85, 0.72, 0.50), 2],
["werkstatt", "Werkstatt Loos", Color(0.70, 0.58, 0.55), 1], ["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 PLACES: Dictionary = {}
static var LIST: Array = [] static var LIST: Array = []
static var PASSANTEN_LISTE: Array = []
## Arbeitsplätze, die ein erzeugter Mensch bekommen kann. ## Arbeitsplätze, die ein erzeugter Mensch bekommen kann.
static var ARBEIT: Array = ["uni", "cafe", "buero", "baeckerei", "werkstatt"] static var ARBEIT: Array = ["uni", "cafe", "buero", "baeckerei", "werkstatt"]
@@ -200,7 +199,11 @@ static func erzeuge(_ungenutzt: int = 0) -> void:
_park_eintragen() _park_eintragen()
_sonderbauten_verteilen() _sonderbauten_verteilen()
_wohnhaeuser_verteilen() _wohnhaeuser_verteilen()
_ecken_eintragen()
_anker_setzen() _anker_setzen()
PASSANTEN_LISTE = []
for i in range(PASSANTEN):
PASSANTEN_LISTE.append(_passant(i))
var eigene := Aushaenge.laden() var eigene := Aushaenge.laden()
var n_eigen := mini(eigene.size(), EINWOHNER) var n_eigen := mini(eigene.size(), EINWOHNER)
for i in range(EINWOHNER - n_eigen): 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) 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 # ------------------------------------------------------------ Belegung
static func _park_eintragen() -> void: static func _park_eintragen() -> void:
@@ -455,13 +500,12 @@ static func _sonderbauten_verteilen() -> void:
"size": [p["size"].x, p["size"].y], "size": [p["size"].x, p["size"].y],
"dir": p["dir"], "dir": p["dir"],
"color": SONDER[s][2], "color": SONDER[s][2],
"etagen": int(SONDER[s][3]),
} }
## Die Wohnhäuser: gleichmäßig über den Bezirk verteilt, damit die Wege ## Die Wohnhäuser: gleichmäßig über den Bezirk verteilt, damit die Wege
## nicht alle in derselben Ecke enden. ## 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: static func _wohnhaeuser_verteilen() -> void:
var frei: Array = [] var frei: Array = []
@@ -487,21 +531,9 @@ static func _wohnhaeuser_verteilen() -> void:
"size": [p["size"].x, p["size"].y], "size": [p["size"].x, p["size"].y],
"dir": p["dir"], "dir": p["dir"],
"color": Color.from_hsv(fmod(0.08 + float(i) * 0.113, 1.0), 0.28, 0.78), "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: static func _anker_setzen() -> void:
for key in PLACES: for key in PLACES:
var s: Array = PLACES[key]["size"] var s: Array = PLACES[key]["size"]
@@ -635,7 +667,6 @@ static func mensch_aus_profil(pr: Dictionary, i: int) -> Dictionary:
"eigen": true, "eigen": true,
"mission": true, "mission": true,
"heim": heim, "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), "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"], "routine": _tag_belegen(TAGESTYPEN[typ], heim, arbeit, bild["blocker"],
bild["ursachen"], i), bild["ursachen"], i),
@@ -678,7 +709,6 @@ static func _mensch(i: int) -> Dictionary:
"verhalten": {}, "verhalten": {},
"mission": true, "mission": true,
"heim": heim, "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), "farbe": Color.from_hsv(fmod(0.02 + float(i) * 0.191, 1.0), 0.5, 0.8),
"routine": _tagesablauf(i, heim, arbeit), "routine": _tagesablauf(i, heim, arbeit),
} }
@@ -710,6 +740,13 @@ static func _nachname(i: int) -> String:
return str(NACHNAMEN[(i / VORNAMEN.size() + i) % NACHNAMEN.size()]) 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: static func by_id(id: String) -> Dictionary:
for h in LIST: for h in LIST:
if h["id"] == id: if h["id"] == id:

View File

@@ -7,7 +7,11 @@ const REICHWEITE_MENSCH := 3.6
var player: Player var player: Player
var ui: UI 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 auf_erde := false
var _fokus: Node3D = null var _fokus: Node3D = null
var _fokus_mensch: Node3D = null var _fokus_mensch: Node3D = null
@@ -20,6 +24,7 @@ func _ready() -> void:
player.look_enabled = false) player.look_enabled = false)
ui.panel_closed.connect(func() -> void: ui.panel_closed.connect(func() -> void:
player.look_enabled = true) player.look_enabled = true)
ui.hinfahren.connect(hinfahren)
player = Player.new() player = Player.new()
add_child(player) add_child(player)
@@ -34,28 +39,86 @@ func _ready() -> void:
func _traumwelt() -> void: func _traumwelt() -> void:
auf_erde = false 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.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 player.yaw = Player.CAM_YAW
func _erde() -> void: func _erde() -> void:
auf_erde = true 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.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 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: ## Vor die Tür eines Menschen setzen -- steht er draußen, neben ihn. Der Fußweg
if welt != null: ## quer durch den Bezirk war Leerlauf; das Gehen zählt in der Wohnung und beim Folgen.
remove_child(welt) func zum_ziel(id: String, melden: bool = true) -> void:
welt.queue_free() 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 welt = neu
add_child(welt)
move_child(welt, 0)
welt.set("spieler", player) # für den Etagenschnitt
Game.teleport_lock = 1.0 Game.teleport_lock = 1.0
_fokus = null _fokus = null
_fokus_mensch = null _fokus_mensch = null
@@ -115,6 +178,8 @@ func _fokus_suchen() -> void:
var p := player.global_position var p := player.global_position
var best := REICHWEITE_OBJEKT var best := REICHWEITE_OBJEKT
for n in get_tree().get_nodes_in_group("interactable"): 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) var d: float = p.distance_to((n as Node3D).global_position)
if d < best: if d < best:
best = d best = d
@@ -123,8 +188,6 @@ func _fokus_suchen() -> void:
var bestm := REICHWEITE_MENSCH var bestm := REICHWEITE_MENSCH
for m in (welt as EarthWorld).humans: for m in (welt as EarthWorld).humans:
var mp: Vector3 = (m as Node3D).global_position + Vector3(0, 1.0, 0) var mp: Vector3 = (m as Node3D).global_position + Vector3(0, 1.0, 0)
if absf(mp.y - p.y) > 2.0:
continue # andere Etage: durch die Decke sieht man nichts
var d: float = p.distance_to(mp) var d: float = p.distance_to(mp)
if d < bestm: if d < bestm:
bestm = d bestm = d
@@ -156,6 +219,10 @@ func _hinweis_setzen() -> void:
ui.setze_hinweis("[E] %s — Techniken lernen" % titel) ui.setze_hinweis("[E] %s — Techniken lernen" % titel)
"brett": "brett":
ui.setze_hinweis("[E] Schwarzes Brett — Mission annehmen") 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) ui.setze_hinweis("[E] %s" % titel)
else: else:
@@ -298,3 +365,12 @@ func _interagieren() -> void:
ui.lehre_panel(str(_fokus.get_meta("arg"))) ui.lehre_panel(str(_fokus.get_meta("arg")))
"brett": "brett":
ui.brett_panel() 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))

View File

@@ -48,220 +48,6 @@ static func kennt(a: String) -> bool:
return ACTIVITY_TEXT.has(a) or Ursachen.ist_blocker(a) 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: static func center(places: Dictionary, key: String) -> Vector3:
var p: Array = places[key]["pos"] var p: Array = places[key]["pos"]
return Vector3(p[0], 0.0, p[1]) 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 size: Array = pl["size"]
var half: float = size[1] * 0.5 if absf(d.z) > 0.0 else size[0] * 0.5 var half: float = size[1] * 0.5 if absf(d.z) > 0.0 else size[0] * 0.5
return c + d * (half + 2.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

View File

@@ -11,7 +11,6 @@ const GRAVITY := 24.0
const JUMP := 9.0 const JUMP := 9.0
const FOLGE_ABSTAND := 2.5 # so weit bleibt der Geist auf der Spur zurück 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 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 SCHWEBE := 0.25 # eine Handbreit über dem Boden
const PITCH := -0.96 # 55° von oben, fest wie in Diablo const PITCH := -0.96 # 55° von oben, fest wie in Diablo
const CAM_YAW := PI * 0.25 # Blick diagonal über den Bezirk const CAM_YAW := PI * 0.25 # Blick diagonal über den Bezirk
@@ -29,9 +28,12 @@ var arm: Node3D
var cam: Camera3D var cam: Camera3D
var _dreh_rest := 0.0 # noch zu drehender Winkel (Q) var _dreh_rest := 0.0 # noch zu drehender Winkel (Q)
var _zoom_stufe := 0 var _zoom_stufe := 0
var _anlauf: Array = [] # Weg zu dem Menschen, an den man sich gehängt hat var _leine_pfad: Array = [] # Pfad zum Verfolgten, alle 0,4 s neu
var _stockt := 0.0 # Sekunden ohne Vorankommen var _leine_uhr := 0.0
var spruenge := 0 # wie oft die Leine nachziehen musste 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 body: Node3D
var _schleier: MeshInstance3D var _schleier: MeshInstance3D
var _kern: MeshInstance3D var _kern: MeshInstance3D
@@ -111,8 +113,7 @@ func _ready() -> void:
func set_mode(m: int) -> void: func set_mode(m: int) -> void:
mode = m mode = m
folgt = null folgt = null
collision_layer = 1 collision_layer = 1 # die Maske stellt main je Welt: jede Welt hat ihre Ebene
collision_mask = 1
if m == Mode.GEIST: if m == Mode.GEIST:
body.scale = Vector3.ONE * 0.6 body.scale = Vector3.ONE * 0.6
_schleier_mat.albedo_color.a = 0.3 _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 -- ## Wer mit gedrückter Taste zwei Sekunden nicht vorankommt, steckt fest --
## dann setzt ihn die Stadt auf die nächste Straße. ## dann setzt ihn die Stadt auf die nächste Straße.
func _eingeklemmt(delta: float, will: bool) -> void: 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 _stockt = 0.0
return return
_stockt += delta _stockt += delta
@@ -210,37 +215,39 @@ func _fallen(delta: float) -> void:
velocity.y -= GRAVITY * delta velocity.y -= GRAVITY * delta
## An einen Menschen hängen. Steht er weit weg, wird erst der Weg zu ihm ## An einen Menschen hängen. Den Weg zu ihm findet das Navigationsnetz.
## geplant: aus dem Haus, über die Straßen, in seine Wohnung.
func anhaengen(m: Bewohner) -> void: func anhaengen(m: Bewohner) -> void:
folgt = m folgt = m
_anlauf = [] _leine_pfad = []
_stockt = 0.0 _leine_uhr = 0.0
if global_position.distance_to(m.global_position) > FOLGE_NAH: _verloren = 0.0
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)
## Folgen heißt: seiner Spur nachgehen, durch dieselben Türen und Treppen. ## Folgen heißt: ihm nachgehen, durch dieselben Türen und Treppen. Der Pfad
## Er läuft so schnell wie nötig, um dranzubleiben -- auch bei hohem Tempo. ## 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: func _an_der_leine(delta: float) -> void:
var mensch := folgt as Bewohner var mensch := folgt as Bewohner
var ziel: Vector3 _leine_uhr -= delta
if not _anlauf.is_empty(): if _leine_uhr <= 0.0 or _leine_pfad.is_empty():
ziel = _anlauf[0] _leine_uhr = 0.4
var bis := ziel - global_position _leine_pfad = []
bis.y = 0.0 if mensch.nav_map.is_valid():
if bis.length() < 0.6 and absf(ziel.y - global_position.y) < 1.6: var pts := NavigationServer3D.map_get_path(mensch.nav_map, global_position,
_anlauf.pop_front() mensch.global_position, true)
if _anlauf.is_empty(): for i in range(1, pts.size()):
ziel = mensch.spur_punkt(FOLGE_ABSTAND) _leine_pfad.append(pts[i])
else: if pts.size() < 2:
ziel = _anlauf[0] 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 var d := ziel - global_position
d.y = 0.0 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) 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) var v := d.normalized() * minf(tempo, d.length() / delta)
velocity.x = v.x velocity.x = v.x
velocity.z = v.z velocity.z = v.z
@@ -250,16 +257,13 @@ func _an_der_leine(delta: float) -> void:
velocity.z = 0.0 velocity.z = 0.0
_fallen(delta) _fallen(delta)
move_and_slide() move_and_slide()
if d.length() > 0.6 and Vector2(velocity.x, velocity.z).length() < 0.3: # Verloren? Nur wenn das Netz keinen Weg weiß und er zu weit weg ist,
_stockt += delta # wird er nachgezogen -- ein Notnagel, kein Spielzug.
else: _verloren = _verloren + delta if abstand > LEINE and _leine_pfad.is_empty() else 0.0
_stockt = 0.0 if _verloren > 3.0:
var zu_weit: bool = _anlauf.is_empty() \ global_position = mensch.global_position + Vector3(0, 0.3, 0)
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
velocity = Vector3.ZERO velocity = Vector3.ZERO
_stockt = 0.0 _verloren = 0.0
spruenge += 1 spruenge += 1

View File

@@ -139,14 +139,11 @@ static func room(parent: Node, center: Vector3, size: Vector3, wall_c: Color,
static func _meshes_von(b: Dictionary) -> Array: static func _meshes_von(b: Dictionary) -> Array:
var out: Array = [] var out: Array = []
for n in b["etagen"]: for n in b["teile"]:
_sammle(n, out) _sammle(n, out)
for n in b["dach"]: for w in b["waende"]:
_sammle(n, out) if not out.has(w):
for liste in b["waende"]: out.append(w)
for w in liste:
if not out.has(w):
out.append(w)
return out return out
@@ -162,12 +159,10 @@ static func _wand(mi: MeshInstance3D, normale: Vector3) -> MeshInstance3D:
return mi return mi
## Etagenschnitt wie bei den Sims: steht der Spieler in einem Bau, verschwinden ## Schnitt wie bei den Sims: steht der Spieler in einem Bau, verschwinden Dach
## das Dach, die Etagen über ihm und die beiden Wände, die zur Kamera zeigen. ## (bei Klötzen der ganze Klotz) und die beiden Wände, die zur Kamera zeigen.
## Ein Bau: {c, size, hoch, etagen, dach, waende, voll}. "voll" heißt: ein ## Ein Bau: {c, size, hoch, teile, waende}. Ein Bau zwischen Kamera und
## geschlossener Klotz, der ganz verschwindet. ## Spieler wird durchsichtig, sonst verschwände der Spieler hinter jedem Nachbarhaus.
## 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: static func schnitt(bauten: Array, spieler: Vector3, kamera: Vector3) -> void:
for b in bauten: for b in bauten:
var c: Vector3 = b["c"] var c: Vector3 = b["c"]
@@ -188,17 +183,11 @@ static func schnitt(bauten: Array, spieler: Vector3, kamera: Vector3) -> void:
(mi as GeometryInstance3D).cast_shadow = \ (mi as GeometryInstance3D).cast_shadow = \
GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if im_weg \ GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if im_weg \
else GeometryInstance3D.SHADOW_CASTING_SETTING_ON else GeometryInstance3D.SHADOW_CASTING_SETTING_ON
var etagen: Array = b["etagen"] for t in b["teile"]:
var pf := clampi(int(floor((spieler.y - c.y + 0.6) / Orte.ETAGE)), 0, maxi(0, etagen.size() - 1)) (t as Node3D).visible = not drin
for f in range(etagen.size()): for w in b["waende"]:
(etagen[f] as Node3D).visible = not drin or (f <= pf and not bool(b.get("voll", false))) var n: Vector3 = w.get_meta("normal")
for d in b["dach"]: (w as Node3D).visible = not (drin and n.dot(kamera - c) > 0.0)
(d as Node3D).visible = not drin
var waende: Array = b["waende"]
for f in range(waende.size()):
for w in waende[f]:
var n: Vector3 = w.get_meta("normal")
(w as Node3D).visible = not (drin and f == pf and n.dot(kamera - c) > 0.0)
## Ein Satteldach: ein geschlossenes Prisma über den Wänden. Der First läuft ## Ein Satteldach: ein geschlossenes Prisma über den Wänden. Der First läuft

View File

@@ -6,6 +6,7 @@ class_name UI
## der Mitte für Orte. Die Zeitleiste unten ist der Tag des Menschen -- alles ## der Mitte für Orte. Die Zeitleiste unten ist der Tag des Menschen -- alles
## im Spiel hängt daran. ## im Spiel hängt daran.
signal hinfahren(id: String)
signal panel_opened signal panel_opened
signal panel_closed 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_GESEHEN := Color(0.46, 0.50, 0.62)
const C_SCHLAF := Color(0.30, 0.33, 0.44) const C_SCHLAF := Color(0.30, 0.33, 0.44)
const C_WARN := Color(0.85, 0.66, 0.32) 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_BLOCKER := Color(0.84, 0.42, 0.35)
const C_SYSTEM := Color(0.44, 0.78, 0.50) 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 offen := false
var _kind := "" var _kind := ""
@@ -299,6 +307,8 @@ func _ablauf_farbe(human_id: String, nr: int) -> Color:
return C_BLOCKER return C_BLOCKER
Game.Stunde.SCHLAF: Game.Stunde.SCHLAF:
return C_SCHLAF return C_SCHLAF
Game.Stunde.VERDAECHTIG:
return C_VERDAECHTIG
return C_GESEHEN return C_GESEHEN
@@ -310,20 +320,24 @@ func _ablauf_aktualisieren() -> void:
return return
var jetzt := int(Game.time_h) var jetzt := int(Game.time_h)
var offen_std := 0 var offen_std := 0
var verdaechtig := 0
var puls := 0.5 + 0.5 * sin(Time.get_ticks_msec() * 0.008) var puls := 0.5 + 0.5 * sin(Time.get_ticks_msec() * 0.008)
for stunde in range(24): for stunde in range(24):
var nr := Game.slot_bei_stunde(id, stunde) var nr := Game.slot_bei_stunde(id, stunde)
var f := _ablauf_farbe(id, nr) var f := _ablauf_farbe(id, nr)
if not Game.gesehen_slot(id, nr): if not Game.gesehen_slot(id, nr):
offen_std += 1 offen_std += 1
if Game.slot_zustand(id, nr) == Game.Stunde.VERDAECHTIG:
verdaechtig += 1
var zelle: ColorRect = _zellen[stunde] var zelle: ColorRect = _zellen[stunde]
if stunde == jetzt: if stunde == jetzt:
f = f.lightened(0.55 if not Game.situation_bei(id) else 0.3 + 0.4 * puls) f = f.lightened(0.55 if not Game.situation_bei(id) else 0.3 + 0.4 * puls)
zelle.color = f zelle.color = f
zelle.custom_minimum_size.y = 24 if stunde == jetzt else 16 zelle.custom_minimum_size.y = 24 if stunde == jetzt else 16
zelle.tooltip_text = "%02d Uhr — %s" % [stunde, _stunden_text(id, nr)] zelle.tooltip_text = "%02d Uhr — %s" % [stunde, _stunden_text(id, nr)]
_ablauf_titel.text = "Ihr Tag 0 – 23 Uhr%s" % \ _ablauf_titel.text = "Ihr Tag 0 – 23 Uhr%s%s" % [
("" if offen_std == 0 else " · %d Stunden unbekannt" % offen_std) "" if offen_std == 0 else " · %d Stunden unbekannt" % offen_std,
"" if verdaechtig == 0 else " · %d verdächtig" % verdaechtig]
var urs: Array = [] var urs: Array = []
for u in Game.ursachen_von(id): for u in Game.ursachen_von(id):
@@ -414,7 +428,7 @@ func _baue_wirte() -> void:
func _wirt_fuer(kind: String) -> PanelContainer: func _wirt_fuer(kind: String) -> PanelContainer:
match kind: match kind:
"journal", "technik", "systeme": "journal", "technik", "akte":
return _drawer return _drawer
"slot", "situation", "anwenden": "slot", "situation", "anwenden":
return _karte_host return _karte_host
@@ -609,7 +623,7 @@ func _reiter() -> void:
_content.add_child(hb) _content.add_child(hb)
var seiten := [ var seiten := [
["techniken", "Techniken (%d)" % Game.known.size()], ["techniken", "Techniken (%d)" % Game.known.size()],
["missionen", "Missionen (%d)" % Game.progress.size()], ["missionen", "Akten (%d)" % Game.progress.size()],
] ]
for eintrag in seiten: for eintrag in seiten:
var b := Button.new() var b := Button.new()
@@ -675,44 +689,145 @@ func _journal_missionen() -> void:
stand = "aktiv · %d %%" % int(Game.mission_progress(id)) stand = "aktiv · %d %%" % int(Game.mission_progress(id))
farbe = C_AKZENT farbe = C_AKZENT
_karte("%s · %s" % [h["name"], stand], farbe, _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: func systeme_panel(human_id: String) -> void:
var scroll_alt := _scroll.scroll_vertical if offen and _kind == "systeme" else 0 akte_panel(human_id)
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()
_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): for u in Game.ursachen_von(human_id):
var name: String = str(u) var name: String = str(u)
if not Game.ursache_bekannt(human_id, name): var stand: String
_text("[color=#8e93a8]? — noch nicht erkannt[/color]", 15) if Game.ursache_geloest(human_id, name) > 0.0:
offene += 1 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 continue
var g := Game.ursache_geloest(human_id, name) _akte_text("[b]%s[/b] %s" % [Ursachen.LISTE[name]["name"], stand], 15)
var stand := "[color=#88ff99]gelöst[/color]" if g > 0.0 else \ var probleme: Array = []
"[color=#ff9977]offen · bremst auf %d %%[/color]" % \ for pr in Game.probleme_von(human_id):
int(float(Ursachen.LISTE[name]["faktor"]) * 100.0) var key := str(pr)
_text("[b]%s[/b] %s" % [Ursachen.LISTE[name]["name"], stand], 15) if not Game.problem_bekannt(human_id, key):
if offene > 0: _trenner() 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) _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_wunsch = scroll_alt
_scroll_frames = 4 _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 ## 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. ## Wochentage. Zu Beginn ist jede Stunde leer; wer dabeisteht, füllt sie.
func _tagesablauf(human_id: String) -> void: func _tagesablauf(human_id: String) -> void:
@@ -720,8 +835,8 @@ func _tagesablauf(human_id: String) -> void:
for stunde in range(24): for stunde in range(24):
if not Game.gesehen_slot(human_id, Game.slot_bei_stunde(human_id, stunde)): if not Game.gesehen_slot(human_id, Game.slot_bei_stunde(human_id, stunde)):
offen += 1 offen += 1
_text("[b]Ihr Tag[/b]%s" % if offen > 0:
("" if offen == 0 else " [color=#8e93a8]%d Stunden leer[/color]" % offen), 17) _akte_text("[color=%s]%d Stunden leer[/color]" % [TINTE_HELL, offen], 14)
# eine Zeile je Abschnitt des Tages -- auch wenn noch keiner bekannt ist, # eine Zeile je Abschnitt des Tages -- auch wenn noch keiner bekannt ist,
# steht der Tag vollständig da # 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 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) \ var offen: bool = Game.gesehen_slot(human_id, nr) and Game.erkannt(human_id, nr) \
and Game.slot_grund(human_id, nr) != "schlaeft" and Game.slot_grund(human_id, nr) != "schlaeft"
_karte("%s %s" % [spanne, text], _ablauf_farbe(human_id, nr), _akte_karte("%s %s" % [spanne, text], _ablauf_farbe(human_id, nr),
func() -> void: slot_panel(human_id, nr, systeme_panel.bind(human_id)), offen) 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. ## 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" % _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) [b["name"], b["ausloeser"], Ursachen.belohnung_text(str(b["belohnung"]))], 16)
else: else:
_text("Hier ist Platz. Ein neues System passt in diesen Slot.", 16) _text("Hier ist Platz für eine neue Routine.", 16)
_trenner() _trenner()
var sys := Game.system_in(human_id, nr) var sys := Game.system_in(human_id, nr)
if not sys.is_empty(): if not sys.is_empty():
_text("[color=#88ff99]Hier steht: %s[/color]" % _text("[color=#88ff99]Hier steht: %s[/color]" %
TechDB.by_id(str(sys["tech"]))["name"], 16) 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) Game.system_entfernen(human_id, nr)
systeme_panel(human_id)) systeme_panel(human_id))
_trenner() _trenner()
@@ -1043,7 +1158,7 @@ func _vorschau_aktualisieren() -> void:
var wert := int(bild["verhalten"][v]) var wert := int(bild["verhalten"][v])
if wert != 0: if wert != 0:
zuege.append("%s %s" % [namen[v], "+" if wert > 0 else "−"]) 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(urs), " · ".join(prob), (bild["blocker"] as Array).size(),
" ".join(zuege) if not zuege.is_empty() else "unauffällig"] " ".join(zuege) if not zuege.is_empty() else "unauffällig"]

View File

@@ -10,26 +10,38 @@ class_name Ursachen
## bemerken, dann ersetzen. Und Duhiggs goldene Regel: Auslöser und Belohnung ## bemerken, dann ersetzen. Und Duhiggs goldene Regel: Auslöser und Belohnung
## bleiben, nur die Handlung dazwischen wird getauscht. ## 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 = { static var LISTE: Dictionary = {
"anfang": { "anfang": {
"name": "Kein Anfang", "kurz": "kommt nie in Gang", "name": "Kein Anfang", "kurz": "kommt nie in Gang",
"faktor": 0.30, "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": { "zeit": {
"name": "Keine Zeit", "kurz": "der Tag ist voll", "name": "Keine Zeit", "kurz": "der Tag ist voll",
"faktor": 0.30, "faktor": 0.30,
"spur": "Vollgeschriebener Kalender",
"spur_text": "Jede Stunde im Kalender ist belegt. Für den Traum steht keine drin.",
}, },
"kraft": { "kraft": {
"name": "Keine Kraft", "kurz": "abends ist nichts mehr übrig", "name": "Keine Kraft", "kurz": "abends ist nichts mehr übrig",
"faktor": 0.40, "faktor": 0.40,
"spur": "Leere Dosen am Bett",
"spur_text": "Drei leere Dosen neben dem Bett. Abends ist nichts mehr übrig.",
}, },
"halt": { "halt": {
"name": "Kein Halt", "kurz": "fängt an und hört wieder auf", "name": "Kein Halt", "kurz": "fängt an und hört wieder auf",
"faktor": 0.50, "faktor": 0.50,
"spur": "Stapel ungeöffneter Briefe",
"spur_text": "Ein Stapel ungeöffneter Briefe. Angefangen und liegen gelassen.",
}, },
"grund": { "grund": {
"name": "Kein Grund", "kurz": "glaubt nicht mehr daran", "name": "Kein Grund", "kurz": "glaubt nicht mehr daran",
"faktor": 0.40, "faktor": 0.40,
"spur": "Verstaubter Koffer",
"spur_text": "Ein Koffer voller alter Pläne, längst verstaubt. Daran glaubt hier niemand mehr.",
}, },
} }

64
tests/flimmer.gd Normal file
View File

@@ -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

6
tests/flimmer.tscn Normal file
View File

@@ -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")

37
tests/rauch.gd Normal file
View File

@@ -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

6
tests/rauch.tscn Normal file
View File

@@ -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")

View File

@@ -12,6 +12,22 @@ if ! timeout 90 godot --headless --path . tests/parse.tscn > "$log/parse.log" 2>
fi fi
timeout 600 godot --headless --path . tests/verify.tscn -- "$@" > "$log/suite.log" 2>&1 timeout 600 godot --headless --path . tests/verify.tscn -- "$@" > "$log/suite.log" 2>&1
code=$? 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")" echo "OK: $(grep -c ' OK ' "$log/suite.log")"
grep -E "FEHLT|Fehlende|SCRIPT ERROR|Parse Error|Langsamste|Schnelllauf" "$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) fehler=$(grep -E "^(SCRIPT )?ERROR:" "$log/suite.log" | grep -v "leaked" | sort | uniq -c | sort -rn)

View File

@@ -8,7 +8,7 @@ var main: Node3D
const ZEITRAFFER := 8.0 # die Engine läuft im Test achtmal schneller const ZEITRAFFER := 8.0 # die Engine läuft im Test achtmal schneller
## Abschnitte, die ganze Tage oder viele Läufe simulieren -- im Schnelllauf übersprungen. ## Abschnitte, die ganze Tage oder viele Läufe simulieren -- im Schnelllauf übersprungen.
const LANGSAM := ["test_tagesablauf", "test_verfolgung", "test_erster_traum_schaffbar", 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 schnell := false
var _uhr_abschnitt := 0 var _uhr_abschnitt := 0
var _dauern: Array = [] var _dauern: Array = []
@@ -46,6 +46,16 @@ func lauf(name: String) -> void:
if schnell and LANGSAM.has(name): if schnell and LANGSAM.has(name):
_uebersprungen += 1 _uebersprungen += 1
return 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() await Callable(self, name).call()
@@ -71,6 +81,12 @@ func _ready() -> void:
await lauf("test_hoersaal") await lauf("test_hoersaal")
await lauf("test_mission_annehmen") await lauf("test_mission_annehmen")
await lauf("test_reise_zur_erde") 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_bezirk")
await lauf("test_tagesablauf") await lauf("test_tagesablauf")
await lauf("test_wege_machbar") await lauf("test_wege_machbar")
@@ -78,6 +94,7 @@ func _ready() -> void:
await lauf("test_verfolgung") await lauf("test_verfolgung")
await lauf("test_charakter") await lauf("test_charakter")
await lauf("test_kamera_und_etagen") await lauf("test_kamera_und_etagen")
await lauf("test_zufallslauf")
await lauf("test_gehen_bei_tempo") await lauf("test_gehen_bei_tempo")
await lauf("test_diagnose") await lauf("test_diagnose")
await lauf("test_notieren_im_spiel") 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: func _panel_text() -> String:
var out: Array = [] var out: Array = []
for c in ui()._content.get_children(): for c in _alle_kinder(ui()._content):
if c is RichTextLabel: if c is RichTextLabel:
out.append((c as RichTextLabel).get_parsed_text()) out.append((c as RichTextLabel).get_parsed_text())
return "\n".join(out) return "\n".join(out)
@@ -193,21 +210,21 @@ func _hingehen(ziel: Node3D) -> void:
## Drückt den ersten aktiven Knopf, dessen Beschriftung so beginnt. ## Drückt den ersten aktiven Knopf, dessen Beschriftung so beginnt.
func _knopf_texte() -> String: func _knopf_texte() -> String:
var out: Array = [] var out: Array = []
for c in ui()._content.get_children(): for c in _alle_kinder(ui()._content):
if c is Button: if c is Button:
out.append((c as Button).text) out.append((c as Button).text)
return "\n".join(out) return "\n".join(out)
func _knopf_da(anfang: String) -> bool: 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): if c is Button and (c as Button).text.begins_with(anfang):
return true return true
return false return false
func _druecke(anfang: String) -> bool: 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): if c is Button and not (c as Button).disabled and (c as Button).text.begins_with(anfang):
(c as Button).pressed.emit() (c as Button).pressed.emit()
return true return true
@@ -420,6 +437,10 @@ func test_traumdorf() -> void:
if w.place == r[0]: if w.place == r[0]:
zuhause += 1 zuhause += 1
p(zuhause == 4, "Um 01:00 sind alle vier an ihrem Nachtplatz (%d)" % zuhause) 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 # Ausgangslage wiederherstellen: das Dorf ist wieder leer
Game.completed.clear() Game.completed.clear()
@@ -583,7 +604,7 @@ func test_hoersaal() -> void:
basis_da = false basis_da = false
p(basis_da, "Die %d Basiswege werden gelehrt" % 1) p(basis_da, "Die %d Basiswege werden gelehrt" % 1)
var lernbar := 0 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"): if c is Button and (c as Button).text.begins_with("Diesen Weg"):
lernbar += 1 lernbar += 1
p(lernbar <= 1, "Und höchstens einer davon ist zu lernen (%d)" % lernbar) 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()) "%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: func test_tagesablauf() -> void:
abschnitt("Tagesablauf der Menschen") abschnitt("Tagesablauf der Menschen")
var mensch: Bewohner = null var mensch: Bewohner = null
@@ -734,7 +986,10 @@ func test_tagesablauf() -> void:
mensch = m mensch = m
p(mensch != null, "Der erste Mensch lebt auf der Erde: %s" % mensch.data["name"]) 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(mensch.activity == "schlaeft", "Um 02:00 schläft er (%s)" % mensch.activity)
p(_xz(mensch.global_position).distance_to( p(_xz(mensch.global_position).distance_to(
_xz(Orte.center(HumanDB.PLACES, str(mensch.data["heim"])))) < 5.0, _xz(Orte.center(HumanDB.PLACES, str(mensch.data["heim"])))) < 5.0,
@@ -791,12 +1046,11 @@ func test_tagesablauf() -> void:
func test_wege_machbar() -> void: func test_wege_machbar() -> void:
abschnitt("Wege passen in die Zeitfenster") abschnitt("Wege passen in die Zeitfenster")
_wege_pruefen("Erde", HumanDB.PLACES, HumanDB.LIST, HumanDB.NETZ, HumanDB.ANKER) _wege_pruefen("Erde", HumanDB.PLACES, HumanDB.LIST, main.erde.nav_map)
_wege_pruefen("Traumwelt", DreamDB.PLACES, DreamDB.LIST, DreamDB.NETZ, DreamDB.ANKER) _wege_pruefen("Traumwelt", DreamDB.PLACES, DreamDB.LIST, main.traum.nav_map)
func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, func _wege_pruefen(welt_name: String, places: Dictionary, liste: Array, karte: RID) -> void:
netz: Netz = null, anker: Dictionary = {}) -> void:
var sek_pro_stunde := Game.DAY_SECONDS / 24.0 var sek_pro_stunde := Game.DAY_SECONDS / 24.0
var alles_ok := true var alles_ok := true
var engste := 999.0 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]) var nach: String = String(r[i][1])
if von == nach: if von == nach:
continue continue
var dauer := _weg_laenge(places, von, nach, m.get("etage", {}), netz, anker) \ var dauer := _weg_laenge(places, von, nach, karte) / Bewohner.GEHTEMPO
/ Bewohner.GEHTEMPO
var slot_h: float = (float(r[i + 1][0]) - float(r[i][0])) if i + 1 < r.size() \ 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])) else (24.0 - float(r[i][0]))
var fenster := slot_h * sek_pro_stunde 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]) 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, ## Weglänge über das Navigationsnetz der Welt -- so, wie die Menschen wirklich laufen.
netz: Netz = null, anker: Dictionary = {}) -> float: func _weg_laenge(places: Dictionary, von: String, nach: String, karte: RID) -> float:
var pts: Array = Orte.route_voll(places, von, nach, Vector3.ZERO, etagen, netz, anker) 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 l := 0.0
var pos := Orte.center(places, von) + Vector3(0, Orte.hoehe(int(etagen.get(von, 0))), 0) for i in range(1, pts.size()):
for pt in pts: l += pts[i - 1].distance_to(pts[i])
l += pos.distance_to(pt)
pos = pt
return l return l
func test_geist() -> void: func test_geist() -> void:
abschnitt("Geist-Bewegung") abschnitt("Geist-Bewegung")
p(main.player.mode == Player.Mode.GEIST, "Spieler ist auf der Erde ein Geist") 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.global_position = Vector3(0, 8.0, 0)
main.player.velocity = Vector3(6, 0, 0) main.player.velocity = Vector3(6, 0, 0)
for i in range(60): 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) "Die Schwerkraft holt ihn auf den Boden (y = %.2f)" % main.player.global_position.y)
var lena: Bewohner = welt().humans[0] 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 davor: Vector3 = lena.global_position
var uhr_geist := Game.time_h
main.player.global_position = lena.global_position + Vector3(0, 1, 0.5) 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") 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: func test_kamera_und_etagen() -> void:
abschnitt("Feste Kamera, kleine Menschen, Stadt mit Stockwerken") abschnitt("Feste Kamera, kleine Menschen, geschlossene Häuser")
ui().schliessen() ui().schliessen()
var sp: Player = main.player var sp: Player = main.player
sp.set_mode(Player.Mode.GEIST) 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, 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]) "Menschen und Geist sind kleiner (%.2f / %.2f)" % [mensch._visual.scale.x, sp.body.scale.x])
# Stockwerke # Geschlossene Häuser: einstöckig, ein Raum mit Tür
var mehr := 0
for key in HumanDB.PLACES:
if Orte.etagen(HumanDB.PLACES, key) > 1:
mehr += 1
p(mehr >= 12, "%d Gebäude haben mehrere Stockwerke" % mehr)
var eng := 0 var eng := 0
for key in HumanDB.PLACES: for key in HumanDB.PLACES:
var sz: Array = HumanDB.PLACES[key]["size"] 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 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 b in welt()._bauten:
for mi in Prims._meshes_von(b): for mi in Prims._meshes_von(b):
var bm := (mi as MeshInstance3D).mesh as BoxMesh 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): if bm != null and (bm.size.x <= 0.0 or bm.size.y <= 0.0 or bm.size.z <= 0.0):
eng += 1 eng += 1
p(eng == 0, "Keine Platte mit negativer Größe") p(eng == 0, "Keine Platte mit negativer Größe")
var oben: Bewohner = null var oben: Bewohner = welt().humans[0]
for m in welt().humans:
if m.etage(str(m.data["heim"])) > 0:
oben = m
break
p(oben != null, "Jemand wohnt über dem Erdgeschoss")
var heim := str(oben.data["heim"]) var heim := str(oben.data["heim"])
var e := oben.etage(heim)
var c := Orte.center(HumanDB.PLACES, heim) var c := Orte.center(HumanDB.PLACES, heim)
var g: Array = HumanDB.PLACES[heim]["size"] 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 uhr := Game.time_h
var zuhause := false var zuhause := false
for i in range(3000): for i in range(3000):
@@ -1035,80 +1283,34 @@ func test_kamera_und_etagen() -> void:
if oben.place == heim and oben._path.is_empty(): if oben.place == heim and oben._path.is_empty():
zuhause = true zuhause = true
break break
p(zuhause and absf(oben.global_position.y - Orte.hoehe(e)) < 0.2, p(zuhause and absf(oben.global_position.y) < 0.3,
"Nachts liegt %s in seiner Wohnung im %d. Stock (y = %.1f)" % "Nachts liegt %s zu Hause, ebenerdig (y = %.1f)" % [oben.data["name"], oben.global_position.y])
[oben.data["name"], e, oben.global_position.y])
var weg_stunde := -1.0
for r in oben.data["routine"]:
if str(r[1]) != heim:
weg_stunde = float(r[0]) + 0.5
break
var draussen := false
var tief := 99.0
for i in range(3000):
schritte(1, 0.05, weg_stunde)
tief = minf(tief, oben.global_position.y)
if _xz(oben.global_position).distance_to(_xz(c)) > float(g[0]) * 0.5 + 3.0:
draussen = absf(oben.global_position.y) < 0.3
break
p(draussen, "Auf die Straße kommt er über die Treppe wieder nach unten (y = %.1f)" %
oben.global_position.y)
# Etagenschnitt # Schnitt: drinnen verschwinden Dach und die Wände zur Kamera
var bau: Dictionary = {} var bau: Dictionary = {}
for b in welt()._bauten: for b in welt()._bauten:
if (b["c"] as Vector3).distance_to(c) < 0.1: if (b["c"] as Vector3).distance_to(c) < 0.1:
bau = b bau = b
var n := Orte.etagen(HumanDB.PLACES, heim) p(not bau.is_empty() and (bau["teile"] as Array).size() == 1 and (bau["waende"] as Array).size() >= 4,
p(not bau.is_empty() and (bau["etagen"] as Array).size() == n, "Sein Haus steht mit Dach und Wänden da")
"Sein Haus steht mit allen %d Etagen da" % n) sp.global_position = c + Vector3(0, 0.3, 0)
sp.global_position = c + Vector3(0, 1.4, 0)
sp.velocity = Vector3.ZERO sp.velocity = Vector3.ZERO
sp.yaw = Player.CAM_YAW sp.yaw = Player.CAM_YAW
await get_tree().physics_frame await get_tree().physics_frame
welt()._process(0.05) 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 var wand_weg := 0
for w in bau["waende"][0]: for w in bau["waende"]:
if not (w as Node3D).visible: if not (w as Node3D).visible:
wand_weg += 1 wand_weg += 1
p(obere_weg and not (bau["dach"][0] as Node3D).visible, p(not (bau["teile"][0] as Node3D).visible, "Steht der Geist im Haus, verschwindet das Dach")
"Steht der Geist im Erdgeschoss, verschwinden Dach und obere Etagen") p(wand_weg >= 2 and wand_weg < (bau["waende"] as Array).size(),
p(wand_weg >= 2 and wand_weg < (bau["waende"][0] as Array).size(),
"Die Wände zur Kamera hin sind weg, die hinteren bleiben (%d von %d Teile)" % "Die Wände zur Kamera hin sind weg, die hinteren bleiben (%d von %d Teile)" %
[wand_weg, (bau["waende"][0] as Array).size()]) [wand_weg, (bau["waende"] as Array).size()])
var stufen: Array = Orte.treppen_lauf(HumanDB.PLACES, heim, 0) sp.global_position = c + Vector3(float(g[0]) + 4.0, 0.3, 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
await get_tree().physics_frame await get_tree().physics_frame
welt()._process(0.05) welt()._process(0.05)
p((bau["etagen"][0] as Node3D).visible and (bau["etagen"][1] as Node3D).visible var alles := (bau["teile"][0] as Node3D).visible
and (n < 3 or not (bau["etagen"][2] as Node3D).visible), for w in bau["waende"]:
"Im ersten Stock sieht man Erdgeschoss und ersten Stock")
sp.global_position = c + Vector3(float(g[0]) + 4.0, 1.4, 0)
await get_tree().physics_frame
welt()._process(0.05)
var alles := (bau["dach"][0] as Node3D).visible
for f in range(n):
if not (bau["etagen"][f] as Node3D).visible:
alles = false
for w in bau["waende"][0]:
if not (w as Node3D).visible: if not (w as Node3D).visible:
alles = false alles = false
p(alles, "Draußen steht das Haus wieder ganz") 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") 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 # 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 var daheim := false
for i in range(3000): for i in range(3000):
schritte(1, 0.05, 2.0) schritte(1, 0.05, 2.0)
@@ -1134,9 +1338,11 @@ func test_kamera_und_etagen() -> void:
sp.velocity = Vector3.ZERO sp.velocity = Vector3.ZERO
sp.spruenge = 0 sp.spruenge = 0
sp.anhaengen(oben) sp.anhaengen(oben)
p(daheim and sp._anlauf.size() >= 4 and (sp._anlauf.back() as Vector3).y > Orte.ETAGE - 0.1, await get_tree().physics_frame
"Von weit weg plant der Geist den Weg über die Straße in seine Etage (%d Punkte)" % await get_tree().physics_frame
sp._anlauf.size()) 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 var angekommen := false
for i in range(2400): for i in range(2400):
await get_tree().physics_frame 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, p((oben._label as Label3D).visible and fremde_schilder == 0,
"Nur der Verfolgte trägt ein Schild, die anderen nicht (%d fremde)" % fremde_schilder) "Nur der Verfolgte trägt ein Schild, die anderen nicht (%d fremde)" % fremde_schilder)
sp.folgt = null 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 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. ## 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. ## Wer dann über den Wegpunkt hinausläuft, pendelt um ihn herum statt anzukommen.
func test_gehen_bei_tempo() -> void: 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" % 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)]) [HumanDB.LIST.size(), "" if stumm.is_empty() else " — nicht: " + ", ".join(stumm)])
p(Game.machbar(id) or true, "Bereit fürs Brett") 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 # --- Die Wörter verändern, wie er sich verhält
p(Game.verhalten(id, "abwehr") >= 1, "Stur: Abwehr %d" % Game.verhalten(id, "abwehr")) 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): for i in range(stufen):
Game.completed.append("stufe_%d" % i) Game.completed.append("stufe_%d" % i)
Game.welt_erzeugen() Game.welt_erzeugen()
_wege_pruefen("Traumdorf mit %d Häusern" % stufen, DreamDB.PLACES, DreamDB.LIST, main._traumwelt()
DreamDB.NETZ, DreamDB.ANKER) 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, _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.completed.assign(merker)
Game.welt_erzeugen() Game.welt_erzeugen()
@@ -2364,8 +2661,8 @@ func test_steuerung() -> void:
var klotz: Dictionary = {} var klotz: Dictionary = {}
var richtung := Vector3.ZERO var richtung := Vector3.ZERO
for b in welt()._bauten: for b in welt()._bauten:
if not bool(b.get("voll", false)): if not (b["waende"] as Array).is_empty():
continue continue # ein Haus mit Wänden ist kein Klotz
for pz in HumanDB.PARZELLEN: for pz in HumanDB.PARZELLEN:
if (pz["pos"] as Vector3).distance_to(b["c"]) < 0.1: if (pz["pos"] as Vector3).distance_to(b["c"]) < 0.1:
klotz = b klotz = b
@@ -2697,7 +2994,8 @@ func test_dorf_organisch() -> void:
sektoren_12 = sekt.size() sektoren_12 = sekt.size()
if stufe == stufens_probe(): if stufe == stufens_probe():
var flaeche := PI * pow(Game.insel_radius(), 2.0) 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)) dichte = "%.0f m² je Haus" % (flaeche / float(stufe))
p(ueberlappung == "", "Über %d Ausbaustufen steht kein Gebäude im anderen%s" % p(ueberlappung == "", "Über %d Ausbaustufen steht kein Gebäude im anderen%s" %