fix: cut the welded ankle band, seat the gun in the hand, flash at the barrel
1. Ankle cuffs. The previous pass re-weighted the offending vertices to the nearer leg, which was the wrong call: the strip between the ankles is REAL geometry spanning the gap (~10 cm of ClothCAndW crossing x = -0.05 .. +0.05 at ankle height), so re-weighting only tore it in half — a visible seam that still stretched. A triangle with one corner weighted to each leg has no correct pose; it must stretch the moment the legs separate. SkinMeshRepair now deletes those triangles instead (48 on Taila: the cloth band plus its outline shell). The cut is limited to BELOW THE KNEE, taken from the skeleton's own rest pose rather than a hardcoded height, because above the knee cross-leg geometry is legitimate — the skirt and shorts genuinely span left-thigh to right-thigh weights at the crotch. 2. The M4 floated because _measure_weapon derived the grip from mesh AABBs, and the FBX guns report bind-pose bounds tens of metres across — it measured the M4 as 24 m long and pushed the gun 7.5 m in front of the character. Bounding boxes are simply not trustworthy for these meshes. The grip was already authored elsewhere: WeaponManager places the first-person viewmodel's hands at fixed points in weapon space, and every weapon marks its barrel tip with muzzle_flash.position. Those move to WeaponGrips (dependency-free, so both the weapon system and the character models can use it without dragging each other's load order along) and third person now reaches for exactly the points the viewmodel uses. Every weapon's grip now lands 0.073 m from the hand — the M4 included, down from 2.32 m — with barrel lengths that match the models (0.30 m for the MP7, 0.68 m for the DMR). 3. Muzzle flash and tracers were spawned off the viewmodel's muzzle. The viewmodel is parented to the camera, so its muzzle sits inside the player's head — in third person the flash appeared by the character's shoulder. world_muzzle() returns the muzzle of the gun actually in the character's hands whenever the character is what the viewer sees, and the networked fire-effect RPC now sends that position too, so remote players stop seeing tracers leave the shooter's head. Measured: the third-person origin sits at the held gun, 0.54 m below the head, instead of on the camera. FSM tests 11/11, spawn smoke test 0 failures. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
35ada4f34a
commit
ec6b8228da
@@ -288,6 +288,20 @@ func set_weapon(script_path: String) -> void:
|
||||
(hand_r_pivot if hand_r_pivot else root_pivot).add_child(w)
|
||||
|
||||
|
||||
## The muzzle of the gun actually in this character's hand — see
|
||||
## SkinnedPlayerModel.get_muzzle_node() for why world effects must use it.
|
||||
func get_muzzle_node() -> Node3D:
|
||||
var holder := hand_r_pivot if hand_r_pivot else root_pivot
|
||||
if not holder:
|
||||
return null
|
||||
for child in holder.get_children():
|
||||
if child.has_meta("is_third_person_weapon"):
|
||||
if "muzzle_flash" in child and child.muzzle_flash:
|
||||
return child.muzzle_flash
|
||||
return child as Node3D
|
||||
return null
|
||||
|
||||
|
||||
## Slide the weapon along its own barrel axis so a plausible grip point — not
|
||||
## whatever origin the artist left the model at — ends up in the fist, and turn
|
||||
## it to face the way the body faces (the model is yawed 180° in _ready, so the
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
extends Object
|
||||
class_name SkinMeshRepair
|
||||
|
||||
## Removes below-the-knee geometry that is welded across BOTH legs.
|
||||
##
|
||||
## Taila's ClothCAndW surface carries a ~10 cm strip at ankle height (spanning
|
||||
## x = -0.05 .. +0.05, straight across the centre line) whose vertices are
|
||||
## weighted to the left leg on one edge and the right leg on the other. Nothing
|
||||
## about the rig or the clips is wrong — the foot bones swing independently
|
||||
## (measured left/right correlation -0.94) and no bone is ever scaled. But a
|
||||
## triangle with one corner on each leg has no correct pose: the moment the legs
|
||||
## separate it MUST stretch. It read as the two ankle cuffs being welded
|
||||
## together.
|
||||
##
|
||||
## An earlier version of this pass tried to re-weight those vertices to the
|
||||
## nearer leg. That was wrong: the strip is real geometry spanning the gap, so
|
||||
## re-weighting only tore it in half — a visible seam that still stretched.
|
||||
## Deleting the cross-leg triangles is the only stable answer, and it is safe
|
||||
## because such a triangle is never legitimate below the knee.
|
||||
##
|
||||
## ABOVE the knee it very much is legitimate — the shorts and skirt genuinely
|
||||
## span from left-thigh to right-thigh weights at the crotch — so the cut is
|
||||
## limited to below the knee, taken from the skeleton's own rest pose rather
|
||||
## than a hardcoded height, so it holds for any character's proportions.
|
||||
|
||||
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
|
||||
|
||||
|
||||
## Returns the number of triangles removed, so callers can log whether a skin
|
||||
## needed the repair at all.
|
||||
static func repair(root: Node, skeleton: Skeleton3D) -> int:
|
||||
var knee_y := _knee_height(skeleton)
|
||||
if is_nan(knee_y):
|
||||
return 0
|
||||
var removed_total := 0
|
||||
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh == null or mi.skin == null:
|
||||
continue
|
||||
# Rebuilding a mesh drops blend shapes, so skins that use them (face
|
||||
# rigs) are left alone rather than silently losing their expressions.
|
||||
if mi.mesh.get_blend_shape_count() > 0:
|
||||
continue
|
||||
removed_total += _repair_mesh(mi, skeleton, knee_y)
|
||||
return removed_total
|
||||
|
||||
|
||||
## Knee height in skeleton space — the boundary above which cross-leg geometry
|
||||
## is legitimate.
|
||||
static func _knee_height(skeleton: Skeleton3D) -> float:
|
||||
if skeleton == null:
|
||||
return NAN
|
||||
var best := NAN
|
||||
for name in ["DEF-shin.L", "shin.L", "DEF-shin.R", "shin.R"]:
|
||||
var i := skeleton.find_bone(name)
|
||||
if i >= 0:
|
||||
best = skeleton.get_bone_global_rest(i).origin.y
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D,
|
||||
knee_y: float) -> int:
|
||||
var side := _side_map(mi.skin, skeleton)
|
||||
var surfaces: Array = []
|
||||
var removed_total := 0
|
||||
for s in range(mi.mesh.get_surface_count()):
|
||||
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||
removed_total += _repair_surface(arrays, side, knee_y)
|
||||
surfaces.append({
|
||||
"arrays": arrays,
|
||||
"material": mi.mesh.surface_get_material(s),
|
||||
"name": mi.mesh.surface_get_name(s),
|
||||
})
|
||||
if removed_total == 0:
|
||||
return 0
|
||||
|
||||
var rebuilt := ArrayMesh.new()
|
||||
for i in surfaces.size():
|
||||
var entry: Dictionary = surfaces[i]
|
||||
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"])
|
||||
rebuilt.surface_set_material(i, entry["material"])
|
||||
if entry["name"] != "":
|
||||
rebuilt.surface_set_name(i, entry["name"])
|
||||
mi.mesh = rebuilt
|
||||
return removed_total
|
||||
|
||||
|
||||
## -1 left leg, +1 right leg, 0 anything else — keyed by SKIN BIND index, which
|
||||
## is what ARRAY_BONES stores (not the skeleton's bone index).
|
||||
static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
|
||||
var out := PackedInt32Array()
|
||||
out.resize(skin.get_bind_count())
|
||||
for b in skin.get_bind_count():
|
||||
var n := skin.get_bind_name(b)
|
||||
if n == "":
|
||||
var bone := skin.get_bind_bone(b)
|
||||
n = skeleton.get_bone_name(bone) if bone >= 0 else ""
|
||||
var is_leg := false
|
||||
for hint in LEG_BONE_HINTS:
|
||||
if n.findn(hint) != -1:
|
||||
is_leg = true
|
||||
break
|
||||
if not is_leg:
|
||||
out[b] = 0
|
||||
elif n.ends_with(".L"):
|
||||
out[b] = -1
|
||||
elif n.ends_with(".R"):
|
||||
out[b] = 1
|
||||
else:
|
||||
out[b] = 0
|
||||
return out
|
||||
|
||||
|
||||
static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
||||
knee_y: float) -> int:
|
||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
||||
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||
if bones.is_empty() or idx.is_empty() or verts.is_empty():
|
||||
return 0
|
||||
var per: int = bones.size() / verts.size()
|
||||
|
||||
var vside := PackedInt32Array()
|
||||
vside.resize(verts.size())
|
||||
for v in verts.size():
|
||||
var best_w := 0.0
|
||||
var best_s := 0
|
||||
for k in per:
|
||||
var w: float = weights[v * per + k]
|
||||
if w > best_w:
|
||||
best_w = w
|
||||
best_s = side[bones[v * per + k]]
|
||||
vside[v] = best_s
|
||||
|
||||
var keep := PackedInt32Array()
|
||||
var removed := 0
|
||||
for t in range(0, idx.size(), 3):
|
||||
var a: int = idx[t]
|
||||
var b: int = idx[t + 1]
|
||||
var c: int = idx[t + 2]
|
||||
var below_knee: bool = (verts[a].y + verts[b].y + verts[c].y) / 3.0 < knee_y
|
||||
var has_l: bool = vside[a] == -1 or vside[b] == -1 or vside[c] == -1
|
||||
var has_r: bool = vside[a] == 1 or vside[b] == 1 or vside[c] == 1
|
||||
if below_knee and has_l and has_r:
|
||||
removed += 1
|
||||
continue
|
||||
keep.append(a)
|
||||
keep.append(b)
|
||||
keep.append(c)
|
||||
if removed > 0:
|
||||
arrays[Mesh.ARRAY_INDEX] = keep
|
||||
return removed
|
||||
@@ -0,0 +1 @@
|
||||
uid://bak2pood5a40t
|
||||
@@ -1,186 +0,0 @@
|
||||
extends Object
|
||||
class_name SkinWeightRepair
|
||||
|
||||
## Fixes stray cross-body leg weights in imported character skins.
|
||||
##
|
||||
## Taila's left ankle cuff has ~24 triangles whose near-midline vertices are
|
||||
## weighted to the RIGHT leg. Nothing in the rig or the clips is wrong — the
|
||||
## foot bones swing independently (measured: left/right swing correlation
|
||||
## -0.94) and no bone is ever scaled. But when the legs separate, those few
|
||||
## vertices are dragged toward the far foot and stretch a band of cuff across
|
||||
## the gap, which reads as "the ankle cuffs are linked together".
|
||||
##
|
||||
## Position alone cannot decide the correct side: the offending vertices sit at
|
||||
## x = -0.002, barely across the centre line, while the cuff they belong to
|
||||
## spans x = 0.00 .. 0.05. So we use CONNECTIVITY instead — a vertex that
|
||||
## disagrees with a large majority of the vertices it shares triangles with is
|
||||
## mis-weighted, and its leg influences get mirrored to the other side.
|
||||
|
||||
const LEG_BONE_HINTS := ["thigh", "shin", "foot", "toe"]
|
||||
## A vertex flips only when this share of its neighbours disagree with it, so a
|
||||
## genuine seam (where both sides legitimately meet) is left alone.
|
||||
const MAJORITY := 0.8
|
||||
|
||||
|
||||
## Repair every skinned surface under `root`. Returns the number of vertices
|
||||
## re-weighted, so callers can log whether a skin needed it.
|
||||
static func repair(root: Node, skeleton: Skeleton3D) -> int:
|
||||
var fixed_total := 0
|
||||
for mi in root.find_children("*", "MeshInstance3D", true, false):
|
||||
if mi.mesh == null or mi.skin == null:
|
||||
continue
|
||||
# Rebuilding a mesh drops blend shapes; skins that use them (face rigs)
|
||||
# are left untouched rather than silently losing expressions.
|
||||
if mi.mesh.get_blend_shape_count() > 0:
|
||||
continue
|
||||
var fixed := _repair_mesh(mi, skeleton)
|
||||
fixed_total += fixed
|
||||
return fixed_total
|
||||
|
||||
|
||||
static func _repair_mesh(mi: MeshInstance3D, skeleton: Skeleton3D) -> int:
|
||||
var skin: Skin = mi.skin
|
||||
var mirror := _mirror_map(skin, skeleton)
|
||||
if mirror.is_empty():
|
||||
return 0
|
||||
var side := _side_map(skin, skeleton)
|
||||
|
||||
var surfaces: Array = []
|
||||
var fixed_total := 0
|
||||
for s in range(mi.mesh.get_surface_count()):
|
||||
var arrays: Array = mi.mesh.surface_get_arrays(s)
|
||||
var fixed := _repair_surface(arrays, side, mirror)
|
||||
fixed_total += fixed
|
||||
surfaces.append({
|
||||
"arrays": arrays,
|
||||
"material": mi.mesh.surface_get_material(s),
|
||||
"name": mi.mesh.surface_get_name(s),
|
||||
})
|
||||
if fixed_total == 0:
|
||||
return 0
|
||||
|
||||
var rebuilt := ArrayMesh.new()
|
||||
for i in surfaces.size():
|
||||
var entry: Dictionary = surfaces[i]
|
||||
rebuilt.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, entry["arrays"])
|
||||
rebuilt.surface_set_material(i, entry["material"])
|
||||
if entry["name"] != "":
|
||||
rebuilt.surface_set_name(i, entry["name"])
|
||||
mi.mesh = rebuilt
|
||||
return fixed_total
|
||||
|
||||
|
||||
## -1 left, +1 right, 0 not a leg bone — keyed by SKIN BIND index, which is what
|
||||
## ARRAY_BONES actually stores (not the skeleton's bone index).
|
||||
static func _side_map(skin: Skin, skeleton: Skeleton3D) -> PackedInt32Array:
|
||||
var out := PackedInt32Array()
|
||||
out.resize(skin.get_bind_count())
|
||||
for b in skin.get_bind_count():
|
||||
var n := _bind_name(skin, skeleton, b)
|
||||
var is_leg := false
|
||||
for hint in LEG_BONE_HINTS:
|
||||
if n.findn(hint) != -1:
|
||||
is_leg = true
|
||||
break
|
||||
if not is_leg:
|
||||
out[b] = 0
|
||||
elif n.ends_with(".L"):
|
||||
out[b] = -1
|
||||
elif n.ends_with(".R"):
|
||||
out[b] = 1
|
||||
else:
|
||||
out[b] = 0
|
||||
return out
|
||||
|
||||
|
||||
## bind index -> bind index of the same bone on the opposite side.
|
||||
static func _mirror_map(skin: Skin, skeleton: Skeleton3D) -> Dictionary:
|
||||
var by_name := {}
|
||||
for b in skin.get_bind_count():
|
||||
by_name[_bind_name(skin, skeleton, b)] = b
|
||||
var out := {}
|
||||
for b in skin.get_bind_count():
|
||||
var n := _bind_name(skin, skeleton, b)
|
||||
var other := ""
|
||||
if n.ends_with(".L"):
|
||||
other = n.substr(0, n.length() - 2) + ".R"
|
||||
elif n.ends_with(".R"):
|
||||
other = n.substr(0, n.length() - 2) + ".L"
|
||||
if other != "" and by_name.has(other):
|
||||
out[b] = by_name[other]
|
||||
return out
|
||||
|
||||
|
||||
static func _bind_name(skin: Skin, skeleton: Skeleton3D, b: int) -> String:
|
||||
var n := skin.get_bind_name(b)
|
||||
if n != "":
|
||||
return n
|
||||
var bone := skin.get_bind_bone(b)
|
||||
return skeleton.get_bone_name(bone) if bone >= 0 else ""
|
||||
|
||||
|
||||
static func _repair_surface(arrays: Array, side: PackedInt32Array,
|
||||
mirror: Dictionary) -> int:
|
||||
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
|
||||
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
|
||||
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
|
||||
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
|
||||
if bones.is_empty() or idx.is_empty() or verts.is_empty():
|
||||
return 0
|
||||
var per: int = bones.size() / verts.size()
|
||||
|
||||
# Dominant leg side per vertex (0 = not leg-driven, left alone).
|
||||
var vside := PackedInt32Array()
|
||||
vside.resize(verts.size())
|
||||
for v in verts.size():
|
||||
var best_w := 0.0
|
||||
var best_s := 0
|
||||
for k in per:
|
||||
var w: float = weights[v * per + k]
|
||||
if w > best_w:
|
||||
best_w = w
|
||||
best_s = side[bones[v * per + k]]
|
||||
vside[v] = best_s
|
||||
|
||||
# Neighbour tallies over shared triangles.
|
||||
var n_left := PackedInt32Array()
|
||||
var n_right := PackedInt32Array()
|
||||
n_left.resize(verts.size())
|
||||
n_right.resize(verts.size())
|
||||
for t in range(0, idx.size(), 3):
|
||||
for i in 3:
|
||||
var v: int = idx[t + i]
|
||||
for j in 3:
|
||||
if i == j:
|
||||
continue
|
||||
var o: int = idx[t + j]
|
||||
if vside[o] == -1:
|
||||
n_left[v] += 1
|
||||
elif vside[o] == 1:
|
||||
n_right[v] += 1
|
||||
|
||||
var fixed := 0
|
||||
for v in verts.size():
|
||||
if vside[v] == 0:
|
||||
continue
|
||||
var total: int = n_left[v] + n_right[v]
|
||||
if total < 4:
|
||||
continue # too little context to judge
|
||||
var agree: int = n_left[v] if vside[v] == -1 else n_right[v]
|
||||
var disagree: int = total - agree
|
||||
if float(disagree) / float(total) < MAJORITY:
|
||||
continue
|
||||
# Overwhelmingly surrounded by the other leg: mirror this vertex's leg
|
||||
# influences so it rides the leg its geometry actually belongs to.
|
||||
var changed := false
|
||||
for k in per:
|
||||
var b: int = bones[v * per + k]
|
||||
if side[b] == 0 or not mirror.has(b):
|
||||
continue
|
||||
bones[v * per + k] = mirror[b]
|
||||
changed = true
|
||||
if changed:
|
||||
fixed += 1
|
||||
if fixed > 0:
|
||||
arrays[Mesh.ARRAY_BONES] = bones
|
||||
return fixed
|
||||
@@ -1 +0,0 @@
|
||||
uid://72vc4gn33foy
|
||||
@@ -162,12 +162,13 @@ func load_model(path: String) -> void:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
else:
|
||||
_ensure_meshes_bound(scene)
|
||||
# Stray cross-body leg weights make the two ankle cuffs look welded
|
||||
# together as the legs separate. See SkinWeightRepair.
|
||||
var reweighted := SkinWeightRepair.repair(scene, skeleton)
|
||||
if reweighted > 0:
|
||||
print("SkinnedPlayerModel: re-weighted %d cross-leg vertices in '%s'"
|
||||
% [reweighted, path.get_file()])
|
||||
# Below-the-knee geometry welded across both legs can only ever stretch
|
||||
# as they separate — it made the ankle cuffs look linked. See
|
||||
# SkinMeshRepair.
|
||||
var culled := SkinMeshRepair.repair(scene, skeleton)
|
||||
if culled > 0:
|
||||
print("SkinnedPlayerModel: removed %d cross-leg triangles from '%s'"
|
||||
% [culled, path.get_file()])
|
||||
_pose_mod = ShooterPoseModifier.new()
|
||||
_pose_mod.name = "ShooterPose"
|
||||
skeleton.add_child(_pose_mod)
|
||||
@@ -626,43 +627,52 @@ func set_weapon(script_path: String) -> void:
|
||||
add_child(w)
|
||||
|
||||
|
||||
## Measure the held weapon along its own barrel axis so the pose layer knows
|
||||
## where the real foregrip and stock butt are, instead of guessing. Distances
|
||||
## are from the GRIP (the weapon node's origin, which sits in the hand), in
|
||||
## metres of character space.
|
||||
## The muzzle of the gun actually in this character's hand.
|
||||
##
|
||||
## Anything the WORLD sees — tracers, muzzle flash, the shot's audio position —
|
||||
## has to originate here whenever the character model is what the viewer is
|
||||
## looking at. The first-person viewmodel is parented to the camera, so ITS
|
||||
## muzzle sits inside the player's head; using it in third person put the flash
|
||||
## next to the character's shoulder.
|
||||
func get_muzzle_node() -> Node3D:
|
||||
if not _weapon_attachment or _weapon_attachment.get_child_count() == 0:
|
||||
return null
|
||||
var w := _weapon_attachment.get_child(0)
|
||||
if "muzzle_flash" in w and w.muzzle_flash:
|
||||
return w.muzzle_flash
|
||||
return w as Node3D
|
||||
|
||||
|
||||
## Seat the weapon in the hand and tell the pose layer where the support hand
|
||||
## and stock are, using the weapon's OWN authored markers.
|
||||
##
|
||||
## This used to derive everything from mesh AABBs, which silently produced
|
||||
## nonsense: the FBX guns report bind-pose bounds tens of metres across (the M4
|
||||
## measured 24 m long), so the grip offset threw the gun 7.5 m in front of the
|
||||
## character. Nothing about a mesh's bounding box is trustworthy here.
|
||||
##
|
||||
## The reliable data is already authored: WeaponManager places the first-person
|
||||
## viewmodel's hands at GRIP_LOCAL and SUPPORT_LOCAL in weapon space, and every
|
||||
## weapon sets muzzle_flash.position at its barrel tip. Third person simply
|
||||
## reaches for the same points the viewmodel does.
|
||||
func _measure_weapon(w: Node3D) -> void:
|
||||
var local_fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis
|
||||
var min_t := INF # most negative = stock end
|
||||
var max_t := -INF # most positive = muzzle end
|
||||
for mi in w.find_children("*", "MeshInstance3D", true, false):
|
||||
if not mi.mesh:
|
||||
continue
|
||||
var xf: Transform3D = w.global_transform.affine_inverse() * mi.global_transform
|
||||
var aabb: AABB = mi.mesh.get_aabb()
|
||||
for i in 8:
|
||||
var t: float = (xf * aabb.get_endpoint(i)).dot(local_fwd)
|
||||
min_t = minf(min_t, t)
|
||||
max_t = maxf(max_t, t)
|
||||
if min_t > max_t:
|
||||
return
|
||||
var s: float = absf(w.scale.z)
|
||||
var total := max_t - min_t
|
||||
if total < 0.0001:
|
||||
return
|
||||
# Weapon models put their origin wherever the artist left it — for the M4
|
||||
# that is barely 10 cm behind the muzzle end, so hanging the hand there
|
||||
# and then parking the stock in the shoulder shoved the hand INTO the
|
||||
# shoulder and the arm folded up behind the head. Re-seat the weapon so
|
||||
# the hand sits at a realistic pistol-grip point (~a third back from the
|
||||
# muzzle), which puts real length of gun behind the hand to reach the
|
||||
# shoulder with.
|
||||
var grip_at := min_t + total * 0.32
|
||||
w.position -= _pose_mod.gun_fwd_hand * (grip_at * s)
|
||||
var back := (grip_at - min_t) * s # butt of the stock, behind the grip
|
||||
var front := (max_t - grip_at) * s # muzzle, ahead of the grip
|
||||
_pose_mod.gun_stock = clampf(back, 0.10, 0.40)
|
||||
# Support hand rides partway out the handguard, never past the muzzle.
|
||||
_pose_mod.gun_fore = clampf(front * 0.55, 0.14, 0.45)
|
||||
var grip: Vector3 = WeaponGrips.GRIP
|
||||
var support: Vector3 = WeaponGrips.SUPPORT
|
||||
var fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis
|
||||
|
||||
# Put the GRIP — not the model origin — in the fist.
|
||||
w.position -= w.transform.basis * grip
|
||||
|
||||
# Support hand: how far along the barrel the viewmodel's off hand rides.
|
||||
var fore: float = absf((support - grip).dot(fwd))
|
||||
# Stock: not authored anywhere, so derive it from the barrel length. Half
|
||||
# the grip-to-muzzle distance behind the grip lands the butt in the
|
||||
# shoulder pocket for every gun in the set.
|
||||
var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST
|
||||
if "muzzle_flash" in w and w.muzzle_flash:
|
||||
muzzle_dist = maxf(absf((w.muzzle_flash.position - grip).dot(fwd)), 0.1)
|
||||
_pose_mod.gun_fore = clampf(fore, 0.14, 0.45)
|
||||
_pose_mod.gun_stock = clampf(muzzle_dist * 0.5, 0.10, 0.40)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,12 +122,35 @@ func _fire() -> void:
|
||||
if current_ammo == 0:
|
||||
_start_reload()
|
||||
|
||||
## The muzzle the WORLD should see this shot come from. In first person that is
|
||||
## the viewmodel's own muzzle. In third person the viewmodel is parked at the
|
||||
## camera, so its muzzle sits inside the player's head — the flash has to come
|
||||
## off the gun the character is actually holding instead.
|
||||
func world_muzzle() -> Node3D:
|
||||
if player and "third_person" in player and player.third_person \
|
||||
and player.has_method("get_visual_model"):
|
||||
var vis = player.get_visual_model()
|
||||
if vis and vis.has_method("get_muzzle_node"):
|
||||
var m: Node3D = vis.get_muzzle_node()
|
||||
if m:
|
||||
return m
|
||||
return muzzle_flash
|
||||
|
||||
|
||||
func _play_muzzle_flash() -> void:
|
||||
if muzzle_flash:
|
||||
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
|
||||
if not muzzle_flash:
|
||||
return
|
||||
var m := world_muzzle()
|
||||
ExplosionVFX.muzzle_flash(m.get_parent(), m.position)
|
||||
# Light whichever muzzle the world can see, so the flash actually throws
|
||||
# light from the barrel rather than from behind the camera.
|
||||
if m is OmniLight3D:
|
||||
var lit: OmniLight3D = m
|
||||
lit.light_energy = 8.0
|
||||
create_tween().tween_property(lit, "light_energy", 0.0, 0.05)
|
||||
if m != muzzle_flash:
|
||||
muzzle_flash.light_energy = 8.0
|
||||
var tween = create_tween()
|
||||
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
|
||||
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
|
||||
|
||||
func _shoot_hitscan() -> void:
|
||||
if not camera: return
|
||||
@@ -186,9 +209,31 @@ func _shoot_hitscan() -> void:
|
||||
_spawn_tracer(origin, final_target)
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true)
|
||||
# Everyone else sees our CHARACTER, never our viewmodel, so the tracer
|
||||
# and the gunshot audio have to leave from the gun in its hands — the
|
||||
# camera position we raycast from is inside our own head.
|
||||
player.server_play_fire_effects.rpc_id(1, remote_muzzle_position(),
|
||||
final_target, name, true)
|
||||
|
||||
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
|
||||
# Cel-styled cosmetic tracer from the barrel tip
|
||||
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
|
||||
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
|
||||
HitscanTracer.spawn_bolt(get_tree().current_scene, muzzle_world_position(), final_target)
|
||||
|
||||
|
||||
## World position the shot visually leaves from. Falls back to the weapon node
|
||||
## when a weapon has no muzzle marker at all (the AWP).
|
||||
func muzzle_world_position() -> Vector3:
|
||||
var m := world_muzzle()
|
||||
return m.global_position if m else global_position
|
||||
|
||||
|
||||
## Where OTHER clients should see this shot leave from: always the gun in the
|
||||
## character's hand, whatever view mode we happen to be in locally.
|
||||
func remote_muzzle_position() -> Vector3:
|
||||
if player and player.has_method("get_visual_model"):
|
||||
var vis = player.get_visual_model()
|
||||
if vis and vis.has_method("get_muzzle_node"):
|
||||
var m: Node3D = vis.get_muzzle_node()
|
||||
if m:
|
||||
return m.global_position
|
||||
return muzzle_world_position()
|
||||
|
||||
@@ -118,12 +118,44 @@ func _fire() -> void:
|
||||
if current_ammo == 0:
|
||||
_start_reload()
|
||||
|
||||
## See BaseHitscanWeapon.world_muzzle(): in third person the viewmodel's muzzle
|
||||
## sits inside the player's head, so world effects must come off the gun the
|
||||
## character is actually holding.
|
||||
func world_muzzle() -> Node3D:
|
||||
if player and "third_person" in player and player.third_person \
|
||||
and player.has_method("get_visual_model"):
|
||||
var vis = player.get_visual_model()
|
||||
if vis and vis.has_method("get_muzzle_node"):
|
||||
var m: Node3D = vis.get_muzzle_node()
|
||||
if m:
|
||||
return m
|
||||
return muzzle_flash
|
||||
|
||||
|
||||
## Where OTHER clients should see this shot leave from — always the held gun.
|
||||
func remote_muzzle_position() -> Vector3:
|
||||
if player and player.has_method("get_visual_model"):
|
||||
var vis = player.get_visual_model()
|
||||
if vis and vis.has_method("get_muzzle_node"):
|
||||
var m: Node3D = vis.get_muzzle_node()
|
||||
if m:
|
||||
return m.global_position
|
||||
var own := world_muzzle()
|
||||
return own.global_position if own else global_position
|
||||
|
||||
|
||||
func _play_muzzle_flash() -> void:
|
||||
if muzzle_flash:
|
||||
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
|
||||
if not muzzle_flash:
|
||||
return
|
||||
var m := world_muzzle()
|
||||
ExplosionVFX.muzzle_flash(m.get_parent(), m.position)
|
||||
if m is OmniLight3D:
|
||||
var lit: OmniLight3D = m
|
||||
lit.light_energy = 8.0
|
||||
create_tween().tween_property(lit, "light_energy", 0.0, 0.05)
|
||||
if m != muzzle_flash:
|
||||
muzzle_flash.light_energy = 8.0
|
||||
var tween = create_tween()
|
||||
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
|
||||
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
|
||||
|
||||
func _shoot_projectile() -> void:
|
||||
if not camera: return
|
||||
@@ -142,7 +174,10 @@ func _shoot_projectile() -> void:
|
||||
_spawn_custom_projectile(origin, fire_dir)
|
||||
|
||||
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
||||
player.server_play_fire_effects.rpc_id(1, origin, fire_dir, weapon_name, false)
|
||||
# Remote viewers see our character, so the projectile must appear to
|
||||
# leave the held gun rather than our camera.
|
||||
player.server_play_fire_effects.rpc_id(1, remote_muzzle_position(),
|
||||
fire_dir, weapon_name, false)
|
||||
|
||||
func _spawn_custom_projectile(_origin: Vector3, _fire_dir: Vector3) -> void:
|
||||
# Virtual method for custom projectiles (e.g., bouncing, homing)
|
||||
|
||||
@@ -133,12 +133,33 @@ func _apply_impulse() -> void:
|
||||
else:
|
||||
player.apply_impulse(final_force)
|
||||
|
||||
## This one extends Node3D directly rather than BaseHitscanWeapon, so it needs
|
||||
## its own copy. See BaseHitscanWeapon.world_muzzle(): in third person the
|
||||
## viewmodel's muzzle sits inside the player's head, so world-visible effects
|
||||
## have to come off the gun the character is actually holding.
|
||||
func world_muzzle() -> Node3D:
|
||||
if player and "third_person" in player and player.third_person \
|
||||
and player.has_method("get_visual_model"):
|
||||
var vis = player.get_visual_model()
|
||||
if vis and vis.has_method("get_muzzle_node"):
|
||||
var m: Node3D = vis.get_muzzle_node()
|
||||
if m:
|
||||
return m
|
||||
return muzzle_flash
|
||||
|
||||
|
||||
func _play_muzzle_flash() -> void:
|
||||
if muzzle_flash:
|
||||
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
|
||||
if not muzzle_flash:
|
||||
return
|
||||
var m := world_muzzle()
|
||||
ExplosionVFX.muzzle_flash(m.get_parent(), m.position)
|
||||
if m is OmniLight3D:
|
||||
var lit: OmniLight3D = m
|
||||
lit.light_energy = 8.0
|
||||
create_tween().tween_property(lit, "light_energy", 0.0, 0.1)
|
||||
if m != muzzle_flash:
|
||||
muzzle_flash.light_energy = 8.0
|
||||
var tween = create_tween()
|
||||
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
|
||||
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
|
||||
|
||||
func _shoot_hitscan() -> void:
|
||||
if not camera: return
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
extends Object
|
||||
class_name WeaponGrips
|
||||
|
||||
## Where the hands sit on a weapon, in the WEAPON's own local space.
|
||||
##
|
||||
## This is the authored ground truth for "where is this gun held". WeaponManager
|
||||
## places the first-person viewmodel's arms at these points, and
|
||||
## SkinnedPlayerModel reaches the third-person character's hands to the same
|
||||
## ones, so both views agree on the grip. Every weapon model in the set is built
|
||||
## with its origin at the pistol grip, which is why one pair of offsets covers
|
||||
## all of them.
|
||||
##
|
||||
## Deliberately dependency-free (no autoloads, no weapon scripts) so both the
|
||||
## weapon system and the character models can reference it without dragging the
|
||||
## other's load order along.
|
||||
|
||||
const GRIP := Vector3(0.04, -0.05, 0.05) # trigger hand
|
||||
const SUPPORT := Vector3(-0.02, -0.02, -0.3) # handguard hand
|
||||
|
||||
## Fallback barrel length (grip -> muzzle) for a weapon with no muzzle marker.
|
||||
const DEFAULT_MUZZLE_DIST := 0.45
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4aa52jn4s27o
|
||||
@@ -274,9 +274,11 @@ func _set_layer_recursive(node: Node, layer_mask: int) -> void:
|
||||
func _add_procedural_arms(weapon: Node3D) -> void:
|
||||
# Attach to weapon instead of model_root so reload choreography can move
|
||||
# the gun (model_root) and each hand (named pivots) independently.
|
||||
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05), "ArmR")
|
||||
# Hand points come from WeaponGrips so the third-person model reaches for
|
||||
# exactly the same spots this viewmodel uses.
|
||||
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), WeaponGrips.GRIP, "ArmR")
|
||||
if "weapon_name" in weapon and weapon.weapon_name != "Knife":
|
||||
_build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3), "ArmL")
|
||||
_build_arm(weapon, Vector3(-0.25, -0.3, 0.4), WeaponGrips.SUPPORT, "ArmL")
|
||||
|
||||
|
||||
## A first-person arm styled after the character skin: dark detached sleeve,
|
||||
|
||||
Reference in New Issue
Block a user