OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_footstep_rhythm.gd

126 lines · gd ref:
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