Feat/10 adding multiplayer fundamentals #14

Merged
Dotts merged 16 commits from feat/10-adding-multiplayer-fundamentals into main 2026-06-06 19:31:25 -07:00
5 changed files with 130 additions and 64 deletions
Showing only changes of commit cd034d1aea - Show all commits
+6 -5
View File
@@ -28,12 +28,13 @@ func _build_geometry() -> void:
fill.light_energy = 0.3 fill.light_energy = 0.3
_build_dust2_layout() _build_dust2_layout()
_build_player()
# Override player spawn point for Dust 2 func _spawn_player(pid: int) -> CharacterBody3D:
if _player: var player = super._spawn_player(pid)
_player.global_position = Vector3(0, 1, 50) player.position = Vector3(randf_range(-2, 2), 1, 50 + randf_range(-2, 2))
_player.look_at(Vector3(0, 1, 0), Vector3.UP) # 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: func _build_dust2_layout() -> void:
# Materials # Materials
+72 -25
View File
@@ -25,6 +25,31 @@ func _ready() -> void:
_build_geometry() _build_geometry()
_build_hud() _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: func _build_materials() -> void:
pass pass
@@ -40,7 +65,6 @@ func _build_geometry() -> void:
_build_speed_corridor() _build_speed_corridor()
_build_target_dummy() _build_target_dummy()
_build_lighting() _build_lighting()
_build_player()
# ── Utility ─────────────────────────────────────────────────────────────────── # ── Utility ───────────────────────────────────────────────────────────────────
@@ -261,12 +285,24 @@ func _build_lighting() -> void:
# ── Player ──────────────────────────────────────────────────────────────────── # ── Player ────────────────────────────────────────────────────────────────────
func _build_player() -> void: func _spawn_player(pid: int) -> CharacterBody3D:
var player := CharacterBody3D.new() var player := CharacterBody3D.new()
player.name = "Player" player.name = str(pid)
player.position = Vector3(0, 1.0, 0) player.set_multiplayer_authority(pid)
add_child(player)
_player = player # Use a slightly random spawn position to avoid exact overlapping
player.position = Vector3(randf_range(-2, 2), 2.0, randf_range(-2, 2))
# Network Synchronizer
var sync = MultiplayerSynchronizer.new()
sync.name = "MultiplayerSynchronizer"
sync.set_multiplayer_authority(pid)
var rep_config = SceneReplicationConfig.new()
rep_config.add_property(":position")
rep_config.add_property(":rotation")
rep_config.add_property("HeadPivot:rotation")
sync.replication_config = rep_config
player.add_child(sync)
# Collision shape # Collision shape
var col_shape := CollisionShape3D.new() var col_shape := CollisionShape3D.new()
@@ -282,11 +318,11 @@ func _build_player() -> void:
if mover_script: if mover_script:
player.set_script(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() var humanoid = load("res://characters/humanoid_model.gd").new()
humanoid.name = "HumanoidModel" humanoid.name = "HumanoidModel"
humanoid.color = Color(0.2, 0.4, 0.8) # Blueish for player 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 humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet
player.add_child(humanoid) player.add_child(humanoid)
@@ -324,7 +360,7 @@ func _build_player() -> void:
var camera := Camera3D.new() var camera := Camera3D.new()
camera.name = "Camera3D" camera.name = "Camera3D"
camera.current = true camera.current = (pid == multiplayer.get_unique_id())
camera.fov = 90.0 camera.fov = 90.0
head_pivot.add_child(camera) head_pivot.add_child(camera)
@@ -344,27 +380,38 @@ func _build_player() -> void:
# Store original capsule height # Store original capsule height
sm.original_capsule_height = capsule.height sm.original_capsule_height = capsule.height
# ── Bootstrap: set_script() doesn't trigger _ready() ───────────────── # ── Dependencies ────────────────────────────────────────────────────────
# Set references BEFORE calling _ready() since sm._ready() will switch_to("ground") # Set references before the node enters the tree. Godot will call _ready()
# which needs machine.player to already be set. # automatically when MultiplayerSpawner adds the node to the scene.
sm.player = player sm.player = player
sm.params = player.params sm.params = player.params
head_pivot.params = player.params head_pivot.params = player.params
# Initialize in dependency order: machine (enters initial state) → player → camera # Authority configuration
if sm.has_method("_ready"): var is_local = (pid == multiplayer.get_unique_id())
sm._ready() if is_local:
if player.has_method("_ready"): _player = player
player._ready() player.set_physics_process(true)
if head_pivot.has_method("_ready"): player.set_process(true)
head_pivot._ready() sm.set_physics_process(true)
else:
# Disable processing/input for remote players
player.set_physics_process(false)
player.set_process(false)
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)
# Ensure processing is enabled return player
player.set_physics_process(true)
player.set_process(true)
sm.set_physics_process(true)
_player = player
# ── HUD ─────────────────────────────────────────────────────────────────────── # ── HUD ───────────────────────────────────────────────────────────────────────
+3
View File
@@ -311,6 +311,7 @@ func _ensure_machine() -> MovementStateMachine:
return null return null
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
if not is_multiplayer_authority(): return
var speed = velocity.length() var speed = velocity.length()
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0) 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. # Decibel scale is non-linear, so lerping from -80 means it stays inaudible for a long time.
@@ -401,6 +402,7 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_latch_player.play() grapple_latch_player.play()
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not is_multiplayer_authority(): return
if is_dead: if is_dead:
# Continuously follow the ragdoll torso # Continuously follow the ragdoll torso
if is_instance_valid(ragdoll_instance) and is_instance_valid(camera): if is_instance_valid(ragdoll_instance) and is_instance_valid(camera):
@@ -632,6 +634,7 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
set_process_input(true) set_process_input(true)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if not is_multiplayer_authority(): return
if is_dead and death_screen and death_screen.visible: 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 InputEventKey or event is InputEventMouseButton or event is InputEventJoypadButton:
if event.is_pressed() and not event.is_echo(): if event.is_pressed() and not event.is_echo():
+31 -19
View File
@@ -10,6 +10,10 @@ var _host_lobby_players_list: ItemList
func _ready() -> void: func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) 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) # BACKGROUND: 3D DIORAMA (Sliding Player)
# ========================================== # ==========================================
@@ -231,6 +235,7 @@ func _ready() -> void:
var _gamemode_opt = OptionButton.new() var _gamemode_opt = OptionButton.new()
_gamemode_opt.add_theme_font_size_override("font_size", 24) _gamemode_opt.add_theme_font_size_override("font_size", 24)
_gamemode_opt.add_item("Deathmatch", 0) _gamemode_opt.add_item("Deathmatch", 0)
_gamemode_opt.add_to_group("host_only_ui")
_gamemode_opt.item_selected.connect(func(index: int): _gamemode_opt.item_selected.connect(func(index: int):
if get_node_or_null("/root/NetworkManager"): if get_node_or_null("/root/NetworkManager"):
get_node("/root/NetworkManager").current_gamemode = _gamemode_opt.get_item_text(index) get_node("/root/NetworkManager").current_gamemode = _gamemode_opt.get_item_text(index)
@@ -313,6 +318,7 @@ func _build_level_card(parent: Container, title: String, scene_path: String, gra
overlay.add_child(lbl) overlay.add_child(lbl)
if is_multiplayer: if is_multiplayer:
btn.add_to_group("host_only_ui")
btn.pressed.connect(func(): get_node("/root/NetworkManager").rpc_load_level.rpc(scene_path)) btn.pressed.connect(func(): get_node("/root/NetworkManager").rpc_load_level.rpc(scene_path))
else: else:
btn.pressed.connect(func(): btn.pressed.connect(func():
@@ -350,16 +356,9 @@ func _on_multiplayer_back_pressed() -> void:
_ui_vbox.show() _ui_vbox.show()
func _on_host_pressed() -> void: func _on_host_pressed() -> void:
_multiplayer_panel.hide()
_host_lobby_panel.show()
var nm = get_node("/root/NetworkManager") var nm = get_node("/root/NetworkManager")
nm.host_game() if nm.host_game() == OK:
_update_lobby_players() _on_connection_succeeded()
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)
func _on_direct_connect_pressed() -> void: func _on_direct_connect_pressed() -> void:
_multiplayer_panel.hide() _multiplayer_panel.hide()
@@ -373,7 +372,25 @@ func _on_connect_confirm_pressed() -> void:
var ip = _direct_connect_panel.get_node("IPInput").text var ip = _direct_connect_panel.get_node("IPInput").text
var port = _direct_connect_panel.get_node("PortInput").text.to_int() var port = _direct_connect_panel.get_node("PortInput").text.to_int()
get_node("/root/NetworkManager").join_game(ip, port) get_node("/root/NetworkManager").join_game(ip, port)
# For simplicity, we can show a connecting label or just wait for the game to start via RPC
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: func _on_lobby_back_pressed() -> void:
_host_lobby_panel.hide() _host_lobby_panel.hide()
@@ -396,19 +413,14 @@ func _update_lobby_players() -> void:
if not _host_lobby_players_list: return if not _host_lobby_players_list: return
_host_lobby_players_list.clear() _host_lobby_players_list.clear()
var nm = get_node("/root/NetworkManager") var nm = get_node("/root/NetworkManager")
var host_name = SettingsManager.username
var host_ping = 0
if nm.player_stats.has(1): host_ping = nm.player_stats[1].get("ping", 0)
for pid in nm.connected_players: for pid in nm.player_stats.keys():
var p_ping = 0 var p_name = nm.player_stats[pid].get("username", "Unknown")
if nm.player_stats.has(pid): p_ping = nm.player_stats[pid].get("ping", 0) var p_ping = nm.player_stats[pid].get("ping", 0)
if pid == 1: if pid == 1:
_host_lobby_players_list.add_item("%s (Host) - %sms" % [host_name, host_ping]) _host_lobby_players_list.add_item("%s (Host) - %sms" % [p_name, p_ping])
else: else:
var p_name = "Player " + str(pid)
if nm.player_stats.has(pid): p_name = nm.player_stats[pid].get("username", p_name)
_host_lobby_players_list.add_item("%s - %sms" % [p_name, p_ping]) _host_lobby_players_list.add_item("%s - %sms" % [p_name, p_ping])
func _on_loadouts_pressed() -> void: func _on_loadouts_pressed() -> void:
+17 -14
View File
@@ -93,17 +93,16 @@ func _resume() -> void:
func _respawn() -> void: func _respawn() -> void:
_resume() # Unpause _resume() # Unpause
# Find local player # Find local player using their unique multiplayer ID
var local_player: Node3D = null var local_player: Node3D = null
var pid = multiplayer.get_unique_id()
if get_tree().current_scene.has_node("Players"): if get_tree().current_scene:
for p in get_tree().current_scene.get_node("Players").get_children(): local_player = get_tree().current_scene.get_node_or_null(str(pid))
if p.has_method("is_multiplayer_authority") and p.is_multiplayer_authority(): if not local_player:
local_player = p var spawner = get_tree().current_scene.get_node_or_null("PlayerSpawner")
break if spawner:
local_player = spawner.get_node_or_null(str(pid))
if not local_player:
local_player = get_tree().current_scene.get_node_or_null("Player")
if local_player and local_player.has_method("take_damage"): if local_player and local_player.has_method("take_damage"):
local_player.take_damage(9999, Vector3.ZERO) local_player.take_damage(9999, Vector3.ZERO)
@@ -271,10 +270,12 @@ func _build_ui() -> void:
user_save.pressed.connect(func(): user_save.pressed.connect(func():
SettingsManager.username = user_input.text SettingsManager.username = user_input.text
SettingsManager._save_settings() SettingsManager._save_settings()
if NetworkManager.multiplayer.has_multiplayer_peer() and not NetworkManager.multiplayer.multiplayer_peer is OfflineMultiplayerPeer: var nm = get_node_or_null("/root/NetworkManager")
NetworkManager.update_username.rpc_id(1, user_input.text) if nm:
else: if nm.multiplayer.has_multiplayer_peer() and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
NetworkManager.update_username(user_input.text) nm.update_username.rpc_id(1, user_input.text)
else:
nm.update_username(user_input.text)
) )
user_hbox.add_child(user_save) user_hbox.add_child(user_save)
@@ -684,7 +685,9 @@ func _show_return_dialog() -> void:
func _return_to_main_menu() -> void: func _return_to_main_menu() -> void:
_resume() # Reset pause state and cursor mode _resume() # Reset pause state and cursor mode
NetworkManager.disconnect_game() 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") get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _show_main_menu() -> void: func _show_main_menu() -> void: