Files
dreamer-game/scripts/bewohner.gd
2026-09-16 20:06:38 +02:00

280 lines
10 KiB
GDScript

extends Node3D
class_name Bewohner
## Bewohner einer Welt: läuft seinen Tagesablauf ab. Auf der Erde kann er von
## einer Technik erreicht werden -- oder eben nicht.
const GEHTEMPO := 8.0
const MASSSTAB := 0.8 # Menschen sind klein, die Stadt wirkt groß
var data: Dictionary
var idx: int = 0
var place: String = ""
var activity: String = ""
var resist_left: float = 0.0
var places: Dictionary = {}
var mit_mission := false
var _visual: Node3D
var _ober: Node3D # Oberkörper: neigt sich, duckt sich, zappelt
var _arme: Array = []
var _label: Label3D
var _koerper: MeshInstance3D
var _kopf: MeshInstance3D
var _aura: MeshInstance3D
var _strahl: MeshInstance3D
var _path: Array = []
var _letzte_aktivitaet: String = ""
var boden: Callable = Callable() # Höhe des Bodens (x, z) -> y; leer heißt eben
var slot: int = 0
var 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 _takt := 0.0 # Schild und Haltungsziele alle 0,3 s, nicht je Bild
var _neigung_ziel := 0.0
var _groesse_ziel := 1.0
var _zappelt := false
var _offset: Vector3
func setup(d: Dictionary, index: int, orte: Dictionary) -> void:
data = d
idx = index
places = orte
mit_mission = bool(d.get("mission", false))
name = "Bewohner_" + str(d["id"])
var winkel := float(index) * TAU / 4.0
_offset = Vector3(cos(winkel), 0, sin(winkel)) * 2.2
_visual = Node3D.new()
_visual.scale = Vector3.ONE * MASSSTAB
add_child(_visual)
var c: Color = d["farbe"]
var leuchtet := bool(d.get("leuchtet", false))
var hoehe := 1.05 + float(index % 3) * 0.08
_ober = Node3D.new()
_ober.position.y = hoehe * 0.45
_visual.add_child(_ober)
var o := Vector3(0, -hoehe * 0.45, 0)
_koerper = Prims.capsule(_ober, o + Vector3(0, hoehe * 0.62, 0), 0.32, hoehe, c)
_kopf = Prims.sphere(_ober, o + Vector3(0, hoehe * 1.18 + 0.1, 0), 0.24, c.lightened(0.45))
Prims.box(_ober, o + Vector3(0, hoehe * 0.95, 0), Vector3(0.9, 0.14, 0.3), c.darkened(0.25), false)
for sx: float in [-1.0, 1.0]:
var gelenk := Node3D.new()
gelenk.position = o + Vector3(sx * 0.46, hoehe * 0.92, 0)
_ober.add_child(gelenk)
Prims.capsule(gelenk, Vector3(0, -0.32, 0), 0.09, 0.62, c.darkened(0.1))
_arme.append(gelenk)
_hut(o + Vector3(0, hoehe * 1.18 + 0.1, 0), c)
Prims.box(_visual, Vector3(0, 0.06, 0), Vector3(0.5, 0.12, 0.7), c.darkened(0.45), false)
if leuchtet:
_koerper.material_override = Prims.mat(c, 0.9)
_kopf.material_override = Prims.mat(c.lightened(0.45), 0.8)
var reif := Prims.cyl(_ober, o + Vector3(0, hoehe * 0.5, 0), 0.62, 0.05,
c.lightened(0.3), false, 1.6)
reif.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_aura = Prims.cyl(self, Vector3(0, 0.05, 0), 0.95, 0.05, Color(1, 0.85, 0.35), false, 2.0)
_aura.visible = false
_strahl = Prims.cyl(self, Vector3(0, 11.0, 0), 0.14, 16.0, Color(1, 0.85, 0.35, 0.22), false, 1.2)
_strahl.visible = false
# gestaffelt, damit sich Schilder in einem Raum nicht übereinanderlegen
_label = Prims.label(self, Vector3(0, 2.3 + float(index % 5) * 0.35, 0), d["name"], 0.36)
var start := _routine_at(Game.time_h)
place = start[0]
activity = start[1]
_letzte_aktivitaet = activity
global_position = Orte.center(places, place) + _offset
## Pfad über das Navigationsnetz zu einem Ort -- leer, wenn es keins gibt.
func _nav_route(nach: String) -> Array:
if not nav_map.is_valid():
return []
var ziel := Orte.center(places, nach) + _offset
var pts := NavigationServer3D.map_get_path(nav_map, global_position, ziel, true)
if pts.size() < 2:
nav_fehl += 1
nav_fehl_info = "%s: %s -> %s von %s nach %s (%d Punkte)" % [data["name"], place, nach,
str(global_position), str(ziel), pts.size()]
print(" [kein Weg] " + nav_fehl_info)
return []
var out: Array = []
for i in range(1, pts.size()):
out.append(pts[i])
return out
## Kopfbedeckung nach Tagestyp des Berufs -- so unterscheiden sich die Leute
## schon aus der Ferne.
func _hut(kopf: Vector3, c: Color) -> void:
var typ := str(HumanDB.BERUF_TAGESTYP.get(str(data.get("beruf", "")), ""))
var dunkel := c.darkened(0.4)
match typ:
"frueh": # Mütze
Prims.cyl(_ober, kopf + Vector3(0, 0.2, 0), 0.22, 0.14, dunkel, false)
"buero": # Hut mit Krempe
Prims.cyl(_ober, kopf + Vector3(0, 0.16, 0), 0.36, 0.04, dunkel, false)
Prims.cyl(_ober, kopf + Vector3(0, 0.3, 0), 0.18, 0.24, dunkel, false)
"studium": # Zipfel
var z := Prims.cyl(_ober, kopf + Vector3(0, 0.36, 0), 0.2, 0.4, dunkel, false)
(z.mesh as CylinderMesh).top_radius = 0.0
"werkstatt": # Kappe mit Schirm
Prims.cyl(_ober, kopf + Vector3(0, 0.18, 0), 0.25, 0.1, dunkel, false)
Prims.box(_ober, kopf + Vector3(0, 0.14, -0.3), Vector3(0.3, 0.04, 0.2), dunkel, false)
_:
pass
## Angst duckt, Erschöpfung beugt, Unruhe zappelt -- solange die Klemme nicht
## überwunden ist. So sieht man Probleme beim Beobachten am Körper.
func _haltung(delta: float, geht: bool) -> void:
if _takt <= 0.0:
_haltungsziele()
var zappeln := sin(Time.get_ticks_msec() * 0.011) * 0.06 if _zappelt and not geht else 0.0
_ober.rotation.x = lerpf(_ober.rotation.x, -_neigung_ziel, 1.0 - pow(0.02, delta))
_ober.rotation.z = lerpf(_ober.rotation.z, zappeln, 1.0 - pow(0.001, delta))
_ober.scale = _ober.scale.lerp(Vector3.ONE * _groesse_ziel, 1.0 - pow(0.02, delta))
var schwung := sin(_bob) * 0.7 if geht else 0.0
for i in range(_arme.size()):
var a: Node3D = _arme[i]
a.rotation.x = lerpf(a.rotation.x, schwung * (1.0 if i == 0 else -1.0),
1.0 - pow(0.001, delta))
func _haltungsziele() -> void:
var id := str(data["id"])
var offen: Dictionary = {}
for k in data.get("probleme", []):
if not Game.problem_ueberwunden(id, str(k)):
offen[str(k)] = true
_neigung_ziel = 0.38 if offen.has("erschoepfung") else 0.0
_groesse_ziel = 0.86 if offen.has("angst") else 1.0
_zappelt = offen.has("unruhe")
## [Ort, Tätigkeit, Slotnummer] zur Stunde h. Steht in dem Slot ein System,
## ist die Tätigkeit die Ersatzhandlung.
func _routine_at(h: float) -> Array:
var r: Array = data["routine"]
var nr := r.size() - 1
for i in range(r.size()):
if h >= float(r[i][0]):
nr = i
var akt: String = str(r[nr][2])
if mit_mission:
akt = Game.slot_aktivitaet(str(data["id"]), nr)
return [str(r[nr][1]), akt, nr]
func _process(roh: float) -> void:
var delta := roh * Game.zeit_faktor()
resist_left = maxf(0.0, resist_left - delta)
var soll := _routine_at(Game.time_h)
slot = int(soll[2])
if soll[0] != place:
_path = _nav_route(soll[0])
if _path.is_empty(): # kein Netz oder kein Weg: Luftlinie, damit er ankommt
_path = [Orte.center(places, soll[0]) + _offset]
place = soll[0]
activity = "unterwegs"
elif _path.is_empty() and activity != soll[1]:
activity = soll[1]
if not _path.is_empty():
var ziel: Vector3 = _path[0]
var hier := global_position
if boden.is_valid():
ziel.y = hier.y # das Gelände bestimmt die Höhe, nicht der Wegpunkt
var d := ziel - hier
if d.length() < 0.4:
_path.remove_at(0)
if _path.is_empty():
activity = soll[1]
else:
# nie über den Wegpunkt hinaus -- sonst pendelt er bei hohem Tempo
# um ihn herum, statt anzukommen
var schritt := d.normalized() * minf(GEHTEMPO * delta, d.length())
global_position += schritt
_visual.rotation.y = lerp_angle(_visual.rotation.y, atan2(-d.x, -d.z), 1.0 - pow(0.002, delta))
_bob += delta * 9.0
_visual.position.y = absf(sin(_bob)) * 0.09
if _path.is_empty():
_visual.position.y = lerpf(_visual.position.y, 0.0, 1.0 - pow(0.01, delta))
_haltung(delta, not _path.is_empty())
if boden.is_valid():
global_position.y = float(boden.call(global_position.x, global_position.z))
_visual.rotation.z = lerp_angle(_visual.rotation.z, PI * 0.5 if activity == "schlaeft" else 0.0,
1.0 - pow(0.01, delta))
if activity != _letzte_aktivitaet:
_letzte_aktivitaet = activity
var erledigt: bool = mit_mission and Game.completed.has(data["id"])
var aktiv: bool = mit_mission and Game.active_mission == data["id"]
_aura.visible = aktiv or erledigt
_strahl.visible = aktiv
if _aura.visible:
_aura.material_override = Prims.mat(
Color(0.4, 1.0, 0.5) if erledigt else Color(1, 0.85, 0.35), 2.0)
_aura.scale.x = 1.0 + sin(Time.get_ticks_msec() * 0.003) * 0.08
_aura.scale.z = _aura.scale.x
# Auf der Erde tragen nur Fokus, Verfolgte, Mission und wer unter der Maus
# steht ein Schild -- sonst überdecken sich acht Namen im Café.
_label.visible = (not mit_mission and not bool(data.get("stumm", false))) \
or Game.markiert.has(str(data["id"]))
if _takt <= 0.0:
_takt = 0.3
if _label.visible:
_label.text = beschriftung()
_takt -= delta
_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))
func beschriftung() -> String:
var s: String = data["name"]
if data.has("rolle"):
s += ", " + str(data["rolle"])
s += "\n" + Orte.text(activity)
if mit_mission and Game.situation_bei(str(data["id"])):
s += "\n» %s «" % Probleme.SITUATIONEN[Game.situation["key"]]["name"]
if resist_left > 0.0:
s += " (macht dicht)"
if mit_mission:
if Game.completed.has(data["id"]):
s += "\n★ Traum erfüllt"
return s
## Sichtbare Reaktion auf eine angewandte Technik.
func reagiere(success: bool, text: String) -> void:
var l := Prims.label(self, Vector3(0, 3.0, 0), text, 0.4,
Color(0.55, 1.0, 0.6) if success else Color(1, 0.55, 0.45))
var tw := l.create_tween()
tw.set_parallel(true)
tw.tween_property(l, "position:y", 5.2, 2.4)
tw.tween_property(l, "modulate:a", 0.0, 2.4).set_delay(0.9)
tw.chain().tween_callback(l.queue_free)
if not success:
resist_left = Game.RESIST_SEKUNDEN * \
(1.0 + 0.3 * float(Game.verhalten(str(data["id"]), "abwehr")))
var blitz := Prims.mat(
Color(0.6, 1.0, 0.7) if success else Color(1, 0.5, 0.4), 1.6)
var orig := _koerper.material_override
_koerper.material_override = blitz
_kopf.material_override = blitz
var t2 := create_tween()
t2.tween_interval(0.35)
t2.tween_callback(func() -> void:
if is_instance_valid(_koerper):
_koerper.material_override = orig
_kopf.material_override = Prims.mat((data["farbe"] as Color).lightened(0.45)))