Files
settled-reach/client/scripts/sandbox/sandbox_debug_hud.gd
T
jpmschweitzerandClaude Fable 5 a7801a942a feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)
New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world
derived from server snapshots. Per-leg constant-velocity interpolation keyed
to the stance throttle, 'server feet / client eyes' facing (wire octant while
moving, client aim octant idle), cadence-synced gait state machine on
AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg
default, T-cycle presets), sim-space grid shader, camera-side wall cutaway,
accumulating never-evict tile store with four-state visibility tint.

Additive seams only: InputMapper.facing_angle_provider (2D path unchanged),
CharacterVisual.play_animation blend_time param + get_animation_player().
Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting.
210 new gdUnit assertions across five suites; verified live (230/230 total,
clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:23:16 +02:00

99 lines
3.7 KiB
GDScript

extends CanvasLayer
## T-1088 numeric locomotion instrumentation HUD (design §10.1): tick, tick_rate,
## stance, player tile, render speed, clip + speed_scale, snapshot age, yaw
## target/actual, store size. Sandbox-only. Cadence sync is verifiable numerically
## on screen, not just by eye — tuning NATIVE_MPS is a read-the-HUD job.
##
## All component reads go through has_method guards so the HUD works from the S1
## skeleton and lights up ("—" -> numbers) as later slices land. Duck-typed contract:
## PlayerRig (S4): get_render_speed() -> float (m/s),
## get_yaw_target_rad() -> float
## PlayerRig or root (S6): get_current_clip() -> StringName,
## get_anim_speed_scale() -> float
## Greybox (S3): get_store_size() -> int
const FONT_SIZE := 13
@onready var _rig: Node = get_node_or_null("../WorldRoot/PlayerRig")
@onready var _model_root: Node3D = get_node_or_null("../WorldRoot/PlayerRig/ModelRoot")
@onready var _greybox: Node = get_node_or_null("../WorldRoot/Greybox")
var _label: Label = null
var _last_tick: int = -1
var _last_snapshot_msec: int = -1
func _ready() -> void:
layer = 10
_label = Label.new()
_label.name = "Readout"
_label.position = Vector2(8, 8)
_label.add_theme_font_size_override("font_size", FONT_SIZE)
_label.add_theme_color_override("font_color", Color(0.85, 0.92, 1.0))
_label.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
_label.add_theme_constant_override("outline_size", 4)
add_child(_label)
func _process(_delta: float) -> void:
# Snapshot age: a consumed-tick change is the arrival signal (world_renderer.gd:33-36
# tick-gate pattern). SimBridge delivery is latest-wins, so consumption time is the
# honest age reference for interpolation debugging.
if GameState.current_tick != _last_tick:
_last_tick = GameState.current_tick
_last_snapshot_msec = Time.get_ticks_msec()
_label.text = _build_text()
func _build_text() -> String:
var lines := PackedStringArray()
var rate := str(GameState.game_time.get("tick_rate", "—"))
lines.append("tick %d rate %s snap age %s" % [GameState.current_tick, rate, _age_text()])
var tile := SandboxSpace.wire_to_tile(GameState.player_position.x, GameState.player_position.y)
lines.append("stance %s tile (%d, %d)" % [GameState.player_stance, tile.x, tile.y])
var anim_sources: Array = [_rig, get_parent()]
lines.append(
"speed %s m/s clip %s scale %s"
% [
_fmt(_call_first([_rig], "get_render_speed"), "%.3f"),
_fmt(_call_first(anim_sources, "get_current_clip"), "%s"),
_fmt(_call_first(anim_sources, "get_anim_speed_scale"), "%.2f"),
]
)
var yaw_target: Variant = _call_first([_rig], "get_yaw_target_rad")
if yaw_target != null:
yaw_target = rad_to_deg(float(yaw_target))
var yaw_actual: Variant = null
if _model_root != null:
yaw_actual = rad_to_deg(_model_root.rotation.y)
lines.append(
"yaw target %s actual %s" % [_fmt(yaw_target, "%.1f deg"), _fmt(yaw_actual, "%.1f deg")]
)
lines.append("store %s tiles" % _fmt(_call_first([_greybox], "get_store_size"), "%d"))
return "\n".join(lines)
func _age_text() -> String:
if _last_snapshot_msec < 0:
return "—"
return "%.3f s" % (float(Time.get_ticks_msec() - _last_snapshot_msec) / 1000.0)
# First answer from the candidate nodes implementing `method`, else null.
static func _call_first(nodes: Array, method: String) -> Variant:
for node in nodes:
if node != null and (node as Object).has_method(method):
return (node as Object).call(method)
return null
# Format a duck-typed value; "—" until the providing component lands.
static func _fmt(value: Variant, format: String) -> String:
if value == null:
return "—"
return format % value