Files
Papay-Shooter/debug/anchor_shift_check.gd
T
2026-08-02 02:20:02 -04:00

138 lines
4.9 KiB
GDScript

extends SceneTree
## Do the hand anchors move where they are told, in the frame they are told in?
##
## `grip_shift` and `fore_shift` exist because the two hand anchors could only
## ever slide along the barrel: `gun_stock` and `gun_fore` are distances along
## the weapon's own axis, so the trigger and support hands travelled up and down
## the gun and nowhere else. What could move freely was the GUN, under anchors
## that stayed put.
##
## Two things have to hold, and only the first is obvious:
##
## 1. the anchor moves by the amount asked for;
## 2. it moves in the GUN's frame, not the skeleton's. A sideways nudge has to
## stay sideways relative to the weapon whether the muzzle is pitched down
## at low ready or level at ADS — otherwise the same number means two
## different places in the two poses, and a skeleton-space implementation
## passes check 1 happily.
##
## MEASURED IN THE GUN'S FRAME, and it has to be. Taking each anchor relative to
## the one it hangs off and rotating into the current gun basis cancels the ADS
## blend and recoil kick exactly, because both move the basis and anchor
## together.
##
## godot --headless --path . -s res://debug/anchor_shift_check.gd
## All three axes, deliberately asymmetric, so an axis swap or a sign flip
## cannot pass.
const SHIFT := Vector3(0.05, -0.03, 0.02)
const TOLERANCE := 0.0015
## Long enough for the ADS blend and the hold's take-up to settle. The gun-frame
## measurement is invariant to both, but a half-blended pose is a bad place to
## be reading anything.
const SETTLE := 40
var _fails := 0
func _init() -> void:
await process_frame
await process_frame
var weapon := _first_weapon()
var data = JSON.parse_string(FileAccess.get_file_as_string(
"res://assets/characters/skins/skins.json"))
for entry in data["skins"]:
await _check(entry["id"], entry.get("model", ""), weapon)
print("\n=== ANCHOR SHIFTS ===\nFailures: %d" % _fails)
quit(1 if _fails > 0 else 0)
func _first_weapon() -> String:
var db = root.get_node("LoadoutManager").weapon_db
var ids: Array = db.keys()
ids.sort()
for id in ids:
var s: String = db[id].get("script", "")
if s != "" and ResourceLoader.exists(s):
return s
return ""
## The two anchors in the gun's own across/up/along frame:
## grip, relative to the buttstock == (0, 0, gun_stock) + grip_shift
## fore, relative to the grip == (0, 0, fore_dist) + fore_shift
func _local(pm) -> Array:
var inv: Basis = pm.dbg_gun_basis.inverse()
var grip: Vector3 = pm.dbg_grip
var fore: Vector3 = pm.dbg_fore
var stock: Vector3 = pm.dbg_stock
return [inv * (grip - stock), inv * (fore - grip)]
func _check(id: String, path: String, weapon: String) -> void:
if path == "" or not ResourceLoader.exists(path):
return
var model := SkinnedPlayerModel.new()
model.model_path = path
model.skin_id = id
root.add_child(model)
for _i in 4:
await process_frame
model.set_weapon(weapon)
for _i in 6:
await process_frame
# Both poses, because the gun's pitch differs between them and that is the
# whole point of expressing the shift in the gun's frame.
for pose in [["low ready", 0.0], ["ADS", 1.0]]:
var pm = model._pose_mod
if pm == null:
_expect(false, "'%s' has a pose layer" % id)
break
model.set_hold_tuning({})
model.update_state("ground", 0.0, false)
model.set_locomotion(0.0, 0.0, pose[1])
for _i in SETTLE:
await process_frame
var base: Array = _local(pm)
# Each anchor on its own. `fore` hangs off `grip`, so shifting the grip
# legitimately carries the support hand with it — moving where the
# trigger hand holds a rifle moves the whole rifle, handguard included.
# Testing them together would just measure that, and the first version
# of this check did, and reported the sum as a 2x error.
for which in [["grip_shift", 0, "trigger"], ["fore_shift", 1, "support"]]:
model.set_hold_tuning({which[0]: SHIFT})
for _i in 6:
await process_frame
var now: Array = _local(pm)
var moved: Vector3 = now[which[1]] - base[which[1]]
# x and y are across the barrel — the freedom that did not exist
# before. z is along it, and for the support hand the reach solver
# owns that, so it is not ours to predict.
var across := Vector2(moved.x, moved.y)
var want := Vector2(SHIFT.x, SHIFT.y)
_expect(across.distance_to(want) <= TOLERANCE,
"'%s' %s: %s anchor moved %.0f, %.0f mm across the barrel (wanted %.0f, %.0f)"
% [id, pose[0], which[2], across.x * 1000.0, across.y * 1000.0,
want.x * 1000.0, want.y * 1000.0])
model.set_hold_tuning({})
for _i in 6:
await process_frame
var back: Array = _local(pm)
var residue: Vector3 = back[which[1]] - base[which[1]]
_expect(Vector2(residue.x, residue.y).length() <= TOLERANCE,
"'%s' %s: clearing %s restores the derived anchor"
% [id, pose[0], which[0]])
model.queue_free()
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: %s" % what)
else:
print(" FAIL: %s" % what)
_fails += 1