Character and movement pass
Full build / Full desktop build + optional release (push) Successful in 3m37s
Full build / Import + headless tests (push) Successful in 33s

NPC characters
- Articulated rig (hips/knees, shoulders/elbows/hands) replaces rigid capsules;
  the walk cycle bends knees and elbows and flees with a longer stride.
- Layered outfits (jacket/vest/coat/dress), extended accessories and hand items
  (bag, umbrella, phone, camera, clipboard, cup), face details and five hair
  silhouettes, all deterministic per profile seed.
- Idle behaviour layers (phone, clipboard, crossed arms, look-around), breathing
  and weight shift; escalation raises hands or points, and noticed NPCs turn
  toward the guard.
- Reaction symbols above the head (!!, ?!, !, ?), door-queue offsets assigned by
  GameManager, three LOD tiers with hysteresis and a billboard impostor.
- smoke_npc_budget.gd: 16 avatars = 534 meshes, ~6.9 ms average (50 ms budget).

Player movement
- Separate accel/decel, step-up assist for kerbs and the entrance steps, and a
  forward-axis fix: W was inverted (get_axis(forward, back) is negative).
- Head bob, landing dip and precision-walk FOV, gated by persisted head-bob and
  invert-Y settings; precision chip and tick; surface-aware footsteps and a
  landing cue forwarded to AudioFeedback.
- Gamepad support (sticks, deadzone, face buttons, bumpers), a control legend
  that follows the active device, and named actions for the remaining keys.
- Key/button remapping in Settings, persisted as input_bindings and applied by
  InputBindings (mouse and gamepad axes are never removed).
- smoke_movement.gd (windowed): climbs the steps, checks precision speed, chip,
  footsteps and landing; new 11-precision-walk capture and catalogue entry.

Verification: headless 208/208, Python tooling 56/56, all 11 smokes pass,
compare 11 good / 0 bad, movement judge match. Version 0.5.0. Only optional
Future ideas remain in TODO.
This commit is contained in:
zwitschi committed 2026-11-01 10:41:17 +01:00
1 parent 2a4e0d1998
commit a27e8cb74c
35 files changed
+1608 -185

No files matched your search

+3
View File
@@ -43,6 +43,9 @@ func save_shift_result(result: Dictionary) -> Error:
const DEFAULT_SETTINGS := {
"look_sensitivity": 0.0022,
"hud_font_scale": 1.0,
"invert_y": false,
"head_bob": true,
"input_bindings": {},
"ai_difficulty": "normal",
}
+67
View File
@@ -0,0 +1,67 @@
class_name InputBindings
extends RefCounted
## Key/button remaps persisted through the settings panel. Games keep working
## with the defaults in `project.godot`; only actions the player rebinds are
## rewritten, and remaps never remove mouse or gamepad axis events.
const REMAPPABLE := [
"move_forward", "move_back", "move_left", "move_right",
"walk_slow", "inspect", "wand", "radio_emergency", "action_menu",
]
## Turn an input event into a storable descriptor, or {} when it is not a key or
## pad button (mouse buttons and axes are deliberately not remappable here).
static func descriptor_from_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key := event as InputEventKey
var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode
if code == 0:
return {}
return {"type": "key", "code": int(code)}
if event is InputEventJoypadButton:
return {"type": "joy", "button": int((event as InputEventJoypadButton).button_index)}
return {}
static func label_for(descriptor: Dictionary) -> String:
match str(descriptor.get("type", "")):
"key":
return OS.get_keycode_string(int(descriptor.get("code", 0)))
"joy":
return "Pad %d" % int(descriptor.get("button", 0))
_:
return ""
## First key/button currently bound to the action, used to label the UI.
static func current_descriptor(action: String) -> Dictionary:
if not InputMap.has_action(action):
return {}
for event in InputMap.action_get_events(action):
var descriptor := descriptor_from_event(event)
if not descriptor.is_empty():
return descriptor
return {}
## Rewrite the actions present in `bindings`. Existing key/button events for
## those actions are replaced; mouse and joypad-axis events are left alone.
static func apply(bindings: Dictionary) -> void:
for action in bindings.keys():
if not InputMap.has_action(action):
continue
var descriptor: Dictionary = bindings[action]
if descriptor.is_empty():
continue
for event in InputMap.action_get_events(action):
if event is InputEventKey or event is InputEventJoypadButton:
InputMap.action_erase_event(action, event)
InputMap.action_add_event(action, event_for(descriptor))
static func event_for(descriptor: Dictionary) -> InputEvent:
match str(descriptor.get("type", "")):
"joy":
var button := InputEventJoypadButton.new()
button.button_index = int(descriptor.get("button", 0))
return button
_:
var key := InputEventKey.new()
key.physical_keycode = int(descriptor.get("code", 0))
return key
+27
View File
@@ -13,6 +13,9 @@ var _bed_player: AudioStreamPlayer = null
var crowd_level: float = 0.0
var bed_playing := false
var radio_crackles := 0
var footsteps := 0
var landings := 0
var precision_ticks := 0
func _ready() -> void:
# Pre-create a few players so overlapping cues don't cut each other off.
@@ -72,6 +75,30 @@ func play_radio_close() -> void:
radio_crackles += 1
_play_noise(0.06, 0.25, 1400.0)
## Surface-aware footstep: a short filtered burst, lower on asphalt, brighter on
## the stone entrance steps.
func play_footstep(surface: String) -> void:
footsteps += 1
var cutoff := 2600.0
match surface:
"asphalt":
cutoff = 1500.0
"stone":
cutoff = 3400.0
_:
cutoff = 2400.0
_play_noise(0.045, 0.22, cutoff)
## Landing thump after a step or drop.
func play_land() -> void:
landings += 1
_play_tone(92.0, 0.09, 0.4)
## Quiet tick when the precision-walk modifier engages.
func play_precision_tick() -> void:
precision_ticks += 1
_play_tone(1180.0, 0.035, 0.22)
## Play a denial/error cue (low buzz).
func play_deny() -> void:
_play_tone(220.0, 0.20, 0.5)
+150 -46
View File
@@ -17,6 +17,7 @@ const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
const VenueScenario := preload("res://src/core/venue_scenario.gd")
const WorldBuilder := preload("res://src/game/world_builder.gd")
const Materials := preload("res://src/game/materials.gd")
const InputBindings := preload("res://src/core/input_bindings.gd")
const GuardController := preload("res://src/game/guard_controller.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
@@ -84,6 +85,8 @@ var pause_overlay: PauseOverlay = null
var settings_panel: SettingsPanel = null
var event_timeline: EventTimeline = null
var _timeline_from_review := false
var _queue_tick := 0.0
var _input_device := "keyboard"
var _shift_running := false
var player_settings: Dictionary = {}
var _focused_avatar: Node3D = null
@@ -122,6 +125,7 @@ var _door_rng := RandomNumberGenerator.new()
const MAX_LIVE_NPCS := 16
func _ready() -> void:
_ensure_action_bindings()
_door_rng.randomize()
player_settings = Save.load_settings() if Save else {}
scene_root = Node3D.new()
@@ -195,8 +199,25 @@ func _build_guard() -> GuardController:
collider.position = Vector3(0, 0.85, 0)
g.add_child(collider)
g.add_to_group("guard")
g.footstep.connect(_on_guard_footstep)
g.landed.connect(_on_guard_landed)
g.precision_changed.connect(_on_precision_changed)
return g
func _on_guard_footstep(surface: String) -> void:
if audio:
audio.play_footstep(surface)
func _on_guard_landed() -> void:
if audio:
audio.play_land()
func _on_precision_changed(active: bool) -> void:
if hud:
hud.set_precision(active)
if audio and active:
audio.play_precision_tick()
func _build_scenario(id: String) -> VenueScenario:
match id:
"joke_shop":
@@ -282,6 +303,9 @@ func _apply_settings(settings: Dictionary) -> void:
player_settings = settings
if guard:
guard.look_sensitivity = float(settings.get("look_sensitivity", 0.0022))
guard.invert_y = bool(settings.get("invert_y", false))
guard.head_bob_enabled = bool(settings.get("head_bob", true))
InputBindings.apply(settings.get("input_bindings", {}))
if hud and hud.has_method("set_font_scale"):
hud.set_font_scale(float(settings.get("hud_font_scale", 1.0)))
@@ -651,6 +675,33 @@ func _on_shift_ended(review: Dictionary) -> void:
shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else [])
_refresh_freeze()
## Keep the people waiting at the door in a deterministic queue: nearest first,
## lateral slots behind the door, so they do not stack on one point.
func _process(delta: float) -> void:
if coordinator == null or npcs.is_empty():
return
_queue_tick -= delta
if _queue_tick > 0.0:
return
_queue_tick = 0.3
_refresh_queue()
func _refresh_queue() -> void:
var waiting: Array = []
for node in npcs:
if not is_instance_valid(node) or node.npc == null:
continue
if node.awaiting_interaction and not node.npc.leave_requested:
waiting.append(node)
else:
node.set_queue_offset(Vector3.ZERO)
waiting.sort_custom(func(a, b) -> bool:
var da: float = a.global_position.distance_to(a.door_position)
var db: float = b.global_position.distance_to(b.door_position)
return da < db)
for index in waiting.size():
waiting[index].set_queue_offset(NpcAvatar.queue_slot_for(index))
func _on_guard_target(target: Node3D) -> void:
targeted_npc = target
_set_focused_avatar(target)
@@ -699,9 +750,43 @@ func _begin_incident(avatar: Node3D) -> void:
hint = "Likely authorized. Press E to verify access."
hud.set_action_hint(hint)
## Bind the remaining gameplay keys as named actions, so remaps can target them
## (and gamepads can grow onto them). Existing actions are left untouched.
static func _ensure_action_bindings() -> void:
var defaults := {
"polite_direction": KEY_Q,
"toggle_debug": KEY_F,
"toggle_flashlight": KEY_G,
"door_lock": KEY_L,
"body_camera": KEY_B,
"cycle_venue": KEY_V,
}
for action in defaults.keys():
if not InputMap.has_action(action):
InputMap.add_action(action)
var has_key := false
for event in InputMap.action_get_events(action):
if event is InputEventKey:
has_key = true
if has_key:
continue
var key := InputEventKey.new()
key.physical_keycode = defaults[action]
InputMap.action_add_event(action, key)
if InputMap.has_action("cycle_target_next"):
var has_tab := false
for event in InputMap.action_get_events("cycle_target_next"):
if event is InputEventKey and event.physical_keycode == KEY_TAB:
has_tab = true
if not has_tab:
var tab := InputEventKey.new()
tab.physical_keycode = KEY_TAB
InputMap.action_add_event("cycle_target_next", tab)
# ------------------------------------------------------------------ input ---
func _unhandled_input(event: InputEvent) -> void:
_track_input_device(event)
if event is InputEventKey and event.echo:
return
if event.is_action_pressed("cancel"):
@@ -713,6 +798,20 @@ func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed:
_handle_key(event)
## Switch the control legend when the player picks up a gamepad or goes back to
## mouse/keyboard. Cheap: only the legend text changes.
func _track_input_device(event: InputEvent) -> void:
var device := ""
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
device = "gamepad"
elif event is InputEventKey or event is InputEventMouse:
device = "keyboard"
if device == "" or device == _input_device:
return
_input_device = device
if hud:
hud.set_input_device(device)
func _handle_mouse_button(event: InputEventMouseButton) -> void:
if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1)
@@ -760,53 +859,58 @@ func _handle_key(event: InputEvent) -> void:
if targeted_npc != null and not inspection_open and not wand_open:
_toggle_wand_screening()
return
match keycode:
KEY_Q:
if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
KEY_TAB:
_cycle_nearby_target()
KEY_F:
hud.toggle_debug()
KEY_G:
_toggle_flashlight()
KEY_L:
_toggle_door_lock()
KEY_B:
_toggle_body_camera()
KEY_V:
_cycle_venue()
_:
_handle_action_key(keycode)
if event.is_action_pressed("polite_direction"):
if targeted_npc != null and not inspection_open:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "polite direction")
return
if event.is_action_pressed("cycle_target_next"):
_cycle_nearby_target(1)
return
if event.is_action_pressed("cycle_target_prev"):
_cycle_nearby_target(-1)
return
if event.is_action_pressed("toggle_debug"):
hud.toggle_debug()
return
if event.is_action_pressed("toggle_flashlight"):
_toggle_flashlight()
return
if event.is_action_pressed("door_lock"):
_toggle_door_lock()
return
if event.is_action_pressed("body_camera"):
_toggle_body_camera()
return
if event.is_action_pressed("cycle_venue"):
_cycle_venue()
return
_handle_action_key(event)
func _handle_action_key(keycode: Key) -> void:
match keycode:
KEY_1:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
KEY_2:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
KEY_3:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
KEY_4:
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
KEY_5:
_apply_interaction(Rules.Level.BLOCKING, "blocking")
KEY_6:
_open_radio(false)
KEY_R:
_open_radio(true)
KEY_A:
if inspection_open:
decide_access(true)
elif wand_open:
decide_wand(true)
KEY_D:
if inspection_open:
decide_access(false)
elif wand_open:
decide_wand(false)
_:
pass
func _handle_action_key(event: InputEvent) -> void:
if event.is_action_pressed("gesture_raise_hand"):
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
elif event.is_action_pressed("gesture_point"):
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
elif event.is_action_pressed("gesture_head_shake"):
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake")
elif event.is_action_pressed("escalate_warning"):
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
elif event.is_action_pressed("escalate_block"):
_apply_interaction(Rules.Level.BLOCKING, "blocking")
elif event.is_action_pressed("radio_assist"):
_open_radio(false)
elif event.is_action_pressed("radio_emergency"):
_open_radio(true)
elif event.is_action_pressed("move_left"):
if inspection_open:
decide_access(true)
elif wand_open:
decide_wand(true)
elif event.is_action_pressed("move_right"):
if inspection_open:
decide_access(false)
elif wand_open:
decide_wand(false)
## `cancel` (Esc, RMB) closes the top modal. With nothing open, Esc pauses the
## shift (or resumes it when paused); RMB does nothing so it never grabs the
+162 -7
View File
@@ -4,12 +4,21 @@ extends CharacterBody3D
signal picked_target(target: Node)
signal cleared_target()
signal footstep(surface: String)
signal landed()
signal precision_changed(active: bool)
const NpcFocus := preload("res://src/game/npc_focus.gd")
var move_speed: float = 3.2
var look_sensitivity: float = 0.0022
var accel: float = 12.0
var accel: float = 22.0
## Separate braking rate: stopping is snappier than starting so the guard does
## not ice-skate into a panel or the door.
var decel: float = 30.0
var invert_y := false
var head_bob_enabled := true
var gamepad_look_sensitivity := 2.6
var is_ready := false
var targeted_npc: Node3D = null
var input_locked := false
@@ -28,21 +37,91 @@ var focus_suppress_s := 0.0
const FOCUS_SUPPRESS_S := 0.75
## Radius used by aim-free cycling.
const NEARBY_RADIUS := 12.0
## Maximum kerb/step height the guard can walk up without jumping.
const STEP_HEIGHT := 0.32
## Distance walked per footstep cue.
const STRIDE_LENGTH := 0.85
const GAMEPAD_DEADZONE := 0.2
@onready var camera: Camera3D = $Head/Camera3D
@onready var head: Node3D = $Head
var _camera_base_y := 0.0
var _base_fov := 75.0
var _bob_phase := 0.0
var _stride := 0.0
var _land_dip := 0.0
var _was_on_floor := true
var _precision := false
func _ready() -> void:
floor_snap_length = 0.3
floor_snap_length = 0.45
_camera_base_y = camera.position.y
_base_fov = camera.fov
_ensure_gamepad_bindings()
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
is_ready = true
## Add gamepad axes/buttons to the named actions if they are not bound yet. Keeps
## `project.godot` keyboard-first while making a controller plug-and-play.
static func _ensure_gamepad_bindings() -> void:
var axes := {
"move_forward": [JOY_AXIS_LEFT_Y, -1.0],
"move_back": [JOY_AXIS_LEFT_Y, 1.0],
"move_left": [JOY_AXIS_LEFT_X, -1.0],
"move_right": [JOY_AXIS_LEFT_X, 1.0],
"look_left": [JOY_AXIS_RIGHT_X, -1.0],
"look_right": [JOY_AXIS_RIGHT_X, 1.0],
"look_up": [JOY_AXIS_RIGHT_Y, -1.0],
"look_down": [JOY_AXIS_RIGHT_Y, 1.0],
}
for action in axes.keys():
if not InputMap.has_action(action):
InputMap.add_action(action, GAMEPAD_DEADZONE)
elif InputMap.action_get_deadzone(action) > GAMEPAD_DEADZONE:
InputMap.action_set_deadzone(action, GAMEPAD_DEADZONE)
var axis: int = axes[action][0]
var value: float = axes[action][1]
var bound := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadMotion and event.axis == axis \
and is_equal_approx(event.axis_value, value):
bound = true
if bound:
continue
var motion := InputEventJoypadMotion.new()
motion.axis = axis
motion.axis_value = value
InputMap.action_add_event(action, motion)
var buttons := {
"action_menu": JOY_BUTTON_A,
"cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X,
"radio_assist": JOY_BUTTON_Y,
"walk_slow": JOY_BUTTON_LEFT_SHOULDER,
"wand": JOY_BUTTON_RIGHT_SHOULDER,
}
for action in buttons.keys():
if not InputMap.has_action(action):
InputMap.add_action(action)
var button: int = buttons[action]
var bound := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton and event.button_index == button:
bound = true
if not bound:
var press := InputEventJoypadButton.new()
press.button_index = button
press.pressed = false
InputMap.action_add_event(action, press)
func _unhandled_input(event: InputEvent) -> void:
if input_locked:
return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity)
head.rotate_x(-event.relative.y * look_sensitivity)
var pitch_sign := 1.0 if invert_y else -1.0
head.rotate_x(pitch_sign * event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
func _physics_process(_delta: float) -> void:
@@ -64,28 +143,104 @@ func _physics_process(_delta: float) -> void:
right.y = 0.0
right = right.normalized()
_update_gamepad_look(_delta)
var input_dir := Vector2(
Input.get_axis("move_left", "move_right"),
Input.get_axis("move_forward", "move_back")
)
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
input_dir = Vector2.ZERO
var desired := (forward * input_dir.y + right * input_dir.x)
# get_axis(forward, back) is negative when W is held, so negate it to walk
# toward the camera's forward vector instead of moonwalking.
var desired := (forward * -input_dir.y + right * input_dir.x)
if desired.length() > 1.0:
desired = desired.normalized()
var precision := Input.is_action_pressed("walk_slow")
if precision != _precision:
_precision = precision
precision_changed.emit(_precision)
var speed := move_speed
if Input.is_action_pressed("walk_slow"):
if precision:
speed *= SLOW_WALK_FACTOR
desired *= speed
velocity.x = move_toward(velocity.x, desired.x, accel * _delta * speed)
velocity.z = move_toward(velocity.z, desired.z, accel * _delta * speed)
var braking := desired.length() < 0.01
var rate := decel if braking else accel
velocity.x = move_toward(velocity.x, desired.x, rate * _delta)
velocity.z = move_toward(velocity.z, desired.z, rate * _delta)
if is_on_floor():
velocity.y = 0.0
_try_step_up(Vector3(velocity.x, 0.0, velocity.z) * _delta)
else:
velocity.y -= 9.8 * _delta
move_and_slide()
_update_feedback(_delta, speed)
_update_target()
## Right-stick look, polled so it keeps turning while held.
func _update_gamepad_look(delta: float) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
return
if not InputMap.has_action("look_left"):
return
var look := Vector2(
Input.get_axis("look_left", "look_right"),
Input.get_axis("look_up", "look_down")
)
if look.length() < GAMEPAD_DEADZONE:
return
rotate_y(-look.x * gamepad_look_sensitivity * delta)
head.rotate_x((1.0 if invert_y else -1.0) * look.y * gamepad_look_sensitivity * delta)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
## Walk up kerbs and the entrance steps without jumping. Tests the blocked
## motion first, then a raised copy, and only commits when the step is short.
func _try_step_up(motion: Vector3) -> void:
if motion.length() < 0.001 or not is_on_floor():
return
var start := global_transform
if not test_move(start, motion):
return
var up := Vector3.UP * STEP_HEIGHT
if test_move(start, up):
return
var raised := start.translated(up)
if test_move(raised, motion):
return
global_transform = raised
if not test_move(global_transform, -up):
global_transform = global_transform.translated(-up)
## Landing dip, head bob, precision FOV and stride-synced footsteps.
func _update_feedback(delta: float, speed: float) -> void:
var on_floor := is_on_floor()
if on_floor and not _was_on_floor:
_land_dip = 0.055
landed.emit()
_was_on_floor = on_floor
var horizontal := Vector2(velocity.x, velocity.z).length()
if on_floor and horizontal > 0.4:
_bob_phase += delta * (5.0 + horizontal)
_stride += horizontal * delta
var stride := STRIDE_LENGTH * (1.35 if _precision else 1.0)
if _stride >= stride:
_stride = 0.0
footstep.emit(_surface_at(global_position))
_land_dip = move_toward(_land_dip, 0.0, delta * 0.55)
var bob := 0.0
if head_bob_enabled and on_floor:
bob = sin(_bob_phase * 2.0) * 0.018 * clampf(horizontal / move_speed, 0.0, 1.0)
camera.position.y = _camera_base_y + bob - _land_dip
var target_fov := _base_fov - (6.0 if _precision and horizontal > 0.1 else 0.0)
camera.fov = lerpf(camera.fov, target_fov, minf(1.0, delta * 8.0))
## Rough surface under the guard for the footstep cue.
func _surface_at(pos: Vector3) -> String:
if pos.z < 4.3 and absf(pos.x) < 5.0:
return "stone"
if pos.z < 12.2:
return "concrete"
return "asphalt"
func _update_target() -> void:
if camera == null:
return
+484 -49
View File
@@ -26,6 +26,11 @@ var _reaction_timer := 0.0
const RUBBERNECK_S := 12.0
const FLEE_S := 20.0
const FLEE_SPEED := 2.4
## LOD distances in metres. Hysteresis keeps the tier from flickering at a
## boundary; the impostor is used far enough away that the silhouette is all
## that reads.
const LOD_TIERS := [14.0, 26.0]
const LOD_HYSTERESIS := 1.5
var agent: NavigationAgent3D = null
## Set once the nav agent proves it has a path; direct movement is the fallback.
var _nav_usable := false
@@ -64,7 +69,9 @@ func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector
position = p_spawn
npc.fsm.state_changed.connect(_on_state_changed)
add_to_group("npc")
_idle_style = idle_style_for(profile.npc_type, seed_value)
_build_body(profile.npc_type)
_build_impostor()
_nav_usable = false
_nav_try_frames = 0
agent = NavigationAgent3D.new()
@@ -104,9 +111,23 @@ var _name_tag: Label3D = null
## Theater props and limbs for the idle poses (ADR-0010 rig).
var _photo_prop: MeshInstance3D = null
var _sign_prop: MeshInstance3D = null
var _legs: Array[MeshInstance3D] = []
var _arms: Array[MeshInstance3D] = []
var _legs: Array[Node3D] = []
var _arms: Array[Node3D] = []
var _knees: Array[Node3D] = []
var _elbows: Array[Node3D] = []
var _hands: Array[Node3D] = []
var _theater_t := 0.0
var _idle_t := 0.0
var _idle_style := "breathe"
var _reaction_tag: Label3D = null
var _impostor: Node3D = null
var _detail_nodes: Array[Node3D] = []
var _lod_tier := 0
var _lod_check_s := 0.0
var _queue_offset := Vector3.ZERO
var _height_scale := 1.0
var _shirt_color := Color(0.7, 0.7, 0.7)
var _skin_color := Color(0.88, 0.72, 0.60)
var _avoidance_active := false
var _photo_prop_base_y := 0.0
var _sign_prop_base_y := 0.0
@@ -132,6 +153,50 @@ const ACCESSORIES := {
Rules.NpcType.CONFUSED_VISITOR: "none",
}
const HAIR_STYLES := ["short", "long", "bun", "beard", "bald", "short"]
const HAND_ITEMS := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
## Outer layer per role/seed. Pure so the same person always dresses the same.
static func layer_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.RESPONDER:
return "vest"
Rules.NpcType.DELIVERY:
return "vest"
Rules.NpcType.AUTHORIZED_STAFF:
return "jacket"
Rules.NpcType.INFLUENCER:
return "coat"
Rules.NpcType.PERFORMER:
return "dress" if seed_value % 2 == 0 else "coat"
Rules.NpcType.PROTESTER:
return "jacket"
Rules.NpcType.AUTHORIZED_GUEST:
return "jacket" if seed_value % 3 == 0 else "none"
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
return "coat"
_:
return "none"
## Deterministic hand item so silhouettes differ without changing the role read.
static func hand_item_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
return "clipboard"
Rules.NpcType.TOURIST:
return "camera"
Rules.NpcType.INFLUENCER:
return "phone"
Rules.NpcType.AUTHORIZED_GUEST:
return "phone" if seed_value % 4 == 0 else "none"
Rules.NpcType.CONFUSED_VISITOR:
return "cup"
Rules.NpcType.PERFORMER, Rules.NpcType.PROTESTER:
return "none"
_:
return HAND_ITEMS[(seed_value * 5 + 1) % 3 + 3] \
if seed_value % 5 == 0 else "none"
## Clothing palette for one avatar. Pure and seed-driven so crowds read as
## individuals and the result is deterministic (tests rely on that).
static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
@@ -142,17 +207,70 @@ static func outfit_for(npc_type: int, seed_value: int) -> Dictionary:
# Jeans through khaki/chino, so not every avatar wears the same trousers.
var pants := Color(0.16, 0.18, 0.23).lerp(Color(0.46, 0.38, 0.28), frac)
var hair_index := (seed_value * 7) % HAIR_COLORS.size()
var style_index := (seed_value * 13 + npc_type * 3) % HAIR_STYLES.size()
return {
"shirt": shirt,
"sleeves": shirt.darkened(0.16),
"pants": pants,
"hair": HAIR_COLORS[hair_index],
"hair_style": HAIR_STYLES[style_index],
"skin": SKIN_TONE.darkened(frac * 0.18),
"accessory": ACCESSORIES.get(npc_type, "none"),
"layer": layer_for(npc_type, seed_value),
"hand_item": hand_item_for(npc_type, seed_value),
"height_scale": 0.92 + 0.16 * frac,
"blend": frac,
}
## Idle behaviour style: someone chatting on a phone, arms crossed, looking
## around, or just breathing. Pure so it is deterministic and testable.
static func idle_style_for(npc_type: int, seed_value: int) -> String:
match npc_type:
Rules.NpcType.INFLUENCER, Rules.NpcType.TOURIST:
return "phone"
Rules.NpcType.PROTESTER, Rules.NpcType.MENTAL_CRISIS:
return "look"
Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY:
return "clipboard"
Rules.NpcType.TRESPASSER, Rules.NpcType.VIOLENT_THREAT:
return "look"
_:
if seed_value % 3 == 0:
return "crossed"
return "breathe"
## Queue slot behind the door for the nth person waiting, so arrivals stack
## instead of sharing one point.
static func queue_slot_for(index: int) -> Vector3:
var slots := [
Vector3(0.0, 0.0, 0.0),
Vector3(0.85, 0.0, 0.45),
Vector3(-0.85, 0.0, 0.45),
Vector3(0.85, 0.0, 1.15),
Vector3(-0.85, 0.0, 1.15),
Vector3(0.0, 0.0, 1.85),
]
return slots[clampi(index, 0, slots.size() - 1)]
static func lod_tier_for_distance(distance: float) -> int:
if distance < LOD_TIERS[0]:
return 0
if distance < LOD_TIERS[1]:
return 1
return 2
## Short reaction symbol shown above the head: readable crowd state at a glance.
static func reaction_label(mode: String, escalation: int) -> String:
if mode == "flee":
return "!!"
if mode == "watch":
return "?!"
if escalation >= 3:
return "!"
if escalation >= 2:
return "?"
return ""
func _build_body(npc_type: int) -> void:
_rig = Node3D.new()
_rig.name = "Rig"
@@ -163,53 +281,184 @@ func _build_body(npc_type: int) -> void:
var pants: Color = outfit["pants"]
var skin: Color = outfit["skin"]
var hair: Color = outfit["hair"]
_rig.scale.y = float(outfit["height_scale"])
_height_scale = float(outfit["height_scale"])
_rig.scale.y = _height_scale
_shirt_color = shirt
_skin_color = skin
var limbs_color := sleeves
# Torso, with a shoulder block and hips so the silhouette is not a tube.
_add_capsule(_rig, 0.30, 0.90, Vector3(0, 1.02, 0), shirt, 0.0, "cloth")
_add_box_mesh(_rig, Vector3(0.58, 0.14, 0.28), Vector3(0, 1.36, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.44, 0.18, 0.26), Vector3(0, 0.62, 0), pants, "cloth")
# Legs and arms are tracked so the walk cycle can swing them.
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(-0.16, 0.4, 0), pants, 0.0, "cloth"))
_legs.append(_add_capsule(_rig, 0.12, 0.8, Vector3(0.16, 0.4, 0), pants, 0.0, "cloth"))
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(-0.42, 1.28, 0), limbs_color, 8.0, "cloth"))
_arms.append(_add_capsule(_rig, 0.09, 0.7, Vector3(0.42, 1.28, 0), limbs_color, -8.0, "cloth"))
# Hands and shoes give the limbs an end point at conversation distance.
_add_sphere(_rig, 0.065, Vector3(-0.47, 0.94, 0.03), skin, "skin")
_add_sphere(_rig, 0.065, Vector3(0.47, 0.94, 0.03), skin, "skin")
# Neck, head, face, hair, shoes so the body reads as a person (ADR-0010).
# Hips and torso, with a shoulder block so the silhouette is not a tube.
_add_box_mesh(_rig, Vector3(0.42, 0.64, 0.27), Vector3(0, 1.05, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.50, 0.13, 0.26), Vector3(0, 1.38, 0), shirt, "cloth")
_add_box_mesh(_rig, Vector3(0.38, 0.20, 0.25), Vector3(0, 0.70, 0), pants, "cloth")
_add_layer(str(outfit["layer"]), shirt)
# Articulated legs: hip pivot -> thigh -> knee -> shin -> shoe.
for side in [-1.0, 1.0]:
var hip := Node3D.new()
hip.name = "Hip%s" % ("L" if side < 0.0 else "R")
hip.position = Vector3(0.13 * side, 0.64, 0)
_rig.add_child(hip)
_add_capsule(hip, 0.105, 0.36, Vector3(0, -0.17, 0), pants, 0.0, "cloth")
var knee := Node3D.new()
knee.name = "Knee"
knee.position = Vector3(0, -0.34, 0)
hip.add_child(knee)
_add_capsule(knee, 0.09, 0.32, Vector3(0, -0.15, 0), pants, 0.0, "cloth")
_add_box_mesh(knee, Vector3(0.16, 0.10, 0.30),
Vector3(0, -0.33, 0.05), Color(0.12, 0.12, 0.14), "rubber")
_legs.append(hip)
_knees.append(knee)
# Articulated arms: shoulder pivot -> upper arm -> elbow -> forearm -> hand.
for side in [-1.0, 1.0]:
var shoulder := Node3D.new()
shoulder.name = "Shoulder%s" % ("L" if side < 0.0 else "R")
shoulder.position = Vector3(0.33 * side, 1.38, 0)
_rig.add_child(shoulder)
_add_capsule(shoulder, 0.075, 0.32, Vector3(0, -0.15, 0), limbs_color, 0.0, "cloth")
var elbow := Node3D.new()
elbow.name = "Elbow"
elbow.position = Vector3(0, -0.30, 0)
shoulder.add_child(elbow)
_add_capsule(elbow, 0.065, 0.28, Vector3(0, -0.14, 0), limbs_color, 0.0, "cloth")
var hand := Node3D.new()
hand.name = "Hand"
hand.position = Vector3(0, -0.30, 0.02)
elbow.add_child(hand)
_add_sphere(hand, 0.065, Vector3.ZERO, skin, "skin")
_arms.append(shoulder)
_elbows.append(elbow)
_hands.append(hand)
# Neck, head, face, hair, so the body reads as a person (ADR-0010).
_add_capsule(_rig, 0.08, 0.16, Vector3(0, 1.48, 0), skin, 0.0, "skin")
_head = Node3D.new()
_head.name = "Head"
_head.position = Vector3(0, 1.62, 0)
_add_sphere(_head, 0.17, Vector3.ZERO, skin, "skin")
_add_sphere(_head, 0.18, Vector3(0, 0.08, -0.02), hair, "cloth")
_add_sphere(_head, 0.035, Vector3(-0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
_add_sphere(_head, 0.035, Vector3(0.06, 0.02, 0.14), Color(0.08, 0.08, 0.1), "glass")
_add_ears(_head, skin)
_add_face(_head)
_add_hair(_head, str(outfit["hair_style"]), hair)
_rig.add_child(_head)
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(-0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_box_mesh(_rig, Vector3(0.16, 0.08, 0.28), Vector3(0.16, 0.05, 0.02), Color(0.12, 0.12, 0.14), "rubber")
_add_accessory(str(outfit["accessory"]), shirt)
_add_hand_item(str(outfit["hand_item"]), skin)
_build_focus_circle()
_add_props(npc_type)
## Outer layer: jacket, hi-vis vest, coat, or dress over the base shirt.
func _add_layer(kind: String, shirt: Color) -> void:
match kind:
"jacket":
_add_box_mesh(_rig, Vector3(0.46, 0.54, 0.31), Vector3(0, 1.08, 0),
shirt.darkened(0.28), "cloth")
_add_box_mesh(_rig, Vector3(0.05, 0.48, 0.02), Vector3(0, 1.12, 0.165),
shirt.darkened(0.42), "cloth")
"vest":
_add_box_mesh(_rig, Vector3(0.45, 0.44, 0.30), Vector3(0, 1.06, 0),
Color(0.92, 0.72, 0.12), "cloth")
_add_box_mesh(_rig, Vector3(0.48, 0.06, 0.31), Vector3(0, 0.84, 0),
Color(0.82, 0.82, 0.84), "cloth")
"coat":
_add_box_mesh(_rig, Vector3(0.47, 0.80, 0.33), Vector3(0, 1.01, 0),
shirt.darkened(0.35), "cloth")
_add_box_mesh(_rig, Vector3(0.54, 0.08, 0.30), Vector3(0, 1.41, 0),
shirt.darkened(0.42), "cloth")
_add_box_mesh(_rig, Vector3(0.04, 0.60, 0.02), Vector3(0, 1.02, 0.17),
shirt.darkened(0.5), "cloth")
"dress":
var skirt := CylinderMesh.new()
skirt.top_radius = 0.24
skirt.bottom_radius = 0.38
skirt.height = 0.52
var mi := MeshInstance3D.new()
mi.mesh = skirt
mi.position = Vector3(0, 0.82, 0)
mi.material_override = _material(shirt.darkened(0.1), "cloth")
_rig.add_child(mi)
_:
pass
func _add_ears(head: Node3D, skin: Color) -> void:
for side in [-1.0, 1.0]:
_add_sphere(head, 0.045, Vector3(0.165 * side, 0.0, 0.0), skin, "skin")
func _add_face(head: Node3D) -> void:
var eye := Color(0.08, 0.08, 0.1)
for side in [-1.0, 1.0]:
_add_sphere(head, 0.032, Vector3(0.06 * side, 0.02, 0.145), eye, "glass")
_add_box_mesh(head, Vector3(0.07, 0.015, 0.02),
Vector3(0.06 * side, 0.075, 0.152), Color(0.16, 0.12, 0.10), "cloth")
_add_box_mesh(head, Vector3(0.09, 0.015, 0.02), Vector3(0, -0.075, 0.15),
Color(0.42, 0.22, 0.20), "cloth")
func _add_hair(head: Node3D, style: String, color: Color) -> void:
match style:
"long":
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
_add_box_mesh(head, Vector3(0.30, 0.36, 0.12), Vector3(0, -0.14, -0.11),
color, "cloth")
"bun":
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
_add_sphere(head, 0.09, Vector3(0, 0.20, -0.10), color, "cloth")
"beard":
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
_add_box_mesh(head, Vector3(0.22, 0.14, 0.10), Vector3(0, -0.11, 0.09),
color.darkened(0.1), "cloth")
"bald":
pass
_:
_add_sphere(head, 0.185, Vector3(0, 0.07, -0.02), color, "cloth")
## Hand-held item attached to the right hand socket: phone, cup, clipboard,
## umbrella, camera, or nothing.
func _add_hand_item(kind: String, skin: Color) -> void:
if _hands.size() < 2:
return
var hand: Node3D = _hands[1]
match kind:
"phone":
_detail(_add_box_mesh(hand, Vector3(0.055, 0.11, 0.012), Vector3(0, -0.03, 0.02),
Color(0.10, 0.11, 0.14), "glass"))
"cup":
_detail(_add_capsule(hand, 0.035, 0.10, Vector3(0, -0.05, 0.02),
Color(0.90, 0.88, 0.82), 0.0, "cloth"))
"clipboard":
_detail(_add_box_mesh(hand, Vector3(0.22, 0.28, 0.02), Vector3(0, -0.08, 0.05),
Color(0.55, 0.40, 0.22), "wood"))
_detail(_add_box_mesh(hand, Vector3(0.16, 0.02, 0.01), Vector3(0, 0.05, 0.07),
Color(0.85, 0.85, 0.88), "metal"))
"umbrella":
_detail(_add_capsule(hand, 0.014, 0.85, Vector3(0, -0.30, 0.02),
Color(0.15, 0.16, 0.20), 0.0, "metal"))
var canopy := CylinderMesh.new()
canopy.top_radius = 0.02
canopy.bottom_radius = 0.34
canopy.height = 0.22
var mi := MeshInstance3D.new()
mi.mesh = canopy
mi.position = Vector3(0, 0.12, 0.02)
mi.material_override = _material(skin.lerp(Color(0.2, 0.25, 0.4), 0.7), "cloth")
hand.add_child(mi)
_detail(mi)
"camera":
_detail(_add_box_mesh(hand, Vector3(0.16, 0.10, 0.09), Vector3(0, -0.04, 0.02),
Color(0.10, 0.10, 0.12), "metal"))
_detail(_add_sphere(hand, 0.045, Vector3(0, -0.02, 0.08), Color(0.2, 0.35, 0.5), "glass"))
## Small role accessory: cap, hood, backpack, sunglasses or a staff lanyard.
func _add_accessory(kind: String, shirt: Color) -> void:
match kind:
"cap":
_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth")
_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth")
_detail(_add_sphere(_head, 0.175, Vector3(0, 0.12, 0), shirt.darkened(0.15), "cloth"))
_detail(_add_box_mesh(_head, Vector3(0.30, 0.03, 0.16), Vector3(0, 0.10, 0.16), shirt.darkened(0.22), "cloth"))
"hood":
_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth")
_detail(_add_sphere(_head, 0.205, Vector3(0, 0.03, -0.03), Color(0.16, 0.16, 0.18), "cloth"))
"backpack":
_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth")
_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth")
_detail(_add_box_mesh(_rig, Vector3(0.34, 0.42, 0.16), Vector3(0, 1.06, -0.24), Color(0.24, 0.28, 0.36), "cloth"))
_detail(_add_box_mesh(_rig, Vector3(0.30, 0.06, 0.02), Vector3(0, 1.24, -0.33), Color(0.42, 0.44, 0.5), "cloth"))
"sunglasses":
_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass")
_detail(_add_box_mesh(_head, Vector3(0.24, 0.05, 0.03), Vector3(0, 0.04, 0.15), Color(0.06, 0.06, 0.08), "glass"))
"lanyard":
_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal")
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth")
_detail(_add_box_mesh(_rig, Vector3(0.05, 0.06, 0.02), Vector3(0, 1.20, 0.16), Color(0.85, 0.78, 0.30), "painted_metal"))
_detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(-0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth"))
_detail(_add_box_mesh(_rig, Vector3(0.02, 0.20, 0.02), Vector3(0.08, 1.34, 0.14), Color(0.3, 0.32, 0.36), "cloth"))
_:
pass
@@ -243,6 +492,14 @@ func _build_focus_circle() -> void:
_name_tag.modulate = Color(0.95, 0.95, 0.9, 0.9)
_name_tag.visible = false
_head.add_child(_name_tag)
_reaction_tag = Label3D.new()
_reaction_tag.name = "ReactionTag"
_reaction_tag.font_size = 72
_reaction_tag.pixel_size = 0.005
_reaction_tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
_reaction_tag.position = Vector3(0, 2.05, 0)
_reaction_tag.visible = false
_rig.add_child(_reaction_tag)
func set_focus_visual(focused: bool, menu_open: bool = false) -> void:
if _focus_circle:
@@ -268,7 +525,7 @@ func _add_capsule(parent: Node3D, radius: float, height: float, pos: Vector3,
return mi
func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
role: String = "skin") -> void:
role: String = "skin") -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := SphereMesh.new()
mesh.radius = radius
@@ -277,6 +534,12 @@ func _add_sphere(parent: Node3D, radius: float, pos: Vector3, color: Color,
mi.position = pos
mi.material_override = _material(color, role)
parent.add_child(mi)
return mi
## Register a small dress detail so the mid-distance LOD can hide it.
func _detail(node: Node3D) -> Node3D:
_detail_nodes.append(node)
return node
func _add_box_mesh(parent: Node3D, size: Vector3, pos: Vector3, color: Color,
role: String = "cloth") -> MeshInstance3D:
@@ -399,6 +662,12 @@ func _face_reaction_point(delta: float) -> void:
func _physics_process(delta: float) -> void:
if npc == null or npc.fsm == null:
return
_tick_lod(delta)
if awaiting_interaction and _queue_offset != Vector3.ZERO:
var queued := door_position + _queue_offset
if target_position.distance_to(queued) > 0.05:
target_position = queued
arrived = false
_advance_fsm(delta)
_tick_reaction(delta)
if reaction_mode == "watch":
@@ -459,20 +728,32 @@ func _animate(delta: float) -> void:
return
var speed := Vector2(velocity.x, velocity.z).length()
var pose := theater_pose()
var escalation: int = npc.highest_escalation_received if npc != null else 0
var crouch := crouch_for_pose(pose)
if speed > 0.3:
_bob_phase += delta * (6.0 + speed)
_rig.position.y = sin(_bob_phase) * 0.035
_rig.rotation.x = sin(_bob_phase * 0.5) * 0.04
_idle_t = 0.0
_bob_phase += delta * (6.0 + speed * 1.7)
var urgency := 1.35 if reaction_mode == "flee" else 1.0
var stride := clampf(0.36 + speed * 0.10, 0.42, 0.7) * urgency
_rig.position.y = sin(_bob_phase * 2.0) * 0.03
_rig.position.x = 0.0
_rig.rotation.x = sin(_bob_phase) * 0.03
_rig.rotation.z = sin(_bob_phase) * 0.02
for i in _legs.size():
_legs[i].rotation.x = limb_swing(_bob_phase, i, 0.5)
for i in _arms.size():
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.38)
var swing := limb_swing(_bob_phase, i, stride)
_legs[i].rotation.x = swing
# Bend the knee as the leg travels behind the body.
var bend := maxf(0.0, -swing) * (1.7 if reaction_mode == "flee" else 1.15)
_knees[i].rotation.x = lerpf(_knees[i].rotation.x, bend, 0.35)
_arms[i].rotation.x = limb_swing(_bob_phase, i, -0.34 * urgency)
_elbows[i].rotation.x = -0.18 - absf(swing) * 0.55
elif pose != "idle" or not is_zero_approx(_rig.position.y):
# Idle theater: seated, photographing, filming, or performing.
_theater_t += delta
_idle_t = 0.0
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, crouch, 0.12)
_rig.position.x = lerpf(_rig.position.x, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
var lift := prop_lift_for_pose(pose, _theater_t)
if _photo_prop:
@@ -487,28 +768,182 @@ func _animate(delta: float) -> void:
var leg_angle := deg_to_rad(-78.0) if pose == "sit" else 0.0
_legs[0].rotation.x = lerpf(_legs[0].rotation.x, leg_angle, 0.12)
_legs[1].rotation.x = lerpf(_legs[1].rotation.x, leg_angle, 0.12)
var knee_angle := deg_to_rad(80.0) if pose == "sit" else 0.0
_knees[0].rotation.x = lerpf(_knees[0].rotation.x, knee_angle, 0.12)
_knees[1].rotation.x = lerpf(_knees[1].rotation.x, knee_angle, 0.12)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.14)
for elbow in _elbows:
elbow.rotation.x = lerpf(elbow.rotation.x, -0.1, 0.14)
else:
_bob_phase = 0.0
_rig.position.y = lerpf(_rig.position.y, 0.0, 0.2)
_rig.rotation.x = lerpf(_rig.rotation.x, 0.0, 0.2)
for i in _legs.size():
_legs[i].rotation.x = lerpf(_legs[i].rotation.x, 0.0, 0.2)
for arm in _arms:
arm.rotation.x = lerpf(arm.rotation.x, 0.0, 0.2)
_knees[i].rotation.x = lerpf(_knees[i].rotation.x, 0.0, 0.2)
for i in _arms.size():
_arms[i].rotation.x = lerpf(_arms[i].rotation.x, 0.0, 0.18)
_elbows[i].rotation.x = lerpf(_elbows[i].rotation.x, -0.08, 0.18)
_idle_t += delta
_idle_layer(pose, escalation)
# The focus circle billboards via its material; no per-frame look_at needed.
# Attention cue: head turns toward the guard when awareness was raised.
if _head != null and npc.guard_noticed:
if _guard_ref == null or not is_instance_valid(_guard_ref):
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
if _guard_ref:
var to_guard := (_guard_ref.global_position - global_position)
to_guard.y = 0.0
var world_yaw := atan2(to_guard.x, to_guard.z)
var fwd := -_rig.global_transform.basis.z
var parent_yaw := atan2(fwd.x, fwd.z)
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
if _head != null:
if npc != null and npc.guard_noticed:
if _guard_ref == null or not is_instance_valid(_guard_ref):
_guard_ref = get_tree().get_first_node_in_group("guard") as Node3D
if _guard_ref:
var to_guard := (_guard_ref.global_position - global_position)
to_guard.y = 0.0
var world_yaw := atan2(to_guard.x, to_guard.z)
var fwd := -_rig.global_transform.basis.z
var parent_yaw := atan2(fwd.x, fwd.z)
_head.rotation.y = lerp_angle(_head.rotation.y, world_yaw - parent_yaw, 0.1)
elif speed <= 0.3 and pose == "idle":
# Idle look-around, unless the person is already engaged.
_head.rotation.y = lerpf(_head.rotation.y, sin(_idle_t * 0.45) * 0.3, 0.05)
_update_reaction_label(escalation)
## Idle behaviour layered on top of the breathing pose: phone, clipboard,
## crossed arms, or looking around, plus escalation body language.
func _idle_layer(pose: String, escalation: int) -> void:
if _rig == null:
return
# Breathing and a slow weight shift keep idle people from looking frozen.
var breath := 1.0 + sin(_idle_t * 1.8) * 0.006
_rig.scale.y = lerpf(_rig.scale.y, _height_scale * breath, 0.15)
_rig.position.x = lerpf(_rig.position.x, sin(_idle_t * 0.7) * 0.015, 0.1)
_rig.rotation.z = lerpf(_rig.rotation.z, sin(_idle_t * 0.7) * 0.015, 0.1)
if pose != "idle" or _arms.size() < 2:
return
var right := _arms[1]
var right_elbow := _elbows[1]
if escalation >= 3:
# Both hands up: the person is being blocked or warned.
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -1.75, 0.14)
right.rotation.x = lerpf(right.rotation.x, -1.75, 0.14)
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.5, 0.14)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.5, 0.14)
_rig.rotation.x = lerpf(_rig.rotation.x, -0.06, 0.1)
return
if escalation >= 2:
# One arm out, pointing forward while arguing.
right.rotation.x = lerpf(right.rotation.x, -1.15, 0.12)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.15, 0.12)
return
match _idle_style:
"phone":
# Phone held up near the head in bursts.
var raise := 0.55 + maxf(0.0, sin(_idle_t * 0.6)) * 0.45
right.rotation.x = lerpf(right.rotation.x, -1.35 * raise, 0.08)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.75 * raise, 0.1)
"clipboard":
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1)
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -0.9, 0.1)
right.rotation.x = lerpf(right.rotation.x, -0.45, 0.1)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -0.8, 0.1)
"crossed":
_arms[0].rotation.x = lerpf(_arms[0].rotation.x, -0.55, 0.1)
right.rotation.x = lerpf(right.rotation.x, -0.55, 0.1)
_elbows[0].rotation.x = lerpf(_elbows[0].rotation.x, -1.25, 0.1)
right_elbow.rotation.x = lerpf(right_elbow.rotation.x, -1.25, 0.1)
"look":
pass
_:
pass
func _update_reaction_label(escalation: int) -> void:
if _reaction_tag == null:
return
var text := reaction_label(reaction_mode, escalation)
_reaction_tag.visible = text != ""
if text == "":
return
_reaction_tag.text = text
if reaction_mode == "flee":
_reaction_tag.modulate = Color(1.0, 0.26, 0.2)
elif reaction_mode == "watch":
_reaction_tag.modulate = Color(1.0, 0.72, 0.2)
elif escalation >= 3:
_reaction_tag.modulate = Color(1.0, 0.35, 0.3)
else:
_reaction_tag.modulate = Color(1.0, 0.85, 0.4)
## Build the far-distance impostor: two billboard quads (body + head) in the
## avatar's clothing colours.
func _build_impostor() -> void:
_impostor = Node3D.new()
_impostor.name = "Impostor"
_impostor.visible = false
add_child(_impostor)
var body := MeshInstance3D.new()
var quad := QuadMesh.new()
quad.size = Vector2(0.56, 1.15)
body.mesh = quad
body.position = Vector3(0, 0.95, 0)
body.material_override = _billboard_material(_shirt_color)
_impostor.add_child(body)
var head := MeshInstance3D.new()
var head_quad := QuadMesh.new()
head_quad.size = Vector2(0.34, 0.34)
head.mesh = head_quad
head.position = Vector3(0, 1.63, 0)
head.material_override = _billboard_material(_skin_color)
_impostor.add_child(head)
func _billboard_material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.albedo_color = color
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
return material
## Apply a LOD tier: 0 full detail, 1 no small props/shadows, 2 impostor.
func apply_lod(tier: int) -> void:
_lod_tier = clampi(tier, 0, 2)
if _rig != null:
_rig.visible = _lod_tier < 2
if _impostor != null:
_impostor.visible = _lod_tier == 2
for node in _detail_nodes:
if is_instance_valid(node):
node.visible = _lod_tier == 0
var shadows := _lod_tier == 0
for node in find_children("*", "MeshInstance3D", true, false):
(node as MeshInstance3D).cast_shadow = \
GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows \
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
func _lod_tier_for(distance: float) -> int:
# Hysteresis: stay in the current tier until the distance clears the edge.
if _lod_tier == 0:
return 1 if distance > LOD_TIERS[0] + LOD_HYSTERESIS else 0
if _lod_tier == 1:
if distance < LOD_TIERS[0] - LOD_HYSTERESIS:
return 0
return 2 if distance > LOD_TIERS[1] + LOD_HYSTERESIS else 1
if distance < LOD_TIERS[1] - LOD_HYSTERESIS:
return 1 if distance >= LOD_TIERS[0] else 0
return 2
func set_queue_offset(offset: Vector3) -> void:
_queue_offset = offset
func _tick_lod(delta: float) -> void:
_lod_check_s -= delta
if _lod_check_s > 0.0:
return
_lod_check_s = 0.5
if not is_inside_tree():
return
var camera := get_viewport().get_camera_3d()
if camera == null:
return
var distance := global_position.distance_to(camera.global_position)
var desired := _lod_tier_for(distance)
if desired != _lod_tier:
apply_lod(desired)
func _advance_fsm(delta: float) -> void:
# Routine traffic despawns after a while once it has finished its walk, so
+24
View File
@@ -45,6 +45,8 @@ var wand_panel: PanelContainer
var wand_text: Label
var wand_meta: Label
var escalation_label: Label
var precision_label: Label
var _controls_label: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
@@ -119,7 +121,9 @@ func _init() -> void:
controls.text = Strings.lookup("hud.controls")
controls.add_theme_font_size_override("font_size", 12)
controls.modulate = Color(0.8, 0.8, 0.85)
controls.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
vbox.add_child(controls)
_controls_label = controls
# --- emergency radio cooldown indicator ---
radio_cooldown_label = Label.new()
@@ -133,6 +137,12 @@ func _init() -> void:
mode_label.text = "Door: open · Light: off"
mode_label.add_theme_font_size_override("font_size", 13)
mode_label.modulate = Color(0.85, 0.85, 0.9)
precision_label = Label.new()
precision_label.text = Strings.lookup("hud.precision")
precision_label.add_theme_font_size_override("font_size", 12)
precision_label.modulate = Color(0.55, 0.85, 1.0)
precision_label.visible = false
vbox.add_child(precision_label)
vbox.add_child(mode_label)
# --- escalation level indicator (color-coded) ---
@@ -371,6 +381,20 @@ func _add_corner_brackets(color: Color) -> void:
func set_action_hint(text: String) -> void:
hint_label.text = text
## Precision-walk state chip next to the door/light indicators.
func set_precision(active: bool) -> void:
if precision_label:
precision_label.visible = active
## Swap the control legend for the device the player is actually using.
func set_input_device(device: String) -> void:
if _controls_label == null:
return
if device == "gamepad":
_controls_label.text = Strings.lookup("hud.controls_gamepad")
else:
_controls_label.text = Strings.lookup("hud.controls")
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
+85
View File
@@ -6,6 +6,8 @@ extends CanvasLayer
## Emits `applied(settings)` so the game manager can push values into the guard
## and HUD as soon as the player confirms.
const InputBindings := preload("res://src/core/input_bindings.gd")
signal applied(settings: Dictionary)
signal closed()
@@ -14,6 +16,10 @@ var _sensitivity: HSlider = null
var _font_scale: HSlider = null
var _sensitivity_value: Label = null
var _font_scale_value: Label = null
var _invert_y: CheckButton = null
var _head_bob: CheckButton = null
var _bind_buttons := {}
var _listening_action := ""
var settings: Dictionary = {}
func _ready() -> void:
@@ -66,6 +72,37 @@ func _build() -> void:
_font_scale = _add_slider(font_row, 0.75, 1.75, 0.05)
_font_scale.value_changed.connect(_on_font_scale_changed)
_invert_y = CheckButton.new()
_invert_y.text = "Invert vertical look"
_invert_y.toggled.connect(_on_invert_y_toggled)
column.add_child(_invert_y)
_head_bob = CheckButton.new()
_head_bob.text = "Head bob and camera motion"
_head_bob.toggled.connect(_on_head_bob_toggled)
column.add_child(_head_bob)
var controls_title := Label.new()
controls_title.text = "Controls (click a binding, then press a key or pad button)"
controls_title.add_theme_font_size_override("font_size", 13)
controls_title.modulate = Color(0.75, 0.8, 0.9)
column.add_child(controls_title)
var grid := GridContainer.new()
grid.columns = 4
grid.add_theme_constant_override("h_separation", 10)
grid.add_theme_constant_override("v_separation", 6)
column.add_child(grid)
for action in InputBindings.REMAPPABLE:
var label := Label.new()
label.text = str(action).replace("_", " ").capitalize()
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
grid.add_child(label)
var button := Button.new()
button.custom_minimum_size = Vector2(130, 0)
button.pressed.connect(_on_rebind.bind(action))
grid.add_child(button)
_bind_buttons[action] = button
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
@@ -112,6 +149,12 @@ func open(current: Dictionary) -> void:
_sensitivity.set_value_no_signal(clampf(sens, _sensitivity.min_value, _sensitivity.max_value))
if _font_scale:
_font_scale.set_value_no_signal(clampf(scale, _font_scale.min_value, _font_scale.max_value))
if _invert_y:
_invert_y.set_pressed_no_signal(bool(settings.get("invert_y", false)))
if _head_bob:
_head_bob.set_pressed_no_signal(bool(settings.get("head_bob", true)))
_listening_action = ""
_refresh_bind_buttons()
_sync_labels()
visible = true
_root.visible = true
@@ -135,6 +178,42 @@ func _on_font_scale_changed(value: float) -> void:
settings["hud_font_scale"] = value
_sync_labels()
func _on_invert_y_toggled(pressed: bool) -> void:
settings["invert_y"] = pressed
func _on_head_bob_toggled(pressed: bool) -> void:
settings["head_bob"] = pressed
func _refresh_bind_buttons() -> void:
var remaps: Dictionary = settings.get("input_bindings", {})
for action in _bind_buttons.keys():
var button: Button = _bind_buttons[action]
var descriptor: Dictionary = remaps.get(action, {})
if descriptor.is_empty():
descriptor = InputBindings.current_descriptor(action)
button.text = InputBindings.label_for(descriptor)
func _on_rebind(action: String) -> void:
_listening_action = action
var button: Button = _bind_buttons.get(action)
if button:
button.text = "Press…"
if button.is_inside_tree():
button.grab_focus()
func _apply_remap(event: InputEvent) -> bool:
if _listening_action == "":
return false
var descriptor := InputBindings.descriptor_from_event(event)
if descriptor.is_empty():
return false
var remaps: Dictionary = settings.get("input_bindings", {})
remaps[_listening_action] = descriptor
settings["input_bindings"] = remaps
_listening_action = ""
_refresh_bind_buttons()
return true
func _on_apply() -> void:
if Save:
Save.save_settings(settings)
@@ -149,6 +228,12 @@ func _on_cancel() -> void:
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if _listening_action != "":
var pressed: bool = (event is InputEventKey and event.pressed and not event.echo) \
or (event is InputEventJoypadButton and event.pressed)
if pressed and _apply_remap(event):
get_viewport().set_input_as_handled()
return
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
_on_cancel()
get_viewport().set_input_as_handled()
+13
View File
@@ -188,6 +188,19 @@ func _advance() -> void:
10:
if state_frames >= 30:
_shoot("09-body-camera-timeline.png")
if gm != null:
gm.event_timeline.close()
gm.guard.global_position = Vector3(0, 0.1, 6.2)
gm.guard.rotation.y = 0.0
Input.action_press("move_forward")
Input.action_press("walk_slow")
state = 11
state_frames = 0
11:
if state_frames >= 50:
_shoot("11-precision-walk.png")
Input.action_release("move_forward")
Input.action_release("walk_slow")
quit(0)
## Put the guard a couple of steps in front of the target so a staged UI capture
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
## Movement smoke (windowed): walks the guard to the entrance to check step-up,
## measures precision-walk speed, and checks footstep/landing cues. The guard
## reads captured-mouse input, so run it windowed:
##
## godot --path . --script res://src/test/smoke_movement.gd
##
## Exit code 0 = pass, 1 = failure.
const MAX_FRAMES := 1600
var frame := 0
var gm: Node = null
var started := false
var step := 0
var step_frame := 0
var failures: Array[String] = []
var start_pos := Vector3.ZERO
var normal_distance := 0.0
var footsteps_before := 0
func _initialize() -> void:
root.size = Vector2i(1280, 720)
root.add_child(load("res://scenes/main.tscn").instantiate())
func _process(_delta: float) -> bool:
frame += 1
if gm == null:
gm = root.find_child("GameManager", true, false)
return false
if not started:
gm.call("_on_venue_selected", "hollywood_building")
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
started = true
return false
if frame > MAX_FRAMES:
_fail("timeout before the movement checks finished")
return true
step_frame += 1
match step:
0:
if step_frame < 20:
return false
_place_guard(8.5)
footsteps_before = gm.audio.footsteps
start_pos = gm.guard.global_position
Input.action_press("move_forward")
step = 1
step_frame = 0
1:
if step_frame >= 280:
Input.action_release("move_forward")
var moved := start_pos.distance_to(gm.guard.global_position)
_check(moved > 2.0, "guard walked toward the entrance (%.1f m)" % moved)
_check(gm.guard.global_position.y > 0.35,
"guard climbed the steps (y=%.2f)" % gm.guard.global_position.y)
_check(gm.guard.global_position.z < 4.2,
"guard reached the steps (z=%.1f)" % gm.guard.global_position.z)
_check(gm.audio.footsteps > footsteps_before,
"footsteps are emitted while walking")
step = 2
step_frame = 0
2:
if step_frame < 45:
return false
_place_guard(8.5)
normal_distance = 0.0
start_pos = gm.guard.global_position
Input.action_press("move_forward")
Input.action_press("walk_slow")
step = 3
step_frame = 0
3:
if step_frame >= 120:
Input.action_release("walk_slow")
Input.action_release("move_forward")
var moved := start_pos.distance_to(gm.guard.global_position)
_check(moved > 1.0 and moved < 4.6,
"precision walk covers less ground (%.1f m in 2 s)" % moved)
var chip = gm.hud.precision_label
_check(chip != null and chip.visible,
"precision walk shows its HUD chip")
step = 4
step_frame = 0
4:
if gm.audio.landings == 0:
gm.guard.global_position += Vector3(0, 1.2, 0)
if step_frame >= 90:
_check(gm.audio.landings > 0, "landing cue fires after a drop")
_finish()
return true
return false
func _place_guard(z: float) -> void:
gm.guard.global_position = Vector3(0, 0.1, z)
gm.guard.rotation.y = 0.0
gm.guard.velocity = Vector3.ZERO
func _check(condition: bool, message: String) -> void:
if condition:
print("ok: " + message)
else:
_fail(message)
func _fail(message: String) -> void:
failures.append(message)
_finish()
func _finish() -> void:
Input.action_release("move_forward")
Input.action_release("walk_slow")
if failures.is_empty():
print("SMOKE OK: movement feel (steps, precision walk, footsteps)")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+57
View File
@@ -0,0 +1,57 @@
extends SceneTree
## NPC performance budget smoke: put the documented live-NPC count in the scene,
## let the animations run, then report the average frame time. The budget is
## 50 ms (20 FPS) so slow CI hardware does not fail on noise; the report is the
## useful part when tuning the rig.
##
## Usage: godot --headless --path . --script res://src/test/smoke_npc_budget.gd
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
const COUNT := 16
const WARMUP_FRAMES := 60
const SAMPLE_FRAMES := 180
const BUDGET_MS := 50.0
var frame := 0
var avatars: Array = []
var sample_ms := 0.0
func _initialize() -> void:
root.size = Vector2i(1280, 720)
var stage := Node3D.new()
stage.name = "BudgetStage"
root.add_child(stage)
for index in COUNT:
var profile := NpcProfile.new()
profile.id = "budget_%d" % index
profile.npc_type = Rules.NpcType.TOURIST
profile.display_name = "Budget %d" % index
var avatar := NpcAvatar.new()
avatar.setup(profile, "roam", index + 1,
Vector3(-6.0 + (index % 8) * 1.7, 0.5, 4.0 + (index / 8) * 2.0),
Vector3(0, 0.5, 8.0), Vector3(0, 0.5, 1.2), profile.id)
stage.add_child(avatar)
avatars.append(avatar)
func _process(delta: float) -> bool:
frame += 1
if frame > WARMUP_FRAMES:
sample_ms += delta * 1000.0
if frame >= WARMUP_FRAMES + SAMPLE_FRAMES:
var average := sample_ms / SAMPLE_FRAMES
var meshes := 0
for avatar in avatars:
meshes += avatar.find_children("*", "MeshInstance3D", true, false).size()
print("NPC budget: %d avatars, %d meshes, avg frame %.2f ms (budget %.0f ms)" % [
COUNT, meshes, average, BUDGET_MS])
if average > BUDGET_MS:
printerr("SMOKE FAIL: frame time %.1f ms exceeds the %.0f ms budget" % [
average, BUDGET_MS])
quit(1)
return true
print("SMOKE OK: %d avatars stay inside the frame budget" % COUNT)
quit(0)
return true
return false
+2 -3
View File
@@ -50,9 +50,8 @@ func _process(_delta: float) -> bool:
var start := bench_center + Vector3(0, 0, 2.2)
start.y = 0.1
gm.guard.global_position = start
# Face the bench (-z): with the guard's basis, forward input at yaw PI
# walks toward lower z.
gm.guard.rotation.y = PI
# Face the bench (-z): yaw 0 looks down -z, so W walks toward it.
gm.guard.rotation.y = 0.0
guard_start = start
Input.action_press("move_forward")
step = 1
+12
View File
@@ -5,6 +5,18 @@ extends RefCounted
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd")
func test_footstep_and_landing_cues_count() -> bool:
var audio := AudioFeedback.new()
audio.play_footstep("concrete")
audio.play_footstep("asphalt")
audio.play_land()
audio.play_precision_tick()
var a := TestUtils.check_eq(audio.footsteps, 2, "footsteps are counted")
var b := TestUtils.check_eq(audio.landings, 1, "landings are counted")
var c := TestUtils.check_eq(audio.precision_ticks, 1, "precision ticks are counted")
audio.free()
return a and b and c
func test_bed_volume_follows_mood() -> bool:
var quiet: float = AudioFeedback._level_to_db(0.0)
var loud: float = AudioFeedback._level_to_db(1.0)
+53
View File
@@ -0,0 +1,53 @@
extends RefCounted
## Headless tests for the key/button remap helper.
const InputBindings := preload("res://src/core/input_bindings.gd")
func test_descriptor_from_events() -> bool:
var key := InputEventKey.new()
key.physical_keycode = KEY_Z
var a := TestUtils.check_eq(InputBindings.descriptor_from_event(key),
{"type": "key", "code": int(KEY_Z)}, "keys store a code")
var button := InputEventJoypadButton.new()
button.button_index = JOY_BUTTON_Y
var b := TestUtils.check_eq(InputBindings.descriptor_from_event(button),
{"type": "joy", "button": int(JOY_BUTTON_Y)}, "pad buttons store an index")
var c := TestUtils.check(
InputBindings.descriptor_from_event(InputEventMouseButton.new()).is_empty(),
"mouse buttons are not remappable")
return a and b and c
func test_apply_rebinds_and_keeps_mouse_events() -> bool:
var old := InputBindings.current_descriptor("inspect")
var remap := {"inspect": {"type": "key", "code": int(KEY_Z)}}
InputBindings.apply(remap)
var rebound := InputBindings.current_descriptor("inspect")
var a := TestUtils.check_eq(rebound, {"type": "key", "code": int(KEY_Z)},
"inspect now uses the new key")
var action_menu_mouse := 0
for event in InputMap.action_get_events("action_menu"):
if event is InputEventMouseButton:
action_menu_mouse += 1
var b := TestUtils.check(action_menu_mouse > 0,
"remapping leaves mouse events alone")
# Restore the original binding for later tests.
if not old.is_empty():
InputBindings.apply({"inspect": old})
return a and b
func test_apply_accepts_pad_buttons() -> bool:
var remap := {"wand": {"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)}}
InputBindings.apply(remap)
var a := TestUtils.check_eq(InputBindings.current_descriptor("wand"),
{"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)},
"actions can be rebound to a pad button")
InputBindings.apply({"wand": {"type": "key", "code": int(KEY_C)}})
return a
func test_labels_are_human_readable() -> bool:
var a := TestUtils.check(InputBindings.label_for(
{"type": "key", "code": int(KEY_W)}) == "W", "key label")
var b := TestUtils.check(InputBindings.label_for(
{"type": "joy", "button": 3}) == "Pad 3", "pad label")
var c := TestUtils.check_eq(InputBindings.label_for({}), "", "empty label")
return a and b and c
+138
View File
@@ -0,0 +1,138 @@
extends RefCounted
## Headless tests for the character pass: layered outfits, hand items, idle
## styles, queue slots, LOD tiers/labels, and the articulated rig structure.
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _avatar(npc_type: int, seed_value: int) -> NpcAvatar:
var profile := NpcProfile.new()
profile.id = "char_test"
profile.npc_type = npc_type
profile.display_name = "Test Person"
var avatar := NpcAvatar.new()
avatar.setup(profile, "enter", seed_value,
Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "char_test")
return avatar
func test_outfit_layers_and_items_are_deterministic() -> bool:
var a: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242)
var b: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242)
return TestUtils.check(
a["layer"] == b["layer"] and a["hand_item"] == b["hand_item"]
and a["hair_style"] == b["hair_style"],
"outfit layers, hand item and hair style follow the seed")
func test_layers_follow_roles() -> bool:
var a := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.RESPONDER, 1), "vest",
"responders wear a hi-vis vest")
var b := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.INFLUENCER, 1), "coat",
"influencers wear a coat")
var c := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.AUTHORIZED_STAFF, 1), "jacket",
"staff wear a jacket")
return a and b and c
func test_hand_items_are_known_and_deterministic() -> bool:
var allowed := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
var ok := true
for seed_value in range(1, 30):
var item := NpcAvatar.hand_item_for(Rules.NpcType.TOURIST, seed_value * 31)
ok = ok and item in allowed
ok = ok and item == NpcAvatar.hand_item_for(
Rules.NpcType.TOURIST, seed_value * 31)
return TestUtils.check(ok, "hand items stay in the catalogue and are deterministic")
func test_idle_styles_are_known() -> bool:
var allowed := ["phone", "crossed", "look", "clipboard", "breathe"]
var ok := true
for seed_value in range(1, 30):
var style := NpcAvatar.idle_style_for(Rules.NpcType.TOURIST, seed_value) \
if seed_value > 1 else NpcAvatar.idle_style_for(Rules.NpcType.INFLUENCER, 1)
ok = ok and style in allowed
return TestUtils.check(ok, "idle styles stay in the catalogue")
func test_queue_slots_are_spaced_and_unique() -> bool:
var seen := {}
var ok := true
var previous := NpcAvatar.queue_slot_for(0)
for index in 6:
var slot := NpcAvatar.queue_slot_for(index)
ok = ok and not seen.has(slot)
seen[slot] = true
if index > 0:
ok = ok and slot.distance_to(previous) > 0.3
previous = slot
return TestUtils.check(ok and NpcAvatar.queue_slot_for(0) == Vector3.ZERO,
"queue slots are unique, spaced, and start at the door")
func test_lod_tiers_use_hysteresis() -> bool:
var avatar := NpcAvatar.new()
avatar._lod_tier = 0
var a := TestUtils.check_eq(avatar._lod_tier_for(5.0), 0, "near stays full detail")
var b := TestUtils.check_eq(avatar._lod_tier_for(16.0), 1,
"past the hysteresis band switches to simplified")
avatar._lod_tier = 1
var c := TestUtils.check_eq(avatar._lod_tier_for(13.0), 1,
"hysteresis keeps the simplified tier just inside the edge")
var d := TestUtils.check_eq(avatar._lod_tier_for(12.0), 0,
"clearing the hysteresis band returns to full detail")
avatar._lod_tier = 1
var e := TestUtils.check_eq(avatar._lod_tier_for(30.0), 2, "far uses the impostor")
avatar.free()
return a and b and c and d and e
func test_reaction_labels_match_state() -> bool:
var a := TestUtils.check_eq(NpcAvatar.reaction_label("flee", 0), "!!", "panic label")
var b := TestUtils.check_eq(NpcAvatar.reaction_label("watch", 0), "?!", "watching label")
var c := TestUtils.check_eq(NpcAvatar.reaction_label("", 3), "!", "blocked label")
var d := TestUtils.check_eq(NpcAvatar.reaction_label("", 0), "", "calm has no label")
return a and b and c and d
func test_walk_to_idle_animation_transition() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 21)
var hip := avatar.find_child("HipL", true, false) as Node3D
var a := TestUtils.check(hip != null, "rig has a hip pivot")
avatar.velocity = Vector3(0, 0, 1.2)
avatar._animate(0.016)
var walking := absf(hip.rotation.x) > 0.01
var b := TestUtils.check(walking, "walking swings the hip")
avatar.velocity = Vector3.ZERO
for i in 90:
avatar._animate(0.016)
var c := TestUtils.check(absf(hip.rotation.x) < 0.05,
"stopping settles the hip back to the idle pose (%.3f)" % hip.rotation.x)
avatar.free()
return a and b and c
func test_rig_is_articulated() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 77)
var a := TestUtils.check(avatar.find_child("HipL", true, false) != null
and avatar.find_child("Knee", true, false) != null,
"legs have hip and knee pivots")
var b := TestUtils.check(avatar.find_child("ShoulderR", true, false) != null
and avatar.find_child("Elbow", true, false) != null
and avatar.find_child("Hand", true, false) != null,
"arms have shoulder, elbow and hand pivots")
var c := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
"the focus code can still find Rig/Head")
avatar.free()
return a and b and c
func test_avatar_mesh_budget_and_lod_swap() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 12)
var meshes := avatar.find_children("*", "MeshInstance3D", true, false)
var a := TestUtils.check(meshes.size() <= 40,
"avatar stays inside the mesh budget (%d)" % meshes.size())
avatar.apply_lod(2)
var impostor := avatar.get_node_or_null("Impostor") as Node3D
var rig := avatar.get_node_or_null("Rig") as Node3D
var b := TestUtils.check(impostor != null and impostor.visible, "far LOD shows the impostor")
var c := TestUtils.check(rig != null and not rig.visible, "far LOD hides the full rig")
avatar.apply_lod(0)
var d := TestUtils.check(rig.visible and not impostor.visible, "near LOD restores the rig")
avatar.free()
return a and b and c and d
+35 -1
View File
@@ -43,11 +43,45 @@ func test_settings_round_trip() -> bool:
save.free()
return a and b and c
func test_camera_settings_round_trip() -> bool:
var save := _save_instance()
var err: int = save.save_settings({
"look_sensitivity": 0.0028, "hud_font_scale": 1.0,
"invert_y": true, "head_bob": false,
})
var a := TestUtils.check_eq(err, OK, "camera settings save")
var loaded: Dictionary = save.load_settings()
var b := TestUtils.check_eq(bool(loaded.get("invert_y", false)), true,
"invert_y survives the round trip")
var c := TestUtils.check_eq(bool(loaded.get("head_bob", true)), false,
"head_bob survives the round trip")
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_input_bindings_round_trip() -> bool:
var save := _save_instance()
var bindings := {"inspect": {"type": "key", "code": int(KEY_Z)}}
var err: int = save.save_settings({"input_bindings": bindings})
var a := TestUtils.check_eq(err, OK, "bindings save")
var loaded: Dictionary = save.load_settings()
var stored: Dictionary = loaded.get("input_bindings", {})
var inspect: Dictionary = stored.get("inspect", {})
var b := TestUtils.check_eq(int(inspect.get("code", 0)), int(KEY_Z),
"bindings survive the settings round trip")
var c := TestUtils.check_eq(str(inspect.get("type", "")), "key",
"binding type survives the settings round trip")
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_settings_defaults_present() -> bool:
var save := _save_instance()
var defaults: Dictionary = save.load_settings()
var ok := TestUtils.check(
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
defaults.has("look_sensitivity") and defaults.has("hud_font_scale")
and defaults.has("invert_y") and defaults.has("head_bob")
and defaults.has("input_bindings"),
"settings have documented defaults")
save.free()
return ok
+54
View File
@@ -0,0 +1,54 @@
extends RefCounted
## Headless tests for movement constants, surface lookup, gamepad bindings, and
## the settings that drive the camera feel.
const GuardController := preload("res://src/game/guard_controller.gd")
func test_surface_lookup_covers_the_block() -> bool:
var guard := GuardController.new()
var a := TestUtils.check_eq(guard._surface_at(Vector3(0, 0.2, 2.0)), "stone",
"the entrance steps are stone")
var b := TestUtils.check_eq(guard._surface_at(Vector3(3, 0.2, 8.0)), "concrete",
"the sidewalk is concrete")
var c := TestUtils.check_eq(guard._surface_at(Vector3(2, 0.1, 18.0)), "asphalt",
"the roadway is asphalt")
guard.free()
return a and b and c
func test_step_and_stride_stay_modest() -> bool:
var a := TestUtils.check(GuardController.STEP_HEIGHT <= 0.35,
"step-up stays at kerb height")
var b := TestUtils.check(GuardController.STRIDE_LENGTH >= 0.5
and GuardController.STRIDE_LENGTH <= 1.2, "stride stays human-sized")
var c := TestUtils.check(GuardController.SLOW_WALK_FACTOR < 1.0
and GuardController.SLOW_WALK_FACTOR > 0.3, "precision walk is slower but usable")
return a and b and c
func test_gamepad_bindings_are_added_once() -> bool:
GuardController._ensure_gamepad_bindings()
GuardController._ensure_gamepad_bindings()
var expected := ["look_left", "look_right", "look_up", "look_down",
"move_forward", "move_back", "move_left", "move_right"]
var problems: Array[String] = []
for action in expected:
if not InputMap.has_action(action):
problems.append("%s missing" % action)
continue
var motions := 0
for event in InputMap.action_get_events(action):
if event is InputEventJoypadMotion:
motions += 1
if motions == 0:
problems.append("%s has no joypad axis" % action)
if InputMap.action_get_deadzone(action) > 0.25:
problems.append("%s deadzone too high" % action)
var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER}
for action in buttons.keys():
var found := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton and event.button_index == buttons[action]:
found = true
if not found:
problems.append("%s has no gamepad button" % action)
return TestUtils.check(problems.is_empty(), "gamepad actions bound (%s)" % str(problems))