The theme was a comic one — cream paper, ink borders, papaya. It is now a charged one: near-black violet, hot papaya, and a lightning yellow spent nowhere except the instant a button is pressed. Chips are cut with two sharp corners and two round ones on a diagonal, which is as close to a skew as a StyleBoxFlat gets and is the difference between a button that reads calm and one that reads fast. BoltRule draws the motif itself, struck a third of the way along its rule rather than centred, so it reads as something that HIT the line. The pause menu was 900 lines of hand-rolled UI that never referenced UITheme at all, so it rendered in Godot's default grey. It now applies the theme — and applies it to its own root Control, not only to the Window, because a Control inherits from its nearest Control ANCESTOR and this screen hangs off a CanvasLayer, which is not one. That was invisible at first: the parts built with UITheme.title() carry their own overrides and looked right next to a list and a button that did not. And it now has a Character screen. The roster on the left, the character themselves on the right, turning — a name in a dropdown is not a character selection screen. The preview is a real SkinnedPlayerModel in its own world, so it shows exactly what will spawn: the same cel look, the same per-class outlines, the same cloth and hair on springs. Selecting applies immediately; there is nothing destructive to confirm, and applying on selection means the character behind the menu changes as you arrow the list, which IS the comparison. PlayerMovementController.set_skin() is the supported way in. Both halves of a skin change are easy to do by halves — `synced_skin_id` is what REMOTE peers rebuild from, and only their _process watches it, so setting the property alone would change everyone else's view of you and not your own. Checked rather than asserted. debug/character_picker_check.gd walks the whole roster and proves each entry loads a skeleton, animations, a surface table and body surfaces — and that the skeleton is MOVING, because the clip name is a variable this class sets on itself and reads "Idle" just as happily when nothing is ticking. debug/ui_capture.gd and debug/roster_capture.gd photograph the screens and every character, which is how three things were found that no assertion could see: the theme break above, a preview showing the back of the character's head, and a turntable that carried on from the last character so the third one you looked at was side-on. Known and not fixed here: momo's idle pose is wrong — arms overhead and a pinched waist. Her rig, not the picker; every other character is correct. Co-Authored-By: Claude Opus 5 <[email protected]>
1562 lines
55 KiB
GDScript
1562 lines
55 KiB
GDScript
extends CharacterBody3D
|
|
class_name PlayerMovementController
|
|
|
|
signal chain_updated(count: int, bonus: float)
|
|
|
|
@export var params: MovementParams = preload("res://movement/movement_params.gd").new()
|
|
|
|
var _machine: MovementStateMachine = null
|
|
var head_pivot: Node3D = null # FPSCameraRig node
|
|
var camera: Camera3D = null
|
|
var flashlight: SpotLight3D = null
|
|
var _damage_layer: CanvasLayer = null
|
|
|
|
# Third-person view toggle (local player only). The first-person camera keeps
|
|
# handling aim/firing; the toggle only changes which camera renders and whether
|
|
# the owner sees their own model.
|
|
var third_person: bool = false
|
|
var _tp_camera: Camera3D = null
|
|
|
|
# Health and Shield
|
|
var max_health: float = 100.0
|
|
var health: float = 100.0
|
|
var max_shield: float = 100.0
|
|
var shield: float = 100.0
|
|
var time_since_last_damage: float = 0.0
|
|
var death_count: int = 0
|
|
var is_dead: bool = false
|
|
|
|
# UI
|
|
var health_bar: ProgressBar
|
|
var shield_bar: ProgressBar
|
|
var health_label: Label
|
|
var shield_label: Label
|
|
var death_screen: Control
|
|
|
|
var ragdoll_instance: Node3D
|
|
# Audio
|
|
var footstep_player: AudioStreamPlayer
|
|
var footstep_streams: Array = []
|
|
var land_player: AudioStreamPlayer
|
|
var _remote_footstep_timer: float = 0.0
|
|
|
|
# Networked one-shot animation actions (reload/throw): the owner bumps the
|
|
# sequence; remote peers play the named action when it changes.
|
|
@export var synced_action: String = ""
|
|
@export var synced_action_seq: int = 0
|
|
var _last_action_seq: int = 0
|
|
var _was_reloading: bool = false
|
|
## Ammo count last frame — a drop means we fired, which kicks the owner's
|
|
## own third-person model (remote shooters go through server_play_fire_effects).
|
|
var _last_local_ammo: int = -1
|
|
var dash_player: AudioStreamPlayer
|
|
var recent_attackers: Dictionary = {} # attacker_id: timestamp
|
|
var hit_player: AudioStreamPlayer
|
|
var slide_player: AudioStreamPlayer
|
|
var wallrun_player: AudioStreamPlayer
|
|
var wind_player: AudioStreamPlayer
|
|
var jump_player: AudioStreamPlayer
|
|
var double_jump_player: AudioStreamPlayer
|
|
var vault_player: AudioStreamPlayer
|
|
|
|
var grapple_shoot_player: AudioStreamPlayer
|
|
var grapple_latch_player: AudioStreamPlayer
|
|
var grapple_swing_player: AudioStreamPlayer
|
|
|
|
# UI
|
|
var hit_marker: Control
|
|
var _hit_marker_tween: Tween
|
|
var reload_ring: Control
|
|
|
|
var grenades: int = 2
|
|
var is_holding_grenade: bool = false
|
|
var trajectory_mesh: MultiMeshInstance3D
|
|
|
|
# Grapple
|
|
var grapple_rope: MeshInstance3D
|
|
|
|
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
|
|
# These are written by the authority each frame and replicated to remote peers
|
|
# via the MultiplayerSynchronizer properties.
|
|
# Movement is CLIENT-AUTHORITATIVE: the owning peer simulates locally and
|
|
# broadcasts position/velocity; remote peers interpolate toward it (see
|
|
# _process). The server stays authoritative for health, shield, and kills.
|
|
var synced_movement_state: String = "idle"
|
|
var synced_movement_speed: float = 0.0
|
|
var synced_is_crouching: bool = false
|
|
var synced_position: Vector3 = Vector3.ZERO
|
|
var synced_velocity: Vector3 = Vector3.ZERO
|
|
var synced_is_ads: bool = false
|
|
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
|
var synced_is_dancing: bool = false # dance emote (B), shown on the model
|
|
|
|
# Anime speed-lines overlay (local player only)
|
|
var _speedlines: ColorRect = null
|
|
var _speedline_burst: float = 0.0
|
|
@export var synced_skin_id: String = ""
|
|
@export var synced_weapon_path: String = ""
|
|
|
|
# Remote interpolation tuning
|
|
const NET_LERP_RATE := 18.0 # exponential smoothing rate for remote players
|
|
const NET_SNAP_DISTANCE := 6.0 # teleport if desync exceeds this (respawns etc.)
|
|
const NET_EXTRAPOLATION := 0.05 # seconds of velocity extrapolation
|
|
|
|
var skinned_model: Node3D = null
|
|
var _applied_skin_id: String = "__none__"
|
|
@export var synced_grapple_point: Vector3 = Vector3.ZERO
|
|
@export var synced_is_grapple_shooting: bool = false
|
|
|
|
# For homing rocket
|
|
@export var synced_is_targeting: bool = false
|
|
@export var synced_homing_target_pos: Vector3 = Vector3.ZERO
|
|
|
|
var synced_loadout_p1: String = ""
|
|
var synced_loadout_p2: String = ""
|
|
var synced_loadout_sp: String = ""
|
|
var synced_loadout_melee: String = ""
|
|
var synced_loadout_ready: bool = false
|
|
|
|
func _ready() -> void:
|
|
var sm := _ensure_machine()
|
|
if sm:
|
|
sm.player = self
|
|
sm.params = params
|
|
sm.movement_event.connect(_on_movement_event)
|
|
|
|
# Find camera rig
|
|
head_pivot = get_node_or_null("HeadPivot")
|
|
if head_pivot:
|
|
camera = head_pivot.get_node_or_null("Camera3D")
|
|
|
|
if camera:
|
|
flashlight = SpotLight3D.new()
|
|
flashlight.spot_range = 100.0
|
|
flashlight.spot_angle = 35.0
|
|
flashlight.light_energy = 5.0
|
|
flashlight.light_volumetric_fog_energy = 0.0 # Prevents the blinding fog glare and laggy trails
|
|
flashlight.shadow_enabled = false
|
|
flashlight.position = Vector3(0, 0, 0)
|
|
flashlight.visible = false
|
|
camera.add_child(flashlight)
|
|
|
|
_setup_audio()
|
|
_setup_grapple()
|
|
_setup_grenade_trajectory()
|
|
|
|
if is_multiplayer_authority():
|
|
_damage_layer = CanvasLayer.new()
|
|
add_child(_damage_layer)
|
|
_setup_hit_marker()
|
|
_setup_hud()
|
|
_setup_speedlines()
|
|
else:
|
|
call_deferred("_hide_remote_weapons")
|
|
|
|
set_process(true)
|
|
if is_multiplayer_authority():
|
|
set_physics_process(true)
|
|
var skin_mgr = get_node_or_null("/root/SkinManager")
|
|
if skin_mgr:
|
|
synced_skin_id = skin_mgr.active_skin_id
|
|
synced_position = position
|
|
_setup_third_person_camera()
|
|
# Build the visual model for whatever skin is selected (remote peers get
|
|
# the id via the synchronizer and rebuild in _process when it arrives).
|
|
_apply_skin_model(synced_skin_id)
|
|
|
|
|
|
## Over-the-shoulder camera for the local player, on a spring arm so it doesn't
|
|
## clip through walls. The first-person camera still exists and handles firing;
|
|
## this one only renders when third_person is on.
|
|
func _setup_third_person_camera() -> void:
|
|
if not head_pivot or not is_instance_valid(camera):
|
|
return
|
|
# Chain: head_pivot > OrbitPivot (Alt free-look, springs back to zero)
|
|
# > ShoulderOffset (camera right of the spine — the character sits
|
|
# slightly LEFT of screen centre, Fortnite-style) > SpringArm.
|
|
var orbit := Node3D.new()
|
|
orbit.name = "OrbitPivot"
|
|
head_pivot.add_child(orbit)
|
|
|
|
var shoulder := Node3D.new()
|
|
shoulder.name = "ShoulderOffset"
|
|
shoulder.position = Vector3(0.55, 0.12, 0.0)
|
|
orbit.add_child(shoulder)
|
|
|
|
var boom := SpringArm3D.new()
|
|
boom.name = "ThirdPersonBoom"
|
|
boom.spring_length = 2.6
|
|
boom.margin = 0.3
|
|
boom.collision_mask = 1 # environment only
|
|
boom.add_excluded_object(get_rid())
|
|
# Aim the arm up-and-back from the shoulder so the camera sits behind/above.
|
|
boom.rotation_degrees = Vector3(14, 0, 0)
|
|
shoulder.add_child(boom)
|
|
|
|
_tp_camera = Camera3D.new()
|
|
_tp_camera.name = "ThirdPersonCamera"
|
|
_tp_camera.fov = camera.fov
|
|
_tp_camera.current = false
|
|
# Never render the first-person viewmodel layer (20) — otherwise the gun
|
|
# and arms float in front of the third-person view.
|
|
_tp_camera.cull_mask &= ~(1 << 19)
|
|
boom.add_child(_tp_camera)
|
|
|
|
|
|
## Toggle between first- and third-person. Firing keeps using the first-person
|
|
## camera, so aim is unchanged; only rendering and self-visibility change.
|
|
func set_third_person(on: bool) -> void:
|
|
if not is_multiplayer_authority():
|
|
return
|
|
third_person = on
|
|
if is_instance_valid(_tp_camera):
|
|
_tp_camera.fov = SettingsManager.world_fov # stay in sync with settings
|
|
_tp_camera.current = on
|
|
if is_instance_valid(camera):
|
|
camera.current = not on
|
|
# Reveal / hide the owner's own model.
|
|
var visual = get_visual_model()
|
|
if visual and visual.has_method("set_owner_visible"):
|
|
visual.set_owner_visible(on)
|
|
# Hide the first-person weapon viewmodel in third person (it would float in
|
|
# the middle of the screen); the third-person weapon on the model shows.
|
|
if is_instance_valid(camera):
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer):
|
|
wman.canvas_layer.visible = not on
|
|
|
|
## 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_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()
|