diff --git a/game.gd b/game.gd index 5efc9c1..a9a9b67 100644 --- a/game.gd +++ b/game.gd @@ -17,4 +17,4 @@ func _ready(): func _on_Button_pressed(): - Global.leave_game() + Global.main_menu() diff --git a/global.gd b/global.gd index cf0ca82..a0996a6 100644 --- a/global.gd +++ b/global.gd @@ -4,35 +4,20 @@ const MultiplayerClient = preload("multiplayer_client.gd") onready var client = MultiplayerClient.new() -const MULTIPLAYER_URL = "wss://webrtc-signaller.deno.dev/" - func _ready(): - client.connect("lobby_joined", self, "_lobby_joined") - client.connect("connected", self, "_connected") add_child(client) +func goto_scene(scene_resource_name): + var _result = get_tree().change_scene("res://%s.tscn" % scene_resource_name) + +func main_menu(): + goto_scene("main") + func start_singleplayer_game(): - print("Starting singleplayer game...") - get_tree().change_scene("res://game.tscn") + goto_scene("game") -func join_lobby(): - get_tree().change_scene("res://join_lobby.tscn") +func multiplayer(): + goto_scene("multiplayer") -func create_lobby(): - join_lobby_with_code("") - -func _lobby_joined(_lobby): - print("Joined!") - goto_lobby() - -func goto_lobby(): - print("Going to lobby...") - get_tree().change_scene("res://lobby.tscn") - -func leave_game(): - client.stop() - print("Leaving game...") - get_tree().change_scene("res://main.tscn") - -func join_lobby_with_code(code): - client.start(MULTIPLAYER_URL, code) +func quit(): + get_tree().quit() diff --git a/main.gd b/main.gd index 4e290d9..ce29459 100644 --- a/main.gd +++ b/main.gd @@ -5,8 +5,8 @@ func _on_Singleplayer_pressed(): func _on_CreateLobbyButton_pressed(): - Global.create_lobby() + Global.multiplayer() func _on_JoinLobbyButton_pressed(): - Global.join_lobby() + Global.quit() diff --git a/main.tscn b/main.tscn index 627c2f1..a5efc91 100644 --- a/main.tscn +++ b/main.tscn @@ -25,7 +25,7 @@ __meta__ = { [node name="Singleplayer" type="Button" parent="VBoxContainer"] margin_right = 995.0 margin_bottom = 20.0 -text = "Start Singleplayer" +text = "Start Singleplayer Game" __meta__ = { "_edit_use_anchors_": false } @@ -34,13 +34,13 @@ __meta__ = { margin_top = 70.0 margin_right = 995.0 margin_bottom = 90.0 -text = "Create Lobby" +text = "Multiplayer" [node name="JoinLobbyButton" type="Button" parent="VBoxContainer"] margin_top = 140.0 margin_right = 995.0 margin_bottom = 160.0 -text = "Join Lobby" +text = "Quit" __meta__ = { "_edit_use_anchors_": false } diff --git a/multiplayer.gd b/multiplayer.gd new file mode 100644 index 0000000..663fea2 --- /dev/null +++ b/multiplayer.gd @@ -0,0 +1,9 @@ +extends Control + +onready var is_loaded = false + +func _ready(): + Global.client.connect_to_signaller() + +func _on_back_pressed(): + pass diff --git a/multiplayer.tscn b/multiplayer.tscn new file mode 100644 index 0000000..d243226 --- /dev/null +++ b/multiplayer.tscn @@ -0,0 +1,40 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://multiplayer.gd" type="Script" id=1] + +[node name="Control" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="back" type="Button" parent="."] +margin_left = 27.0 +margin_top = 357.0 +margin_right = 286.0 +margin_bottom = 432.0 +text = "Back" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="create_lobby" type="Button" parent="."] +margin_right = 259.0 +margin_bottom = 75.0 +text = "Create Lobby" +__meta__ = { +"_edit_use_anchors_": false +} + +[node name="lobbies" type="Label" parent="."] +margin_left = 25.0 +margin_top = 91.0 +margin_right = 591.0 +margin_bottom = 325.0 +__meta__ = { +"_edit_use_anchors_": false +} + +[connection signal="pressed" from="back" to="." method="_on_back_pressed"] diff --git a/multiplayer_client.gd b/multiplayer_client.gd index 09f1db3..6bacd99 100644 --- a/multiplayer_client.gd +++ b/multiplayer_client.gd @@ -1,87 +1,90 @@ -extends "ws_webrtc_client.gd" +extends Node -var rtc_mp: WebRTCMultiplayer = WebRTCMultiplayer.new() -var sealed = false +""" +This module sets up WebRTC peer connections. +""" -func _init(): - connect("connected", self, "connected") - connect("disconnected", self, "disconnected") +var multiplayer_url = "ws://localhost:8888" +var webrtc_ice_servers = [ + { "urls": ["stun:stun.l.google.com:19302"] } +] - connect("offer_received", self, "offer_received") - connect("answer_received", self, "answer_received") - connect("candidate_received", self, "candidate_received") +const SignallerClient = preload("signaller_client.gd") - connect("lobby_joined", self, "lobby_joined") - connect("lobby_sealed", self, "lobby_sealed") - connect("peer_connected", self, "peer_connected") - connect("peer_disconnected", self, "peer_disconnected") +onready var mp = WebRTCMultiplayer.new() +onready var sc = SignallerClient.new() -func start(url, lobby = ""): - stop() - sealed = false - self.lobby = lobby - connect_to_url(url) +func _ready(): + # connect("connected", self, "connected") + # connect("disconnected", self, "disconnected") -func stop(): - rtc_mp.close() + # connect("offer_received", self, "offer_received") + # connect("answer_received", self, "answer_received") + # connect("candidate_received", self, "candidate_received") + + # connect("lobby_joined", self, "lobby_joined") + # connect("lobby_sealed", self, "lobby_sealed") + # connect("peer_connected", self, "peer_connected") + # connect("peer_disconnected", self, "peer_disconnected") + add_child(sc) + +func close(): + mp.close() + sc.close() + +func connect_to_signaller(): close() + sc.connect_to_websocket_signaller(multiplayer_url) func _create_peer(id): var peer: WebRTCPeerConnection = WebRTCPeerConnection.new() - peer.initialize({ - "iceServers": [ { "urls": ["stun:stun.l.google.com:19302"] } ] - }) + peer.initialize({"iceServers": webrtc_ice_servers}) peer.connect("session_description_created", self, "_offer_created", [id]) peer.connect("ice_candidate_created", self, "_new_ice_candidate", [id]) - rtc_mp.add_peer(peer, id) - if id > rtc_mp.get_unique_id(): - peer.create_offer() + mp.add_peer(peer, id) + if id > mp.get_unique_id(): + # TODO: peer.create_offer() + pass return peer func _new_ice_candidate(mid_name, index_name, sdp_name, id): print("New ICE Candidate: ", mid_name, index_name, sdp_name, id) - send_candidate(id, mid_name, index_name, sdp_name) + # TODO: send_candidate(id, mid_name, index_name, sdp_name) func _offer_created(type, data, id): - if not rtc_mp.has_peer(id): + if not mp.has_peer(id): return print("created", type) - rtc_mp.get_peer(id).connection.set_local_description(type, data) - if type == "offer": send_offer(id, data) - else: send_answer(id, data) + mp.get_peer(id).connection.set_local_description(type, data) + if type == "offer": + # TODO: send_offer(id, data) + pass + else: + # TODO: send_answer(id, data) + pass func connected(id): print("Connected %d" % id) - rtc_mp.initialize(id, true) - -func lobby_joined(lobby): - self.lobby = lobby - -func lobby_sealed(): - sealed = true - -func disconnected(): - print("Disconnected: %d: %s" % [code, reason]) - if not sealed: - stop() # Unexpected disconnect + mp.initialize(id, true) func peer_connected(id): print("Peer connected %d" % id) _create_peer(id) func peer_disconnected(id): - if rtc_mp.has_peer(id): rtc_mp.remove_peer(id) + if mp.has_peer(id): + mp.remove_peer(id) func offer_received(id, offer): print("Got offer: %d" % id) - if rtc_mp.has_peer(id): - rtc_mp.get_peer(id).connection.set_remote_description("offer", offer) + if mp.has_peer(id): + mp.get_peer(id).connection.set_remote_description("offer", offer) func answer_received(id, answer): print("Got answer: %d" % id) - if rtc_mp.has_peer(id): - rtc_mp.get_peer(id).connection.set_remote_description("answer", answer) + if mp.has_peer(id): + mp.get_peer(id).connection.set_remote_description("answer", answer) func candidate_received(id, mid, index, sdp): - if rtc_mp.has_peer(id): - rtc_mp.get_peer(id).connection.add_ice_candidate(mid, index, sdp) + if mp.has_peer(id): + mp.get_peer(id).connection.add_ice_candidate(mid, index, sdp) diff --git a/readme.md b/readme.md index aec470c..2b4117c 100644 --- a/readme.md +++ b/readme.md @@ -4,4 +4,10 @@ # Signalling Server - deno run -A server.ts +The signaling server is written in TypeScript to run on Deno. It can run in the +cloud via Deno deploy. + + PORT=8888 deno run --allow-env --allow-net server.ts + +- https://dash.deno.com/projects/webrtc-signaller +- https://webrtc-signaller.deno.dev diff --git a/server.ts b/server.ts index 6219cef..95cb0c3 100644 --- a/server.ts +++ b/server.ts @@ -1,69 +1,66 @@ -import { randomId, randomSecret } from "./gen.ts"; +const PORT = parseInt(Deno.env.get("PORT") || "80"); -const MAX_PEERS = 4096; -const MAX_LOBBIES = 1024; -const PORT = 80; +const randomInt = (low: number, high: number) => + Math.floor(Math.random() * (high - low + 1) + low); -const NO_LOBBY_TIMEOUT = 10000; -const SEAL_CLOSE_TIMEOUT = 10000; -// const PING_INTERVAL = 10000; +const randomSecret = () => new Array(8).map(() => randomInt(0, 10)).join(""); -const STR_NO_LOBBY = "Have not joined lobby yet"; -const STR_HOST_DISCONNECTED = "Room host has disconnected"; -const STR_ONLY_HOST_CAN_SEAL = "Only host can seal the lobby"; -const STR_SEAL_COMPLETE = "Seal complete"; -const STR_TOO_MANY_LOBBIES = "Too many lobbies open, disconnecting"; -const STR_ALREADY_IN_LOBBY = "Already in a lobby"; -const STR_LOBBY_DOES_NOT_EXIST = "Lobby does not exist"; -const STR_LOBBY_IS_SEALED = "Lobby is sealed"; -const STR_INVALID_FORMAT = "Invalid message format"; -const STR_NEED_LOBBY = "Invalid message when not in a lobby"; -const STR_SERVER_ERROR = "Server error, lobby not found"; -const STR_INVALID_DEST = "Invalid destination"; -const STR_INVALID_CMD = "Invalid command"; -const STR_TOO_MANY_PEERS = "Too many peers connected"; - -// TODO: setup regular pings? - -// state -const lobbies = new Map(); -let peersCount = 0; - -class ProtoError extends Error { - code: number; - - constructor(code: number, message: string) { - super(message); - this.code = code; - } +interface Client { + socket: WebSocket; } -class Peer { - id: number; - ws: WebSocket; - lobby: string; - timeout: number; +// TODO: version comparison - constructor(id: number, ws: WebSocket) { - this.id = id; - this.ws = ws; - this.lobby = ""; +interface Lobby { + name: string; + clients: Client[]; + // TODO: private vs public lobbies? +} - // close connection after 10 sec if client has not joined a lobby - this.timeout = setTimeout(() => { - if (!this.lobby) ws.close(4000, STR_NO_LOBBY); - }, NO_LOBBY_TIMEOUT); - } +const lobbies = new Set(); +const clients = new Set(); - joinLobby(lobbyName: string) { - if (lobbyName === "") { - if (lobbies.size >= MAX_LOBBIES) { - throw new ProtoError(4000, STR_TOO_MANY_LOBBIES); - } - // Peer must not already be in a lobby - if (this.lobby !== "") { - throw new ProtoError(4000, STR_ALREADY_IN_LOBBY); - } +console.log("Listening on port", PORT); +for await (const conn of Deno.listen({ port: PORT })) { + (async () => { + for await (const { respondWith, request } of Deno.serveHttp(conn)) { + const { socket, response } = Deno.upgradeWebSocket(request); + const client: Client = { socket }; + socket.onmessage = (ev) => { + console.log("Client Message Received", ev); + }; + socket.onopen = (ev) => { + console.log("New Client:", ev); + clients.add(client); + socket.send("lobbies_start"); + lobbies.forEach(({ name, clients }) => { + socket.send(JSON.stringify({ name, numClients: clients.length })); + }); + socket.send("lobbies_end"); + }; + socket.onclose = (ev) => { + console.log("Client Socket Close:", ev); + clients.delete(client); + }; + socket.onerror = (ev) => { + console.log("Client Socket Error:", ev); + clients.delete(client); + }; + respondWith(response); + } + })(); +} + +/* +function peerCreatesLobby(peer: Peer, lobbyName: string) { + // TODO: ensure we can create a lobby + lobbies.add({ + }) +} + */ + +/* +function peerJoinsLobby(peer: Peer, lobby: Lobby) { lobbyName = randomSecret(); lobbies.set(lobbyName, new Lobby(lobbyName, this.id)); console.log(`Peer ${this.id} created lobby ${lobbyName}`); @@ -80,63 +77,45 @@ class Peer { lobby.join(this); this.ws.send(`J: ${lobbyName}\n`); } + // TODO: ensure peer not already in a lobby + const assigned = this.getPeerId(peer); + peer.ws.send(`I: ${assigned}\n`); + this.peers.forEach((p) => { + p.ws.send(`N: ${assigned}\n`); + peer.ws.send(`N: ${this.getPeerId(p)}\n`); + }); + this.peers.push(peer); } + */ -class Lobby { - name: string; - host: number; - peers: Peer[]; - sealed: boolean; - closeTimer: number; - - constructor(name: string, host: number) { - this.name = name; - this.host = host; - this.peers = []; - this.sealed = false; +/* +function peerLeavesLobby(peer: Peer) { + const idx = this.peers.findIndex((p) => peer === p); + if (idx === -1) return false; + const assigned = this.getPeerId(peer); + const close = assigned === 1; + this.peers.forEach((p) => { + try { + // room host disconnected + if (close) p.ws.close(4000, STR_HOST_DISCONNECTED); + // notify peers + else p.ws.send(`D: ${assigned}\n`); + } catch (e) { + console.error(`Error when leaving: ${e}`); + } + }); + this.peers.splice(idx, 1); + if (close && this.closeTimer >= 0) { + // we are closing already. + clearTimeout(this.closeTimer); this.closeTimer = -1; } + return close; +} + */ - getPeerId(peer: Peer) { - if (this.host === peer.id) return 1; - return peer.id; - } - - join(peer: Peer) { - const assigned = this.getPeerId(peer); - peer.ws.send(`I: ${assigned}\n`); - this.peers.forEach((p) => { - p.ws.send(`N: ${assigned}\n`); - peer.ws.send(`N: ${this.getPeerId(p)}\n`); - }); - this.peers.push(peer); - } - - leave(peer: Peer) { - const idx = this.peers.findIndex((p) => peer === p); - if (idx === -1) return false; - const assigned = this.getPeerId(peer); - const close = assigned === 1; - this.peers.forEach((p) => { - try { - // room host disconnected - if (close) p.ws.close(4000, STR_HOST_DISCONNECTED); - // notify peers - else p.ws.send(`D: ${assigned}\n`); - } catch (e) { - console.error(`Error when leaving: ${e}`); - } - }); - this.peers.splice(idx, 1); - if (close && this.closeTimer >= 0) { - // we are closing already. - clearTimeout(this.closeTimer); - this.closeTimer = -1; - } - return close; - } - - seal(peer: Peer) { +/* +function graduateLobby(lobby: Lobby) { // only host can seal if (peer.id !== this.host) { throw new ProtoError(4000, STR_ONLY_HOST_CAN_SEAL); @@ -155,39 +134,14 @@ class Lobby { p.ws.close(1000, STR_SEAL_COMPLETE); }); }, SEAL_CLOSE_TIMEOUT); - } } +} + */ + +/* function parseMsg(peer: Peer, msg: string) { - const sep = msg.indexOf("\n"); - if (sep < 0) throw new ProtoError(4000, STR_INVALID_FORMAT); - - const cmd = msg.slice(0, sep); - if (cmd.length < 3) throw new ProtoError(4000, STR_INVALID_FORMAT); - - const data = msg.slice(sep); - - // join - if (cmd.startsWith("J: ")) { - peer.joinLobby(cmd.substr(3).trim()); - return; - } - - if (!peer.lobby) throw new ProtoError(4000, STR_NEED_LOBBY); - const lobby = lobbies.get(peer.lobby); - if (!lobby) throw new ProtoError(4000, STR_SERVER_ERROR); - - // seal - if (cmd.startsWith("S: ")) { - lobby.seal(peer); - return; - } - - // Message relaying format: - // - // [O|A|C]: DEST_ID\n - // PAYLOAD - // + // TODO: modify this? // O: Client is sending an offer. // A: Client is sending an answer. // C: Client is sending a candidate. @@ -208,63 +162,4 @@ function parseMsg(peer: Peer, msg: string) { } throw new ProtoError(4000, STR_INVALID_CMD); } - -console.log(`Server running on port ${PORT}`); -const server = Deno.listen({ port: PORT }); -for await (const conn of server) { - (async () => { - const httpConn = Deno.serveHttp(conn); - for await (const requestEvent of httpConn) { - if (requestEvent) { - try { - const { socket, response } = Deno.upgradeWebSocket( - requestEvent.request, - ); - const id = randomId(); - const peer = new Peer(id, socket); - socket.onopen = (_ev) => { - if (peersCount >= MAX_PEERS) { - socket.close(4000, STR_TOO_MANY_PEERS); - return; - } - peersCount++; - }; - socket.onmessage = (ev) => { - try { - parseMsg(peer, ev.data); - } catch (e) { - const code = e.code || 4000; - console.log(`Error parsing message from ${id}:\n` + ev.data); - socket.close(code, e.message); - } - }; - socket.onclose = (ev) => { - peersCount--; - console.log( - `Connection with peer ${peer.id} closed ` + - `with reason: ${ev.reason}`, - ); - if ( - peer.lobby && - lobbies.has(peer.lobby) && - lobbies.get(peer.lobby).leave(peer) - ) { - lobbies.delete(peer.lobby); - console.log(`Deleted lobby ${peer.lobby}`); - console.log(`Open lobbies: ${lobbies.size}`); - peer.lobby = ""; - } - if (peer.timeout >= 0) { - clearTimeout(peer.timeout); - peer.timeout = -1; - } - }; - socket.onerror = (e) => console.error("WebSocket error:", e); - requestEvent.respondWith(response); - } catch (e) { - console.log(`Error during connection:`, e); - } - } - } - })(); -} + */ diff --git a/ws_webrtc_client.gd b/signaller_client.gd similarity index 55% rename from ws_webrtc_client.gd rename to signaller_client.gd index 887eaff..0af67dc 100644 --- a/ws_webrtc_client.gd +++ b/signaller_client.gd @@ -1,52 +1,75 @@ extends Node -export var autojoin = true -export var lobby = "" # Will create a new lobby if empty. +""" +This module is responsible for making a WebSocket connection to the signaller +in order to enable establishish WebRTC P2P connections. Another module is +expected to fully setup the peer connections. +""" -var client: WebSocketClient = WebSocketClient.new() -var code = 1000 -var reason = "Unknown" +onready var ws: WebSocketClient = WebSocketClient.new() -signal lobby_joined(lobby) -signal connected(id) -signal disconnected() -signal peer_connected(id) -signal peer_disconnected(id) -signal offer_received(id, offer) -signal answer_received(id, answer) -signal candidate_received(id, mid, index, sdp) -signal lobby_sealed() - -func _init(): - client.connect("data_received", self, "_parse_msg") - client.connect("connection_established", self, "_connected") - client.connect("connection_closed", self, "_closed") - client.connect("connection_error", self, "_closed") - client.connect("server_close_request", self, "_close_request") - -func connect_to_url(url): - close() - code = 1000 - reason = "Unknown" - client.connect_to_url(url) +func _ready(): + var _result = ws.connect("data_received", self, "_parse_msg") + _result = ws.connect("connection_established", self, "_connected") + _result = ws.connect("connection_closed", self, "_closed") + _result = ws.connect("connection_error", self, "_closed") + _result = ws.connect("server_close_request", self, "_close_request") func close(): - client.disconnect_from_host() + ws.disconnect_from_host() -func _closed(was_clean = false): - emit_signal("disconnected") +func connect_to_websocket_signaller(url: String): + print(ws) + close() + print("Attempting to connect to WebSocket signalling server at ", url) + var _result = ws.connect_to_url(url) -func _close_request(code, reason): - self.code = code - self.reason = reason +func _closed(): + # emit_signal("disconnected") + pass + +func _close_request(code: int, reason: String): + print("Received WebSocket close request from signalling server ", code, reason) func _connected(protocol = ""): - client.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT) - if autojoin: - join_lobby(lobby) + print("WebSocket signaller connected via protocol ", protocol) + ws.get_peer(1).set_write_mode(WebSocketPeer.WRITE_MODE_TEXT) + +func _process(_delta: float): + var status: int = ws.get_connection_status() + if status == WebSocketClient.CONNECTION_CONNECTING or status == WebSocketClient.CONNECTION_CONNECTED: + ws.poll() func _parse_msg(): - var pkt_str: String = client.get_peer(1).get_packet().get_string_from_utf8() + var pkt_str: String = ws.get_peer(1).get_packet().get_string_from_utf8() + print("Signaller sent: ", pkt_str) + + + + + + + + + + + + + + + + + + + + + + + + +""" +func _parse_msg(): + var pkt_str: String = ws.get_peer(1).get_packet().get_string_from_utf8() var req: PoolStringArray = pkt_str.split("\n", true, 1) if req.size() != 2: # Invalid request size @@ -99,10 +122,10 @@ func _parse_msg(): emit_signal("candidate_received", src_id, candidate[0], int(candidate[1]), candidate[2]) func join_lobby(joined_lobby): - return client.get_peer(1).put_packet(("J: %s\n" % joined_lobby).to_utf8()) + return ws.get_peer(1).put_packet(("J: %s\n" % joined_lobby).to_utf8()) func seal_lobby(): - return client.get_peer(1).put_packet("S: \n".to_utf8()) + return ws.get_peer(1).put_packet("S: \n".to_utf8()) func send_candidate(id, mid, index, sdp) -> int: return _send_msg("C", id, "\n%s\n%d\n%s" % [mid, index, sdp]) @@ -114,9 +137,5 @@ func send_answer(id, answer) -> int: return _send_msg("A", id, answer) func _send_msg(type, id, data) -> int: - return client.get_peer(1).put_packet(("%s: %d\n%s" % [type, id, data]).to_utf8()) - -func _process(_delta): - var status: int = client.get_connection_status() - if status == WebSocketClient.CONNECTION_CONNECTING or status == WebSocketClient.CONNECTION_CONNECTED: - client.poll() + return ws.get_peer(1).put_packet(("%s: %d\n%s" % [type, id, data]).to_utf8()) +""" diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..ac899fb --- /dev/null +++ b/types.ts @@ -0,0 +1,9 @@ +export interface Client { + socket: WebSocket; +} + +export interface Lobby { + name: string; + secret: string; + clients: Client[]; +}