feat(client): follow-facing — body commits to path, mouse drives layered head/torso look-at (T-1088)

During an RMB path-follow the body yaw locks to the leg direction (rig
commit_body_to_motion, from interpolated velocity — immune to SetFacing
interleaving between throttled steps). The mouse instead drives a layered
look-at: LookAtModifier3D pair on Head (±70°) + spine_02 (±30° torso twist
for looking far lateral/behind — past their sum the character physically
cannot look further without turning). Forward axis measured from the
armature rest pose (+Z), not guessed. Influence fades in/out on follow
start/end. Pure client presentation per D-249; SetFacing still rides the
wire, so the server vision cone follows the mouse while walking — the
character looks where the player points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 20:35:43 +02:00
co-authored by Claude Fable 5
parent 72f88dae1d
commit cb6b39331d
7 changed files with 180 additions and 4 deletions
+23
View File
@@ -815,3 +815,26 @@ Animation supply resolved (2026-07-06): user purchased UAL1[Source+Pro] (120 cli
Live-session feedback (user, 2026-07-06): (1) Corridor cutaway verdict — two minds: ''extremely trippy, could be the game''s own look'' vs ''player cannot see what is in the room unless close''. Resolution: cutaway MODES, toggled live in the sandbox (corridor / bottom-walls-low / both) — bottom-wall rule is data-driven (wall drops to stub when a KNOWN floor tile sits on its camera-far side; camera-facing directions derived from WorldRoot basis at runtime, not hand-derived). Default picked by feel during the session; the trippy corridor stays available as a signature-look option. (2) NEW: mouse-over move-here marker on the hovered tile + optimal path line — client-side A* over the never-evict KNOWN tile store (info-boundary-honest: the character plans only through known tiles; fog is unpathable), 8-dir with diagonal step cost 1 (time-optimal per the no-sqrt2 wire), terrain-cost provider seam (uniform today, Phase-4 terrain later), click executes by streaming ordinary Move* step commands through the existing throttle (zero protocol change, server validates every step), cancelled by WASD override / path invalidation / arrival. Settles more of Q-084 (click-to-move half).
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).', NULL, '2026-07-06 17:37:13', '2026-07-06 17:37:13.480', '2026-07-06 17:37:13.480', NULL, '32dbbffd0e953374ed22104cae0e636c', 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 ('06FKCER95EK266H8RWPW8XT1F0', 'description', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.
Animation supply resolved (2026-07-06): user purchased UAL1[Source+Pro] (120 clips, identical lists) + UAL2[Source] (134 clips), unzipped in docs/assets/downloads/. Gains: Turn90_L/R + Turn180_L/R (turn-in-place), Sprint_Enter/Exit + Crouch_Enter/Exit (stance transitions), full 8-direction Walk/Jog/Crouch sets incl. diagonals, Counter_Idle + talking/phone/sitting idles (life-sim verbs), plus _RM root-motion variants (unused — in-place is correct for server-authoritative interpolation). Still absent everywhere: walk start/stop + stairs — the only remaining custom clips. Integration is NOT a drop-in swap: upstream renamed clips (Walk_Loop -> Walk_Fwd_Loop etc.), so wire via the design''s recorded plan — explicit library names + lib/Clip addressing + gait-table update. NOTE for facing design: the 8-dir walk sets unlock Q-084''s parked walk-vs-aim split (direction-matched clips relative to facing) — revisit during the S9 tuning session.
Live-session feedback (user, 2026-07-06): (1) Corridor cutaway verdict — two minds: ''extremely trippy, could be the game''s own look'' vs ''player cannot see what is in the room unless close''. Resolution: cutaway MODES, toggled live in the sandbox (corridor / bottom-walls-low / both) — bottom-wall rule is data-driven (wall drops to stub when a KNOWN floor tile sits on its camera-far side; camera-facing directions derived from WorldRoot basis at runtime, not hand-derived). Default picked by feel during the session; the trippy corridor stays available as a signature-look option. (2) NEW: mouse-over move-here marker on the hovered tile + optimal path line — client-side A* over the never-evict KNOWN tile store (info-boundary-honest: the character plans only through known tiles; fog is unpathable), 8-dir with diagonal step cost 1 (time-optimal per the no-sqrt2 wire), terrain-cost provider seam (uniform today, Phase-4 terrain later), click executes by streaming ordinary Move* step commands through the existing throttle (zero protocol change, server validates every step), cancelled by WASD override / path invalidation / arrival. Settles more of Q-084 (click-to-move half).
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).', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.
Animation supply resolved (2026-07-06): user purchased UAL1[Source+Pro] (120 clips, identical lists) + UAL2[Source] (134 clips), unzipped in docs/assets/downloads/. Gains: Turn90_L/R + Turn180_L/R (turn-in-place), Sprint_Enter/Exit + Crouch_Enter/Exit (stance transitions), full 8-direction Walk/Jog/Crouch sets incl. diagonals, Counter_Idle + talking/phone/sitting idles (life-sim verbs), plus _RM root-motion variants (unused — in-place is correct for server-authoritative interpolation). Still absent everywhere: walk start/stop + stairs — the only remaining custom clips. Integration is NOT a drop-in swap: upstream renamed clips (Walk_Loop -> Walk_Fwd_Loop etc.), so wire via the design''s recorded plan — explicit library names + lib/Clip addressing + gait-table update. NOTE for facing design: the 8-dir walk sets unlock Q-084''s parked walk-vs-aim split (direction-matched clips relative to facing) — revisit during the S9 tuning session.
Live-session feedback (user, 2026-07-06): (1) Corridor cutaway verdict — two minds: ''extremely trippy, could be the game''s own look'' vs ''player cannot see what is in the room unless close''. Resolution: cutaway MODES, toggled live in the sandbox (corridor / bottom-walls-low / both) — bottom-wall rule is data-driven (wall drops to stub when a KNOWN floor tile sits on its camera-far side; camera-facing directions derived from WorldRoot basis at runtime, not hand-derived). Default picked by feel during the session; the trippy corridor stays available as a signature-look option. (2) NEW: mouse-over move-here marker on the hovered tile + optimal path line — client-side A* over the never-evict KNOWN tile store (info-boundary-honest: the character plans only through known tiles; fog is unpathable), 8-dir with diagonal step cost 1 (time-optimal per the no-sqrt2 wire), terrain-cost provider seam (uniform today, Phase-4 terrain later), click executes by streaming ordinary Move* step commands through the existing throttle (zero protocol change, server validates every step), cancelled by WASD override / path invalidation / arrival. Settles more of Q-084 (click-to-move half).
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;
+13
View File
@@ -695,3 +695,16 @@ 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).', 'backlog', 'high', NULL, 'client', NULL, '2026-07-06 07:01:06.475', '2026-07-06 17:37:13.480', NULL, '6a60bee830c86a4c53c735ec17087499', 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 ('06FKH8SJRMXTP6XQNBXHRD4CQ0', 'task', '06FBPPMZNNEV052DBYYY3A897C', 'test_fog_shader perf assertions flake under machine load — make budget load-tolerant', 'test_visibility_texture_update_performance asserts wall-clock < 0.5ms and failed at 0.704ms during a pre-push while a live game session + capture harness shared the machine (2026-07-06); 46/46 green re-run in isolation. Fix options: median-of-N timing, a generous multiplier, or gating perf assertions behind an env var so functional coverage always runs but timing only gates on a quiet machine.', 'backlog', 'low', NULL, 'client', NULL, '2026-07-06 18:14:07.301', '2026-07-06 18:14:07.301', NULL, '1a1f4e07550e0c8834184ffec0816848', 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 ('06FKCER95EK266H8RWPW8XT1F0', 'story', '06FB0TNSRXX29H5X0EB0F0GBW4', '3D character locomotion sidequest — composited character walks the Gauntlet on the live server', 'Fable-5-window sidequest (sanctioned cascade exception, 2026-07-05): the presentation layer that turns discrete server tile steps into a believable walking 3D character. Scope: 3D sandbox dev scene (SR_LIVE=1, greybox tiles derived from server snapshots, no hand-authored map), CharacterVisual instantiated in-world, locomotion rig (tile-step + facing-octant interpolation, stance-aware speeds), UAL animation state machine (idle/walk/sprint/careful/crouch), D-148 camera rig (30deg low-angle follow, 45deg rotation). Server-authoritative from day one — no client-local movement model. Builds on D-148/D-149/D-244/D-066 and the T-417 stance system. If the Fable 5 window closes mid-flight, work parks here.
Visual reference (user, 2026-07-06): Xenonauts 2 nails the target tiles/occlusion/character perspective — readable tile grid, low-angle tactical camera with angle options (matches D-148 30deg low-angle + 45deg rotation steps and the spikes/3dpipeline angle presets), and crucially its wall/roof occlusion treatment: camera-facing walls cut down/ghosted so interiors stay readable, floors peel per z-level. One deliberate difference: our camera locks to the player character (D-015/T-116) — no free map panning. Implication for the locomotion sandbox: greybox walls should prototype camera-facing cutaway occlusion early, since wall readability interacts with the locked low-angle camera.
Scoping call (user + lead agreement, 2026-07-06): wall cutaway is CLIENT-SIDE presentation, decoupled from fog-of-perception. Rationale: two occlusion systems with different subjects — perception/fog governs what the CHARACTER knows (server-enforced info boundary; client only receives observer-filtered geometry), cutaway governs what the PLAYER''S CAMERA sees (pure render mode on wall data the client already holds). They compose: cutaway cuts render height; fog overlay still gates what is visible behind. Walls remain first-class client data objects (future destruction/explosion interactions), cutaway is a render mode on them like tint/LOD. Nuance parked for Phase 5 proper: destroyed-while-unobserved walls should render in remembered state (fog-memory applied to geometry). If this holds through implementation, mint a D-record (perception domain) when the sidequest lands.
Animation supply resolved (2026-07-06): user purchased UAL1[Source+Pro] (120 clips, identical lists) + UAL2[Source] (134 clips), unzipped in docs/assets/downloads/. Gains: Turn90_L/R + Turn180_L/R (turn-in-place), Sprint_Enter/Exit + Crouch_Enter/Exit (stance transitions), full 8-direction Walk/Jog/Crouch sets incl. diagonals, Counter_Idle + talking/phone/sitting idles (life-sim verbs), plus _RM root-motion variants (unused — in-place is correct for server-authoritative interpolation). Still absent everywhere: walk start/stop + stairs — the only remaining custom clips. Integration is NOT a drop-in swap: upstream renamed clips (Walk_Loop -> Walk_Fwd_Loop etc.), so wire via the design''s recorded plan — explicit library names + lib/Clip addressing + gait-table update. NOTE for facing design: the 8-dir walk sets unlock Q-084''s parked walk-vs-aim split (direction-matched clips relative to facing) — revisit during the S9 tuning session.
Live-session feedback (user, 2026-07-06): (1) Corridor cutaway verdict — two minds: ''extremely trippy, could be the game''s own look'' vs ''player cannot see what is in the room unless close''. Resolution: cutaway MODES, toggled live in the sandbox (corridor / bottom-walls-low / both) — bottom-wall rule is data-driven (wall drops to stub when a KNOWN floor tile sits on its camera-far side; camera-facing directions derived from WorldRoot basis at runtime, not hand-derived). Default picked by feel during the session; the trippy corridor stays available as a signature-look option. (2) NEW: mouse-over move-here marker on the hovered tile + optimal path line — client-side A* over the never-evict KNOWN tile store (info-boundary-honest: the character plans only through known tiles; fog is unpathable), 8-dir with diagonal step cost 1 (time-optimal per the no-sqrt2 wire), terrain-cost provider seam (uniform today, Phase-4 terrain later), click executes by streaming ordinary Move* step commands through the existing throttle (zero protocol change, server validates every step), cancelled by WASD override / path invalidation / arrival. Settles more of Q-084 (click-to-move half).
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).', '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);
+57
View File
@@ -0,0 +1,57 @@
class_name SandboxHeadLook
extends RefCounted
## T-1088 follow-facing refinement: layered mouse look-at while a path-follow
## commits the body to the leg direction (locomotion_rig.commit_body_to_motion).
##
## Two LookAtModifier3D nodes under the CharacterVisual skeleton aim at the
## shared HeadAimTarget: spine_02 contributes a small torso twist (looking far
## lateral/behind), Head does the bulk. Past their combined limits the character
## physically cannot look further without turning — correct. Pure client
## presentation on the D-249 model; SetFacing still rides the wire, so the
## SERVER vision cone follows the mouse while the body walks the path.
##
## Bone axes verified from armature.glb rest pose (bone_axes_dump.py): both
## bones are Y-up with +Z as the facing axis in Godot bone space — forward_axis
## is set from measurement, not guessed (the E/W-mirror lesson).
var _mods: Array[LookAtModifier3D] = []
var _target_influence: float = 0.0
## Create + configure the modifier pair. Call once after CharacterVisual has
## built its skeleton (the sandbox never rebuilds the descriptor; a rebuild
## would free the skeleton and require a re-setup).
func setup(skeleton: Skeleton3D, aim_target: Node3D) -> void:
_mods.clear()
var configs: Array[Dictionary] = [
{"bone": "spine_02", "limit_deg": SandboxConstants.HEAD_LOOK_TORSO_LIMIT_DEG},
{"bone": "Head", "limit_deg": SandboxConstants.HEAD_LOOK_HEAD_LIMIT_DEG},
]
for cfg in configs:
var m := LookAtModifier3D.new()
m.name = "LookAt_%s" % cfg["bone"]
skeleton.add_child(m)
m.bone_name = str(cfg["bone"])
m.target_node = m.get_path_to(aim_target)
m.forward_axis = SkeletonModifier3D.BONE_AXIS_PLUS_Z
m.primary_rotation_axis = Vector3.AXIS_Y
m.use_secondary_rotation = true # slight pitch toward the ground point
m.use_angle_limitation = true
m.symmetry_limitation = true
m.primary_limit_angle = deg_to_rad(float(cfg["limit_deg"]))
m.influence = 0.0
_mods.append(m)
## Fade the look-at in while a follow is active, out otherwise.
func set_active(active: bool) -> void:
_target_influence = 1.0 if active else 0.0
func update(delta: float) -> void:
if _mods.is_empty():
return
var step := delta / maxf(SandboxConstants.HEAD_LOOK_FADE_S, 0.001)
for m in _mods:
if is_instance_valid(m):
m.influence = move_toward(m.influence, _target_influence, step)
+16 -4
View File
@@ -62,6 +62,10 @@ 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).
@@ -197,10 +201,18 @@ func _update_facing(delta: float) -> void:
var suppressed := false
if suppression_provider.is_valid():
suppressed = bool(suppression_provider.call())
var idle_octant := ""
if idle_facing_provider.is_valid():
idle_octant = str(idle_facing_provider.call())
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
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))
else:
var idle_octant := ""
if idle_facing_provider.is_valid():
idle_octant = str(idle_facing_provider.call())
yaw_target = select_yaw_target(is_moving, wire_octant, idle_octant, suppressed, yaw_target)
if model_root == null:
return
var budget_key := stance if is_moving else "Idle"
@@ -41,6 +41,11 @@ var _aim_provider: SandboxMouseAimProvider = null
## per frame). Installed in _ready(); the RMB gesture handling is in _unhandled_input.
var _path_preview: PathPreview = null
var _path_follower: PathFollower = null
## T-1088 follow-facing: layered head/torso look-at (active during follows) and
## its shared aim-target node (WorldRoot-local, positioned at the mouse ground
## point each frame).
var _head_look: SandboxHeadLook = null
var _head_aim_target: Node3D = null
## Time (ms) a non-double RMB press began, -1 = none pending. Resolves click vs
## long-press on RELEASE (RMB_LONG_PRESS_MS threshold).
var _rmb_press_msec: int = -1
@@ -97,6 +102,15 @@ func _ready() -> void:
_anim = LocomotionAnim.new()
_anim.setup(player_rig, character_visual)
# (3d) Follow-facing look-at (T-1088): aim target under WorldRoot (its local
# space IS the sim-aligned space ground_hit_local returns) + the modifier
# pair on the freshly-built skeleton.
_head_aim_target = Node3D.new()
_head_aim_target.name = "HeadAimTarget"
world_root.add_child(_head_aim_target)
_head_look = SandboxHeadLook.new()
_head_look.setup(character_visual.get_skeleton(), _head_aim_target)
# (4) InputMapper facing provider (design §9).
_install_facing_provider()
@@ -390,6 +404,32 @@ 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())
_update_head_aim_target()
_head_look.update(delta)
## Position the shared look-at target at the mouse's ground point (WorldRoot-
## local — the same sim-aligned space the aim provider returns) at eye height.
func _update_head_aim_target() -> void:
var cam := get_viewport().get_camera_3d()
if cam == null or _head_aim_target == null:
return
var mouse := get_viewport().get_mouse_position()
var hit: Variant = SandboxMouseAimProvider.ground_hit_local(
cam.project_ray_origin(mouse), cam.project_ray_normal(mouse),
world_root.global_transform
)
if hit != null:
var p: Vector3 = hit
p.y = SandboxConstants.HEAD_LOOK_EYE_HEIGHT_M
_head_aim_target.position = p
## Hard camera-pivot snap (design §7) — first-snapshot latch + teleport fan-out.
func _snap_camera_to_player() -> void:
@@ -121,6 +121,17 @@ const TINTS := {
## Ground-plane deadzone (m) for the mouse facing provider — mirrors the 2D jitter guard.
const MOUSE_AIM_DEADZONE_M := 0.1
# -- Head/torso look-at during path-follow (T-1088 follow-facing) ---------------------
## Head bone yaw limit (deg) for the mouse look-at while the body walks a path.
const HEAD_LOOK_HEAD_LIMIT_DEG := 70.0
## Torso (spine_02) contribution limit (deg) — engages when looking far lateral/behind.
const HEAD_LOOK_TORSO_LIMIT_DEG := 30.0
## Look-at influence fade in/out (s) on follow start/end.
const HEAD_LOOK_FADE_S := 0.25
## Aim point height above the mouse ground hit (m) — roughly eye level.
const HEAD_LOOK_EYE_HEIGHT_M := 1.55
# -- Path preview / click-to-move (T-1088 live-session item 2) ------------------------
## Move-here marker fill on the hovered destination subtile — distinct accent,
@@ -0,0 +1,20 @@
"""Dump rest-pose local matrices for look-at target bones in armature.glb."""
import sys
import bpy
glb = sys.argv[sys.argv.index("--") + 1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=glb)
for obj in bpy.data.objects:
if obj.type == "ARMATURE":
for name in ("Head", "neck_01", "spine_02", "spine_03"):
b = obj.data.bones.get(name)
if b is None:
print(name, "MISSING")
continue
# matrix_local: bone rest transform in armature space.
m = b.matrix_local
print(name, "head:", [round(v, 3) for v in b.head_local])
for row in range(3):
print(" ", [round(m[row][c], 3) for c in range(4)])