Files
Papay-Shooter/movement/player_movement_controller.gd
T
Nicholas ButzkeandClaude Opus 4.8 0d125dc03f fix: third-person gun actually held — IK to the weapon, real recoil, no flip
Four separate third-person defects, one shared root: the arms were posed at
art-directed ANGLES that merely approximated the gun, and a stuck one-shot
flag kept handing them back to the animation clip.

- The rifle hold was silently DEAD after the first reload or throw. The gate
  trusted AnimationNodeOneShot's `active` parameter, which never clears, so
  clip_owns_arms stayed true forever and the clip drove the arms while the
  gun hung off the hand. `_upper_lock` (a timer we own) is now the
  authority, and the one-shot is explicitly faded out when it expires.
  This alone is why the gun was never held correctly.
- Arms are now solved with real two-bone IK (`_ik_arm`) onto points derived
  from the WEAPON: the stock is anchored in the shoulder pocket, the grip
  and foregrip fall out along the barrel, and the support hand is placed on
  the actual handguard (sliding inboard if the model is too long to reach).
  Verified numerically: the hand lands within 2mm of its target.
- `_measure_weapon` measures the model's AABB along its barrel and re-seats
  it so the hand sits at a realistic pistol-grip point. Weapon models put
  their origin anywhere — the M4's was 10cm from the muzzle end, so parking
  its stock in the shoulder drove the hand INTO the shoulder and folded the
  arm up behind the head.
- Recoil now shows for the OWNER in third person: server_play_fire_effects
  only ever fired for remote shooters, so the local player saw nothing. The
  ammo counter dropping now kicks the model. The kick rides in the gun's aim
  direction so the IK carries BOTH hands up with it, instead of rotating the
  arms and shoving the support hand off the gun.
- Reload no longer flips the gun. The library's pistol-reload rotates the
  wrist the gun is parented to, which turned the rifle upside-down (mag to
  the sky) while the hand reached down for it. The hold now keeps the right
  arm through a reload and the support hand does the magazine work at the
  real mag well, under the receiver.

anim_capture gains recoil shots and lets ADS settle before framing;
debug/dump_bones.gd prints a skin's bone hierarchy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 10:15:54 -04:00

1539 lines
54 KiB
GDScript

extends CharacterBody3D
class_name PlayerMovementController
signal chain_updated(count: int, bonus: float)
@export var params: MovementParams = preload("res://movement/movement_params.gd").new()
var _machine: MovementStateMachine = null
var head_pivot: Node3D = null # FPSCameraRig node
var camera: Camera3D = null
var flashlight: SpotLight3D = null
var _damage_layer: CanvasLayer = null
# Third-person view toggle (local player only). The first-person camera keeps
# handling aim/firing; the toggle only changes which camera renders and whether
# the owner sees their own model.
var third_person: bool = false
var _tp_camera: Camera3D = null
# Health and Shield
var max_health: float = 100.0
var health: float = 100.0
var max_shield: float = 100.0
var shield: float = 100.0
var time_since_last_damage: float = 0.0
var death_count: int = 0
var is_dead: bool = false
# UI
var health_bar: ProgressBar
var shield_bar: ProgressBar
var health_label: Label
var shield_label: Label
var death_screen: Control
var ragdoll_instance: Node3D
# Audio
var footstep_player: AudioStreamPlayer
var footstep_streams: Array = []
var land_player: AudioStreamPlayer
var _remote_footstep_timer: float = 0.0
# Networked one-shot animation actions (reload/throw): the owner bumps the
# sequence; remote peers play the named action when it changes.
@export var synced_action: String = ""
@export var synced_action_seq: int = 0
var _last_action_seq: int = 0
var _was_reloading: bool = false
## Ammo count last frame — a drop means we fired, which kicks the owner's
## own third-person model (remote shooters go through server_play_fire_effects).
var _last_local_ammo: int = -1
var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer
var slide_player: AudioStreamPlayer
var wallrun_player: AudioStreamPlayer
var wind_player: AudioStreamPlayer
var jump_player: AudioStreamPlayer
var double_jump_player: AudioStreamPlayer
var vault_player: AudioStreamPlayer
var grapple_shoot_player: AudioStreamPlayer
var grapple_latch_player: AudioStreamPlayer
var grapple_swing_player: AudioStreamPlayer
# UI
var hit_marker: Control
var _hit_marker_tween: Tween
var reload_ring: Control
var grenades: int = 2
var is_holding_grenade: bool = false
var trajectory_mesh: MultiMeshInstance3D
# Grapple
var grapple_rope: MeshInstance3D
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
# These are written by the authority each frame and replicated to remote peers
# via the MultiplayerSynchronizer properties.
# Movement is CLIENT-AUTHORITATIVE: the owning peer simulates locally and
# broadcasts position/velocity; remote peers interpolate toward it (see
# _process). The server stays authoritative for health, shield, and kills.
var synced_movement_state: String = "idle"
var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false
var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
var synced_is_dancing: bool = false # dance emote (B), shown on the model
# Anime speed-lines overlay (local player only)
var _speedlines: ColorRect = null
var _speedline_burst: float = 0.0
@export var synced_skin_id: String = ""
@export var synced_weapon_path: String = ""
# Remote interpolation tuning
const NET_LERP_RATE := 18.0 # exponential smoothing rate for remote players
const NET_SNAP_DISTANCE := 6.0 # teleport if desync exceeds this (respawns etc.)
const NET_EXTRAPOLATION := 0.05 # seconds of velocity extrapolation
var skinned_model: Node3D = null
var _applied_skin_id: String = "__none__"
@export var synced_grapple_point: Vector3 = Vector3.ZERO
@export var synced_is_grapple_shooting: bool = false
# For homing rocket
@export var synced_is_targeting: bool = false
@export var synced_homing_target_pos: Vector3 = Vector3.ZERO
var synced_loadout_p1: String = ""
var synced_loadout_p2: String = ""
var synced_loadout_sp: String = ""
var synced_loadout_melee: String = ""
var synced_loadout_ready: bool = false
func _ready() -> void:
var sm := _ensure_machine()
if sm:
sm.player = self
sm.params = params
sm.movement_event.connect(_on_movement_event)
# Find camera rig
head_pivot = get_node_or_null("HeadPivot")
if head_pivot:
camera = head_pivot.get_node_or_null("Camera3D")
if camera:
flashlight = SpotLight3D.new()
flashlight.spot_range = 100.0
flashlight.spot_angle = 35.0
flashlight.light_energy = 5.0
flashlight.light_volumetric_fog_energy = 0.0 # Prevents the blinding fog glare and laggy trails
flashlight.shadow_enabled = false
flashlight.position = Vector3(0, 0, 0)
flashlight.visible = false
camera.add_child(flashlight)
_setup_audio()
_setup_grapple()
_setup_grenade_trajectory()
if is_multiplayer_authority():
_damage_layer = CanvasLayer.new()
add_child(_damage_layer)
_setup_hit_marker()
_setup_hud()
_setup_speedlines()
else:
call_deferred("_hide_remote_weapons")
set_process(true)
if is_multiplayer_authority():
set_physics_process(true)
var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr:
synced_skin_id = skin_mgr.active_skin_id
synced_position = position
_setup_third_person_camera()
# Build the visual model for whatever skin is selected (remote peers get
# the id via the synchronizer and rebuild in _process when it arrives).
_apply_skin_model(synced_skin_id)
## Over-the-shoulder camera for the local player, on a spring arm so it doesn't
## clip through walls. The first-person camera still exists and handles firing;
## this one only renders when third_person is on.
func _setup_third_person_camera() -> void:
if not head_pivot or not is_instance_valid(camera):
return
# Chain: head_pivot > OrbitPivot (Alt free-look, springs back to zero)
# > ShoulderOffset (camera right of the spine — the character sits
# slightly LEFT of screen centre, Fortnite-style) > SpringArm.
var orbit := Node3D.new()
orbit.name = "OrbitPivot"
head_pivot.add_child(orbit)
var shoulder := Node3D.new()
shoulder.name = "ShoulderOffset"
shoulder.position = Vector3(0.55, 0.12, 0.0)
orbit.add_child(shoulder)
var boom := SpringArm3D.new()
boom.name = "ThirdPersonBoom"
boom.spring_length = 2.6
boom.margin = 0.3
boom.collision_mask = 1 # environment only
boom.add_excluded_object(get_rid())
# Aim the arm up-and-back from the shoulder so the camera sits behind/above.
boom.rotation_degrees = Vector3(14, 0, 0)
shoulder.add_child(boom)
_tp_camera = Camera3D.new()
_tp_camera.name = "ThirdPersonCamera"
_tp_camera.fov = camera.fov
_tp_camera.current = false
# Never render the first-person viewmodel layer (20) — otherwise the gun
# and arms float in front of the third-person view.
_tp_camera.cull_mask &= ~(1 << 19)
boom.add_child(_tp_camera)
## Toggle between first- and third-person. Firing keeps using the first-person
## camera, so aim is unchanged; only rendering and self-visibility change.
func set_third_person(on: bool) -> void:
if not is_multiplayer_authority():
return
third_person = on
if is_instance_valid(_tp_camera):
_tp_camera.fov = SettingsManager.world_fov # stay in sync with settings
_tp_camera.current = on
if is_instance_valid(camera):
camera.current = not on
# Reveal / hide the owner's own model.
var visual = get_visual_model()
if visual and visual.has_method("set_owner_visible"):
visual.set_owner_visible(on)
# Hide the first-person weapon viewmodel in third person (it would float in
# the middle of the screen); the third-person weapon on the model shows.
if is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer):
wman.canvas_layer.visible = not on
## Returns the node that visually represents this player (skinned GLB model
## if the active skin has one, otherwise the procedural HumanoidModel).
func get_visual_model() -> Node3D:
if is_instance_valid(skinned_model):
return skinned_model
return get_node_or_null("HumanoidModel")
## Swap the visual model to match a skin id. GLB skins replace the procedural
## model entirely; color skins tint the procedural model.
func _apply_skin_model(skin_id: String) -> void:
_applied_skin_id = skin_id
var humanoid = get_node_or_null("HumanoidModel")
var old = get_node_or_null("SkinnedModel")
if old:
remove_child(old)
old.queue_free()
skinned_model = null
var skin_mgr = get_node_or_null("/root/SkinManager")
var skin = skin_mgr.get_skin(skin_id) if skin_mgr else null
var has_model: bool = skin != null and skin.model_path != "" \
and (ResourceLoader.exists(skin.model_path) or FileAccess.file_exists(skin.model_path))
if has_model:
var model := SkinnedPlayerModel.new()
model.name = "SkinnedModel"
model.model_path = skin.model_path
# Owner gets the first-person body view (head hidden, fully animated);
# everyone else sees the full third-person model.
model.first_person_mode = is_multiplayer_authority()
model.position = Vector3(0, -0.9, 0) # capsule center -> feet
add_child(model)
skinned_model = model
if humanoid:
humanoid.visible = false
else:
if humanoid:
humanoid.visible = not is_dead
if skin and humanoid.has_method("_apply_color"):
humanoid._apply_color(skin.color_tint)
func _hide_remote_weapons() -> void:
if camera:
var wman = camera.get_node_or_null("WeaponManager")
if wman:
wman.visible = false
wman.process_mode = Node.PROCESS_MODE_DISABLED
var cv = wman.get("canvas_layer")
if cv:
cv.visible = false
func _setup_audio() -> void:
footstep_player = AudioStreamPlayer.new()
footstep_player.bus = "SFX"
for path in ["res://assets/sounds/footstep.wav", "res://assets/sounds/footstep_01.wav",
"res://assets/sounds/footstep_02.wav", "res://assets/sounds/footstep_03.wav",
"res://assets/sounds/footstep_04.wav"]:
if ResourceLoader.exists(path):
footstep_streams.append(load(path))
footstep_player.stream = footstep_streams[0] if not footstep_streams.is_empty() else null
add_child(footstep_player)
land_player = AudioStreamPlayer.new()
land_player.bus = "SFX"
land_player.stream = load("res://assets/sounds/land.wav")
add_child(land_player)
jump_player = AudioStreamPlayer.new()
jump_player.bus = "SFX"
jump_player.stream = load("res://assets/sounds/jump.wav")
add_child(jump_player)
double_jump_player = AudioStreamPlayer.new()
double_jump_player.bus = "SFX"
jump_player.bus = "SFX"
double_jump_player.stream = load("res://assets/sounds/double_jump.wav")
add_child(double_jump_player)
vault_player = AudioStreamPlayer.new()
vault_player.bus = "SFX"
vault_player.stream = load("res://assets/sounds/vault.wav")
add_child(vault_player)
dash_player = AudioStreamPlayer.new()
dash_player.bus = "SFX"
dash_player.stream = load("res://assets/sounds/dash.wav")
dash_player.volume_db = -5.0
add_child(dash_player)
hit_player = AudioStreamPlayer.new()
hit_player.bus = "SFX"
hit_player.stream = load("res://assets/sounds/hit_confirm.wav")
hit_player.max_polyphony = 8
add_child(hit_player)
slide_player = AudioStreamPlayer.new()
slide_player.bus = "SFX"
slide_player.stream = load("res://assets/sounds/slide.wav")
slide_player.volume_db = -10.0
add_child(slide_player)
wallrun_player = AudioStreamPlayer.new()
wallrun_player.bus = "SFX"
wallrun_player.stream = load("res://assets/sounds/wallrun.wav")
wallrun_player.volume_db = -10.0
add_child(wallrun_player)
wind_player = AudioStreamPlayer.new()
wind_player.bus = "Wind"
wind_player.stream = load("res://assets/sounds/wind.wav")
wind_player.volume_db = -80.0 # Start silent
add_child(wind_player)
wind_player.play()
grapple_shoot_player = AudioStreamPlayer.new()
grapple_shoot_player.bus = "SFX"
grapple_shoot_player.stream = load("res://assets/sounds/dash.wav")
grapple_shoot_player.volume_db = -5.0
grapple_shoot_player.pitch_scale = 1.5
add_child(grapple_shoot_player)
grapple_latch_player = AudioStreamPlayer.new()
grapple_latch_player.bus = "SFX"
grapple_latch_player.stream = load("res://assets/sounds/hit_confirm.wav")
grapple_latch_player.pitch_scale = 0.8
add_child(grapple_latch_player)
grapple_swing_player = AudioStreamPlayer.new()
grapple_swing_player.bus = "SFX"
grapple_swing_player.stream = load("res://assets/sounds/wind.wav")
grapple_swing_player.pitch_scale = 1.5
grapple_swing_player.volume_db = -80.0
add_child(grapple_swing_player)
grapple_swing_player.play()
func _setup_speedlines() -> void:
_speedlines = ColorRect.new()
_speedlines.name = "SpeedLines"
_speedlines.set_anchors_preset(Control.PRESET_FULL_RECT)
_speedlines.mouse_filter = Control.MOUSE_FILTER_IGNORE
var mat := ShaderMaterial.new()
mat.shader = load("res://assets/shaders/speed_lines.gdshader")
mat.set_shader_parameter("intensity", 0.0)
_speedlines.material = mat
_damage_layer.add_child(_speedlines)
func _setup_hit_marker() -> void:
hit_marker = Control.new()
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
hit_marker.modulate.a = 0.0 # Hidden by default
_damage_layer.add_child(hit_marker)
# Draw an X perfectly centered
var length = 12
var thickness = 2
for angle in [PI/4, 3*PI/4, 5*PI/4, 7*PI/4]:
var rect = ColorRect.new()
rect.color = Color.WHITE
rect.size = Vector2(length, thickness)
rect.pivot_offset = rect.size / 2.0
# Center the rect itself at (0,0) before offset
var center_pos = -rect.size / 2.0
# Move it outward along the angle so it doesn't cover the exact center dot
rect.position = center_pos + Vector2(cos(angle), sin(angle)) * (length / 2.0)
rect.rotation = angle
hit_marker.add_child(rect)
# Reload Ring
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.set_anchors_preset(Control.PRESET_CENTER)
_damage_layer.add_child(reload_ring)
func _setup_grapple() -> void:
grapple_rope = MeshInstance3D.new()
var rope_mesh = CylinderMesh.new()
rope_mesh.top_radius = 0.04
rope_mesh.bottom_radius = 0.04
rope_mesh.height = 1.0 # Will be scaled dynamically
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.1, 0.1, 0.1) # Dark grey cable
mat.roughness = 0.8
rope_mesh.material = mat
grapple_rope.mesh = rope_mesh
grapple_rope.visible = false
get_tree().current_scene.call_deferred("add_child", grapple_rope)
func _setup_grenade_trajectory() -> void:
trajectory_mesh = MultiMeshInstance3D.new()
var mm = MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_3D
mm.instance_count = 60
trajectory_mesh.multimesh = mm
var dot_mesh = SphereMesh.new()
dot_mesh.radius = 0.05
dot_mesh.height = 0.1
var dot_mat = StandardMaterial3D.new()
dot_mat.albedo_color = Color(1.0, 1.0, 1.0, 0.5)
dot_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
dot_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
dot_mesh.material = dot_mat
trajectory_mesh.multimesh.mesh = dot_mesh
trajectory_mesh.visible = false
get_tree().current_scene.call_deferred("add_child", trajectory_mesh)
var _last_hit_sound_time: int = 0
func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
if not camera or not _damage_layer: return
var label = load("res://ui/floating_damage_text.gd").new()
label.text = str(round(amount))
label.add_theme_font_size_override("font_size", 24)
label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1))
label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
label.add_theme_constant_override("outline_size", 4)
# Small random offset in 3D space so multiple pellets don't perfectly overlap
var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
label.target_pos = hit_pos + offset
label.camera = camera
_damage_layer.add_child(label)
# Only play the hit sound and flash the marker once per frame (or roughly every 16ms)
# This prevents the shotgun from stacking 8 hit sounds in a single frame.
var now = Time.get_ticks_msec()
if now - _last_hit_sound_time > 10:
_last_hit_sound_time = now
hit_player.play()
if _hit_marker_tween and _hit_marker_tween.is_valid():
_hit_marker_tween.kill()
hit_marker.modulate.a = 1.0
_hit_marker_tween = create_tween()
_hit_marker_tween.tween_property(hit_marker, "modulate:a", 0.0, 0.4)
func apply_impulse(force: Vector3) -> void:
velocity += force
# Force an upward launch to break ground contact
if force.y > 0.1:
# Tiny upward bump helps move_and_slide detach from the floor
global_position.y += 0.1
@rpc("any_peer", "call_local", "reliable")
func server_apply_impulse(force: Vector3) -> void:
if not multiplayer.is_server(): return
var sender = multiplayer.get_remote_sender_id()
if sender != 1 and sender != str(name).to_int(): return
# Movement is client-authoritative, so the impulse must land on the peer
# that simulates this body.
if is_multiplayer_authority():
apply_impulse(force)
else:
authority_apply_impulse.rpc_id(get_multiplayer_authority(), force)
@rpc("any_peer", "call_local", "reliable")
func authority_apply_impulse(force: Vector3) -> void:
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
if multiplayer.get_remote_sender_id() != 1: return
if is_multiplayer_authority():
apply_impulse(force)
func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void:
var attacker_id = 0
var weapon_name = "Killed"
if _source:
if _source.has_method("get_multiplayer_authority"):
attacker_id = _source.get_multiplayer_authority()
elif "owner_player" in _source and _source.owner_player:
attacker_id = int(str(_source.owner_player.name))
elif _source is CharacterBody3D:
attacker_id = int(str(_source.name))
if _source.has_method("get_weapon_name"):
weapon_name = _source.get_weapon_name()
# If this is singleplayer offline, just call it directly
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
rpc_take_damage(amount, _hit_pos, attacker_id, weapon_name, impulse)
else:
# Tell the server to handle the damage and broadcast it to everyone!
server_take_damage.rpc_id(1, amount, _hit_pos, attacker_id, weapon_name, impulse)
@rpc("any_peer", "call_local", "unreliable")
func server_play_fire_effects(origin: Vector3, target_or_dir: Vector3, weapon_name: String, is_hitscan: bool) -> void:
if not multiplayer.is_server(): return
rpc_play_fire_effects.rpc(origin, target_or_dir, weapon_name, is_hitscan)
@rpc("any_peer", "call_local", "unreliable")
func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name: String, is_hitscan: bool) -> void:
# Local player already spawned effects locally with 0 latency
if is_multiplayer_authority(): return
# Positional gunshot for the remote shooter — occlusion + reflections via
# AudioManager make it locatable ("that came from behind the wooden wall").
var am = get_tree().root.get_node_or_null("AudioManager")
if am:
var sid: String = am.weapon_sound_id(_weapon_name)
if sid != "" and am.has_sound(sid):
am.play_3d(sid, origin)
# Visible shot kick on the remote shooter's model
var rv = get_visual_model()
if rv and rv.has_method("add_gun_recoil"):
rv.add_gun_recoil()
if is_hitscan:
# Spawn a standalone tracer directly into the scene
_spawn_remote_tracer(origin, target_or_dir)
else:
# Spawn a visual-only projectile flying from origin along the direction
_spawn_remote_projectile(origin, target_or_dir, _weapon_name)
func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
HitscanTracer.spawn_bolt(get_tree().current_scene, origin, target)
func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void:
# Try to find the actual weapon to spawn its custom projectile so it looks identical!
if camera:
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var target_weapon = null
for w in wman.weapons.values():
if "weapon_name" in w and w.weapon_name == weapon_name:
target_weapon = w
break
if target_weapon and target_weapon.has_method("_spawn_custom_projectile"):
target_weapon._spawn_custom_projectile(origin, fire_dir)
return
# Generic fallback if weapon not found (e.g. joined late and weapons didn't build yet)
var proj = Node3D.new()
proj.name = "RemoteProjectile"
if weapon_name == "Plasma Gun":
var mesh_instance = MeshInstance3D.new()
var p_mesh = SphereMesh.new()
p_mesh.radius = 0.08
p_mesh.height = 0.16
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.6, 1.0)
mat.emission_enabled = true
mat.emission = Color(0.2, 0.6, 1.0)
mat.emission_energy_multiplier = 5.0
p_mesh.material = mat
mesh_instance.mesh = p_mesh
proj.add_child(mesh_instance)
var light = OmniLight3D.new()
light.light_color = Color(0.2, 0.6, 1.0)
light.light_energy = 2.0
light.omni_range = 4.0
proj.add_child(light)
else:
# Generic rocket fallback
var mesh_inst = MeshInstance3D.new()
var p_mesh = CylinderMesh.new()
p_mesh.top_radius = 0.0
p_mesh.bottom_radius = 0.05
p_mesh.height = 0.2
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.8, 0.2, 0.2)
mat.emission_enabled = true
mat.emission = Color(1.5, 0.3, 0.1)
p_mesh.material = mat
mesh_inst.mesh = p_mesh
mesh_inst.rotation.x = deg_to_rad(-90)
proj.add_child(mesh_inst)
var smoke = GPUParticles3D.new()
var smoke_mat = ParticleProcessMaterial.new()
smoke_mat.direction = Vector3(0, 0, 1)
smoke_mat.spread = 5.0
smoke_mat.initial_velocity_min = 0.0
smoke_mat.initial_velocity_max = 0.5
smoke_mat.gravity = Vector3(0, 0.5, 0)
smoke.process_material = smoke_mat
smoke.amount = 20
smoke.lifetime = 0.5
smoke.position = Vector3(0, 0, 0.1)
var smoke_mesh = SphereMesh.new()
smoke_mesh.radius = 0.04
var smoke_mesh_mat = StandardMaterial3D.new()
smoke_mesh_mat.albedo_color = Color(0.5, 0.5, 0.5, 0.6)
smoke_mesh_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
smoke_mesh.material = smoke_mesh_mat
smoke.draw_pass_1 = smoke_mesh
proj.add_child(smoke)
# Orient the projectile in the direction of fire
var up_vec = Vector3.UP
if abs(fire_dir.normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
get_tree().current_scene.add_child(proj)
proj.global_position = origin
proj.look_at(origin + fire_dir, up_vec)
# Animate movement using tween at appropriate speed
var speed = 35.0 if weapon_name != "Plasma Gun" else 80.0
var end_pos = origin + fire_dir.normalized() * (speed * 5.0)
var tween = get_tree().create_tween()
tween.tween_property(proj, "global_position", end_pos, 5.0)
tween.tween_callback(proj.queue_free)
@rpc("any_peer", "call_local", "reliable")
func server_play_explosion(pos: Vector3, radius: float) -> void:
if not multiplayer.is_server(): return
rpc_play_explosion.rpc(pos, radius)
@rpc("any_peer", "call_local", "reliable")
func rpc_play_explosion(pos: Vector3, radius: float) -> void:
if is_multiplayer_authority(): return
ExplosionVFX.spawn(get_tree(), pos, radius)
@rpc("any_peer", "call_local", "reliable")
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if not multiplayer.is_server(): return
# Broadcast damage event to all peers so the victim dies on all screens.
# Knockback is applied inside rpc_take_damage on the victim's own peer,
# since that peer simulates this body.
rpc_take_damage.rpc(amount, hit_pos, attacker_id, weapon_name, impulse)
@rpc("any_peer", "call_local", "reliable")
func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if is_dead:
if is_instance_valid(ragdoll_instance):
ragdoll_instance.apply_initial_velocities(Vector3.ZERO, impulse)
return
# Show damage number for the attacker
if not multiplayer.has_multiplayer_peer() or attacker_id == multiplayer.get_unique_id():
if is_multiplayer_authority():
spawn_damage_number(amount, hit_pos)
else:
var my_node = get_parent().get_node_or_null(str(attacker_id))
if my_node and my_node.has_method("spawn_damage_number"):
my_node.spawn_damage_number(amount, hit_pos)
# Show damage indicator and screen shake for the victim
if is_multiplayer_authority() and amount > 0.0 and (not multiplayer.has_multiplayer_peer() or attacker_id != multiplayer.get_unique_id()):
_show_damage_indicator(hit_pos)
if attacker_id != 0:
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
# Flinch: the victim's model plays a hit reaction on every screen.
if amount > 0.0:
var vm = get_visual_model()
if vm and vm.has_method("play_oneshot"):
vm.play_oneshot("Hit", 0.3)
# Knockback lands on the simulating peer (movement is client-authoritative)
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
apply_impulse(impulse)
time_since_last_damage = 0.0
if shield > 0.0:
if shield >= amount:
shield -= amount
amount = 0.0
else:
amount -= shield
shield = 0.0
if amount > 0.0:
health -= amount
if health <= 0.0:
health = 0.0
die(impulse)
if multiplayer.is_server():
var nm = get_node_or_null("/root/NetworkManager")
if nm and multiplayer.has_multiplayer_peer():
var killer_id = attacker_id
var assist_ids: Array = []
var now = Time.get_ticks_msec() / 1000.0
for aid in recent_attackers.keys():
if aid != killer_id and now - recent_attackers[aid] <= 10.0:
assist_ids.append(aid)
var real_victim_name = "Player " + str(name)
if "player_name" in self and self.player_name != "":
real_victim_name = self.player_name
elif str(name).to_int() == 1:
real_victim_name = "Host"
if nm.has_method("register_kill"):
var victim_id = str(name).to_int()
if victim_id == 0:
victim_id = 1 # Fallback to host if something is weird
nm.register_kill.rpc(victim_id, killer_id, weapon_name, real_victim_name, assist_ids)
func _show_damage_indicator(hit_pos: Vector3) -> void:
if not _damage_layer: return
var indicator = Polygon2D.new()
indicator.polygon = PackedVector2Array([
Vector2(0, -100),
Vector2(-20, -75),
Vector2(20, -75)
])
indicator.color = Color(1.0, 0.0, 0.0, 0.8)
indicator.position = get_viewport().get_visible_rect().size / 2.0
var forward = -global_transform.basis.z
forward.y = 0.0
if forward.length_squared() > 0.01:
forward = forward.normalized()
var right = global_transform.basis.x
right.y = 0.0
right = right.normalized()
var dir_to_hit = (hit_pos - global_position)
dir_to_hit.y = 0.0
if dir_to_hit.length_squared() > 0.01:
dir_to_hit = dir_to_hit.normalized()
var angle = atan2(dir_to_hit.dot(right), dir_to_hit.dot(forward))
indicator.rotation = angle
_damage_layer.add_child(indicator)
var tween = create_tween()
tween.tween_property(indicator, "color:a", 0.0, 1.0)
tween.tween_callback(indicator.queue_free)
if camera:
var shake_tween = create_tween()
shake_tween.tween_property(camera, "h_offset", randf_range(-0.2, 0.2), 0.05)
shake_tween.parallel().tween_property(camera, "v_offset", randf_range(-0.2, 0.2), 0.05)
shake_tween.chain().tween_property(camera, "h_offset", 0.0, 0.05)
shake_tween.parallel().tween_property(camera, "v_offset", 0.0, 0.05)
func get_weapon_name() -> String:
if camera:
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var slot = wman.get("active_slot")
if slot != null and wman.weapons.has(slot):
var w = wman.weapons[slot]
if w and w.has_method("get_weapon_name"):
return w.get_weapon_name()
elif w:
var p = ""
if w.has_meta("script_path"):
p = w.get_meta("script_path").get_file().get_basename()
elif w.get_script():
p = w.get_script().resource_path.get_file().get_basename()
else:
p = "Killed"
return p.capitalize()
return "Killed"
func _ensure_machine() -> MovementStateMachine:
if is_instance_valid(_machine):
return _machine
if has_node("MovementStateMachine"):
var node = $MovementStateMachine
if node and node.has_method("switch_to") and node.has_method("register_chain_mechanic"):
_machine = node
return _machine
_machine = null
return null
func _physics_process(_delta: float) -> void:
# The owning peer reads input and feeds its own state machine directly.
# No server round-trip: movement responds on the same frame it's pressed.
if is_multiplayer_authority():
var raw_input := Vector2.ZERO
var input_jump := false
var input_jump_just := false
var input_crouch := false
var input_dash := false
var input_grapple := false
var input_grapple_just := false
var world_dir := Vector3.ZERO
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
raw_input = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
var right := global_transform.basis.x
right.y = 0.0
right = right.normalized()
world_dir = (forward * (-raw_input.y) + right * raw_input.x)
if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized()
input_jump = Input.is_action_pressed("jump")
input_jump_just = Input.is_action_just_pressed("jump")
input_crouch = Input.is_action_pressed("crouch")
input_dash = Input.is_action_just_pressed("dash")
input_grapple = Input.is_action_pressed("grapple")
input_grapple_just = Input.is_action_just_pressed("grapple")
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
flashlight.visible = !flashlight.visible
if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person)
# Dance emote (B): toggles while grounded and idle-ish; any
# movement/jump/crouch input breaks it.
if Input.is_action_just_pressed("emote"):
var m := _ensure_machine()
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
if not synced_is_dancing and m and m.current_state == "ground" and slow:
synced_is_dancing = true
else:
synced_is_dancing = false
if synced_is_dancing:
var m2 := _ensure_machine()
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
var airborne: bool = m2 and m2.current_state != "ground"
if moving or airborne:
synced_is_dancing = false
var machine := _ensure_machine()
if machine:
machine.input_dir = raw_input
machine.wish_dir_world = world_dir
machine.input_jump_pressed = input_jump
machine.input_jump_just_pressed = input_jump_just
machine.input_crouch = input_crouch
machine.input_dash = input_dash
machine.input_grapple = input_grapple
machine.input_grapple_just_pressed = input_grapple_just
var speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
if not wind_player.playing and wind_factor > 0.0:
wind_player.play()
# Anime speed lines: fade in past ~1.2x walk speed, spike on dash.
if is_instance_valid(_speedlines):
_speedline_burst = maxf(_speedline_burst - _delta * 2.5, 0.0)
var hs := Vector2(velocity.x, velocity.z).length()
var speed_intensity := clampf((hs - params.walk_speed * 1.2) / 12.0, 0.0, 0.85)
var target := maxf(speed_intensity, _speedline_burst)
var mat := _speedlines.material as ShaderMaterial
var cur: float = mat.get_shader_parameter("intensity")
mat.set_shader_parameter("intensity", lerpf(cur, target, 1.0 - exp(-10.0 * _delta)))
var sm := _ensure_machine()
if not sm:
return
# The owning peer publishes its grapple state for everyone's rope visuals
if is_multiplayer_authority():
synced_grapple_point = sm.grapple_point
synced_is_grapple_shooting = sm.is_grapple_shooting
# Update grapple rope visuals (runs on all clients for smooth interpolation)
if (synced_movement_state == "grapple" or synced_is_grapple_shooting) and camera:
grapple_rope.visible = true
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var end_pos = synced_grapple_point
var dist = start_pos.distance_to(end_pos)
if dist > 0.01:
grapple_rope.global_position = (start_pos + end_pos) / 2.0
var up_vec = Vector3.UP
var dir = (end_pos - start_pos).normalized()
if abs(dir.dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
grapple_rope.transform.basis = Basis.looking_at(dir, up_vec) * Basis.from_euler(Vector3(PI/2.0, 0, 0))
grapple_rope.scale = Vector3(1.0, dist, 1.0)
else:
if grapple_rope:
grapple_rope.visible = false
if synced_movement_state == "grapple" and velocity.length() > 5.0:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
else:
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
# Local player's aim-down-sights state (drives the model's weapon raise).
synced_is_ads = _read_ads()
# Drive the visual model (skinned GLB or procedural) from local state
var visual = get_visual_model()
if visual:
var h_speed = Vector2(velocity.x, velocity.z).length()
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
if visual.has_method("set_locomotion"):
var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing)
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Publish state for remote peers
synced_movement_state = sm.current_state
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
synced_is_crouching = sm.input_crouch
synced_wall_side = sm.wall_side
synced_position = position
synced_velocity = velocity
## Movement direction relative to facing: x = strafe (+right), y = forward
## (+forward). Derived from velocity so it works for local and remote players.
func _local_move_dir() -> Vector2:
var hspeed := Vector2(velocity.x, velocity.z).length()
if hspeed < 0.5:
return Vector2.ZERO
var local_vel := global_transform.basis.inverse() * velocity
return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed)
## Whether the local player's active weapon is aiming down sights.
func _read_ads() -> bool:
if not is_instance_valid(camera):
return false
var wman = camera.get_node_or_null("WeaponManager")
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
var w = wman.weapons[wman.active_slot]
if w and "is_ads" in w:
return w.is_ads
return false
func _on_movement_event(ev: String, data: Dictionary) -> void:
if ev == "chain_updated":
chain_updated.emit(data.count, data.bonus)
elif ev == "grapple_shoot":
grapple_shoot_player.play()
elif ev == "grapple_latch":
grapple_latch_player.play()
elif ev == "dash":
_speedline_burst = 1.0
elif ev == "land":
if land_player:
var heavy: bool = data.get("heavy", false)
land_player.pitch_scale = (0.8 if heavy else 1.0) * randf_range(0.95, 1.05)
land_player.volume_db = 2.0 if heavy else -3.0
land_player.play()
func _process(delta: float) -> void:
# Remote players: interpolate toward the owner's synced transform and
# apply synced movement state to their visual model
if not is_multiplayer_authority():
if synced_loadout_ready and not has_meta("remote_weapons_built"):
set_meta("remote_weapons_built", true)
var wman = camera.get_node_or_null("WeaponManager")
if wman and wman.has_method("_build_remote_loadout"):
wman._build_remote_loadout(synced_loadout_p1, synced_loadout_p2, synced_loadout_sp, synced_loadout_melee)
# Skin can arrive/change after spawn — rebuild the model when it does
if synced_skin_id != _applied_skin_id:
_apply_skin_model(synced_skin_id)
# Snapshot interpolation: chase the owner's last known position with a
# little velocity extrapolation so fast targets stay accurate between
# packets. Snap on big desyncs (respawn/teleport).
if not is_dead and synced_position != Vector3.ZERO:
var target := synced_position + synced_velocity * NET_EXTRAPOLATION
if position.distance_to(target) > NET_SNAP_DISTANCE:
position = target
else:
position = position.lerp(target, 1.0 - exp(-NET_LERP_RATE * delta))
velocity = synced_velocity
var visual = get_visual_model()
if visual:
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
if visual.has_method("set_locomotion"):
var d := _local_move_dir()
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing)
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Upper body follows the owner's synced camera pitch
if visual.has_method("set_aim_pitch") and head_pivot:
visual.set_aim_pitch(head_pivot.rotation.x)
# Replayed one-shot actions (reload, grenade throw)
if visual.has_method("play_action") and synced_action_seq != _last_action_seq:
_last_action_seq = synced_action_seq
visual.play_action(synced_action)
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path)
visual.set_meta("current_weapon_path", synced_weapon_path)
# Positional footsteps for remote players (their movement states don't
# run here). Occlusion via AudioManager makes them read through walls
# correctly — the classic "someone's above me on the wooden platform".
if not is_dead and synced_movement_state == "ground" and synced_movement_speed > 1.0:
_remote_footstep_timer -= delta
if _remote_footstep_timer <= 0.0:
var am = get_tree().root.get_node_or_null("AudioManager")
if am and am.has_sound("footstep"):
am.play_3d("footstep", global_position + Vector3(0, -0.8, 0), -8.0)
_remote_footstep_timer = maxf(0.2, 3.0 / synced_movement_speed)
else:
_remote_footstep_timer = 0.0
return
# Local third-person model: aim pitch + reload one-shot (also broadcast
# to remote peers via the synced action counter)
var lvisual = get_visual_model()
if lvisual:
if lvisual.has_method("set_aim_pitch") and head_pivot:
lvisual.set_aim_pitch(head_pivot.rotation.x)
var lw = _active_weapon()
var now_reloading: bool = lw != null and "reloading" in lw and lw.reloading
if now_reloading and not _was_reloading:
_trigger_action("reload")
_was_reloading = now_reloading
# Shot kick on our OWN third-person model. server_play_fire_effects
# only fires for REMOTE shooters, so without this the owner sees no
# recoil at all in third person. Detect it from the ammo counter.
var ammo_now: int = -1
if lw:
if "current_ammo" in lw:
ammo_now = lw.current_ammo
elif "shells" in lw:
ammo_now = lw.shells
if ammo_now >= 0 and _last_local_ammo >= 0 and ammo_now < _last_local_ammo \
and lvisual.has_method("add_gun_recoil"):
lvisual.add_gun_recoil()
_last_local_ammo = ammo_now
# Update UI
if is_instance_valid(health_bar):
health_bar.value = health
health_label.text = "%d / %d" % [ceil(health), max_health]
if is_instance_valid(shield_bar):
shield_bar.value = shield
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
# Update Reload Ring
if is_instance_valid(reload_ring) and is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var slot = wman.get("active_slot")
if slot != null and wman.weapons.has(slot):
var w = wman.weapons[slot]
if "reloading" in w and w.reloading and "reload_timer" in w and "reload_time" in w:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
else:
reload_ring.progress = 0.0
if is_multiplayer_authority() and not is_dead:
if is_holding_grenade and grenades > 0:
_draw_trajectory()
elif is_instance_valid(trajectory_mesh):
trajectory_mesh.visible = false
if is_dead:
# Continuously follow the ragdoll torso
if is_instance_valid(ragdoll_instance) and is_instance_valid(camera):
var torso = ragdoll_instance.torso_body
if is_instance_valid(torso) and ragdoll_instance.has_meta("spring_arm"):
var spring_arm = ragdoll_instance.get_meta("spring_arm")
if is_instance_valid(spring_arm):
# Follow torso position exactly to avoid lerp bobbing
spring_arm.global_position = torso.global_position
if death_screen and death_screen.visible:
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) or Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) or Input.is_key_pressed(KEY_SPACE) or Input.is_key_pressed(KEY_ENTER) or Input.is_key_pressed(KEY_ESCAPE) == false and (Input.is_action_just_pressed("ui_accept") or Input.is_action_just_pressed("jump") or Input.is_action_just_pressed("fire")):
_on_respawn_pressed()
return
# Shield recharge logic
time_since_last_damage += delta
if time_since_last_damage >= 5.0 and shield < max_shield:
shield += 20.0 * delta
if shield > max_shield:
shield = max_shield
func _setup_hud() -> void:
var margin = MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
margin.offset_left = 20
margin.offset_bottom = -20
margin.grow_vertical = Control.GROW_DIRECTION_BEGIN
var vbox = VBoxContainer.new()
margin.add_child(vbox)
# Add Match HUD overlay
if is_multiplayer_authority():
var match_hud_scene = load("res://ui/match_hud.tscn")
if match_hud_scene:
var match_hud = match_hud_scene.instantiate()
add_child(match_hud)
# Shield Bar (Top)
var shield_box = VBoxContainer.new()
var s_title = Label.new()
s_title.text = "SHIELD"
s_title.add_theme_font_size_override("font_size", 12)
s_title.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
shield_box.add_child(s_title)
shield_bar = ProgressBar.new()
shield_bar.custom_minimum_size = Vector2(200, 20)
shield_bar.max_value = max_shield
shield_bar.value = shield
shield_bar.show_percentage = false
var s_sb = StyleBoxFlat.new()
s_sb.bg_color = Color(0.1, 0.4, 0.8)
shield_bar.add_theme_stylebox_override("fill", s_sb)
shield_label = Label.new()
shield_label.text = "100 / 100"
shield_label.set_anchors_preset(Control.PRESET_CENTER)
shield_bar.add_child(shield_label)
shield_box.add_child(shield_bar)
vbox.add_child(shield_box)
# Spacer
vbox.add_child(Control.new())
# Health Bar (Bottom)
var health_box = VBoxContainer.new()
var h_title = Label.new()
h_title.text = "HEALTH"
h_title.add_theme_font_size_override("font_size", 12)
h_title.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
health_box.add_child(h_title)
health_bar = ProgressBar.new()
health_bar.custom_minimum_size = Vector2(200, 20)
health_bar.max_value = max_health
health_bar.value = health
health_bar.show_percentage = false
var h_sb = StyleBoxFlat.new()
h_sb.bg_color = Color(0.8, 0.1, 0.1)
health_bar.add_theme_stylebox_override("fill", h_sb)
health_label = Label.new()
health_label.text = "100 / 100"
health_label.set_anchors_preset(Control.PRESET_CENTER)
health_bar.add_child(health_label)
health_box.add_child(health_bar)
vbox.add_child(health_box)
_damage_layer.add_child(margin)
# Setup Death Screen
death_screen = ColorRect.new()
death_screen.color = Color(0, 0, 0, 0.7)
death_screen.set_anchors_preset(Control.PRESET_FULL_RECT)
death_screen.visible = false
var center = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
death_screen.add_child(center)
var d_vbox = VBoxContainer.new()
d_vbox.add_theme_constant_override("separation", 20)
center.add_child(d_vbox)
var d_title = Label.new()
d_title.text = "SYSTEM FAILURE"
d_title.add_theme_font_size_override("font_size", 48)
d_title.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2))
d_vbox.add_child(d_title)
var respawn_label = Label.new()
respawn_label.text = "PRESS ANY KEY TO REBOOT"
respawn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
respawn_label.add_theme_font_size_override("font_size", 24)
d_vbox.add_child(respawn_label)
var tw = create_tween().set_loops()
tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8)
tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8)
if is_multiplayer_authority():
_damage_layer.add_child(death_screen)
func die(impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: return
is_dead = true
# Return to first person so the toggle state doesn't fight the death cam.
if is_multiplayer_authority() and third_person:
set_third_person(false)
# Disable movement state machine inputs
if _machine:
_machine.process_mode = Node.PROCESS_MODE_DISABLED
# Hide old animated model
var humanoid = get_node_or_null("HumanoidModel")
if humanoid:
humanoid.visible = false
var skinned = get_node_or_null("SkinnedModel")
if skinned:
skinned.visible = false
# Disable collision so player doesn't block bullets
var col = get_node_or_null("CollisionShape3D")
if col:
col.set_deferred("disabled", true)
# Spawn true physics ragdoll
death_count += 1
var ragdoll = load("res://characters/procedural_ragdoll.gd").new()
ragdoll.name = "Ragdoll_%s_%d" % [self.name, death_count]
ragdoll_instance = ragdoll
get_tree().current_scene.add_child(ragdoll)
ragdoll.global_transform = global_transform
ragdoll.build_ragdoll(Color(0.0, 0.75, 0.75)) # Miku teal — matches player 1 skin
# Wait a frame for physics to initialize then apply velocity
get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll, velocity, impulse))
# Move camera to 3rd person view using SpringArm3D
if is_instance_valid(camera):
# Hide the weapon manager so it isn't floating in front of the 3rd person camera
var wman = camera.get_node_or_null("WeaponManager")
if wman:
wman.visible = false
wman.process_mode = Node.PROCESS_MODE_DISABLED
var cam_trans = camera.global_transform
camera.get_parent().remove_child(camera)
# Determine fixed offset direction for camera to follow from
var back_dir = cam_trans.basis.z.normalized()
back_dir.y = 0 # Flatten it
if back_dir.length_squared() < 0.1:
back_dir = Vector3.BACK
else:
back_dir = back_dir.normalized()
var spring_arm = SpringArm3D.new()
spring_arm.name = "DeathSpringArm"
spring_arm.spring_length = 1.5
spring_arm.margin = 0.5
# Give it collision capability against the world but ignore the ragdoll shapes
spring_arm.collision_mask = 1 # Environment mask
for child in ragdoll.get_children():
if child is CollisionObject3D:
spring_arm.add_excluded_object(child.get_rid())
get_tree().current_scene.add_child(spring_arm)
# Put the arm at the torso's current position
spring_arm.global_position = ragdoll.torso_body.global_position
# Calculate upper-right position
var right_dir = back_dir.cross(Vector3.UP).normalized()
if right_dir.length_squared() < 0.1:
right_dir = Vector3.RIGHT
# The vector only defines direction, length is controlled by spring_length
var desired_cam_pos = spring_arm.global_position + back_dir * 2.5 + right_dir * 1.5 + Vector3.UP * 1.5
# SpringArm3D extends along its local +Z axis.
# look_at() points the local -Z axis at the target.
# To point +Z at desired_cam_pos, we must look_at the OPPOSITE direction.
var opposite_dir = spring_arm.global_position + (spring_arm.global_position - desired_cam_pos)
spring_arm.look_at(opposite_dir, Vector3.UP)
spring_arm.add_child(camera)
# Since +Z points outwards to the camera, -Z points inwards to the torso.
# Camera3D natively looks down -Z. Setting rotation to ZERO makes it perfectly look at the torso!
camera.position = Vector3.ZERO
camera.rotation = Vector3.ZERO
ragdoll_instance.set_meta("spring_arm", spring_arm)
# Show death screen
if is_multiplayer_authority():
death_screen.visible = true
set_process_input(true)
func _apply_ragdoll_velocity(ragdoll, l_vel: Vector3, imp: Vector3) -> void:
if is_instance_valid(ragdoll) and ragdoll.has_method("apply_initial_velocities"):
ragdoll.apply_initial_velocities(l_vel, imp)
func _input(event: InputEvent) -> void:
if not is_multiplayer_authority(): return
if is_dead and death_screen and death_screen.visible:
if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton:
if event.is_pressed() and not event.is_echo():
_on_respawn_pressed()
if not is_dead and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
if event.is_action_pressed("grenade") and grenades > 0:
is_holding_grenade = true
elif event.is_action_released("grenade") and is_holding_grenade:
is_holding_grenade = false
_throw_grenade()
func _draw_trajectory() -> void:
if not camera or not trajectory_mesh: return
trajectory_mesh.visible = true
var throw_speed = 20.0
var throw_up_speed = 5.0
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
var gravity = 15.0 # Typical default
var current_pos = camera.global_position + camera.global_transform.basis.z * -0.5
var time_step = 0.05
var bounciness = 0.5
var space_state = get_world_3d().direct_space_state
for i in range(trajectory_mesh.multimesh.instance_count):
var t = Transform3D(Basis(), current_pos)
trajectory_mesh.multimesh.set_instance_transform(i, t)
var next_vel = throw_vel
next_vel.y -= gravity * time_step
var next_pos = current_pos + throw_vel * time_step
var query = PhysicsRayQueryParameters3D.create(current_pos, next_pos)
query.exclude = [get_rid()]
var hit = space_state.intersect_ray(query)
if hit:
current_pos = hit.position + hit.normal * 0.05
throw_vel = next_vel.bounce(hit.normal) * bounciness
else:
current_pos = next_pos
throw_vel = next_vel
## The local player's currently equipped weapon node (null if none).
func _active_weapon():
if not is_instance_valid(camera):
return null
var wman = camera.get_node_or_null("WeaponManager")
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
return wman.weapons[wman.active_slot]
return null
## Play a named one-shot on the local model and broadcast it to peers.
func _trigger_action(action: String) -> void:
synced_action = action
synced_action_seq += 1
var v = get_visual_model()
if v and v.has_method("play_action"):
v.play_action(action)
func _throw_grenade() -> void:
if grenades <= 0: return
grenades -= 1
_trigger_action("throw")
var throw_speed = 20.0
var throw_up_speed = 5.0
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
var spawn_pos = camera.global_position + camera.global_transform.basis.z * -0.5
if multiplayer.has_multiplayer_peer() and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
server_spawn_grenade.rpc_id(1, spawn_pos, throw_vel)
else:
_spawn_grenade_local(spawn_pos, throw_vel, 1)
@rpc("any_peer", "call_local", "reliable")
func server_spawn_grenade(pos: Vector3, vel: Vector3) -> void:
if not multiplayer.is_server(): return
var id = multiplayer.get_remote_sender_id()
client_spawn_grenade.rpc(pos, vel, id)
@rpc("authority", "call_local", "reliable")
func client_spawn_grenade(pos: Vector3, vel: Vector3, owner_id: int) -> void:
_spawn_grenade_local(pos, vel, owner_id)
func _spawn_grenade_local(pos: Vector3, vel: Vector3, owner_id: int) -> void:
var g_scene = load("res://weapons/grenade_projectile.gd")
if not g_scene: return
var g = g_scene.new()
# Set position before adding to tree to avoid global_transform warning
g.position = pos
g.velocity = vel
var p = null
if owner_id == multiplayer.get_unique_id():
p = self
else:
var root = get_tree().current_scene
if root.has_node("Players/" + str(owner_id)):
p = root.get_node("Players/" + str(owner_id))
g.owner_player = p
get_tree().current_scene.add_child(g)
func _on_respawn_pressed() -> void:
if death_screen:
death_screen.visible = false
if multiplayer.is_server():
var spawn_pos = Vector3(randf_range(-5, 5), 5, randf_range(-5, 5))
rpc_respawn.rpc(spawn_pos)
else:
rpc_request_respawn.rpc_id(1)
var _last_respawn_request_time: float = 0.0
@rpc("any_peer", "call_local", "reliable")
func rpc_request_respawn() -> void:
if not multiplayer.is_server():
return
var sender_id = multiplayer.get_remote_sender_id()
if sender_id != 1 and sender_id != str(name).to_int():
return
var now = Time.get_ticks_msec() / 1000.0
if now - _last_respawn_request_time < 0.5:
return
_last_respawn_request_time = now
if is_dead:
var spawn_pos = Vector3(randf_range(-5, 5), 5, randf_range(-5, 5))
rpc_respawn.rpc(spawn_pos)
else:
# Deal 9999 damage to trigger the full death sequence locally and over the network
take_damage(9999, global_position, null, Vector3.ZERO)
@rpc("any_peer", "call_local", "reliable")
func rpc_respawn(spawn_pos: Vector3) -> void:
if is_dead:
is_dead = false
health = max_health
shield = max_shield
time_since_last_damage = 0.0
grenades = 2
is_holding_grenade = false
if is_instance_valid(ragdoll_instance):
if ragdoll_instance.has_meta("spring_arm"):
var sa = ragdoll_instance.get_meta("spring_arm")
if is_instance_valid(sa):
sa.queue_free()
ragdoll_instance.queue_free()
visible = true
process_mode = Node.PROCESS_MODE_INHERIT
# Restore camera parent
if is_instance_valid(camera) and is_instance_valid(head_pivot):
var p = camera.get_parent()
if p != head_pivot:
p.remove_child(camera)
head_pivot.add_child(camera)
camera.position = Vector3.ZERO
camera.rotation = Vector3.ZERO
# Re-enable movement state machine
if _machine:
_machine.process_mode = Node.PROCESS_MODE_INHERIT
# Re-enable collision
var col = get_node_or_null("CollisionShape3D")
if col:
col.set_deferred("disabled", false)
var visual_model = get_visual_model()
if visual_model:
visual_model.visible = true
var humanoid_model = get_node_or_null("HumanoidModel")
if humanoid_model and humanoid_model != visual_model:
humanoid_model.visible = not is_instance_valid(skinned_model)
# Everyone applies the broadcast spawn position; the owning peer also
# resets its synced transform so remotes snap instead of lerping across
# the map.
position = spawn_pos
velocity = Vector3.ZERO
if is_multiplayer_authority():
synced_position = spawn_pos
synced_velocity = Vector3.ZERO
# Local client resets UI and rebuilds weapons for their view
if is_multiplayer_authority():
if death_screen:
death_screen.visible = false
# Optional: reset weapons
var wman = camera.get_node_or_null("WeaponManager") if camera else null
if wman:
wman.visible = true
wman.process_mode = Node.PROCESS_MODE_INHERIT
var cv = wman.get("canvas_layer")
if cv:
cv.visible = true
if wman.has_method("_build_loadout"):
wman._build_loadout()