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. The hold BREATHES — there is a ## `sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the ## same world position twice, and the first version of this check compared ## absolute positions and reported a 3.5 mm error that was just the character ## inhaling. Taking each anchor relative to the one it hangs off and rotating ## into the current gun basis cancels the breathing, the ADS blend and the ## recoil kick exactly, because all three move the basis and the 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