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:
Nicholas Butzke
2026-07-21 19:16:22 -04:00
co-authored by Claude Opus 4.8
parent 35ada4f34a
commit ec6b8228da
12 changed files with 363 additions and 247 deletions
+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()