Files
security-game/src/game/npc_avatar.gd
T
zwitschi 1e972a7490
CI / headless-tests (push) Successful in 55s
Implement TODO backlog: LATE phase fix, protester, CALL_SOMEONE, door lock, flashlight, persistence, navmesh, JSON localization, and release tooling
- Fix LATE shift phase (start 700s) so the violent-threat event is reachable
- Add PROTESTER archetype (rules, profile, colors, events in public venues)
- Add CALL_SOMEONE reaction (FSM -> ARGUING, influencer/trespasser/protester weights)
- Door lock: DoorState core class, L binding, red/green visual indicator,
  locked doors thwart FORCE_ENTRY
- Flashlight: G binding, SpotLight3D, perception radius bonus at evening/late
- Persistence: save shift results via Save autoload, history + best score on
  review screen, round-trip test
- Navigation: flat NavigationRegion3D navmesh, NavigationAgent3D path following
  with direct-movement fallback; steps made visual-only so NPCs reach entrance
- Localization: strings moved to content/strings.en.json (loader keeps
  lookup/fmt signatures; coverage test over all keys)
- Distribution: export_presets.cfg (Linux/Windows/macOS), tools/build_releases.sh,
  docs/RELEASING.md, prominent README Downloads section
- Remove stale signal_bus.gd (invalid static-signal experiment)
- Tests: 53 passing across 9 suites
2026-10-31 12:51:13 +01:00

175 lines
5.5 KiB
GDScript

class_name NpcAvatar
extends CharacterBody3D
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
## Gives the controller a body, color-coded by archetype, and simple movement.
const NPCController := preload("res://src/core/npc_controller.gd")
const Rules := preload("res://src/core/rules.gd")
var npc: NPCController = null
var spawn_position: Vector3 = Vector3.ZERO
var target_position: Vector3 = Vector3.ZERO
var door_position: Vector3 = Vector3(0, 0.5, 1.2)
var awaiting_interaction := false
var arrived := false
var agent: NavigationAgent3D = null
## Set once the nav agent proves it has a path; direct movement is the fallback.
var _nav_usable := false
var _nav_try_frames := 0
const TYPE_COLORS := {
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
Rules.NpcType.PROTESTER: Color(0.90, 0.40, 0.20),
}
func _init() -> void:
npc = NPCController.new()
func get_npc_controller() -> NPCController:
return npc
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3, p_door: Vector3 = Vector3(0, 0.5, 1.2)) -> void:
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
spawn_position = p_spawn
target_position = p_target
door_position = p_door
position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed)
_build_body(profile.npc_type)
_nav_usable = false
_nav_try_frames = 0
agent = NavigationAgent3D.new()
agent.name = "NavAgent"
agent.path_desired_distance = 0.4
agent.target_desired_distance = 4.0
agent.radius = 0.35
agent.height = 1.2
add_child(agent)
# Normalize intent targets: approach the entrance, stop near the door.
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
shape.radius = 0.35
shape.height = 1.6
collider.shape = shape
collider.position = Vector3(0, 0.8, 0)
add_child(collider)
func _build_body(npc_type: int) -> void:
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.6
body.mesh = capsule
var mat := StandardMaterial3D.new()
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
body.material_override = mat
add_child(body)
var id_tag := MeshInstance3D.new()
var sphere := SphereMesh.new()
sphere.radius = 0.12
sphere.height = 0.24
var tag_mat := StandardMaterial3D.new()
tag_mat.albedo_color = Color(1, 1, 1)
id_tag.mesh = sphere
id_tag.material_override = tag_mat
id_tag.position = Vector3(0, 1.05, 0)
id_tag.scale = Vector3(1, 0.4, 1)
add_child(id_tag)
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
return
_advance_fsm(delta)
_move_toward_target(delta)
func _advance_fsm(delta: float) -> void:
# Returning-trespasser: an NPC directed away may circle back to the door.
if npc.tick_return(delta):
arrived = false
awaiting_interaction = false
target_position = door_position
return
if npc.leave_requested:
var away := global_position + (global_position - spawn_position).normalized() * 40.0
away.y = 0.5
target_position = away
return
if arrived and not awaiting_interaction:
awaiting_interaction = true
func _move_toward_target(delta: float) -> void:
if agent == null:
_move_direct(delta)
return
agent.target_position = target_position
if agent.is_navigation_finished():
if (global_position - target_position).length() < 3.0:
_arrive()
else:
# Path finished but we are far away: navigation is unusable, fall back.
_move_direct(delta)
return
# Learn whether the navmesh actually serves this spot.
if not _nav_usable:
_nav_try_frames += 1
if agent.is_target_reachable():
_nav_usable = true
elif _nav_try_frames > 10:
_move_direct(delta)
return
var next := agent.get_next_path_position()
var dir := (next - global_position)
dir.y = 0.0
if dir.length() < 0.2:
velocity = Vector3.ZERO
return
dir = dir.normalized()
velocity.x = dir.x * 1.3
velocity.z = dir.z * 1.3
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide()
## Fallback used when no navigation mesh is available (e.g. minimal scenes).
func _move_direct(delta: float) -> void:
var to_target := target_position - global_position
to_target.y = 0.0
var dist := to_target.length()
if dist < 0.7:
_arrive()
return
var dir := to_target.normalized()
velocity.x = dir.x * 1.3
velocity.z = dir.z * 1.3
velocity.y -= 9.8 * delta
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
move_and_slide()
func _arrive() -> void:
arrived = true
velocity = Vector3.ZERO
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
target_position = door_position
if (global_position - door_position).length() < 0.8:
awaiting_interaction = true
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
match to_state:
"LEAVING":
npc.leave_requested = true
"THREATENING", "FORCING_ENTRY":
# Advance toward the door posture; keep it simple for the prototype.
pass