OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_spine_look.gd

140 lines · gd ref:
extends SceneTree
## GATE: a head turn is spread down the spine, not bent at one joint.
##
##   DISPLAY=:0 "$G" --path . --rendering-driver vulkan --script res://scenes/test/gate_spine_look.gd
##
## Reported 2026-10-01: "when the player looks around it looks like it is rotating
## only on one spine". ChestIK put half the turn on DEF-spine.002 and half on .003,
## leaving .001 (and the pelvis) straight. Overgrowth's DrawBody spreads the chest
## key's rotation along the chain. The player stands still; the camera turns
## TURN_DEG to the side; each spine bone's LOCAL rotation change against the
## straight-ahead baseline is its share of the twist. Passes when every one of
## .001/.002/.003 carries at least MIN_SHARE and none more than MAX_SHARE.
const TURN_DEG := 60.0
const SETTLE := 2.0
const BONES := ["DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
const MIN_SHARE := 0.15
const MAX_SHARE := 0.5

var _player: Node3D
var _skel: Skeleton3D
var _anim: Node
var _ran := false

class StubGame extends Node3D:
	var current_map: Node = null


func _process(_d: float) -> bool:
	if not _ran:
		_ran = true
		_run()
	return false


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


func _rots() -> Array:
	var out := []
	for b: String in BONES:
		out.append(_skel.get_bone_pose_rotation(_skel.find_bone(b)))
	return out


func _avg_rots(frames: int) -> Array:
	# Averaged over frames: the idle gaze adds a little noise on top of the look.
	var acc: Array = []
	for i in frames:
		await _skel.skeleton_updated
		var r := _rots()
		if acc.is_empty():
			acc = r
		else:
			for k in r.size():
				acc[k] = (acc[k] as Quaternion).slerp(r[k], 1.0 / float(i + 1))
	return acc


func _run() -> void:
	get_multiplayer().multiplayer_peer = OfflineMultiplayerPeer.new()
	var game := StubGame.new()
	game.name = "Game"
	root.add_child(game)
	var fb := StaticBody3D.new()
	var fc := CollisionShape3D.new()
	var box := BoxShape3D.new()
	box.size = Vector3(200, 2, 200)
	fc.shape = box
	fb.add_child(fc)
	fb.position = Vector3(0, -1, 0)
	game.add_child(fb)
	_player = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
	game.add_child(_player)
	_player.global_position = Vector3(0, 0.3, 0)
	_skel = _find_skel(_player)
	var head: Node3D = _player.get_node("CameraHead")
	# PIN THE IDLE GAZE. Standing still blends 40% of a random gaze into the look,
	# re-picked every 0.8-2 s, which swung the measured totals 24-122 deg between
	# runs of the same code. No new random target for the run; none now.
	var anim: Node = _find_anim(_player)
	if anim != null:
		anim.set("_random_look_delay", 1.0e9)
		anim.set("_random_look_dir", Vector3.ZERO)
	# Look the way the body faces first. The rig's visual forward is the model's +Z
	# (180 deg import flip) and a freshly spawned player faces the default camera,
	# so the controller hands the animator NO look direction at all (it requires
	# cam_look . model.z > 0) -- the first run measured 0.3 deg of twist for a 60
	# deg turn because nothing was looking. In play you face away from the camera
	# after the first step; this is that state.
	head.rotation.y = PI
	await create_timer(SETTLE).timeout
	var base: Array = await _avg_rots(30)
	head.rotation.y += deg_to_rad(TURN_DEG)
	await create_timer(SETTLE).timeout
	var turned: Array = await _avg_rots(30)
	# SWING-TWIST about the bone's own axis (+Y on this rig, measured): a head turn
	# should be TWIST down the spine. Bend (swing) is reported so a rotation on the
	# wrong axis cannot pass as a turn.
	var deltas: Array[float] = []
	var total := 0.0
	var bend := ""
	for k in BONES.size():
		var q: Quaternion = (base[k] as Quaternion).inverse() * (turned[k] as Quaternion)
		if q.w < 0.0:
			q = -q
		var twist: float = rad_to_deg(2.0 * atan2(q.y, q.w))
		var tq := Quaternion(Vector3.UP, deg_to_rad(twist))
		var swing: float = rad_to_deg((tq.inverse() * q).get_angle())
		bend += "%s bend %.1f  " % [BONES[k], swing]
		deltas.append(absf(twist))
		total += absf(twist)
	var ok := total > 1.0
	var line := ""
	for k in BONES.size():
		var share: float = deltas[k] / maxf(total, 0.0001)
		line += "%s %.1f deg (%.0f%%)  " % [BONES[k], deltas[k], share * 100.0]
		if share < MIN_SHARE or share > MAX_SHARE:
			ok = false
	print("[SPINE] turn %.0f deg, TWIST: %s total %.1f deg" % [TURN_DEG, line, total])
	print("[SPINE] bend (should stay small): %s" % bend)
	print("RESULT: %s" % ("PASS" if ok else "FAIL"))
	quit(0 if ok else 1)


func _find_anim(n: Node) -> Node:
	if n.name == "ProceduralAnimator":
		return n
	for c in n.get_children():
		var r := _find_anim(c)
		if r != null:
			return r
	return null