extends Node3D
## GATE — footsteps follow the FEET (user 2026-10-06: "do the footstep rhythm fix").
##
## They were a fixed 0.35 s timer: ~2.9 steps/s at any speed, so a slow walk chattered
## and nothing lined up with the legs. The procedural walk cycle advances by DISTANCE
## (procedural_animator._update_cycle_phase: phase += speed*dt / stride, stride lerped
## walk_stride_length -> run_stride_length by speed) and has TWO footfalls per cycle.
## So over a straight run the expected count is Σ distance / (stride(speed) / 2).
##
## A player on a long flat floor is driven forward at full and at half input for RUN_S;
## the footstep count (player.footstep_count) must be within TOL of the expected count
## at BOTH speeds, and consecutive steps must alternate feet.
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . \
## res://scenes/test/gate_footstep_rhythm.tscn
const RUN_S := 6.0
const TOL := 0.25
var _fails := 0
var _checks := 0
## Re-applied EVERY physics frame, after the player's own input sync (which overwrites
## input_direction from the real keyboard each frame) — gate_step_up's pattern.
var _drive_player: CharacterBody3D = null
var _drive := Vector2.ZERO
func _physics_process(_d: float) -> void:
if _drive_player != null and is_instance_valid(_drive_player):
_drive_player.input_sync.input_direction = _drive
func _ok(m: String, c: bool, got: Variant = "") -> void:
_checks += 1
print(" %-62s %s %s" % [m, "OK" if c else "**FAIL**", str(got)])
if not c:
_fails += 1
func _box(lo: Vector3, hi: Vector3) -> void:
var b := StaticBody3D.new()
var cs := CollisionShape3D.new()
var sh := BoxShape3D.new()
sh.size = hi - lo
cs.shape = sh
b.position = (lo + hi) * 0.5
b.add_child(cs)
add_child(b)
func _ready() -> void:
process_priority = 100
process_physics_priority = 100
_box(Vector3(-4, -1, -400), Vector3(4, 0, 4))
_run.call_deferred()
func _run() -> void:
for mag: float in [1.0, 0.5]:
await _case(mag)
print("GATE footstep_rhythm: %s (%d checks, %d failed)" % ["PASS" if _fails == 0 else "FAIL", _checks, _fails])
get_tree().quit(0 if _fails == 0 else 1)
func _case(mag: float) -> void:
var p: CharacterBody3D = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
add_child(p)
p.global_position = Vector3(0, 0.05, 0)
p.camera_head.global_transform = Transform3D(Basis.IDENTITY, p.camera_head.global_position)
_drive_player = p
_drive = Vector2.ZERO
for i in 30:
await get_tree().physics_frame
var anim: Node = p.get("_proc_animator")
var ws: float = anim.get("walk_stride_length")
var rs: float = anim.get("run_stride_length")
var wt: float = anim.get("walk_speed_threshold")
var rt: float = anim.get("run_speed_threshold")
_drive = Vector2(0.0, -mag)
# let it reach speed before counting
for i in 60:
await get_tree().physics_frame
var c0: int = int(p.get("footstep_count"))
var expected := 0.0
var dist := 0.0
var t := 0.0
var prev := p.global_position
var peaks: Array = [[], []]
var cur_max := [0.0, 0.0]
var prev_sw := [0.0, 0.0]
while t < RUN_S:
await get_tree().physics_frame
if OS.get_environment("GATE_DEBUG") != "":
var fik: Object = anim.get("_foot_ik")
if fik != null:
var sw: Array = fik.get("_swing")
for fi in 2:
cur_max[fi] = maxf(cur_max[fi], float(sw[fi]))
if float(sw[fi]) < 0.02 and prev_sw[fi] >= 0.02:
peaks[fi].append(snappedf(cur_max[fi], 0.01))
cur_max[fi] = 0.0
prev_sw[fi] = float(sw[fi])
var d := get_physics_process_delta_time()
t += d
var moved := Vector2(p.global_position.x - prev.x, p.global_position.z - prev.z).length()
prev = p.global_position
var speed := moved / d
dist += moved
if speed >= wt:
var stride := rs if speed >= rt else lerpf(ws, rs, clampf((speed - wt) / (rt - wt), 0.0, 1.0))
expected += moved / (stride * 0.5)
if OS.get_environment("GATE_DEBUG") != "":
print("DBG input %.1f swing peaks L=%s R=%s" % [mag, str(peaks[0]), str(peaks[1])])
var got: int = int(p.get("footstep_count")) - c0
var feet: Array = p.get("footstep_feet") if p.get("footstep_feet") != null else []
var alternate := true
for i in range(1, feet.size()):
if feet[i] == feet[i - 1]:
alternate = false
_ok("input %.1f: %.1f m in %.0f s — steps match the stride (±%d%%)" % [mag, dist, RUN_S, int(TOL * 100)],
expected > 2.0 and absf(got - expected) <= expected * TOL, "got %d, expected %.1f" % [got, expected])
_ok("input %.1f: consecutive steps alternate feet" % mag, feet.size() >= 2 and alternate,
"%d steps recorded" % feet.size())
_drive_player = null
p.queue_free()
await get_tree().physics_frame