extends SceneTree ## Does a rig anchor actually move anything? ## ## The anchor system is easy to build so that it looks right and does nothing: ## the lab shows sliders, the file saves, the JSON round-trips, and the gun does ## not move a millimetre — because the value was read into a variable nobody ## consumed, or because the weapon was measured before the offset was applied ## and the hands are still being solved onto the old grip. ## ## So this asserts the physical consequence. Set an anchor, and the weapon must ## move by that much, in the hand bone's own axes, on every character. ## ## godot --headless --path . -s res://debug/rig_anchor_check.gd const OFFSET := Vector3(0.03, -0.02, 0.05) ## Millimetres of slack. The weapon is parented to a BoneAttachment3D that ## follows an animating skeleton, so the two samples are a frame apart on a ## moving arm; this has to be loose enough to survive that and tight enough that ## "did not move at all" fails. const TOLERANCE := 0.004 var _fails := 0 func _init() -> void: # Autoloads are not in the tree yet when a `-s` script's _init runs. await process_frame await process_frame var data = JSON.parse_string(FileAccess.get_file_as_string( "res://assets/characters/skins/skins.json")) var weapon := _first_weapon() if weapon == "": print("no weapon script in the loadout database") quit(1) return for entry in data["skins"]: await _check_skin(entry["id"], entry.get("model", ""), weapon) print("\n=== RIG ANCHORS ===\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 script: String = db[id].get("script", "") if script != "" and ResourceLoader.exists(script): return script return "" func _check_skin(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 4: await process_frame var w := _weapon_node(model) if w == null: print(" FAIL: '%s' did not mount a weapon at all" % id) _fails += 1 model.queue_free() return # Measured in the ATTACHMENT's frame, not the world's. The attachment tracks # a bone on an animating skeleton, so a world-space delta would be the sum of # the anchor and a frame of the idle animation — and the idle is bigger. var before: Vector3 = w.position model.set_anchors({"grip_offset": OFFSET}) await process_frame w = _weapon_node(model) var moved: Vector3 = w.position - before var err := (moved - OFFSET).length() if err <= TOLERANCE: print(" OK: '%s' grip anchor moved the weapon by %.1f, %.1f, %.1f mm" % [id, moved.x * 1000.0, moved.y * 1000.0, moved.z * 1000.0]) else: print(" FAIL: '%s' asked for (%.3f, %.3f, %.3f), got (%.3f, %.3f, %.3f)" % [id, OFFSET.x, OFFSET.y, OFFSET.z, moved.x, moved.y, moved.z]) _fails += 1 # ...and back to nothing tuned, which must restore the untouched mount # exactly. An anchor that cannot be cleared is a one-way door in a tool whose # whole purpose is trying things out. model.set_anchors({}) await process_frame w = _weapon_node(model) if w.position.distance_to(before) <= TOLERANCE: print(" OK: '%s' clearing the anchor restores the derived mount" % id) else: print(" FAIL: '%s' did not return to the derived mount" % id) _fails += 1 model.queue_free() ## The attachment is a child of the SKELETON, which is nested somewhere inside ## the loaded glTF scene rather than being a direct child of the model — so it ## is reached from the skeleton, not by a path from the model. func _weapon_node(model) -> Node3D: if model.skeleton == null: return null var attach: Node = model.skeleton.get_node_or_null("WeaponAttachment") if attach == null or attach.get_child_count() == 0: return null return attach.get_child(0) as Node3D