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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user