Character and movement pass
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:
1 parent
2a4e0d1998
commit
a27e8cb74c
35 files changed
+1608
-185
No files matched your search
+150
-46
@@ -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
|
||||
|
||||
Reference in new issue
Block a user