There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.
globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:
Deathmatch 25 frags or 10 minutes.
Team Deathmatch two squads to 50, teams balanced by COUNT on join (a 4v4
that loses three from one side must refill the short side;
round-robin on join order leaves it 4v1 forever), friendly
fire off and enforced on the server before damage is even
broadcast — a mode where the damage lands but the kill does
not count is worse than either.
Gun Game every kill promotes you a rung and swaps your weapon. The
score IS the rung and the limit IS the ladder's length, so
finishing the ladder and reaching the score limit are the
same event and only one win condition exists.
`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.
Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.
ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.
Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.
spawn smoke 0, game modes 30/30, movement 11/11.
Co-Authored-By: Claude Opus 5 <[email protected]>
1482 lines
54 KiB
GDScript
1482 lines
54 KiB
GDScript
extends CharacterBody3D
|
|
class_name PlayerMovementController
|
|
|
|
signal chain_updated(count: int, bonus: float)
|
|
|
|
@export var params: MovementParams = preload("res://movement/movement_params.gd").new()
|
|
|
|
var _machine: MovementStateMachine = null
|
|
var head_pivot: Node3D = null # FPSCameraRig node
|
|
var camera: Camera3D = null
|
|
var flashlight: SpotLight3D = null
|
|
var _damage_layer: CanvasLayer = null
|
|
|
|
# Third-person view toggle (local player only). The first-person camera keeps
|
|
# handling aim/firing; the toggle only changes which camera renders and whether
|
|
# the owner sees their own model.
|
|
var third_person: bool = false
|
|
var _tp_camera: Camera3D = null
|
|
|
|
# Health and Shield
|
|
var max_health: float = 100.0
|
|
var health: float = 100.0
|
|
var max_shield: float = 100.0
|
|
var shield: float = 100.0
|
|
var time_since_last_damage: float = 0.0
|
|
var death_count: int = 0
|
|
var is_dead: bool = false
|
|
|
|
# UI
|
|
## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd.
|
|
var _hud: PlayerHUD = null
|
|
## Kept because the controller drives them directly: the death screen is toggled
|
|
## by die()/respawn, and the ring is read by the reload logic.
|
|
var death_screen: Control
|
|
|
|
var ragdoll_instance: Node3D
|
|
# Audio
|
|
var footstep_player: AudioStreamPlayer
|
|
var footstep_streams: Array = []
|
|
var land_player: AudioStreamPlayer
|
|
var _remote_footstep_timer: float = 0.0
|
|
|
|
# Networked one-shot animation actions (reload/throw): the owner bumps the
|
|
# sequence; remote peers play the named action when it changes.
|
|
@export var synced_action: String = ""
|
|
@export var synced_action_seq: int = 0
|
|
var _last_action_seq: int = 0
|
|
var _was_reloading: bool = false
|
|
## Ammo count last frame — a drop means we fired, which kicks the owner's
|
|
## own third-person model (remote shooters go through server_play_fire_effects).
|
|
var _last_local_ammo: int = -1
|
|
var dash_player: AudioStreamPlayer
|
|
var recent_attackers: Dictionary = {} # attacker_id: timestamp
|
|
var hit_player: AudioStreamPlayer
|
|
var slide_player: AudioStreamPlayer
|
|
var wallrun_player: AudioStreamPlayer
|
|
var wind_player: AudioStreamPlayer
|
|
var jump_player: AudioStreamPlayer
|
|
var double_jump_player: AudioStreamPlayer
|
|
var vault_player: AudioStreamPlayer
|
|
|
|
var grapple_shoot_player: AudioStreamPlayer
|
|
var grapple_latch_player: AudioStreamPlayer
|
|
var grapple_swing_player: AudioStreamPlayer
|
|
|
|
# UI
|
|
## The reload ring, owned by the HUD. Kept here because the reload logic reads it.
|
|
var reload_ring: Control
|
|
|
|
var grenades: int = 2
|
|
var is_holding_grenade: bool = false
|
|
var trajectory_mesh: MultiMeshInstance3D
|
|
|
|
# Grapple
|
|
var grapple_rope: MeshInstance3D
|
|
|
|
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
|
|
# These are written by the authority each frame and replicated to remote peers
|
|
# via the MultiplayerSynchronizer properties.
|
|
# Movement is CLIENT-AUTHORITATIVE: the owning peer simulates locally and
|
|
# broadcasts position/velocity; remote peers interpolate toward it (see
|
|
# _process). The server stays authoritative for health, shield, and kills.
|
|
var synced_movement_state: String = "idle"
|
|
var synced_movement_speed: float = 0.0
|
|
var synced_is_crouching: bool = false
|
|
var synced_position: Vector3 = Vector3.ZERO
|
|
var synced_velocity: Vector3 = Vector3.ZERO
|
|
var synced_is_ads: bool = false
|
|
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
|
var synced_is_dancing: bool = false # dance emote (B), shown on the model
|
|
## Which of the five routines in DanceRoutines is playing. Replicated, so other
|
|
## players see the emote that was actually chosen rather than always the first.
|
|
var synced_dance_index: int = 0
|
|
## The radial dial, and how long the emote button has been held. -1 means this
|
|
## press was consumed by stopping a dance and must not open the wheel.
|
|
var _emote_wheel: EmoteWheel = null
|
|
var _emote_held: float = -1.0
|
|
|
|
# Anime speed-lines overlay (local player only)
|
|
var _speedlines: ColorRect = null
|
|
var _speedline_burst: float = 0.0
|
|
@export var synced_skin_id: String = ""
|
|
@export var synced_weapon_path: String = ""
|
|
|
|
# Remote interpolation tuning
|
|
const NET_LERP_RATE := 18.0 # exponential smoothing rate for remote players
|
|
const NET_SNAP_DISTANCE := 6.0 # teleport if desync exceeds this (respawns etc.)
|
|
const NET_EXTRAPOLATION := 0.05 # seconds of velocity extrapolation
|
|
|
|
var skinned_model: Node3D = null
|
|
var _applied_skin_id: String = "__none__"
|
|
@export var synced_grapple_point: Vector3 = Vector3.ZERO
|
|
@export var synced_is_grapple_shooting: bool = false
|
|
|
|
# For homing rocket
|
|
@export var synced_is_targeting: bool = false
|
|
@export var synced_homing_target_pos: Vector3 = Vector3.ZERO
|
|
|
|
var synced_loadout_p1: String = ""
|
|
var synced_loadout_p2: String = ""
|
|
var synced_loadout_sp: String = ""
|
|
var synced_loadout_melee: String = ""
|
|
var synced_loadout_ready: bool = false
|
|
|
|
func _ready() -> void:
|
|
var sm := _ensure_machine()
|
|
if sm:
|
|
sm.player = self
|
|
sm.params = params
|
|
sm.movement_event.connect(_on_movement_event)
|
|
|
|
# Find camera rig
|
|
head_pivot = get_node_or_null("HeadPivot")
|
|
if head_pivot:
|
|
camera = head_pivot.get_node_or_null("Camera3D")
|
|
|
|
if camera:
|
|
flashlight = SpotLight3D.new()
|
|
flashlight.spot_range = 100.0
|
|
flashlight.spot_angle = 35.0
|
|
flashlight.light_energy = 5.0
|
|
flashlight.light_volumetric_fog_energy = 0.0 # Prevents the blinding fog glare and laggy trails
|
|
flashlight.shadow_enabled = false
|
|
flashlight.position = Vector3(0, 0, 0)
|
|
flashlight.visible = false
|
|
camera.add_child(flashlight)
|
|
|
|
_setup_audio()
|
|
_setup_grapple()
|
|
_setup_grenade_trajectory()
|
|
|
|
if is_multiplayer_authority():
|
|
_damage_layer = CanvasLayer.new()
|
|
add_child(_damage_layer)
|
|
_setup_hud()
|
|
_setup_speedlines()
|
|
else:
|
|
call_deferred("_hide_remote_weapons")
|
|
|
|
set_process(true)
|
|
if is_multiplayer_authority():
|
|
set_physics_process(true)
|
|
var skin_mgr = get_node_or_null("/root/SkinManager")
|
|
if skin_mgr:
|
|
synced_skin_id = skin_mgr.active_skin_id
|
|
synced_position = position
|
|
_setup_third_person_camera()
|
|
# Build the visual model for whatever skin is selected (remote peers get
|
|
# the id via the synchronizer and rebuild in _process when it arrives).
|
|
_apply_skin_model(synced_skin_id)
|
|
|
|
|
|
## Over-the-shoulder camera for the local player, on a spring arm so it doesn't
|
|
## clip through walls. The first-person camera still exists and handles firing;
|
|
## this one only renders when third_person is on.
|
|
func _setup_third_person_camera() -> void:
|
|
if not head_pivot or not is_instance_valid(camera):
|
|
return
|
|
# Chain: head_pivot > OrbitPivot (Alt free-look, springs back to zero)
|
|
# > ShoulderOffset (camera right of the spine — the character sits
|
|
# slightly LEFT of screen centre, Fortnite-style) > SpringArm.
|
|
var orbit := Node3D.new()
|
|
orbit.name = "OrbitPivot"
|
|
head_pivot.add_child(orbit)
|
|
|
|
var shoulder := Node3D.new()
|
|
shoulder.name = "ShoulderOffset"
|
|
shoulder.position = Vector3(0.55, 0.12, 0.0)
|
|
orbit.add_child(shoulder)
|
|
|
|
var boom := SpringArm3D.new()
|
|
boom.name = "ThirdPersonBoom"
|
|
boom.spring_length = 2.6
|
|
boom.margin = 0.3
|
|
boom.collision_mask = 1 # environment only
|
|
boom.add_excluded_object(get_rid())
|
|
# Aim the arm up-and-back from the shoulder so the camera sits behind/above.
|
|
boom.rotation_degrees = Vector3(14, 0, 0)
|
|
shoulder.add_child(boom)
|
|
|
|
_tp_camera = Camera3D.new()
|
|
_tp_camera.name = "ThirdPersonCamera"
|
|
_tp_camera.fov = camera.fov
|
|
_tp_camera.current = false
|
|
# Never render the first-person viewmodel layer (20) — otherwise the gun
|
|
# and arms float in front of the third-person view.
|
|
_tp_camera.cull_mask &= ~(1 << 19)
|
|
boom.add_child(_tp_camera)
|
|
|
|
|
|
## Toggle between first- and third-person. Firing keeps using the first-person
|
|
## camera, so aim is unchanged; only rendering and self-visibility change.
|
|
func set_third_person(on: bool) -> void:
|
|
if not is_multiplayer_authority():
|
|
return
|
|
third_person = on
|
|
if is_instance_valid(_tp_camera):
|
|
_tp_camera.fov = SettingsManager.world_fov # stay in sync with settings
|
|
_tp_camera.current = on
|
|
if is_instance_valid(camera):
|
|
camera.current = not on
|
|
# Reveal / hide the owner's own model.
|
|
var visual = get_visual_model()
|
|
if visual and visual.has_method("set_owner_visible"):
|
|
visual.set_owner_visible(on)
|
|
# Hide the first-person weapon viewmodel in third person (it would float in
|
|
# the middle of the screen); the third-person weapon on the model shows.
|
|
if is_instance_valid(camera):
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman and "canvas_layer" in wman and is_instance_valid(wman.canvas_layer):
|
|
wman.canvas_layer.visible = not on
|
|
|
|
## Swap this player's character model, live.
|
|
##
|
|
## The only supported way in from outside — the escape menu's character picker
|
|
## calls this. It exists because the two halves of a skin change are easy to do
|
|
## by halves: `synced_skin_id` is what REMOTE peers rebuild from, and they only
|
|
## check it because their `_process` watches for it to change, which the
|
|
## authority's does not. Setting the property alone would change every other
|
|
## player's view of you and not your own.
|
|
##
|
|
## Rebuilding drops the old model and its third-person reveal with it, so that
|
|
## is re-applied here too; otherwise picking a new character while in third
|
|
## person leaves you looking at a shadow.
|
|
func set_skin(skin_id: String) -> void:
|
|
if skin_id == "" or skin_id == _applied_skin_id:
|
|
return
|
|
synced_skin_id = skin_id
|
|
_apply_skin_model(skin_id)
|
|
if third_person:
|
|
var visual := get_visual_model()
|
|
if visual and visual.has_method("set_owner_visible"):
|
|
visual.set_owner_visible(true)
|
|
|
|
|
|
## Returns the node that visually represents this player (skinned GLB model
|
|
## if the active skin has one, otherwise the procedural HumanoidModel).
|
|
func get_visual_model() -> Node3D:
|
|
if is_instance_valid(skinned_model):
|
|
return skinned_model
|
|
return get_node_or_null("HumanoidModel")
|
|
|
|
## Swap the visual model to match a skin id. GLB skins replace the procedural
|
|
## model entirely; color skins tint the procedural model.
|
|
func _apply_skin_model(skin_id: String) -> void:
|
|
_applied_skin_id = skin_id
|
|
var humanoid = get_node_or_null("HumanoidModel")
|
|
var old = get_node_or_null("SkinnedModel")
|
|
if old:
|
|
remove_child(old)
|
|
old.queue_free()
|
|
skinned_model = null
|
|
|
|
var skin_mgr = get_node_or_null("/root/SkinManager")
|
|
var skin = skin_mgr.get_skin(skin_id) if skin_mgr else null
|
|
var has_model: bool = skin != null and skin.model_path != "" \
|
|
and (ResourceLoader.exists(skin.model_path) or FileAccess.file_exists(skin.model_path))
|
|
|
|
if has_model:
|
|
var model := SkinnedPlayerModel.new()
|
|
model.name = "SkinnedModel"
|
|
model.model_path = skin.model_path
|
|
# Owner gets the first-person body view (head hidden, fully animated);
|
|
# everyone else sees the full third-person model.
|
|
model.first_person_mode = is_multiplayer_authority()
|
|
model.position = Vector3(0, -0.9, 0) # capsule center -> feet
|
|
add_child(model)
|
|
skinned_model = model
|
|
if humanoid:
|
|
humanoid.visible = false
|
|
else:
|
|
if humanoid:
|
|
humanoid.visible = not is_dead
|
|
if skin and humanoid.has_method("_apply_color"):
|
|
humanoid._apply_color(skin.color_tint)
|
|
|
|
func _hide_remote_weapons() -> void:
|
|
if camera:
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman:
|
|
wman.visible = false
|
|
wman.process_mode = Node.PROCESS_MODE_DISABLED
|
|
var cv = wman.get("canvas_layer")
|
|
if cv:
|
|
cv.visible = false
|
|
|
|
func _setup_audio() -> void:
|
|
footstep_player = AudioStreamPlayer.new()
|
|
footstep_player.bus = "SFX"
|
|
for path in ["res://assets/sounds/footstep.wav", "res://assets/sounds/footstep_01.wav",
|
|
"res://assets/sounds/footstep_02.wav", "res://assets/sounds/footstep_03.wav",
|
|
"res://assets/sounds/footstep_04.wav"]:
|
|
if ResourceLoader.exists(path):
|
|
footstep_streams.append(load(path))
|
|
footstep_player.stream = footstep_streams[0] if not footstep_streams.is_empty() else null
|
|
add_child(footstep_player)
|
|
|
|
land_player = AudioStreamPlayer.new()
|
|
land_player.bus = "SFX"
|
|
land_player.stream = load("res://assets/sounds/land.wav")
|
|
add_child(land_player)
|
|
|
|
jump_player = AudioStreamPlayer.new()
|
|
jump_player.bus = "SFX"
|
|
jump_player.stream = load("res://assets/sounds/jump.wav")
|
|
add_child(jump_player)
|
|
|
|
double_jump_player = AudioStreamPlayer.new()
|
|
double_jump_player.bus = "SFX"
|
|
jump_player.bus = "SFX"
|
|
double_jump_player.stream = load("res://assets/sounds/double_jump.wav")
|
|
add_child(double_jump_player)
|
|
|
|
vault_player = AudioStreamPlayer.new()
|
|
vault_player.bus = "SFX"
|
|
vault_player.stream = load("res://assets/sounds/vault.wav")
|
|
add_child(vault_player)
|
|
|
|
dash_player = AudioStreamPlayer.new()
|
|
dash_player.bus = "SFX"
|
|
dash_player.stream = load("res://assets/sounds/dash.wav")
|
|
dash_player.volume_db = -5.0
|
|
add_child(dash_player)
|
|
|
|
hit_player = AudioStreamPlayer.new()
|
|
hit_player.bus = "SFX"
|
|
hit_player.stream = load("res://assets/sounds/hit_confirm.wav")
|
|
hit_player.max_polyphony = 8
|
|
add_child(hit_player)
|
|
|
|
slide_player = AudioStreamPlayer.new()
|
|
slide_player.bus = "SFX"
|
|
slide_player.stream = load("res://assets/sounds/slide.wav")
|
|
slide_player.volume_db = -10.0
|
|
add_child(slide_player)
|
|
|
|
wallrun_player = AudioStreamPlayer.new()
|
|
wallrun_player.bus = "SFX"
|
|
wallrun_player.stream = load("res://assets/sounds/wallrun.wav")
|
|
wallrun_player.volume_db = -10.0
|
|
add_child(wallrun_player)
|
|
|
|
wind_player = AudioStreamPlayer.new()
|
|
wind_player.bus = "Wind"
|
|
wind_player.stream = load("res://assets/sounds/wind.wav")
|
|
wind_player.volume_db = -80.0 # Start silent
|
|
add_child(wind_player)
|
|
wind_player.play()
|
|
|
|
grapple_shoot_player = AudioStreamPlayer.new()
|
|
grapple_shoot_player.bus = "SFX"
|
|
grapple_shoot_player.stream = load("res://assets/sounds/dash.wav")
|
|
grapple_shoot_player.volume_db = -5.0
|
|
grapple_shoot_player.pitch_scale = 1.5
|
|
add_child(grapple_shoot_player)
|
|
|
|
grapple_latch_player = AudioStreamPlayer.new()
|
|
grapple_latch_player.bus = "SFX"
|
|
grapple_latch_player.stream = load("res://assets/sounds/hit_confirm.wav")
|
|
grapple_latch_player.pitch_scale = 0.8
|
|
add_child(grapple_latch_player)
|
|
|
|
grapple_swing_player = AudioStreamPlayer.new()
|
|
grapple_swing_player.bus = "SFX"
|
|
grapple_swing_player.stream = load("res://assets/sounds/wind.wav")
|
|
grapple_swing_player.pitch_scale = 1.5
|
|
grapple_swing_player.volume_db = -80.0
|
|
add_child(grapple_swing_player)
|
|
grapple_swing_player.play()
|
|
|
|
func _setup_speedlines() -> void:
|
|
_speedlines = ColorRect.new()
|
|
_speedlines.name = "SpeedLines"
|
|
_speedlines.set_anchors_preset(Control.PRESET_FULL_RECT)
|
|
_speedlines.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
var mat := ShaderMaterial.new()
|
|
mat.shader = load("res://assets/shaders/speed_lines.gdshader")
|
|
mat.set_shader_parameter("intensity", 0.0)
|
|
_speedlines.material = mat
|
|
_damage_layer.add_child(_speedlines)
|
|
|
|
|
|
func _setup_grapple() -> void:
|
|
grapple_rope = MeshInstance3D.new()
|
|
var rope_mesh = CylinderMesh.new()
|
|
rope_mesh.top_radius = 0.04
|
|
rope_mesh.bottom_radius = 0.04
|
|
rope_mesh.height = 1.0 # Will be scaled dynamically
|
|
|
|
var mat = StandardMaterial3D.new()
|
|
mat.albedo_color = Color(0.1, 0.1, 0.1) # Dark grey cable
|
|
mat.roughness = 0.8
|
|
rope_mesh.material = mat
|
|
|
|
grapple_rope.mesh = rope_mesh
|
|
grapple_rope.visible = false
|
|
get_tree().current_scene.call_deferred("add_child", grapple_rope)
|
|
|
|
func _setup_grenade_trajectory() -> void:
|
|
trajectory_mesh = MultiMeshInstance3D.new()
|
|
var mm = MultiMesh.new()
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.instance_count = 60
|
|
trajectory_mesh.multimesh = mm
|
|
var dot_mesh = SphereMesh.new()
|
|
dot_mesh.radius = 0.05
|
|
dot_mesh.height = 0.1
|
|
var dot_mat = StandardMaterial3D.new()
|
|
dot_mat.albedo_color = Color(1.0, 1.0, 1.0, 0.5)
|
|
dot_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
dot_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
dot_mesh.material = dot_mat
|
|
trajectory_mesh.multimesh.mesh = dot_mesh
|
|
trajectory_mesh.visible = false
|
|
get_tree().current_scene.call_deferred("add_child", trajectory_mesh)
|
|
|
|
var _last_hit_sound_time: int = 0
|
|
|
|
func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
|
|
if not camera or not _damage_layer: return
|
|
|
|
var label = load("res://ui/floating_damage_text.gd").new()
|
|
label.text = str(round(amount))
|
|
label.add_theme_font_size_override("font_size", 28)
|
|
# The theme's papaya and its violet ink, not an approximate orange on pure
|
|
# black — these numbers fly over the same 3D scene the rest of the HUD does.
|
|
label.add_theme_color_override("font_color", UITheme.PAPAYA)
|
|
label.add_theme_color_override("font_outline_color", UITheme.INK)
|
|
label.add_theme_constant_override("outline_size", 7)
|
|
|
|
# Small random offset in 3D space so multiple pellets don't perfectly overlap
|
|
var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
|
|
label.target_pos = hit_pos + offset
|
|
label.camera = camera
|
|
|
|
_damage_layer.add_child(label)
|
|
|
|
# Only play the hit sound and flash the marker once per frame (or roughly every 16ms)
|
|
# This prevents the shotgun from stacking 8 hit sounds in a single frame.
|
|
var now = Time.get_ticks_msec()
|
|
if now - _last_hit_sound_time > 10:
|
|
_last_hit_sound_time = now
|
|
hit_player.play()
|
|
# The confirmation is part of the reticle now, so it lands where the eye
|
|
# already is and shares its ink outline.
|
|
if is_instance_valid(_hud):
|
|
_hud.confirm_hit()
|
|
|
|
func apply_impulse(force: Vector3) -> void:
|
|
velocity += force
|
|
# Force an upward launch to break ground contact
|
|
if force.y > 0.1:
|
|
# Tiny upward bump helps move_and_slide detach from the floor
|
|
global_position.y += 0.1
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func server_apply_impulse(force: Vector3) -> void:
|
|
if not multiplayer.is_server(): return
|
|
var sender = multiplayer.get_remote_sender_id()
|
|
if sender != 1 and sender != str(name).to_int(): return
|
|
# Movement is client-authoritative, so the impulse must land on the peer
|
|
# that simulates this body.
|
|
if is_multiplayer_authority():
|
|
apply_impulse(force)
|
|
else:
|
|
authority_apply_impulse.rpc_id(get_multiplayer_authority(), force)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func authority_apply_impulse(force: Vector3) -> void:
|
|
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
if multiplayer.get_remote_sender_id() != 1: return
|
|
if is_multiplayer_authority():
|
|
apply_impulse(force)
|
|
|
|
func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void:
|
|
var attacker_id = 0
|
|
var weapon_name = "Killed"
|
|
|
|
if _source:
|
|
if _source.has_method("get_multiplayer_authority"):
|
|
attacker_id = _source.get_multiplayer_authority()
|
|
elif "owner_player" in _source and _source.owner_player:
|
|
attacker_id = int(str(_source.owner_player.name))
|
|
elif _source is CharacterBody3D:
|
|
attacker_id = int(str(_source.name))
|
|
|
|
if _source.has_method("get_weapon_name"):
|
|
weapon_name = _source.get_weapon_name()
|
|
|
|
# If this is singleplayer offline, just call it directly
|
|
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
|
rpc_take_damage(amount, _hit_pos, attacker_id, weapon_name, impulse)
|
|
else:
|
|
# Tell the server to handle the damage and broadcast it to everyone!
|
|
server_take_damage.rpc_id(1, amount, _hit_pos, attacker_id, weapon_name, impulse)
|
|
|
|
@rpc("any_peer", "call_local", "unreliable")
|
|
func server_play_fire_effects(origin: Vector3, target_or_dir: Vector3, weapon_name: String, is_hitscan: bool) -> void:
|
|
if not multiplayer.is_server(): return
|
|
rpc_play_fire_effects.rpc(origin, target_or_dir, weapon_name, is_hitscan)
|
|
|
|
@rpc("any_peer", "call_local", "unreliable")
|
|
func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name: String, is_hitscan: bool) -> void:
|
|
# Local player already spawned effects locally with 0 latency
|
|
if is_multiplayer_authority(): return
|
|
|
|
# Positional gunshot for the remote shooter — occlusion + reflections via
|
|
# AudioManager make it locatable ("that came from behind the wooden wall").
|
|
var am = get_tree().root.get_node_or_null("AudioManager")
|
|
if am:
|
|
var sid: String = am.weapon_sound_id(_weapon_name)
|
|
if sid != "" and am.has_sound(sid):
|
|
am.play_3d(sid, origin)
|
|
|
|
# Visible shot kick on the remote shooter's model
|
|
var rv = get_visual_model()
|
|
if rv and rv.has_method("add_gun_recoil"):
|
|
rv.add_gun_recoil()
|
|
|
|
if is_hitscan:
|
|
# Spawn a standalone tracer directly into the scene
|
|
_spawn_remote_tracer(origin, target_or_dir)
|
|
else:
|
|
# Spawn a visual-only projectile flying from origin along the direction
|
|
_spawn_remote_projectile(origin, target_or_dir, _weapon_name)
|
|
|
|
func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
|
|
HitscanTracer.spawn_bolt(get_tree().current_scene, origin, target)
|
|
|
|
func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void:
|
|
# Try to find the actual weapon to spawn its custom projectile so it looks identical!
|
|
if camera:
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman:
|
|
var target_weapon = null
|
|
for w in wman.weapons.values():
|
|
if "weapon_name" in w and w.weapon_name == weapon_name:
|
|
target_weapon = w
|
|
break
|
|
if target_weapon and target_weapon.has_method("_spawn_custom_projectile"):
|
|
target_weapon._spawn_custom_projectile(origin, fire_dir)
|
|
return
|
|
|
|
# Generic fallback if weapon not found (e.g. joined late and weapons didn't build yet)
|
|
var proj = Node3D.new()
|
|
proj.name = "RemoteProjectile"
|
|
|
|
if weapon_name == "Plasma Gun":
|
|
var mesh_instance = MeshInstance3D.new()
|
|
var p_mesh = SphereMesh.new()
|
|
p_mesh.radius = 0.08
|
|
p_mesh.height = 0.16
|
|
var mat = StandardMaterial3D.new()
|
|
mat.albedo_color = Color(0.2, 0.6, 1.0)
|
|
mat.emission_enabled = true
|
|
mat.emission = Color(0.2, 0.6, 1.0)
|
|
mat.emission_energy_multiplier = 5.0
|
|
p_mesh.material = mat
|
|
mesh_instance.mesh = p_mesh
|
|
proj.add_child(mesh_instance)
|
|
|
|
var light = OmniLight3D.new()
|
|
light.light_color = Color(0.2, 0.6, 1.0)
|
|
light.light_energy = 2.0
|
|
light.omni_range = 4.0
|
|
proj.add_child(light)
|
|
else:
|
|
# Generic rocket fallback
|
|
var mesh_inst = MeshInstance3D.new()
|
|
var p_mesh = CylinderMesh.new()
|
|
p_mesh.top_radius = 0.0
|
|
p_mesh.bottom_radius = 0.05
|
|
p_mesh.height = 0.2
|
|
var mat = StandardMaterial3D.new()
|
|
mat.albedo_color = Color(0.8, 0.2, 0.2)
|
|
mat.emission_enabled = true
|
|
mat.emission = Color(1.5, 0.3, 0.1)
|
|
p_mesh.material = mat
|
|
mesh_inst.mesh = p_mesh
|
|
mesh_inst.rotation.x = deg_to_rad(-90)
|
|
proj.add_child(mesh_inst)
|
|
|
|
var smoke = GPUParticles3D.new()
|
|
var smoke_mat = ParticleProcessMaterial.new()
|
|
smoke_mat.direction = Vector3(0, 0, 1)
|
|
smoke_mat.spread = 5.0
|
|
smoke_mat.initial_velocity_min = 0.0
|
|
smoke_mat.initial_velocity_max = 0.5
|
|
smoke_mat.gravity = Vector3(0, 0.5, 0)
|
|
smoke.process_material = smoke_mat
|
|
smoke.amount = 20
|
|
smoke.lifetime = 0.5
|
|
smoke.position = Vector3(0, 0, 0.1)
|
|
|
|
var smoke_mesh = SphereMesh.new()
|
|
smoke_mesh.radius = 0.04
|
|
var smoke_mesh_mat = StandardMaterial3D.new()
|
|
smoke_mesh_mat.albedo_color = Color(0.5, 0.5, 0.5, 0.6)
|
|
smoke_mesh_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
smoke_mesh.material = smoke_mesh_mat
|
|
smoke.draw_pass_1 = smoke_mesh
|
|
proj.add_child(smoke)
|
|
|
|
# Orient the projectile in the direction of fire
|
|
var up_vec = Vector3.UP
|
|
if abs(fire_dir.normalized().dot(up_vec)) > 0.99:
|
|
up_vec = Vector3.RIGHT
|
|
|
|
get_tree().current_scene.add_child(proj)
|
|
proj.global_position = origin
|
|
proj.look_at(origin + fire_dir, up_vec)
|
|
|
|
# Animate movement using tween at appropriate speed
|
|
var speed = 35.0 if weapon_name != "Plasma Gun" else 80.0
|
|
var end_pos = origin + fire_dir.normalized() * (speed * 5.0)
|
|
var tween = get_tree().create_tween()
|
|
tween.tween_property(proj, "global_position", end_pos, 5.0)
|
|
tween.tween_callback(proj.queue_free)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func server_play_explosion(pos: Vector3, radius: float) -> void:
|
|
if not multiplayer.is_server(): return
|
|
rpc_play_explosion.rpc(pos, radius)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func rpc_play_explosion(pos: Vector3, radius: float) -> void:
|
|
if is_multiplayer_authority(): return
|
|
|
|
ExplosionVFX.spawn(get_tree(), pos, radius)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
|
|
if not multiplayer.is_server(): return
|
|
|
|
# Friendly fire, decided by the MODE and enforced here — on the server, before
|
|
# the damage is broadcast — so a mode that turns it off turns it off for real
|
|
# rather than merely declining to award the kill. Team Deathmatch with damage
|
|
# that lands and a kill that does not count is worse than either.
|
|
var nm = get_node_or_null("/root/NetworkManager")
|
|
if nm and nm.has_method("can_damage") and attacker_id != 0:
|
|
var victim_id := int(str(name)) if str(name).is_valid_int() else 0
|
|
if victim_id != 0 and not nm.can_damage(attacker_id, victim_id):
|
|
return
|
|
|
|
# Broadcast damage event to all peers so the victim dies on all screens.
|
|
# Knockback is applied inside rpc_take_damage on the victim's own peer,
|
|
# since that peer simulates this body.
|
|
rpc_take_damage.rpc(amount, hit_pos, attacker_id, weapon_name, impulse)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
|
|
if is_dead:
|
|
if is_instance_valid(ragdoll_instance):
|
|
ragdoll_instance.apply_initial_velocities(Vector3.ZERO, impulse)
|
|
return
|
|
|
|
# Show damage number for the attacker
|
|
if not multiplayer.has_multiplayer_peer() or attacker_id == multiplayer.get_unique_id():
|
|
if is_multiplayer_authority():
|
|
spawn_damage_number(amount, hit_pos)
|
|
else:
|
|
var my_node = get_parent().get_node_or_null(str(attacker_id))
|
|
if my_node and my_node.has_method("spawn_damage_number"):
|
|
my_node.spawn_damage_number(amount, hit_pos)
|
|
|
|
# Show damage indicator and screen shake for the victim
|
|
if is_multiplayer_authority() and amount > 0.0 and (not multiplayer.has_multiplayer_peer() or attacker_id != multiplayer.get_unique_id()):
|
|
_show_damage_indicator(hit_pos)
|
|
|
|
if attacker_id != 0:
|
|
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
|
|
|
|
# Flinch: the victim's model plays a hit reaction on every screen.
|
|
if amount > 0.0:
|
|
var vm = get_visual_model()
|
|
if vm and vm.has_method("play_oneshot"):
|
|
vm.play_oneshot("Hit", 0.3)
|
|
|
|
# Knockback lands on the simulating peer (movement is client-authoritative)
|
|
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
|
|
apply_impulse(impulse)
|
|
|
|
time_since_last_damage = 0.0
|
|
|
|
if shield > 0.0:
|
|
if shield >= amount:
|
|
shield -= amount
|
|
amount = 0.0
|
|
else:
|
|
amount -= shield
|
|
shield = 0.0
|
|
|
|
if amount > 0.0:
|
|
health -= amount
|
|
if health <= 0.0:
|
|
health = 0.0
|
|
die(impulse)
|
|
|
|
if multiplayer.is_server():
|
|
var nm = get_node_or_null("/root/NetworkManager")
|
|
if nm and multiplayer.has_multiplayer_peer():
|
|
var killer_id = attacker_id
|
|
|
|
var assist_ids: Array = []
|
|
var now = Time.get_ticks_msec() / 1000.0
|
|
for aid in recent_attackers.keys():
|
|
if aid != killer_id and now - recent_attackers[aid] <= 10.0:
|
|
assist_ids.append(aid)
|
|
|
|
var real_victim_name = "Player " + str(name)
|
|
if "player_name" in self and self.player_name != "":
|
|
real_victim_name = self.player_name
|
|
elif str(name).to_int() == 1:
|
|
real_victim_name = "Host"
|
|
|
|
if nm.has_method("register_kill"):
|
|
var victim_id = str(name).to_int()
|
|
if victim_id == 0:
|
|
victim_id = 1 # Fallback to host if something is weird
|
|
nm.register_kill.rpc(victim_id, killer_id, weapon_name, real_victim_name, assist_ids)
|
|
|
|
func _show_damage_indicator(hit_pos: Vector3) -> void:
|
|
if not _damage_layer: return
|
|
|
|
var indicator = Polygon2D.new()
|
|
indicator.polygon = PackedVector2Array([
|
|
Vector2(0, -100),
|
|
Vector2(-20, -75),
|
|
Vector2(20, -75)
|
|
])
|
|
indicator.color = Color(1.0, 0.0, 0.0, 0.8)
|
|
indicator.position = get_viewport().get_visible_rect().size / 2.0
|
|
|
|
var forward = -global_transform.basis.z
|
|
forward.y = 0.0
|
|
if forward.length_squared() > 0.01:
|
|
forward = forward.normalized()
|
|
var right = global_transform.basis.x
|
|
right.y = 0.0
|
|
right = right.normalized()
|
|
|
|
var dir_to_hit = (hit_pos - global_position)
|
|
dir_to_hit.y = 0.0
|
|
if dir_to_hit.length_squared() > 0.01:
|
|
dir_to_hit = dir_to_hit.normalized()
|
|
var angle = atan2(dir_to_hit.dot(right), dir_to_hit.dot(forward))
|
|
indicator.rotation = angle
|
|
|
|
_damage_layer.add_child(indicator)
|
|
var tween = create_tween()
|
|
tween.tween_property(indicator, "color:a", 0.0, 1.0)
|
|
tween.tween_callback(indicator.queue_free)
|
|
|
|
if camera:
|
|
var shake_tween = create_tween()
|
|
shake_tween.tween_property(camera, "h_offset", randf_range(-0.2, 0.2), 0.05)
|
|
shake_tween.parallel().tween_property(camera, "v_offset", randf_range(-0.2, 0.2), 0.05)
|
|
shake_tween.chain().tween_property(camera, "h_offset", 0.0, 0.05)
|
|
shake_tween.parallel().tween_property(camera, "v_offset", 0.0, 0.05)
|
|
|
|
func get_weapon_name() -> String:
|
|
if camera:
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman:
|
|
var slot = wman.get("active_slot")
|
|
if slot != null and wman.weapons.has(slot):
|
|
var w = wman.weapons[slot]
|
|
if w and w.has_method("get_weapon_name"):
|
|
return w.get_weapon_name()
|
|
elif w:
|
|
var p = ""
|
|
if w.has_meta("script_path"):
|
|
p = w.get_meta("script_path").get_file().get_basename()
|
|
elif w.get_script():
|
|
p = w.get_script().resource_path.get_file().get_basename()
|
|
else:
|
|
p = "Killed"
|
|
return p.capitalize()
|
|
return "Killed"
|
|
|
|
func _ensure_machine() -> MovementStateMachine:
|
|
if is_instance_valid(_machine):
|
|
return _machine
|
|
if has_node("MovementStateMachine"):
|
|
var node = $MovementStateMachine
|
|
if node and node.has_method("switch_to") and node.has_method("register_chain_mechanic"):
|
|
_machine = node
|
|
return _machine
|
|
_machine = null
|
|
return null
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
# The owning peer reads input and feeds its own state machine directly.
|
|
# No server round-trip: movement responds on the same frame it's pressed.
|
|
if is_multiplayer_authority():
|
|
var raw_input := Vector2.ZERO
|
|
var input_jump := false
|
|
var input_jump_just := false
|
|
var input_crouch := false
|
|
var input_dash := false
|
|
var input_grapple := false
|
|
var input_grapple_just := false
|
|
var world_dir := Vector3.ZERO
|
|
|
|
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
|
raw_input = Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
|
var forward := -global_transform.basis.z
|
|
forward.y = 0.0
|
|
forward = forward.normalized()
|
|
var right := global_transform.basis.x
|
|
right.y = 0.0
|
|
right = right.normalized()
|
|
|
|
world_dir = (forward * (-raw_input.y) + right * raw_input.x)
|
|
if world_dir.length_squared() > 1.0:
|
|
world_dir = world_dir.normalized()
|
|
|
|
input_jump = Input.is_action_pressed("jump")
|
|
input_jump_just = Input.is_action_just_pressed("jump")
|
|
input_crouch = Input.is_action_pressed("crouch")
|
|
input_dash = Input.is_action_just_pressed("dash")
|
|
input_grapple = Input.is_action_pressed("grapple")
|
|
input_grapple_just = Input.is_action_just_pressed("grapple")
|
|
|
|
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
|
|
flashlight.visible = !flashlight.visible
|
|
|
|
if Input.is_action_just_pressed("toggle_camera_view"):
|
|
set_third_person(not third_person)
|
|
|
|
# Emote (B): HOLD to open the radial dial and point at a dance,
|
|
# release to commit. A tap too short to have aimed anything just
|
|
# toggles the last one, which is exactly what the button did before
|
|
# the wheel existed — so the old muscle memory still works.
|
|
if Input.is_action_just_pressed("emote"):
|
|
_emote_held = 0.0
|
|
if synced_is_dancing:
|
|
# Already dancing: the press stops it, and no wheel opens.
|
|
# Having to aim at something in order to STOP is the most
|
|
# annoying possible way to build this.
|
|
synced_is_dancing = false
|
|
_emote_held = -1.0
|
|
elif _emote_wheel:
|
|
_emote_wheel.open()
|
|
elif Input.is_action_pressed("emote") and _emote_held >= 0.0:
|
|
_emote_held += _delta
|
|
elif Input.is_action_just_released("emote") and _emote_held >= 0.0:
|
|
var aimed := _emote_wheel.close() if _emote_wheel else -1
|
|
# A tap replays the last emote; a hold plays whatever was aimed
|
|
# at. Either way the same grounded-and-slow gate applies.
|
|
var pick := aimed if aimed >= 0 else synced_dance_index
|
|
var m := _ensure_machine()
|
|
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
|
|
if m and m.current_state == "ground" and slow:
|
|
synced_dance_index = pick
|
|
synced_is_dancing = true
|
|
if synced_is_dancing:
|
|
var m2 := _ensure_machine()
|
|
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
|
|
var airborne: bool = m2 and m2.current_state != "ground"
|
|
if moving or airborne:
|
|
synced_is_dancing = false
|
|
# The wheel eats aiming while it is open, so the player picking an emote
|
|
# does not also spin their character round. Movement is deliberately NOT
|
|
# blocked — a wheel that roots you in the open is a wheel nobody uses.
|
|
if _emote_wheel and _emote_wheel.visible and head_pivot:
|
|
head_pivot.set_process_input(false)
|
|
elif head_pivot and not head_pivot.is_processing_input() and not is_dead:
|
|
head_pivot.set_process_input(true)
|
|
|
|
var machine := _ensure_machine()
|
|
if machine:
|
|
machine.input_dir = raw_input
|
|
machine.wish_dir_world = world_dir
|
|
machine.input_jump_pressed = input_jump
|
|
machine.input_jump_just_pressed = input_jump_just
|
|
machine.input_crouch = input_crouch
|
|
machine.input_dash = input_dash
|
|
machine.input_grapple = input_grapple
|
|
machine.input_grapple_just_pressed = input_grapple_just
|
|
|
|
var speed = velocity.length()
|
|
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
|
|
wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
|
|
if not wind_player.playing and wind_factor > 0.0:
|
|
wind_player.play()
|
|
|
|
# Anime speed lines: fade in past ~1.2x walk speed, spike on dash.
|
|
if is_instance_valid(_speedlines):
|
|
_speedline_burst = maxf(_speedline_burst - _delta * 2.5, 0.0)
|
|
var hs := Vector2(velocity.x, velocity.z).length()
|
|
var speed_intensity := clampf((hs - params.walk_speed * 1.2) / 12.0, 0.0, 0.85)
|
|
var target := maxf(speed_intensity, _speedline_burst)
|
|
var mat := _speedlines.material as ShaderMaterial
|
|
var cur: float = mat.get_shader_parameter("intensity")
|
|
mat.set_shader_parameter("intensity", lerpf(cur, target, 1.0 - exp(-10.0 * _delta)))
|
|
|
|
var sm := _ensure_machine()
|
|
if not sm:
|
|
return
|
|
|
|
# The owning peer publishes its grapple state for everyone's rope visuals
|
|
if is_multiplayer_authority():
|
|
synced_grapple_point = sm.grapple_point
|
|
synced_is_grapple_shooting = sm.is_grapple_shooting
|
|
|
|
# Update grapple rope visuals (runs on all clients for smooth interpolation)
|
|
if (synced_movement_state == "grapple" or synced_is_grapple_shooting) and camera:
|
|
grapple_rope.visible = true
|
|
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
|
|
var end_pos = synced_grapple_point
|
|
var dist = start_pos.distance_to(end_pos)
|
|
if dist > 0.01:
|
|
grapple_rope.global_position = (start_pos + end_pos) / 2.0
|
|
var up_vec = Vector3.UP
|
|
var dir = (end_pos - start_pos).normalized()
|
|
if abs(dir.dot(up_vec)) > 0.99:
|
|
up_vec = Vector3.RIGHT
|
|
grapple_rope.transform.basis = Basis.looking_at(dir, up_vec) * Basis.from_euler(Vector3(PI/2.0, 0, 0))
|
|
grapple_rope.scale = Vector3(1.0, dist, 1.0)
|
|
else:
|
|
if grapple_rope:
|
|
grapple_rope.visible = false
|
|
|
|
if synced_movement_state == "grapple" and velocity.length() > 5.0:
|
|
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -15.0, _delta * 10.0)
|
|
else:
|
|
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
|
|
|
|
# Local player's aim-down-sights state (drives the model's weapon raise).
|
|
synced_is_ads = _read_ads()
|
|
|
|
# Drive the visual model (skinned GLB or procedural) from local state
|
|
var visual = get_visual_model()
|
|
if visual:
|
|
var h_speed = Vector2(velocity.x, velocity.z).length()
|
|
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
|
|
if visual.has_method("set_locomotion"):
|
|
var d := _local_move_dir()
|
|
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
|
if visual.has_method("set_wall_side"):
|
|
visual.set_wall_side(sm.wall_side)
|
|
if visual.has_method("set_dancing"):
|
|
visual.set_dancing(synced_is_dancing, synced_dance_index)
|
|
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
|
|
visual.set_grapple_target(synced_grapple_point)
|
|
|
|
# Publish state for remote peers
|
|
synced_movement_state = sm.current_state
|
|
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
|
|
synced_is_crouching = sm.input_crouch
|
|
synced_wall_side = sm.wall_side
|
|
synced_position = position
|
|
synced_velocity = velocity
|
|
|
|
|
|
## Movement direction relative to facing: x = strafe (+right), y = forward
|
|
## (+forward). Derived from velocity so it works for local and remote players.
|
|
func _local_move_dir() -> Vector2:
|
|
var hspeed := Vector2(velocity.x, velocity.z).length()
|
|
if hspeed < 0.5:
|
|
return Vector2.ZERO
|
|
var local_vel := global_transform.basis.inverse() * velocity
|
|
return Vector2(local_vel.x / hspeed, -local_vel.z / hspeed)
|
|
|
|
|
|
## Whether the local player's active weapon is aiming down sights.
|
|
func _read_ads() -> bool:
|
|
if not is_instance_valid(camera):
|
|
return false
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
|
|
var w = wman.weapons[wman.active_slot]
|
|
if w and "is_ads" in w:
|
|
return w.is_ads
|
|
return false
|
|
|
|
|
|
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
|
if ev == "chain_updated":
|
|
chain_updated.emit(data.count, data.bonus)
|
|
elif ev == "grapple_shoot":
|
|
grapple_shoot_player.play()
|
|
elif ev == "grapple_latch":
|
|
grapple_latch_player.play()
|
|
elif ev == "dash":
|
|
_speedline_burst = 1.0
|
|
elif ev == "land":
|
|
if land_player:
|
|
var heavy: bool = data.get("heavy", false)
|
|
land_player.pitch_scale = (0.8 if heavy else 1.0) * randf_range(0.95, 1.05)
|
|
land_player.volume_db = 2.0 if heavy else -3.0
|
|
land_player.play()
|
|
|
|
func _process(delta: float) -> void:
|
|
# Remote players: interpolate toward the owner's synced transform and
|
|
# apply synced movement state to their visual model
|
|
if not is_multiplayer_authority():
|
|
if synced_loadout_ready and not has_meta("remote_weapons_built"):
|
|
set_meta("remote_weapons_built", true)
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman and wman.has_method("_build_remote_loadout"):
|
|
wman._build_remote_loadout(synced_loadout_p1, synced_loadout_p2, synced_loadout_sp, synced_loadout_melee)
|
|
|
|
# Skin can arrive/change after spawn — rebuild the model when it does
|
|
if synced_skin_id != _applied_skin_id:
|
|
_apply_skin_model(synced_skin_id)
|
|
|
|
# Snapshot interpolation: chase the owner's last known position with a
|
|
# little velocity extrapolation so fast targets stay accurate between
|
|
# packets. Snap on big desyncs (respawn/teleport).
|
|
if not is_dead and synced_position != Vector3.ZERO:
|
|
var target := synced_position + synced_velocity * NET_EXTRAPOLATION
|
|
if position.distance_to(target) > NET_SNAP_DISTANCE:
|
|
position = target
|
|
else:
|
|
position = position.lerp(target, 1.0 - exp(-NET_LERP_RATE * delta))
|
|
velocity = synced_velocity
|
|
|
|
var visual = get_visual_model()
|
|
if visual:
|
|
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
|
|
if visual.has_method("set_locomotion"):
|
|
var d := _local_move_dir()
|
|
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
|
if visual.has_method("set_wall_side"):
|
|
visual.set_wall_side(synced_wall_side)
|
|
if visual.has_method("set_dancing"):
|
|
visual.set_dancing(synced_is_dancing, synced_dance_index)
|
|
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
|
|
visual.set_grapple_target(synced_grapple_point)
|
|
# Upper body follows the owner's synced camera pitch
|
|
if visual.has_method("set_aim_pitch") and head_pivot:
|
|
visual.set_aim_pitch(head_pivot.rotation.x)
|
|
# Replayed one-shot actions (reload, grenade throw)
|
|
if visual.has_method("play_action") and synced_action_seq != _last_action_seq:
|
|
_last_action_seq = synced_action_seq
|
|
visual.play_action(synced_action)
|
|
# Check for weapon changes
|
|
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
|
|
visual.set_weapon(synced_weapon_path)
|
|
visual.set_meta("current_weapon_path", synced_weapon_path)
|
|
|
|
# Positional footsteps for remote players (their movement states don't
|
|
# run here). Occlusion via AudioManager makes them read through walls
|
|
# correctly — the classic "someone's above me on the wooden platform".
|
|
if not is_dead and synced_movement_state == "ground" and synced_movement_speed > 1.0:
|
|
_remote_footstep_timer -= delta
|
|
if _remote_footstep_timer <= 0.0:
|
|
var am = get_tree().root.get_node_or_null("AudioManager")
|
|
if am and am.has_sound("footstep"):
|
|
am.play_3d("footstep", global_position + Vector3(0, -0.8, 0), -8.0)
|
|
_remote_footstep_timer = maxf(0.2, 3.0 / synced_movement_speed)
|
|
else:
|
|
_remote_footstep_timer = 0.0
|
|
return
|
|
|
|
# Local third-person model: aim pitch + reload one-shot (also broadcast
|
|
# to remote peers via the synced action counter)
|
|
var lvisual = get_visual_model()
|
|
if lvisual:
|
|
if lvisual.has_method("set_aim_pitch") and head_pivot:
|
|
lvisual.set_aim_pitch(head_pivot.rotation.x)
|
|
var lw = _active_weapon()
|
|
var now_reloading: bool = lw != null and "reloading" in lw and lw.reloading
|
|
if now_reloading and not _was_reloading:
|
|
_trigger_action("reload")
|
|
_was_reloading = now_reloading
|
|
# Shot kick on our OWN third-person model. server_play_fire_effects
|
|
# only fires for REMOTE shooters, so without this the owner sees no
|
|
# recoil at all in third person. Detect it from the ammo counter.
|
|
var ammo_now: int = -1
|
|
if lw:
|
|
if "current_ammo" in lw:
|
|
ammo_now = lw.current_ammo
|
|
elif "shells" in lw:
|
|
ammo_now = lw.shells
|
|
if ammo_now >= 0 and _last_local_ammo >= 0 and ammo_now < _last_local_ammo \
|
|
and lvisual.has_method("add_gun_recoil"):
|
|
lvisual.add_gun_recoil()
|
|
_last_local_ammo = ammo_now
|
|
|
|
# Vitals are pushed (the controller owns health and shield); ammo, the
|
|
# reticle bloom and the reload ring are pulled by the HUD from the weapon,
|
|
# which is the authority on all three.
|
|
if is_instance_valid(_hud):
|
|
_hud.set_vitals(health, max_health, shield, max_shield)
|
|
|
|
if is_multiplayer_authority() and not is_dead:
|
|
if is_holding_grenade and grenades > 0:
|
|
_draw_trajectory()
|
|
elif is_instance_valid(trajectory_mesh):
|
|
trajectory_mesh.visible = false
|
|
|
|
if is_dead:
|
|
# Continuously follow the ragdoll torso
|
|
if is_instance_valid(ragdoll_instance) and is_instance_valid(camera):
|
|
var torso = ragdoll_instance.torso_body
|
|
if is_instance_valid(torso) and ragdoll_instance.has_meta("spring_arm"):
|
|
var spring_arm = ragdoll_instance.get_meta("spring_arm")
|
|
if is_instance_valid(spring_arm):
|
|
# Follow torso position exactly to avoid lerp bobbing
|
|
spring_arm.global_position = torso.global_position
|
|
|
|
if death_screen and death_screen.visible:
|
|
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) or Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT) or Input.is_key_pressed(KEY_SPACE) or Input.is_key_pressed(KEY_ENTER) or Input.is_key_pressed(KEY_ESCAPE) == false and (Input.is_action_just_pressed("ui_accept") or Input.is_action_just_pressed("jump") or Input.is_action_just_pressed("fire")):
|
|
_on_respawn_pressed()
|
|
|
|
return
|
|
|
|
# Shield recharge logic
|
|
time_since_last_damage += delta
|
|
if time_since_last_damage >= 5.0 and shield < max_shield:
|
|
shield += 20.0 * delta
|
|
if shield > max_shield:
|
|
shield = max_shield
|
|
|
|
## Build the first-person HUD.
|
|
##
|
|
## Everything it draws lives in ui/player_hud.gd now. This used to be 105 lines
|
|
## of stock ProgressBars and a plain-black death screen inlined here, which is
|
|
## why none of it shared the game's look: a HUD assembled inside a 1500-line
|
|
## movement controller is a HUD nobody styles.
|
|
func _setup_hud() -> void:
|
|
# Match state (timer, score, killfeed, scoreboard) is a separate overlay and
|
|
# is deliberately not part of the player's own HUD.
|
|
if is_multiplayer_authority():
|
|
var match_hud_scene = load("res://ui/match_hud.tscn")
|
|
if match_hud_scene:
|
|
add_child(match_hud_scene.instantiate())
|
|
|
|
_hud = PlayerHUD.new()
|
|
_hud.name = "PlayerHUD"
|
|
_hud.player = self
|
|
add_child(_hud)
|
|
|
|
# The emote dial rides on the HUD's canvas, above the viewmodel.
|
|
_emote_wheel = EmoteWheel.new()
|
|
_emote_wheel.name = "EmoteWheel"
|
|
_hud.add_child(_emote_wheel)
|
|
|
|
# The controller still owns these two — it toggles the death screen on death
|
|
# and the ring is read by the reload logic — so keep the references it had.
|
|
death_screen = _hud.death_screen
|
|
reload_ring = _hud.reload_ring
|
|
_hud.set_vitals(health, max_health, shield, max_shield)
|
|
|
|
|
|
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
|
if is_dead: return
|
|
is_dead = true
|
|
|
|
# Return to first person so the toggle state doesn't fight the death cam.
|
|
if is_multiplayer_authority() and third_person:
|
|
set_third_person(false)
|
|
|
|
# Disable movement state machine inputs
|
|
if _machine:
|
|
_machine.process_mode = Node.PROCESS_MODE_DISABLED
|
|
|
|
# Hide old animated model
|
|
var humanoid = get_node_or_null("HumanoidModel")
|
|
if humanoid:
|
|
humanoid.visible = false
|
|
var skinned = get_node_or_null("SkinnedModel")
|
|
if skinned:
|
|
skinned.visible = false
|
|
|
|
# Disable collision so player doesn't block bullets
|
|
var col = get_node_or_null("CollisionShape3D")
|
|
if col:
|
|
col.set_deferred("disabled", true)
|
|
|
|
# Spawn true physics ragdoll
|
|
death_count += 1
|
|
var ragdoll = load("res://characters/procedural_ragdoll.gd").new()
|
|
ragdoll.name = "Ragdoll_%s_%d" % [self.name, death_count]
|
|
ragdoll_instance = ragdoll
|
|
get_tree().current_scene.add_child(ragdoll)
|
|
ragdoll.global_transform = global_transform
|
|
ragdoll.build_ragdoll(Color(0.0, 0.75, 0.75)) # Miku teal — matches player 1 skin
|
|
|
|
# Wait a frame for physics to initialize then apply velocity
|
|
get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll, velocity, impulse))
|
|
|
|
# Move camera to 3rd person view using SpringArm3D
|
|
if is_instance_valid(camera):
|
|
# Hide the weapon manager so it isn't floating in front of the 3rd person camera
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman:
|
|
wman.visible = false
|
|
wman.process_mode = Node.PROCESS_MODE_DISABLED
|
|
|
|
var cam_trans = camera.global_transform
|
|
camera.get_parent().remove_child(camera)
|
|
|
|
# Determine fixed offset direction for camera to follow from
|
|
var back_dir = cam_trans.basis.z.normalized()
|
|
back_dir.y = 0 # Flatten it
|
|
if back_dir.length_squared() < 0.1:
|
|
back_dir = Vector3.BACK
|
|
else:
|
|
back_dir = back_dir.normalized()
|
|
|
|
var spring_arm = SpringArm3D.new()
|
|
spring_arm.name = "DeathSpringArm"
|
|
spring_arm.spring_length = 1.5
|
|
spring_arm.margin = 0.5
|
|
|
|
# Give it collision capability against the world but ignore the ragdoll shapes
|
|
spring_arm.collision_mask = 1 # Environment mask
|
|
for child in ragdoll.get_children():
|
|
if child is CollisionObject3D:
|
|
spring_arm.add_excluded_object(child.get_rid())
|
|
|
|
get_tree().current_scene.add_child(spring_arm)
|
|
|
|
# Put the arm at the torso's current position
|
|
spring_arm.global_position = ragdoll.torso_body.global_position
|
|
|
|
# Calculate upper-right position
|
|
var right_dir = back_dir.cross(Vector3.UP).normalized()
|
|
if right_dir.length_squared() < 0.1:
|
|
right_dir = Vector3.RIGHT
|
|
|
|
# The vector only defines direction, length is controlled by spring_length
|
|
var desired_cam_pos = spring_arm.global_position + back_dir * 2.5 + right_dir * 1.5 + Vector3.UP * 1.5
|
|
|
|
# SpringArm3D extends along its local +Z axis.
|
|
# look_at() points the local -Z axis at the target.
|
|
# To point +Z at desired_cam_pos, we must look_at the OPPOSITE direction.
|
|
var opposite_dir = spring_arm.global_position + (spring_arm.global_position - desired_cam_pos)
|
|
spring_arm.look_at(opposite_dir, Vector3.UP)
|
|
|
|
spring_arm.add_child(camera)
|
|
|
|
# Since +Z points outwards to the camera, -Z points inwards to the torso.
|
|
# Camera3D natively looks down -Z. Setting rotation to ZERO makes it perfectly look at the torso!
|
|
camera.position = Vector3.ZERO
|
|
camera.rotation = Vector3.ZERO
|
|
|
|
ragdoll_instance.set_meta("spring_arm", spring_arm)
|
|
|
|
# Show death screen
|
|
if is_multiplayer_authority():
|
|
death_screen.visible = true
|
|
set_process_input(true)
|
|
|
|
func _apply_ragdoll_velocity(ragdoll, l_vel: Vector3, imp: Vector3) -> void:
|
|
if is_instance_valid(ragdoll) and ragdoll.has_method("apply_initial_velocities"):
|
|
ragdoll.apply_initial_velocities(l_vel, imp)
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if not is_multiplayer_authority(): return
|
|
if is_dead and death_screen and death_screen.visible:
|
|
if event is InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton:
|
|
if event.is_pressed() and not event.is_echo():
|
|
_on_respawn_pressed()
|
|
|
|
if not is_dead and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
|
if event.is_action_pressed("grenade") and grenades > 0:
|
|
is_holding_grenade = true
|
|
elif event.is_action_released("grenade") and is_holding_grenade:
|
|
is_holding_grenade = false
|
|
_throw_grenade()
|
|
|
|
func _draw_trajectory() -> void:
|
|
if not camera or not trajectory_mesh: return
|
|
trajectory_mesh.visible = true
|
|
|
|
var throw_speed = 20.0
|
|
var throw_up_speed = 5.0
|
|
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
|
|
var gravity = 15.0 # Typical default
|
|
var current_pos = camera.global_position + camera.global_transform.basis.z * -0.5
|
|
var time_step = 0.05
|
|
var bounciness = 0.5
|
|
|
|
var space_state = get_world_3d().direct_space_state
|
|
|
|
for i in range(trajectory_mesh.multimesh.instance_count):
|
|
var t = Transform3D(Basis(), current_pos)
|
|
trajectory_mesh.multimesh.set_instance_transform(i, t)
|
|
|
|
var next_vel = throw_vel
|
|
next_vel.y -= gravity * time_step
|
|
var next_pos = current_pos + throw_vel * time_step
|
|
|
|
var query = PhysicsRayQueryParameters3D.create(current_pos, next_pos)
|
|
query.exclude = [get_rid()]
|
|
var hit = space_state.intersect_ray(query)
|
|
|
|
if hit:
|
|
current_pos = hit.position + hit.normal * 0.05
|
|
throw_vel = next_vel.bounce(hit.normal) * bounciness
|
|
else:
|
|
current_pos = next_pos
|
|
throw_vel = next_vel
|
|
|
|
## The local player's currently equipped weapon node (null if none).
|
|
func _active_weapon():
|
|
if not is_instance_valid(camera):
|
|
return null
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman and "active_slot" in wman and wman.weapons.has(wman.active_slot):
|
|
return wman.weapons[wman.active_slot]
|
|
return null
|
|
|
|
|
|
## Play a named one-shot on the local model and broadcast it to peers.
|
|
func _trigger_action(action: String) -> void:
|
|
synced_action = action
|
|
synced_action_seq += 1
|
|
var v = get_visual_model()
|
|
if v and v.has_method("play_action"):
|
|
v.play_action(action)
|
|
|
|
|
|
func _throw_grenade() -> void:
|
|
if grenades <= 0: return
|
|
grenades -= 1
|
|
_trigger_action("throw")
|
|
|
|
var throw_speed = 20.0
|
|
var throw_up_speed = 5.0
|
|
var throw_vel = camera.global_transform.basis.z * -throw_speed + Vector3.UP * throw_up_speed + velocity
|
|
var spawn_pos = camera.global_position + camera.global_transform.basis.z * -0.5
|
|
|
|
if multiplayer.has_multiplayer_peer() and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
|
server_spawn_grenade.rpc_id(1, spawn_pos, throw_vel)
|
|
else:
|
|
_spawn_grenade_local(spawn_pos, throw_vel, 1)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func server_spawn_grenade(pos: Vector3, vel: Vector3) -> void:
|
|
if not multiplayer.is_server(): return
|
|
var id = multiplayer.get_remote_sender_id()
|
|
client_spawn_grenade.rpc(pos, vel, id)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func client_spawn_grenade(pos: Vector3, vel: Vector3, owner_id: int) -> void:
|
|
_spawn_grenade_local(pos, vel, owner_id)
|
|
|
|
func _spawn_grenade_local(pos: Vector3, vel: Vector3, owner_id: int) -> void:
|
|
var g_scene = load("res://weapons/grenade_projectile.gd")
|
|
if not g_scene: return
|
|
var g = g_scene.new()
|
|
# Set position before adding to tree to avoid global_transform warning
|
|
g.position = pos
|
|
g.velocity = vel
|
|
|
|
var p = null
|
|
if owner_id == multiplayer.get_unique_id():
|
|
p = self
|
|
else:
|
|
var root = get_tree().current_scene
|
|
if root.has_node("Players/" + str(owner_id)):
|
|
p = root.get_node("Players/" + str(owner_id))
|
|
g.owner_player = p
|
|
get_tree().current_scene.add_child(g)
|
|
|
|
func _on_respawn_pressed() -> void:
|
|
if death_screen:
|
|
death_screen.visible = false
|
|
if multiplayer.is_server():
|
|
var spawn_pos = Vector3(randf_range(-5, 5), 5, randf_range(-5, 5))
|
|
rpc_respawn.rpc(spawn_pos)
|
|
else:
|
|
rpc_request_respawn.rpc_id(1)
|
|
|
|
var _last_respawn_request_time: float = 0.0
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func rpc_request_respawn() -> void:
|
|
if not multiplayer.is_server():
|
|
return
|
|
var sender_id = multiplayer.get_remote_sender_id()
|
|
if sender_id != 1 and sender_id != str(name).to_int():
|
|
return
|
|
|
|
var now = Time.get_ticks_msec() / 1000.0
|
|
if now - _last_respawn_request_time < 0.5:
|
|
return
|
|
_last_respawn_request_time = now
|
|
|
|
if is_dead:
|
|
var spawn_pos = Vector3(randf_range(-5, 5), 5, randf_range(-5, 5))
|
|
rpc_respawn.rpc(spawn_pos)
|
|
else:
|
|
# Deal 9999 damage to trigger the full death sequence locally and over the network
|
|
take_damage(9999, global_position, null, Vector3.ZERO)
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func rpc_respawn(spawn_pos: Vector3) -> void:
|
|
if is_dead:
|
|
is_dead = false
|
|
health = max_health
|
|
shield = max_shield
|
|
time_since_last_damage = 0.0
|
|
grenades = 2
|
|
is_holding_grenade = false
|
|
|
|
if is_instance_valid(ragdoll_instance):
|
|
if ragdoll_instance.has_meta("spring_arm"):
|
|
var sa = ragdoll_instance.get_meta("spring_arm")
|
|
if is_instance_valid(sa):
|
|
sa.queue_free()
|
|
ragdoll_instance.queue_free()
|
|
|
|
visible = true
|
|
process_mode = Node.PROCESS_MODE_INHERIT
|
|
|
|
# Restore camera parent
|
|
if is_instance_valid(camera) and is_instance_valid(head_pivot):
|
|
var p = camera.get_parent()
|
|
if p != head_pivot:
|
|
p.remove_child(camera)
|
|
head_pivot.add_child(camera)
|
|
camera.position = Vector3.ZERO
|
|
camera.rotation = Vector3.ZERO
|
|
|
|
# Re-enable movement state machine
|
|
if _machine:
|
|
_machine.process_mode = Node.PROCESS_MODE_INHERIT
|
|
|
|
# Re-enable collision
|
|
var col = get_node_or_null("CollisionShape3D")
|
|
if col:
|
|
col.set_deferred("disabled", false)
|
|
|
|
var visual_model = get_visual_model()
|
|
if visual_model:
|
|
visual_model.visible = true
|
|
var humanoid_model = get_node_or_null("HumanoidModel")
|
|
if humanoid_model and humanoid_model != visual_model:
|
|
humanoid_model.visible = not is_instance_valid(skinned_model)
|
|
|
|
# Everyone applies the broadcast spawn position; the owning peer also
|
|
# resets its synced transform so remotes snap instead of lerping across
|
|
# the map.
|
|
position = spawn_pos
|
|
velocity = Vector3.ZERO
|
|
if is_multiplayer_authority():
|
|
synced_position = spawn_pos
|
|
synced_velocity = Vector3.ZERO
|
|
|
|
# Local client resets UI and rebuilds weapons for their view
|
|
if is_multiplayer_authority():
|
|
if death_screen:
|
|
death_screen.visible = false
|
|
|
|
# Optional: reset weapons
|
|
var wman = camera.get_node_or_null("WeaponManager") if camera else null
|
|
if wman:
|
|
wman.visible = true
|
|
wman.process_mode = Node.PROCESS_MODE_INHERIT
|
|
var cv = wman.get("canvas_layer")
|
|
if cv:
|
|
cv.visible = true
|
|
if wman.has_method("_build_loadout"):
|
|
wman._build_loadout()
|