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()