OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_handspring.gd

220 lines · gd ref:
extends Node3D
## GATE — the HANDSPRING: Jump during a ground roll (user 2026-10-05: "when the player is
## rolling and switches to stand, sometimes they continue to roll as they switch to
## standing and end up doing a kind of handspring / flip, it looks good" -> "make it a
## deliberate move, jump during the roll").
##
## Headless, physics only: FlipRoll's state, the body velocity and duck, per frame.
##   A. ACCIDENTAL no more: air flip, land, roll — once the roll ends the body is upright
##      and STAYS upright (the air flip's leftover `settling` spin used to play out after
##      the roll, a full turn while standing)
##   B. DELIBERATE: run, roll, Jump mid-roll ->
##        it is a handspring (FlipRoll.handspring), the body leaves the ground
##        it keeps the roll's forward speed (min horizontal speed through it)
##        the body OPENS within 4 frames and is EXTENDED (duck < 0.5) over the top (135..225)
##        the turn completes to upright, with no frame-to-frame snap (MAX_STEP)
##        it lands, upright, still moving forward
##   C. a plain roll is still a roll: no handspring, no extra turn after it
##   D. a plain jump (not rolling) is still a jump: no handspring
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless --path . res://scenes/test/gate_handspring.tscn

const MAX_STEP := 40.0          # degrees per physics frame (a 0.4 s turn is ~15)

var fails := 0
var checks := 0
var _player: Node = null


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 _ready() -> void:
	process_priority = 100
	var b := StaticBody3D.new()
	var cs := CollisionShape3D.new()
	var sh := BoxShape3D.new()
	sh.size = Vector3(20, 2, 400)
	cs.shape = sh
	b.position = Vector3(0, -1, -150)
	b.add_child(cs)
	add_child(b)
	await _run()
	if checks < 16:
		print("  **FAIL** only %d checks ran" % checks)
		fails += 1
	print("\nGATE handspring: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


func _spawn() -> void:
	if _player != null:
		_player.queue_free()
		await get_tree().physics_frame
	_player = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
	add_child(_player)
	_player.global_position = Vector3(0, 0.02, 3.0)


func _fr() -> RefCounted:
	var an: Node = _player.get("_proc_animator")
	return an.get("_flip_roll") if an != null else null


## Signed-free roll angle in degrees [0, 360).
func _ang() -> float:
	var fr := _fr()
	return fposmod(float(fr.flip_angle), 1.0) * 360.0 if fr != null else 0.0


## Hold forward, run `frames` physics frames, calling `step(i)` each; returns rows.
func _drive(frames: int, step: Callable) -> Array:
	var rows: Array = []
	for i in frames:
		_player.input_sync.input_direction = Vector2(0.0, -1.0)
		step.call(i)
		await get_tree().physics_frame
		var fr := _fr()
		var v: Vector3 = _player.velocity
		rows.append({"i": i, "ang": _ang(), "duck": float(_player.get("duck_amount")),
			"floor": bool(_player.is_on_floor()), "y": float(_player.global_position.y),
			"hs": Vector2(v.x, v.z).length(),
			"flipping": fr != null and bool(fr.flipping), "settling": fr != null and bool(fr.settling),
			"rolling": fr != null and bool(fr.rolling),
			"hand": fr != null and bool(fr.get("handspring") if "handspring" in fr else false)})
		if OS.get_environment("GATE_DEBUG") == "1" and i % 5 == 0:
			print("    i%3d y %.2f floor %s hs %.2f ang %.0f duck %.2f flip %s roll %s settle %s hand %s" % [i,
				rows[-1]["y"], rows[-1]["floor"], rows[-1]["hs"], rows[-1]["ang"], rows[-1]["duck"],
				rows[-1]["flipping"], rows[-1]["rolling"], rows[-1]["settling"], rows[-1]["hand"]])
	return rows


func _tap(i: int, at: int, action: String) -> void:
	if i == at:
		Input.action_press(action)
	elif i == at + 2:
		Input.action_release(action)


func _jump(i: int, at: int) -> void:
	_player.input_sync.wants_jump = (i == at)


func _run() -> void:
	# ---- A. air flip, land, roll ----
	# The flip goes in LATE in the fall, so its settle spin is still running at landing
	# (the case that played out after the roll). A dict, not locals: GDScript lambdas
	# capture locals BY VALUE, so an assignment inside one never reaches the caller.
	await _spawn()
	var st := {"landed": -1, "roll": -1, "jump": -1}
	var rows_a: Array = await _drive(280, func(i: int) -> void:
		_jump(i, 40)
		_tap(i, 128, "crouch")
		if i > 140 and int(st["roll"]) < 0 and bool(_player.is_on_floor()):
			var fr0 := _fr()
			print("    A landed f%d settling=%s" % [i, str(fr0 != null and bool(fr0.settling))])
			st["landed"] = i
			st["roll"] = i + 1
		if int(st["roll"]) > 0:
			_tap(i, int(st["roll"]), "crouch"))
	var landed_at: int = st["landed"]
	var roll_at: int = st["roll"]
	var flipped := rows_a.any(func(r: Dictionary) -> bool: return int(r["i"]) > 128 and int(r["i"]) < 140 and bool(r["flipping"]) and not bool(r["rolling"]))
	_ok("A. setup: the air flip ran", flipped)
	_ok("A. setup: landed and rolled", roll_at > 0 and rows_a.any(func(r: Dictionary) -> bool: return bool(r["rolling"])),
		"landed f%d" % landed_at)
	var roll_end := -1
	for r: Dictionary in rows_a:
		if roll_at > 0 and int(r["i"]) > roll_at + 3 and roll_end < 0 and not bool(r["flipping"]):
			roll_end = int(r["i"])
	var after_turn := 0.0
	for r: Dictionary in rows_a:
		if roll_end > 0 and int(r["i"]) > roll_end + 3:
			var a: float = r["ang"]
			after_turn = maxf(after_turn, minf(a, 360.0 - a))
	_ok("A. after the roll the body stays upright (no leftover flip)", roll_end > 0 and after_turn < 15.0,
		"roll ended f%d, max tilt after %.1f deg" % [roll_end, after_turn])

	# ---- B. the handspring ----
	await _spawn()
	var rows_b: Array = await _drive(150, func(i: int) -> void:
		_tap(i, 40, "crouch")
		var fr := _fr()
		if int(st["jump"]) < 0 and fr != null and bool(fr.rolling) and float(fr.flip_progress) >= 0.35:
			st["jump"] = i
		_jump(i, int(st["jump"])))
	var jump_at: int = st["jump"]
	var hs_rows := rows_b.filter(func(r: Dictionary) -> bool: return bool(r["hand"]))
	_ok("B. Jump mid-roll is a HANDSPRING", jump_at > 0 and not hs_rows.is_empty(), "jump f%d, %d frames" % [jump_at, hs_rows.size()])
	if hs_rows.is_empty():
		return
	var y0: float = rows_b[jump_at]["y"]
	var peak := -INF
	var min_hs := INF
	var air := false
	for r: Dictionary in rows_b:
		if int(r["i"]) >= jump_at and int(r["i"]) <= jump_at + 50:
			peak = maxf(peak, float(r["y"]) - y0)
			min_hs = minf(min_hs, float(r["hs"]))
			if not bool(r["floor"]):
				air = true
	_ok("B. it leaves the ground", air and peak > 0.3, "peak %.2f m" % peak)
	_ok("B. it keeps the roll's forward speed", min_hs > 6.0, "min %.2f m/s" % min_hs)
	var inverted := hs_rows.filter(func(r: Dictionary) -> bool: return float(r["ang"]) > 90.0 and float(r["ang"]) < 270.0)
	_ok("B. it goes over the top (upside down frames)", not inverted.is_empty(), "%d" % inverted.size())
	# The jump is pressed with the roll already ~90-130 deg round, so the first frames
	# after it are still the tuck being pushed open — as a real handspring launches.
	# What must hold: OPEN over the top, and open fast.
	var top := hs_rows.filter(func(r: Dictionary) -> bool: return float(r["ang"]) > 135.0 and float(r["ang"]) < 225.0)
	var tucked := top.filter(func(r: Dictionary) -> bool: return float(r["duck"]) >= 0.5)
	_ok("B. EXTENDED over the top (135..225 deg), not the roll's tuck", not top.is_empty() and tucked.is_empty(),
		"%d of %d top frames tucked" % [tucked.size(), top.size()])
	var open_at := -1
	for r: Dictionary in rows_b:
		if int(r["i"]) >= jump_at and open_at < 0 and float(r["duck"]) < 0.5:
			open_at = int(r["i"]) - jump_at
	_ok("B. the body opens within 4 frames of the jump", open_at >= 0 and open_at <= 4, "%d frames" % open_at)
	var step := 0.0
	var prev := -1.0
	for r: Dictionary in rows_b:
		if int(r["i"]) < jump_at:
			continue
		var a: float = r["ang"]
		if prev >= 0.0:
			var d := absf(a - prev)
			step = maxf(step, minf(d, 360.0 - d))
		prev = a
	_ok("B. no snap: largest frame-to-frame turn", step <= MAX_STEP, "%.1f deg" % step)
	var last: Dictionary = rows_b[-1]
	var la: float = last["ang"]
	_ok("B. it lands", bool(last["floor"]))
	_ok("B. upright at the end", minf(la, 360.0 - la) < 10.0 and not bool(last["flipping"]) and not bool(last["settling"]),
		"%.1f deg" % la)
	_ok("B. still moving forward", float(last["hs"]) > 3.0, "%.2f m/s" % float(last["hs"]))

	# ---- C. a plain roll ----
	await _spawn()
	var rows_c: Array = await _drive(110, func(i: int) -> void: _tap(i, 40, "crouch"))
	_ok("C. a plain roll is no handspring", not rows_c.any(func(r: Dictionary) -> bool: return bool(r["hand"])))
	var c_end := -1
	for r: Dictionary in rows_c:
		if int(r["i"]) > 45 and c_end < 0 and not bool(r["flipping"]):
			c_end = int(r["i"])
	var c_tilt := 0.0
	for r: Dictionary in rows_c:
		if c_end > 0 and int(r["i"]) > c_end + 2:
			c_tilt = maxf(c_tilt, minf(float(r["ang"]), 360.0 - float(r["ang"])))
	_ok("C. and ends upright", c_end > 0 and c_tilt < 15.0, "%.1f deg" % c_tilt)
	_ok("C. it stayed on the ground", rows_c.all(func(r: Dictionary) -> bool: return float(r["y"]) < 0.3))

	# ---- D. a plain jump ----
	await _spawn()
	var rows_d: Array = await _drive(80, func(i: int) -> void: _jump(i, 40))
	_ok("D. a plain jump is no handspring", not rows_d.any(func(r: Dictionary) -> bool: return bool(r["hand"])))
	_ok("D. ... and no flip", not rows_d.any(func(r: Dictionary) -> bool: return bool(r["flipping"])))
	_ok("D. it jumped", rows_d.any(func(r: Dictionary) -> bool: return float(r["y"]) > 0.5))