Feat/outline thickness and tp weapon hold #22
@@ -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