OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_step_up.gd

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