extends Object class_name RigAnchors ## Named attachment points on a character's skeleton, adjustable per character. ## ## An anchor is a bone ROLE plus an offset: "the grip sits here, relative to the ## right hand". The role is resolved from the rig sidecar, so nothing here ever ## spells a bone name — that rule is what let four characters hold a gun at all. ## The offset is the part a human has to decide. ## ## Why an offset is needed even though the code derives a mount: ## ## The third-person weapon is seated at the hand bone's ORIGIN with no ## hand-relative rotation, and the pose layer then aims it by rotating the wrist ## until the gun's forward axis lies on the aim line. That is deliberate and it ## is right — a constant rotation there is expressed in the BONE's axes, no two ## rigs agree on those, and a fixed `(0, 90, -90)` is exactly why the hand mount ## points used to be wrong on every character. ## ## But a hand bone's origin is the WRIST, not the palm. How far down the palm a ## grip should sit, and how the gun should roll in the fingers, is a judgement ## about that character's hand — how big it is, how the fingers were modelled, ## how the artist posed the thumb. It cannot be derived, it differs per ## character, and it is small. So it is an offset, it defaults to zero, and zero ## means "exactly what the code derives" — which is what every character gets ## until someone opens the rig lab and decides otherwise. const PATH := "res://assets/characters/rig_anchors.json" ## Written to the project when running from source; falls back to user:// for an ## exported build, where res:// is read-only. const USER_PATH := "user://rig_anchors.json" ## The one subject key. Anchors are per CHARACTER, not per weapon — where a grip ## sits in a palm is a fact about the hand, and re-tuning it for every gun would ## be re-answering the same question. TuningStore is keyed by subject, so this ## names the only one there is. const SUBJECT := "anchors" ## key -> [label, minimum, maximum, is_vector, default] ## ## The lab builds its whole anchor UI from this, so adding an anchor here is all ## it takes to expose one. Ranges are what a plausible answer lives inside, not ## what the value can technically be: a grip more than 12 cm from the wrist is ## not a grip, it is a mistake, and a slider that can express it only makes the ## useful range harder to hit. ## ## Every default is ZERO, and that is load-bearing — see the note above. A knob ## whose slider sits at 0 next to a code default of something else means the ## first touch of that slider silently changes behaviour. const KNOBS := [ ["grip_offset", "Grip position in the palm (m)", -0.12, 0.12, true, Vector3.ZERO], ["grip_rotation", "Grip roll/pitch/yaw (rad)", -1.6, 1.6, true, Vector3.ZERO], ] ## Which bone role each anchor hangs off. Roles, not names — resolved through ## the sidecar the pipeline writes. const ANCHOR_BONE := { "grip_offset": "hand.R", "grip_rotation": "hand.R", } static func default_for(key: String): return TuningStore.default_for(KNOBS, key) static func load_all() -> Dictionary: return TuningStore.read(PATH, USER_PATH) ## The resolved anchor table for one character. static func resolve(all: Dictionary, skin_id: String) -> Dictionary: return TuningStore.resolve(all, skin_id, SUBJECT) static func save(all: Dictionary, skin_id: String, table: Dictionary) -> String: return TuningStore.write(all, skin_id, SUBJECT, table, PATH, USER_PATH) ## The grip anchor as a transform to seat a weapon with, in hand-bone space. ## ## Identity when nothing is tuned, which is what the code did before anchors ## existed — so a character nobody has opened the lab for is bit-for-bit ## unchanged. static func grip_transform(table: Dictionary) -> Transform3D: var pos: Vector3 = table.get("grip_offset", Vector3.ZERO) var rot: Vector3 = table.get("grip_rotation", Vector3.ZERO) if pos == Vector3.ZERO and rot == Vector3.ZERO: return Transform3D.IDENTITY return Transform3D(Basis.from_euler(rot), pos)