Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
288 lines
9.4 KiB
GDScript
288 lines
9.4 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)
|
|
SAVE_GAME, # #554: F5 quicksave — sends SaveGame to server with save path
|
|
LOAD_GAME, # #554: F6 quickload — sends LoadGame to server with save path
|
|
DEBUG_COMMAND, # #581: debug console command dispatch — sends DebugCommandKind to server
|
|
CHANGE_SETTINGS, # #646: persist a setting to server SQLite — sends {key, value} to server
|
|
REQUEST_ALL_SETTINGS, # #646: request full settings dump from server after handshake (unit variant)
|
|
DELETE_SETTING, # #646: delete a setting by key from server SQLite (struct variant)
|
|
}
|
|
|
|
# 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 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
|
|
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
|
|
elif event.is_action_pressed("quicksave"):
|
|
action = Action.SAVE_GAME
|
|
elif event.is_action_pressed("quickload"):
|
|
action = Action.LOAD_GAME
|
|
|
|
if action != -1:
|
|
var entry := {
|
|
"action": action,
|
|
"timestamp_msec": Time.get_ticks_msec(),
|
|
}
|
|
# #554: Attach save path for SaveGame/LoadGame actions
|
|
if action == Action.SAVE_GAME or action == Action.LOAD_GAME:
|
|
var game_id := GameState.current_game_id
|
|
if game_id.is_empty():
|
|
get_viewport().set_input_as_handled()
|
|
return # No active session — ignore save/load
|
|
entry["action_data"] = {"path": "user://saves/" + game_id + "/quicksave.sav"}
|
|
input_queue.append(entry)
|
|
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
|