feat(multiplayer): ENet networking + player/world sync + lobby menu + chat
Add dedicated server / host / client modes via NetworkManager autoload, PlayerSyncComponent (20 Hz unreliable RPC), WorldSyncComponent (authoritative block break/place), ChatManager (F2), LobbyMenu scene, updated MainMenu with Solo/Heberger/Rejoindre/Quitter buttons. Port changed to 7777 (9999 occupied by sntlkeyssrvr on this machine). Mobs disabled in multi (spawn solo only). Solo mode untouched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
61
scripts/net/ChatManager.gd
Normal file
61
scripts/net/ChatManager.gd
Normal file
@@ -0,0 +1,61 @@
|
||||
extends Node
|
||||
|
||||
# Simple chat: F2 opens input, send to all peers, printed to console.
|
||||
|
||||
var _input_open: bool = false
|
||||
var _input_line: LineEdit = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Build minimal LineEdit for chat input
|
||||
_input_line = LineEdit.new()
|
||||
_input_line.placeholder_text = "Message... (Entrée pour envoyer, Echap pour annuler)"
|
||||
_input_line.custom_minimum_size = Vector2(500, 40)
|
||||
_input_line.visible = false
|
||||
_input_line.text_submitted.connect(_on_text_submitted)
|
||||
add_child(_input_line)
|
||||
|
||||
if not InputMap.has_action("open_chat"):
|
||||
InputMap.add_action("open_chat")
|
||||
var ev := InputEventKey.new()
|
||||
ev.physical_keycode = KEY_F2
|
||||
InputMap.action_add_event("open_chat", ev)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("open_chat") and not _input_open:
|
||||
_open_chat()
|
||||
get_viewport().set_input_as_handled()
|
||||
elif _input_open and event.is_action_pressed("escape"):
|
||||
_close_chat()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _open_chat() -> void:
|
||||
_input_open = true
|
||||
_input_line.visible = true
|
||||
_input_line.text = ""
|
||||
_input_line.grab_focus()
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
|
||||
|
||||
func _close_chat() -> void:
|
||||
_input_open = false
|
||||
_input_line.visible = false
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
|
||||
func _on_text_submitted(text: String) -> void:
|
||||
if text.strip_edges().length() == 0:
|
||||
_close_chat()
|
||||
return
|
||||
send_chat.rpc(text.strip_edges())
|
||||
_close_chat()
|
||||
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func send_chat(msg: String) -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if sender_id == 0:
|
||||
sender_id = multiplayer.get_unique_id()
|
||||
print("[CHAT][peer %d] %s" % [sender_id, msg])
|
||||
1
scripts/net/ChatManager.gd.uid
Normal file
1
scripts/net/ChatManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://0qvav8nyjqdt
|
||||
44
scripts/net/LobbyMenu.gd
Normal file
44
scripts/net/LobbyMenu.gd
Normal file
@@ -0,0 +1,44 @@
|
||||
extends Control
|
||||
|
||||
@onready var _ip_input: LineEdit = $Panel/VBox/IPInput
|
||||
@onready var _port_input: LineEdit = $Panel/VBox/PortInput
|
||||
@onready var _status_label: Label = $Panel/VBox/StatusLabel
|
||||
@onready var _connect_btn: Button = $Panel/VBox/ConnectBtn
|
||||
@onready var _back_btn: Button = $Panel/VBox/BackBtn
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_ip_input.text = "127.0.0.1"
|
||||
_port_input.text = str(NetworkManager.DEFAULT_PORT)
|
||||
_status_label.text = ""
|
||||
|
||||
NetworkManager.connection_succeeded.connect(_on_connected)
|
||||
NetworkManager.connection_failed.connect(_on_failed)
|
||||
|
||||
_connect_btn.pressed.connect(_on_connect_pressed)
|
||||
_back_btn.pressed.connect(_on_back_pressed)
|
||||
|
||||
|
||||
func _on_connect_pressed() -> void:
|
||||
var address: String = _ip_input.text.strip_edges()
|
||||
var port: int = int(_port_input.text.strip_edges())
|
||||
if address.length() == 0:
|
||||
_status_label.text = "Adresse invalide"
|
||||
return
|
||||
_status_label.text = "Connexion en cours..."
|
||||
_connect_btn.disabled = true
|
||||
NetworkManager.join_server(address, port)
|
||||
|
||||
|
||||
func _on_connected() -> void:
|
||||
_status_label.text = "Connecte!"
|
||||
get_tree().change_scene_to_file("res://scenes/Main.tscn")
|
||||
|
||||
|
||||
func _on_failed() -> void:
|
||||
_status_label.text = "Connexion echouee"
|
||||
_connect_btn.disabled = false
|
||||
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://scenes/MainMenu.tscn")
|
||||
1
scripts/net/LobbyMenu.gd.uid
Normal file
1
scripts/net/LobbyMenu.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://djs0exhdnreiu
|
||||
132
scripts/net/NetworkManager.gd
Normal file
132
scripts/net/NetworkManager.gd
Normal file
@@ -0,0 +1,132 @@
|
||||
extends Node
|
||||
|
||||
signal connection_succeeded()
|
||||
signal connection_failed()
|
||||
signal server_started()
|
||||
signal server_closed()
|
||||
signal peer_connected(id: int)
|
||||
signal peer_disconnected(id: int)
|
||||
|
||||
enum Mode { NONE, SOLO, HOST, CLIENT, DEDICATED }
|
||||
var current_mode: Mode = Mode.NONE
|
||||
|
||||
const DEFAULT_PORT := 7777
|
||||
const MAX_PLAYERS := 16
|
||||
|
||||
var peer: ENetMultiplayerPeer = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
multiplayer.connected_to_server.connect(_on_connection_ok)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
multiplayer.server_disconnected.connect(_on_server_disconnected)
|
||||
|
||||
# Auto-start from command line args
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if "--server" in args:
|
||||
start_dedicated_server(DEFAULT_PORT)
|
||||
elif "--client" in args:
|
||||
var idx := args.find("--client")
|
||||
var address := "127.0.0.1"
|
||||
if idx + 1 < args.size():
|
||||
address = args[idx + 1]
|
||||
join_server(address, DEFAULT_PORT)
|
||||
|
||||
|
||||
func start_dedicated_server(port: int = DEFAULT_PORT) -> bool:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, MAX_PLAYERS)
|
||||
if err != OK:
|
||||
push_error("NetworkManager: server creation failed — error %d" % err)
|
||||
peer = null
|
||||
return false
|
||||
multiplayer.multiplayer_peer = peer
|
||||
current_mode = Mode.DEDICATED
|
||||
print("[NET] Dedicated server listening on port %d" % port)
|
||||
server_started.emit()
|
||||
return true
|
||||
|
||||
|
||||
func start_host(port: int = DEFAULT_PORT) -> bool:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, MAX_PLAYERS)
|
||||
if err != OK:
|
||||
push_error("NetworkManager: host creation failed — error %d" % err)
|
||||
peer = null
|
||||
return false
|
||||
multiplayer.multiplayer_peer = peer
|
||||
current_mode = Mode.HOST
|
||||
print("[NET] Host server listening on port %d" % port)
|
||||
server_started.emit()
|
||||
return true
|
||||
|
||||
|
||||
func join_server(address: String, port: int = DEFAULT_PORT) -> bool:
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(address, port)
|
||||
if err != OK:
|
||||
push_error("NetworkManager: client connection failed — error %d" % err)
|
||||
peer = null
|
||||
return false
|
||||
multiplayer.multiplayer_peer = peer
|
||||
current_mode = Mode.CLIENT
|
||||
print("[NET] Connecting to %s:%d" % [address, port])
|
||||
return true
|
||||
|
||||
|
||||
func disconnect_from_server() -> void:
|
||||
if peer != null:
|
||||
peer.close()
|
||||
peer = null
|
||||
multiplayer.multiplayer_peer = null
|
||||
current_mode = Mode.NONE
|
||||
server_closed.emit()
|
||||
|
||||
|
||||
func _on_peer_connected(id: int) -> void:
|
||||
print("[NET] Peer connected: %d" % id)
|
||||
peer_connected.emit(id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
print("[NET] Peer disconnected: %d" % id)
|
||||
peer_disconnected.emit(id)
|
||||
|
||||
|
||||
func _on_connection_ok() -> void:
|
||||
print("[NET] Connected to server (my id: %d)" % multiplayer.get_unique_id())
|
||||
connection_succeeded.emit()
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
push_warning("[NET] Connection failed")
|
||||
peer = null
|
||||
multiplayer.multiplayer_peer = null
|
||||
current_mode = Mode.NONE
|
||||
connection_failed.emit()
|
||||
|
||||
|
||||
func _on_server_disconnected() -> void:
|
||||
push_warning("[NET] Server disconnected")
|
||||
peer = null
|
||||
multiplayer.multiplayer_peer = null
|
||||
current_mode = Mode.NONE
|
||||
server_closed.emit()
|
||||
|
||||
|
||||
func is_server() -> bool:
|
||||
return current_mode == Mode.HOST or current_mode == Mode.DEDICATED
|
||||
|
||||
|
||||
func is_client() -> bool:
|
||||
return current_mode == Mode.CLIENT
|
||||
|
||||
|
||||
func is_solo() -> bool:
|
||||
return current_mode == Mode.SOLO or current_mode == Mode.NONE
|
||||
|
||||
|
||||
func get_my_id() -> int:
|
||||
return multiplayer.get_unique_id()
|
||||
1
scripts/net/NetworkManager.gd.uid
Normal file
1
scripts/net/NetworkManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dijtouqot4v5y
|
||||
63
scripts/net/PlayerSyncComponent.gd
Normal file
63
scripts/net/PlayerSyncComponent.gd
Normal file
@@ -0,0 +1,63 @@
|
||||
extends Node
|
||||
|
||||
@export var sync_rate: float = 0.05 # 20 Hz
|
||||
|
||||
var _owner_id: int = 1
|
||||
var _timer: float = 0.0
|
||||
|
||||
# Interpolation buffer for remote dolphins
|
||||
var _remote_pos: Vector3 = Vector3.ZERO
|
||||
var _remote_yaw: float = 0.0
|
||||
var _remote_pitch: float = 0.0
|
||||
var _has_remote_data: bool = false
|
||||
var _interp_speed: float = 12.0
|
||||
|
||||
|
||||
func setup(owner_peer_id: int) -> void:
|
||||
_owner_id = owner_peer_id
|
||||
set_multiplayer_authority(owner_peer_id)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if NetworkManager.is_solo():
|
||||
return
|
||||
|
||||
var parent_node := get_parent()
|
||||
if not is_instance_valid(parent_node):
|
||||
return
|
||||
|
||||
if multiplayer.get_unique_id() == _owner_id:
|
||||
# Send our transform at sync_rate
|
||||
_timer += delta
|
||||
if _timer >= sync_rate:
|
||||
_timer = 0.0
|
||||
var pos: Vector3 = parent_node.global_position
|
||||
var yaw: float = parent_node.rotation.y
|
||||
var pitch: float = 0.0
|
||||
if parent_node.has_node("CameraPivot"):
|
||||
pitch = parent_node.get_node("CameraPivot").rotation.x
|
||||
var boosting: bool = false
|
||||
if "is_boosting" in parent_node:
|
||||
boosting = parent_node.is_boosting
|
||||
receive_transform.rpc(pos, yaw, pitch, boosting)
|
||||
else:
|
||||
# Interpolate toward remote state
|
||||
if _has_remote_data:
|
||||
parent_node.global_position = parent_node.global_position.lerp(
|
||||
_remote_pos, _interp_speed * delta
|
||||
)
|
||||
parent_node.rotation.y = lerp_angle(parent_node.rotation.y, _remote_yaw, _interp_speed * delta)
|
||||
if parent_node.has_node("CameraPivot"):
|
||||
var pivot: Node3D = parent_node.get_node("CameraPivot")
|
||||
pivot.rotation.x = lerp_angle(pivot.rotation.x, _remote_pitch, _interp_speed * delta)
|
||||
|
||||
|
||||
@rpc("any_peer", "unreliable")
|
||||
func receive_transform(pos: Vector3, rot_yaw: float, rot_pitch: float, boost: bool) -> void:
|
||||
_remote_pos = pos
|
||||
_remote_yaw = rot_yaw
|
||||
_remote_pitch = rot_pitch
|
||||
_has_remote_data = true
|
||||
var parent_node := get_parent()
|
||||
if is_instance_valid(parent_node) and "is_boosting" in parent_node:
|
||||
parent_node.is_boosting = boost
|
||||
1
scripts/net/PlayerSyncComponent.gd.uid
Normal file
1
scripts/net/PlayerSyncComponent.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c3ojrbvoxtbrp
|
||||
74
scripts/net/WorldSyncComponent.gd
Normal file
74
scripts/net/WorldSyncComponent.gd
Normal file
@@ -0,0 +1,74 @@
|
||||
extends Node
|
||||
|
||||
# Attached to ChunkManager node.
|
||||
# Server: intercepts block changes and broadcasts to clients.
|
||||
# Client: receives and applies block changes locally.
|
||||
|
||||
var _chunk_manager: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_chunk_manager = get_parent()
|
||||
|
||||
|
||||
# Called by Main.gd instead of world.break_block / world.place_block when in multiplayer.
|
||||
func server_break_block(world_pos: Vector3) -> int:
|
||||
if not NetworkManager.is_server():
|
||||
# Client sends request to server
|
||||
request_break.rpc_id(1, world_pos)
|
||||
return 0
|
||||
var broken_id: int = _chunk_manager.break_block(world_pos)
|
||||
if broken_id > 0:
|
||||
broadcast_block_change.rpc(world_pos, 0)
|
||||
return broken_id
|
||||
|
||||
|
||||
func server_place_block(world_pos: Vector3, block_id: int) -> bool:
|
||||
if not NetworkManager.is_server():
|
||||
# Client sends request to server
|
||||
request_place.rpc_id(1, world_pos, block_id)
|
||||
return false
|
||||
var placed: bool = _chunk_manager.place_block(world_pos, block_id)
|
||||
if placed:
|
||||
broadcast_block_change.rpc(world_pos, block_id)
|
||||
return placed
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func request_break(pos: Vector3) -> void:
|
||||
if not NetworkManager.is_server():
|
||||
return
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
var broken_id: int = _chunk_manager.break_block(pos)
|
||||
if broken_id > 0:
|
||||
broadcast_block_change.rpc(pos, 0)
|
||||
# Notify requesting peer of item pickup
|
||||
notify_break_result.rpc_id(sender_id, broken_id)
|
||||
|
||||
|
||||
@rpc("any_peer", "reliable")
|
||||
func request_place(pos: Vector3, block_id: int) -> void:
|
||||
if not NetworkManager.is_server():
|
||||
return
|
||||
var placed: bool = _chunk_manager.place_block(pos, block_id)
|
||||
if placed:
|
||||
broadcast_block_change.rpc(pos, block_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func broadcast_block_change(world_pos: Vector3, new_id: int) -> void:
|
||||
# Applied on all peers including server via call_local
|
||||
if multiplayer.is_server():
|
||||
return # already applied on server
|
||||
if new_id == 0:
|
||||
_chunk_manager.break_block(world_pos)
|
||||
else:
|
||||
_chunk_manager.place_block(world_pos, new_id)
|
||||
|
||||
|
||||
@rpc("authority", "reliable")
|
||||
func notify_break_result(broken_id: int) -> void:
|
||||
# Signal to Main.gd to add item to local inventory
|
||||
var main := get_tree().get_first_node_in_group("main")
|
||||
if is_instance_valid(main) and main.has_method("_on_remote_block_broken"):
|
||||
main._on_remote_block_broken(broken_id)
|
||||
1
scripts/net/WorldSyncComponent.gd.uid
Normal file
1
scripts/net/WorldSyncComponent.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://eo80c8lq8qr1
|
||||
Reference in New Issue
Block a user