Compare commits

..
17 Commits
Author SHA1 Message Date
Dotts 064bc90f53 Merge pull request 'Feat/10 adding multiplayer fundamentals' (#14) from feat/10-adding-multiplayer-fundamentals into main
Reviewed-on: #14
2026-06-06 19:31:25 -07:00
DottsGit 1a989f3586 feat: implement player movement controller and movement state machine system 2026-06-06 21:16:48 -04:00
DottsGit 30dbf7a4cd feat: implement player movement controller and initial weapon framework including double barrel shotgun and explosive projectiles 2026-06-06 21:09:40 -04:00
DottsGit 54dfd342c2 feat: implement walking and killable enemy dummy entities and player movement controller base 2026-06-06 21:00:45 -04:00
DottsGit eb9344c0bb feat: implement PlayerMovementController with support for movement states, multiplayer synchronization, and audio/UI systems 2026-06-06 20:51:34 -04:00
DottsGit 07a15fe998 fix: a lot of network sync fixes 2026-06-06 20:45:10 -04:00
DottsGit dbbfbef4a4 fix: a lot of networking bugs 2026-06-06 19:26:22 -04:00
DottsGit ff3e01a9e5 feat: implement procedural humanoid animations, modular weapon system, and dummy entity base classes 2026-06-06 19:10:29 -04:00
DottsGit fcdd75afc0 feat: add match HUD with scoreboard, timer, and dynamic killfeed 2026-06-06 15:19:59 -04:00
DottsGit cd034d1aea feat: add dust2 level builder, player movement controller, and UI menu components 2026-06-06 15:17:54 -04:00
DottsGit 31336fe870 feat: add Dust 2 level builder and implement pause menu UI with settings and loadout management 2026-06-06 14:49:45 -04:00
DottsGit acfb8bca16 feat: implement network manager, pause menu, and export configuration for project deployment 2026-06-06 14:33:43 -04:00
DottsGit d3680765da adj: respawning kills self 2026-06-06 14:23:28 -04:00
DottsGit 2a3e6a2c78 fix: fixing respawning 2026-06-06 14:18:45 -04:00
DottsGit 9adbff3968 feat: implement multiplayer network manager and player movement controller with base game entities and UI 2026-06-06 14:03:57 -04:00
DottsGit 168bd8f1ce feat: implement networked match HUD with real-time scoreboard and killfeed systems 2026-06-06 13:49:12 -04:00
DottsGit aefed46daf feat: adding basic multiplayer 2026-06-06 13:28:43 -04:00
33 changed files with 1934 additions and 243 deletions
+3
View File
@@ -51,3 +51,6 @@ user_settings/
*LF*
tags
*.log
Papaya-Shooter.pck
Papaya-Shooter.exe
Papaya-Shooter.console.exe
+2 -1
View File
@@ -36,6 +36,8 @@ var movement_speed: float = 0.0
var _anim_time: float = 0.0
func _ready() -> void:
rotation_degrees.y = 180
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.8
@@ -197,7 +199,6 @@ func set_weapon(script_path: String) -> void:
_set_shadows_recursive(w)
# Force position after the weapon's _build_model() sets it for 1st person
# root_pivot is rotated 180 deg around Y, so local +Z is FORWARD and local -X is RIGHT
w.position = Vector3(-0.15, 1.0, 0.4)
w.rotation_degrees = Vector3(0, 180, 0)
)
-17
View File
@@ -1,17 +0,0 @@
[gd_scene load_steps=2 format=3 uid="uid://player_base"]
[ext_resource type="Script" path="res://movement/player_movement_controller.gd" id="1"]
[node name="PlayerBase" type="CharacterBody3D"]
[node name="PlayerMovementController" type="Node" parent="."]
script = ExtResource("1")
[node name="MovementStateMachine" type="Node" parent="PlayerMovementController"]
[node name="Camera3D" type="Camera3D" parent="PlayerMovementController"]
[node name="CapsuleShape3D" type="CollisionShape3D" parent="."]
shape = CapsuleShape3D.new()
shape.radius = 0.4
shape.height = 1.8
+27 -11
View File
@@ -9,18 +9,18 @@ func build_ragdoll(color: Color) -> void:
mat.roughness = 0.8
# Create bodies
torso_body = _create_body(Vector3(0.4, 0.7, 0.25), mat, Vector3(0, 1.15, 0), 20.0)
var head_body = _create_body(Vector3(0.25, 0.25, 0.25), mat, Vector3(0, 1.65, 0), 5.0)
torso_body = _create_body(Vector3(0.4, 0.7, 0.25), mat, Vector3(0, 1.15, 0), 20.0, "Torso")
var head_body = _create_body(Vector3(0.25, 0.25, 0.25), mat, Vector3(0, 1.65, 0), 5.0, "Head")
var upper_arm_l_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(-0.28, 1.275, 0), 3.0)
var lower_arm_l_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(-0.28, 0.925, 0), 2.0)
var upper_arm_r_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(0.28, 1.275, 0), 3.0)
var lower_arm_r_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(0.28, 0.925, 0), 2.0)
var upper_arm_l_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(-0.28, 1.275, 0), 3.0, "UpperArmL")
var lower_arm_l_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(-0.28, 0.925, 0), 2.0, "LowerArmL")
var upper_arm_r_body = _create_body(Vector3(0.12, 0.35, 0.12), mat, Vector3(0.28, 1.275, 0), 3.0, "UpperArmR")
var lower_arm_r_body = _create_body(Vector3(0.1, 0.35, 0.1), mat, Vector3(0.28, 0.925, 0), 2.0, "LowerArmR")
var thigh_l_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(-0.12, 0.625, 0), 5.0)
var calf_l_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(-0.12, 0.175, 0), 3.0)
var thigh_r_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(0.12, 0.625, 0), 5.0)
var calf_r_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(0.12, 0.175, 0), 3.0)
var thigh_l_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(-0.12, 0.625, 0), 5.0, "ThighL")
var calf_l_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(-0.12, 0.175, 0), 3.0, "CalfL")
var thigh_r_body = _create_body(Vector3(0.15, 0.45, 0.15), mat, Vector3(0.12, 0.625, 0), 5.0, "ThighR")
var calf_r_body = _create_body(Vector3(0.13, 0.45, 0.13), mat, Vector3(0.12, 0.175, 0), 3.0, "CalfR")
add_child(torso_body)
add_child(head_body)
@@ -33,6 +33,21 @@ func build_ragdoll(color: Color) -> void:
add_child(thigh_r_body)
add_child(calf_r_body)
var is_server = not multiplayer.has_multiplayer_peer() or multiplayer.is_server()
var rep = SceneReplicationConfig.new()
for child in get_children():
if child is RigidBody3D:
rep.add_property(NodePath(str(child.name) + ":position"))
rep.add_property(NodePath(str(child.name) + ":rotation"))
if not is_server:
child.freeze = true
child.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
var sync = MultiplayerSynchronizer.new()
sync.name = "RagdollSync"
sync.replication_config = rep
add_child(sync)
# Need to wait a frame for paths to be valid before creating joints
call_deferred("_setup_joints", head_body,
upper_arm_l_body, lower_arm_l_body,
@@ -76,8 +91,9 @@ func apply_initial_velocities(linear_vel: Vector3, impulse: Vector3) -> void:
if is_instance_valid(torso_body):
torso_body.apply_central_impulse(impulse * 2.0)
func _create_body(size: Vector3, mat: Material, pos: Vector3, mass: float) -> RigidBody3D:
func _create_body(size: Vector3, mat: Material, pos: Vector3, mass: float, bname: String) -> RigidBody3D:
var body = RigidBody3D.new()
body.name = bname
body.position = pos
body.mass = mass
+11 -5
View File
@@ -28,12 +28,13 @@ func _build_geometry() -> void:
fill.light_energy = 0.3
_build_dust2_layout()
_build_player()
# Override player spawn point for Dust 2
if _player:
_player.global_position = Vector3(0, 1, 50)
_player.look_at(Vector3(0, 1, 0), Vector3.UP)
func _spawn_player(pid: int) -> CharacterBody3D:
var player = super._spawn_player(pid)
player.position = Vector3(randf_range(-2, 2), 1, 50 + randf_range(-2, 2))
# Looking at (0,1,0) from (0,1,50) is facing straight down the -Z axis, which is rotation = 0,0,0
player.rotation = Vector3.ZERO
return player
func _build_dust2_layout() -> void:
# Materials
@@ -208,6 +209,11 @@ func _build_dust2_layout() -> void:
add_child(combat_area)
func _build_dust2_dummies() -> void:
# Do not spawn dummies in real multiplayer matches
var nm = get_node_or_null("/root/NetworkManager")
if nm and nm.multiplayer.multiplayer_peer is ENetMultiplayerPeer:
return
var spawn_dummy = func(pos: Vector3, rot_y: float):
var dummy = load("res://entities/killable_dummy.gd").new()
dummy.position = pos
+98 -22
View File
@@ -25,6 +25,31 @@ func _ready() -> void:
_build_geometry()
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
spawner.name = "PlayerSpawner"
spawner.spawn_path = "." # Spawn directly as children of the level
spawner.spawn_function = _spawn_player
add_child(spawner)
var nm = get_node_or_null("/root/NetworkManager")
if nm and nm.multiplayer.is_server():
# Spawn existing players
for pid in nm.player_stats.keys():
spawner.spawn(pid)
# Spawn late joiners
nm.player_connected.connect(func(pid):
spawner.spawn(pid)
)
# Despawn on disconnect
nm.player_disconnected.connect(func(pid):
var p = get_node_or_null(str(pid))
if p:
p.queue_free()
)
func _build_materials() -> void:
pass
@@ -40,7 +65,6 @@ func _build_geometry() -> void:
_build_speed_corridor()
_build_target_dummy()
_build_lighting()
_build_player()
# ── Utility ───────────────────────────────────────────────────────────────────
@@ -261,12 +285,48 @@ func _build_lighting() -> void:
# ── Player ────────────────────────────────────────────────────────────────────
func _build_player() -> void:
func _spawn_player(pid: int) -> CharacterBody3D:
var player := CharacterBody3D.new()
player.name = "Player"
player.position = Vector3(0, 1.0, 0)
add_child(player)
_player = player
player.name = str(pid)
player.set_multiplayer_authority(pid)
# Use a slightly random spawn position to avoid exact overlapping
player.position = Vector3(randf_range(-2, 2), 2.0, randf_range(-2, 2))
# Server Synchronizer (Host is the ground truth)
var server_sync = MultiplayerSynchronizer.new()
server_sync.name = "ServerSynchronizer"
server_sync.set_multiplayer_authority(1) # Host always controls these
var server_rep_config = SceneReplicationConfig.new()
server_rep_config.add_property(":position")
server_rep_config.add_property(":synced_movement_state")
server_rep_config.add_property(":synced_movement_speed")
server_rep_config.add_property(":synced_is_crouching")
server_rep_config.add_property(":health")
server_rep_config.add_property(":shield")
server_rep_config.add_property(":is_dead")
server_rep_config.add_property(":synced_grapple_point")
server_rep_config.add_property(":synced_is_grapple_shooting")
server_sync.replication_config = server_rep_config
player.add_child(server_sync)
# Client Synchronizer (Client dictates their aim and loadout setup)
var client_sync = MultiplayerSynchronizer.new()
client_sync.name = "MultiplayerSynchronizer" # Keep original name for compatibility if needed elsewhere
client_sync.set_multiplayer_authority(pid)
var client_rep_config = SceneReplicationConfig.new()
client_rep_config.add_property(":rotation")
client_rep_config.add_property("HeadPivot:rotation")
client_rep_config.add_property(":synced_weapon_path")
client_rep_config.add_property(":synced_loadout_p1")
client_rep_config.add_property(":synced_loadout_p2")
client_rep_config.add_property(":synced_loadout_sp")
client_rep_config.add_property(":synced_loadout_melee")
client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos")
client_sync.replication_config = client_rep_config
player.add_child(client_sync)
# Collision shape
var col_shape := CollisionShape3D.new()
@@ -282,11 +342,11 @@ func _build_player() -> void:
if mover_script:
player.set_script(mover_script)
# Humanoid Model for Player (Shadows only so it doesn't clip with camera)
# Humanoid Model for Player (Shadows only locally, visible to others)
var humanoid = load("res://characters/humanoid_model.gd").new()
humanoid.name = "HumanoidModel"
humanoid.color = Color(0.2, 0.4, 0.8) # Blueish for player
humanoid.shadows_only = true
humanoid.shadows_only = (pid == multiplayer.get_unique_id())
humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet
player.add_child(humanoid)
@@ -324,7 +384,7 @@ func _build_player() -> void:
var camera := Camera3D.new()
camera.name = "Camera3D"
camera.current = true
camera.current = (pid == multiplayer.get_unique_id())
camera.fov = 90.0
head_pivot.add_child(camera)
@@ -344,27 +404,43 @@ func _build_player() -> void:
# Store original capsule height
sm.original_capsule_height = capsule.height
# ── Bootstrap: set_script() doesn't trigger _ready() ─────────────────
# Set references BEFORE calling _ready() since sm._ready() will switch_to("ground")
# which needs machine.player to already be set.
# ── Dependencies ────────────────────────────────────────────────────────
# Set references before the node enters the tree. Godot will call _ready()
# automatically when MultiplayerSpawner adds the node to the scene.
sm.player = player
sm.params = player.params
head_pivot.params = player.params
# Initialize in dependency order: machine (enters initial state) → player → camera
if sm.has_method("_ready"):
sm._ready()
if player.has_method("_ready"):
player._ready()
if head_pivot.has_method("_ready"):
head_pivot._ready()
# Ensure processing is enabled
# Authority configuration
var is_local = (pid == multiplayer.get_unique_id())
if is_local:
_player = player
player.set_physics_process(true)
player.set_process(true)
sm.set_physics_process(true)
else:
# Disable physics processing/input for remote players
# but keep _process enabled so synced state updates the HumanoidModel
player.set_physics_process(false)
player.set_process(true)
player.set_process_input(false)
sm.set_physics_process(false)
sm.set_process(false)
sm.set_process_input(false)
head_pivot.set_process_input(false)
head_pivot.set_process(false)
head_pivot.set_physics_process(false)
var wman = camera.get_node_or_null("WeaponManager")
if wman:
wman.set_process_input(false)
wman.set_process(false)
wman.set_physics_process(false)
wman.visible = false
var cv = wman.get("canvas_layer")
if cv:
cv.visible = false
_player = player
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
+82 -43
View File
@@ -1,13 +1,14 @@
extends Area3D
class_name CombatArea
var time_left: float = 5.0
var time_safe: float = 0.0
var out_of_bounds_players: Dictionary = {} # body -> { time_left: 5.0 }
var recovery_players: Dictionary = {} # body -> { time_safe: 0.0, time_left: float }
var _player_out_of_bounds: Node3D = null
var _ui_layer: CanvasLayer = null
var _ui_label: Label = null
var _ui_overlay: ColorRect = null
var _is_local_player_out: bool = false
var _local_player_body: Node3D = null
func _ready() -> void:
collision_layer = 0
@@ -17,50 +18,85 @@ func _ready() -> void:
set_process(false)
func _process(delta: float) -> void:
if _player_out_of_bounds:
# If the player dies while out of bounds, stop the timer and remove the UI
if "is_dead" in _player_out_of_bounds and _player_out_of_bounds.is_dead:
_player_out_of_bounds = null
# Process players who are actively out of bounds
var to_kill: Array = []
for body in out_of_bounds_players.keys():
if "is_dead" in body and body.is_dead:
out_of_bounds_players.erase(body)
if body == _local_player_body:
_remove_ui()
return
continue
# Player is outside the combat area!
time_left -= delta
if time_left <= 0.0:
time_left = 0.0
_kill_player()
var data = out_of_bounds_players[body]
data.time_left -= delta
if data.time_left <= 0.0:
data.time_left = 0.0
to_kill.append(body)
# Update UI
if _ui_label and _ui_overlay:
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % time_left
_ui_overlay.color = Color(1.0, 0.0, 0.0, 0.8 * (1.0 - (time_left / 5.0)))
else:
# Player is inside the combat area, but might have a damaged timer
if time_left < 5.0:
time_safe += delta
if time_safe >= 10.0:
time_left = 5.0
time_safe = 0.0
set_process(false) # Fully recovered, sleep
if body == _local_player_body and _is_local_player_out and _ui_label and _ui_overlay:
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % data.time_left
_ui_overlay.color = Color(1.0, 0.0, 0.0, 0.8 * (1.0 - (data.time_left / 5.0)))
for body in to_kill:
_kill_player(body)
# Process players who returned but are recovering
var recovered: Array = []
for body in recovery_players.keys():
if "is_dead" in body and body.is_dead:
recovered.append(body)
continue
var data = recovery_players[body]
data.time_safe += delta
if data.time_safe >= 10.0:
recovered.append(body)
for body in recovered:
recovery_players.erase(body)
if out_of_bounds_players.is_empty() and recovery_players.is_empty():
set_process(false)
func _on_body_exited(body: Node3D) -> void:
if body.name == "Player":
# Don't trigger if the player is already dead or the scene is tearing down
if body is CharacterBody3D and body.has_method("take_damage") and str(body.name).is_valid_int():
if "is_dead" in body and body.is_dead:
return
if body.is_queued_for_deletion() or not is_inside_tree():
return
_player_out_of_bounds = body
time_safe = 0.0 # Reset recovery timer
var start_time = 5.0
if recovery_players.has(body):
start_time = recovery_players[body].time_left
recovery_players.erase(body)
out_of_bounds_players[body] = { "time_left": start_time }
var is_local = false
if multiplayer.has_multiplayer_peer():
if body.get_multiplayer_authority() == multiplayer.get_unique_id():
is_local = true
if is_local:
_is_local_player_out = true
_local_player_body = body
_create_ui()
set_process(true)
func _on_body_entered(body: Node3D) -> void:
if body == _player_out_of_bounds:
_player_out_of_bounds = null
if out_of_bounds_players.has(body):
var time_left_val = out_of_bounds_players[body].time_left
out_of_bounds_players.erase(body)
if time_left_val < 5.0:
recovery_players[body] = { "time_left": time_left_val, "time_safe": 0.0 }
set_process(true)
if body == _local_player_body and _is_local_player_out:
_remove_ui()
# set_process(true) stays on to tick the time_safe recovery
_is_local_player_out = false
_local_player_body = null
func _create_ui() -> void:
if _ui_layer: return
@@ -71,6 +107,7 @@ func _create_ui() -> void:
_ui_overlay = ColorRect.new()
_ui_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_ui_overlay.color = Color(1.0, 0.0, 0.0, 0.0)
_ui_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_ui_layer.add_child(_ui_overlay)
_ui_label = Label.new()
@@ -82,7 +119,10 @@ func _create_ui() -> void:
_ui_label.add_theme_color_override("font_color", Color(1, 0.2, 0.2))
_ui_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_ui_label.add_theme_constant_override("outline_size", 8)
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % time_left
var display_time = 5.0
if out_of_bounds_players.has(_local_player_body):
display_time = out_of_bounds_players[_local_player_body].time_left
_ui_label.text = "WARNING: RETURN TO COMBAT AREA\n%.1f" % display_time
_ui_layer.add_child(_ui_label)
@@ -96,15 +136,14 @@ func _remove_ui() -> void:
_ui_label = null
_ui_overlay = null
func _kill_player() -> void:
if _player_out_of_bounds:
# PlayerMovementController's take_damage requires (amount, source)
if _player_out_of_bounds.has_method("take_damage"):
_player_out_of_bounds.take_damage(9999, Vector3.ZERO)
elif "health" in _player_out_of_bounds:
_player_out_of_bounds.health = 0
if "is_dead" in _player_out_of_bounds:
_player_out_of_bounds.is_dead = true
func _kill_player(body: Node3D) -> void:
if body:
if multiplayer.is_server():
if body.has_method("take_damage"):
body.take_damage(9999, Vector3.ZERO, null, Vector3.ZERO)
_player_out_of_bounds = null
out_of_bounds_players.erase(body)
if body == _local_player_body and _is_local_player_out:
_remove_ui()
_is_local_player_out = false
_local_player_body = null
+56 -8
View File
@@ -11,8 +11,10 @@ var max_shield: float = 100.0
var shield: float = 100.0
var is_dead: bool = false
var time_since_last_damage: float = 0.0
var ragdoll_instance: Node3D = null
var pending_impulse: Vector3 = Vector3.ZERO
var recent_attackers: Dictionary = {}
func _ready() -> void:
add_to_group("enemies")
@@ -38,9 +40,33 @@ func _ready() -> void:
_update_label()
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void:
func _process(delta: float) -> void:
if is_dead:
return
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
_update_label()
func take_damage(amount: float, hit_position: Vector3, source: Node = null, _impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead:
return
var attacker_id = 0
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 attacker_id != 0:
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
time_since_last_damage = 0.0
var dmg_taken = amount
if shield > 0:
@@ -61,6 +87,29 @@ func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> v
if health <= 0:
_die(hit_position, source, dmg_taken)
var nm = get_node_or_null("/root/NetworkManager")
if nm and multiplayer.has_multiplayer_peer():
var killer_id = 0
var weapon_name = "Killed"
if source:
if source.has_method("get_multiplayer_authority"):
killer_id = source.get_multiplayer_authority()
elif "owner_player" in source and source.owner_player:
killer_id = int(str(source.owner_player.name))
elif source is CharacterBody3D:
killer_id = int(str(source.name))
if source.has_method("get_weapon_name"):
weapon_name = source.get_weapon_name()
var assist_ids: Array = []
var now = Time.get_ticks_msec() / 1000.0
for aid in recent_attackers.keys():
if now - recent_attackers[aid] <= 10.0:
assist_ids.append(aid)
nm.register_kill.rpc(-1, killer_id, weapon_name, "Target Dummy", assist_ids)
else:
_wiggle()
@@ -105,14 +154,13 @@ func _die(_hit_position: Vector3, source: Node, damage_amount: float) -> void:
linear_vel = hit_dir * (damage_amount * 0.02)
impulse = hit_dir * (damage_amount * 0.5)
get_tree().create_timer(0.01).timeout.connect(func():
if is_instance_valid(ragdoll_instance):
ragdoll_instance.apply_initial_velocities(linear_vel, impulse)
)
get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll_instance, linear_vel, impulse))
get_tree().create_timer(3.0).timeout.connect(func():
_respawn()
)
get_tree().create_timer(3.0).timeout.connect(_respawn)
func _apply_ragdoll_velocity(ragdoll: Node3D, l_vel: Vector3, imp: Vector3) -> void:
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(l_vel, imp)
func _respawn() -> void:
if is_instance_valid(ragdoll_instance):
+1 -1
View File
@@ -33,7 +33,7 @@ func _ready() -> void:
dps_label.modulate = Color(1, 0.8, 0)
add_child(dps_label)
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void:
func take_damage(amount: float, hit_position: Vector3, source: Node = null, impulse: Vector3 = Vector3.ZERO) -> void:
var now = Time.get_ticks_msec() / 1000.0
damage_history.append({"time": now, "amount": amount})
+56 -9
View File
@@ -11,6 +11,8 @@ var max_shield: float = 100.0
var shield: float = 100.0
var is_dead: bool = false
var time_since_last_damage: float = 0.0
var recent_attackers: Dictionary = {} # attacker_id: timestamp
var ragdoll_instance: Node3D = null
var pending_impulse: Vector3 = Vector3.ZERO
@@ -50,6 +52,14 @@ func _physics_process(delta: float) -> void:
if is_dead:
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
_update_label()
# Basic gravity
if not is_on_floor():
velocity.y -= 9.8 * delta
@@ -81,10 +91,24 @@ func _physics_process(delta: float) -> void:
var current_h_speed = Vector2(velocity.x, velocity.z).length()
visual_node.update_state("ground", current_h_speed)
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void:
func take_damage(amount: float, hit_position: Vector3, source: Node = null, impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead:
return
var attacker_id = 0
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 attacker_id != 0:
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
time_since_last_damage = 0.0
var dmg_taken = amount
if shield > 0:
if shield >= amount:
@@ -96,6 +120,8 @@ func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> v
if amount > 0:
health -= amount
if health <= 0:
health = 0
_update_label()
@@ -104,6 +130,29 @@ func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> v
if health <= 0:
_die(hit_position, source, dmg_taken)
var nm = get_node_or_null("/root/NetworkManager")
if nm and multiplayer.has_multiplayer_peer():
var killer_id = 0
var weapon_name = "Killed"
if source:
if source.has_method("get_multiplayer_authority"):
killer_id = source.get_multiplayer_authority()
elif "owner_player" in source and source.owner_player:
killer_id = int(str(source.owner_player.name))
elif source is CharacterBody3D:
killer_id = int(str(source.name))
if source.has_method("get_weapon_name"):
weapon_name = source.get_weapon_name()
var assist_ids: Array = []
var now = Time.get_ticks_msec() / 1000.0
for aid in recent_attackers.keys():
if now - recent_attackers[aid] <= 10.0:
assist_ids.append(aid)
nm.register_kill.rpc(-1, killer_id, weapon_name, "Walking Dummy", assist_ids)
else:
_wiggle()
@@ -149,15 +198,13 @@ func _die(_hit_position: Vector3, source: Node, damage_amount: float) -> void:
linear_vel = hit_dir * (damage_amount * 0.02)
impulse = hit_dir * (damage_amount * 0.5)
get_tree().create_timer(0.01).timeout.connect(func():
if is_instance_valid(ragdoll_instance):
# The ragdoll also inherits the current walking velocity natively
ragdoll_instance.apply_initial_velocities(velocity + linear_vel, impulse)
)
get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll_instance, velocity + linear_vel, impulse))
get_tree().create_timer(3.0).timeout.connect(func():
_respawn()
)
get_tree().create_timer(3.0).timeout.connect(_respawn)
func _apply_ragdoll_velocity(ragdoll: Node3D, l_vel: Vector3, imp: Vector3) -> void:
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(l_vel, imp)
func _respawn() -> void:
if is_instance_valid(ragdoll_instance):
+71
View File
@@ -0,0 +1,71 @@
[preset.0]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="./Papaya-Shooter.exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
patch_delta_min_reduction=0.1
patch_delta_include_filters="*"
patch_delta_exclude_filters=""
encryption_include_filters=""
encryption_exclude_filters=""
seed=0
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.0.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=2
binary_format/embed_pck=false
texture_format/s3tc_bptc=true
texture_format/etc2_astc=false
shader_baker/enabled=false
binary_format/architecture="x86_64"
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon="uid://cxxfrs5ov5vay"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version=""
application/product_version=""
application/company_name=""
application/product_name=""
application/file_description=""
application/copyright=""
application/trademarks=""
application/export_angle=0
application/export_d3d12=0
application/d3d12_agility_sdk_multiarch=true
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue"
ssh_remote_deploy/cleanup_script="Stop-ScheduledTask -TaskName godot_remote_debug -ErrorAction:SilentlyContinue
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false -ErrorAction:SilentlyContinue
Remove-Item -Recurse -Force '{temp_dir}'"
+246
View File
@@ -0,0 +1,246 @@
extends Node
signal player_connected(id: int)
signal player_disconnected(id: int)
signal connection_failed
signal connection_succeeded
signal match_state_updated
signal stats_updated
signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String)
const DEFAULT_PORT = 31415
var connected_players: Array[int] = []
# Dict of ID -> { "username": String, "kills": int, "deaths": int, "assists": int, "ping": int }
var player_stats: Dictionary = {}
var match_active: bool = false
var match_time_remaining: float = 0.0
var current_gamemode: String = "Deathmatch"
var current_scene_path: String = ""
var _ping_timer: Timer
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
_ping_timer = Timer.new()
_ping_timer.wait_time = 1.0
_ping_timer.autostart = false
_ping_timer.timeout.connect(_on_ping_timer)
add_child(_ping_timer)
func host_game(port: int = DEFAULT_PORT) -> Error:
var peer = ENetMultiplayerPeer.new()
var err = peer.create_server(port)
if err == OK:
multiplayer.multiplayer_peer = peer
connected_players.clear()
connected_players.append(1) # Host is 1
_init_player_stats(1, SettingsManager.username)
_ping_timer.start()
print("Hosting on port %s" % port)
return err
func join_game(ip: String, port: int = DEFAULT_PORT) -> Error:
var peer = ENetMultiplayerPeer.new()
var err = peer.create_client(ip, port)
if err == OK:
multiplayer.multiplayer_peer = peer
print("Connecting to %s:%s" % [ip, port])
return err
func disconnect_game() -> void:
multiplayer.multiplayer_peer = null
connected_players.clear()
player_stats.clear()
match_active = false
_ping_timer.stop()
func start_singleplayer_match(gamemode: String = "Deathmatch") -> void:
# Ensure peer is offline if not already connected
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
connected_players.clear()
connected_players.append(1)
_init_player_stats(1, SettingsManager.username)
match_active = true
match_time_remaining = 300.0
current_gamemode = gamemode
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
func _init_player_stats(id: int, username: String) -> void:
# Keep existing color if player re-joins, or generate new
var p_color = Color(randf_range(0.2, 1.0), randf_range(0.2, 1.0), randf_range(0.2, 1.0)).to_html(false)
player_stats[id] = {
"username": username,
"kills": 0,
"deaths": 0,
"assists": 0,
"ping": 0,
"color": p_color
}
stats_updated.emit()
func _on_peer_connected(id: int) -> void:
print("Player connected: ", id)
if not connected_players.has(id):
connected_players.append(id)
# If server, we wait for the client to register themselves via RPC
pass
func _on_peer_disconnected(id: int) -> void:
print("Player disconnected: ", id)
connected_players.erase(id)
if player_stats.has(id):
player_stats.erase(id)
player_disconnected.emit(id)
if multiplayer.is_server():
rpc("sync_player_stats", player_stats)
func _on_connected_to_server() -> void:
print("Successfully connected to server")
connection_succeeded.emit()
# Tell the server our username
rpc_id(1, "register_player", SettingsManager.username)
func _on_connection_failed() -> void:
print("Failed to connect to server")
multiplayer.multiplayer_peer = null
connection_failed.emit()
func _on_server_disconnected() -> void:
print("Server disconnected")
multiplayer.multiplayer_peer = null
connected_players.clear()
player_stats.clear()
match_active = false
@rpc("any_peer", "call_remote", "reliable")
func register_player(username: String) -> void:
if multiplayer.is_server():
var sender_id = multiplayer.get_remote_sender_id()
_init_player_stats(sender_id, username)
player_connected.emit(sender_id)
# Sync everyone's stats
rpc("sync_player_stats", player_stats)
# If a match is active, force the late-joiner to load the level
if match_active:
rpc_id(sender_id, "rpc_load_level", current_scene_path)
rpc_id(sender_id, "sync_match_state", match_active, match_time_remaining, current_gamemode)
@rpc("any_peer", "call_local", "reliable")
func update_username(new_name: String) -> void:
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
var sender_id = multiplayer.get_remote_sender_id()
if sender_id == 0:
sender_id = 1
if player_stats.has(sender_id):
player_stats[sender_id]["username"] = new_name
stats_updated.emit()
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
rpc("sync_player_stats", player_stats)
@rpc("authority", "call_local", "reliable")
func sync_player_stats(stats: Dictionary) -> void:
player_stats = stats
stats_updated.emit()
@rpc("authority", "call_local", "reliable")
func sync_match_state(active: bool, time_rem: float, mode: String) -> void:
match_active = active
match_time_remaining = time_rem
current_gamemode = mode
match_state_updated.emit()
@rpc("authority", "call_local", "reliable")
func rpc_load_level(scene_path: String) -> void:
print("Loading level: ", scene_path)
current_scene_path = scene_path
if multiplayer.is_server():
match_active = true
match_time_remaining = 300.0 # 5 minutes
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
get_tree().change_scene_to_file(scene_path)
func _process(delta: float) -> void:
if match_active:
if match_time_remaining > 0:
match_time_remaining -= delta
if match_time_remaining <= 0:
match_time_remaining = 0
match_active = false
# We emit this so HUDs can update their timers smoothly
match_state_updated.emit()
# --- PING SYSTEM ---
func _on_ping_timer() -> void:
if multiplayer.is_server():
var time = Time.get_ticks_msec()
for pid in connected_players:
if pid != 1:
rpc_id(pid, "receive_ping_request", time)
rpc("sync_player_stats", player_stats)
@rpc("authority", "call_remote", "unreliable")
func receive_ping_request(server_time: int) -> void:
rpc_id(1, "receive_ping_response", server_time)
@rpc("any_peer", "call_remote", "unreliable")
func receive_ping_response(server_time: int) -> void:
if multiplayer.is_server():
var sender_id = multiplayer.get_remote_sender_id()
var rtt = Time.get_ticks_msec() - server_time
if player_stats.has(sender_id):
player_stats[sender_id]["ping"] = rtt
# --- GAMEPLAY EVENTS ---
@rpc("any_peer", "call_local", "reliable")
func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_victim_name: String = "", assist_ids: Array = []) -> void:
if multiplayer.is_server():
if killer_id != 0 and killer_id != victim_id:
if player_stats.has(killer_id):
player_stats[killer_id]["kills"] += 1
if victim_id != -1:
if player_stats.has(victim_id):
player_stats[victim_id]["deaths"] += 1
for aid in assist_ids:
if player_stats.has(aid) and aid != killer_id and aid != victim_id:
player_stats[aid]["assists"] += 1
var v_name = player_stats[victim_id]["username"] if player_stats.has(victim_id) else (custom_victim_name if custom_victim_name != "" else "Unknown")
var k_name = player_stats[killer_id]["username"] if player_stats.has(killer_id) else "Unknown"
var v_color = player_stats[victim_id].get("color", "cccccc") if player_stats.has(victim_id) else "cccccc"
var k_color = player_stats[killer_id].get("color", "cccccc") if player_stats.has(killer_id) else "cccccc"
# Auto-fallback for singleplayer testing where player_stats might not be initialized
if not player_stats.has(killer_id) and killer_id == 1:
k_name = SettingsManager.username
k_color = "55aaff" # Default blueish for uninitialized local player
if victim_id == -1 and "Dummy" in custom_victim_name:
v_color = "ff5555" # Red for dummies
if killer_id == 0 or killer_id == victim_id:
k_name = "" # Suicide or world death
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
sync_player_stats.rpc(player_stats)
@rpc("authority", "call_local", "reliable")
func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
killfeed_event.emit(victim, killer, weapon, v_color, k_color)
+1
View File
@@ -0,0 +1 @@
uid://bcq67g48h8hka
+6 -6
View File
@@ -26,6 +26,7 @@ var default_bindings = {
var current_bindings = {}
var show_debug_ui: bool = false
var username: String = "Player"
var mouse_sensitivity: float = 0.002
var ads_sensitivity: float = 0.001
@@ -131,6 +132,7 @@ func _save_settings() -> void:
var save_data = {
"bindings": current_bindings,
"show_debug_ui": show_debug_ui,
"username": username,
"mouse_sensitivity": mouse_sensitivity,
"ads_sensitivity": ads_sensitivity,
"display_mode": display_mode,
@@ -165,12 +167,10 @@ func _load_settings() -> void:
for action in loaded_bindings:
if current_bindings.has(action):
current_bindings[action] = loaded_bindings[action]
if data.has("show_debug_ui"):
show_debug_ui = data["show_debug_ui"]
if data.has("mouse_sensitivity"):
mouse_sensitivity = data["mouse_sensitivity"]
if data.has("ads_sensitivity"):
ads_sensitivity = data["ads_sensitivity"]
if data.has("username"): username = data["username"]
if data.has("show_debug_ui"): show_debug_ui = data["show_debug_ui"]
if data.has("mouse_sensitivity"): mouse_sensitivity = data["mouse_sensitivity"]
if data.has("ads_sensitivity"): ads_sensitivity = data["ads_sensitivity"]
if data.has("display_mode"): display_mode = data["display_mode"]
if data.has("resolution_index"): resolution_index = data["resolution_index"]
if data.has("fps_cap"): fps_cap = data["fps_cap"]
+5
View File
@@ -64,6 +64,8 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
if player and not player.multiplayer.is_server(): return
# Update coyote time
if on_ground:
coyote_timer = params.coyote_time
@@ -92,6 +94,9 @@ func _physics_process(delta: float) -> void:
_try_start_grapple()
if is_grapple_shooting:
if not input_grapple:
is_grapple_shooting = false
else:
grapple_shoot_time += delta
if grapple_shoot_time >= grapple_travel_time:
is_grapple_shooting = false
+600 -87
View File
@@ -16,6 +16,7 @@ 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
@@ -26,10 +27,10 @@ var shield_label: Label
var death_screen: Control
var ragdoll_instance: Node3D
# Audio
var footstep_player: AudioStreamPlayer
var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer
var slide_player: AudioStreamPlayer
var wallrun_player: AudioStreamPlayer
@@ -48,6 +49,25 @@ var _hit_marker_tween: Tween
# 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.
var synced_movement_state: String = "idle"
var synced_movement_speed: float = 0.0
var synced_is_crouching: bool = false
@export var synced_weapon_path: String = ""
@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()
@@ -61,17 +81,31 @@ func _ready() -> void:
if head_pivot:
camera = head_pivot.get_node_or_null("Camera3D")
_setup_audio()
_setup_grapple()
if is_multiplayer_authority():
_damage_layer = CanvasLayer.new()
add_child(_damage_layer)
_setup_audio()
_setup_hit_marker()
_setup_grapple()
_setup_hud()
else:
call_deferred("_hide_remote_weapons")
set_process(true)
if is_multiplayer_authority():
set_physics_process(true)
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"
@@ -218,14 +252,244 @@ func apply_impulse(force: Vector3) -> void:
# 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
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
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:
var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
var t_mesh_inst = MeshInstance3D.new()
var t_mesh = CapsuleMesh.new()
t_mesh.radius = 0.02
t_mesh.height = 1.0
var t_mat = StandardMaterial3D.new()
t_mat.albedo_color = Color(1.0, 0.8, 0.2)
t_mat.emission_enabled = true
t_mat.emission = Color(2.0, 1.5, 0.5)
t_mesh.material = t_mat
t_mesh_inst.mesh = t_mesh
t_mesh_inst.rotation.x = deg_to_rad(90)
t_mesh_inst.position = Vector3(0, 0, -0.5)
tracer.add_child(t_mesh_inst)
tracer.target_position = target
if origin.distance_squared_to(target) > 0.1:
get_tree().current_scene.add_child(tracer)
tracer.global_position = origin
var up_vec = Vector3.UP
if abs((target - origin).normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
tracer.look_at(target, up_vec)
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
var visual = MeshInstance3D.new()
var s_mesh = SphereMesh.new()
s_mesh.radius = radius
s_mesh.height = radius * 2.0
visual.mesh = s_mesh
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.0, 0.6) # Translucent orange
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.3, 0.0)
visual.material_override = mat
visual.position = pos
get_tree().current_scene.add_child(visual)
var tween = visual.create_tween()
tween.tween_property(mat, "albedo_color:a", 0.0, 0.3)
tween.parallel().tween_property(visual, "scale", Vector3(1.1, 1.1, 1.1), 0.3)
tween.tween_callback(visual.queue_free)
var audio = AudioStreamPlayer3D.new()
audio.stream = load("res://assets/sounds/shotgun_fire.wav")
audio.bus = "SFX"
audio.pitch_scale = 0.7
audio.max_distance = 150.0
get_tree().current_scene.add_child(audio)
audio.global_position = pos
audio.play()
audio.finished.connect(audio.queue_free)
@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
if impulse.length_squared() > 0.01:
apply_impulse(impulse)
# Broadcast damage event to all peers so the victim dies on all screens
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 self-damage
spawn_damage_number(amount, _hit_pos)
# 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
time_since_last_damage = 0.0
@@ -243,6 +507,88 @@ func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impul
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
@@ -254,67 +600,34 @@ func _ensure_machine() -> MovementStateMachine:
_machine = null
return null
var _client_input_dir: Vector2 = Vector2.ZERO
var _client_wish_dir_world: Vector3 = Vector3.ZERO
var _client_jump: bool = false
var _client_jump_just_pressed: bool = false
var _client_crouch: bool = false
var _client_dash: bool = false
var _client_grapple: bool = false
var _client_grapple_just_pressed: bool = false
@rpc("any_peer", "call_local", "unreliable")
func server_receive_inputs(input_dir: Vector2, wish_dir: Vector3, jump: bool, jump_just: bool, crouch: bool, dash: bool, grapple: bool, grapple_just: bool) -> void:
if not multiplayer.is_server(): return
var sender = multiplayer.get_remote_sender_id()
if sender != str(name).to_int() and sender != 1: return # Accept from owner or host self
_client_input_dir = input_dir
_client_wish_dir_world = wish_dir
_client_jump = jump
_client_jump_just_pressed = jump_just
_client_crouch = crouch
_client_dash = dash
_client_grapple = grapple
_client_grapple_just_pressed = grapple_just
func _physics_process(_delta: float) -> void:
var speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
# Decibel scale is non-linear, so lerping from -80 means it stays inaudible for a long time.
# -40 db is practically silent for a background loop, -15 db is our max quiet wind.
wind_player.volume_db = lerpf(-40.0, -15.0, wind_factor)
if not wind_player.playing and wind_factor > 0.0:
wind_player.play()
var sm := _ensure_machine()
if not sm:
return
# Poll input in _physics_process so it's synchronized with the state machine tick
sm.input_jump_just_pressed = Input.is_action_just_pressed("jump")
sm.input_jump_pressed = Input.is_action_pressed("jump")
sm.input_crouch = Input.is_action_pressed("crouch")
sm.input_dash = Input.is_action_just_pressed("dash")
sm.input_grapple = Input.is_action_pressed("grapple")
sm.input_grapple_just_pressed = Input.is_action_just_pressed("grapple")
# Update grapple rope visuals
if (sm.current_state == "grapple" or sm.is_grapple_shooting) and camera:
grapple_rope.visible = true
# For shoot, start at right side of camera slightly below and closer
var start_pos = camera.global_position + camera.global_transform.basis * Vector3(0.3, -0.3, -0.15)
var end_pos = sm.grapple_point
if sm.is_grapple_shooting and sm.grapple_travel_time > 0.0:
# Interpolate end_pos
var t = clampf(sm.grapple_shoot_time / sm.grapple_travel_time, 0.0, 1.0)
end_pos = start_pos.lerp(sm.grapple_point, t)
var dist = start_pos.distance_to(end_pos)
if dist > 0.01:
grapple_rope.global_position = (start_pos + end_pos) / 2.0
# Look at end point
var up_vec = Vector3.UP
var dir = (end_pos - start_pos).normalized()
if abs(dir.dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
# Set rotation first, which resets scale
grapple_rope.transform.basis = Basis.looking_at(dir, up_vec) * Basis.from_euler(Vector3(PI/2.0, 0, 0))
# Then apply the scale
grapple_rope.scale = Vector3(1.0, dist, 1.0)
else:
if grapple_rope:
grapple_rope.visible = false
# Swing sound
if sm.current_state == "grapple" and sm.player.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)
# Raw 2D input
# Local client captures input and sends it
if is_multiplayer_authority():
var raw_input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
# Transform input direction by player yaw so movement is camera-relative
var forward := -global_transform.basis.z
forward.y = 0.0
forward = forward.normalized()
@@ -326,8 +639,69 @@ func _physics_process(_delta: float) -> void:
if world_dir.length_squared() > 1.0:
world_dir = world_dir.normalized()
sm.input_dir = raw_input
sm.wish_dir_world = world_dir
var input_jump = Input.is_action_pressed("jump")
var input_jump_just = Input.is_action_just_pressed("jump")
var input_crouch = Input.is_action_pressed("crouch")
var input_dash = Input.is_action_just_pressed("dash")
var input_grapple = Input.is_action_pressed("grapple")
var input_grapple_just = Input.is_action_just_pressed("grapple")
server_receive_inputs.rpc_id(1, raw_input, world_dir, input_jump, input_jump_just, input_crouch, input_dash, input_grapple, 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()
var sm := _ensure_machine()
if not sm:
return
# Only the Server sets input values to the StateMachine for evaluation
if multiplayer.is_server():
sm.input_dir = _client_input_dir
sm.wish_dir_world = _client_wish_dir_world
sm.input_jump_pressed = _client_jump
sm.input_jump_just_pressed = _client_jump_just_pressed
sm.input_crouch = _client_crouch
sm.input_dash = _client_dash
sm.input_grapple = _client_grapple
sm.input_grapple_just_pressed = _client_grapple_just_pressed
# Reset one-frame actions
_client_jump_just_pressed = false
_client_dash = false
_client_grapple_just_pressed = false
# Update synced properties for the grapple
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)
# Update humanoid model animation state (runs everywhere)
# Update humanoid model animation state
var humanoid = get_node_or_null("HumanoidModel")
@@ -335,6 +709,11 @@ func _physics_process(_delta: float) -> void:
var h_speed = Vector2(velocity.x, velocity.z).length()
humanoid.update_state(sm.current_state, h_speed, sm.input_crouch)
# Write synced 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
func _on_movement_event(ev: String, data: Dictionary) -> void:
if ev == "chain_updated":
@@ -345,6 +724,31 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_latch_player.play()
func _process(delta: float) -> void:
# Remote players: apply synced movement state to their HumanoidModel
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)
var humanoid = get_node_or_null("HumanoidModel")
if humanoid:
humanoid.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != humanoid.get_meta("current_weapon_path", ""):
humanoid.set_weapon(synced_weapon_path)
humanoid.set_meta("current_weapon_path", synced_weapon_path)
return
# Update UI
if is_instance_valid(health_bar):
health_bar.value = health
health_label.text = "%d / %d" % [ceil(health), max_health]
if is_instance_valid(shield_bar):
shield_bar.value = shield
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
if is_dead:
# Continuously follow the ragdoll torso
if is_instance_valid(ragdoll_instance) and is_instance_valid(camera):
@@ -354,6 +758,11 @@ func _process(delta: float) -> void:
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
@@ -363,14 +772,6 @@ func _process(delta: float) -> void:
if shield > max_shield:
shield = max_shield
# Update UI
if is_instance_valid(health_bar):
health_bar.value = health
health_label.text = "%d / %d" % [ceil(health), max_health]
if is_instance_valid(shield_bar):
shield_bar.value = shield
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
func _setup_hud() -> void:
var margin = MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
@@ -381,6 +782,13 @@ func _setup_hud() -> void:
var vbox = VBoxContainer.new()
margin.add_child(vbox)
# Add Match HUD overlay
if is_multiplayer_authority():
var match_hud_scene = load("res://ui/match_hud.tscn")
if match_hud_scene:
var match_hud = match_hud_scene.instantiate()
add_child(match_hud)
# Shield Bar (Top)
var shield_box = VBoxContainer.new()
var s_title = Label.new()
@@ -458,12 +866,17 @@ func _setup_hud() -> void:
d_title.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2))
d_vbox.add_child(d_title)
var respawn_btn = Button.new()
respawn_btn.text = "REBOOT"
respawn_btn.add_theme_font_size_override("font_size", 24)
respawn_btn.pressed.connect(_on_respawn_pressed)
d_vbox.add_child(respawn_btn)
var respawn_label = Label.new()
respawn_label.text = "PRESS ANY KEY TO REBOOT"
respawn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
respawn_label.add_theme_font_size_override("font_size", 24)
d_vbox.add_child(respawn_label)
var tw = create_tween().set_loops()
tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8)
tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8)
if is_multiplayer_authority():
_damage_layer.add_child(death_screen)
func die(impulse: Vector3 = Vector3.ZERO) -> void:
@@ -485,17 +898,16 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
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.2, 0.4, 0.8)) # Blueish player color
# Wait a frame for physics to initialize then apply velocity
get_tree().create_timer(0.01).timeout.connect(func():
if is_instance_valid(ragdoll):
ragdoll.apply_initial_velocities(velocity, impulse)
)
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):
@@ -503,6 +915,7 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
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)
@@ -555,9 +968,109 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
ragdoll_instance.set_meta("spring_arm", spring_arm)
# Show death screen
if is_multiplayer_authority():
death_screen.visible = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
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()
func _on_respawn_pressed() -> void:
# Reload scene
get_tree().reload_current_scene()
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
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_node_or_null("HumanoidModel")
if visual_model:
visual_model.visible = true
# Server sets the actual position to sync to everyone
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
position = spawn_pos
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()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+6
View File
@@ -20,6 +20,7 @@ config/icon="res://icon.svg"
SettingsManager="*res://globals/settings_manager.gd"
LoadoutManager="*res://globals/loadout_manager.gd"
PauseMenu="*res://ui/pause_menu.gd"
NetworkManager="*res://globals/network_manager.gd"
[input]
@@ -88,6 +89,11 @@ grapple={
"events": [Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":3,"canceled":false,"pressed":false,"double_click":false,"script":null)
]
}
scoreboard={
"deadzone": 0.0,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194306,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]
+20
View File
@@ -0,0 +1,20 @@
extends SceneTree
func _init():
var nm = load("res://globals/network_manager.gd").new()
nm.name = "NetworkManager"
root.add_child(nm)
var hud = load("res://ui/match_hud.gd").new()
hud.name = "MatchHUD"
root.add_child(hud)
# Mock network manager state
nm.match_active = true
nm._init_player_stats(1, "LocalTester")
# Try to call register_kill
nm.register_kill.rpc(-1, 1, "Rifle", "Target Dummy")
# Process one frame
call_deferred("quit")
+1
View File
@@ -0,0 +1 @@
uid://clrd47khl65oq
+270 -8
View File
@@ -2,10 +2,18 @@ extends Control
var _ui_vbox: VBoxContainer
var _level_selector_panel: VBoxContainer
var _multiplayer_panel: VBoxContainer
var _direct_connect_panel: VBoxContainer
var _host_lobby_panel: Control
var _host_lobby_players_list: ItemList
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
var nm = get_node_or_null("/root/NetworkManager")
if nm:
nm.connection_succeeded.connect(_on_connection_succeeded)
# ==========================================
# BACKGROUND: 3D DIORAMA (Sliding Player)
# ==========================================
@@ -97,21 +105,193 @@ func _ready() -> void:
_add_button(_level_selector_panel, "Back", _on_back_pressed)
func _build_level_card(parent: Container, title: String, scene_path: String, gradient: Gradient) -> void:
# ==========================================
# MULTIPLAYER PANEL
# ==========================================
_multiplayer_panel = VBoxContainer.new()
_multiplayer_panel.add_theme_constant_override("separation", 20)
_multiplayer_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50)
_multiplayer_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50)
_multiplayer_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600)
_multiplayer_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50)
_multiplayer_panel.hide()
add_child(_multiplayer_panel)
var mp_title = Label.new()
mp_title.text = "Multiplayer"
mp_title.add_theme_font_size_override("font_size", 64)
mp_title.add_theme_color_override("font_color", Color(1, 1, 1))
mp_title.add_theme_color_override("font_outline_color", Color(0, 0, 0))
mp_title.add_theme_constant_override("outline_size", 8)
_multiplayer_panel.add_child(mp_title)
var mp_spacer = Control.new()
mp_spacer.custom_minimum_size = Vector2(0, 20)
_multiplayer_panel.add_child(mp_spacer)
_add_button(_multiplayer_panel, "Host Game", _on_host_pressed)
_add_button(_multiplayer_panel, "Direct Connect", _on_direct_connect_pressed)
_add_button(_multiplayer_panel, "Back", _on_multiplayer_back_pressed)
# ==========================================
# DIRECT CONNECT PANEL
# ==========================================
_direct_connect_panel = VBoxContainer.new()
_direct_connect_panel.add_theme_constant_override("separation", 20)
_direct_connect_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50)
_direct_connect_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50)
_direct_connect_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600)
_direct_connect_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50)
_direct_connect_panel.hide()
add_child(_direct_connect_panel)
var dc_title = Label.new()
dc_title.text = "Direct Connect"
dc_title.add_theme_font_size_override("font_size", 64)
dc_title.add_theme_color_override("font_color", Color(1, 1, 1))
dc_title.add_theme_color_override("font_outline_color", Color(0, 0, 0))
dc_title.add_theme_constant_override("outline_size", 8)
_direct_connect_panel.add_child(dc_title)
var dc_ip_label = Label.new()
dc_ip_label.text = "IP Address:"
dc_ip_label.add_theme_font_size_override("font_size", 24)
_direct_connect_panel.add_child(dc_ip_label)
var dc_ip_input = LineEdit.new()
dc_ip_input.text = "127.0.0.1"
dc_ip_input.name = "IPInput"
dc_ip_input.add_theme_font_size_override("font_size", 24)
dc_ip_input.custom_minimum_size = Vector2(300, 50)
_direct_connect_panel.add_child(dc_ip_input)
var dc_port_label = Label.new()
dc_port_label.text = "Port:"
dc_port_label.add_theme_font_size_override("font_size", 24)
_direct_connect_panel.add_child(dc_port_label)
var dc_port_input = LineEdit.new()
dc_port_input.text = "31415" # Default Port
dc_port_input.name = "PortInput"
dc_port_input.add_theme_font_size_override("font_size", 24)
dc_port_input.custom_minimum_size = Vector2(300, 50)
_direct_connect_panel.add_child(dc_port_input)
var dc_spacer = Control.new()
dc_spacer.custom_minimum_size = Vector2(0, 20)
_direct_connect_panel.add_child(dc_spacer)
_add_button(_direct_connect_panel, "Connect", _on_connect_confirm_pressed)
_add_button(_direct_connect_panel, "Back", _on_dc_back_pressed)
# ==========================================
# HOST LOBBY PANEL
# ==========================================
_host_lobby_panel = Control.new()
_host_lobby_panel.set_anchors_preset(Control.PRESET_FULL_RECT)
_host_lobby_panel.hide()
add_child(_host_lobby_panel)
var hl_hbox = HBoxContainer.new()
hl_hbox.set_anchors_preset(Control.PRESET_FULL_RECT)
hl_hbox.add_theme_constant_override("separation", 50)
_host_lobby_panel.add_child(hl_hbox)
# Left Side (Level Selector)
var hl_left = VBoxContainer.new()
hl_left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hl_left.set_anchor_and_offset(SIDE_LEFT, 0.0, 50)
hl_left.set_anchor_and_offset(SIDE_TOP, 0.0, 50)
hl_left.add_theme_constant_override("separation", 20)
var hl_margin = MarginContainer.new()
hl_margin.add_theme_constant_override("margin_left", 50)
hl_margin.add_theme_constant_override("margin_top", 50)
hl_margin.add_theme_constant_override("margin_bottom", 50)
hl_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hl_hbox.add_child(hl_margin)
hl_margin.add_child(hl_left)
var hl_title = Label.new()
hl_title.text = "Host Lobby - Select Level"
hl_title.add_theme_font_size_override("font_size", 64)
hl_title.add_theme_color_override("font_color", Color(1, 1, 1))
hl_title.add_theme_color_override("font_outline_color", Color(0, 0, 0))
hl_title.add_theme_constant_override("outline_size", 8)
hl_left.add_child(hl_title)
var hl_cards = HBoxContainer.new()
hl_cards.add_theme_constant_override("separation", 20)
hl_left.add_child(hl_cards)
_build_level_card(hl_cards, "Dust 2", "res://scenes/dust2_level/dust2_level.tscn", d2_gradient, true, Vector2(200, 250))
_build_level_card(hl_cards, "Test Level", "res://scenes/test_level/test_level.tscn", tl_gradient, true, Vector2(200, 250))
var mode_label = Label.new()
mode_label.text = "Gamemode"
mode_label.add_theme_font_size_override("font_size", 32)
hl_left.add_child(mode_label)
var _gamemode_opt = OptionButton.new()
_gamemode_opt.add_theme_font_size_override("font_size", 24)
_gamemode_opt.add_item("Deathmatch", 0)
_gamemode_opt.add_to_group("host_only_ui")
_gamemode_opt.item_selected.connect(func(index: int):
if get_node_or_null("/root/NetworkManager"):
get_node("/root/NetworkManager").current_gamemode = _gamemode_opt.get_item_text(index)
)
hl_left.add_child(_gamemode_opt)
var hl_spacer = Control.new()
hl_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
hl_left.add_child(hl_spacer)
_add_button(hl_left, "Disband & Back", _on_lobby_back_pressed)
# Right Side (Player List)
var hl_right = VBoxContainer.new()
hl_right.custom_minimum_size = Vector2(400, 0)
var hl_right_margin = MarginContainer.new()
hl_right_margin.add_theme_constant_override("margin_top", 50)
hl_right_margin.add_theme_constant_override("margin_right", 50)
hl_right_margin.add_theme_constant_override("margin_bottom", 50)
hl_hbox.add_child(hl_right_margin)
hl_right_margin.add_child(hl_right)
var pl_title = Label.new()
pl_title.text = "Players Connected"
pl_title.add_theme_font_size_override("font_size", 48)
pl_title.add_theme_color_override("font_outline_color", Color(0, 0, 0))
pl_title.add_theme_constant_override("outline_size", 8)
hl_right.add_child(pl_title)
var port_label = Label.new()
if get_node_or_null("/root/NetworkManager"):
port_label.text = "Port: " + str(get_node("/root/NetworkManager").DEFAULT_PORT)
port_label.add_theme_font_size_override("font_size", 24)
port_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
hl_right.add_child(port_label)
_host_lobby_players_list = ItemList.new()
_host_lobby_players_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
_host_lobby_players_list.add_theme_font_size_override("font_size", 24)
hl_right.add_child(_host_lobby_players_list)
func _build_level_card(parent: Container, title: String, scene_path: String, gradient: Gradient, is_multiplayer: bool = false, card_size: Vector2 = Vector2(300, 400)) -> void:
var btn = TextureButton.new()
btn.custom_minimum_size = Vector2(300, 400)
btn.custom_minimum_size = card_size
var tex = GradientTexture2D.new()
tex.gradient = gradient
tex.width = 300
tex.height = 400
tex.width = int(card_size.x)
tex.height = int(card_size.y)
tex.fill_to = Vector2(1, 1)
btn.texture_normal = tex
var tex_hover = GradientTexture2D.new()
tex_hover.gradient = gradient
tex_hover.width = 300
tex_hover.height = 400
tex_hover.width = int(card_size.x)
tex_hover.height = int(card_size.y)
tex_hover.fill_to = Vector2(1, 1)
tex_hover.use_hdr = true # Slight bright effect on hover
btn.texture_hover = tex_hover
@@ -137,7 +317,16 @@ func _build_level_card(parent: Container, title: String, scene_path: String, gra
lbl.set_anchors_preset(Control.PRESET_FULL_RECT)
overlay.add_child(lbl)
btn.pressed.connect(func(): get_tree().change_scene_to_file(scene_path))
if is_multiplayer:
btn.add_to_group("host_only_ui")
btn.pressed.connect(func(): get_node("/root/NetworkManager").rpc_load_level.rpc(scene_path))
else:
btn.pressed.connect(func():
var nm = get_node_or_null("/root/NetworkManager")
if nm:
nm.start_singleplayer_match("Deathmatch")
get_tree().change_scene_to_file(scene_path)
)
parent.add_child(btn)
@@ -159,7 +348,80 @@ func _on_singleplayer_pressed() -> void:
_level_selector_panel.show()
func _on_multiplayer_pressed() -> void:
print("Multiplayer not implemented yet.")
_ui_vbox.hide()
_multiplayer_panel.show()
func _on_multiplayer_back_pressed() -> void:
_multiplayer_panel.hide()
_ui_vbox.show()
func _on_host_pressed() -> void:
var nm = get_node("/root/NetworkManager")
if nm.host_game() == OK:
_on_connection_succeeded()
func _on_direct_connect_pressed() -> void:
_multiplayer_panel.hide()
_direct_connect_panel.show()
func _on_dc_back_pressed() -> void:
_direct_connect_panel.hide()
_multiplayer_panel.show()
func _on_connect_confirm_pressed() -> void:
var ip = _direct_connect_panel.get_node("IPInput").text
var port = _direct_connect_panel.get_node("PortInput").text.to_int()
get_node("/root/NetworkManager").join_game(ip, port)
func _on_connection_succeeded() -> void:
_direct_connect_panel.hide()
_multiplayer_panel.hide()
_host_lobby_panel.show()
var nm = get_node("/root/NetworkManager")
var is_host = nm.multiplayer.is_server()
for node in get_tree().get_nodes_in_group("host_only_ui"):
if node is BaseButton:
node.disabled = not is_host
if not nm.player_connected.is_connected(_on_network_player_connected):
nm.player_connected.connect(_on_network_player_connected)
nm.player_disconnected.connect(_on_network_player_disconnected)
if not nm.stats_updated.is_connected(_update_lobby_players):
nm.stats_updated.connect(_update_lobby_players)
_update_lobby_players()
func _on_lobby_back_pressed() -> void:
_host_lobby_panel.hide()
_multiplayer_panel.show()
var nm = get_node("/root/NetworkManager")
nm.disconnect_game()
if nm.player_connected.is_connected(_on_network_player_connected):
nm.player_connected.disconnect(_on_network_player_connected)
nm.player_disconnected.disconnect(_on_network_player_disconnected)
if nm.stats_updated.is_connected(_update_lobby_players):
nm.stats_updated.disconnect(_update_lobby_players)
func _on_network_player_connected(_id: int) -> void:
_update_lobby_players()
func _on_network_player_disconnected(_id: int) -> void:
_update_lobby_players()
func _update_lobby_players() -> void:
if not _host_lobby_players_list: return
_host_lobby_players_list.clear()
var nm = get_node("/root/NetworkManager")
for pid in nm.player_stats.keys():
var p_name = nm.player_stats[pid].get("username", "Unknown")
var p_ping = nm.player_stats[pid].get("ping", 0)
if pid == 1:
_host_lobby_players_list.add_item("%s (Host) - %sms" % [p_name, p_ping])
else:
_host_lobby_players_list.add_item("%s - %sms" % [p_name, p_ping])
func _on_loadouts_pressed() -> void:
if has_node("/root/PauseMenu"):
+214
View File
@@ -0,0 +1,214 @@
extends CanvasLayer
var _nm: Node
# Top HUD
var _time_label: Label
var _local_kills_label: Label
var _leader_label: Label
# Killfeed
var _killfeed_vbox: VBoxContainer
# Scoreboard
var _scoreboard_panel: PanelContainer
var _scoreboard_grid: GridContainer
func _ready() -> void:
_nm = get_node_or_null("/root/NetworkManager")
# Top HUD
var top_hbox = HBoxContainer.new()
top_hbox.set_anchors_preset(Control.PRESET_TOP_WIDE)
top_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
top_hbox.add_theme_constant_override("separation", 50)
var margin = MarginContainer.new()
margin.add_theme_constant_override("margin_top", 20)
margin.set_anchors_preset(Control.PRESET_TOP_WIDE)
margin.add_child(top_hbox)
add_child(margin)
_time_label = Label.new()
_time_label.add_theme_font_size_override("font_size", 48)
_time_label.add_theme_color_override("font_outline_color", Color.BLACK)
_time_label.add_theme_constant_override("outline_size", 8)
top_hbox.add_child(_time_label)
_local_kills_label = Label.new()
_local_kills_label.text = "0 Kills"
_local_kills_label.add_theme_font_size_override("font_size", 64)
_local_kills_label.add_theme_color_override("font_color", Color(1, 0.8, 0.2))
_local_kills_label.add_theme_color_override("font_outline_color", Color.BLACK)
_local_kills_label.add_theme_constant_override("outline_size", 12)
top_hbox.add_child(_local_kills_label)
_leader_label = Label.new()
_leader_label.text = "Leader: None"
_leader_label.add_theme_font_size_override("font_size", 32)
_leader_label.add_theme_color_override("font_outline_color", Color.BLACK)
_leader_label.add_theme_constant_override("outline_size", 8)
top_hbox.add_child(_leader_label)
# Killfeed
var kf_margin = MarginContainer.new()
kf_margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
kf_margin.grow_horizontal = Control.GROW_DIRECTION_BEGIN
kf_margin.add_theme_constant_override("margin_top", 20)
kf_margin.add_theme_constant_override("margin_right", 20)
add_child(kf_margin)
_killfeed_vbox = VBoxContainer.new()
_killfeed_vbox.alignment = BoxContainer.ALIGNMENT_END
kf_margin.add_child(_killfeed_vbox)
# Scoreboard Overlay
var center_container = CenterContainer.new()
center_container.set_anchors_preset(Control.PRESET_FULL_RECT)
add_child(center_container)
_scoreboard_panel = PanelContainer.new()
_scoreboard_panel.custom_minimum_size = Vector2(800, 600)
var sb_style = StyleBoxFlat.new()
sb_style.bg_color = Color(0, 0, 0, 0.8)
_scoreboard_panel.add_theme_stylebox_override("panel", sb_style)
_scoreboard_panel.hide()
center_container.add_child(_scoreboard_panel)
var sb_vbox = VBoxContainer.new()
var sb_margin = MarginContainer.new()
sb_margin.add_theme_constant_override("margin_left", 20)
sb_margin.add_theme_constant_override("margin_top", 20)
sb_margin.add_theme_constant_override("margin_right", 20)
sb_margin.add_theme_constant_override("margin_bottom", 20)
sb_margin.add_child(sb_vbox)
_scoreboard_panel.add_child(sb_margin)
var sb_title = Label.new()
sb_title.text = "Scoreboard"
sb_title.add_theme_font_size_override("font_size", 48)
sb_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
sb_vbox.add_child(sb_title)
_scoreboard_grid = GridContainer.new()
_scoreboard_grid.columns = 5
_scoreboard_grid.add_theme_constant_override("h_separation", 50)
_scoreboard_grid.add_theme_constant_override("v_separation", 10)
sb_vbox.add_child(_scoreboard_grid)
_populate_scoreboard_headers()
# Signals
if _nm:
_nm.stats_updated.connect(_update_hud)
_nm.killfeed_event.connect(_on_killfeed_event)
_nm.match_state_updated.connect(_update_timer)
_update_hud()
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("scoreboard"):
_scoreboard_panel.show()
_update_hud()
elif Input.is_action_just_released("scoreboard"):
_scoreboard_panel.hide()
if _nm and _nm.match_active:
_update_timer()
func _update_timer() -> void:
if not _nm: return
var t = int(_nm.match_time_remaining)
var m = int(t / 60.0)
var s = t % 60
_time_label.text = "%02d:%02d" % [m, s]
func _populate_scoreboard_headers() -> void:
for child in _scoreboard_grid.get_children():
child.queue_free()
var headers = ["Player", "Kills", "Deaths", "Assists", "Ping"]
for h in headers:
var l = Label.new()
l.text = h
l.add_theme_font_size_override("font_size", 24)
l.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
_scoreboard_grid.add_child(l)
func _update_hud() -> void:
if not _nm: return
var my_id = multiplayer.get_unique_id()
var leader_name = "None"
var max_kills = -1
var stats = _nm.player_stats
# Sort players by kills for scoreboard
var players = stats.keys()
players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0))
_populate_scoreboard_headers()
for pid in players:
var p_data = stats[pid]
var p_kills = p_data.get("kills", 0)
# Find leader
if p_kills > max_kills:
max_kills = p_kills
leader_name = p_data.get("username", "Player " + str(pid))
# Update local kills
if pid == my_id:
_local_kills_label.text = str(p_kills) + " Kills"
# Build Scoreboard Row
var c_name = Label.new()
c_name.text = p_data.get("username", "Player")
c_name.add_theme_font_size_override("font_size", 24)
c_name.add_theme_color_override("font_color", Color(p_data.get("color", "cccccc")))
_scoreboard_grid.add_child(c_name)
var c_k = Label.new()
c_k.text = str(p_kills)
c_k.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_k)
var c_d = Label.new()
c_d.text = str(p_data.get("deaths", 0))
c_d.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_d)
var c_a = Label.new()
c_a.text = str(p_data.get("assists", 0))
c_a.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_a)
var c_p = Label.new()
c_p.text = str(p_data.get("ping", 0)) + "ms"
c_p.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_p)
_leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills]
func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
var l = RichTextLabel.new()
l.bbcode_enabled = true
l.fit_content = true
l.autowrap_mode = TextServer.AUTOWRAP_OFF
if killer == "":
l.text = "[color=#%s]%s[/color] died." % [v_color, victim]
else:
l.text = "[color=#%s]%s[/color] [%s] [color=#%s]%s[/color]" % [k_color, killer, weapon, v_color, victim]
l.add_theme_font_size_override("normal_font_size", 20)
l.add_theme_color_override("font_outline_color", Color.BLACK)
l.add_theme_constant_override("outline_size", 4)
_killfeed_vbox.add_child(l)
# Fade out and queue_free after 5 seconds
var t = get_tree().create_tween()
t.tween_interval(4.0)
t.tween_property(l, "modulate:a", 0.0, 1.0)
t.tween_callback(l.queue_free)
+1
View File
@@ -0,0 +1 @@
uid://dymuoha2tdj63
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://c54h8k8u8p7q6"]
[ext_resource type="Script" path="res://ui/match_hud.gd" id="1_match"]
[node name="MatchHUD" type="CanvasLayer"]
script = ExtResource("1_match")
+43 -4
View File
@@ -92,7 +92,20 @@ func _resume() -> void:
func _respawn() -> void:
_resume() # Unpause
get_tree().reload_current_scene()
# Find local player using their unique multiplayer ID
var local_player: Node3D = null
var pid = multiplayer.get_unique_id()
if get_tree().current_scene:
local_player = get_tree().current_scene.get_node_or_null(str(pid))
if not local_player:
var spawner = get_tree().current_scene.get_node_or_null("PlayerSpawner")
if spawner:
local_player = spawner.get_node_or_null(str(pid))
if local_player and local_player.has_method("rpc_request_respawn"):
local_player.rpc_request_respawn.rpc_id(1)
func _build_ui() -> void:
bg = ColorRect.new()
@@ -239,9 +252,32 @@ func _build_ui() -> void:
var general_tab = VBoxContainer.new()
general_tab.name = "General"
tab_container.add_child(general_tab)
var gen_label = Label.new()
gen_label.text = "Settings coming soon..."
general_tab.add_child(gen_label)
var user_hbox = HBoxContainer.new()
general_tab.add_child(user_hbox)
var user_lbl = Label.new()
user_lbl.text = "Username:"
user_lbl.custom_minimum_size = Vector2(150, 0)
user_hbox.add_child(user_lbl)
var user_input = LineEdit.new()
user_input.text = SettingsManager.username
user_input.custom_minimum_size = Vector2(200, 0)
user_hbox.add_child(user_input)
var user_save = Button.new()
user_save.text = "Save"
user_save.pressed.connect(func():
SettingsManager.username = user_input.text
SettingsManager._save_settings()
var nm = get_node_or_null("/root/NetworkManager")
if nm:
if nm.multiplayer.has_multiplayer_peer() and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
nm.update_username.rpc_id(1, user_input.text)
else:
nm.update_username(user_input.text)
)
user_hbox.add_child(user_save)
var spacer_gen = Control.new()
spacer_gen.size_flags_vertical = Control.SIZE_EXPAND_FILL
@@ -649,6 +685,9 @@ func _show_return_dialog() -> void:
func _return_to_main_menu() -> void:
_resume() # Reset pause state and cursor mode
var nm = get_node_or_null("/root/NetworkManager")
if nm:
nm.disconnect_game()
get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _show_main_menu() -> void:
+5
View File
@@ -161,7 +161,12 @@ func _shoot_hitscan() -> void:
hits_remaining -= 1
current_damage_mult *= penetration_damage_penalty
final_target = result.position
_spawn_tracer(origin, final_target)
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true)
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
# Spawn cosmetic tracer
var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
+3
View File
@@ -121,6 +121,9 @@ func _shoot_projectile() -> void:
_spawn_custom_projectile(origin, fire_dir)
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_play_fire_effects.rpc_id(1, origin, fire_dir, weapon_name, false)
func _spawn_custom_projectile(_origin: Vector3, _fire_dir: Vector3) -> void:
# Virtual method for custom projectiles (e.g., bouncing, homing)
pass
+5 -6
View File
@@ -146,12 +146,11 @@ func _apply_impulse() -> void:
var push_dir = camera.global_transform.basis.z.normalized()
# Apply impulse to player velocity
player.velocity += push_dir * impulse_strength
# Small hack to ensure we aren't considered on floor if we shoot down
if push_dir.y > 0:
# Just slightly lift the player to break floor contact immediately
player.position.y += 0.05
var final_force = push_dir * impulse_strength
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_apply_impulse.rpc_id(1, final_force)
else:
player.apply_impulse(final_force)
func _play_muzzle_flash() -> void:
if muzzle_flash:
+16 -7
View File
@@ -137,6 +137,8 @@ func _on_hit(result: Dictionary) -> void:
if not result.collider.has_method("take_damage"):
if result.collider is StaticBody3D or result.collider is CSGShape3D:
ImpactSpawner.spawn(get_tree(), "crater", result.position, result.normal, explosion_radius * 1.5)
elif owner_player and owner_player.multiplayer.is_server():
result.collider.take_damage(damage, result.position, owner_player)
_explode(result.position)
destroy()
@@ -189,19 +191,26 @@ func _explode(pos: Vector3) -> void:
# Add a tiny bit of upward bias to knockback so it lifts them
dir = (dir + Vector3(0, 0.5, 0)).normalized()
# Apply knockback if it has apply_impulse
if col.has_method("apply_impulse"):
# Apply knockback if it has server_apply_impulse (for rocket jumping)
if col.has_method("server_apply_impulse") and col == owner_player:
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
col.server_apply_impulse.rpc_id(1, dir * final_knockback)
else:
col.apply_impulse(dir * final_knockback)
# Apply damage
# Apply damage (and knockback for other players via take_damage)
if col.has_method("take_damage"):
if col == owner_player and not can_self_damage:
# Skip self damage, but we still applied the knockback!
# Skip self damage (we already applied knockback above)
pass
else:
col.take_damage(final_damage, target_pos, owner_player)
elif owner_player:
col.take_damage(final_damage, target_pos, owner_player, dir * final_knockback)
# Visual Effect
# Broadcast Visual Explosion Effect to other peers
if owner_player and owner_player.is_multiplayer_authority() and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and owner_player.multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius)
# Local Visual Effect
var visual = MeshInstance3D.new()
var s_mesh = SphereMesh.new()
s_mesh.radius = explosion_radius
+10 -3
View File
@@ -102,11 +102,18 @@ func _physics_process(delta: float) -> void:
if ignited and is_instance_valid(weapon_source):
var target_dir: Vector3 = initial_aim_dir
var is_painting = weapon_source.get("is_targeting")
var is_painting = false
var target: Vector3 = Vector3.ZERO
if weapon_source.get("player") and weapon_source.player:
is_painting = weapon_source.player.synced_is_targeting
target = weapon_source.player.synced_homing_target_pos
else:
is_painting = weapon_source.get("is_targeting")
if is_painting != null and is_painting:
var target: Vector3 = weapon_source.get("homing_target_pos")
if target != null and target != Vector3.ZERO:
target = weapon_source.get("homing_target_pos")
if is_painting and target != Vector3.ZERO:
target_dir = (target - global_position).normalized()
if target_dir != Vector3.ZERO:
+1
View File
@@ -58,6 +58,7 @@ func _on_hit(result: Dictionary) -> void:
final_damage = lerpf(damage, min_damage, falloff_factor)
if result.collider.has_method("take_damage"):
if owner_player and owner_player.multiplayer.is_server():
result.collider.take_damage(final_damage, result.position, owner_player)
elif result.collider is StaticBody3D or result.collider is CSGShape3D:
var size = 0.4 if impact_type == "plasma" else 0.15
+22 -1
View File
@@ -91,9 +91,18 @@ func _process(delta: float) -> void:
(targeting_laser.mesh as CylinderMesh).height = 300.0
targeting_laser.position.z = -150.0
if player and player.is_multiplayer_authority():
player.synced_is_targeting = true
player.synced_homing_target_pos = homing_target_pos
else:
if player and player.is_multiplayer_authority():
player.synced_is_targeting = false
func unequip() -> void:
is_targeting = false
targeting_laser.visible = false
if player and player.is_multiplayer_authority():
player.synced_is_targeting = false
# Override fire to shoot 8 rockets at once
func _fire() -> void:
@@ -141,6 +150,9 @@ func _fire() -> void:
var fire_dir = (base_dir + right * x_dir + up * y_dir).normalized()
_spawn_custom_projectile(proj_origin, fire_dir)
if multiplayer.has_multiplayer_peer() and multiplayer.multiplayer_peer is not OfflineMultiplayerPeer:
player.server_play_fire_effects.rpc_id(1, proj_origin, fire_dir, weapon_name, false)
var _current_aim_dir: Vector3 = Vector3.ZERO
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
@@ -176,7 +188,16 @@ func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
if player:
proj.velocity += fire_dir * max(0.0, player.velocity.dot(fire_dir))
proj.initial_aim_dir = _current_aim_dir
var aim_dir = _current_aim_dir
# If remote player, use the synced head rotation to determine forward direction
if player and not player.is_multiplayer_authority():
var head = player.get_node_or_null("HeadPivot")
if head:
aim_dir = -head.global_transform.basis.z.normalized()
else:
aim_dir = fire_dir # Fallback
proj.initial_aim_dir = aim_dir
proj.cruise_speed = 65.0
proj.ignition_timer = randf_range(0.3, 0.6) # Staggered ignition
+42
View File
@@ -61,6 +61,17 @@ func _process(_delta: float) -> void:
vm_camera.fov = camera.fov
func _build_loadout() -> void:
var is_auth = false
if player:
is_auth = player.is_multiplayer_authority()
else:
is_auth = is_multiplayer_authority()
print("DEBUG [", multiplayer.get_unique_id(), "]: _build_loadout is_auth=", is_auth, " player=", str(player.name) if player else "null")
if not is_auth:
return # Let the player_movement_controller's remote _process call _build_remote_loadout
# Clear existing weapons
for w in weapons.values():
if is_instance_valid(w):
@@ -68,6 +79,17 @@ func _build_loadout() -> void:
weapons.clear()
var l = LoadoutManager.get_active_loadout()
print("DEBUG [", multiplayer.get_unique_id(), "]: Loadout primary1=", l["primary_1"])
if player:
player.synced_loadout_p1 = l["primary_1"]
player.synced_loadout_p2 = l["primary_2"]
player.synced_loadout_sp = l["special"]
if l.has("melee"):
player.synced_loadout_melee = l["melee"]
else:
player.synced_loadout_melee = ""
player.synced_loadout_ready = true
_spawn_weapon(1, l["primary_1"])
_spawn_weapon(2, l["primary_2"])
@@ -77,17 +99,34 @@ func _build_loadout() -> void:
_equip_slot(1)
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
for w in weapons.values():
if is_instance_valid(w):
w.queue_free()
weapons.clear()
_spawn_weapon(1, p1)
_spawn_weapon(2, p2)
_spawn_weapon(3, sp)
if melee != "":
_spawn_weapon(4, melee)
_equip_slot(1)
func _spawn_weapon(slot: int, weapon_id: String) -> void:
if weapon_id == "" or weapon_id == "none" or not LoadoutManager.weapon_db.has(weapon_id):
print("DEBUG [", multiplayer.get_unique_id(), "]: _spawn_weapon failed check for ", weapon_id)
return
var w_data = LoadoutManager.weapon_db[weapon_id]
var script_path = w_data["script"]
if script_path == "":
print("DEBUG [", multiplayer.get_unique_id(), "]: _spawn_weapon failed script path for ", weapon_id)
return
var script = load(script_path)
if script:
print("DEBUG [", multiplayer.get_unique_id(), "]: Successfully loaded script, adding weapon ", weapon_id)
var w = script.new()
w.name = "Weapon_" + str(slot) + "_" + weapon_id
# Assuming all weapons have player and camera vars
@@ -184,6 +223,9 @@ func _equip_slot(slot: int) -> void:
var humanoid = player.get_node("HumanoidModel")
if humanoid.has_method("set_weapon") and w.has_meta("script_path"):
humanoid.set_weapon(w.get_meta("script_path"))
# Update the synced variable so remote peers pick it up
if "synced_weapon_path" in player:
player.synced_weapon_path = w.get_meta("script_path")
func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: