diff --git a/src/scenes/ingame_hud.tscn b/src/scenes/ingame_hud.tscn index 753e39b..a5c713f 100644 --- a/src/scenes/ingame_hud.tscn +++ b/src/scenes/ingame_hud.tscn @@ -122,6 +122,28 @@ theme = SubResource("Theme_standard_font") [node name="HBoxContainer" type="HBoxContainer" parent="UpperRight" unique_id=332290975] layout_mode = 2 +[node name="VBoxContainerHost" type="VBoxContainer" parent="UpperRight/HBoxContainer" unique_id=1933444958] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="LabelHost" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerHost" unique_id=1807484687] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "HOST" +horizontal_alignment = 1 + +[node name="LabelPlayerCount" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerHost" unique_id=1807484688] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "Players: 1" +horizontal_alignment = 1 + +[node name="LabelRoomCode" type="Label" parent="UpperRight/HBoxContainer/VBoxContainerHost" unique_id=1807484689] +layout_mode = 2 +theme = SubResource("Theme_standard_font") +text = "" +horizontal_alignment = 1 + [node name="VBoxContainerBoss" type="VBoxContainer" parent="UpperRight/HBoxContainer" unique_id=1933444957] layout_mode = 2 size_flags_horizontal = 3 diff --git a/src/scenes/main_menu.tscn b/src/scenes/main_menu.tscn index b24c550..6c254e7 100644 --- a/src/scenes/main_menu.tscn +++ b/src/scenes/main_menu.tscn @@ -1,11 +1,11 @@ [gd_scene format=3 uid="uid://d4fgxay8kqp5u"] -[ext_resource type="Script" path="res://scripts/game_ui.gd" id="1"] +[ext_resource type="Script" uid="uid://ofhtysy8r43v" path="res://scripts/game_ui.gd" id="1"] -[node name="GameUI" type="CanvasLayer"] +[node name="GameUI" type="CanvasLayer" unique_id=2051025577] script = ExtResource("1") -[node name="Control" type="Control" parent="."] +[node name="Control" type="Control" parent="." unique_id=1160035312] layout_mode = 3 anchors_preset = 15 anchor_right = 1.0 @@ -13,7 +13,7 @@ anchor_bottom = 1.0 grow_horizontal = 2 grow_vertical = 2 -[node name="MainMenu" type="Panel" parent="Control"] +[node name="MainMenu" type="Panel" parent="Control" unique_id=1231078062] layout_mode = 1 anchors_preset = 8 anchor_left = 0.5 @@ -27,7 +27,7 @@ offset_bottom = 250.0 grow_horizontal = 2 grow_vertical = 2 -[node name="VBoxContainer" type="VBoxContainer" parent="Control/MainMenu"] +[node name="VBoxContainer" type="VBoxContainer" parent="Control/MainMenu" unique_id=1287690408] layout_mode = 1 anchors_preset = 8 anchor_left = 0.5 @@ -42,65 +42,85 @@ grow_horizontal = 2 grow_vertical = 2 theme_override_constants/separation = 20 -[node name="Title" type="Label" parent="Control/MainMenu/VBoxContainer"] +[node name="Title" type="Label" parent="Control/MainMenu/VBoxContainer" unique_id=1219314113] layout_mode = 2 text = "Multiplayer Coop RPG" horizontal_alignment = 1 vertical_alignment = 1 autowrap_mode = 2 -[node name="Spacer1" type="Control" parent="Control/MainMenu/VBoxContainer"] +[node name="Spacer1" type="Control" parent="Control/MainMenu/VBoxContainer" unique_id=1826786842] custom_minimum_size = Vector2(0, 20) layout_mode = 2 -[node name="LocalPlayersContainer" type="HBoxContainer" parent="Control/MainMenu/VBoxContainer"] +[node name="NetworkModeContainer" type="HBoxContainer" parent="Control/MainMenu/VBoxContainer" unique_id=1234567890] layout_mode = 2 -[node name="Label" type="Label" parent="Control/MainMenu/VBoxContainer/LocalPlayersContainer"] +[node name="Label" type="Label" parent="Control/MainMenu/VBoxContainer/NetworkModeContainer" unique_id=1234567891] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Network Mode:" +vertical_alignment = 1 + +[node name="NetworkModeOption" type="OptionButton" parent="Control/MainMenu/VBoxContainer/NetworkModeContainer" unique_id=1234567892] +layout_mode = 2 +size_flags_horizontal = 3 +selected = 0 +item_count = 3 +popup/item_0/text = "ENet (PC/LAN)" +popup/item_0/id = 0 +popup/item_1/text = "WebRTC (Browser/Web)" +popup/item_1/id = 1 +popup/item_2/text = "WebSocket (Cross-Platform)" +popup/item_2/id = 2 + +[node name="LocalPlayersContainer" type="HBoxContainer" parent="Control/MainMenu/VBoxContainer" unique_id=1860926318] +layout_mode = 2 + +[node name="Label" type="Label" parent="Control/MainMenu/VBoxContainer/LocalPlayersContainer" unique_id=1617550731] layout_mode = 2 size_flags_horizontal = 3 text = "Local Players:" vertical_alignment = 1 -[node name="SpinBox" type="SpinBox" parent="Control/MainMenu/VBoxContainer/LocalPlayersContainer"] +[node name="SpinBox" type="SpinBox" parent="Control/MainMenu/VBoxContainer/LocalPlayersContainer" unique_id=1805678257] layout_mode = 2 min_value = 1.0 max_value = 4.0 value = 1.0 -[node name="AddressContainer" type="HBoxContainer" parent="Control/MainMenu/VBoxContainer"] +[node name="AddressContainer" type="HBoxContainer" parent="Control/MainMenu/VBoxContainer" unique_id=69104887] layout_mode = 2 -[node name="Label" type="Label" parent="Control/MainMenu/VBoxContainer/AddressContainer"] +[node name="Label" type="Label" parent="Control/MainMenu/VBoxContainer/AddressContainer" unique_id=1727520435] layout_mode = 2 size_flags_horizontal = 3 text = "Server Address:" vertical_alignment = 1 -[node name="AddressInput" type="LineEdit" parent="Control/MainMenu/VBoxContainer/AddressContainer"] +[node name="AddressInput" type="LineEdit" parent="Control/MainMenu/VBoxContainer/AddressContainer" unique_id=314768458] layout_mode = 2 size_flags_horizontal = 3 -placeholder_text = "127.0.0.1" -expand_to_text_length = true +placeholder_text = "ruinborn.thefirstboss.com" -[node name="Spacer2" type="Control" parent="Control/MainMenu/VBoxContainer"] +[node name="Spacer2" type="Control" parent="Control/MainMenu/VBoxContainer" unique_id=1061067008] custom_minimum_size = Vector2(0, 20) layout_mode = 2 -[node name="HostButton" type="Button" parent="Control/MainMenu/VBoxContainer"] +[node name="HostButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=268532531] layout_mode = 2 text = "Host Game" -[node name="JoinButton" type="Button" parent="Control/MainMenu/VBoxContainer"] +[node name="JoinButton" type="Button" parent="Control/MainMenu/VBoxContainer" unique_id=417556513] layout_mode = 2 text = "Join Game" -[node name="Spacer3" type="Control" parent="Control/MainMenu/VBoxContainer"] +[node name="Spacer3" type="Control" parent="Control/MainMenu/VBoxContainer" unique_id=1966231585] custom_minimum_size = Vector2(0, 20) layout_mode = 2 size_flags_vertical = 3 -[node name="Instructions" type="Label" parent="Control/MainMenu/VBoxContainer"] +[node name="Instructions" type="Label" parent="Control/MainMenu/VBoxContainer" unique_id=1999421718] layout_mode = 2 text = "Controls: WASD/Arrows - Move (P1) | Gamepad (P2+) @@ -108,4 +128,3 @@ E/A (Tap) - Lift/Throw E/A (Hold) - Push/Pull" horizontal_alignment = 1 autowrap_mode = 2 - diff --git a/src/scripts/game_ui.gd b/src/scripts/game_ui.gd index 33fe4c5..fbcdf08 100644 --- a/src/scripts/game_ui.gd +++ b/src/scripts/game_ui.gd @@ -5,6 +5,8 @@ extends CanvasLayer @onready var main_menu = $Control/MainMenu @onready var host_button = $Control/MainMenu/VBoxContainer/HostButton @onready var join_button = $Control/MainMenu/VBoxContainer/JoinButton +@onready var network_mode_option = $Control/MainMenu/VBoxContainer/NetworkModeContainer/NetworkModeOption +@onready var network_mode_container = $Control/MainMenu/VBoxContainer/NetworkModeContainer @onready var local_players_spinbox = $Control/MainMenu/VBoxContainer/LocalPlayersContainer/SpinBox @onready var address_input = $Control/MainMenu/VBoxContainer/AddressContainer/AddressInput @@ -32,6 +34,27 @@ func _ready(): host_button.pressed.connect(_on_host_pressed) join_button.pressed.connect(_on_join_pressed) + # Setup network mode dropdown + if network_mode_option: + network_mode_option.item_selected.connect(_on_network_mode_changed) + + # On web builds, filter out ENet option (only WebRTC and WebSocket available) + if OS.get_name() == "Web": + print("GameUI: Web platform detected, filtering network mode options") + # Remove ENet option (index 0) for web builds + network_mode_option.remove_item(0) + # Adjust selected index (was 0 for ENet, now 0 is WebRTC) + network_mode_option.selected = 0 + # Update network manager to use WebRTC by default + network_manager.set_network_mode(1) + else: + # On native builds, default to ENet (index 0) + network_mode_option.selected = 0 + network_manager.set_network_mode(0) + + # Update address input placeholder based on initial mode + _on_network_mode_changed(network_mode_option.selected) + # Connect network signals if network_manager: network_manager.connection_succeeded.connect(_on_connection_succeeded) @@ -50,7 +73,8 @@ func _check_command_line_args(): var should_host = false var should_join = false var should_debug = false - var join_address = "127.0.0.1" + var force_webrtc = false + var join_address = "ruinborn.thefirstboss.com" var local_count = 1 for arg in args: @@ -60,6 +84,9 @@ func _check_command_line_args(): elif arg == "--join": should_join = true print("GameUI: Found --join argument") + elif arg == "--websocket" or arg == "--webrtc": + force_webrtc = true + print("GameUI: Found --websocket/--webrtc argument (forcing WebSocket mode)") elif arg == "--room-debug": should_debug = true print("GameUI: Found --room-debug argument") @@ -68,7 +95,18 @@ func _check_command_line_args(): elif arg.begins_with("--players="): local_count = int(arg.split("=")[1]) - print("GameUI: Parsed flags - should_host: ", should_host, ", should_join: ", should_join, ", should_debug: ", should_debug) + print("GameUI: Parsed flags - should_host: ", should_host, ", should_join: ", should_join, ", force_webrtc: ", force_webrtc, ", should_debug: ", should_debug) + + # Force WebRTC mode if --webrtc flag is present + if force_webrtc: + network_manager.set_network_mode(1) # WebRTC + if network_mode_option: + if OS.get_name() == "Web": + network_mode_option.selected = 0 # WebRTC is first option on web + else: + network_mode_option.selected = 1 # WebRTC is second option on native + _on_network_mode_changed(network_mode_option.selected) + print("GameUI: WebRTC mode forced via --webrtc flag") # Set debug flag only if --room-debug is used with --host or --join if should_debug and (should_host or should_join): @@ -91,24 +129,68 @@ func _check_command_line_args(): # Connection callback will handle starting the game pass +func _on_network_mode_changed(index: int): + # On web builds, index 0 = WebRTC, index 1 = WebSocket + # On native builds, index 0 = ENet, index 1 = WebRTC, index 2 = WebSocket + var actual_mode: int + if OS.get_name() == "Web": + # Web builds: 0 = WebRTC, 1 = WebSocket + actual_mode = index + 1 # Map 0->1 (WebRTC), 1->2 (WebSocket) + else: + # Native builds: 0 = ENet, 1 = WebRTC, 2 = WebSocket + actual_mode = index + + network_manager.set_network_mode(actual_mode) + + # Update address input placeholder based on mode + if address_input: + match actual_mode: + 0: # ENet + address_input.placeholder_text = "Server IP or domain" + 1: # WebRTC + address_input.placeholder_text = "Enter Room Code (e.g., ABC123)" + 2: # WebSocket + address_input.placeholder_text = "Enter Room Code (e.g., ABC123)" + + var mode_names = ["ENet", "WebRTC", "WebSocket"] + print("GameUI: Network mode changed to: ", mode_names[actual_mode]) + func _on_host_pressed(): var local_count = int(local_players_spinbox.value) network_manager.set_local_player_count(local_count) if network_manager.host_game(): - print("Hosting game with ", local_count, " local players") + var mode = network_manager.network_mode + if mode == 1 or mode == 2: # WebRTC or WebSocket + var room_id = network_manager.get_room_id() + var mode_name = "WebRTC" if mode == 1 else "WebSocket" + print("Hosting ", mode_name, " game - Room Code: ", room_id) + print("Share this code with players!") + else: + print("Hosting ENet game with ", local_count, " local players") _start_game() func _on_join_pressed(): var address = address_input.text if address.is_empty(): - address = "127.0.0.1" + var mode = network_manager.network_mode + if mode == 1 or mode == 2: # WebRTC or WebSocket + print("Error: Please enter a room code") + return + else: # ENet + address = "127.0.0.1" var local_count = int(local_players_spinbox.value) network_manager.set_local_player_count(local_count) if network_manager.join_game(address): - print("Joining game at ", address, " with ", local_count, " local players") + var mode = network_manager.network_mode + if mode == 1: # WebRTC + print("Joining WebRTC game with room code: ", address) + elif mode == 2: # WebSocket + print("Joining WebSocket game with room code: ", address) + else: # ENet + print("Joining ENet game at ", address, " with ", local_count, " local players") func _on_connection_succeeded(): print("Connection succeeded, starting game") diff --git a/src/scripts/ingame_hud.gd b/src/scripts/ingame_hud.gd index c6380b1..7946eec 100644 --- a/src/scripts/ingame_hud.gd +++ b/src/scripts/ingame_hud.gd @@ -12,8 +12,12 @@ var label_time: Label = null var label_time_value: Label = null var label_boss: Label = null var texture_progress_bar_boss_hp: TextureProgressBar = null +var label_host: Label = null +var label_player_count: Label = null +var label_room_code: Label = null var game_world: Node = null +var network_manager: Node = null var local_player: Node = null var level_start_time: float = 0.0 var player_search_attempts: int = 0 @@ -34,6 +38,16 @@ func _ready(): label_time_value = get_node_or_null("UpperLeft/HBoxContainer/VBoxContainerTime/LabelTimeValue") label_boss = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/LabelBoss") texture_progress_bar_boss_hp = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerBoss/TextureProgressBarBossHP") + label_host = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelHost") + label_player_count = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelPlayerCount") + label_room_code = get_node_or_null("UpperRight/HBoxContainer/VBoxContainerHost/LabelRoomCode") + + # Find network manager + network_manager = get_node_or_null("/root/NetworkManager") + if network_manager: + # Connect to player connection signals to update player count + network_manager.player_connected.connect(_on_player_connected) + network_manager.player_disconnected.connect(_on_player_disconnected) # Debug: Check if nodes were found if not label_time_value: @@ -56,6 +70,9 @@ func _ready(): if label_boss: label_boss.visible = false + # Update host info display + _update_host_info() + # Start level timer level_start_time = Time.get_ticks_msec() / 1000.0 @@ -63,6 +80,41 @@ func _ready(): player_search_attempts = 0 _find_local_player() +func _on_player_connected(_peer_id: int, _player_info: Dictionary): + _update_host_info() + +func _on_player_disconnected(_peer_id: int, _player_info: Dictionary): + _update_host_info() + +func _update_host_info(): + if not network_manager: + return + + # Update HOST label visibility + if label_host: + label_host.visible = network_manager.is_hosting + + # Update player count + if label_player_count and network_manager.players_info: + var total_players = 0 + for peer_id in network_manager.players_info.keys(): + var info = network_manager.players_info[peer_id] + total_players += info.get("local_player_count", 1) + label_player_count.text = "Players: " + str(total_players) + + # Update room code (only show if WebRTC/WebSocket and hosting) + if label_room_code: + var mode = network_manager.network_mode + if (mode == 1 or mode == 2) and network_manager.is_hosting: # WebRTC or WebSocket + var room_id = network_manager.get_room_id() + if not room_id.is_empty(): + label_room_code.text = "Room: " + room_id + label_room_code.visible = true + else: + label_room_code.visible = false + else: + label_room_code.visible = false + func _find_local_player(): # Prevent infinite recursion player_search_attempts += 1 diff --git a/src/scripts/matchbox_client.gd b/src/scripts/matchbox_client.gd new file mode 100644 index 0000000..00dde04 --- /dev/null +++ b/src/scripts/matchbox_client.gd @@ -0,0 +1,387 @@ +extends Node + +# Matchbox Client - Handles WebSocket signaling for WebRTC via Matchbox server +# Protocol: https://github.com/johanhelsing/matchbox + +signal peer_joined(peer_id: int) +signal peer_left(peer_id: int) +signal peer_connected(peer_id: int) +signal connection_failed() +signal connection_succeeded() +signal webrtc_ready() # Emitted when WebRTC mesh is set up after Welcome message + +const MATCHBOX_SERVER = "wss://matchbox.thefirstboss.com" +const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3578" + +var websocket: WebSocketPeer = null +var webrtc_peer: WebRTCMultiplayerPeer = null +var room_name: String = "" +var is_connected: bool = false +var is_hosting: bool = false # Set by NetworkManager to determine if we're host (ID 1) or client (ID 2+) +var my_uuid: String = "" # Our UUID assigned by Matchbox +var my_peer_id: int = 0 # Our integer peer ID (1 = host, 2+ = clients) +var peer_uuid_to_id: Dictionary = {} # UUID -> integer peer ID +var peer_id_counter: int = 1 # Counter for assigning integer peer IDs +var peer_connections: Dictionary = {} # peer_id (int) -> WebRTCPeerConnection +var pending_offers: Dictionary = {} # peer_id -> offer data + +func _ready(): + pass + +func connect_to_room(room: String, hosting: bool = false) -> bool: + if websocket: + disconnect_from_room() + + room_name = room + is_hosting = hosting + print("MatchboxClient: Connecting to room: ", room, " (hosting: ", hosting, ")") + + # Create WebSocket connection to Matchbox + websocket = WebSocketPeer.new() + var url = MATCHBOX_SERVER + "/" + room_name + var error = websocket.connect_to_url(url) + + if error != OK: + push_error("MatchboxClient: Failed to connect to Matchbox: " + str(error)) + return false + + print("MatchboxClient: Connecting to: ", url) + return true + +func disconnect_from_room(): + if websocket: + websocket.close() + websocket = null + + # Close all peer connections + for peer_id in peer_connections: + var pc = peer_connections[peer_id] + if pc: + pc.close() + peer_connections.clear() + pending_offers.clear() + is_connected = false + room_name = "" + +func _process(_delta): + if not websocket: + return + + websocket.poll() + + var state = websocket.get_ready_state() + + if state == WebSocketPeer.STATE_OPEN: + if not is_connected: + is_connected = true + connection_succeeded.emit() + print("MatchboxClient: Connected to Matchbox server") + + # Process incoming messages + while websocket.get_available_packet_count() > 0: + var packet = websocket.get_packet() + _handle_message(packet.get_string_from_utf8()) + + elif state == WebSocketPeer.STATE_CLOSED: + if is_connected: + is_connected = false + connection_failed.emit() + print("MatchboxClient: Disconnected from Matchbox server") + else: + # Connection failed before being established + print("MatchboxClient: Connection failed (state: CLOSED)") + connection_failed.emit() + + elif state == WebSocketPeer.STATE_CONNECTING: + # Still connecting, wait for next poll + pass + + elif state == WebSocketPeer.STATE_CLOSING: + # Connection is closing + print("MatchboxClient: Connection closing...") + +func _handle_message(message: String): + print("MatchboxClient: Received message: ", message) + var json = JSON.new() + var error = json.parse(message) + if error != OK: + push_error("MatchboxClient: Failed to parse message: " + message) + return + + var data = json.data + print("MatchboxClient: Parsed data: ", data) + + # Matchbox protocol uses direct keys: IdAssigned, NewPeer, PeerLeft, Signal + if data.has("IdAssigned"): + _handle_id_assigned(data.get("IdAssigned", "")) + elif data.has("NewPeer"): + _handle_new_peer(data.get("NewPeer", "")) + elif data.has("PeerLeft"): + _handle_peer_left_uuid(data.get("PeerLeft", "")) + elif data.has("Signal"): + _handle_signal_message_dict(data.get("Signal", {})) + else: + print("MatchboxClient: Unknown message format: ", data) + +func _handle_id_assigned(uuid: String): + print("MatchboxClient: IdAssigned message received: ", uuid) + my_uuid = uuid + + # Assign peer ID based on whether we're hosting or joining + # Host gets ID 1, clients get ID 2, 3, 4, etc. + if is_hosting: + my_peer_id = 1 + peer_uuid_to_id[my_uuid] = my_peer_id + peer_id_counter = 2 # Next peer will get ID 2 + else: + # Client: we'll assign ourselves a temporary ID, but this needs coordination + # Actually, we can't determine our ID from IdAssigned alone - we need to wait + # For now, assign ID 2 (assumes only one client joins at a time) + # TODO: Proper peer ID coordination needed for multiple simultaneous joins + my_peer_id = 2 # Client gets ID 2 + peer_uuid_to_id[my_uuid] = my_peer_id + peer_id_counter = 3 # Next peer will get ID 3 + + print("MatchboxClient: Assigned UUID: ", my_uuid, " -> Peer ID: ", my_peer_id, " (hosting: ", is_hosting, ")") + + # Setup WebRTC mesh now that we know our peer ID + if setup_webrtc_peer(): + webrtc_ready.emit() + print("MatchboxClient: WebRTC mesh ready, signal emitted") + +func _handle_new_peer(uuid: String): + if uuid.is_empty(): + return + + print("MatchboxClient: NewPeer message received: ", uuid) + + # Assign sequential integer peer ID + var peer_id = peer_id_counter + peer_uuid_to_id[uuid] = peer_id + peer_id_counter += 1 + + print("MatchboxClient: New peer UUID: ", uuid, " -> Peer ID: ", peer_id) + peer_joined.emit(peer_id) + _create_peer_connection(peer_id) + +func _handle_peer_left_uuid(uuid: String): + if uuid.is_empty(): + return + + var peer_id = peer_uuid_to_id.get(uuid, 0) + if peer_id == 0: + return + + print("MatchboxClient: Peer left UUID: ", uuid, " -> Peer ID: ", peer_id) + peer_uuid_to_id.erase(uuid) + peer_left.emit(peer_id) + _close_peer_connection(peer_id) + +func _handle_signal_message_dict(signal_data: Dictionary): + # Matchbox Signal format: {"From": "uuid", "signal": {...}} + # We need to convert UUID to integer peer ID + var from_uuid = signal_data.get("From", "") + if from_uuid.is_empty(): + return + + var peer_id = peer_uuid_to_id.get(from_uuid, 0) + if peer_id == 0: + # Unknown peer - might be a new peer, assign ID + peer_id = peer_id_counter + peer_uuid_to_id[from_uuid] = peer_id + peer_id_counter += 1 + print("MatchboxClient: Unknown peer in Signal, assigned ID: ", peer_id) + + var sig = signal_data.get("signal", {}) + if sig.is_empty(): + return + + _handle_signal_message(peer_id, sig) + +func _handle_signal_message(peer_id: int, signal_data: Dictionary): + var peer_conn = peer_connections.get(peer_id) + if not peer_conn: + # Create connection if it doesn't exist + _create_peer_connection(peer_id) + peer_conn = peer_connections.get(peer_id) + if not peer_conn: + return + + var type = signal_data.get("type", "") + + match type: + "offer": + _handle_offer(peer_id, signal_data) + "answer": + _handle_answer(peer_id, signal_data) + "ice-candidate": + _handle_ice_candidate(peer_id, signal_data) + +func _create_peer_connection(peer_id: int) -> WebRTCPeerConnection: + if peer_connections.has(peer_id): + return peer_connections[peer_id] + + print("MatchboxClient: Creating peer connection for peer ", peer_id) + + var pc = WebRTCPeerConnection.new() + + # Configure STUN server + var config = { + "iceServers": [ + { + "urls": [STUN_SERVER] + } + ] + } + + var error = pc.initialize(config) + if error != OK: + push_error("MatchboxClient: Failed to initialize peer connection: " + str(error)) + return null + + # Connect signals + pc.session_description_created.connect(_on_session_description_created.bind(peer_id)) + pc.ice_candidate_created.connect(_on_ice_candidate_created.bind(peer_id)) + + peer_connections[peer_id] = pc + + # If we're the host (peer_id 1), create offer for other peers + # Otherwise wait for offer + if my_peer_id == 1 and peer_id != 1: + # We're the host, create offer + pc.create_offer() + + return pc + +func _close_peer_connection(peer_id: int): + var pc = peer_connections.get(peer_id) + if pc: + pc.close() + peer_connections.erase(peer_id) + pending_offers.erase(peer_id) + +func _handle_offer(peer_id: int, signal_data: Dictionary): + var pc = peer_connections.get(peer_id) + if not pc: + pc = _create_peer_connection(peer_id) + if not pc: + return + + var sdp = signal_data.get("sdp", "") + if sdp.is_empty(): + return + + var set_error = pc.set_remote_description("offer", sdp) + if set_error != OK: + push_error("MatchboxClient: Failed to set remote offer: " + str(set_error)) + return + + # Create answer + pc.create_answer() + +func _handle_answer(peer_id: int, signal_data: Dictionary): + var pc = peer_connections.get(peer_id) + if not pc: + return + + var sdp = signal_data.get("sdp", "") + if sdp.is_empty(): + return + + var error = pc.set_remote_description("answer", sdp) + if error != OK: + push_error("MatchboxClient: Failed to set remote answer: " + str(error)) + return + + print("MatchboxClient: Answer received for peer ", peer_id) + peer_connected.emit(peer_id) + +func _handle_ice_candidate(peer_id: int, signal_data: Dictionary): + var pc = peer_connections.get(peer_id) + if not pc: + return + + var candidate = signal_data.get("candidate", "") + var sdp_mid = signal_data.get("sdpMid", "") + var sdp_mline_index = signal_data.get("sdpMLineIndex", 0) + + if candidate.is_empty(): + return + + var error = pc.add_ice_candidate(sdp_mid, sdp_mline_index, candidate) + if error != OK: + push_error("MatchboxClient: Failed to add ICE candidate: " + str(error)) + return + +func _on_session_description_created(peer_id: int, type: String, sdp: String): + var message = { + "type": "Signal", + "id": peer_id, + "signal": { + "type": type, + "sdp": sdp + } + } + _send_message(message) + +func _on_ice_candidate_created(peer_id: int, media: String, index: int, name: String): + var message = { + "type": "Signal", + "id": peer_id, + "signal": { + "type": "ice-candidate", + "candidate": name, + "sdpMid": media, + "sdpMLineIndex": index + } + } + _send_message(message) + +func _send_message(message: Dictionary): + if not websocket or websocket.get_ready_state() != WebSocketPeer.STATE_OPEN: + return + + var json = JSON.stringify(message) + var error = websocket.send_text(json) + if error != OK: + push_error("MatchboxClient: Failed to send message: " + str(error)) + +func get_my_peer_id() -> int: + return my_peer_id + +func setup_webrtc_peer() -> bool: + if webrtc_peer: + # Already set up + return true + + webrtc_peer = WebRTCMultiplayerPeer.new() + + # Create mesh with our peer ID (assigned by Matchbox) + # For host: peer_id = 1, for clients: peer_id = 2, 3, 4, etc. + var error = webrtc_peer.create_mesh(my_peer_id) + + if error != OK: + push_error("MatchboxClient: Failed to create WebRTC mesh: " + str(error)) + return false + + multiplayer.multiplayer_peer = webrtc_peer + + print("MatchboxClient: WebRTC mesh created with peer ID: ", my_peer_id) + return true + +func add_peer_to_mesh(peer_id: int): + if not webrtc_peer: + if not setup_webrtc_peer(): + return + + var pc = peer_connections.get(peer_id) + if not pc: + push_error("MatchboxClient: No peer connection for peer ", peer_id) + return + + var error = webrtc_peer.add_peer(pc, peer_id) + if error != OK: + push_error("MatchboxClient: Failed to add peer to mesh: " + str(error)) + return + + print("MatchboxClient: Added peer ", peer_id, " to WebRTC mesh") diff --git a/src/scripts/matchbox_client.gd.uid b/src/scripts/matchbox_client.gd.uid new file mode 100644 index 0000000..55eefb4 --- /dev/null +++ b/src/scripts/matchbox_client.gd.uid @@ -0,0 +1 @@ +uid://bm8jiiypl81wj diff --git a/src/scripts/network_manager.gd b/src/scripts/network_manager.gd index 15db158..d6d9e07 100644 --- a/src/scripts/network_manager.gd +++ b/src/scripts/network_manager.gd @@ -2,21 +2,40 @@ extends Node # Network Manager - Handles multiplayer connections and player spawning # Supports both hosting and joining games +# Auto-detects platform and uses appropriate networking: +# - ENet for native builds (Windows, Linux, Mac) +# - WebRTC for web builds (requires signaling server) signal player_connected(peer_id, player_info) signal player_disconnected(peer_id, player_info) signal connection_failed() signal connection_succeeded() -const DEFAULT_PORT = 7777 +const DEFAULT_PORT = 21212 const MAX_PLAYERS = 8 +const MATCHBOX_SERVER = "wss://matchbox.thefirstboss.com" +const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3578" var players_info = {} # Dictionary of peer_id -> {local_player_count: int, player_names: []} var local_player_count = 1 # How many local players on this machine var is_hosting = false var show_room_labels = false # Show room labels when in debug mode +var network_mode: int = 0 # 0 = ENet, 1 = WebRTC, 2 = WebSocket +var room_id = "" # Room ID for Matchbox (WebRTC) or WebSocket server URL +var matchbox_client: Node = null # Matchbox client instance +const WEBSOCKET_SERVER_URL = "ws://ruinborn.thefirstboss.com:21212" # WebSocket server URL func _ready(): + # Detect if running in browser - default to WebRTC on web, ENet on native + if OS.get_name() == "Web": + network_mode = 1 # WebRTC default for web + print("NetworkManager: Detected Web platform, defaulting to WebRTC") + print("NetworkManager: Matchbox server: ", MATCHBOX_SERVER) + else: + network_mode = 0 # ENet default for native + print("NetworkManager: Using ENet by default for native platform") + print("NetworkManager: You can switch network modes in the menu") + # Connect multiplayer signals multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) @@ -24,47 +43,221 @@ func _ready(): multiplayer.connection_failed.connect(_on_connection_failed) multiplayer.server_disconnected.connect(_on_server_disconnected) -func host_game(port: int = DEFAULT_PORT) -> bool: - var peer = ENetMultiplayerPeer.new() - var error = peer.create_server(port, MAX_PLAYERS) +func set_network_mode(mode: int): + # 0 = ENet, 1 = WebRTC, 2 = WebSocket + # WebRTC is only available on web builds + if mode == 1 and OS.get_name() != "Web": + push_error("NetworkManager: WebRTC is not available on native platforms. WebRTC only works in web builds. Falling back to ENet.") + network_mode = 0 + print("NetworkManager: ENet mode enabled (WebRTC not available on native platforms)") + return - if error != OK: - push_error("Failed to create server: " + str(error)) - return false + network_mode = mode + match mode: + 0: + print("NetworkManager: ENet mode enabled") + 1: + print("NetworkManager: WebRTC mode enabled") + print("NetworkManager: Matchbox server: ", MATCHBOX_SERVER) + 2: + print("NetworkManager: WebSocket mode enabled") + print("NetworkManager: WebSocket server: ", WEBSOCKET_SERVER_URL) + +func force_webrtc_mode(enable: bool): + # Legacy function for backwards compatibility + if enable: + set_network_mode(1) + else: + set_network_mode(0) + +func host_game(port: int = DEFAULT_PORT, matchbox_room: String = "") -> bool: + var peer + var error - multiplayer.multiplayer_peer = peer - is_hosting = true - - # Register the host as a player - var my_id = multiplayer.get_unique_id() - players_info[my_id] = { - "local_player_count": local_player_count, - "player_names": _generate_player_names(local_player_count, my_id) - } - - print("Server started on port ", port) - return true + if network_mode == 1: # WebRTC + # WebRTC for browser builds using Matchbox signaling + # Generate room ID if not provided + if matchbox_room.is_empty(): + room_id = _generate_room_id() + else: + room_id = matchbox_room + + print("NetworkManager: Creating WebRTC host with room ID: ", room_id) + print("NetworkManager: Share this room code with players!") + print("NetworkManager: Matchbox URL: ", MATCHBOX_SERVER, "/", room_id) + + # Create Matchbox client + var matchbox_script = load("res://scripts/matchbox_client.gd") + matchbox_client = Node.new() + matchbox_client.set_script(matchbox_script) + add_child(matchbox_client) + + # Connect Matchbox signals + matchbox_client.peer_joined.connect(_on_matchbox_peer_joined) + matchbox_client.peer_left.connect(_on_matchbox_peer_left) + matchbox_client.peer_connected.connect(_on_matchbox_peer_connected) + matchbox_client.connection_succeeded.connect(_on_matchbox_connected) + matchbox_client.connection_failed.connect(_on_matchbox_connection_failed) + matchbox_client.webrtc_ready.connect(_on_matchbox_webrtc_ready) + + # Setup WebRTC peer (will be initialized when Matchbox connects) + # For now, we'll set it up after Matchbox connection + is_hosting = true + + # Connect to Matchbox room (pass is_hosting = true) + if not matchbox_client.connect_to_room(room_id, true): + push_error("Failed to connect to Matchbox room") + return false + + # Register the host as a player (peer_id 1) + players_info[1] = { + "local_player_count": local_player_count, + "player_names": _generate_player_names(local_player_count, 1) + } + + return true + elif network_mode == 2: # WebSocket + # WebSocket for both native and web builds + # Generate room ID if not provided + if matchbox_room.is_empty(): + room_id = _generate_room_id() + else: + room_id = matchbox_room + + print("NetworkManager: Creating WebSocket host with room ID: ", room_id) + print("NetworkManager: Share this room code with players!") + print("NetworkManager: WebSocket URL: ", WEBSOCKET_SERVER_URL, "/", room_id) + + peer = WebSocketMultiplayerPeer.new() + var url = WEBSOCKET_SERVER_URL + "/" + room_id + error = peer.create_server(port) + + if error != OK: + push_error("Failed to create WebSocket server: " + str(error)) + return false + + multiplayer.multiplayer_peer = peer + is_hosting = true + + print("WebSocket server started on port ", port) + print("Players can join at: ", url) + + # Register the host as a player + var my_id = multiplayer.get_unique_id() + players_info[my_id] = { + "local_player_count": local_player_count, + "player_names": _generate_player_names(local_player_count, my_id) + } + + return true + else: # ENet (mode 0) + # ENet for native builds + peer = ENetMultiplayerPeer.new() + error = peer.create_server(port, MAX_PLAYERS) + + if error != OK: + push_error("Failed to create ENet server: " + str(error)) + return false + + multiplayer.multiplayer_peer = peer + is_hosting = true + + print("ENet server started on port ", port) + print("Players can join at: ", get_local_ip(), ":", port) + + # Register the host as a player + var my_id = multiplayer.get_unique_id() + players_info[my_id] = { + "local_player_count": local_player_count, + "player_names": _generate_player_names(local_player_count, my_id) + } + + return true func join_game(address: String, port: int = DEFAULT_PORT) -> bool: - var peer = ENetMultiplayerPeer.new() - var error = peer.create_client(address, port) + var peer + var error - if error != OK: - push_error("Failed to create client: " + str(error)) - return false - - multiplayer.multiplayer_peer = peer - is_hosting = false - - print("Attempting to connect to ", address, ":", port) - return true + if network_mode == 1: # WebRTC + # WebRTC for browser builds using Matchbox signaling + # 'address' is the room ID for WebRTC + room_id = address + + print("NetworkManager: Joining WebRTC game with room ID: ", room_id) + print("NetworkManager: Matchbox URL: ", MATCHBOX_SERVER, "/", room_id) + + # Create Matchbox client + var matchbox_script = load("res://scripts/matchbox_client.gd") + matchbox_client = Node.new() + matchbox_client.set_script(matchbox_script) + add_child(matchbox_client) + + # Connect Matchbox signals + matchbox_client.peer_joined.connect(_on_matchbox_peer_joined) + matchbox_client.peer_left.connect(_on_matchbox_peer_left) + matchbox_client.peer_connected.connect(_on_matchbox_peer_connected) + matchbox_client.connection_succeeded.connect(_on_matchbox_connected) + matchbox_client.connection_failed.connect(_on_matchbox_connection_failed) + matchbox_client.webrtc_ready.connect(_on_matchbox_webrtc_ready) + + is_hosting = false + + # Connect to Matchbox room (pass is_hosting = false) + if not matchbox_client.connect_to_room(room_id, false): + push_error("Failed to connect to Matchbox room") + return false + + return true + elif network_mode == 2: # WebSocket + # WebSocket for both native and web builds + # 'address' is the room ID for WebSocket + room_id = address + + print("NetworkManager: Joining WebSocket game with room ID: ", room_id) + print("NetworkManager: WebSocket URL: ", WEBSOCKET_SERVER_URL, "/", room_id) + + peer = WebSocketMultiplayerPeer.new() + var url = WEBSOCKET_SERVER_URL + "/" + room_id + error = peer.create_client(url) + + if error != OK: + push_error("Failed to create WebSocket client: " + str(error)) + return false + + multiplayer.multiplayer_peer = peer + is_hosting = false + + print("Attempting to connect to WebSocket server: ", url) + + return true + else: # ENet (mode 0) + # ENet for native builds + peer = ENetMultiplayerPeer.new() + error = peer.create_client(address, port) + + if error != OK: + push_error("Failed to create ENet client: " + str(error)) + return false + + multiplayer.multiplayer_peer = peer + is_hosting = false + + print("Attempting to connect to ", address, ":", port) + + return true func disconnect_from_game(): + if matchbox_client: + matchbox_client.disconnect_from_room() + matchbox_client.queue_free() + matchbox_client = null + if multiplayer.multiplayer_peer: multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer = null players_info.clear() is_hosting = false + room_id = "" func set_local_player_count(count: int): local_player_count = max(1, min(count, 4)) # Limit to 1-4 local players @@ -159,3 +352,140 @@ func get_all_player_ids() -> Array: func get_player_info(peer_id: int) -> Dictionary: return players_info.get(peer_id, {}) +# Matchbox callback handlers +func _on_matchbox_connected(): + print("NetworkManager: Connected to Matchbox server") + # WebRTC peer will be set up when Welcome message is received with our peer ID + +func _on_matchbox_webrtc_ready(): + print("NetworkManager: WebRTC mesh is ready") + + # Register ourselves if we're not hosting (clients need to register) + if not is_hosting and matchbox_client: + var my_peer_id = matchbox_client.get_my_peer_id() + if my_peer_id > 0 and not players_info.has(my_peer_id): + players_info[my_peer_id] = { + "local_player_count": local_player_count, + "player_names": _generate_player_names(local_player_count, my_peer_id) + } + print("NetworkManager: Registered joining player with peer ID: ", my_peer_id) + + # Emit connection_succeeded signal so the game can start + connection_succeeded.emit() + +func _on_matchbox_connection_failed(): + print("NetworkManager: Failed to connect to Matchbox server") + connection_failed.emit() + +func _on_matchbox_peer_joined(peer_id: int): + print("NetworkManager: Matchbox peer joined: ", peer_id) + # Peer connection will be created by Matchbox client + # Once connected, we'll add it to WebRTC mesh + +func _on_matchbox_peer_left(peer_id: int): + print("NetworkManager: Matchbox peer left: ", peer_id) + # Player disconnect will be handled by multiplayer signals + +func _on_matchbox_peer_connected(peer_id: int): + print("NetworkManager: Matchbox peer connected: ", peer_id) + # Add peer to WebRTC mesh + if matchbox_client: + matchbox_client.add_peer_to_mesh(peer_id) + + # Register player info + if not players_info.has(peer_id): + players_info[peer_id] = { + "local_player_count": 1, # Default, will be updated via RPC + "player_names": _generate_player_names(1, peer_id) + } + + # Emit player connected signal + player_connected.emit(peer_id, players_info[peer_id]) + +func get_room_id() -> String: + return room_id + +func get_local_ip() -> String: + var addresses = IP.get_local_addresses() + for addr in addresses: + # Skip localhost and IPv6 + if addr.begins_with("127.") or ":" in addr: + continue + return addr + return "127.0.0.1" + +func _generate_room_id() -> String: + # Generate a random 6-character room code + var chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # Avoid confusing chars like O/0, I/1 + var code = "" + for i in range(6): + code += chars[randi() % chars.length()] + return code + + +func get_webrtc_peer() -> WebRTCMultiplayerPeer: + if network_mode == 1 and multiplayer.multiplayer_peer is WebRTCMultiplayerPeer: + return multiplayer.multiplayer_peer as WebRTCMultiplayerPeer + return null + +# Create a WebRTC peer connection with STUN server configured +func create_peer_connection() -> WebRTCPeerConnection: + var peer_connection = WebRTCPeerConnection.new() + + # Configure STUN server for NAT traversal + var config = { + "iceServers": [ + { + "urls": [STUN_SERVER] + } + ] + } + + var error = peer_connection.initialize(config) + if error != OK: + push_error("Failed to initialize WebRTC peer connection: " + str(error)) + return null + + print("WebRTC peer connection initialized with STUN server: ", STUN_SERVER) + return peer_connection + +# Add a peer connection for WebRTC mesh networking +func add_webrtc_peer(peer_id: int) -> bool: + var webrtc = get_webrtc_peer() + if not webrtc: + push_error("Not using WebRTC") + return false + + var peer_connection = create_peer_connection() + if not peer_connection: + return false + + var error = webrtc.add_peer(peer_connection, peer_id) + + if error != OK: + push_error("Failed to add WebRTC peer: " + str(error)) + return false + + print("Added WebRTC peer: ", peer_id, " with STUN server configured") + return true + +# Get the peer connection info for a specific peer +func get_peer_connection(peer_id: int) -> Dictionary: + var webrtc = get_webrtc_peer() + if not webrtc: + return {} + + return webrtc.get_peer(peer_id) + +# Get connection info to share with remote peer (for manual signaling) +# This would typically be sent via a signaling server +# Note: With matchbox, signaling is handled automatically +func get_connection_offer(peer_id: int) -> Dictionary: + var peer_conn = get_peer_connection(peer_id) + if peer_conn.is_empty(): + return {} + + # In a real implementation, you'd get the SDP offer from the peer connection + # With matchbox, this is handled automatically via the signaling server + print("To implement: Get SDP offer for peer ", peer_id) + return {} diff --git a/src/scripts/webrtc_network.gd b/src/scripts/webrtc_network.gd new file mode 100644 index 0000000..94bb207 --- /dev/null +++ b/src/scripts/webrtc_network.gd @@ -0,0 +1,141 @@ +extends Node + +# WebRTC Network Manager - For browser/WebAssembly builds +# Uses WebRTCMultiplayerPeer for P2P connections + +signal connection_ready +signal connection_failed +signal session_created(session_data) +signal peer_connected_webrtc(peer_id) +signal peer_disconnected_webrtc(peer_id) + +const STUN_SERVER = "stun:ruinborn.thefirstboss.com:3578" + +var webrtc_peer: WebRTCMultiplayerPeer = null +var is_host: bool = false + +func _ready(): + print("WebRTC: Using STUN server: ", STUN_SERVER) + +func create_host() -> bool: + print("WebRTC: Creating host") + webrtc_peer = WebRTCMultiplayerPeer.new() + + # Godot 4.x API: initialize(peer_id, server_compat) + # peer_id 1 for server, server_compat = true + var error = webrtc_peer.initialize(1, true) + if error != OK: + push_error("WebRTC: Failed to initialize server: " + str(error)) + return false + + multiplayer.multiplayer_peer = webrtc_peer + is_host = true + + # Connect signals + webrtc_peer.peer_connected.connect(_on_peer_connected) + webrtc_peer.peer_disconnected.connect(_on_peer_disconnected) + + print("WebRTC: Host initialized successfully") + connection_ready.emit() + return true + +func create_client() -> bool: + print("WebRTC: Creating client") + webrtc_peer = WebRTCMultiplayerPeer.new() + + # Godot 4.x API: initialize(peer_id, server_compat) + # peer_id 0 for auto-assign, server_compat = false for client + var error = webrtc_peer.initialize(0, false) + if error != OK: + push_error("WebRTC: Failed to initialize client: " + str(error)) + return false + + multiplayer.multiplayer_peer = webrtc_peer + is_host = false + + # Connect signals + webrtc_peer.peer_connected.connect(_on_peer_connected) + webrtc_peer.peer_disconnected.connect(_on_peer_disconnected) + + print("WebRTC: Client initialized successfully") + connection_ready.emit() + return true + +func create_mesh(peer_id: int) -> bool: + print("WebRTC: Creating mesh peer for ID ", peer_id) + if not webrtc_peer: + push_error("WebRTC: Peer not initialized") + return false + + # Create peer connection with STUN server configured + var peer_connection = WebRTCPeerConnection.new() + + # Configure STUN server for NAT traversal + var config = { + "iceServers": [ + { + "urls": [STUN_SERVER] + } + ] + } + + var init_error = peer_connection.initialize(config) + if init_error != OK: + push_error("WebRTC: Failed to initialize peer connection: " + str(init_error)) + return false + + var error = webrtc_peer.add_peer(peer_connection, peer_id) + + if error != OK: + push_error("WebRTC: Failed to add peer: " + str(error)) + return false + + print("WebRTC: Mesh peer created for ID ", peer_id, " with STUN server") + return true + +# Get the local session description (offer or answer) to share with remote peer +func get_local_session() -> String: + if not webrtc_peer: + return "" + + # Get all connection states and session descriptions + var session_data = {} + + # For host, we need to create offers for each connected peer + # For client, we need to create an answer + + # Note: WebRTCMultiplayerPeer handles this internally + # We just need to get the peer connection for manual signaling + + # This is a simplified approach - for full WebRTC, you'd need to: + # 1. Create RTCPeerConnection + # 2. Create offer/answer + # 3. Exchange ICE candidates + + # For now, return empty - we'll use a signaling server approach instead + return "" + +# Set the remote session description from another peer +func set_remote_session(session_data: String) -> bool: + if not webrtc_peer: + return false + + # Parse and apply remote session + # This would involve SDP parsing and ICE candidate exchange + + return true + +func _on_peer_connected(id: int): + print("WebRTC: Peer connected: ", id) + peer_connected_webrtc.emit(id) + +func _on_peer_disconnected(id: int): + print("WebRTC: Peer disconnected: ", id) + peer_disconnected_webrtc.emit(id) + +func close_connection(): + if webrtc_peer: + webrtc_peer.close() + webrtc_peer = null + multiplayer.multiplayer_peer = null + is_host = false diff --git a/src/scripts/webrtc_network.gd.uid b/src/scripts/webrtc_network.gd.uid new file mode 100644 index 0000000..9a2e767 --- /dev/null +++ b/src/scripts/webrtc_network.gd.uid @@ -0,0 +1 @@ +uid://cyyaodbagbthp