feat(characters): rig anchors, and a lab that edits any tuning group
The weapon lab could tune how a character HOLDS a gun. It could not tune where
the gun sits in the hand, and that is a different question with a different
scope: a hold is per character and weapon, an anchor is a fact about the hand.
RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far
down the palm a grip should sit — and how the gun rolls in the fingers — depends
on how big that character's hand is and how the artist posed the thumb. It
cannot be derived, it differs per character, and it is small. So it is an offset
that defaults to identity, and identity means exactly what the code derived
before anchors existed: an untuned character is bit-for-bit unchanged.
Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone
attachment is expressed in the BONE's axes, which no two rigs agree on — that
constant is why the hand mount points were wrong on every character. The derived
mount stays derived; the anchor is a nudge on top of it, and nothing here spells
a bone name.
The layering, the JSON round trip and the res://-then-user:// write are now
TuningStore, because none of that was ever specific to weapons and two copies of
it would mean two places for "an exported build's tuning pass is silently
discarded" to come back. WeaponHoldTuning is built on it with its file format
unchanged.
The lab is a rig lab now, and it grew three things:
ANCHORS a second knob group. It cost a spec table — everything in the lab is
written against "which specs, which file, keyed on what" rather than
twice against the two groups, so anchors got sliders, live preview,
reset, save and clipboard for free. A third group is a third row in
GROUPS.
CLIP audition one animation on its own. The four pose buttons are the
states the game drives; watching a whole clip end to end is how you
see where a retarget went wrong, and there was no way to do it.
SURFACES what the importer decided each surface IS, and a click to isolate a
class. Isolating is how the decision gets CHECKED: click `hair` and
anything else still standing was misclassified. Hidden by swapping
in a transparent material rather than hiding the node, because a
mesh is not one class — Miku's body, face and hair are three
surfaces of one mesh.
And it takes the game's theme, applied to its own panel rather than only to the
Window, for the same reason the pause menu needed it: a CanvasLayer is not a
Control, so theme inheritance stops at one.
debug/rig_anchor_check.gd asserts the physical consequence rather than the
plumbing — an anchor system is easy to build so that the sliders move, the file
saves, the JSON round-trips and the gun does not budge. All six characters move
their weapon by exactly the offset asked for, measured in the attachment's frame
because a world-space delta on an animating skeleton is mostly the idle
animation, and all six return exactly to the derived mount when it is cleared.
Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon
triangulates, and a crossing outline fails triangulation and draws nothing at
all except a console full of "Invalid polygon data". Same silhouette, walked as
a closed loop, with the ink edge as a polyline rather than a grown polygon —
growing a concave shape from its centroid reintroduces the crossing.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26f7c2c622
commit
08ae85b286
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ck8e6odry037
|
||||
@@ -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,19 +910,54 @@ 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:
|
||||
_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 = Transform3D.IDENTITY
|
||||
w.transform = RigAnchors.grip_transform(anchors)
|
||||
_measure_weapon(w)
|
||||
|
||||
|
||||
@@ -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".
|
||||
if hold_tune.is_empty():
|
||||
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
|
||||
if hold_tune.is_empty():
|
||||
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 = {}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cq378n7o0qnoc
|
||||
@@ -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 "<could not write>"
|
||||
return TuningStore.write(all, skin_id, weapon_id, knobs, PATH, USER_PATH)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://br28g1yiljtpp
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnx7lbjpa6gpu
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2ucqqxfeaer5
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user