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]>
240 lines
7.8 KiB
GDScript
240 lines
7.8 KiB
GDScript
extends Node3D
|
|
class_name BaseHitscanWeapon
|
|
|
|
@export var weapon_name: String = "Base Hitscan"
|
|
@export var fire_rate: float = 0.1 # Seconds between shots
|
|
@export var max_ammo: int = 30
|
|
@export var reload_time: float = 1.5
|
|
@export var base_damage: float = 10.0
|
|
@export var min_damage: float = 5.0
|
|
@export var falloff_start: float = 10.0
|
|
@export var max_range: float = 100.0
|
|
@export var automatic: bool = true
|
|
@export var spread_angle: float = 0.01
|
|
@export var recoil_amplitude: float = 0.05
|
|
|
|
@export var penetration_count: int = 0
|
|
@export var penetration_damage_penalty: float = 0.5
|
|
|
|
## Reload choreography style (see ViewmodelAnim): mag, boltmag, break,
|
|
## tube, cell, none.
|
|
@export var reload_style: String = "mag"
|
|
|
|
var current_ammo: int = 30
|
|
var reloading: bool = false
|
|
var reload_timer: float = 0.0
|
|
var fire_cooldown: float = 0.0
|
|
var is_firing: bool = false
|
|
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
|
|
|
|
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:
|
|
current_ammo = max_ammo
|
|
set_process_input(true)
|
|
set_process(true)
|
|
|
|
model_root = Node3D.new()
|
|
add_child(model_root)
|
|
_build_model()
|
|
|
|
func _build_model() -> void:
|
|
# Virtual method to be overridden
|
|
pass
|
|
|
|
func _process(delta: float) -> void:
|
|
if fire_cooldown > 0.0:
|
|
fire_cooldown -= delta
|
|
|
|
if reloading:
|
|
# The manual-of-arms animation runs via ViewmodelAnim; here we just
|
|
# track the timer and hand the ammo over at the end.
|
|
reload_timer -= delta
|
|
if reload_timer <= 0.0:
|
|
current_ammo = max_ammo
|
|
reloading = false
|
|
elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0:
|
|
_fire()
|
|
|
|
# Per-shot recoil: the gun slides back into the grip and the muzzle kicks
|
|
# up, then springs back. Scaled by this weapon's recoil so an AWP slams
|
|
# while an MP7 chatters.
|
|
if not reloading and _vm_kick > 0.001:
|
|
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-14.0 * delta))
|
|
ViewmodelAnim.apply_kick(self, _vm_kick, recoil_amplitude)
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
|
return
|
|
|
|
if event.is_action_pressed("fire"):
|
|
is_firing = true
|
|
if not automatic and fire_cooldown <= 0.0 and current_ammo > 0 and not reloading:
|
|
_fire()
|
|
elif current_ammo == 0 and not reloading:
|
|
_start_reload()
|
|
elif event.is_action_released("fire"):
|
|
is_firing = false
|
|
|
|
if event.is_action_pressed("reload"):
|
|
if current_ammo < max_ammo and not reloading:
|
|
_start_reload()
|
|
|
|
func _start_reload() -> void:
|
|
reloading = true
|
|
reload_timer = reload_time
|
|
is_firing = false
|
|
_vm_kick = 0.0
|
|
ViewmodelAnim.play_reload(self, reload_style, 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 _fire() -> void:
|
|
if current_ammo <= 0 or reloading:
|
|
return
|
|
|
|
current_ammo -= 1
|
|
fire_cooldown = fire_rate
|
|
_vm_kick = minf(_vm_kick + 1.0, 1.4)
|
|
|
|
_shoot_hitscan()
|
|
_play_muzzle_flash()
|
|
|
|
if get_parent() and get_parent().has_method("add_recoil"):
|
|
get_parent().add_recoil(recoil_amplitude, randf_range(-recoil_amplitude * 0.5, recoil_amplitude * 0.5))
|
|
|
|
if fire_sound:
|
|
# Slight pitch variation
|
|
fire_sound.pitch_scale = 1.0 + randf_range(-0.05, 0.05)
|
|
fire_sound.play()
|
|
|
|
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 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
|
|
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
|
|
|
|
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()
|
|
|
|
# Calculate spread
|
|
var r = sqrt(randf()) * spread_angle
|
|
var theta = randf() * PI * 2.0
|
|
var offset_dir = (right * cos(theta) + up * sin(theta)) * r
|
|
var ray_dir = (forward + offset_dir).normalized()
|
|
|
|
var target = origin + ray_dir * max_range
|
|
var query = PhysicsRayQueryParameters3D.create(origin, target)
|
|
|
|
if player:
|
|
query.exclude = [player.get_rid()]
|
|
|
|
var hits_remaining = penetration_count + 1
|
|
var current_damage_mult = 1.0
|
|
var final_target = target
|
|
|
|
while hits_remaining > 0:
|
|
var result = space_state.intersect_ray(query)
|
|
if not result:
|
|
break # Nothing else in the way
|
|
|
|
var hit_dist = origin.distance_to(result.position)
|
|
var damage = base_damage * current_damage_mult
|
|
|
|
if hit_dist > falloff_start:
|
|
var falloff_factor = clampf((hit_dist - falloff_start) / (max_range - falloff_start), 0.0, 1.0)
|
|
damage = lerpf(base_damage * current_damage_mult, min_damage * current_damage_mult, falloff_factor)
|
|
|
|
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:
|
|
# Not a target, spawn a bullet hole
|
|
ImpactSpawner.spawn(get_tree(), "bullet", result.position, result.normal, 0.1)
|
|
|
|
# Add this hit to exclude so next loop goes through it
|
|
var exc = query.exclude
|
|
exc.append(result.rid)
|
|
|
|
# If the collider has multiple shapes, we should really exclude the whole collision object
|
|
# but RID of the body should be enough.
|
|
query.exclude = exc
|
|
|
|
hits_remaining -= 1
|
|
current_damage_mult *= penetration_damage_penalty
|
|
final_target = result.position
|
|
_spawn_tracer(origin, final_target)
|
|
|
|
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
|
# 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
|
|
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()
|