diff --git a/characters/rig_anchors.gd b/characters/rig_anchors.gd new file mode 100644 index 0000000..275562e --- /dev/null +++ b/characters/rig_anchors.gd @@ -0,0 +1,90 @@ +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) diff --git a/characters/rig_anchors.gd.uid b/characters/rig_anchors.gd.uid new file mode 100644 index 0000000..f245cc5 --- /dev/null +++ b/characters/rig_anchors.gd.uid @@ -0,0 +1 @@ +uid://ck8e6odry037 diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index 3962f0b..4935990 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -100,9 +100,12 @@ var is_holding_weapon: bool = false ## Which skin this is, so per-character hold tuning can be looked up. Set by ## whoever spawns the model; falls back to the GLB's basename. var skin_id: String = "" -## Live hold overrides. Written by debug/weapon_lab.gd while tuning and by +## Live hold overrides. Written by debug/rig_lab.gd while tuning and by ## set_weapon() from the saved table otherwise. var hold_tune: Dictionary = {} +## Live anchor overrides — where the grip sits in the palm, and how the gun +## rolls in the fingers. Same two sources as hold_tune. See RigAnchors. +var anchors: Dictionary = {} # Animation blending: locomotion plays full-body through a Transition node; # gameplay one-shots (reload/throw/shoot/hit) play through an @@ -907,20 +910,55 @@ func get_lean_debug() -> float: return _cur_fwd +## Every canonical clip this character resolved to something real. +## +## Canonical rather than raw, because the raw names differ per character — one +## rig's "CrouchIdle" is another's "Crouch_Idle_Loop" — and the canonical name is +## what the game asks for. For debug/rig_lab.gd's clip scrubber. +func clip_names_debug() -> Array: + return _resolved_clips.keys() + + +## Play one clip outright, ignoring the locomotion state machine. Lab only. +func play_clip_debug(canonical: String) -> void: + _play_clip(canonical, true) + + ## Push a new hold tuning table in and re-seat the weapon with it. ## -## For debug/weapon_lab.gd: the knobs that live on the pose layer take effect on +## For debug/rig_lab.gd: the knobs that live on the pose layer take effect on ## the next frame, but weapon SIZE and the grip offset are baked into the ## attachment when the weapon is seated, so those need the weapon re-measured. func set_hold_tuning(t: Dictionary) -> void: hold_tune = t if _pose_mod: _pose_mod.tune = t - if _weapon_attachment and _weapon_attachment.get_child_count() > 0: - var w := _weapon_attachment.get_child(0) as Node3D - if w: - w.transform = Transform3D.IDENTITY - _measure_weapon(w) + _reseat_weapon() + + +## Push a new anchor table in and re-seat the weapon on it. +## +## Separate from set_hold_tuning because the two are separate questions with +## separate scopes — a hold is per character AND weapon, an anchor is per +## character — and the lab edits them on different screens. +func set_anchors(a: Dictionary) -> void: + anchors = a + _reseat_weapon() + + +## Re-apply the grip anchor and re-measure, after either table changed. +## +## `_measure_weapon` reads the weapon's transform to work out where its grip and +## muzzle are, so the anchor has to be back in place BEFORE it runs — measuring +## from identity and then offsetting would move the gun without moving the +## points the hands are being solved onto. +func _reseat_weapon() -> void: + if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0: + return + var w := _weapon_attachment.get_child(0) as Node3D + if w: + w.transform = RigAnchors.grip_transform(anchors) + _measure_weapon(w) ## Whether the locomotion cycle is running backwards, which points the stride @@ -955,10 +993,15 @@ func set_weapon(script_path: String) -> void: return # Per-character, per-weapon hold overrides, if any have been tuned. Empty is # the normal case and means "use what the code derives". + var sid := skin_id if skin_id != "" else model_path.get_file().get_basename() if hold_tune.is_empty(): - var sid := skin_id if skin_id != "" else model_path.get_file().get_basename() hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid, script_path.get_file().get_basename()) + # Anchors are per character, not per weapon — where a grip sits in a palm is + # a fact about the hand — so unlike hold_tune they are not re-read per gun + # unless the lab has pushed a live set in. + if anchors.is_empty(): + anchors = RigAnchors.resolve(RigAnchors.load_all(), sid) var script = load(script_path) if not script: @@ -992,7 +1035,13 @@ func set_weapon(script_path: String) -> void: # rig, and the wrist then absorbs whatever that bone's roll happens to be. # The grip is placed at the bone's origin below, so the gun sits IN the # hand rather than at a fixed offset from a differently-oriented bone. - w.transform = Transform3D.IDENTITY + # + # ...with one adjustment on top: the character's own grip ANCHOR. A hand + # bone's origin is the wrist, not the palm, and how far down the palm a + # grip should sit is a fact about that character's hand — how big it is, + # how the fingers were modelled — which cannot be derived. It defaults to + # identity, so a character nobody has tuned behaves exactly as before. + w.transform = RigAnchors.grip_transform(anchors) if _pose_mod: _pose_mod.gun_fwd_hand = Vector3(0, 0, -1) _pose_mod.gun_up_hand = Vector3(0, 1, 0) @@ -1249,7 +1298,7 @@ class ShooterPoseModifier extends SkeletonModifier3D: ## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar. var fingers: Dictionary = {} ## Per-character hold overrides — see characters/weapon_hold_tuning.gd and - ## debug/weapon_lab.gd. A dictionary rather than a field per knob so a new + ## debug/rig_lab.gd. A dictionary rather than a field per knob so a new ## knob needs no plumbing: add it here, read it with _t/_tv, and the lab ## picks it up from the same table. var tune: Dictionary = {} diff --git a/characters/tuning_store.gd b/characters/tuning_store.gd new file mode 100644 index 0000000..dc376ed --- /dev/null +++ b/characters/tuning_store.gd @@ -0,0 +1,112 @@ +extends Object +class_name TuningStore + +## Per-character art direction on disk, layered so a number can be set once for +## everyone and then contradicted exactly where it matters. +## +## defaults every character, every subject +## skins.._all this character, every subject +## skins.. this character, this subject +## +## "Subject" is whatever the caller is keying on — a weapon id for how a gun is +## held, an anchor set for where it is held. The store does not care. +## +## This is the shape WeaponHoldTuning arrived at, pulled out so it is not the +## only thing that can have it. Every knob in the rifle hold started as a +## constant tuned against one rig and was wrong on the next character imported; +## the ones that can be derived from the skeleton now are, and what is left is +## genuinely an artist's judgement — how high a stock rides, where in the palm a +## grip sits. Judgement wants a slider and a file, not another guess in code. +## +## An absent or empty file means "use the built-in defaults", so the game runs +## perfectly well with nothing tuned at all. This only ever ADDS information. + +## Where a tuning file is read from and written to. +## +## The project copy is preferred on save so a tuning pass lands in version +## control beside the character it belongs to. `user://` is the fallback for an +## exported build, where res:// is read-only — and it WINS on load, so a pass +## made in a shipped build is not silently discarded. +static func read(res_path: String, user_path: String) -> Dictionary: + var base := _read_one(res_path) + var over := _read_one(user_path) + if over.is_empty(): + return base + if base.is_empty(): + return over + # Shallow is enough: the layers below are merged per key anyway. + for k in over: + base[k] = over[k] + return base + + +static func _read_one(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) + return parsed if typeof(parsed) == TYPE_DICTIONARY else {} + + +## The resolved table for one character and one subject, most general first. +## +## Vectors survive the JSON round trip as three-element arrays and are rebuilt +## here rather than at every read site — a caller that forgot would get an Array +## where it expected a Vector3, which fails somewhere else entirely. +static func resolve(all: Dictionary, skin_id: String, subject: String) -> Dictionary: + var out := {} + var skins: Dictionary = all.get("skins", {}) + var mine: Dictionary = skins.get(skin_id, {}) + for layer in [all.get("defaults", {}), mine.get("_all", {}), + mine.get(subject, {})]: + if typeof(layer) != TYPE_DICTIONARY: + continue + for k in layer: + out[k] = layer[k] + return revive(out) + + +## Three-element arrays back into Vector3s, in place. Anything else is left +## alone, so a knob that is genuinely a list of three numbers would need its own +## handling — none is, and one that was would be a Vector3 anyway. +static func revive(table: Dictionary) -> Dictionary: + for k in table.keys(): + var v = table[k] + if v is Array and v.size() == 3: + table[k] = Vector3(float(v[0]), float(v[1]), float(v[2])) + return table + + +static func flatten(table: Dictionary) -> Dictionary: + var flat := {} + for k in table: + var v = table[k] + flat[k] = [v.x, v.y, v.z] if v is Vector3 else v + return flat + + +## Store one character+subject's table and write the file. Returns where it went. +static func write(all: Dictionary, skin_id: String, subject: String, + table: Dictionary, res_path: String, user_path: String) -> String: + if not all.has("skins"): + all["skins"] = {} + if not all["skins"].has(skin_id): + all["skins"][skin_id] = {} + all["skins"][skin_id][subject] = flatten(table) + + var text := JSON.stringify(all, " ") + for path in [res_path, user_path]: + var f := FileAccess.open(path, FileAccess.WRITE) + if f: + f.store_string(text) + f.close() + return path + return "" + + +## The default for a knob, from a spec table shaped +## `[key, label, minimum, maximum, is_vector, default]`. +static func default_for(specs: Array, key: String): + for spec in specs: + if spec[0] == key: + return spec[5] + return 0.0 diff --git a/characters/tuning_store.gd.uid b/characters/tuning_store.gd.uid new file mode 100644 index 0000000..286e78c --- /dev/null +++ b/characters/tuning_store.gd.uid @@ -0,0 +1 @@ +uid://cq378n7o0qnoc diff --git a/characters/weapon_hold_tuning.gd b/characters/weapon_hold_tuning.gd index 6caf448..50a78ca 100644 --- a/characters/weapon_hold_tuning.gd +++ b/characters/weapon_hold_tuning.gd @@ -10,7 +10,7 @@ class_name WeaponHoldTuning ## rides, how far the elbow flares, how hard the fingers close. Those want an ## artist's eye and a slider, not another guess in code. ## -## This is where that judgement is stored. debug/weapon_lab.gd writes it; +## This is where that judgement is stored. debug/rig_lab.gd writes it; ## SkinnedPlayerModel reads it when a weapon is equipped. ## ## Resolution is layered, most general first, so a single number can be set once @@ -61,79 +61,24 @@ const KNOBS := [ ## The built-in value for a knob, for a lab that has nothing saved yet. static func default_for(key: String): - for spec in KNOBS: - if spec[0] == key: - return spec[5] - return 0.0 - - -static func _read(path: String) -> Dictionary: - if not FileAccess.file_exists(path): - return {} - var parsed = JSON.parse_string(FileAccess.get_file_as_string(path)) - return parsed if typeof(parsed) == TYPE_DICTIONARY else {} + return TuningStore.default_for(KNOBS, key) +## The layering, the JSON round trip and the res://-then-user:// write all live +## in TuningStore now, because they are not specific to weapons — rig anchors +## want exactly the same behaviour, and having two copies of it would mean two +## places for "an exported build's tuning pass is silently discarded" to come +## back. The on-disk format is unchanged. static func load_all() -> Dictionary: - # user:// wins, so a tuning pass made in an exported build is not lost, and - # so the lab can be used without a writable project directory. - var base := _read(PATH) - var over := _read(USER_PATH) - if over.is_empty(): - return base - if base.is_empty(): - return over - # Shallow merge is enough: the layers below are merged per key anyway. - for k in over: - base[k] = over[k] - return base + return TuningStore.read(PATH, USER_PATH) ## The resolved knob table for one character holding one weapon. -## -## Vectors survive the JSON round trip as three-element arrays, so they are -## rebuilt here rather than at every read site. static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary: - var out := {} - var skins: Dictionary = all.get("skins", {}) - var mine: Dictionary = skins.get(skin_id, {}) - for layer in [all.get("defaults", {}), mine.get("_all", {}), - mine.get(weapon_id, {})]: - if typeof(layer) != TYPE_DICTIONARY: - continue - for k in layer: - out[k] = layer[k] - for k in out.keys(): - var v = out[k] - if v is Array and v.size() == 3: - out[k] = Vector3(float(v[0]), float(v[1]), float(v[2])) - return out + return TuningStore.resolve(all, skin_id, weapon_id) ## Store one character+weapon's knobs and write the file. Returns where it went. static func save(all: Dictionary, skin_id: String, weapon_id: String, knobs: Dictionary) -> String: - if not all.has("skins"): - all["skins"] = {} - if not all["skins"].has(skin_id): - all["skins"][skin_id] = {} - var flat := {} - for k in knobs: - var v = knobs[k] - flat[k] = [v.x, v.y, v.z] if v is Vector3 else v - all["skins"][skin_id][weapon_id] = flat - - var text := JSON.stringify(all, " ") - # Prefer the project copy so a tuning pass lands in version control with the - # character it belongs to; fall back to user:// when res:// is not writable. - var f := FileAccess.open(PATH, FileAccess.WRITE) - if f: - f.store_string(text) - f.close() - return PATH - f = FileAccess.open(USER_PATH, FileAccess.WRITE) - if f: - f.store_string(text) - f.close() - return USER_PATH - return "" + return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH) diff --git a/debug/character_picker_check.gd.uid b/debug/character_picker_check.gd.uid new file mode 100644 index 0000000..e81e5af --- /dev/null +++ b/debug/character_picker_check.gd.uid @@ -0,0 +1 @@ +uid://br28g1yiljtpp diff --git a/debug/rig_anchor_check.gd b/debug/rig_anchor_check.gd new file mode 100644 index 0000000..4f4551a --- /dev/null +++ b/debug/rig_anchor_check.gd @@ -0,0 +1,115 @@ +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 diff --git a/debug/rig_lab.gd b/debug/rig_lab.gd new file mode 100644 index 0000000..98b43b0 --- /dev/null +++ b/debug/rig_lab.gd @@ -0,0 +1,613 @@ +extends Node3D + +## Rig lab — the room where a character's rig is adjusted by eye, in 3D, live. +## +## godot --path . res://debug/rig_lab.tscn +## +## Everything about a rig that CAN be derived from the skeleton is derived: the +## bone roles, the mount rotation, the wrist roll, the weapon size, which +## surfaces are hair. What is left over is not a missing derivation, it is +## judgement — how high a stock rides, how far an elbow flares, how far down the +## palm a grip sits on THIS character's hand. Judgement wants an eye and a +## slider, not another constant tuned against one rig and wrong on the next. +## +## This is the environment for that. Three things can be adjusted here: +## +## HOLD how this character holds this weapon. Per character AND weapon, +## because a rifle and a pistol are not held alike. +## -> assets/characters/weapon_holds.json +## ANCHORS where the grip sits in the palm and how the gun rolls in the +## fingers. Per character only — that is a fact about the hand. +## -> assets/characters/rig_anchors.json +## SURFACES what the importer decided each surface of the model IS. Read-only +## here, but isolating a class is how you check the decision: click +## `hair` and only the hair should remain. +## +## Both tuning files are layered (defaults -> this character -> this weapon) and +## an absent file means "use what the code derives", so nothing here is required +## for the game to run correctly. +## +## CONTROLS +## left drag orbit wheel zoom +## middle drag pan F frame the hands +## R reset knobs S save C copy JSON to clipboard +## +## The three coloured markers are the points being solved for — red is the +## trigger grip, green the support hand on the handguard, blue the buttstock. If +## a hand is not on its marker the IK could not reach, which is a different +## problem from the marker being in the wrong place. + +const POSES := [ + ["Low ready", "ground", 0.0, 0.0], + ["Aiming", "ground", 0.0, 1.0], + ["Running", "ground", 9.0, 0.0], + ["Crouched", "ground", 0.0, 0.0], +] + +## The two editable knob groups, and everything the UI needs to build, resolve +## and save each one. Adding a third group is adding a row here. +## +## `subject` is what the group is keyed on inside its file: a weapon id for the +## hold, a fixed word for anchors, because an anchor is per character and +## re-tuning it per gun would be re-answering the same question. +const GROUPS := { + "hold": { + "title": "HOLD · this character, this weapon", + "per_weapon": true, + }, + "anchors": { + "title": "ANCHORS · this character", + "per_weapon": false, + }, +} + +var _model: SkinnedPlayerModel +var _skins: Array = [] +var _weapons: Array = [] +var _skin := 0 +var _weapon := 0 +var _pose := 0 +## group -> resolved knob table, and group -> the whole file it came from. +var _knobs: Dictionary = {"hold": {}, "anchors": {}} +var _all: Dictionary = {"hold": {}, "anchors": {}} +## group -> key -> {spec, label, x, y, z} +var _sliders: Dictionary = {"hold": {}, "anchors": {}} +## "" means the pose buttons drive the model; anything else is a canonical clip +## being played on its own so a single animation can be watched end to end. +var _clip := "" +var _clip_picker: OptionButton +var _surface_box: VBoxContainer +var _isolated := "" +## MeshInstance3D -> surface index -> the override material displaced by +## isolation, so it can be put back without rebuilding the character. +var _hidden_surfaces: Dictionary = {} +var _status: Label +var _cam: Camera3D +var _yaw := 0.6 +var _pitch := -0.1 +var _dist := 2.2 +var _pivot := Vector3(0, 1.25, 0) +var _markers: Array = [] +var _pickers: Dictionary = {} + + +## Frames to wait before the self-shot below. The model loads asynchronously and +## the cloth solver needs a moment to settle, so an immediate capture shows a +## half-built character. +const SHOT_WARMUP := 40 +var _shot_path := "" +var _frames := 0 + + +func _ready() -> void: + _build_world() + _collect_sources() + # `-- shot [skin] [weapon]` renders one frame and quits, so the lab + # can be checked without a human at the controls. + var args := OS.get_cmdline_user_args() + if args.size() >= 2 and String(args[0]) == "shot": + _shot_path = String(args[1]) + if args.size() > 2: + _skin = maxi(0, _index_of(_skins, String(args[2]))) + if args.size() > 3: + _weapon = maxi(0, _index_of(_weapons, String(args[3]))) + _build_ui() + _reload_model() + + +func _index_of(list: Array, id: String) -> int: + for i in list.size(): + if String(list[i].id) == id: + return i + return -1 + + +# ── scene ───────────────────────────────────────────────────────────────────── + +func _build_world() -> void: + var env := WorldEnvironment.new() + var e := Environment.new() + e.background_mode = Environment.BG_COLOR + e.background_color = Color(0.17, 0.18, 0.22) + e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + e.ambient_light_color = Color(0.55, 0.57, 0.65) + e.ambient_light_energy = 1.0 + env.environment = e + add_child(env) + + var key := DirectionalLight3D.new() + key.rotation_degrees = Vector3(-42, 132, 0) + key.light_energy = 1.5 + add_child(key) + var fill := DirectionalLight3D.new() + fill.rotation_degrees = Vector3(-18, -40, 0) + fill.light_energy = 0.5 + add_child(fill) + + _cam = Camera3D.new() + _cam.fov = 45.0 + add_child(_cam) + _update_camera() + + # Grip / support / stock, so the points under the sliders are visible. + for c in [Color(1, 0.3, 0.3), Color(0.3, 1, 0.4), Color(0.4, 0.6, 1)]: + var m := MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.012 + sphere.height = 0.024 + m.mesh = sphere + var mat := StandardMaterial3D.new() + mat.albedo_color = c + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # Depth-tested on purpose. Drawn through the body they look like they are + # floating in front of the chest when they are in fact behind an arm, + # which is exactly the wrong impression for judging whether a hand is on + # its target. + mat.no_depth_test = false + m.material_override = mat + add_child(m) + _markers.append(m) + + +func _collect_sources() -> void: + for id in SkinManager.skins: + var s = SkinManager.skins[id] + # GLB-backed skins only; the colour tints have no skeleton to pose. + if "model_path" in s and String(s.model_path) != "": + _skins.append({"id": id, "name": s.skin_name, "path": s.model_path}) + _skins.sort_custom(func(a, b): return a.id < b.id) + for id in LoadoutManager.weapon_db: + var w: Dictionary = LoadoutManager.weapon_db[id] + if String(w.get("script", "")) != "": + _weapons.append({"id": id, "name": w.get("name", id), + "script": w["script"]}) + _weapons.sort_custom(func(a, b): return a.id < b.id) + _all["hold"] = WeaponHoldTuning.load_all() + _all["anchors"] = RigAnchors.load_all() + + +func _reload_model() -> void: + if _model: + _model.queue_free() + _model = SkinnedPlayerModel.new() + _model.model_path = _skins[_skin].path + _model.skin_id = _skins[_skin].id + add_child(_model) + await get_tree().process_frame + _load_knobs() + _model.set_weapon(_weapons[_weapon].script) + _push("hold") + _push("anchors") + _clip = "" + _refresh_clip_picker() + _rebuild_surface_list() + _apply_pose() + + +## Fill the clip list from what this character actually HAS. +## +## Canonical names, from the model's own resolution table, rather than the raw +## clip names in the GLB — those differ per character (one rig's "CrouchIdle" is +## another's "Crouch_Idle_Loop") and the canonical name is the one the game asks +## for, so it is the one worth being able to audition. +func _refresh_clip_picker() -> void: + if _clip_picker == null: + return + _clip_picker.clear() + _clip_picker.add_item("(pose driven)") + if _model: + var names: Array = _model.clip_names_debug() + names.sort() + for n in names: + _clip_picker.add_item(n) + _clip_picker.select(0) + + +func _select_clip(index: int) -> void: + _clip = "" if index <= 0 else _clip_picker.get_item_text(index) + if _clip != "" and _model: + _model.play_clip_debug(_clip) + _status.text = "Pose buttons driving" if _clip == "" \ + else "Playing '%s' on its own" % _clip + + +func _apply_pose() -> void: + if not _model or not _model.loaded: + return + # A clip chosen in the scrubber owns playback: driving update_state as well + # would blend straight back to whatever the pose implies, and the clip would + # never be seen. + if _clip != "": + return + var p: Array = POSES[_pose] + _model.update_state(p[1], p[2], _pose == 3) + _model.set_locomotion(0.0, 1.0 if p[2] > 0.1 else 0.0, p[3]) + + +func _process(_delta: float) -> void: + _apply_pose() + if _shot_path != "": + _frames += 1 + if _frames == SHOT_WARMUP: + # Frame the hands, which is the only part anyone is judging. + if _model and _model._pose_mod: + _pivot = _model.skeleton.global_transform * _model._pose_mod.dbg_grip + _dist = 0.7 + _yaw = 1.15 + _pitch = -0.15 + _update_camera() + elif _frames > SHOT_WARMUP + 2: + var img := get_viewport().get_texture().get_image() + img.save_png(_shot_path) + print("rig_lab: saved ", _shot_path) + get_tree().quit() + return + if _model and _model._pose_mod and _model.skeleton: + var to_world: Transform3D = _model.skeleton.global_transform + var pm = _model._pose_mod + var pts := [pm.dbg_grip, pm.dbg_fore, pm.dbg_stock] + for i in _markers.size(): + _markers[i].global_position = to_world * pts[i] + _markers[i].visible = pts[i] != Vector3.ZERO + + +# ── knobs ───────────────────────────────────────────────────────────────────── +# +# The two groups differ only in which spec table describes them, which file they +# live in and what they are keyed on. Everything below is written once against +# those three facts rather than twice against the two groups, so the anchors got +# a full editor — sliders, live preview, reset, save, clipboard — for the cost of +# a spec table. + +func _specs(group: String) -> Array: + return RigAnchors.KNOBS if group == "anchors" else WeaponHoldTuning.KNOBS + + +func _default_for(group: String, key: String): + return RigAnchors.default_for(key) if group == "anchors" \ + else WeaponHoldTuning.default_for(key) + + +func _load_knobs() -> void: + for group in GROUPS: + if group == "anchors": + _knobs[group] = RigAnchors.resolve(_all[group], _skins[_skin].id) + else: + _knobs[group] = WeaponHoldTuning.resolve(_all[group], + _skins[_skin].id, _weapons[_weapon].id) + for k in _sliders[group]: + var entry = _sliders[group][k] + var spec: Array = entry.spec + if spec[4]: + var v: Vector3 = _knobs[group].get(k, _default_for(group, k)) + entry.x.set_value_no_signal(v.x) + entry.y.set_value_no_signal(v.y) + entry.z.set_value_no_signal(v.z) + else: + entry.x.set_value_no_signal(float(_knobs[group].get(k, + _default_for(group, k)))) + _refresh_label(group, k) + + +func _knob_changed(group: String, key: String) -> void: + var entry = _sliders[group][key] + var spec: Array = entry.spec + if spec[4]: + _knobs[group][key] = Vector3(entry.x.value, entry.y.value, entry.z.value) + else: + _knobs[group][key] = entry.x.value + _refresh_label(group, key) + _push(group) + + +func _push(group: String) -> void: + if _model == null: + return + if group == "anchors": + _model.set_anchors(_knobs[group]) + else: + _model.set_hold_tuning(_knobs[group]) + + +func _refresh_label(group: String, key: String) -> void: + var entry = _sliders[group][key] + var spec: Array = entry.spec + if spec[4]: + entry.label.text = "%s %.3f, %.3f, %.3f" % [spec[1], entry.x.value, + entry.y.value, entry.z.value] + else: + var v: float = entry.x.value + entry.label.text = "%s %s" % [spec[1], + "auto" if (key == "weapon_scale" and v < 0.01) else "%.3f" % v] + + +# ── ui ──────────────────────────────────────────────────────────────────────── + +func _build_ui() -> void: + var layer := CanvasLayer.new() + add_child(layer) + + # The lab is a dev tool, but it is still a screen in this game, and the + # theme is meant to be the ONE theme. It also has to be applied to this + # Control rather than only to the Window: a CanvasLayer is not a Control, so + # theme inheritance stops at it. + UITheme.apply_global(get_tree()) + var panel := PanelContainer.new() + panel.theme = UITheme.build() + panel.set_anchors_preset(Control.PRESET_LEFT_WIDE) + panel.custom_minimum_size = Vector2(440, 0) + layer.add_child(panel) + + var scroll := ScrollContainer.new() + panel.add_child(scroll) + var box := VBoxContainer.new() + box.custom_minimum_size = Vector2(410, 0) + box.add_theme_constant_override("separation", 2) + scroll.add_child(box) + + box.add_child(UITheme.title("RIG LAB", 34, 0.0)) + box.add_child(UITheme.divider(0.3)) + + box.add_child(_picker("Character", _skins, func(i): + _skin = i + _reload_model())) + box.add_child(_picker("Weapon", _weapons, func(i): + _weapon = i + _load_knobs() + if _model: + _model.set_weapon(_weapons[_weapon].script) + _push("hold") + _push("anchors"))) + var poses: Array = [] + for p in POSES: + poses.append({"name": p[0]}) + box.add_child(_picker("Pose", poses, func(i): _pose = i)) + # Clip scrubber. The four poses above are the states the game drives; this + # plays one clip on its own, which is the only way to watch a whole + # animation end to end and see where a retarget went wrong. + var clip_row := _picker("Clip", [{"name": "(pose driven)"}], + func(i): _select_clip(i)) + _clip_picker = clip_row.get_child(1) + box.add_child(clip_row) + + for group in GROUPS: + box.add_child(UITheme.divider(0.5)) + box.add_child(UITheme.heading(GROUPS[group]["title"], 18)) + for spec in _specs(group): + box.add_child(_knob_row(group, spec)) + box.add_child(_group_buttons(group)) + + box.add_child(UITheme.divider(0.7)) + box.add_child(UITheme.heading("SURFACES · click to isolate", 18)) + _surface_box = VBoxContainer.new() + _surface_box.add_theme_constant_override("separation", 1) + box.add_child(_surface_box) + + _status = Label.new() + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _status.custom_minimum_size = Vector2(400, 40) + _status.text = "left drag orbit · wheel zoom · middle drag pan · F frame hands" + box.add_child(_status) + # Reflect whatever the shot arguments or the defaults selected, or the + # dropdown says one thing while the scene shows another. + _pickers["Character"].select(_skin) + _pickers["Weapon"].select(_weapon) + _pickers["Pose"].select(_pose) + + +func _picker(label: String, items: Array, on_pick: Callable) -> Control: + var row := HBoxContainer.new() + var l := Label.new() + l.text = label + l.custom_minimum_size = Vector2(90, 0) + row.add_child(l) + var opt := OptionButton.new() + opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL + for it in items: + opt.add_item(String(it.name)) + opt.item_selected.connect(on_pick) + row.add_child(opt) + _pickers[label] = opt + return row + + +func _knob_row(group: String, spec: Array) -> Control: + var key: String = spec[0] + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 0) + var label := Label.new() + label.add_theme_font_size_override("font_size", 16) + box.add_child(label) + var entry := {"spec": spec, "label": label} + var axes := ["x", "y", "z"] if spec[4] else ["x"] + for a in axes: + var sl := HSlider.new() + sl.min_value = spec[2] + sl.max_value = spec[3] + sl.step = 0.001 + sl.custom_minimum_size = Vector2(400, 14) + sl.value_changed.connect(func(_v): _knob_changed(group, key)) + box.add_child(sl) + entry[a] = sl + _sliders[group][key] = entry + return box + + +func _group_buttons(group: String) -> Control: + var row := HBoxContainer.new() + for b in [["Save", func(): _save(group)], ["Reset", func(): _reset(group)], + ["Copy", func(): _copy(group)]]: + var btn := Button.new() + btn.text = b[0] + btn.pressed.connect(b[1]) + row.add_child(btn) + return row + + +func _save(group: String) -> void: + var where := "" + if group == "anchors": + where = RigAnchors.save(_all[group], _skins[_skin].id, _knobs[group]) + _status.text = "Saved %s anchors to %s" % [_skins[_skin].id, where] + else: + where = WeaponHoldTuning.save(_all[group], _skins[_skin].id, + _weapons[_weapon].id, _knobs[group]) + _status.text = "Saved %s + %s hold to %s" % [_skins[_skin].id, + _weapons[_weapon].id, where] + + +func _reset(group: String) -> void: + _knobs[group] = {} + for k in _sliders[group]: + var e = _sliders[group][k] + for a in ["x", "y", "z"]: + if e.has(a): + # Back to the DEFAULT, not to zero. Those are the same thing for + # every anchor and for most of the hold, but not for all of it — + # a slider parked at 0 next to a code default of 1.0 means reset + # quietly switched that behaviour off rather than restoring it. + var d = _default_for(group, k) + var v: float = (d[a] if d is Vector3 else float(d)) \ + if e.spec[4] else float(d) + e[a].set_value_no_signal(v) + _refresh_label(group, k) + _push(group) + _status.text = "Reset %s to what the code derives" % group + + +func _copy(group: String) -> void: + DisplayServer.clipboard_set(JSON.stringify( + TuningStore.flatten(_knobs[group]), " ")) + _status.text = "Copied %s to the clipboard" % group + + +# ── surfaces ────────────────────────────────────────────────────────────────── +# +# What the importer decided each surface of this model IS. Read-only, because +# the decision belongs in the sidecar where the whole game reads it — but +# ISOLATING a class is how the decision gets checked. Click `hair` and only the +# hair should be left standing. Anything else still visible was misclassified. + +func _rebuild_surface_list() -> void: + for c in _surface_box.get_children(): + c.queue_free() + _isolated = "" + _hidden_surfaces.clear() + if _model == null: + return + var counts := {} + for cls in [SkinSurfaces.BODY, SkinSurfaces.CLOTH, SkinSurfaces.HAIR, + SkinSurfaces.ACCESSORY, SkinSurfaces.LINEWORK]: + var n: int = _model.surfaces_of(cls).size() + if n > 0: + counts[cls] = n + for cls in counts: + var btn := Button.new() + btn.text = "%s ×%d" % [cls, counts[cls]] + btn.toggle_mode = true + btn.pressed.connect(func(): _isolate(cls if _isolated != cls else "")) + _surface_box.add_child(btn) + if counts.is_empty(): + _surface_box.add_child(UITheme.heading("no surface table", 16)) + + +## Show only one surface class, or "" for all of them. +## +## Hidden by swapping in a fully transparent material rather than by hiding the +## MeshInstance, because a mesh is not one class: Miku's body, face and hair are +## three surfaces of a single mesh, and hiding the node would take all three. +## The displaced materials are kept so this is reversible without rebuilding the +## character — which would also throw away whatever is being tuned. +func _isolate(cls: String) -> void: + for mi in _hidden_surfaces: + if is_instance_valid(mi): + for s in _hidden_surfaces[mi]: + mi.set_surface_override_material(s, _hidden_surfaces[mi][s]) + _hidden_surfaces.clear() + _isolated = cls + for b in _surface_box.get_children(): + if b is Button: + b.set_pressed_no_signal(b.text.begins_with(cls) and cls != "") + if cls == "" or _model == null: + _status.text = "Showing every surface" + return + + var keep := {} + for pair in _model.surfaces_of(cls): + keep["%s|%d" % [pair[0].get_instance_id(), pair[1]]] = true + var blank := StandardMaterial3D.new() + blank.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + blank.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + blank.albedo_color = Color(0, 0, 0, 0) + for mi in _model.find_children("*", "MeshInstance3D", true, false): + if mi.mesh == null: + continue + for s in mi.mesh.get_surface_count(): + if keep.has("%s|%d" % [mi.get_instance_id(), s]): + continue + if not _hidden_surfaces.has(mi): + _hidden_surfaces[mi] = {} + _hidden_surfaces[mi][s] = mi.get_surface_override_material(s) + mi.set_surface_override_material(s, blank) + _status.text = "Isolated '%s' — anything else still visible is misclassified" % cls + + +# ── camera ──────────────────────────────────────────────────────────────────── + +func _update_camera() -> void: + var b := Basis.from_euler(Vector3(_pitch, _yaw, 0)) + _cam.global_transform = Transform3D(b, _pivot + b * Vector3(0, 0, _dist)) + + +func _unhandled_input(e: InputEvent) -> void: + if e is InputEventMouseMotion: + if e.button_mask & MOUSE_BUTTON_MASK_LEFT: + _yaw -= e.relative.x * 0.006 + _pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4) + _update_camera() + elif e.button_mask & MOUSE_BUTTON_MASK_MIDDLE: + var b := _cam.global_transform.basis + _pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015 + _update_camera() + elif e is InputEventMouseButton and e.pressed: + if e.button_index == MOUSE_BUTTON_WHEEL_UP: + _dist = maxf(_dist * 0.9, 0.15) + _update_camera() + elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _dist = minf(_dist * 1.1, 8.0) + _update_camera() + elif e is InputEventKey and e.pressed and not e.echo: + match e.keycode: + KEY_F: + # Frame the hands, which is what is actually being judged. + if _model and _model._pose_mod: + _pivot = _model.skeleton.global_transform \ + * _model._pose_mod.dbg_grip + _dist = 0.55 + _update_camera() + # The shortcuts act on the HOLD group, which is what they meant + # when it was the only group. Anchors have their own buttons. + KEY_S: _save("hold") + KEY_R: _reset("hold") + KEY_C: _copy("hold") + KEY_ESCAPE: get_tree().quit() diff --git a/debug/weapon_lab.gd.uid b/debug/rig_lab.gd.uid similarity index 100% rename from debug/weapon_lab.gd.uid rename to debug/rig_lab.gd.uid diff --git a/debug/rig_lab.tscn b/debug/rig_lab.tscn new file mode 100644 index 0000000..9f929e2 --- /dev/null +++ b/debug/rig_lab.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://c1aec1m0pwuk3"] + +[ext_resource type="Script" path="res://debug/rig_lab.gd" id="1_lab"] + +[node name="RigLab" type="Node3D"] +script = ExtResource("1_lab") diff --git a/debug/roster_capture.gd.uid b/debug/roster_capture.gd.uid new file mode 100644 index 0000000..8386f5a --- /dev/null +++ b/debug/roster_capture.gd.uid @@ -0,0 +1 @@ +uid://cnx7lbjpa6gpu diff --git a/debug/ui_capture.gd.uid b/debug/ui_capture.gd.uid new file mode 100644 index 0000000..93a83b7 --- /dev/null +++ b/debug/ui_capture.gd.uid @@ -0,0 +1 @@ +uid://c2ucqqxfeaer5 diff --git a/debug/weapon_lab.gd b/debug/weapon_lab.gd deleted file mode 100644 index bbe93fc..0000000 --- a/debug/weapon_lab.gd +++ /dev/null @@ -1,403 +0,0 @@ -extends Node3D - -## Weapon hold lab — tune how each character holds each gun, in 3D, live. -## -## godot --path . res://debug/weapon_lab.tscn -## -## Every knob in the rifle hold started life as a constant tuned against one rig, -## and every one of them was wrong on the next character imported. The ones that -## CAN be derived from the skeleton now are — the mount rotation, the wrist roll, -## the weapon size. What is left is genuinely art direction: how high the stock -## rides, how far the elbow flares, how hard the fingers close. Those want an eye -## and a slider, not another guess in code. -## -## Pick a character, pick a weapon, pick a pose, drag the sliders, press Save. -## The result lands in assets/characters/weapon_holds.json keyed by character and -## weapon, and the game reads it whenever that gun is equipped. -## -## CONTROLS -## left drag orbit wheel zoom -## middle drag pan F frame the hands -## R reset knobs S save C copy JSON to clipboard -## -## The three coloured markers are the points being solved for — red is the -## trigger grip, green the support hand on the handguard, blue the buttstock. If -## a hand is not on its marker the IK could not reach, which is a different -## problem from the marker being in the wrong place. - -const POSES := [ - ["Low ready", "ground", 0.0, 0.0], - ["Aiming", "ground", 0.0, 1.0], - ["Running", "ground", 9.0, 0.0], - ["Crouched", "ground", 0.0, 0.0], -] - -var _model: SkinnedPlayerModel -var _skins: Array = [] -var _weapons: Array = [] -var _skin := 0 -var _weapon := 0 -var _pose := 0 -var _knobs: Dictionary = {} -var _all: Dictionary = {} -var _sliders: Dictionary = {} -var _status: Label -var _cam: Camera3D -var _yaw := 0.6 -var _pitch := -0.1 -var _dist := 2.2 -var _pivot := Vector3(0, 1.25, 0) -var _markers: Array = [] -var _pickers: Dictionary = {} - - -## Frames to wait before the self-shot below. The model loads asynchronously and -## the cloth solver needs a moment to settle, so an immediate capture shows a -## half-built character. -const SHOT_WARMUP := 40 -var _shot_path := "" -var _frames := 0 - - -func _ready() -> void: - _build_world() - _collect_sources() - # `-- shot [skin] [weapon]` renders one frame and quits, so the lab - # can be checked without a human at the controls. - var args := OS.get_cmdline_user_args() - if args.size() >= 2 and String(args[0]) == "shot": - _shot_path = String(args[1]) - if args.size() > 2: - _skin = maxi(0, _index_of(_skins, String(args[2]))) - if args.size() > 3: - _weapon = maxi(0, _index_of(_weapons, String(args[3]))) - _build_ui() - _reload_model() - - -func _index_of(list: Array, id: String) -> int: - for i in list.size(): - if String(list[i].id) == id: - return i - return -1 - - -# ── scene ───────────────────────────────────────────────────────────────────── - -func _build_world() -> void: - var env := WorldEnvironment.new() - var e := Environment.new() - e.background_mode = Environment.BG_COLOR - e.background_color = Color(0.17, 0.18, 0.22) - e.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR - e.ambient_light_color = Color(0.55, 0.57, 0.65) - e.ambient_light_energy = 1.0 - env.environment = e - add_child(env) - - var key := DirectionalLight3D.new() - key.rotation_degrees = Vector3(-42, 132, 0) - key.light_energy = 1.5 - add_child(key) - var fill := DirectionalLight3D.new() - fill.rotation_degrees = Vector3(-18, -40, 0) - fill.light_energy = 0.5 - add_child(fill) - - _cam = Camera3D.new() - _cam.fov = 45.0 - add_child(_cam) - _update_camera() - - # Grip / support / stock, so the points under the sliders are visible. - for c in [Color(1, 0.3, 0.3), Color(0.3, 1, 0.4), Color(0.4, 0.6, 1)]: - var m := MeshInstance3D.new() - var sphere := SphereMesh.new() - sphere.radius = 0.012 - sphere.height = 0.024 - m.mesh = sphere - var mat := StandardMaterial3D.new() - mat.albedo_color = c - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - # Depth-tested on purpose. Drawn through the body they look like they are - # floating in front of the chest when they are in fact behind an arm, - # which is exactly the wrong impression for judging whether a hand is on - # its target. - mat.no_depth_test = false - m.material_override = mat - add_child(m) - _markers.append(m) - - -func _collect_sources() -> void: - for id in SkinManager.skins: - var s = SkinManager.skins[id] - # GLB-backed skins only; the colour tints have no skeleton to pose. - if "model_path" in s and String(s.model_path) != "": - _skins.append({"id": id, "name": s.skin_name, "path": s.model_path}) - _skins.sort_custom(func(a, b): return a.id < b.id) - for id in LoadoutManager.weapon_db: - var w: Dictionary = LoadoutManager.weapon_db[id] - if String(w.get("script", "")) != "": - _weapons.append({"id": id, "name": w.get("name", id), - "script": w["script"]}) - _weapons.sort_custom(func(a, b): return a.id < b.id) - _all = WeaponHoldTuning.load_all() - - -func _reload_model() -> void: - if _model: - _model.queue_free() - _model = SkinnedPlayerModel.new() - _model.model_path = _skins[_skin].path - _model.skin_id = _skins[_skin].id - add_child(_model) - await get_tree().process_frame - _load_knobs() - _model.set_weapon(_weapons[_weapon].script) - _model.set_hold_tuning(_knobs) - _apply_pose() - - -func _apply_pose() -> void: - if not _model or not _model.loaded: - return - var p: Array = POSES[_pose] - _model.update_state(p[1], p[2], _pose == 3) - _model.set_locomotion(0.0, 1.0 if p[2] > 0.1 else 0.0, p[3]) - - -func _process(_delta: float) -> void: - _apply_pose() - if _shot_path != "": - _frames += 1 - if _frames == SHOT_WARMUP: - # Frame the hands, which is the only part anyone is judging. - if _model and _model._pose_mod: - _pivot = _model.skeleton.global_transform * _model._pose_mod.dbg_grip - _dist = 0.7 - _yaw = 1.15 - _pitch = -0.15 - _update_camera() - elif _frames > SHOT_WARMUP + 2: - var img := get_viewport().get_texture().get_image() - img.save_png(_shot_path) - print("weapon_lab: saved ", _shot_path) - get_tree().quit() - return - if _model and _model._pose_mod and _model.skeleton: - var to_world: Transform3D = _model.skeleton.global_transform - var pm = _model._pose_mod - var pts := [pm.dbg_grip, pm.dbg_fore, pm.dbg_stock] - for i in _markers.size(): - _markers[i].global_position = to_world * pts[i] - _markers[i].visible = pts[i] != Vector3.ZERO - - -# ── knobs ───────────────────────────────────────────────────────────────────── - -func _load_knobs() -> void: - _knobs = WeaponHoldTuning.resolve(_all, _skins[_skin].id, _weapons[_weapon].id) - for k in _sliders: - var entry = _sliders[k] - var spec: Array = entry.spec - if spec[4]: - var v: Vector3 = _knobs.get(k, WeaponHoldTuning.default_for(k)) - entry.x.set_value_no_signal(v.x) - entry.y.set_value_no_signal(v.y) - entry.z.set_value_no_signal(v.z) - else: - entry.x.set_value_no_signal(float(_knobs.get(k, - WeaponHoldTuning.default_for(k)))) - _refresh_label(k) - - -func _knob_changed(key: String) -> void: - var entry = _sliders[key] - var spec: Array = entry.spec - if spec[4]: - _knobs[key] = Vector3(entry.x.value, entry.y.value, entry.z.value) - else: - _knobs[key] = entry.x.value - _refresh_label(key) - if _model: - _model.set_hold_tuning(_knobs) - - -func _refresh_label(key: String) -> void: - var entry = _sliders[key] - var spec: Array = entry.spec - if spec[4]: - entry.label.text = "%s %.3f, %.3f, %.3f" % [spec[1], entry.x.value, - entry.y.value, entry.z.value] - else: - var v: float = entry.x.value - entry.label.text = "%s %s" % [spec[1], - "auto" if (key == "weapon_scale" and v < 0.01) else "%.3f" % v] - - -# ── ui ──────────────────────────────────────────────────────────────────────── - -func _build_ui() -> void: - var layer := CanvasLayer.new() - add_child(layer) - - var panel := PanelContainer.new() - panel.set_anchors_preset(Control.PRESET_LEFT_WIDE) - panel.custom_minimum_size = Vector2(430, 0) - layer.add_child(panel) - - var scroll := ScrollContainer.new() - panel.add_child(scroll) - var box := VBoxContainer.new() - box.custom_minimum_size = Vector2(410, 0) - box.add_theme_constant_override("separation", 2) - scroll.add_child(box) - - var title := Label.new() - title.text = "WEAPON HOLD LAB" - box.add_child(title) - - box.add_child(_picker("Character", _skins, func(i): - _skin = i - _reload_model())) - box.add_child(_picker("Weapon", _weapons, func(i): - _weapon = i - _load_knobs() - if _model: - _model.set_weapon(_weapons[_weapon].script) - _model.set_hold_tuning(_knobs))) - var poses: Array = [] - for p in POSES: - poses.append({"name": p[0]}) - box.add_child(_picker("Pose", poses, func(i): _pose = i)) - - box.add_child(HSeparator.new()) - for spec in WeaponHoldTuning.KNOBS: - box.add_child(_knob_row(spec)) - - box.add_child(HSeparator.new()) - var row := HBoxContainer.new() - for b in [["Save (S)", func(): _save()], ["Reset (R)", func(): _reset()], - ["Copy JSON (C)", func(): _copy()]]: - var btn := Button.new() - btn.text = b[0] - btn.pressed.connect(b[1]) - row.add_child(btn) - box.add_child(row) - - _status = Label.new() - _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _status.custom_minimum_size = Vector2(400, 40) - _status.text = "left drag orbit · wheel zoom · middle drag pan · F frame hands" - box.add_child(_status) - # Reflect whatever the shot arguments or the defaults selected, or the - # dropdown says one thing while the scene shows another. - _pickers["Character"].select(_skin) - _pickers["Weapon"].select(_weapon) - _pickers["Pose"].select(_pose) - - -func _picker(label: String, items: Array, on_pick: Callable) -> Control: - var row := HBoxContainer.new() - var l := Label.new() - l.text = label - l.custom_minimum_size = Vector2(90, 0) - row.add_child(l) - var opt := OptionButton.new() - opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - for it in items: - opt.add_item(String(it.name)) - opt.item_selected.connect(on_pick) - row.add_child(opt) - _pickers[label] = opt - return row - - -func _knob_row(spec: Array) -> Control: - var key: String = spec[0] - var box := VBoxContainer.new() - box.add_theme_constant_override("separation", 0) - var label := Label.new() - box.add_child(label) - var entry := {"spec": spec, "label": label} - var axes := ["x", "y", "z"] if spec[4] else ["x"] - for a in axes: - var sl := HSlider.new() - sl.min_value = spec[2] - sl.max_value = spec[3] - sl.step = 0.001 - sl.custom_minimum_size = Vector2(400, 14) - sl.value_changed.connect(func(_v): _knob_changed(key)) - box.add_child(sl) - entry[a] = sl - _sliders[key] = entry - return box - - -func _save() -> void: - var where := WeaponHoldTuning.save(_all, _skins[_skin].id, - _weapons[_weapon].id, _knobs) - _status.text = "Saved %s + %s to %s" % [_skins[_skin].id, - _weapons[_weapon].id, where] - - -func _reset() -> void: - _knobs.clear() - for k in _sliders: - var e = _sliders[k] - for a in ["x", "y", "z"]: - if e.has(a): - e[a].set_value_no_signal(0.0) - _refresh_label(k) - if _model: - _model.set_hold_tuning({}) - _status.text = "Reset to what the code derives" - - -func _copy() -> void: - var flat := {} - for k in _knobs: - var v = _knobs[k] - flat[k] = [v.x, v.y, v.z] if v is Vector3 else v - DisplayServer.clipboard_set(JSON.stringify(flat, " ")) - _status.text = "Copied this character+weapon's knobs to the clipboard" - - -# ── camera ──────────────────────────────────────────────────────────────────── - -func _update_camera() -> void: - var b := Basis.from_euler(Vector3(_pitch, _yaw, 0)) - _cam.global_transform = Transform3D(b, _pivot + b * Vector3(0, 0, _dist)) - - -func _unhandled_input(e: InputEvent) -> void: - if e is InputEventMouseMotion: - if e.button_mask & MOUSE_BUTTON_MASK_LEFT: - _yaw -= e.relative.x * 0.006 - _pitch = clampf(_pitch - e.relative.y * 0.006, -1.4, 1.4) - _update_camera() - elif e.button_mask & MOUSE_BUTTON_MASK_MIDDLE: - var b := _cam.global_transform.basis - _pivot += (b.x * -e.relative.x + b.y * e.relative.y) * _dist * 0.0015 - _update_camera() - elif e is InputEventMouseButton and e.pressed: - if e.button_index == MOUSE_BUTTON_WHEEL_UP: - _dist = maxf(_dist * 0.9, 0.15) - _update_camera() - elif e.button_index == MOUSE_BUTTON_WHEEL_DOWN: - _dist = minf(_dist * 1.1, 8.0) - _update_camera() - elif e is InputEventKey and e.pressed and not e.echo: - match e.keycode: - KEY_F: - # Frame the hands, which is what is actually being judged. - if _model and _model._pose_mod: - _pivot = _model.skeleton.global_transform \ - * _model._pose_mod.dbg_grip - _dist = 0.55 - _update_camera() - KEY_S: _save() - KEY_R: _reset() - KEY_C: _copy() - KEY_ESCAPE: get_tree().quit() diff --git a/debug/weapon_lab.tscn b/debug/weapon_lab.tscn deleted file mode 100644 index 4b0efdd..0000000 --- a/debug/weapon_lab.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene format=3 uid="uid://c1aec1m0pwuk3"] - -[ext_resource type="Script" path="res://debug/weapon_lab.gd" id="1_lab"] - -[node name="WeaponLab" type="Node3D"] -script = ExtResource("1_lab") diff --git a/ui/bolt_rule.gd b/ui/bolt_rule.gd index 039c908..62c9209 100644 --- a/ui/bolt_rule.gd +++ b/ui/bolt_rule.gd @@ -42,30 +42,24 @@ func _draw() -> void: draw_line(Vector2(minf(w, strike + gap), mid), Vector2(w, mid), line_color, thickness) - # Bolt: down-right, kick back left, down-right again. + # The bolt, as a SIMPLE polygon — six points, no edge crossing another. + # `draw_colored_polygon` triangulates, and a self-intersecting outline fails + # triangulation outright and draws nothing but a console full of "Invalid + # polygon data". The obvious zigzag (down-right, back-left, down-right) + # crosses itself; this is the same silhouette walked as a closed loop + # instead: down the left edge, out to the tip, back up the right edge. var pts := PackedVector2Array([ - Vector2(strike + half * 0.55, mid - half), - Vector2(strike - half * 0.10, mid + half * 0.12), - Vector2(strike + half * 0.30, mid + half * 0.12), - Vector2(strike - half * 0.55, mid + half), - Vector2(strike + half * 0.12, mid - half * 0.10), - Vector2(strike - half * 0.28, mid - half * 0.10), + Vector2(strike + half * 0.55, mid - half), # top, right of centre + Vector2(strike - half * 0.45, mid + half * 0.05), # down-left to the notch + Vector2(strike + half * 0.00, mid + half * 0.05), # step right + Vector2(strike - half * 0.35, mid + half), # down-left to the tip + Vector2(strike + half * 0.55, mid - half * 0.15), # back up the right edge + Vector2(strike + half * 0.10, mid - half * 0.15), # step left ]) - # Ink first, one step out, so the bolt keeps a drawn edge against any - # background it is placed on. - draw_colored_polygon(_grown(pts, 2.0), ink) draw_colored_polygon(pts, bolt_color) - - -## The same polygon pushed out from its own centre — a cheap outline that needs -## no second point list to maintain. -func _grown(pts: PackedVector2Array, by: float) -> PackedVector2Array: - var centre := Vector2.ZERO - for p in pts: - centre += p - centre /= float(pts.size()) - var out := PackedVector2Array() - for p in pts: - var dir := (p - centre) - out.append(p + (dir.normalized() * by if dir.length() > 0.001 else Vector2.ZERO)) - return out + # Ink edge ON TOP as a closed polyline, not as a second, larger polygon — + # growing a concave shape from its centroid can push a point past its + # neighbour and produce exactly the self-intersection above. + var loop := pts.duplicate() + loop.append(pts[0]) + draw_polyline(loop, ink, 2.5)