Files
Papay-Shooter/movement/player_movement_controller.gd
T
Nicholas ButzkeandClaude Opus 5 f1a4f7df52 feat(emotes): five dances, built like animation, behind a radial dial
The shared clip library ships exactly one `Dance_Loop`, and five copies of one
clip is not five dances. What the runtime does have is a procedural pose layer
over a real skeleton with spring-driven hair and cloth, which is enough — if
the motion is constructed the way an animator would construct it rather than
the way a programmer reaches for first.

Wiring sine waves to bones is that first reach, and everyone can tell. A raw
sine moves fastest through the middle and slowest at the ends by the same
amount on every channel, all in phase, forever. It floats. It has no weight, no
accent, and no sense that one part of the body is driving and the rest is
following. Four principles fix it, and all four are cheap:

  OVERLAP        the body is a chain. Hips lead, spine follows a beat later,
                 head last. One subtraction — `beat - lag * i` — and the spring
                 solver then carries it out through the hair and skirt for free,
                 because the dance layer runs before it.
  ACCENT         a dance HITS poses. `shape` bends the wave so it hangs at the
                 extremes and snaps between them, which is what a key-and-
                 breakdown pass produces by hand.
  WEIGHT         the HIPS translate, not just rotate. A body that never leaves
                 its own axis reads as a puppet on a stick.
  CONTRAST       Robot deliberately breaks all of the above — zero lag,
                 quantised motion — and reads as mechanical precisely because
                 the other four do not.

Spin spots its head: it holds a heading against the turn and whips round to
catch up, which is what a real dancer does to keep from getting dizzy and the
most recognisable thing about a turn.

The dial is a radial menu because every option is then the SAME DISTANCE from
where the pointer starts — the choice is a direction, and a direction becomes
muscle memory in a way "the fourth row down" does not. Selection is by ANGLE
alone, so a flick and a careful nudge do the same thing. HOLD to open, release
to commit; a tap too short to have aimed replays the last emote, which is what
the button did before, so the old habit still works. Pressing while already
dancing just stops — having to aim at something in order to STOP would be the
most annoying possible way to build this.

debug/dance_check.gd asserts the overlap, and getting it to measure that took
four wrong measurements, each of which is now a comment where it was made:

  - correlating the hips' TRANSLATION against the head's position relative to
    them compared two different quantities at different periods; it ranked the
    Robot, whose lag is zero by construction, as the most overlapped routine.
  - a signed scalar `angle * sign of the axis's largest component` is
    DISCONTINUOUS — as a rocking bone passes back through rest the axis flips —
    so smooth Two-Step measured a full-range jump per frame, which is exactly
    what quantised motion looks like.
  - a bone's GLOBAL rotation carries every ancestor's, so the head correlates
    with the hips at lag zero however delayed the head itself is.
  - and the hips and head are driven by different channels anyway.

Measuring two links of the SAME chain, as local rotation vectors, agrees with
the authored lag: Spin measures 9 frames against 8.4 authored, Two-Step 7
against 6.6, Robot 0. The Robot is checked on the property it actually has —
its jump per frame is 0.41 of its range against 0.03-0.06 for the others.

RigRoles is pulled out of ShooterPoseModifier so the dance layer resolves bones
the same way rather than carrying a second copy. Two copies is how a rig ends up
animating correctly under one modifier and not the other.

spawn smoke 0 failures, 11/11 movement, 21/21 weapon-hold pairs, contrast 108/108.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:20:33 -04:00

1472 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
## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd.
var _hud: PlayerHUD = null
## Kept because the controller drives them directly: the death screen is toggled
## by die()/respawn, and the ring is read by the reload logic.
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
## The reload ring, owned by the HUD. Kept here because the reload logic reads it.
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
## Which of the five routines in DanceRoutines is playing. Replicated, so other
## players see the emote that was actually chosen rather than always the first.
var synced_dance_index: int = 0
## The radial dial, and how long the emote button has been held. -1 means this
## press was consumed by stopping a dance and must not open the wheel.
var _emote_wheel: EmoteWheel = null
var _emote_held: float = -1.0
# 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_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
## Swap this player's character model, live.
##
## The only supported way in from outside — the escape menu's character picker
## calls this. It exists because the two halves of a skin change are easy to do
## by halves: `synced_skin_id` is what REMOTE peers rebuild from, and they only
## check it because their `_process` watches for it to change, which the
## authority's does not. Setting the property alone would change every other
## player's view of you and not your own.
##
## Rebuilding drops the old model and its third-person reveal with it, so that
## is re-applied here too; otherwise picking a new character while in third
## person leaves you looking at a shadow.
func set_skin(skin_id: String) -> void:
if skin_id == "" or skin_id == _applied_skin_id:
return
synced_skin_id = skin_id
_apply_skin_model(skin_id)
if third_person:
var visual := get_visual_model()
if visual and visual.has_method("set_owner_visible"):
visual.set_owner_visible(true)
## 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_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", 28)
# The theme's papaya and its violet ink, not an approximate orange on pure
# black — these numbers fly over the same 3D scene the rest of the HUD does.
label.add_theme_color_override("font_color", UITheme.PAPAYA)
label.add_theme_color_override("font_outline_color", UITheme.INK)
label.add_theme_constant_override("outline_size", 7)
# 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()
# The confirmation is part of the reticle now, so it lands where the eye
# already is and shares its ink outline.
if is_instance_valid(_hud):
_hud.confirm_hit()
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)
# Emote (B): HOLD to open the radial dial and point at a dance,
# release to commit. A tap too short to have aimed anything just
# toggles the last one, which is exactly what the button did before
# the wheel existed — so the old muscle memory still works.
if Input.is_action_just_pressed("emote"):
_emote_held = 0.0
if synced_is_dancing:
# Already dancing: the press stops it, and no wheel opens.
# Having to aim at something in order to STOP is the most
# annoying possible way to build this.
synced_is_dancing = false
_emote_held = -1.0
elif _emote_wheel:
_emote_wheel.open()
elif Input.is_action_pressed("emote") and _emote_held >= 0.0:
_emote_held += _delta
elif Input.is_action_just_released("emote") and _emote_held >= 0.0:
var aimed := _emote_wheel.close() if _emote_wheel else -1
# A tap replays the last emote; a hold plays whatever was aimed
# at. Either way the same grounded-and-slow gate applies.
var pick := aimed if aimed >= 0 else synced_dance_index
var m := _ensure_machine()
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
if m and m.current_state == "ground" and slow:
synced_dance_index = pick
synced_is_dancing = true
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
# The wheel eats aiming while it is open, so the player picking an emote
# does not also spin their character round. Movement is deliberately NOT
# blocked — a wheel that roots you in the open is a wheel nobody uses.
if _emote_wheel and _emote_wheel.visible and head_pivot:
head_pivot.set_process_input(false)
elif head_pivot and not head_pivot.is_processing_input() and not is_dead:
head_pivot.set_process_input(true)
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, synced_dance_index)
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, synced_dance_index)
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
# Vitals are pushed (the controller owns health and shield); ammo, the
# reticle bloom and the reload ring are pulled by the HUD from the weapon,
# which is the authority on all three.
if is_instance_valid(_hud):
_hud.set_vitals(health, max_health, shield, max_shield)
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
## Build the first-person HUD.
##
## Everything it draws lives in ui/player_hud.gd now. This used to be 105 lines
## of stock ProgressBars and a plain-black death screen inlined here, which is
## why none of it shared the game's look: a HUD assembled inside a 1500-line
## movement controller is a HUD nobody styles.
func _setup_hud() -> void:
# Match state (timer, score, killfeed, scoreboard) is a separate overlay and
# is deliberately not part of the player's own HUD.
if is_multiplayer_authority():
var match_hud_scene = load("res://ui/match_hud.tscn")
if match_hud_scene:
add_child(match_hud_scene.instantiate())
_hud = PlayerHUD.new()
_hud.name = "PlayerHUD"
_hud.player = self
add_child(_hud)
# The emote dial rides on the HUD's canvas, above the viewmodel.
_emote_wheel = EmoteWheel.new()
_emote_wheel.name = "EmoteWheel"
_hud.add_child(_emote_wheel)
# The controller still owns these two — it toggles the death screen on death
# and the ring is read by the reload logic — so keep the references it had.
death_screen = _hud.death_screen
reload_ring = _hud.reload_ring
_hud.set_vitals(health, max_health, shield, max_shield)
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()