Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
6 changed files with 628 additions and 9 deletions
Showing only changes of commit 33d07b3717 - Show all commits
+78 -9
View File
@@ -94,6 +94,12 @@ var _joint_helpers: Array = []
var _rig_info: Dictionary = {} var _rig_info: Dictionary = {}
var _spring_mod: SpringBones var _spring_mod: SpringBones
var is_holding_weapon: bool = false 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
## set_weapon() from the saved table otherwise.
var hold_tune: Dictionary = {}
# Animation blending: locomotion plays full-body through a Transition node; # Animation blending: locomotion plays full-body through a Transition node;
# gameplay one-shots (reload/throw/shoot/hit) play through an # gameplay one-shots (reload/throw/shoot/hit) play through an
@@ -863,6 +869,22 @@ func get_lean_debug() -> float:
return _cur_fwd return _cur_fwd
## 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
## the next frame, but weapon SIZE and the grip offset are baked into the
## attachment when the weapon is seated, so those need the weapon re-measured.
func set_hold_tuning(t: Dictionary) -> void:
hold_tune = t
if _pose_mod:
_pose_mod.tune = t
if _weapon_attachment and _weapon_attachment.get_child_count() > 0:
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = Transform3D.IDENTITY
_measure_weapon(w)
## Whether the locomotion cycle is running backwards, which points the stride ## Whether the locomotion cycle is running backwards, which points the stride
## the opposite way to the hips. For debug/travel_dir_check.gd. ## the opposite way to the hips. For debug/travel_dir_check.gd.
func stride_reversed_debug() -> bool: func stride_reversed_debug() -> bool:
@@ -893,6 +915,12 @@ func set_weapon(script_path: String) -> void:
is_holding_weapon = script_path != "" is_holding_weapon = script_path != ""
if script_path == "" or not skeleton: if script_path == "" or not skeleton:
return 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()
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename())
var script = load(script_path) var script = load(script_path)
if not script: if not script:
@@ -1039,15 +1067,25 @@ func _measure_weapon(w: Node3D) -> void:
# Size the gun to the arm that has to hold it, then seat the grip in the fist. # Size the gun to the arm that has to hold it, then seat the grip in the fist.
var k := _weapon_scale(stock_u + fore_u) var k := _weapon_scale(stock_u + fore_u)
# A tuned size wins over the derived one. The derivation keeps the handguard
# inside the support arm's reach, which is a floor on believability, not a
# statement about how big the gun should LOOK.
if hold_tune.get("weapon_scale", 0.0) > 0.01:
k = float(hold_tune["weapon_scale"])
w.scale = Vector3.ONE * k w.scale = Vector3.ONE * k
w.position -= w.transform.basis * grip w.position -= w.transform.basis * grip
_pose_mod.gun_fore = fore_u * k _pose_mod.gun_fore = fore_u * k
if hold_tune.get("gun_fore", 0.0) > 0.0001:
_pose_mod.gun_fore = float(hold_tune["gun_fore"])
# Stock: not authored anywhere, so derive it from the barrel. Half the # Stock: not authored anywhere, so derive it from the barrel. Half the
# grip-to-muzzle distance behind the grip lands the butt in the shoulder # grip-to-muzzle distance behind the grip lands the butt in the shoulder
# pocket for every gun in the set. # pocket for every gun in the set.
_pose_mod.gun_stock = stock_u * k _pose_mod.gun_stock = stock_u * k
if hold_tune.get("gun_stock", 0.0) > 0.0001:
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
_pose_mod.tune = hold_tune
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -1172,6 +1210,25 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var roles: Dictionary = {} var roles: Dictionary = {}
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar. ## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
var fingers: Dictionary = {} var fingers: Dictionary = {}
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and
## debug/weapon_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 = {}
## Where the hold put the weapon this frame, in skeleton space. The lab draws
## these so the points being tuned are visible rather than inferred.
var dbg_grip: Vector3 = Vector3.ZERO
var dbg_fore: Vector3 = Vector3.ZERO
var dbg_stock: Vector3 = Vector3.ZERO
func _t(key: String, fallback: float) -> float:
return float(tune.get(key, fallback))
func _tv(key: String, fallback: Vector3) -> Vector3:
var v = tune.get(key)
# A zero-length vector means "not set" — see WeaponHoldTuning.KNOBS. It
# is how the elbow poles keep their hip/ADS blend unless overridden.
return v if (v is Vector3 and v.length() > 0.0001) else fallback
var _fing: Dictionary = {} # same, resolved to bone indices var _fing: Dictionary = {} # same, resolved to bone indices
var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame
## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose. ## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose.
@@ -1488,12 +1545,14 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if ua_r < 0: if ua_r < 0:
return return
var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02 var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02
var t_pocket_hip := _tv("pocket_hip", POCKET_HIP)
var t_pocket_ads := _tv("pocket_ads", POCKET_ADS)
# ~7 degrees of muzzle rise per shot, stacking a little on full auto. # ~7 degrees of muzzle rise per shot, stacking a little on full auto.
var kick := recoil * 0.12 var kick := recoil * 0.12
# 1. The gun's line: pitched down at low-ready, on the camera line at # 1. The gun's line: pitched down at low-ready, on the camera line at
# ADS, kicked up by recoil. # ADS, kicked up by recoil.
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe var gun_pitch := lerpf(_t("pitch_hip", GUN_PITCH_HIP), -aim_pitch, ads) - kick + breathe
var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \ var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
* Vector3(0, 0, 1)).normalized() * Vector3(0, 0, 1)).normalized()
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the # Gun's "up" — perpendicular to the barrel in the vertical plane, so the
@@ -1506,7 +1565,7 @@ class ShooterPoseModifier extends SkeletonModifier3D:
# 2. Anchor the stock at the shoulder, then walk out along the barrel. # 2. Anchor the stock at the shoulder, then walk out along the barrel.
var shoulder := skel.get_bone_global_pose(ua_r).origin var shoulder := skel.get_bone_global_pose(ua_r).origin
var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads) var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
var stock_pos := shoulder + pocket var stock_pos := shoulder + pocket
var grip_pos := stock_pos + aim_dir * gun_stock var grip_pos := stock_pos + aim_dir * gun_stock
# The support hand rides as far out the handguard as it can actually # The support hand rides as far out the handguard as it can actually
@@ -1532,6 +1591,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if fore_dist <= floor_fore: if fore_dist <= floor_fore:
break break
var fore_pos := grip_pos + aim_dir * fore_dist var fore_pos := grip_pos + aim_dir * fore_dist
dbg_grip = grip_pos
dbg_fore = fore_pos
dbg_stock = stock_pos
# 3. Support hand goes to the mag well during a reload (under the # 3. Support hand goes to the mag well during a reload (under the
# receiver — the correct side), otherwise to the handguard. # receiver — the correct side), otherwise to the handguard.
@@ -1550,8 +1612,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20) l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
# 4. Solve both arms onto those points. # 4. Solve both arms onto those points.
var pole_r: Vector3 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized() var pole_r: Vector3 = _tv("pole_r", POLE_R_HIP.lerp(POLE_R_ADS, ads)).normalized()
var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, ads).normalized() var pole_l: Vector3 = _tv("pole_l", POLE_L_HIP.lerp(POLE_L_ADS, ads)).normalized()
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R", var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
"DEF-hand.R", grip_pos, pole_r, _hold_r) "DEF-hand.R", grip_pos, pole_r, _hold_r)
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L", var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
@@ -1580,7 +1642,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var up_flat := (up_now - aim_dir * up_now.dot(aim_dir)) var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
if up_flat.length_squared() > 0.0001: if up_flat.length_squared() > 0.0001:
var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir) var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc arc = Quaternion(aim_dir, roll + _t("trigger_roll",
R_HAND_TWIST)) * arc
_set_global_rot(skel, hand, g_fa_r, arc, _hold_r) _set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
# 6. Support hand: WRAP the handguard. # 6. Support hand: WRAP the handguard.
@@ -1605,7 +1668,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if hand_l >= 0: if hand_l >= 0:
# -aim_dir so the hand comes at the handguard from the body side # -aim_dir so the hand comes at the handguard from the body side
# rather than reaching over it backwards. # rather than reaching over it backwards.
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up, # `support_roll` rolls the whole target frame about the barrel,
# which is the one axis a hand wrapping a cylinder is free in.
var roll_q := Quaternion(aim_dir, _t("support_roll", 0.0))
var up_r := roll_q * gun_up
var want := Basis(up_r.cross(-aim_dir).normalized(), up_r,
-aim_dir) -aim_dir)
var rest: Basis = _hand_frame["L"] var rest: Basis = _hand_frame["L"]
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \ var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
@@ -1646,14 +1713,16 @@ class ShooterPoseModifier extends SkeletonModifier3D:
if bones.is_empty(): if bones.is_empty():
continue continue
# The right index rides the trigger; everything else wraps. # The right index rides the trigger; everything else wraps.
var amount: Array = CURL_TRIGGER if (side == "R" and digit == "index") else CURL_WRAP var trigger: bool = side == "R" and digit == "index"
var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
for i in bones.size(): for i in bones.size():
var a: float = amount[i] if i < amount.size() else amount[-1] var a: float = amount[i] if i < amount.size() else amount[-1]
_add_space(skel, bones[i], Quaternion(axis, a * w)) _add_space(skel, bones[i], Quaternion(axis, a * scale * w))
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array()) var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
for i in thumb.size(): for i in thumb.size():
var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1] var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1]
_add_space(skel, thumb[i], Quaternion(axis, a * w)) _add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w))
## Straight-arm length of an arm chain, from the rest pose. ## Straight-arm length of an arm chain, from the rest pose.
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String, func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,
+139
View File
@@ -0,0 +1,139 @@
extends Object
class_name WeaponHoldTuning
## Per-character, per-weapon overrides for how a gun is held.
##
## Every knob in the rifle hold used to be a constant tuned against one rig, and
## every one of them was wrong on the next character imported — the mount
## rotation, the wrist twist, the weapon size. The ones that CAN be derived from
## the skeleton now are. The rest are genuinely art direction: how high the stock
## 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;
## SkinnedPlayerModel reads it when a weapon is equipped.
##
## Resolution is layered, most general first, so a single number can be set once
## for everything and then contradicted where it matters:
##
## defaults every character, every weapon
## skins.<skin>._all this character, every weapon
## skins.<skin>.<weapon> this character, this weapon
##
## An empty file means "use the built-in defaults", so the game runs perfectly
## well with no tuning at all — this only ever adds information.
const PATH := "res://assets/characters/weapon_holds.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://weapon_holds.json"
## key -> [label, minimum, maximum, is_vector, default]
##
## The lab builds its whole UI from this, so adding a knob here is all it takes
## to expose one. Ranges are what a plausible answer lives inside, not what the
## value can technically be.
##
## The DEFAULT must match what the code does when nothing is tuned, or the lab
## lies: a slider parked at 0 next to a code default of 1.0 means the first touch
## of that slider silently switches the behaviour off. Zero means "let the code
## decide" only where it is called out below.
const KNOBS := [
["weapon_scale", "Weapon size (0 = fit to arm)", 0.0, 1.4, false, 0.0],
["gun_fore", "Support hand along barrel (0 = auto)", 0.0, 0.50, false, 0.0],
["gun_stock", "Grip to buttstock (0 = auto)", 0.0, 0.45, false, 0.0],
["pitch_hip", "Muzzle pitch, low ready", -0.6, 0.6, false, 0.16],
["support_roll", "Support hand roll", -3.2, 3.2, false, 0.0],
["trigger_roll", "Trigger hand roll", -3.2, 3.2, false, 0.0],
["curl_wrap", "Finger wrap", 0.0, 2.0, false, 1.0],
["curl_trigger", "Trigger finger", 0.0, 2.0, false, 1.0],
["curl_thumb", "Thumb", 0.0, 2.0, false, 1.0],
["pocket_hip", "Stock pocket, low ready", -0.30, 0.30, true,
Vector3(0.03, -0.07, 0.06)],
["pocket_ads", "Stock pocket, aiming", -0.30, 0.30, true,
Vector3(0.05, 0.01, 0.07)],
# Zero means "use the code's own hip/ADS blend" for these two — see _tv in
# ShooterPoseModifier, which treats a zero-length vector as unset.
["pole_r", "Firing elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
["pole_l", "Support elbow (0 = auto)", -1.5, 1.5, true, Vector3.ZERO],
]
## 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 {}
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
## 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
## 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>"
+1
View File
@@ -0,0 +1 @@
uid://cxbghp14y3i7v
+403
View File
@@ -0,0 +1,403 @@
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
View File
@@ -0,0 +1 @@
uid://dmqu5ghj4pxl1
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://cwpnlabweapon01"]
[ext_resource type="Script" path="res://debug/weapon_lab.gd" id="1_lab"]
[node name="WeaponLab" type="Node3D"]
script = ExtResource("1_lab")