OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / ski_stance_gate.gd

215 lines · gd ref:
extends Node3D
## Does holding the ski key change the STANCE, not just the traction?
##
## Holding jump already drops traction to ski_hold_traction (player_procedural.gd
## :751, :1183) — the physics half of skiing has been there. Nothing tells the
## ANIMATOR, so a skier is posed exactly like a runner and the mechanic does not
## read on screen at all.
##
## WHAT A SKI STANCE IS, and so what this measures:
##   WIDTH   the feet separate laterally — a wider base of support, because the
##           disturbing force is sideways and the base has to span it.
##   CROUCH  the pelvis drops — a lower centre of mass over that base.
##   BANK    the body leans INTO a turn. Not decoration: on skis the lean is what
##           puts the edge into the surface, and it scales with how hard you are
##           turning.
##
## THE CONTROL IS THE TABLE. Rows 1 and 2 are the same run and differ only in
## whether the key is held; so are 3 and 4. If a pair agrees, the gate is not
## seeing the feature and every number in it is worthless — which is the failure
## this whole session kept paying for.
##
## Run:
##   "$HYPER_TPS_GODOT" --headless res://scenes/test/ski_stance_gate.tscn

const SETTLE := 70
const RUN := 120
const TURN_RATE := 1.2          ## rad/s of input rotation in the turning rows

var _plan: Array = []
var _idx := 0
var _f := 0
var _player: CharacterBody3D = null
var _ground: StaticBody3D = null
var _skel: Node = null
var _width_sum := 0.0
var _hip_sum := 0.0
var _n := 0
var _peak_tilt := 0.0
var _ski_seen := 0
var _rows: Array = []
var _done := false


func _ready() -> void:
	process_priority = 100
	for ski in [false, true]:
		_plan.append({"ski": ski, "turn": false})
	for ski in [false, true]:
		_plan.append({"ski": ski, "turn": true})
	_build()


func _build() -> void:
	_ground = StaticBody3D.new()
	var cs := CollisionShape3D.new()
	var box := BoxShape3D.new()
	box.size = Vector3(400.0, 1.0, 400.0)
	cs.shape = box
	_ground.add_child(cs)
	_ground.position = Vector3(0.0, -0.5, 0.0)
	add_child(_ground)

	var scene: PackedScene = load("res://scenes/player/player_procedural.tscn")
	_player = scene.instantiate()
	add_child(_player)
	_player.global_position = Vector3(0.0, 0.6, 0.0)
	_player.camera_head.global_transform = Transform3D(Basis.IDENTITY, Vector3.ZERO)
	_skel = null
	_f = 0
	_width_sum = 0.0
	_hip_sum = 0.0
	_n = 0
	_peak_tilt = 0.0
	_ski_seen = 0


func _physics_process(_d: float) -> void:
	if _done or _player == null:
		return
	if _skel == null:
		_skel = _find_skel(_player)
		if _skel != null and _skel.has_signal("skeleton_updated"):
			_skel.skeleton_updated.connect(_on_skeleton_updated)
	var cfg: Dictionary = _plan[_idx]
	if bool(cfg["turn"]):
		var t: float = float(_f) / 60.0 * TURN_RATE
		_player.input_sync.input_direction = Vector2(sin(t), -cos(t))
	else:
		_player.input_sync.input_direction = Vector2(0.0, -1.0)
	# Hold-to-ski is Input-driven and needs is_on_floor(), so it cannot be forced
	# by setting the flag: press the real action.
	if bool(cfg["ski"]):
		Input.action_press("jump")
	else:
		Input.action_release("jump")
	_f += 1
	if _f > SETTLE and _player.ski_holding:
		_ski_seen += 1
	if OS.has_environment("HTPS_SKI") and _f > SETTLE and _f % 10 == 0 and bool(cfg["ski"]) and not bool(cfg["turn"]):
		print("[ski] f=%3d floor=%-5s state=%d ski=%-5s hold=%.3f fuel=%.2f y=%.2f vy=%+.2f"
				% [_f, str(_player.is_on_floor()), _player.state, str(_player.ski_holding),
				   _player._jump_hold_time, _player.jump_fuel_remaining,
				   _player.global_position.y, _player.velocity.y])


func _on_skeleton_updated() -> void:
	# SAMPLE ONLY A SETTLED, GROUNDED STANCE. Pressing the ski key fires one jump
	# first -- pre_jump_delay 0.08 is shorter than ski_hold_delay 0.15, so the tap
	# has already gone by the time the hold latches. Measured: the player leaves
	# the ground at f=80, lands at f=130 and skis steadily from f=140. Averaging
	# from a fixed frame put that whole arc in the numbers and read the pelvis at
	# 2.12 m against a standing 0.69 -- an airborne player, not a stance.
	if _done or _skel == null or _player == null or _f <= SETTLE:
		return
	if not _player.is_on_floor():
		return
	if bool(_plan[_idx]["ski"]) and not _player.ski_holding:
		return
	var l: int = _skel.find_bone("DEF-foot.L")
	var r: int = _skel.find_bone("DEF-foot.R")
	var h: int = _skel.find_bone("DEF-spine")
	if l < 0 or r < 0 or h < 0:
		return
	var gx: Transform3D = _skel.global_transform
	var pl: Vector3 = (gx * _skel.get_bone_global_pose(l)).origin
	var pr: Vector3 = (gx * _skel.get_bone_global_pose(r)).origin
	# Lateral separation is measured across the MODEL's right axis, not in world
	# X: the player turns, and a world-axis measurement would read the turn.
	var right: Vector3 = (_player.get_node("Model") as Node3D).global_transform.basis.x.normalized()
	_width_sum += absf((pl - pr).dot(right))
	_hip_sum += (gx * _skel.get_bone_global_pose(h)).origin.y
	_n += 1
	# LEAN IS MEASURED AT THE SPINE, NOT AT THE MODEL NODE. The acceleration tilt
	# is applied as a rotation of the BONES about the COM (_apply_flip_rotation),
	# so the Model's own basis never moves and a model-space measurement reads a
	# flat 0.00 through a hard turn -- which is what this gate did first.
	var mu: Vector3 = (gx * _skel.get_bone_global_pose(h)).basis.y.normalized()
	var tilt: float = rad_to_deg(mu.angle_to(Vector3.UP))
	if tilt > _peak_tilt:
		_peak_tilt = tilt


func _process(_d: float) -> void:
	if _done or _player == null or _f < SETTLE + RUN:
		return
	var cfg: Dictionary = _plan[_idx]
	_rows.append({"ski": cfg["ski"], "turn": cfg["turn"],
			"width": (_width_sum / float(_n)) if _n > 0 else 0.0,
			"hip": (_hip_sum / float(_n)) if _n > 0 else 0.0,
			"tilt": _peak_tilt,
			"held": 100.0 * float(_ski_seen) / float(RUN), "n": _n})
	_player.queue_free(); _player = null
	_ground.queue_free(); _ground = null
	_idx += 1
	if _idx < _plan.size():
		_build()
		return
	_report()
	_done = true
	get_tree().quit(0)


func _report() -> void:
	print("")
	print("run          ski held  samples   stance width   pelvis height   peak lean")
	print("-------------------------------------------------------------------")
	for r in _rows:
		print("%-12s %6.0f %%   %5d   %9.4f m   %10.4f m   %7.2f deg"
				% ["turning" if r["turn"] else "straight", r["held"], int(r["n"]),
				   r["width"], r["hip"], r["tilt"]])
	print("")
	var straight_off: Dictionary = _rows[0]
	var straight_on: Dictionary = _rows[1]
	var turn_off: Dictionary = _rows[2]
	var turn_on: Dictionary = _rows[3]
	for r in _rows:
		if int(r["n"]) < 20:
			print("RIG FAILED: only %d settled samples in the %s/%s row — not enough of"
					% [int(r["n"]), "ski" if r["ski"] else "no-ski", "turn" if r["turn"] else "straight"])
			print("a grounded stance was ever observed to average.")
			return
	var dw: float = float(straight_on["width"]) - float(straight_off["width"])
	var dh: float = float(straight_on["hip"]) - float(straight_off["hip"])
	var dt: float = float(turn_on["tilt"]) - float(turn_off["tilt"])
	print("holding the key changes:  width %+.4f m   pelvis %+.4f m   lean %+.2f deg"
			% [dw, dh, dt])
	if absf(dw) < 0.01 and absf(dh) < 0.01 and absf(dt) < 1.0:
		print("")
		print("FAIL  nothing about the stance responds to the ski key. The traction")
		print("      changes and the body does not, so skiing is posed as running.")
		return
	var ok := true
	if dw < 0.04:
		print("FAIL  stance does not widen (%+.4f m, want >= +0.04)" % dw)
		ok = false
	if dh > -0.02:
		print("FAIL  pelvis does not drop (%+.4f m, want <= -0.02)" % dh)
		ok = false
	if dt < 2.0:
		print("FAIL  no extra lean into the turn (%+.2f deg, want >= +2)" % dt)
		ok = false
	if ok:
		print("")
		print("PASS  wider, lower, and it banks into the turn")


func _find_skel(n: Node) -> Node:
	if n.get_class() == "Skeleton3D":
		return n
	for c in n.get_children():
		var f: Node = _find_skel(c)
		if f != null:
			return f
	return null