Files
Papay-Shooter/weapons/double_barrel_shotgun.gd
T
Nicholas ButzkeandClaude Opus 4.8 ec6b8228da 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]>
2026-07-21 19:16:22 -04:00

216 lines
6.6 KiB
GDScript

extends Node3D
class_name DoubleBarrelShotgun
@export var reload_time: float = 1.0
var shells: int = 2
var reloading: bool = false
var reload_timer: float = 0.0
var _vm_kick: float = 0.0
var player: CharacterBody3D
var camera: Camera3D
var muzzle_flash: OmniLight3D
var fire_sound: AudioStreamPlayer3D
var reload_sound: AudioStreamPlayer3D
var model_root: Node3D
func _ready() -> void:
set_process_input(true)
set_process(true)
_build_model()
func _build_model() -> void:
if not model_root:
model_root = Node3D.new()
add_child(model_root)
position = Vector3(0.3, -0.3, -0.690)
var model_scene = load("res://assets/weapons/double barrel/source/Double Deuce.fbx")
if model_scene:
var model = model_scene.instantiate()
model_root.add_child(model)
model.scale = Vector3(0.275, 0.275, 0.275)
model.rotation_degrees.y = 90
for light in model.find_children("*", "Light3D", true, false):
light.queue_free()
# Muzzle Flash Light
muzzle_flash = OmniLight3D.new()
muzzle_flash.light_color = Color(1.0, 0.8, 0.3)
muzzle_flash.light_energy = 0.0
muzzle_flash.omni_range = 4.0
muzzle_flash.position = Vector3(0, 0.03, -0.45)
model_root.add_child(muzzle_flash)
# Audio Players
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = AudioManager.stream_for("shotgun_fire", "res://assets/sounds/shotgun_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -20.0
add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
add_child(reload_sound)
func _process(delta: float) -> void:
if reloading:
reload_timer -= delta
if reload_timer <= 0.0:
shells = 2
reloading = false
# Big single-shot shove that springs back.
if not reloading and _vm_kick > 0.001:
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-12.0 * delta))
ViewmodelAnim.apply_kick(self, _vm_kick, 0.10)
func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
return
if event.is_action_pressed("fire"):
_try_fire()
elif event.is_action_released("fire"):
_try_fire()
if event.is_action_pressed("reload"):
if shells < 2 and not reloading:
_start_reload()
func _start_reload() -> void:
reloading = true
reload_timer = reload_time
_vm_kick = 0.0
ViewmodelAnim.play_reload(self, "break", reload_time)
if reload_sound:
reload_sound.play()
## Called by WeaponManager when this weapon is holstered mid-anything.
func unequip() -> void:
ViewmodelAnim.stop(self)
_vm_kick = 0.0
func _try_fire() -> void:
if shells > 0 and not reloading:
shells -= 1
print("BANG! Shells left: ", shells)
_vm_kick = minf(_vm_kick + 1.0, 1.4)
_apply_impulse()
_shoot_hitscan()
_play_muzzle_flash()
if fire_sound:
fire_sound.play()
# Start reload timer if we just shot the LAST shell
if shells == 0:
_start_reload()
elif shells == 0 and not reloading:
# Empty click, force reload
_start_reload()
func _apply_impulse() -> void:
if not player or not camera:
return
# Get the shotgun impulse parameter from the player's movement controller
var impulse_strength = 20.0
if player.has_method("get") and "params" in player:
if player.params and "shotgun_jump_impulse" in player.params:
impulse_strength = player.params.shotgun_jump_impulse
# Impulse direction is opposite to the camera's forward direction
var push_dir = camera.global_transform.basis.z.normalized()
# Apply impulse to player velocity
var final_force = push_dir * impulse_strength
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
player.server_apply_impulse.rpc_id(1, final_force)
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 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
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
func _shoot_hitscan() -> void:
if not camera: return
var space_state = camera.get_world_3d().direct_space_state
var origin = camera.global_position
var forward = -camera.global_transform.basis.z.normalized()
var right = camera.global_transform.basis.x.normalized()
var up = camera.global_transform.basis.y.normalized()
var num_pellets = 12
var max_range = 50.0
var base_dmg = 15.0
var min_dmg = 3.0
var falloff_start = 8.0
var spread_angle = 0.08 # Radians
for i in range(num_pellets):
# Random spread inside cone
var r = sqrt(randf()) * spread_angle
var theta = randf() * PI * 2.0
var offset_dir = (right * cos(theta) + up * sin(theta)) * r
var pellet_dir = (forward + offset_dir).normalized()
var target = origin + pellet_dir * max_range
var query = PhysicsRayQueryParameters3D.create(origin, target)
if player:
query.exclude = [player.get_rid()]
var final_target = target
var result = space_state.intersect_ray(query)
if result:
final_target = result.position
var hit_dist = origin.distance_to(result.position)
var damage = base_dmg
if hit_dist > falloff_start:
var falloff_factor = clampf((hit_dist - falloff_start) / (max_range - falloff_start), 0.0, 1.0)
damage = lerpf(base_dmg, min_dmg, falloff_factor)
if result.collider.has_method("apply_impulse"):
result.collider.apply_impulse(pellet_dir * 2.5) # Apply strong physical force for ragdoll
if result.collider.has_method("take_damage"):
result.collider.take_damage(damage, result.position, player)
elif result.collider is StaticBody3D or result.collider is CSGShape3D:
# ImpactSpawner not available in this build, skip
pass
# Cel-styled cosmetic tracer per pellet
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)