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>
99 lines
3.7 KiB
GDScript
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
|