feat(client): D-252 view/movement split — body from leg velocity, view is the mouse (T-1093)

D-252 (new record, amends D-054/D-249, resolves Q-084's walk-vs-aim split):
Facing is view-only; movement no longer writes it. Client side: the rig's
moving-body yaw now always derives from leg velocity (the wire octant is the
VIEW and must never rotate the body — the follow-only commit flag
generalizes and disappears); the layered head/torso look-at runs during any
movement, WASD included; the ~100ms post-step re-assert mitigation is
removed as dead (server-side facing_from_delta removal lands separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:59:06 +02:00
co-authored by Claude Fable 5
parent 6453f71220
commit 629c0a9b1e
8 changed files with 28 additions and 54 deletions
-20
View File
@@ -208,26 +208,6 @@ func queue_move_step(tile_delta: Vector2i) -> bool:
# so no throttle — the server's handle_toggle_stance has no cooldown, so the sandbox
# follower can burst the exact ladder distance and trust it (server validates). No-op
# while input is suppressed (dialogue / free camera), matching movement.
## T-1088 follow-facing: unconditionally re-send the CURRENT facing octant.
## The server overwrites Facing from the move delta on every accepted step
## (movement.rs facing_from_delta), and the change-gated send above never
## re-asserts an unchanged octant — during a path-follow the vision cone would
## stick path-forward until the mouse crossed an octant boundary. The follower
## calls this ~2 server ticks after each emitted step (same-tick re-asserts
## lose: movement wins within a tick). Suppression-guarded like all sends.
func reassert_facing() -> void:
if GameState.dialogue_active or GameState.free_camera_mode:
return
_last_sent_octant = facing_octant
input_queue.append(
{
"action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
}
)
func queue_stance_toggle(up: bool) -> void:
if GameState.dialogue_active or GameState.free_camera_mode:
return
+7 -11
View File
@@ -62,10 +62,6 @@ var step_window_ms_provider: Callable = Callable()
## unset (NPCs) to collapse the facing split to pure wire facing — single code
## path by construction.
var idle_facing_provider: Callable = Callable()
## T-1088 follow-facing: while a client-initiated path-follow is active the
## body yaw commits to the leg direction (mouse -> head_look, not body).
## Set/cleared by the sandbox root from PathFollower.is_active().
var commit_body_to_motion: bool = false
## () -> bool, true while input is suppressed. The player adapter returns
## GameState.dialogue_active or GameState.free_camera_mode — the exact condition
## under which InputMapper computes but does not send octants (input_mapper.gd:71).
@@ -201,13 +197,13 @@ func _update_facing(delta: float) -> void:
var suppressed := false
if suppression_provider.is_valid():
suppressed = bool(suppression_provider.call())
if commit_body_to_motion and is_moving and velocity.length_squared() > 0.0001:
# Path-follow (T-1088 follow-facing refinement): the body is committed to
# the leg direction — the mouse drives the head/torso look-at instead
# (head_look.gd), and SetFacing still steers the server vision cone.
# Leg direction in WorldRoot-local space -> yaw via the same convention
# as SandboxSpace (local +X = sim East, +Z = sim South).
yaw_target = SandboxSpace.sim_angle_to_yaw(atan2(velocity.z, velocity.x))
if is_moving:
# D-252: wire facing is the VIEW (mouse) — it must never rotate the body.
# While moving, body yaw = the leg direction (WorldRoot-local velocity;
# local +X = sim East, +Z = sim South per SandboxSpace). During the idle
# hysteresis window velocity is zero — hold the last yaw (no flip-flop).
if velocity.length_squared() > 0.0001:
yaw_target = SandboxSpace.sim_angle_to_yaw(atan2(velocity.z, velocity.x))
else:
var idle_octant := ""
if idle_facing_provider.is_valid():
+5 -6
View File
@@ -404,12 +404,11 @@ func _per_frame_update(delta: float) -> void:
_path_preview.update(char_tile, is_floor, greybox.store.size())
_path_follower.tick(char_tile, is_floor)
# T-1088 follow-facing: while a follow is active the body commits to the leg
# direction and the mouse drives the layered head/torso look-at instead.
# SetFacing still rides the wire (the server vision cone follows the mouse).
var follow_active := _path_follower.is_active()
player_rig.commit_body_to_motion = follow_active
_head_look.set_active(follow_active and not _input_suppressed())
# D-252 view/movement split: the body always walks the leg direction (rig);
# the mouse is the VIEW — server cone via SetFacing, and the layered
# head/torso look-at whenever the character is in motion (WASD or follow).
var looking := (_path_follower.is_active() or player_rig.is_moving)
_head_look.set_active(looking and not _input_suppressed())
_update_head_aim_target()
_head_look.update(delta)
+1 -16
View File
@@ -48,14 +48,7 @@ enum Mode { NORMAL, SPRINT, CROUCH }
## last element. Empty while inactive.
var _path: Array[Vector3i] = []
var _goal: Vector3i = Vector3i.ZERO
## T-1088 follow-facing: two server ticks (50 ms each) after an emitted step —
## past the tick that applies the move (whose facing_from_delta would win) but
## as soon after as the wire allows.
const FACING_REASSERT_DELAY_MS := 100
var _active: bool = false
## 0 = nothing pending; else the msec deadline for the post-step facing re-assert.
var _reassert_at_ms: int = 0
var _mode: int = Mode.NORMAL
## SPRINT only: the pre-sprint stance (kept for readability/debug) and the exact
@@ -127,13 +120,6 @@ func cancel() -> void:
func tick(current_tile: Vector3i, is_floor: Callable) -> void:
if not _active:
return
# T-1088 follow-facing: the server overwrote Facing from this follow's last
# accepted step — re-assert the mouse octant once the overwrite tick has
# passed (~2 server ticks; a same-tick re-assert loses, movement wins within
# a tick). Keeps the vision cone on the mouse while the body walks the path.
if _reassert_at_ms > 0 and Time.get_ticks_msec() >= _reassert_at_ms:
_reassert_at_ms = 0
InputMapper.reassert_facing()
# Cancel (no stance change): input suppression — dialogue / free camera.
if GameState.dialogue_active or GameState.free_camera_mode:
cancel()
@@ -161,8 +147,7 @@ func tick(current_tile: Vector3i, is_floor: Callable) -> void:
# the 200 ms cadence automatically — the character accelerates into the sprint).
var next_tile: Vector3i = _path[idx + 1]
var delta := Vector2i(next_tile.x - current_tile.x, next_tile.y - current_tile.y)
if InputMapper.queue_move_step(delta):
_reassert_at_ms = Time.get_ticks_msec() + FACING_REASSERT_DELAY_MS
InputMapper.queue_move_step(delta)
# ---------------------------------------------------------------------------