--- a/project.godot +++ b/project.godot @@ -40,6 +40,8 @@ window/size/viewport_width=1280 window/size/viewport_height=720 window/vsync/vsync_mode=false +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" [editor_plugins] --- a/client/menus/main_menu.tscn +++ b/client/menus/main_menu.tscn @@ -426,6 +426,7 @@ offset_bottom = 360.0 [node name="Quit" parent="MainMenu" instance=ExtResource("4")] +visible = false custom_minimum_size = Vector2(40, 40) layout_mode = 0 anchor_top = 1.0 @@ -454,6 +455,7 @@ clip_text = true [node name="Play2" parent="MainMenu/Buttons" instance=ExtResource("4")] +visible = false custom_minimum_size = Vector2(200, 40) layout_mode = 2 text = "MENU_LABEL_ONLINE" @@ -470,13 +472,13 @@ text = "MENU_OPTIONS" [node name="Screenshots" parent="MainMenu/Buttons" instance=ExtResource("4")] +visible = false custom_minimum_size = Vector2(200, 40) layout_mode = 2 text = "MENU_LABEL_SCREENSHOTS" [node name="TextureRect" type="TextureRect" parent="MainMenu"] layout_mode = 0 -anchor_bottom = 1.0 offset_left = 128.0 offset_top = -248.0 offset_right = 1152.0 --- a/client/menus/options_menu.gd +++ b/client/menus/options_menu.gd @@ -302,4 +302,4 @@ $Menu/TabContainer/Credits/RichTextLabel.text = text func _on_Credits_meta_clicked(meta): - OS.shell_open(meta) # Open links in the credits + pass # fredygames: links in the credits do nothing (no outgoing navigation) --- a/common/scripts/global.gd +++ b/common/scripts/global.gd @@ -46,25 +46,21 @@ var VERSION := SemVer.new(1, 0, 0, 2) ## Create a server running on this machine. [br] -## If [param public] is false, the local server will listen only on localhost, -## otherwise the server will be accessable from any computer in the network. [br] -## Can be used for singleplayer (public = false) or LAN multiplayer (public = true) +## fredygames.com web build: the server and the client of a local game talk +## through an in-memory loopback transport (see loopback_peer.gd), not through +## ENet, since the browser has no UDP sockets and a local game has no use for +## the network. [param public] is ignored: nothing ever listens on a port. func create_local_server(public: bool = false) -> Node: var game_server: Node = load("res://server/game.tscn").instantiate() - var server := ENetMultiplayerPeer.new() - if not public: - server.set_bind_ip("127.0.0.1") - if server.create_server(0) != OK: - return null - var port := server.host.get_local_port() - + var pair: Array = preload("res://common/scripts/loopback_peer.gd").make_pair() + get_tree().root.add_child(game_server) var server_multiplayer := MultiplayerAPI.create_default_interface() - server_multiplayer.multiplayer_peer = server + server_multiplayer.multiplayer_peer = pair[0] get_tree().set_multiplayer(server_multiplayer, game_server.get_node("Game").get_path()) game_server.get_node("Game").init_server() - - var client := connect_remote_server("127.0.0.1", port) + + var client := _connect_loopback(pair[1]) # Clean up on error if not client: game_server.queue_free() @@ -75,16 +71,11 @@ get_node("/root/Client").queue_free() get_node("/root/Server").queue_free() -## Connect to the server running on host [param ip] and port [param port]. -func connect_remote_server(ip: String, port: int) -> Node: - var client := ENetMultiplayerPeer.new() - if client.create_client(ip, port) != OK: - return null - +func _connect_loopback(peer: MultiplayerPeer) -> Node: var game_client = load("res://client/game.tscn").instantiate() var client_multiplayer := MultiplayerAPI.create_default_interface() - client_multiplayer.multiplayer_peer = client - + client_multiplayer.multiplayer_peer = peer + # Ugly hack: # The lobby menu for singleplayer games is in the main menu, which is not in the # /root/Client/Game/ subtree... @@ -93,9 +84,14 @@ get_tree().root.add_child(game_client) get_tree().set_multiplayer(client_multiplayer, game_client.get_node("Game").get_path()) game_client.get_node("Game").init_client() - + return game_client.get_node("Game") +## Connect to a remote server. [br] +## fredygames.com web build: there is no networking, so this always fails. +func connect_remote_server(_ip: String, _port: int) -> Node: + return null + ## Returns the current local server (if any). func get_current_server() -> Node: return get_node_or_null("/root/Client/Game") --- /dev/null +++ b/common/scripts/loopback_peer.gd @@ -0,0 +1,137 @@ +## In-memory transport between the local game server and the local game client. +## +## The browser build has no UDP sockets, and a single-player game has no use for +## the network anyway, so the server and the client that run in the same process +## talk through this pair of peers instead of ENet. The pair is created by +## [method make_pair]; element 0 is the server (unique id 1), element 1 is the +## client. Nothing here touches the network. +## +## Added by fredygames.com for the web build. Copyright (C) 2026 FredyGames. +## Licensed under the GNU GPL v3.0 or later, like the rest of the game code. +extends MultiplayerPeerExtension + +var _other: MultiplayerPeerExtension +var _id := 1 +var _server_side := false +var _status := MultiplayerPeer.CONNECTION_CONNECTING +var _inbox: Array[PackedByteArray] = [] +var _announce: Array[int] = [] +var _dropped: Array[int] = [] +var _target := 0 +var _channel := 0 +var _mode := MultiplayerPeer.TRANSFER_MODE_RELIABLE +var _refusing := false + +## Returns [server_peer, client_peer], already linked to each other. +static func make_pair() -> Array: + var server = (load("res://common/scripts/loopback_peer.gd") as GDScript).new() + var client = (load("res://common/scripts/loopback_peer.gd") as GDScript).new() + server._id = 1 + server._server_side = true + server._status = MultiplayerPeer.CONNECTION_CONNECTED + client._id = randi_range(2, 0x7FFFFFFF) + client._status = MultiplayerPeer.CONNECTION_CONNECTING + server._other = client + client._other = server + # Each side learns about the other one on its next poll. + server._announce.append(client._id) + client._announce.append(1) + return [server, client] + +func _poll() -> void: + if not _announce.is_empty(): + _status = MultiplayerPeer.CONNECTION_CONNECTED + var ids := _announce.duplicate() + _announce.clear() + for id in ids: + peer_connected.emit(id) + if not _dropped.is_empty(): + var ids := _dropped.duplicate() + _dropped.clear() + for id in ids: + peer_disconnected.emit(id) + +func _put_packet_script(p_buffer: PackedByteArray) -> Error: + if _other == null or _status != MultiplayerPeer.CONNECTION_CONNECTED: + return ERR_UNCONFIGURED + var deliver := false + if _server_side: + deliver = _target == 0 or _target == _other._id or (_target < 0 and -_target != _other._id) + else: + deliver = _target == 0 or _target == 1 + if deliver: + _other._inbox.push_back(p_buffer) + return OK + +func _get_packet_script() -> PackedByteArray: + if _inbox.is_empty(): + return PackedByteArray() + return _inbox.pop_front() + +func _get_available_packet_count() -> int: + return _inbox.size() + +func _get_max_packet_size() -> int: + return 1 << 24 + +func _get_packet_peer() -> int: + if _other == null: + return 0 + return _other._id + +func _get_packet_channel() -> int: + return 0 + +func _get_packet_mode() -> MultiplayerPeer.TransferMode: + return MultiplayerPeer.TRANSFER_MODE_RELIABLE + +func _set_transfer_channel(p_channel: int) -> void: + _channel = p_channel + +func _get_transfer_channel() -> int: + return _channel + +func _set_transfer_mode(p_mode: MultiplayerPeer.TransferMode) -> void: + _mode = p_mode + +func _get_transfer_mode() -> MultiplayerPeer.TransferMode: + return _mode + +func _set_target_peer(p_peer: int) -> void: + _target = p_peer + +func _is_server() -> bool: + return _server_side + +func _get_unique_id() -> int: + return _id + +func _get_connection_status() -> MultiplayerPeer.ConnectionStatus: + return _status + +func _set_refuse_new_connections(p_enable: bool) -> void: + _refusing = p_enable + +func _is_refusing_new_connections() -> bool: + return _refusing + +func _is_server_relay_supported() -> bool: + return false + +func _disconnect_peer(p_peer: int, _p_force: bool) -> void: + if _other == null: + return + if _server_side and p_peer == _other._id: + _dropped.append(_other._id) + _other._dropped.append(1) + _other._status = MultiplayerPeer.CONNECTION_DISCONNECTED + _other._other = null + _other = null + +func _close() -> void: + _status = MultiplayerPeer.CONNECTION_DISCONNECTED + _inbox.clear() + if _other != null: + _other._status = MultiplayerPeer.CONNECTION_DISCONNECTED + _other._other = null + _other = null --- a/client/menus/lobby/lobby_menu.gd +++ b/client/menus/lobby/lobby_menu.gd @@ -12,7 +12,9 @@ lobby.changed.connect(_on_settings_changed) $MarginContainer/VBoxContainer/Content/VBoxContainer/Board.disabled = true $MarginContainer/VBoxContainer/Footer/Start.disabled = true - $MarginContainer/VBoxContainer/Footer/HBoxContainer/Lobby.text = "Join code: " + lobby.name + # fredygames: there is no online play, so the join code and its copy button are hidden + $MarginContainer/VBoxContainer/Footer/HBoxContainer/Lobby.hide() + $MarginContainer/VBoxContainer/Footer/HBoxContainer/Button.hide() for board in PluginSystem.board_loader.get_loaded_boards(): var boards := $MarginContainer/VBoxContainer/Content/VBoxContainer/Board --- a/server/lobby.gd +++ b/server/lobby.gd @@ -82,6 +82,9 @@ for player in player_info: remaining.erase(player.character) while len(player_info) < LOBBY_SIZE: + # fredygames: with fewer characters than seats, the bots may share a character + if remaining.is_empty(): + remaining = PluginSystem.character_loader.get_loaded_characters() var idx = randi() % len(remaining) var character = remaining[idx] remaining[idx] = remaining[-1]