Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
16 changed files with 1028 additions and 508 deletions
Showing only changes of commit 08ae85b286 - Show all commits
+90
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ck8e6odry037
+59 -10
View File
@@ -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 ## 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. ## whoever spawns the model; falls back to the GLB's basename.
var skin_id: String = "" 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. ## set_weapon() from the saved table otherwise.
var hold_tune: Dictionary = {} 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; # Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an # gameplay one-shots (reload/throw/shoot/hit) play through an
@@ -907,20 +910,55 @@ func get_lean_debug() -> float:
return _cur_fwd 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. ## 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 ## 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. ## attachment when the weapon is seated, so those need the weapon re-measured.
func set_hold_tuning(t: Dictionary) -> void: func set_hold_tuning(t: Dictionary) -> void:
hold_tune = t hold_tune = t
if _pose_mod: if _pose_mod:
_pose_mod.tune = t _pose_mod.tune = t
if _weapon_attachment and _weapon_attachment.get_child_count() > 0: _reseat_weapon()
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = Transform3D.IDENTITY ## Push a new anchor table in and re-seat the weapon on it.
_measure_weapon(w) ##
## 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 ## Whether the locomotion cycle is running backwards, which points the stride
@@ -955,10 +993,15 @@ func set_weapon(script_path: String) -> void:
return return
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is # Per-character, per-weapon hold overrides, if any have been tuned. Empty is
# the normal case and means "use what the code derives". # 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(): 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, hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename()) 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) var script = load(script_path)
if not script: 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. # 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 # 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. # 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: if _pose_mod:
_pose_mod.gun_fwd_hand = Vector3(0, 0, -1) _pose_mod.gun_fwd_hand = Vector3(0, 0, -1)
_pose_mod.gun_up_hand = Vector3(0, 1, 0) _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. ## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
var fingers: Dictionary = {} var fingers: Dictionary = {}
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and ## 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 ## knob needs no plumbing: add it here, read it with _t/_tv, and the lab
## picks it up from the same table. ## picks it up from the same table.
var tune: Dictionary = {} var tune: Dictionary = {}
+112
View File
@@ -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.<skin>._all this character, every subject
## skins.<skin>.<key> 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 "<could not write>"
## 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
+1
View File
@@ -0,0 +1 @@
uid://cq378n7o0qnoc
+10 -65
View File
@@ -10,7 +10,7 @@ class_name WeaponHoldTuning
## rides, how far the elbow flares, how hard the fingers close. Those want an ## 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. ## 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. ## SkinnedPlayerModel reads it when a weapon is equipped.
## ##
## Resolution is layered, most general first, so a single number can be set once ## 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. ## The built-in value for a knob, for a lab that has nothing saved yet.
static func default_for(key: String): static func default_for(key: String):
for spec in KNOBS: return TuningStore.default_for(KNOBS, key)
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 {}
## 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: static func load_all() -> Dictionary:
# user:// wins, so a tuning pass made in an exported build is not lost, and return TuningStore.read(PATH, USER_PATH)
# 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
## The resolved knob table for one character holding one weapon. ## 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: static func resolve(all: Dictionary, skin_id: String, weapon_id: String) -> Dictionary:
var out := {} return TuningStore.resolve(all, skin_id, weapon_id)
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
## Store one character+weapon's knobs and write the file. Returns where it went. ## 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, static func save(all: Dictionary, skin_id: String, weapon_id: String,
knobs: Dictionary) -> String: knobs: Dictionary) -> String:
if not all.has("skins"): return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH)
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 "<could not write>"
+1
View File
@@ -0,0 +1 @@
uid://br28g1yiljtpp
+115
View File
@@ -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
+613
View File
@@ -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 <path> [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()
+6
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://cnx7lbjpa6gpu
+1
View File
@@ -0,0 +1 @@
uid://c2ucqqxfeaer5
-403
View File
@@ -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 <path> [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()
-6
View File
@@ -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")
+18 -24
View File
@@ -42,30 +42,24 @@ func _draw() -> void:
draw_line(Vector2(minf(w, strike + gap), mid), Vector2(w, mid), draw_line(Vector2(minf(w, strike + gap), mid), Vector2(w, mid),
line_color, thickness) 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([ var pts := PackedVector2Array([
Vector2(strike + half * 0.55, mid - half), Vector2(strike + half * 0.55, mid - half), # top, right of centre
Vector2(strike - half * 0.10, mid + half * 0.12), Vector2(strike - half * 0.45, mid + half * 0.05), # down-left to the notch
Vector2(strike + half * 0.30, mid + half * 0.12), Vector2(strike + half * 0.00, mid + half * 0.05), # step right
Vector2(strike - half * 0.55, mid + half), Vector2(strike - half * 0.35, mid + half), # down-left to the tip
Vector2(strike + half * 0.12, mid - half * 0.10), Vector2(strike + half * 0.55, mid - half * 0.15), # back up the right edge
Vector2(strike - half * 0.28, mid - half * 0.10), 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) draw_colored_polygon(pts, bolt_color)
# 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
## The same polygon pushed out from its own centre — a cheap outline that needs # neighbour and produce exactly the self-intersection above.
## no second point list to maintain. var loop := pts.duplicate()
func _grown(pts: PackedVector2Array, by: float) -> PackedVector2Array: loop.append(pts[0])
var centre := Vector2.ZERO draw_polyline(loop, ink, 2.5)
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