Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
12 changed files with 363 additions and 247 deletions
Showing only changes of commit ec6b8228da - Show all commits
+14
View File
@@ -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
+153
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bak2pood5a40t
-186
View File
@@ -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
View File
@@ -1 +0,0 @@
uid://72vc4gn33foy
+52 -42
View File
@@ -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 ───────────────────────────────────────────────────────────────────
+52 -7
View File
@@ -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()
+40 -5
View File
@@ -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)
+25 -4
View File
@@ -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
+21
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b4aa52jn4s27o
+4 -2
View File
@@ -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,