From 629c0a9b1ec1bbaf57f4730af29e62f74fae7502 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Jul 2026 20:59:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20D-252=20view/movement=20split?= =?UTF-8?q?=20=E2=80=94=20body=20from=20leg=20velocity,=20view=20is=20the?= =?UTF-8?q?=20mouse=20(T-1093)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .pql/changelog/ticket_history/2026-07.sql | 1 + .pql/changelog/tickets/2026-07.sql | 1 + client/scripts/autoloads/input_mapper.gd | 20 -------------------- client/scripts/sandbox/locomotion_rig.gd | 18 +++++++----------- client/scripts/sandbox/locomotion_sandbox.gd | 11 +++++------ client/scripts/sandbox/path_follower.gd | 17 +---------------- governance/README.md | 1 + governance/decisions/architecture.md | 13 ++++++++++++- 8 files changed, 28 insertions(+), 54 deletions(-) diff --git a/.pql/changelog/ticket_history/2026-07.sql b/.pql/changelog/ticket_history/2026-07.sql index d6fee3892..da2f4f6a6 100644 --- a/.pql/changelog/ticket_history/2026-07.sql +++ b/.pql/changelog/ticket_history/2026-07.sql @@ -838,3 +838,4 @@ Live-session feedback (user, 2026-07-06): (1) Corridor cutaway verdict — two m RMB gesture vocabulary (user, live session 2026-07-06, added to the path-preview feature): single right-click = walk there (current stance); DOUBLE right-click = sprint there (ladder ToggleStanceUp burst to Sprint via the ordinary command queue, pre-sprint stance RESTORED on arrival; a double upgrades the already-started follow in place); LONG-PRESS right-click (~0.4s, commit on release) = ''take cover at location'', prototyped as go-there-then-Crouch-on-arrival (no restore — crouching was the point). Real cover mechanics (wall adjacency, directional protection) are future combat design; the gesture is the input-vocabulary prototype. WASD always cancels any follow without stance restore (player override is deliberate). Follow-facing refinement (user, live session 2026-07-06): during an RMB path-follow the BODY commits to the leg/movement direction (mouse no longer swings body yaw); the mouse instead drives a LAYERED LOOK-AT — Head bone (~70deg limit) plus torso twist via a spine bone (~30deg) for looking far lateral/behind; beyond their sum the character physically cannot look further without turning (correct). Pure client presentation on the D-249 model; SetFacing continues to ride the wire during follows, so the SERVER vision cone still follows the mouse (diegetic: the character looks where the player points while walking a committed path). Implementation: Godot LookAtModifier3D pair under the CharacterVisual skeleton, influence-faded in/out on follow start/end; rig gains a commit_body_to_motion flag (yaw from leg velocity while set).', NULL, '2026-07-06 18:29:55', '2026-07-06 18:29:55.012', '2026-07-06 18:29:55.012', NULL, '890daaabcde4a85a222f9d206dcda2d0', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'status', 'backlog', 'in_progress', NULL, '2026-07-06 18:55:29', '2026-07-06 18:55:29.038', '2026-07-06 18:55:29.038', NULL, 'da64cb6b48dd4311c5b257cbd70d891b', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-07.sql b/.pql/changelog/tickets/2026-07.sql index 9c1e9f7cf..136e27fee 100644 --- a/.pql/changelog/tickets/2026-07.sql +++ b/.pql/changelog/tickets/2026-07.sql @@ -709,3 +709,4 @@ RMB gesture vocabulary (user, live session 2026-07-06, added to the path-preview Follow-facing refinement (user, live session 2026-07-06): during an RMB path-follow the BODY commits to the leg/movement direction (mouse no longer swings body yaw); the mouse instead drives a LAYERED LOOK-AT — Head bone (~70deg limit) plus torso twist via a spine bone (~30deg) for looking far lateral/behind; beyond their sum the character physically cannot look further without turning (correct). Pure client presentation on the D-249 model; SetFacing continues to ride the wire during follows, so the SERVER vision cone still follows the mouse (diegetic: the character looks where the player points while walking a committed path). Implementation: Godot LookAtModifier3D pair under the CharacterVisual skeleton, influence-faded in/out on follow start/end; rig gains a commit_body_to_motion flag (yaw from leg velocity while set).', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 18:29:55.012', NULL, 'c45dd9ede3dbadcc20b4ce12b90a94f5', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'task', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Server aim-lock: explicit SetFacing should survive accepted moves (vision-cone flap during path-follows)', 'Found live in the T-1088 sandbox: during a client path-follow the vision cone flaps to path-forward on every accepted step because movement.rs facing_from_delta overwrites Facing on each move, and D-054''s change-gated SetFacing never re-asserts an unchanged octant. Client mitigation shipped (InputMapper.reassert_facing ~2 ticks after each emitted step) shrinks the flap to ~100ms per step but cannot eliminate it — movement wins within a tick. Proper fix is server-side facing decoupling (e.g. an explicit-aim flag/timestamp on Facing that accepted moves do not overwrite while fresh), which is exactly Q-084''s parked walk-vs-aim split now with play evidence. Needs a D-record before wire semantics change; touch nothing until the sidequest''s presentation-layer constraint lifts.', 'backlog', 'medium', NULL, 'server', NULL, '2026-07-06 18:44:32.561', '2026-07-06 18:44:32.561', NULL, 'c83a2bdbf3c41b678f04db6e5077b78a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FKHFRCP5R0VBA9BKR97RRBJ0', 'task', '06FB0TNSRXX29H5X0EB0F0GBW4', 'Server aim-lock: explicit SetFacing should survive accepted moves (vision-cone flap during path-follows)', 'Found live in the T-1088 sandbox: during a client path-follow the vision cone flaps to path-forward on every accepted step because movement.rs facing_from_delta overwrites Facing on each move, and D-054''s change-gated SetFacing never re-asserts an unchanged octant. Client mitigation shipped (InputMapper.reassert_facing ~2 ticks after each emitted step) shrinks the flap to ~100ms per step but cannot eliminate it — movement wins within a tick. Proper fix is server-side facing decoupling (e.g. an explicit-aim flag/timestamp on Facing that accepted moves do not overwrite while fresh), which is exactly Q-084''s parked walk-vs-aim split now with play evidence. Needs a D-record before wire semantics change; touch nothing until the sidequest''s presentation-layer constraint lifts.', 'in_progress', 'medium', NULL, 'server', NULL, '2026-07-06 18:44:32.561', '2026-07-06 18:55:29.037', NULL, '01cc8d20f141986d0d4fdfe4d6cb1f22', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 3a0fedfc0..47d92bc86 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -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 diff --git a/client/scripts/sandbox/locomotion_rig.gd b/client/scripts/sandbox/locomotion_rig.gd index 20115ce6e..6e356a067 100644 --- a/client/scripts/sandbox/locomotion_rig.gd +++ b/client/scripts/sandbox/locomotion_rig.gd @@ -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(): diff --git a/client/scripts/sandbox/locomotion_sandbox.gd b/client/scripts/sandbox/locomotion_sandbox.gd index eef0ce6ef..7a0117dcb 100644 --- a/client/scripts/sandbox/locomotion_sandbox.gd +++ b/client/scripts/sandbox/locomotion_sandbox.gd @@ -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) diff --git a/client/scripts/sandbox/path_follower.gd b/client/scripts/sandbox/path_follower.gd index aa8b0663d..8a4c9001a 100644 --- a/client/scripts/sandbox/path_follower.gd +++ b/client/scripts/sandbox/path_follower.gd @@ -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) # --------------------------------------------------------------------------- diff --git a/governance/README.md b/governance/README.md index 449ec5448..01b3336fa 100644 --- a/governance/README.md +++ b/governance/README.md @@ -317,6 +317,7 @@ line in place — keep the Q-record for the audit trail rather than deleting it. - [D-249: 3D facing presentation authority — server feet, client eyes](decisions/architecture.md#d-249-3d-facing-presentation-authority--server-feet-client-eyes) — _architecture_ - [D-250: Wall cutaway is client-side camera presentation, decoupled from fog-of-perception](decisions/perception.md#d-250-wall-cutaway-is-client-side-camera-presentation-decoupled-from-fog-of-perception) — _perception_ - [D-251: Character asset route, 2026 reconfirmation — Quaternius rig, in-house wardrobe, purchased animation tiers](decisions/content.md#d-251-character-asset-route-2026-reconfirmation--quaternius-rig-in-house-wardrobe-purchased-animation-tiers) — _content_ +- [D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent](decisions/architecture.md#d-252-facing-is-view-only--movement-no-longer-writes-facing-npc-gaze-is-intent) — _architecture_ ## Open questions diff --git a/governance/decisions/architecture.md b/governance/decisions/architecture.md index c0b069614..fba83db90 100644 --- a/governance/decisions/architecture.md +++ b/governance/decisions/architecture.md @@ -1952,4 +1952,15 @@ Technical foundation decisions that constrain implementation: engine, client-ser --- -*105 decisions (D-001 through D-249, excluding gaps). Last updated: 2026-07-06 (D-248/D-249 — 3D locomotion presentation: per-leg constant-velocity interpolation keyed to the stance throttle, cadence-synced gaits, and the "server feet, client eyes" facing authority split; T-1088 locomotion sandbox).* +### D-252: Facing is view-only — movement no longer writes Facing; NPC gaze is intent + +- **Decision:** The `Facing` component (and the wire's `player_facing`/entity facing octants) is the **view direction only** — the vision-cone heading. Accepted moves **no longer overwrite Facing** (`facing_from_delta` is removed from the movement path): the player's Facing changes only via explicit `SetFacing` (the mouse octant, D-054's change-gated send). **Movement direction is not a wire concern** — the client derives body heading from position deltas (the 3D rig's per-leg velocity, [D-248](#d-248-3d-locomotion-presentation--per-leg-constant-velocity-interpolation-keyed-to-the-stance-throttle)). **NPC gaze becomes intent:** the NPC path-follow system sets Facing to the step direction explicitly (same observable behavior as the old physics coupling), which relocates look-where-you-walk from the movement system into AI intent — later behaviors (a patrolling guard glancing sideways, an NPC backing away while watching the player) become Facing writes by the AI, no special cases. +- **Motivation (play evidence, T-1088 live session 2026-07-06):** during client path-follows the vision cone flapped to path-forward on every accepted step and stuck there until the mouse crossed an octant boundary — the movement overwrite fought the explicit aim, and the best client-side mitigation (post-step re-assert, ~2-tick delay) still left a ~100 ms flap per step because movement wins within a tick. Splitting the semantics is the correct fix; the walk-one-way-look-another model this enables is exactly the split [Q-084] parked. +- **Consequences:** "bump-to-turn" (blocked moves updated facing) is retired — correct under a mouse-view model. The client's post-step `reassert_facing` mitigation is removed. [D-249](#d-249-3d-facing-presentation-authority--server-feet-client-eyes) is **amended by this record**: while moving, the body yaw source is the leg-velocity direction (wire facing while moving is now the *view*, which must never rotate the body); idle behavior is unchanged; the layered head/torso look-at ([T-1088] follow-facing) may run during any movement, not only path-follows. The 2D renderer's player sprite direction follows the view octant in all states (was: move direction while moving) — accepted drift on D-166-frozen code. **Amends [D-054](#d-054-tile-based-movement-with-same-tile-occupancy)** (its facing sentence: octant-only wire + explicit sends stand; the movement overwrite is struck). +- **Cross-reference:** D-054, D-248, D-249, D-015 (vision cone), Q-084 (walk-vs-aim split — resolved by this record for semantics; camera rotation option stays parked). Tickets: T-1093 (implementation), T-1088. +- **Raised by:** Jeroen ("we need to split movement direction and viewing direction in the server protocol", live session 2026-07-06, after the T-1093 mitigation demonstrated the residual flap) with Claude (mitigation evidence, NPC-intent relocation, consequence audit). +- **Dissent:** None + +--- + +*106 decisions (D-001 through D-252, excluding gaps). Last updated: 2026-07-06 (D-252 — Facing is view-only: movement no longer writes Facing, NPC gaze becomes AI intent, bump-to-turn retired; resolves the Q-084 walk-vs-aim split with T-1088 play evidence).*