Right-side insert panel toggled with J key. Facts grouped by entity with confidence, source, and state metadata. Contradicted entries in amber with strikethrough (THE FRIEND arc surface). Stale entries dimmed. Mutual exclusion with dialogue box. All labels via UIStrings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
8.3 KiB
GDScript
222 lines
8.3 KiB
GDScript
extends Node
|
|
|
|
# Semantic actions — NO raw key codes cross the bridge
|
|
# Movement uses hold-to-move (polled each frame in _process).
|
|
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
|
|
#
|
|
# D-054: Mouse-relative facing and movement.
|
|
# Mouse position determines facing direction (client-side float).
|
|
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
|
|
# Server receives facing octant only — the full float stays client-side.
|
|
#
|
|
# Movement throttle: client-side rate limit per stance (D-053).
|
|
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
|
|
# Server cooldown is authoritative, but the client throttle prevents
|
|
# flooding and gives correct movement feel in test mode.
|
|
enum Action {
|
|
MOVE_NORTH, MOVE_NORTHEAST, MOVE_EAST, MOVE_SOUTHEAST,
|
|
MOVE_SOUTH, MOVE_SOUTHWEST, MOVE_WEST, MOVE_NORTHWEST,
|
|
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
|
|
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
|
|
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
|
|
OPEN_JOURNAL, # #264: J key — toggle knowledge journal panel, client-only
|
|
SET_FACING, # D-054: facing octant update (no movement)
|
|
TELEPORT_HUB, # #501: Home key — Gauntlet dev teleport (not production fast-travel)
|
|
}
|
|
|
|
var input_queue: Array[Dictionary] = []
|
|
|
|
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
|
|
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
|
|
var facing_angle: float = -PI / 2.0 # Default: North
|
|
var facing_octant: String = "North" # Derived from facing_angle
|
|
var _last_sent_octant: String = "North" # Track to avoid redundant sends
|
|
|
|
# Minimum milliseconds between movement commands, per stance.
|
|
# Tuned so Walk feels like walking, Sprint feels fast but readable.
|
|
const MOVE_INTERVAL_MS := {
|
|
"Sprint": 200, # 5/sec — fast but trackable
|
|
"Walk": 400, # 2.5/sec — comfortable walking pace
|
|
"Careful": 600, # ~1.7/sec — deliberate, scanning
|
|
"Crouch": 800, # 1.25/sec — creeping
|
|
}
|
|
var _last_move_msec: int = 0
|
|
|
|
|
|
# Hold-to-move: poll held direction keys each frame, throttled by stance.
|
|
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
|
|
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
|
|
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
|
|
func _process(_delta: float) -> void:
|
|
# D-054: Update facing angle from mouse position every frame
|
|
_update_facing_from_mouse()
|
|
|
|
if GameState.dialogue_active:
|
|
return
|
|
|
|
# D-054: Send facing octant to server when it changes (even without movement)
|
|
if facing_octant != _last_sent_octant:
|
|
_last_sent_octant = facing_octant
|
|
input_queue.append({
|
|
"action": Action.SET_FACING,
|
|
"timestamp_msec": Time.get_ticks_msec(),
|
|
"action_data": {"facing": facing_octant},
|
|
})
|
|
|
|
# Poll held WASD keys
|
|
var raw_dir := Vector2i.ZERO
|
|
if Input.is_action_pressed("move_north"):
|
|
raw_dir.y -= 1
|
|
if Input.is_action_pressed("move_south"):
|
|
raw_dir.y += 1
|
|
if Input.is_action_pressed("move_east"):
|
|
raw_dir.x += 1
|
|
if Input.is_action_pressed("move_west"):
|
|
raw_dir.x -= 1
|
|
|
|
if raw_dir != Vector2i.ZERO:
|
|
var now := Time.get_ticks_msec()
|
|
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
|
|
if now - _last_move_msec >= interval:
|
|
_last_move_msec = now
|
|
# D-054: Transform WASD input relative to mouse facing
|
|
var world_dir := _wasd_to_world_dir(raw_dir)
|
|
var action: Action = _dir_to_action(world_dir)
|
|
input_queue.append({
|
|
"action": action,
|
|
"timestamp_msec": now,
|
|
})
|
|
|
|
|
|
# Discrete actions: fire once on key press (not held).
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
var action: Action = -1
|
|
|
|
if event.is_action_pressed("interact"):
|
|
action = Action.INTERACT
|
|
elif event.is_action_pressed("perception_mode"):
|
|
action = Action.USE_PERCEPTION_MODE
|
|
elif event.is_action_pressed("open_menu"):
|
|
action = Action.OPEN_MENU
|
|
elif event.is_action_pressed("pause"):
|
|
var tick_rate = GameState.game_time.get("tick_rate", "Full")
|
|
action = Action.UNPAUSE if tick_rate == "Paused" else Action.PAUSE
|
|
elif event.is_action_pressed("stance_up"):
|
|
action = Action.TOGGLE_STANCE_UP
|
|
elif event.is_action_pressed("stance_down"):
|
|
action = Action.TOGGLE_STANCE_DOWN
|
|
elif event.is_action_pressed("bug_report"):
|
|
action = Action.BUG_REPORT
|
|
elif event.is_action_pressed("open_journal"):
|
|
action = Action.OPEN_JOURNAL
|
|
elif event.is_action_pressed("teleport_hub"):
|
|
if GameState.gauntlet_mode:
|
|
action = Action.TELEPORT_HUB
|
|
|
|
if action != -1:
|
|
input_queue.append({
|
|
"action": action,
|
|
"timestamp_msec": Time.get_ticks_msec(),
|
|
})
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
func flush_queue() -> Array[Dictionary]:
|
|
var queue = input_queue.duplicate()
|
|
input_queue.clear()
|
|
return queue
|
|
|
|
|
|
## Reset facing state to default (North). Use in tests per D-030 testability.
|
|
func reset_facing_state() -> void:
|
|
facing_angle = -PI / 2.0
|
|
facing_octant = "North"
|
|
_last_sent_octant = "North"
|
|
|
|
|
|
# D-054: Compute facing angle from mouse position relative to player screen position.
|
|
# Uses viewport canvas transform to convert world coords to screen coords.
|
|
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
|
|
# autoload that runs before game loop rendering, so position is always current-tick.
|
|
func _update_facing_from_mouse() -> void:
|
|
var vp := get_viewport()
|
|
if vp == null:
|
|
return
|
|
var canvas_xf := vp.get_canvas_transform()
|
|
var player_world_px := GameState.player_position * Constants.TILE_SIZE
|
|
var player_screen := canvas_xf * player_world_px
|
|
var mouse_screen := vp.get_mouse_position()
|
|
var delta := mouse_screen - player_screen
|
|
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
|
|
if delta.length_squared() > 4.0:
|
|
facing_angle = delta.angle()
|
|
facing_octant = _angle_to_octant(facing_angle)
|
|
|
|
|
|
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
|
|
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
|
|
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
|
|
# Forward = facing_angle direction. Output: nearest octant direction vector.
|
|
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
|
|
# Build a continuous direction vector relative to facing.
|
|
# raw_dir.y: -1 = W (forward), +1 = S (backward)
|
|
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
|
|
var forward := Vector2(cos(facing_angle), sin(facing_angle))
|
|
var right := Vector2(-forward.y, forward.x) # 90° clockwise
|
|
|
|
# Combine: forward/back from W/S, strafe from A/D
|
|
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
|
|
|
|
# Snap to nearest octant direction
|
|
return _snap_to_octant_dir(world_float)
|
|
|
|
|
|
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
|
|
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
|
|
if dir.length_squared() < 0.001:
|
|
return Vector2i.ZERO
|
|
var angle := dir.angle()
|
|
# Quantize to nearest 45° (PI/4)
|
|
var octant := roundi(angle / (PI / 4.0))
|
|
match octant:
|
|
0: return Vector2i(1, 0) # East
|
|
1: return Vector2i(1, 1) # Southeast
|
|
2, -6: return Vector2i(0, 1) # South
|
|
3, -5: return Vector2i(-1, 1) # Southwest
|
|
4, -4: return Vector2i(-1, 0) # West
|
|
-3, 5: return Vector2i(-1, -1) # Northwest
|
|
-2: return Vector2i(0, -1) # North
|
|
-1: return Vector2i(1, -1) # Northeast
|
|
_: return Vector2i.ZERO
|
|
|
|
|
|
# D-054: Convert a facing angle (radians) to the nearest octant name.
|
|
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
|
|
static func _angle_to_octant(angle: float) -> String:
|
|
var octant := roundi(angle / (PI / 4.0))
|
|
match octant:
|
|
0: return "East"
|
|
1: return "Southeast"
|
|
2, -6: return "South"
|
|
3, -5: return "Southwest"
|
|
4, -4: return "West"
|
|
-3, 5: return "Northwest"
|
|
-2: return "North"
|
|
-1: return "Northeast"
|
|
_: return "East"
|
|
|
|
|
|
# Map a direction vector to the corresponding movement Action.
|
|
# Handles all 8 directions via composite W+D, W+A, etc.
|
|
static func _dir_to_action(dir: Vector2i) -> Action:
|
|
match dir:
|
|
Vector2i(0, -1): return Action.MOVE_NORTH
|
|
Vector2i(1, -1): return Action.MOVE_NORTHEAST
|
|
Vector2i(1, 0): return Action.MOVE_EAST
|
|
Vector2i(1, 1): return Action.MOVE_SOUTHEAST
|
|
Vector2i(0, 1): return Action.MOVE_SOUTH
|
|
Vector2i(-1, 1): return Action.MOVE_SOUTHWEST
|
|
Vector2i(-1, 0): return Action.MOVE_WEST
|
|
Vector2i(-1, -1): return Action.MOVE_NORTHWEST
|
|
_: return Action.MOVE_NORTH
|