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