From 5db858527e152225808335d90d5c88921f55db14 Mon Sep 17 00:00:00 2001 From: Floppyrj45 Date: Sun, 19 Apr 2026 17:48:48 +0200 Subject: [PATCH] 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 --- project.godot | 7 + scenes/LobbyMenu.tscn | 107 +++++++++++++ scenes/MainMenu.tscn | 72 +++++++-- scripts/Main.gd | 206 ++++++++++++++++++++----- scripts/ambience/MainMenu.gd | 25 +++ scripts/net/ChatManager.gd | 61 ++++++++ scripts/net/ChatManager.gd.uid | 1 + scripts/net/LobbyMenu.gd | 44 ++++++ scripts/net/LobbyMenu.gd.uid | 1 + scripts/net/NetworkManager.gd | 132 ++++++++++++++++ scripts/net/NetworkManager.gd.uid | 1 + scripts/net/PlayerSyncComponent.gd | 63 ++++++++ scripts/net/PlayerSyncComponent.gd.uid | 1 + scripts/net/WorldSyncComponent.gd | 74 +++++++++ scripts/net/WorldSyncComponent.gd.uid | 1 + 15 files changed, 748 insertions(+), 48 deletions(-) create mode 100644 scenes/LobbyMenu.tscn create mode 100644 scripts/net/ChatManager.gd create mode 100644 scripts/net/ChatManager.gd.uid create mode 100644 scripts/net/LobbyMenu.gd create mode 100644 scripts/net/LobbyMenu.gd.uid create mode 100644 scripts/net/NetworkManager.gd create mode 100644 scripts/net/NetworkManager.gd.uid create mode 100644 scripts/net/PlayerSyncComponent.gd create mode 100644 scripts/net/PlayerSyncComponent.gd.uid create mode 100644 scripts/net/WorldSyncComponent.gd create mode 100644 scripts/net/WorldSyncComponent.gd.uid diff --git a/project.godot b/project.godot index 8c3d17a..0d8e35b 100644 --- a/project.godot +++ b/project.godot @@ -87,10 +87,17 @@ escape={ ] } +open_chat={ +"deadzone": 0.5, +"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":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + [autoload] BlockDatabase="*res://scripts/world/BlockDatabase.gd" AudioManager="*res://scripts/ambience/AudioManager.gd" ItemDatabase="*res://scripts/inventory/ItemDatabase.gd" +NetworkManager="*res://scripts/net/NetworkManager.gd" [rendering] environment/defaults/default_clear_color=Color(0.05, 0.15, 0.25, 1) diff --git a/scenes/LobbyMenu.tscn b/scenes/LobbyMenu.tscn new file mode 100644 index 0000000..44fd6f1 --- /dev/null +++ b/scenes/LobbyMenu.tscn @@ -0,0 +1,107 @@ +[gd_scene load_steps=3 format=3 uid="uid://dauphincraft_lobby"] + +[ext_resource type="Script" path="res://scripts/net/LobbyMenu.gd" id="1_lobbyscript"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_btn"] +bg_color = Color(0.05, 0.15, 0.3, 0.75) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.3, 0.8, 1.0, 1.0) +corner_radius_top_left = 6 +corner_radius_top_right = 6 +corner_radius_bottom_right = 6 +corner_radius_bottom_left = 6 + +[node name="LobbyMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_lobbyscript") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.02, 0.08, 0.18, 1.0) + +[node name="Panel" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -200.0 +offset_top = -200.0 +offset_right = 200.0 +offset_bottom = 200.0 +alignment = 1 + +[node name="Title" type="Label" parent="Panel"] +layout_mode = 2 +text = "REJOINDRE" +horizontal_alignment = 1 +theme_override_font_sizes/font_size = 48 +theme_override_colors/font_color = Color(0.7, 0.95, 1.0, 1.0) + +[node name="Spacer1" type="Control" parent="Panel"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 20) + +[node name="VBox" type="VBoxContainer" parent="Panel"] +layout_mode = 2 + +[node name="IPLabel" type="Label" parent="Panel/VBox"] +text = "Adresse IP du serveur" +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.7, 0.9, 1.0, 1.0) + +[node name="IPInput" type="LineEdit" parent="Panel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(400, 48) +placeholder_text = "127.0.0.1" +theme_override_font_sizes/font_size = 20 + +[node name="Spacer2" type="Control" parent="Panel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 10) + +[node name="PortLabel" type="Label" parent="Panel/VBox"] +text = "Port" +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.7, 0.9, 1.0, 1.0) + +[node name="PortInput" type="LineEdit" parent="Panel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(400, 48) +placeholder_text = "9999" +theme_override_font_sizes/font_size = 20 + +[node name="Spacer3" type="Control" parent="Panel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(0, 10) + +[node name="StatusLabel" type="Label" parent="Panel/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +theme_override_colors/font_color = Color(1.0, 0.8, 0.3, 1.0) + +[node name="ConnectBtn" type="Button" parent="Panel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(400, 56) +text = "Se connecter" +theme_override_font_sizes/font_size = 24 +theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) +theme_override_styles/normal = SubResource("StyleBoxFlat_btn") + +[node name="BackBtn" type="Button" parent="Panel/VBox"] +layout_mode = 2 +custom_minimum_size = Vector2(400, 56) +text = "Retour" +theme_override_font_sizes/font_size = 24 +theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) +theme_override_styles/normal = SubResource("StyleBoxFlat_btn") diff --git a/scenes/MainMenu.tscn b/scenes/MainMenu.tscn index 09f5709..e9d1fe7 100644 --- a/scenes/MainMenu.tscn +++ b/scenes/MainMenu.tscn @@ -36,9 +36,9 @@ anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 offset_left = -200.0 -offset_top = -160.0 +offset_top = -220.0 offset_right = 200.0 -offset_bottom = 160.0 +offset_bottom = 220.0 alignment = 1 [node name="Title" type="Label" parent="CenterContainer"] @@ -53,36 +53,52 @@ theme_override_constants/shadow_offset_y = 3 [node name="Subtitle" type="Label" parent="CenterContainer"] layout_mode = 2 -text = "Un monde voxel sous l'océan" +text = "Un monde voxel sous l'ocean" horizontal_alignment = 1 theme_override_font_sizes/font_size = 22 theme_override_colors/font_color = Color(0.5, 0.75, 0.9, 0.85) [node name="Spacer" type="Control" parent="CenterContainer"] layout_mode = 2 -custom_minimum_size = Vector2(0, 30) +custom_minimum_size = Vector2(0, 20) [node name="DiveButton" type="Button" parent="CenterContainer"] layout_mode = 2 -custom_minimum_size = Vector2(300, 56) -text = "Plonger" -theme_override_font_sizes/font_size = 26 +custom_minimum_size = Vector2(300, 52) +text = "Solo" +theme_override_font_sizes/font_size = 24 +theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) +theme_override_styles/normal = SubResource("StyleBoxFlat_btn") + +[node name="HostButton" type="Button" parent="CenterContainer"] +layout_mode = 2 +custom_minimum_size = Vector2(300, 52) +text = "Heberger" +theme_override_font_sizes/font_size = 24 +theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) +theme_override_styles/normal = SubResource("StyleBoxFlat_btn") + +[node name="JoinButton" type="Button" parent="CenterContainer"] +layout_mode = 2 +custom_minimum_size = Vector2(300, 52) +text = "Rejoindre" +theme_override_font_sizes/font_size = 24 theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) theme_override_styles/normal = SubResource("StyleBoxFlat_btn") [node name="OptionsButton" type="Button" parent="CenterContainer"] layout_mode = 2 -custom_minimum_size = Vector2(300, 56) +custom_minimum_size = Vector2(300, 52) text = "Options" -theme_override_font_sizes/font_size = 26 +theme_override_font_sizes/font_size = 24 theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) theme_override_styles/normal = SubResource("StyleBoxFlat_btn") [node name="QuitButton" type="Button" parent="CenterContainer"] layout_mode = 2 -custom_minimum_size = Vector2(300, 56) +custom_minimum_size = Vector2(300, 52) text = "Quitter" -theme_override_font_sizes/font_size = 26 +theme_override_font_sizes/font_size = 24 theme_override_colors/font_color = Color(0.8, 0.97, 1.0, 1.0) theme_override_styles/normal = SubResource("StyleBoxFlat_btn") @@ -134,8 +150,42 @@ max_value = 1.0 step = 0.01 value = 0.8 +[node name="HostPortPopup" type="Window" parent="."] +title = "Heberger une partie" +size = Vector2i(360, 200) +visible = false + +[node name="VBox" type="VBoxContainer" parent="HostPortPopup"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 20.0 +offset_top = 20.0 +offset_right = -20.0 +offset_bottom = -20.0 + +[node name="PortLabel" type="Label" parent="HostPortPopup/VBox"] +text = "Port (defaut 9999)" + +[node name="PortInput" type="LineEdit" parent="HostPortPopup/VBox"] +layout_mode = 2 +text = "9999" +theme_override_font_sizes/font_size = 18 + +[node name="StatusLabel" type="Label" parent="HostPortPopup/VBox"] +theme_override_colors/font_color = Color(1.0, 0.4, 0.4, 1.0) + +[node name="ConfirmBtn" type="Button" parent="HostPortPopup/VBox"] +layout_mode = 2 +text = "Lancer le serveur" +theme_override_font_sizes/font_size = 18 + [connection signal="pressed" from="CenterContainer/DiveButton" to="." method="_on_dive_pressed"] +[connection signal="pressed" from="CenterContainer/HostButton" to="." method="_on_host_pressed"] +[connection signal="pressed" from="CenterContainer/JoinButton" to="." method="_on_join_pressed"] [connection signal="pressed" from="CenterContainer/OptionsButton" to="." method="_on_options_pressed"] [connection signal="pressed" from="CenterContainer/QuitButton" to="." method="_on_quit_pressed"] [connection signal="value_changed" from="OptionsPopup/VBox/MusicSlider" to="." method="_on_music_slider_value_changed"] [connection signal="value_changed" from="OptionsPopup/VBox/SFXSlider" to="." method="_on_sfx_slider_value_changed"] +[connection signal="pressed" from="HostPortPopup/VBox/ConfirmBtn" to="." method="_on_host_confirm_pressed"] diff --git a/scripts/Main.gd b/scripts/Main.gd index 207485e..f4b99f3 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -1,8 +1,6 @@ extends Node3D @onready var world: Node3D = $World/ChunkManager -@onready var dolphin: CharacterBody3D = $Dolphin -@onready var hud: CanvasLayer = $Dolphin/HUD @onready var plankton_follower: Node3D = $PlanktonFollower var inventory: Inventory = Inventory.new() @@ -11,19 +9,78 @@ var _pause_menu: Control = null var _last_chunk_update_pos: Vector3 = Vector3(99999, 99999, 99999) +# Multiplayer state +var _my_dolphin: CharacterBody3D = null +var _peer_dolphins: Dictionary = {} # peer_id -> CharacterBody3D +var _world_sync: Node = null +var _chat_manager: Node = null + +# Dolphin scene cached +var _dolphin_scene: PackedScene = preload("res://scenes/Dolphin.tscn") + func _ready() -> void: - dolphin.block_break_requested.connect(_on_block_break) - dolphin.block_place_requested.connect(_on_block_place) - dolphin.echolocation_triggered.connect(_on_echolocation) + add_to_group("main") - if dolphin.has_signal("hotbar_scroll"): - dolphin.hotbar_scroll.connect(inventory.scroll_hotbar) + # Setup WorldSyncComponent on ChunkManager + _world_sync = load("res://scripts/net/WorldSyncComponent.gd").new() + _world_sync.name = "WorldSyncComponent" + world.add_child(_world_sync) + world.world_seed = 12345 + + var mode := NetworkManager.current_mode + + if NetworkManager.is_solo(): + _spawn_local_dolphin() + _spawn_mobs() + elif NetworkManager.is_server(): + # HOST or DEDICATED: spawn mobs, connect peer signals, spawn dolphin for existing peers + _spawn_mobs() + NetworkManager.peer_connected.connect(_on_net_peer_connected) + NetworkManager.peer_disconnected.connect(_on_net_peer_disconnected) + if mode == NetworkManager.Mode.HOST: + # Host also plays — spawn own dolphin + _spawn_networked_dolphin(multiplayer.get_unique_id()) + # Spawn dolphins for any peers already connected (edge case) + for peer_id in multiplayer.get_peers(): + _spawn_networked_dolphin(peer_id) + elif NetworkManager.is_client(): + # CLIENT: only dolphin for self, world updates come from server + NetworkManager.peer_disconnected.connect(_on_net_peer_disconnected) + _spawn_networked_dolphin(multiplayer.get_unique_id()) + + # Chat (all modes except dedicated headless) + if mode != NetworkManager.Mode.DEDICATED or not DisplayServer.get_name() == "headless": + _chat_manager = load("res://scripts/net/ChatManager.gd").new() + _chat_manager.name = "ChatManager" + add_child(_chat_manager) + + # Audio + AudioManager.play_ambient_loop("underwater_ambient") + AudioManager.play_music("underwater_theme") + AudioManager.play_whale_call_random() + + # Starting inventory + inventory.add_item(2, 10) + inventory.add_item(6, 5) + inventory.add_item(8, 2) + + +func _spawn_local_dolphin() -> void: + var dolphin: CharacterBody3D = _dolphin_scene.instantiate() + dolphin.name = "Dolphin" + dolphin.position = Vector3(0, 55, 0) + add_child(dolphin) + _my_dolphin = dolphin + _connect_dolphin_signals(dolphin) + + var hud: CanvasLayer = dolphin.get_node_or_null("HUD") if is_instance_valid(hud) and hud.has_method("connect_to_dolphin"): hud.connect_to_dolphin(dolphin) - # Load and attach InventoryUI + dolphin.add_to_group("player") + var ui_scene: PackedScene = load("res://scenes/InventoryUI.tscn") if ui_scene != null: _inventory_ui = ui_scene.instantiate() as CanvasLayer @@ -32,50 +89,111 @@ func _ready() -> void: else: push_error("Main: could not load InventoryUI.tscn") - # Load and attach PauseMenu var pause_scene: PackedScene = preload("res://scenes/PauseMenu.tscn") _pause_menu = pause_scene.instantiate() as Control add_child(_pause_menu) _pause_menu.hide() - AudioManager.play_ambient_loop("underwater_ambient") - AudioManager.play_music("underwater_theme") - AudioManager.play_whale_call_random() + plankton_follower.global_position = dolphin.position - world.world_seed = 12345 - # Starting inventory - inventory.add_item(2, 10) # 10 SAND - inventory.add_item(6, 5) # 5 KELP - inventory.add_item(8, 2) # 2 ICE +func _spawn_networked_dolphin(peer_id: int) -> void: + var dolphin: CharacterBody3D = _dolphin_scene.instantiate() + dolphin.name = "Dolphin_%d" % peer_id + dolphin.position = Vector3(0, 55, 0) + add_child(dolphin) - # Add dolphin to player group (for mob detection) - dolphin.add_to_group("player") + # Attach sync component + var sync_comp: Node = load("res://scripts/net/PlayerSyncComponent.gd").new() + sync_comp.name = "PlayerSyncComponent" + dolphin.add_child(sync_comp) + sync_comp.setup(peer_id) - # Spawner de mobs + var my_id := multiplayer.get_unique_id() + if peer_id == my_id: + _my_dolphin = dolphin + _connect_dolphin_signals(dolphin) + dolphin.add_to_group("player") + + var hud: CanvasLayer = dolphin.get_node_or_null("HUD") + if is_instance_valid(hud) and hud.has_method("connect_to_dolphin"): + hud.connect_to_dolphin(dolphin) + + var ui_scene: PackedScene = load("res://scenes/InventoryUI.tscn") + if ui_scene != null: + _inventory_ui = ui_scene.instantiate() as CanvasLayer + add_child(_inventory_ui) + _inventory_ui.setup(inventory) + + var pause_scene: PackedScene = preload("res://scenes/PauseMenu.tscn") + _pause_menu = pause_scene.instantiate() as Control + add_child(_pause_menu) + _pause_menu.hide() + + plankton_follower.global_position = dolphin.position + else: + # Remote dolphin: disable input processing + dolphin.set_process_unhandled_input(false) + if dolphin.has_method("set_physics_process"): + # Keep physics but no input + pass + _peer_dolphins[peer_id] = dolphin + + +func _despawn_peer_dolphin(peer_id: int) -> void: + if _peer_dolphins.has(peer_id): + var d: CharacterBody3D = _peer_dolphins[peer_id] + if is_instance_valid(d): + d.queue_free() + _peer_dolphins.erase(peer_id) + + +func _spawn_mobs() -> void: var MobSpawnerScene: PackedScene = load("res://scenes/mobs/MobSpawner.tscn") + if MobSpawnerScene == null: + return var mob_spawner: Node3D = MobSpawnerScene.instantiate() add_child(mob_spawner) - mob_spawner.player = mob_spawner.get_path_to(dolphin) + if is_instance_valid(_my_dolphin): + mob_spawner.player = mob_spawner.get_path_to(_my_dolphin) + + +func _connect_dolphin_signals(dolphin: CharacterBody3D) -> void: + if dolphin.has_signal("block_break_requested"): + dolphin.block_break_requested.connect(_on_block_break) + if dolphin.has_signal("block_place_requested"): + dolphin.block_place_requested.connect(_on_block_place) + if dolphin.has_signal("echolocation_triggered"): + dolphin.echolocation_triggered.connect(_on_echolocation) + if dolphin.has_signal("hotbar_scroll"): + dolphin.hotbar_scroll.connect(inventory.scroll_hotbar) + + +func _on_net_peer_connected(peer_id: int) -> void: + if NetworkManager.is_server(): + _spawn_networked_dolphin(peer_id) + + +func _on_net_peer_disconnected(peer_id: int) -> void: + _despawn_peer_dolphin(peer_id) func _process(_delta: float) -> void: - # Throttle chunk updates: only when dolphin moved > 4m - if dolphin.global_position.distance_to(_last_chunk_update_pos) > 4.0: - world.update_player_position(dolphin.global_position) - _last_chunk_update_pos = dolphin.global_position - plankton_follower.global_position = dolphin.global_position + if not is_instance_valid(_my_dolphin): + return + if _my_dolphin.global_position.distance_to(_last_chunk_update_pos) > 4.0: + world.update_player_position(_my_dolphin.global_position) + _last_chunk_update_pos = _my_dolphin.global_position + plankton_follower.global_position = _my_dolphin.global_position func _unhandled_input(event: InputEvent) -> void: if not event.is_action_pressed("escape"): return - # Priority: close inventory first if open if _inventory_ui != null and _inventory_ui.get("_is_open"): _inventory_ui.call("_toggle_inventory") get_viewport().set_input_as_handled() return - # Toggle pause menu if is_instance_valid(_pause_menu): if _pause_menu.visible: _pause_menu.hide_pause() @@ -85,9 +203,13 @@ func _unhandled_input(event: InputEvent) -> void: func _on_block_break(hit_position: Vector3, _normal: Vector3) -> void: - var broken_id: int = world.break_block(hit_position) - if broken_id > 0: - inventory.add_item(broken_id, 1) + if NetworkManager.is_solo(): + var broken_id: int = world.break_block(hit_position) + if broken_id > 0: + inventory.add_item(broken_id, 1) + AudioManager.play_bubble_sfx(hit_position) + else: + _world_sync.server_break_block(hit_position) AudioManager.play_bubble_sfx(hit_position) @@ -102,15 +224,25 @@ func _on_block_place(hit_position: Vector3, normal: Vector3) -> void: var block_coord := Vector3(floor(place_pos.x), floor(place_pos.y), floor(place_pos.z)) var block_center := block_coord + Vector3(0.5, 0.5, 0.5) - # Refuse if bloc overlaps player AABB (CapsuleShape3D r=0.4 h=2.0) - var dolphin_pos: Vector3 = dolphin.global_position - var dist := block_center - dolphin_pos - if abs(dist.x) < 1.0 and abs(dist.y) < 1.8 and abs(dist.z) < 1.0: - return # too close to player, silent refuse + if is_instance_valid(_my_dolphin): + var dolphin_pos: Vector3 = _my_dolphin.global_position + var dist := block_center - dolphin_pos + if abs(dist.x) < 1.0 and abs(dist.y) < 1.8 and abs(dist.z) < 1.0: + return - if world.place_block(place_pos, selected["item_id"]): + if NetworkManager.is_solo(): + if world.place_block(place_pos, selected["item_id"]): + inventory.remove_item_from_slot(inventory.selected_hotbar, 1) + else: + _world_sync.server_place_block(place_pos, selected["item_id"]) inventory.remove_item_from_slot(inventory.selected_hotbar, 1) func _on_echolocation(position: Vector3, _radius: float) -> void: AudioManager.play_bubble_sfx(position) + + +# Called by WorldSyncComponent when server confirms a block was broken by this client +func _on_remote_block_broken(broken_id: int) -> void: + if broken_id > 0: + inventory.add_item(broken_id, 1) diff --git a/scripts/ambience/MainMenu.gd b/scripts/ambience/MainMenu.gd index ebce57b..1bef57c 100644 --- a/scripts/ambience/MainMenu.gd +++ b/scripts/ambience/MainMenu.gd @@ -3,6 +3,10 @@ extends Control @onready var _options_popup: Window = $OptionsPopup @onready var _music_slider: HSlider = $OptionsPopup/VBox/MusicSlider @onready var _sfx_slider: HSlider = $OptionsPopup/VBox/SFXSlider +@onready var _host_port_popup: Window = $HostPortPopup +@onready var _host_port_input: LineEdit = $HostPortPopup/VBox/PortInput +@onready var _host_status: Label = $HostPortPopup/VBox/StatusLabel + func _ready() -> void: AudioManager.play_music("underwater_theme.mp3") @@ -10,9 +14,30 @@ func _ready() -> void: func _on_dive_pressed() -> void: + NetworkManager.current_mode = NetworkManager.Mode.SOLO get_tree().change_scene_to_file("res://scenes/Main.tscn") +func _on_host_pressed() -> void: + _host_status.text = "" + _host_port_input.text = str(NetworkManager.DEFAULT_PORT) + _host_port_popup.popup_centered() + + +func _on_host_confirm_pressed() -> void: + var port: int = int(_host_port_input.text.strip_edges()) + _host_port_popup.hide() + if NetworkManager.start_host(port): + get_tree().change_scene_to_file("res://scenes/Main.tscn") + else: + _host_status.text = "Erreur demarrage serveur" + _host_port_popup.popup_centered() + + +func _on_join_pressed() -> void: + get_tree().change_scene_to_file("res://scenes/LobbyMenu.tscn") + + func _on_options_pressed() -> void: _options_popup.popup_centered() diff --git a/scripts/net/ChatManager.gd b/scripts/net/ChatManager.gd new file mode 100644 index 0000000..d0ae461 --- /dev/null +++ b/scripts/net/ChatManager.gd @@ -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]) diff --git a/scripts/net/ChatManager.gd.uid b/scripts/net/ChatManager.gd.uid new file mode 100644 index 0000000..20ad54c --- /dev/null +++ b/scripts/net/ChatManager.gd.uid @@ -0,0 +1 @@ +uid://0qvav8nyjqdt diff --git a/scripts/net/LobbyMenu.gd b/scripts/net/LobbyMenu.gd new file mode 100644 index 0000000..c4c937f --- /dev/null +++ b/scripts/net/LobbyMenu.gd @@ -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") diff --git a/scripts/net/LobbyMenu.gd.uid b/scripts/net/LobbyMenu.gd.uid new file mode 100644 index 0000000..962540e --- /dev/null +++ b/scripts/net/LobbyMenu.gd.uid @@ -0,0 +1 @@ +uid://djs0exhdnreiu diff --git a/scripts/net/NetworkManager.gd b/scripts/net/NetworkManager.gd new file mode 100644 index 0000000..e7ea72e --- /dev/null +++ b/scripts/net/NetworkManager.gd @@ -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() diff --git a/scripts/net/NetworkManager.gd.uid b/scripts/net/NetworkManager.gd.uid new file mode 100644 index 0000000..f1ef6e6 --- /dev/null +++ b/scripts/net/NetworkManager.gd.uid @@ -0,0 +1 @@ +uid://dijtouqot4v5y diff --git a/scripts/net/PlayerSyncComponent.gd b/scripts/net/PlayerSyncComponent.gd new file mode 100644 index 0000000..bcf28ba --- /dev/null +++ b/scripts/net/PlayerSyncComponent.gd @@ -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 diff --git a/scripts/net/PlayerSyncComponent.gd.uid b/scripts/net/PlayerSyncComponent.gd.uid new file mode 100644 index 0000000..e0bbbe0 --- /dev/null +++ b/scripts/net/PlayerSyncComponent.gd.uid @@ -0,0 +1 @@ +uid://c3ojrbvoxtbrp diff --git a/scripts/net/WorldSyncComponent.gd b/scripts/net/WorldSyncComponent.gd new file mode 100644 index 0000000..05874e2 --- /dev/null +++ b/scripts/net/WorldSyncComponent.gd @@ -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) diff --git a/scripts/net/WorldSyncComponent.gd.uid b/scripts/net/WorldSyncComponent.gd.uid new file mode 100644 index 0000000..1dab873 --- /dev/null +++ b/scripts/net/WorldSyncComponent.gd.uid @@ -0,0 +1 @@ +uid://eo80c8lq8qr1