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

+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