extends Node3D
## GATE — PLAYER STEP-UP (docs/player-step-up-design.md §6; user 2026-10-05: "0.79,
## automatic, derive remotely — build it").
##
## The real procedural player walks (held input, like foot_walk_gate) into lanes:
##
## 1. ledges of 0.10 / 0.15 / 0.224 / 0.30 / 0.395 m: climbed with is_on_floor() true
## on EVERY frame and never AIRBORNE (the walk band)
## 2. ledges of 0.45 / 0.60 / 0.79 m: climbed with exactly ONE hop each; the animator
## shows STEP_HOP, never FALL / LAND
## 3. a 0.85 m ledge: NOT climbed by walking into it
## 4. a flight of 8 × 0.224 m steps up and 8 down: no AIRBORNE frame at all
## 5. a 0.6 m counter alongside the path (walking parallel to it): no hop
## 6. with the feature OFF (step_up = hop_max = 0) a 0.224 m step is a WALL — the
## reason for all of this — while 0.10 m is still ridden by the capsule alone
## 7. the DRAWN body height is continuous: no frame-to-frame jump > 0.07 m — the
## player's catch-up rate (step_catchup_speed 3.6 m/s = 0.06 m a frame at 60 fps)
## plus 1 cm of margin (0.07); a pop is a whole step (0.224 m) in one frame
## 8. a flight of 8 x 0.60 m steps (1.0 m treads) — a steep incline: climbed by several
## RUNNING LEAPS at near run pace, lead leg alternating, never FALL/LAND
## 11. REAL stairs (0.224 m risers on 0.30 / 0.45 m treads): walked, never hopped, and
## at RUN SPEED — the mean horizontal speed on the flight is >= 90% of the flat's
## and no frame drops under half of it (it was 2.24 of 8 m/s, stopping dead at
## every riser — user 2026-10-05: "player stair step is all wrong")
## 10. a REMOTE copy (another authority, fed the local sync_state each frame) plays
## STEP_HOP for the 0.60 m hop — derived, no sync bytes (user: "derive remotely")
## 9. walking up a 20° RAMP is not a step: the drawn-height offset stays ~0 (a slope
## is explained by the ground, so the body is never drawn lagging below itself)
##
## Run: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_step_up.tscn
const RUN_FRAMES := 200
const LANE_GAP := 6.0
var _plan: Array = [] # [{name, build: Callable, check: Callable, feature: bool}]
var _idx := -1
var _frames := 0
var _player: Node = null
var _lane: Node3D = null
var _log: Array = [] # per frame: {y, floor, state, anim, model_y, hopping}
var fails := 0
var checks := 0
var _done := false
var _remote: Node = null
var _remote_hop_frames := 0
var _remote_fall_frames := 0
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-66s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _box(parent: Node3D, 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)
parent.add_child(b)
func _ready() -> void:
process_priority = 100
for h: float in [0.10, 0.15, 0.224, 0.30, 0.395, 0.448]:
_plan.append({"name": "walk %.3f" % h, "h": h, "kind": "walk"})
for h2: float in [0.50, 0.60, 0.79]:
_plan.append({"name": "hop %.2f" % h2, "h": h2, "kind": "hop"})
_plan.append({"name": "blocked 0.85", "h": 0.85, "kind": "blocked"})
_plan.append({"name": "flight 0.224 up+down", "h": 0.224, "kind": "flight"})
_plan.append({"name": "counter alongside", "h": 0.6, "kind": "counter"})
_plan.append({"name": "OFF: 0.224 is a wall", "h": 0.224, "kind": "off_wall"})
_plan.append({"name": "OFF: 0.10 still ridden", "h": 0.10, "kind": "off_ride"})
_plan.append({"name": "leap flight 0.60", "h": 0.60, "kind": "hop_flight"})
_plan.append({"name": "ramp 20°", "h": 0.0, "kind": "ramp"})
# REAL stairs (user 2026-10-05: "player stair step is all wrong"): treads 0.30 / 0.45 m.
_plan.append({"name": "stairs 0.224 x 0.30 tread", "h": 0.224, "kind": "stairs", "run": 0.30})
_plan.append({"name": "stairs 0.224 x 0.45 tread", "h": 0.224, "kind": "stairs", "run": 0.45})
# 16 units (user: "run up much higher, say 16 max").
_plan.append({"name": "stairs 0.448 x 0.45 tread", "h": 0.448, "kind": "stairs", "run": 0.45})
_next()
func _next() -> void:
if _player != null:
_player.queue_free()
_player = null
if _remote != null:
_remote.queue_free()
_remote = null
if _lane != null:
_lane.queue_free()
_lane = null
_idx += 1
if _idx >= _plan.size():
_finish()
return
var p: Dictionary = _plan[_idx]
_lane = Node3D.new()
_lane.position = Vector3(float(_idx) * LANE_GAP, 0, 0)
add_child(_lane)
_box(_lane, Vector3(-2, -1, -40), Vector3(2, 0, 6)) # the floor
var h: float = p["h"]
match String(p["kind"]):
"walk", "hop", "blocked", "off_wall", "off_ride":
_box(_lane, Vector3(-2, 0, -40), Vector3(2, h, -3))
"stairs":
var rr: float = p["run"]
for i in 10:
_box(_lane, Vector3(-2, 0, -3 - rr * (i + 1)), Vector3(2, h * (i + 1), -3 - rr * i))
_box(_lane, Vector3(-2, 0, -40), Vector3(2, h * 10, -3 - rr * 10))
"flight", "hop_flight":
var n := 8
var run := 0.6 if String(p["kind"]) == "flight" else 1.0
for i in n: # up
_box(_lane, Vector3(-2, 0, -3 - run * (i + 1)), Vector3(2, h * (i + 1), -3 - run * i))
for j in n: # down the far side
var z0 := -3 - run * n - run * j
_box(_lane, Vector3(-2, 0, z0 - run), Vector3(2, h * (n - j - 1) + 0.0001, z0))
"counter":
_box(_lane, Vector3(0.36, 0, -40), Vector3(1.4, h, 6))
"ramp":
# A 20° ramp rising toward -z, its foot at z = -3.
var rb := StaticBody3D.new()
var rcs := CollisionShape3D.new()
var rsh := BoxShape3D.new()
rsh.size = Vector3(4, 0.2, 30)
rcs.shape = rsh
rb.add_child(rcs)
rb.rotation = Vector3(deg_to_rad(20.0), 0, 0)
rb.position = Vector3(0, 15.0 * sin(deg_to_rad(20.0)) - 0.1 / cos(deg_to_rad(20.0)), -3.0 - 15.0 * cos(deg_to_rad(20.0)))
_lane.add_child(rb)
_player = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
add_child(_player)
_player.global_position = _lane.position + Vector3(0, 0.02, 3.0)
_player.camera_head.global_transform = Transform3D(Basis.IDENTITY, _player.camera_head.global_position)
var off: bool = String(p["kind"]).begins_with("off_")
_player.set("step_up", 0.0 if off else 0.45) # 16 units (user 2026-10-05)
_player.set("hop_max", 0.0 if off else 0.79)
_player.input_sync.input_direction = Vector2(0.0, -1.0)
if String(p["name"]) == "hop 0.60":
_remote = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
_remote.set_multiplayer_authority(2) # not us: _process_remote runs
add_child(_remote)
_remote.global_position = _player.global_position + Vector3(0, 0, 20)
_remote_hop_frames = 0
_remote_fall_frames = 0
_frames = 0
_log = []
func _physics_process(_d: float) -> void:
if _done or _player == null:
return
_player.input_sync.input_direction = Vector2(0.0, -1.0)
_frames += 1
if _frames > 20: # landed from the spawn drop, up to speed
var an: Node = _player.get("_proc_animator")
_log.append({"y": _player.global_position.y - _lane.position.y,
"z": _player.global_position.z,
"floor": _player.is_on_floor(), "state": int(_player.state),
"anim": int(an.get("_anim_state")) if an != null else -1,
"model_y": _player.model.position.y + _player.global_position.y,
"smooth": float(_player.get("_step_smooth")),
"hopping": bool(_player.get("_step_hopping")) if _player.get("_step_hopping") != null else false,
"lead": int(_player.get("_hop_lead")) if _player.get("_hop_lead") != null else -1,
"hspeed": Vector2(_player.velocity.x, _player.velocity.z).length()})
if _remote != null:
_remote.sync_state = _player.sync_state
var ra: Node = _remote.get("_proc_animator")
if ra != null and _frames > 20:
var st_r := int(ra.get("_anim_state"))
if st_r == 12: # AnimState.STEP_HOP
_remote_hop_frames += 1
elif st_r == 4 or st_r == 5:
_remote_fall_frames += 1
if _frames >= RUN_FRAMES:
if _remote != null:
_ok("10. the REMOTE copy plays STEP_HOP for the hop (derived)", _remote_hop_frames > 0,
"%d STEP_HOP frame(s)" % _remote_hop_frames)
_ok("10. ... and never FALL/LAND", _remote_fall_frames == 0, "%d" % _remote_fall_frames)
_check(_plan[_idx])
_next()
## Mean and slowest horizontal speed of the frames with lane-relative z in (z_lo, z_hi).
func _speed_in(z_lo: float, z_hi: float) -> Array:
var tot := 0.0
var n := 0
var slowest := INF
for f: Dictionary in _log:
var zz := float(f["z"]) - _lane.position.z
if zz > z_lo and zz < z_hi:
tot += float(f["hspeed"])
slowest = minf(slowest, float(f["hspeed"]))
n += 1
return [tot / maxf(1, n), slowest, n]
func _count_hops() -> Array:
var leads: Array = []
var was := false
for f: Dictionary in _log:
if f["hopping"] and not was:
leads.append(f["lead"])
was = f["hopping"]
return leads
func _check(p: Dictionary) -> void:
var h: float = p["h"]
var top_y: float = _log[-1]["y"] if not _log.is_empty() else -1.0
var air := 0
var nofloor := 0
var fall_land := 0
for i in _log.size():
var f: Dictionary = _log[i]
if int(f["state"]) == 1: # State.AIRBORNE
air += 1
if not bool(f["floor"]):
nofloor += 1
if int(f["anim"]) == 4 or int(f["anim"]) == 5: # AnimState.FALL / LAND
fall_land += 1
var hops := _count_hops()
match String(p["kind"]):
"walk":
_ok("1. %s: on top" % p["name"], absf(top_y - h) < 0.03, "y %.3f" % top_y)
_ok("1. %s: on the floor every frame, never airborne" % p["name"], nofloor == 0 and air == 0,
"%d frame(s) off the floor, %d airborne" % [nofloor, air])
"hop":
_ok("2. %s: on top" % p["name"], absf(top_y - h) < 0.03, "y %.3f" % top_y)
_ok("2. %s: exactly one hop" % p["name"], hops.size() == 1, "%d" % hops.size())
_ok("2. %s: STEP_HOP, never FALL/LAND" % p["name"], fall_land == 0, "%d FALL/LAND frame(s)" % fall_land)
# A RUNNING LEAP (user: "at a similar speed as a normal run"): from 2 m before
# the riser to 2 m past it, the pace stays >= 85% of the flat's.
var flat2: Array = _speed_in(0.0, 2.8)
var leap2: Array = _speed_in(-5.0, -1.0)
_ok("2. %s: leapt at run pace (mean >= 85%% of flat)" % p["name"], float(leap2[0]) >= float(flat2[0]) * 0.85,
"flat %.2f, through the leap %.2f (slowest %.2f) m/s" % [flat2[0], leap2[0], leap2[1]])
"blocked":
_ok("3. 0.85 m is not climbed by walking", top_y < 0.1 and hops.is_empty(), "y %.3f, %d hop(s)" % [top_y, hops.size()])
"flight":
_ok("4. a 0.224 m flight up and down: back on the floor beyond it", top_y < 0.03 and float(_log[-1]["z"]) < _lane.position.z - 3 - 0.6 * 16,
"y %.3f z %.2f" % [top_y, float(_log[-1]["z"])])
_ok("4. ... with no AIRBORNE frame", air == 0, "%d airborne frame(s)" % air)
"counter":
_ok("5. walking alongside a 0.6 m counter: no hop", hops.is_empty() and top_y < 0.05, "%d hop(s), y %.3f" % [hops.size(), top_y])
"off_wall":
_ok("6. feature OFF: 0.224 m is a wall", top_y < 0.05, "y %.3f" % top_y)
"off_ride":
_ok("6. feature OFF: 0.10 m is still ridden by the capsule", absf(top_y - h) < 0.03, "y %.3f" % top_y)
"ramp":
var worst_s := 0.0
for f2: Dictionary in _log:
if float(f2["z"]) < _lane.position.z - 4.0 and float(f2["z"]) > _lane.position.z - 14.0:
worst_s = maxf(worst_s, absf(float(f2["smooth"])))
_ok("9. up a 20° ramp the drawn body never lags (|offset| < 0.03 m)", worst_s < 0.03 and top_y > 1.0,
"worst %.3f m, reached y %.2f" % [worst_s, top_y])
"stairs":
_ok("11. %s: walked up to the top" % p["name"], absf(top_y - h * 10) < 0.05, "y %.3f" % top_y)
_ok("11. %s: WALKED, not hopped (no hop, never airborne)" % p["name"], hops.is_empty() and air == 0,
"%d hop(s), %d airborne frame(s)" % [hops.size(), air])
var flat_v: Array = []
var stair_v: Array = []
for f3: Dictionary in _log:
var zz := float(f3["z"]) - _lane.position.z
if zz > -2.0:
flat_v.append(float(f3["hspeed"]))
elif zz < -3.3 and zz > -3.0 - float(p["run"]) * 9.0:
stair_v.append(float(f3["hspeed"]))
var fm := 0.0
for v: float in flat_v:
fm += v
fm /= maxf(1, flat_v.size())
var sm := 0.0
var slowest := INF
for v2: float in stair_v:
sm += v2
slowest = minf(slowest, v2)
sm /= maxf(1, stair_v.size())
var lag := 0.0
for f4: Dictionary in _log:
var z4 := float(f4["z"]) - _lane.position.z
if z4 < -3.3 - float(p["run"]) * 2.0 and z4 > -3.0 - float(p["run"]) * 9.0:
lag = maxf(lag, absf(float(f4["smooth"])))
_ok("11. %s: the drawn body stays within 0.2 m of the real one" % p["name"], lag <= 0.2,
"max %.3f m" % lag)
_ok("11. %s: at run speed (mean >= 90%% of flat, none under half)" % p["name"],
not stair_v.is_empty() and sm >= fm * 0.9 and slowest >= fm * 0.5,
"flat %.2f, stairs mean %.2f, slowest %.2f m/s" % [fm, sm, slowest])
"hop_flight":
# An INCLINE is leapt as an incline: each leap clears a nosing and stays above
# the line of nosings for a stride, landing a tread or two up — several
# leaps up 8 steps, not one per step (at run speed that cannot land).
# The lane goes back DOWN the far side (0.6 m drops are real falls, beyond the
# 0.45 m step-down band), so: the HIGHEST point, and FALL/LAND on the way UP.
var peak := -INF
var fl_up := 0
for f5: Dictionary in _log:
peak = maxf(peak, float(f5["y"]))
var z5 := float(f5["z"]) - _lane.position.z
if z5 > -3.0 - 1.0 * 8.0 and (int(f5["anim"]) == 4 or int(f5["anim"]) == 5):
fl_up += 1
_ok("8. 0.60 m x 8 flight (1.0 m treads): reached the top", absf(peak - h * 8) < 0.05, "highest y %.3f" % peak)
_ok("8. ... by several running leaps (>= 2)", hops.size() >= 2, "%d leaps %s" % [hops.size(), str(hops)])
_ok("8. ... never FALL/LAND on the way up", fl_up == 0, "%d" % fl_up)
var flat3: Array = _speed_in(0.0, 2.8)
var up3: Array = _speed_in(-3.0 - 1.0 * 8.0, -2.0)
_ok("8. ... at near run pace (mean >= 75% of flat)", float(up3[0]) >= float(flat3[0]) * 0.75,
"flat %.2f, up the flight %.2f (slowest %.2f) m/s" % [flat3[0], up3[0], up3[1]])
var alt := hops.size() >= 2
for i in range(1, mini(hops.size(), 4)):
alt = alt and int(hops[i]) != int(hops[i - 1])
_ok("8. the lead leg alternates", alt, str(hops))
# 7. continuity of the DRAWN model height (body + model offset), every lane.
var worst := 0.0
for i in range(1, _log.size()):
worst = maxf(worst, absf(float(_log[i]["model_y"]) - float(_log[i - 1]["model_y"])))
if String(p["kind"]) == "walk":
_ok("7. %s: drawn height continuous (< 0.07 m/frame)" % p["name"], worst < 0.07, "%.3f m" % worst)
elif String(p["kind"]) == "flight":
# A FLIGHT is a sustained climb: at a run up 0.224 m risers the body really rises
# ~6 m/s (0.1 m a frame), so the drawn body must move that fast — the bound is
# the climb, not the single-step ease. A pop would be a whole riser (0.224).
_ok("7. %s: drawn height continuous (< 0.1 m/frame)" % p["name"], worst < 0.1, "%.3f m" % worst)
func _finish() -> void:
_done = true
if checks < 34:
print(" **FAIL** only %d checks ran" % checks)
fails += 1
print("\nGATE step_up: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)