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