diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 0f8d70188..d4c7cee89 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -3,7 +3,11 @@ 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). -# Composite diagonals: holding W+D simultaneously → northeast. +# +# 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. @@ -15,10 +19,17 @@ enum Action { 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 + SET_FACING, # D-054: facing octant update (no movement) } 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 := { @@ -31,27 +42,44 @@ 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 - var dir := Vector2i.ZERO - if Input.is_action_pressed("move_north"): - dir.y -= 1 - if Input.is_action_pressed("move_south"): - dir.y += 1 - if Input.is_action_pressed("move_east"): - dir.x += 1 - if Input.is_action_pressed("move_west"): - dir.x -= 1 - if dir != Vector2i.ZERO: + # 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 - var action: Action = _dir_to_action(dir) + # 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, @@ -92,6 +120,76 @@ func flush_queue() -> Array[Dictionary]: return queue +# D-054: Compute facing angle from mouse position relative to player screen position. +# Uses viewport canvas transform to convert world coords to screen coords. +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: diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 9400132ca..46b9bb8db 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -258,6 +258,8 @@ static func _action_enum_to_wire(action: int) -> String: return "" # Client-only action, not part of wire protocol InputMapper.Action.BUG_REPORT: return "" # Client-only action (#495), not part of wire protocol + InputMapper.Action.SET_FACING: + return "SetFacing" # D-054: facing octant update (no movement) _: push_warning("SimBridge: unknown action enum %s" % action) return "" @@ -280,12 +282,18 @@ func _test_snapshot() -> Dictionary: if dist <= 2 and _test_has_los(_test_player_pos, npc_pos): _test_in_dialogue = true continue + if action_name == "SetFacing": + # D-054: facing update handled separately — octant comes from InputMapper + _test_facing = InputMapper.facing_octant + continue var delta := _action_to_delta(action_name) var new_pos := _test_player_pos + delta if _test_is_walkable(new_pos): _test_player_pos = new_pos if delta != Vector2i.ZERO: - _test_facing = _delta_to_facing(delta) + # D-054: Facing is now mouse-driven, not movement-driven. + # Use InputMapper's octant instead of deriving from movement delta. + _test_facing = InputMapper.facing_octant # Walk-away dismisses dialogue (D-064) if _test_in_dialogue: _test_in_dialogue = false diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index 5582d66e7..57ddf6dc9 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -119,11 +119,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: if not is_equal_approx(entity_node.modulate.a, target_alpha): entity_node.modulate.a = target_alpha - # v2: Update facing indicator rotation (player entity only) + # D-054: Update facing indicator from client-side mouse angle (not server). + # InputMapper.facing_angle is a continuous float — smoother than octant snapping. if entity_id == GameState.player_entity_id: var indicator = entity_node.get_node_or_null("FacingIndicator") if indicator != null: - indicator.rotation = _facing_to_rotation(GameState.player_facing) + # facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up). + # Rotate from North basis: add PI/2 to convert. + indicator.rotation = InputMapper.facing_angle + PI / 2.0 # Remove an entity node func _remove_entity_node(entity_id: int) -> void: