Feat/outline thickness and tp weapon hold #22
@@ -136,6 +136,31 @@ Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD
|
||||
to isolate it — that is how the classifier gets checked: click `hair` and
|
||||
anything else still standing was misclassified.
|
||||
|
||||
**Drag the coloured markers.** They ARE the anchor points the hands are solved
|
||||
onto — red the trigger grip, green the support hand, blue the buttstock — and
|
||||
the one under the mouse swells and draws through the body so it can be grabbed
|
||||
where the hands would otherwise hide it.
|
||||
|
||||
Dragging an anchor is not the same as any slider:
|
||||
|
||||
| | moves |
|
||||
|---|---|
|
||||
| `grip_offset` (ANCHORS) | the GUN, inside the fist |
|
||||
| `gun_fore` / `gun_stock` (HOLD) | the hands ALONG the weapon's own axis |
|
||||
| dragging a marker | the anchor itself, in three dimensions |
|
||||
|
||||
That distinction was the gap. `gun_fore` and `gun_stock` are distances along the
|
||||
barrel, so the two hand anchors could slide up and down the gun and nowhere
|
||||
else — no use for a handguard below the bore, an angled foregrip, or a pistol
|
||||
whose grip is nowhere near its barrel line. A drag writes `grip_shift` or
|
||||
`fore_shift` in the gun's own across/up/along frame, so a sideways nudge stays
|
||||
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
|
||||
for whichever pose is showing.
|
||||
|
||||
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so
|
||||
a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop
|
||||
when it looks right — and measured, not assumed.
|
||||
|
||||
## Read before you touch anything
|
||||
|
||||
Load the reference that matches what you are doing. They are short and each one
|
||||
|
||||
@@ -44,6 +44,8 @@ Two related traps:
|
||||
| `surface_class_check.gd` | every surface resolves from the sidecar, not the fallback | 0 fallbacks on all six skins |
|
||||
| `character_picker_check.gd` | the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES | 0 failures |
|
||||
| `rig_anchor_check.gd` | a grip anchor physically moves the weapon, and clears | 0 failures |
|
||||
| `anchor_shift_check.gd` | the hand anchors move in the GUN's frame, both poses | 0 failures |
|
||||
| `anchor_drag_check.gd` | dragging a marker writes the knob the mouse asked for | 0 failures |
|
||||
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
||||
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
||||
| `ui_capture.gd` | one photo of every menu screen | — |
|
||||
@@ -78,6 +80,14 @@ Three of these exist because the obvious check passes on a broken system.
|
||||
nobody consumed. It measures in the attachment's frame, not the world's:
|
||||
the attachment tracks a bone on an animating skeleton, so a world-space delta
|
||||
is mostly the idle animation.
|
||||
- `anchor_shift_check` and `anchor_drag_check` both measure in the GUN's frame
|
||||
rather than the world's, and have to. The hold BREATHES — a
|
||||
`sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
|
||||
same world position twice, and comparing absolute positions reported a 3.5 mm
|
||||
error that was the character inhaling. Taking each anchor relative to the one
|
||||
it hangs off and rotating into the current gun basis cancels the breathing,
|
||||
the ADS blend and the recoil kick exactly, because all three move the basis
|
||||
and the anchor together.
|
||||
- `surface_class_check` FAILS on a surface that falls through to the heuristic
|
||||
instead of resolving from the table. A model whose names stopped matching still
|
||||
renders — the fallback catches it — and quietly loses its per-class art
|
||||
|
||||
@@ -1307,6 +1307,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
var dbg_grip: Vector3 = Vector3.ZERO
|
||||
var dbg_fore: Vector3 = Vector3.ZERO
|
||||
var dbg_stock: Vector3 = Vector3.ZERO
|
||||
## The gun's frame this frame — across, up, along the barrel. The lab needs
|
||||
## it to turn a mouse drag on an anchor marker back into the axes its knob is
|
||||
## expressed in; without it, dragging left would mean something different at
|
||||
## every pitch of the weapon.
|
||||
var dbg_gun_basis: Basis = Basis.IDENTITY
|
||||
|
||||
func _t(key: String, fallback: float) -> float:
|
||||
return float(tune.get(key, fallback))
|
||||
@@ -1654,7 +1659,14 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
var shoulder := skel.get_bone_global_pose(ua_r).origin
|
||||
var pocket: Vector3 = t_pocket_hip.lerp(t_pocket_ads, ads)
|
||||
var stock_pos := shoulder + pocket
|
||||
var grip_pos := stock_pos + aim_dir * gun_stock
|
||||
# The gun's own frame: across, up, along the barrel. The hand anchors are
|
||||
# nudged in THIS rather than in skeleton space so a sideways offset stays
|
||||
# sideways relative to the weapon as it pitches from low ready to ADS,
|
||||
# instead of sliding around the gun as it tips.
|
||||
var gun_basis := Basis(side, gun_up, aim_dir)
|
||||
dbg_gun_basis = gun_basis
|
||||
var grip_pos := stock_pos + aim_dir * gun_stock \
|
||||
+ gun_basis * _tv("grip_shift", Vector3.ZERO)
|
||||
# The support hand rides as far out the handguard as it can actually
|
||||
# REACH. Without this a long rifle puts the foregrip past the left
|
||||
# arm's limit and the IK yanks the whole arm out straight.
|
||||
@@ -1677,7 +1689,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
||||
fore_dist = maxf(fore_dist * 0.8, floor_fore)
|
||||
if fore_dist <= floor_fore:
|
||||
break
|
||||
var fore_pos := grip_pos + aim_dir * fore_dist
|
||||
var fore_pos := grip_pos + aim_dir * fore_dist \
|
||||
+ gun_basis * _tv("fore_shift", Vector3.ZERO)
|
||||
dbg_grip = grip_pos
|
||||
dbg_fore = fore_pos
|
||||
dbg_stock = stock_pos
|
||||
|
||||
@@ -52,6 +52,25 @@ const KNOBS := [
|
||||
Vector3(0.03, -0.07, 0.06)],
|
||||
["pocket_ads", "Stock pocket, aiming", -0.30, 0.30, true,
|
||||
Vector3(0.05, 0.01, 0.07)],
|
||||
# The two hand anchors, off the barrel line.
|
||||
#
|
||||
# `gun_stock` and `gun_fore` above are DISTANCES ALONG the barrel, and for a
|
||||
# long time that was the only freedom either anchor had: the trigger hand
|
||||
# could slide up and down the gun's own axis and nowhere else, and so could
|
||||
# the support hand. That is fine for where along a handguard to hold, and
|
||||
# useless for a handguard that sits below the bore, an angled foregrip, or a
|
||||
# pistol whose grip is nowhere near its barrel line.
|
||||
#
|
||||
# These are in the GUN's frame — x across, y up, z along the barrel — so they
|
||||
# stay meaningful as the weapon pitches between low ready and ADS. Zero is
|
||||
# exactly the old behaviour. Their z overlaps `gun_stock`/`gun_fore`, which
|
||||
# is redundant but harmless, and keeping the along-axis distances separate is
|
||||
# what lets the reach solver slide the support hand back down the handguard
|
||||
# without also undoing a deliberate sideways nudge.
|
||||
["grip_shift", "Trigger-hand anchor, off the barrel line", -0.15, 0.15, true,
|
||||
Vector3.ZERO],
|
||||
["fore_shift", "Support-hand anchor, off the barrel line", -0.15, 0.15, true,
|
||||
Vector3.ZERO],
|
||||
# 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],
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
extends SceneTree
|
||||
|
||||
## Does dragging an anchor marker in the rig lab move that anchor where the
|
||||
## mouse went?
|
||||
##
|
||||
## The maths behind a viewport drag has four frames in it — screen, world,
|
||||
## skeleton, gun — and every one is a chance to transpose an inverse or lose a
|
||||
## handedness. All of those mistakes still MOVE the marker, so "the number
|
||||
## changed" proves nothing. What is asserted here is that the number changed by
|
||||
## the RIGHT AMOUNT, in the frame that knob is written in, derived independently
|
||||
## from the camera.
|
||||
##
|
||||
## Deliberately NOT asserted: that the marker lands exactly under the mouse. It
|
||||
## does not, and the reason is a real property of the hold rather than a bug in
|
||||
## the drag — see THE FEEDBACK below. The screen-space check kept here is a
|
||||
## direction-and-order-of-magnitude one, which is the band the transform
|
||||
## mistakes above actually live in: a swapped axis or a lost handedness sends
|
||||
## the marker the wrong way entirely.
|
||||
##
|
||||
## THE FEEDBACK. Every anchor hangs off the shoulder — `stock_pos = shoulder +
|
||||
## pocket`, and the grip and fore anchors are measured out from there. The
|
||||
## shoulder is driven by the arm, and the arm is chasing the anchor. So moving
|
||||
## an anchor moves the shoulder, which moves the anchor again. Measured from a
|
||||
## clean slate it settles at 0.77x-1.13x of the drag depending on which anchor,
|
||||
## which is small enough to be invisible interactively — you stop dragging when
|
||||
## it looks right.
|
||||
##
|
||||
## It is worth the paragraph because of how it first showed up. Without the
|
||||
## `_reset` between cases below, each drag started on top of the last one still
|
||||
## working its way through the arm, and the ratio read 1.5x-1.8x; waiting LONGER
|
||||
## for the pose to settle made it worse rather than better, which is the
|
||||
## opposite of how a settling error behaves and is what gave the compounding
|
||||
## away.
|
||||
##
|
||||
## godot --path . -s res://debug/anchor_drag_check.gd
|
||||
|
||||
const LAB := "res://debug/rig_lab.tscn"
|
||||
const DRAG := Vector2(60, 0)
|
||||
## Metres. What the knob is checked to — this part is exact maths, so it can be.
|
||||
const KNOB_TOLERANCE := 0.0005
|
||||
## The screen-space check is direction and order of magnitude only. See above.
|
||||
const MIN_TRAVEL := 0.5
|
||||
const MAX_TRAVEL := 2.2
|
||||
|
||||
var _fails := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
await process_frame
|
||||
var lab: Node = load(LAB).instantiate()
|
||||
root.add_child(lab)
|
||||
# The pose layer chases its targets exponentially at a rate times DELTA, and
|
||||
# a headless run is uncapped, so each frame advances the blend by almost
|
||||
# nothing and the hold takes hundreds of frames to stop moving on its own.
|
||||
for _i in 300:
|
||||
await process_frame
|
||||
|
||||
if lab._model == null or not lab._model.loaded or lab._model._pose_mod == null:
|
||||
_expect(false, "the lab built a character with a pose layer")
|
||||
_done()
|
||||
return
|
||||
_expect(true, "the lab built a character with a pose layer")
|
||||
|
||||
# Frame the hands, so a pixel is a small distance in the world — a drag
|
||||
# measured at arm's length is mostly noise.
|
||||
lab._pivot = lab._model.skeleton.global_transform * lab._model._pose_mod.dbg_grip
|
||||
lab._dist = 0.6
|
||||
lab._update_camera()
|
||||
for _i in 20:
|
||||
await process_frame
|
||||
|
||||
for case in [[0, "trigger hand", "grip_shift"], [1, "support hand", "fore_shift"],
|
||||
[2, "buttstock", "pocket_hip"]]:
|
||||
await _drag_case(lab, case[0], case[1], case[2])
|
||||
_done()
|
||||
|
||||
|
||||
func _drag_case(lab: Node, marker: int, label: String, key: String) -> void:
|
||||
# From a clean slate each time, or the second case measures the first's
|
||||
# shift still working its way through the arm.
|
||||
lab._reset("hold")
|
||||
for _i in 120:
|
||||
await process_frame
|
||||
|
||||
var m: Node3D = lab._markers[marker]
|
||||
if not m.visible:
|
||||
_expect(false, "the %s marker is visible" % label)
|
||||
return
|
||||
var before: Vector2 = lab._cam.unproject_position(m.global_position)
|
||||
_expect(lab._marker_under(before) == marker,
|
||||
"the %s marker is grabbable where it is drawn" % label)
|
||||
|
||||
# What the drag SHOULD write, worked out from the camera here rather than
|
||||
# from the lab's own code, so the two have to agree independently.
|
||||
var want: Vector3 = _expected(lab, marker, m.global_position, before, before + DRAG)
|
||||
var was: Vector3 = _knob(lab, key)
|
||||
|
||||
lab._begin_drag(marker, before)
|
||||
# In steps, as a real drag arrives — a single jump would hide an error that
|
||||
# accumulates per motion event.
|
||||
for step in 6:
|
||||
lab._drag_to(before + DRAG * (float(step + 1) / 6.0))
|
||||
await process_frame
|
||||
lab._drag_marker = -1
|
||||
|
||||
var wrote: Vector3 = _knob(lab, key) - was
|
||||
var err: float = (wrote - want).length()
|
||||
_expect(err <= KNOB_TOLERANCE,
|
||||
"the %s drag wrote %s into '%s' (wanted %s, off by %.2f mm)"
|
||||
% [label, _mm(wrote), key, _mm(want), err * 1000.0])
|
||||
|
||||
# ...and the marker really did go that way on screen.
|
||||
for _i in 150:
|
||||
await process_frame
|
||||
var now: Vector2 = lab._cam.unproject_position(m.global_position)
|
||||
var moved: Vector2 = now - before
|
||||
var along: float = moved.dot(DRAG.normalized()) / DRAG.length()
|
||||
_expect(along >= MIN_TRAVEL and along <= MAX_TRAVEL,
|
||||
"the %s marker followed the drag (%.2fx of it; the shoulder feedback puts this over 1)"
|
||||
% [label, along])
|
||||
|
||||
|
||||
## The knob delta a drag from `a` to `b` ought to produce, in that knob's frame.
|
||||
func _expected(lab: Node, marker: int, at: Vector3, a: Vector2, b: Vector2) -> Vector3:
|
||||
var world := _plane(lab._cam, at, b) - _plane(lab._cam, at, a)
|
||||
var v: Vector3 = lab._model.skeleton.global_transform.basis.inverse() * world
|
||||
if lab.MARKER_KNOB[marker][1] == "gun":
|
||||
v = lab._model._pose_mod.dbg_gun_basis.inverse() * v
|
||||
return v
|
||||
|
||||
|
||||
func _plane(cam: Camera3D, at: Vector3, mouse: Vector2) -> Vector3:
|
||||
var origin := cam.project_ray_origin(mouse)
|
||||
var dir := cam.project_ray_normal(mouse)
|
||||
var n := -cam.global_transform.basis.z
|
||||
return origin + dir * (((at - origin).dot(n)) / dir.dot(n))
|
||||
|
||||
|
||||
## An absent knob reads as its DEFAULT, not as zero.
|
||||
##
|
||||
## Those are the same thing for `grip_shift` and `fore_shift` and not for
|
||||
## `pocket_hip`, whose default is (30, -70, 60) mm. Reading it as zero made a
|
||||
## perfectly correct 60 px drag look like a 97 mm error — the difference was
|
||||
## exactly the default. The lab has a note about this trap in `_reset`; it is
|
||||
## just as easy to walk into from a test.
|
||||
func _knob(lab: Node, key: String) -> Vector3:
|
||||
var v = lab._knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
|
||||
return v if v is Vector3 else Vector3.ZERO
|
||||
|
||||
|
||||
func _mm(v: Vector3) -> String:
|
||||
return "(%.0f, %.0f, %.0f) mm" % [v.x * 1000.0, v.y * 1000.0, v.z * 1000.0]
|
||||
|
||||
|
||||
func _expect(ok: bool, what: String) -> void:
|
||||
if ok:
|
||||
print(" OK: %s" % what)
|
||||
else:
|
||||
print(" FAIL: %s" % what)
|
||||
_fails += 1
|
||||
|
||||
|
||||
func _done() -> void:
|
||||
print("\n=== ANCHOR DRAG ===\nFailures: %d" % _fails)
|
||||
quit(1 if _fails > 0 else 0)
|
||||
@@ -0,0 +1,140 @@
|
||||
extends SceneTree
|
||||
|
||||
## Do the hand anchors move where they are told, in the frame they are told in?
|
||||
##
|
||||
## `grip_shift` and `fore_shift` exist because the two hand anchors could only
|
||||
## ever slide along the barrel: `gun_stock` and `gun_fore` are distances along
|
||||
## the weapon's own axis, so the trigger and support hands travelled up and down
|
||||
## the gun and nowhere else. What could move freely was the GUN, under anchors
|
||||
## that stayed put.
|
||||
##
|
||||
## Two things have to hold, and only the first is obvious:
|
||||
##
|
||||
## 1. the anchor moves by the amount asked for;
|
||||
## 2. it moves in the GUN's frame, not the skeleton's. A sideways nudge has to
|
||||
## stay sideways relative to the weapon whether the muzzle is pitched down
|
||||
## at low ready or level at ADS — otherwise the same number means two
|
||||
## different places in the two poses, and a skeleton-space implementation
|
||||
## passes check 1 happily.
|
||||
##
|
||||
## MEASURED IN THE GUN'S FRAME, and it has to be. The hold BREATHES — there is a
|
||||
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — so no anchor is ever at the
|
||||
## same world position twice, and the first version of this check compared
|
||||
## absolute positions and reported a 3.5 mm error that was just the character
|
||||
## inhaling. Taking each anchor relative to the one it hangs off and rotating
|
||||
## into the current gun basis cancels the breathing, the ADS blend and the
|
||||
## recoil kick exactly, because all three move the basis and the anchor together.
|
||||
##
|
||||
## godot --headless --path . -s res://debug/anchor_shift_check.gd
|
||||
|
||||
## All three axes, deliberately asymmetric, so an axis swap or a sign flip
|
||||
## cannot pass.
|
||||
const SHIFT := Vector3(0.05, -0.03, 0.02)
|
||||
const TOLERANCE := 0.0015
|
||||
## Long enough for the ADS blend and the hold's take-up to settle. The gun-frame
|
||||
## measurement is invariant to both, but a half-blended pose is a bad place to
|
||||
## be reading anything.
|
||||
const SETTLE := 40
|
||||
|
||||
var _fails := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
await process_frame
|
||||
await process_frame
|
||||
var weapon := _first_weapon()
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(
|
||||
"res://assets/characters/skins/skins.json"))
|
||||
for entry in data["skins"]:
|
||||
await _check(entry["id"], entry.get("model", ""), weapon)
|
||||
print("\n=== ANCHOR SHIFTS ===\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 s: String = db[id].get("script", "")
|
||||
if s != "" and ResourceLoader.exists(s):
|
||||
return s
|
||||
return ""
|
||||
|
||||
|
||||
## The two anchors in the gun's own across/up/along frame:
|
||||
## grip, relative to the buttstock == (0, 0, gun_stock) + grip_shift
|
||||
## fore, relative to the grip == (0, 0, fore_dist) + fore_shift
|
||||
func _local(pm) -> Array:
|
||||
var inv: Basis = pm.dbg_gun_basis.inverse()
|
||||
var grip: Vector3 = pm.dbg_grip
|
||||
var fore: Vector3 = pm.dbg_fore
|
||||
var stock: Vector3 = pm.dbg_stock
|
||||
return [inv * (grip - stock), inv * (fore - grip)]
|
||||
|
||||
|
||||
func _check(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 6:
|
||||
await process_frame
|
||||
|
||||
# Both poses, because the gun's pitch differs between them and that is the
|
||||
# whole point of expressing the shift in the gun's frame.
|
||||
for pose in [["low ready", 0.0], ["ADS", 1.0]]:
|
||||
var pm = model._pose_mod
|
||||
if pm == null:
|
||||
_expect(false, "'%s' has a pose layer" % id)
|
||||
break
|
||||
model.set_hold_tuning({})
|
||||
model.update_state("ground", 0.0, false)
|
||||
model.set_locomotion(0.0, 0.0, pose[1])
|
||||
for _i in SETTLE:
|
||||
await process_frame
|
||||
var base: Array = _local(pm)
|
||||
|
||||
# Each anchor on its own. `fore` hangs off `grip`, so shifting the grip
|
||||
# legitimately carries the support hand with it — moving where the
|
||||
# trigger hand holds a rifle moves the whole rifle, handguard included.
|
||||
# Testing them together would just measure that, and the first version
|
||||
# of this check did, and reported the sum as a 2x error.
|
||||
for which in [["grip_shift", 0, "trigger"], ["fore_shift", 1, "support"]]:
|
||||
model.set_hold_tuning({which[0]: SHIFT})
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
var now: Array = _local(pm)
|
||||
var moved: Vector3 = now[which[1]] - base[which[1]]
|
||||
# x and y are across the barrel — the freedom that did not exist
|
||||
# before. z is along it, and for the support hand the reach solver
|
||||
# owns that, so it is not ours to predict.
|
||||
var across := Vector2(moved.x, moved.y)
|
||||
var want := Vector2(SHIFT.x, SHIFT.y)
|
||||
_expect(across.distance_to(want) <= TOLERANCE,
|
||||
"'%s' %s: %s anchor moved %.0f, %.0f mm across the barrel (wanted %.0f, %.0f)"
|
||||
% [id, pose[0], which[2], across.x * 1000.0, across.y * 1000.0,
|
||||
want.x * 1000.0, want.y * 1000.0])
|
||||
|
||||
model.set_hold_tuning({})
|
||||
for _i in 6:
|
||||
await process_frame
|
||||
var back: Array = _local(pm)
|
||||
var residue: Vector3 = back[which[1]] - base[which[1]]
|
||||
_expect(Vector2(residue.x, residue.y).length() <= TOLERANCE,
|
||||
"'%s' %s: clearing %s restores the derived anchor"
|
||||
% [id, pose[0], which[0]])
|
||||
model.queue_free()
|
||||
|
||||
|
||||
func _expect(ok: bool, what: String) -> void:
|
||||
if ok:
|
||||
print(" OK: %s" % what)
|
||||
else:
|
||||
print(" FAIL: %s" % what)
|
||||
_fails += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmh6vowe1f6d5
|
||||
@@ -0,0 +1 @@
|
||||
uid://colk7su4vk56m
|
||||
+189
-8
@@ -28,14 +28,27 @@ extends Node3D
|
||||
## for the game to run correctly.
|
||||
##
|
||||
## CONTROLS
|
||||
## left drag orbit wheel zoom
|
||||
## middle drag pan F frame the hands
|
||||
## drag a MARKER move that anchor left drag orbit
|
||||
## middle drag pan wheel zoom
|
||||
## 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.
|
||||
## The three coloured markers ARE the anchors — red is the trigger grip, green
|
||||
## the support hand on the handguard, blue the buttstock — and they can be
|
||||
## dragged. The marker under the mouse swells; drag it and the hand follows.
|
||||
##
|
||||
## Dragging an anchor is not the same as any slider above it. The sliders move
|
||||
## the GUN: `grip_offset` slides the weapon around inside the fist, and
|
||||
## `gun_fore` / `gun_stock` slide the hands along the weapon's own axis. Neither
|
||||
## can take a hand OFF that axis, which is what a handguard below the bore, an
|
||||
## angled foregrip or a pistol grip all need. A drag writes `grip_shift` or
|
||||
## `fore_shift`, in the gun's own across/up/along frame, so a nudge sideways
|
||||
## stays sideways as the weapon pitches; the buttstock marker writes the shoulder
|
||||
## pocket for whichever pose is showing.
|
||||
##
|
||||
## If a hand is not ON its marker, the IK could not reach — a different problem
|
||||
## from the marker being in the wrong place, and dragging the marker further will
|
||||
## not fix it.
|
||||
|
||||
const POSES := [
|
||||
["Low ready", "ground", 0.0, 0.0],
|
||||
@@ -162,7 +175,8 @@ func _build_world() -> void:
|
||||
# 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.
|
||||
# its target. `_process` lifts this for the marker under the mouse, so
|
||||
# the one you are about to grab is the one you can see.
|
||||
mat.no_depth_test = false
|
||||
m.material_override = mat
|
||||
add_child(m)
|
||||
@@ -269,6 +283,155 @@ func _process(_delta: float) -> void:
|
||||
for i in _markers.size():
|
||||
_markers[i].global_position = to_world * pts[i]
|
||||
_markers[i].visible = pts[i] != Vector3.ZERO
|
||||
# The one being dragged, or the one the mouse is over, swells AND
|
||||
# draws through the body.
|
||||
#
|
||||
# Depth testing is right for the other two: drawn through the mesh
|
||||
# they look like they are floating in front of the chest when they
|
||||
# are in fact behind an arm, which is the wrong impression for
|
||||
# judging whether a hand reached its target. But it is wrong for the
|
||||
# one being grabbed — at any useful framing the hands occlude all
|
||||
# three markers, and a handle you cannot see is a handle you cannot
|
||||
# find. `_marker_under` never cared about occlusion; this makes that
|
||||
# visible rather than a secret.
|
||||
var hot: bool = (i == _drag_marker) or (_drag_marker < 0 and i == _hover_marker)
|
||||
_markers[i].scale = Vector3.ONE * (1.9 if hot else 1.0)
|
||||
var mat := _markers[i].material_override as StandardMaterial3D
|
||||
if mat:
|
||||
mat.no_depth_test = hot
|
||||
|
||||
|
||||
# ── dragging the anchors ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The markers are the anchor POINTS the hands are solved onto. Until now they
|
||||
# could only be moved through the sliders, and two of the three could only move
|
||||
# along the barrel — `gun_stock` and `gun_fore` are distances along the gun's own
|
||||
# axis, so the trigger and support hands slid up and down the weapon and nowhere
|
||||
# else. What the sliders DID move freely was the gun itself, under anchors that
|
||||
# stayed put.
|
||||
#
|
||||
# So: grab a marker and drag it. Each one writes to the knob that expresses that
|
||||
# motion, in the frame that knob is written in — which is why the pose layer now
|
||||
# publishes the gun's basis. A drag left has to mean "left across the weapon"
|
||||
# whether the muzzle is down at low ready or level at ADS.
|
||||
|
||||
## marker index -> [knob key, frame]. "gun" is the weapon's own across/up/along
|
||||
## basis; "skeleton" is the model's, which is where the shoulder pocket lives.
|
||||
const MARKER_KNOB := [
|
||||
["grip_shift", "gun"],
|
||||
["fore_shift", "gun"],
|
||||
["pocket", "skeleton"], # resolved to pocket_hip / pocket_ads by pose
|
||||
]
|
||||
## How close, in pixels, the mouse has to be to a marker to take hold of it.
|
||||
const GRAB_RADIUS := 26.0
|
||||
|
||||
var _drag_marker := -1
|
||||
var _hover_marker := -1
|
||||
## The plane a drag is measured on: fixed at grab time so it does not drift
|
||||
## toward the camera as the marker follows the mouse.
|
||||
var _drag_plane_at := Vector3.ZERO
|
||||
var _drag_last := Vector3.ZERO
|
||||
|
||||
|
||||
## The stock anchor is a blend of two knobs; a drag edits whichever one this
|
||||
## pose is actually showing. Dragging at low ready must not silently rewrite the
|
||||
## aiming pocket.
|
||||
func _stock_knob() -> String:
|
||||
return "pocket_ads" if POSES[_pose][3] > 0.5 else "pocket_hip"
|
||||
|
||||
|
||||
func _knob_for(marker: int) -> String:
|
||||
var key: String = MARKER_KNOB[marker][0]
|
||||
return _stock_knob() if key == "pocket" else key
|
||||
|
||||
|
||||
func _marker_under(mouse: Vector2) -> int:
|
||||
var best := -1
|
||||
var best_d := GRAB_RADIUS
|
||||
for i in _markers.size():
|
||||
if not _markers[i].visible:
|
||||
continue
|
||||
var p: Vector3 = _markers[i].global_position
|
||||
# Behind the camera projects to a nonsense point that can still land
|
||||
# within the grab radius.
|
||||
if _cam.is_position_behind(p):
|
||||
continue
|
||||
var d := _cam.unproject_position(p).distance_to(mouse)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = i
|
||||
return best
|
||||
|
||||
|
||||
## Where the mouse ray meets the drag plane — through the grab point, facing the
|
||||
## camera.
|
||||
func _plane_hit(mouse: Vector2) -> Vector3:
|
||||
var origin := _cam.project_ray_origin(mouse)
|
||||
var dir := _cam.project_ray_normal(mouse)
|
||||
var n := -_cam.global_transform.basis.z
|
||||
var denom := dir.dot(n)
|
||||
if absf(denom) < 1e-5:
|
||||
return _drag_last
|
||||
return origin + dir * (((_drag_plane_at - origin).dot(n)) / denom)
|
||||
|
||||
|
||||
func _begin_drag(marker: int, mouse: Vector2) -> void:
|
||||
_drag_marker = marker
|
||||
_drag_plane_at = _markers[marker].global_position
|
||||
_drag_last = _plane_hit(mouse)
|
||||
_status.text = "Dragging the %s anchor (%s)" % [
|
||||
["trigger hand", "support hand", "buttstock"][marker], _knob_for(marker)]
|
||||
|
||||
|
||||
func _drag_to(mouse: Vector2) -> void:
|
||||
if _model == null or _model._pose_mod == null or _model.skeleton == null:
|
||||
return
|
||||
var now := _plane_hit(mouse)
|
||||
var world_delta := now - _drag_last
|
||||
_drag_last = now
|
||||
|
||||
# World -> skeleton, because that is the space the pose layer works in.
|
||||
var delta: Vector3 = _model.skeleton.global_transform.basis.inverse() * world_delta
|
||||
if MARKER_KNOB[_drag_marker][1] == "gun":
|
||||
# ...and on into the gun's frame for the two hand anchors. Orthonormal,
|
||||
# so the inverse is the transpose, but say what is meant.
|
||||
delta = _model._pose_mod.dbg_gun_basis.inverse() * delta
|
||||
|
||||
var key := _knob_for(_drag_marker)
|
||||
var spec := _spec_for("hold", key)
|
||||
var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
|
||||
if not (cur is Vector3):
|
||||
cur = Vector3.ZERO
|
||||
var lo: float = spec[2]
|
||||
var hi: float = spec[3]
|
||||
_set_knob("hold", key, Vector3(
|
||||
clampf(cur.x + delta.x, lo, hi),
|
||||
clampf(cur.y + delta.y, lo, hi),
|
||||
clampf(cur.z + delta.z, lo, hi)))
|
||||
|
||||
|
||||
func _spec_for(group: String, key: String) -> Array:
|
||||
for spec in _specs(group):
|
||||
if spec[0] == key:
|
||||
return spec
|
||||
return ["", "", -1.0, 1.0, true, Vector3.ZERO]
|
||||
|
||||
|
||||
## Write a knob from somewhere other than its own slider, and keep the slider in
|
||||
## step. Without the write-back the sliders would silently disagree with the
|
||||
## model the moment anything was dragged, and Save would store the sliders.
|
||||
func _set_knob(group: String, key: String, value) -> void:
|
||||
_knobs[group][key] = value
|
||||
var entry = _sliders[group].get(key)
|
||||
if entry != null:
|
||||
if entry.spec[4]:
|
||||
entry.x.set_value_no_signal(value.x)
|
||||
entry.y.set_value_no_signal(value.y)
|
||||
entry.z.set_value_no_signal(value.z)
|
||||
else:
|
||||
entry.x.set_value_no_signal(float(value))
|
||||
_refresh_label(group, key)
|
||||
_push(group)
|
||||
|
||||
|
||||
# ── knobs ─────────────────────────────────────────────────────────────────────
|
||||
@@ -406,7 +569,7 @@ func _build_ui() -> void:
|
||||
_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"
|
||||
_status.text = "DRAG A MARKER to move that anchor · 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.
|
||||
@@ -581,6 +744,14 @@ func _update_camera() -> void:
|
||||
|
||||
func _unhandled_input(e: InputEvent) -> void:
|
||||
if e is InputEventMouseMotion:
|
||||
# An anchor drag OWNS the left button — otherwise grabbing a marker
|
||||
# would also orbit the camera, and the anchor would appear to move
|
||||
# because the view did.
|
||||
if _drag_marker >= 0:
|
||||
_drag_to(e.position)
|
||||
return
|
||||
_hover_marker = _marker_under(e.position) \
|
||||
if e.button_mask == 0 else _hover_marker
|
||||
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)
|
||||
@@ -589,6 +760,16 @@ func _unhandled_input(e: InputEvent) -> void:
|
||||
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.button_index == MOUSE_BUTTON_LEFT:
|
||||
if e.pressed:
|
||||
var hit := _marker_under(e.position)
|
||||
if hit >= 0:
|
||||
_begin_drag(hit, e.position)
|
||||
elif _drag_marker >= 0:
|
||||
_status.text = "%s is now %s — Save to keep it" % [
|
||||
_knob_for(_drag_marker),
|
||||
_knobs["hold"].get(_knob_for(_drag_marker), Vector3.ZERO)]
|
||||
_drag_marker = -1
|
||||
elif e is InputEventMouseButton and e.pressed:
|
||||
if e.button_index == MOUSE_BUTTON_WHEEL_UP:
|
||||
_dist = maxf(_dist * 0.9, 0.15)
|
||||
|
||||
Reference in New Issue
Block a user