From 864e70063b4afa92f8bf0565e6b8e1ebfd57c0ce Mon Sep 17 00:00:00 2001 From: Penguin_Spy Date: Thu, 11 Apr 2024 01:53:31 -0700 Subject: [PATCH] correctly sync all bodies to the appropriate clients; correctly init WebRTC when a 2nd client connects improve & simplify the parsing of signaling server URLs. relative urls are not allowed, and the endpoint to connect to for published session codes is relative to the document's origin thus, this code is now identical in dev & prod, and functions equivalently --- client/Client.js | 5 ++- common/World.js | 5 ++- common/util.js | 34 +++++++++++++++++- link/Constants.js | 3 -- link/DirectLink.js | 79 ++++++++++++++++++++---------------------- link/Link.js | 6 +++- link/NetworkLink.js | 45 ++++++++++-------------- link/PeerConnection.js | 47 +++++++++++++++++-------- link/util.js | 49 ++++++++++++++++++++++++++ 9 files changed, 183 insertions(+), 90 deletions(-) create mode 100644 link/util.js diff --git a/client/Client.js b/client/Client.js index 7d02bf2..1772a5f 100644 --- a/client/Client.js +++ b/client/Client.js @@ -14,7 +14,10 @@ export default class Client { // generate a UUID for the player if one does not exist this.uuid = localStorage.getItem("player_uuid") if(this.uuid === null) { - this.uuid = crypto.randomUUID() + // generates a UUID by asking for a blob url (which are always uuids). doesn't use crypto.randomUUID() because it doesn't work in non-secure contexts + const url = URL.createObjectURL(new Blob()) + this.uuid = url.substr(-36) + URL.revokeObjectURL(url) localStorage.setItem("player_uuid", this.uuid) } console.log("[client] player uuid", this.uuid) diff --git a/common/World.js b/common/World.js index 2746bd4..a43c76c 100644 --- a/common/World.js +++ b/common/World.js @@ -2,7 +2,7 @@ import Body from "/common/Body.js" import * as CANNON from 'cannon' import * as THREE from 'three' -import { DT } from "/common/util.js" +import { CircularQueue, DT } from "/common/util.js" import CelestialBody from "/common/bodies/CelestialBody.js" import CharacterBody from "./bodies/CharacterBody.js" import TestBody from "/common/bodies/TestBody.js" @@ -48,6 +48,7 @@ export default class World { }) this.nextNetID = 0 // unique across everything (even bodies & components won't share one) + this.netSyncQueue = new CircularQueue() // load bodies data.bodies.forEach(b => this.loadBody(b)) @@ -175,9 +176,11 @@ export default class World { this.physics.addBody(body.rigidBody) if(body.mesh) this.scene.add(body.mesh) this.bodies.push(body) + body.netID = this.nextNetID this.nextNetID++ body.netPriority = 0 + this.netSyncQueue.push(body) } getBodyByNetID(netID) { diff --git a/common/util.js b/common/util.js index c7c27d5..1780cc0 100644 --- a/common/util.js +++ b/common/util.js @@ -16,6 +16,38 @@ class TwoWayMap { } } +// circular queue thing +class CircularQueue { + #array; #cursor + constructor() { + this.#array = [] + this.#cursor = 0 + } + + /** The number of elements in the queue */ + get size() { + return this.#array.length + } + + /** Pushes an element onto the front of the queue with maximum priority. + * @param {any} element */ + push(element) { + // insert the new element at the current cursor + this.#array.splice(this.#cursor, 0, element) + } + + /** Returns the element with the highest priority and resets its priority. + * @returns {any} */ + next() { + const element = this.#array[this.#cursor] + this.#cursor++ // goes to the next highest priority element, looping around to the start of the array when necessary + if(this.#cursor > this.#array.length) { + this.#cursor = 0 + } + return element + } +} + /** * type safety checks during deserialization * @param {any} variable The value to check the type of @@ -43,7 +75,7 @@ function check(variable, type) { } } -export { TwoWayMap, check } +export { TwoWayMap, CircularQueue, check } /** * The fraction of a second that 1 update takes. \ diff --git a/link/Constants.js b/link/Constants.js index 02871f6..84d3074 100644 --- a/link/Constants.js +++ b/link/Constants.js @@ -1,5 +1,3 @@ -const SIGNAL_ENDPOINT = "wss://voxilon.penguinspy.dev/signal" - const PacketType = Object.freeze({ CHAT: 0, LOAD_WORLD: 1, @@ -8,6 +6,5 @@ const PacketType = Object.freeze({ }) export { - SIGNAL_ENDPOINT, PacketType } diff --git a/link/DirectLink.js b/link/DirectLink.js index cda0f45..613e4a0 100644 --- a/link/DirectLink.js +++ b/link/DirectLink.js @@ -4,11 +4,15 @@ import PeerConnection from '/link/PeerConnection.js' import PacketEncoder from '/link/PacketEncoder.js' import PacketDecoder from '/link/PacketDecoder.js' import Link from '/link/Link.js' -import { SIGNAL_ENDPOINT, PacketType } from '/link/Constants.js' +import { PacketType } from '/link/Constants.js' import Client from '/client/Client.js' +import { sessionPublishURL } from '/link/util.js' const { CHAT, SYNC_BODY } = PacketType export default class DirectLink extends Link { + /** @type {World} */ + world + /** * @param {Client} client */ @@ -72,17 +76,15 @@ export default class DirectLink extends Link { /* --- Direct Link methods --- */ - async publish(uri) { + async publish() { try { console.info("Publishing session to signaling server") - if(!uri) { uri = SIGNAL_ENDPOINT + "/new_session" } - // create session & start listening for WebRTC connections - this.ws = new WebSocket(uri) - this.ws.onmessage = e => { + this.ws = new WebSocket(sessionPublishURL) + this.ws.addEventListener("message", e => { const data = JSON.parse(e.data) - console.log("[link Receive]", data) + console.log("[link signal receive]", data) switch(data.type) { case "hello": // from the signaling server console.info(`Join code: ${data.join_code}`) @@ -102,14 +104,13 @@ export default class DirectLink extends Link { client.id = data.from client.uuid = data.uuid - // replaces the websocket onmessage handler with the peer connection one for establishing WebRTC client.pc = new PeerConnection(this.ws, client.id) client.dataChannel = client.pc.createDataChannel("link", { ordered: false, negotiated: true, id: 0 }) - client.dataChannel.onclose = e => { console.info(`[dataChannel:${client.id}] close`) } + client.dataChannel.onclose = e => { console.info(`[dataChannel:${client.id}] close`, e) } client.dataChannel.onmessage = ({ data }) => { try { this._handlePacket(client, data) @@ -138,11 +139,11 @@ export default class DirectLink extends Link { default: break; } - } + }) - this.ws.onclose = ({ code, reason }) => { + this.ws.addEventListener("close", ({ code, reason }) => { console.warn(`Websocket closed | ${code}: ${reason}`) - } + }) } catch(err) { console.error("An error occured while publishing the universe:", err) @@ -158,19 +159,19 @@ export default class DirectLink extends Link { this.emit('chat_message', packet) this.broadcast(PacketEncoder.CHAT(packet.author, packet.msg)) break; - case SYNC_BODY: - // validate it is the clients own body - if(packet.i !== client.body.netID) { - console.error(`client #${client.id} sent sync packet for incorrect body:`, packet) - client.dataChannel.close() - break - } - - client.body.position.set(...packet.p) - client.body.velocity.set(...packet.v) - client.body.quaternion.set(...packet.q) - client.body.angularVelocity.set(...packet.a) - + case SYNC_BODY: + // validate it is the clients own body + if(packet.i !== client.body.netID) { + console.error(`client #${client.id} sent sync packet for incorrect body:`, packet) + client.dataChannel.close() + break + } + + client.body.position.set(...packet.p) + client.body.velocity.set(...packet.v) + client.body.quaternion.set(...packet.q) + client.body.angularVelocity.set(...packet.a) + break; default: throw new TypeError(`Unknown packet type ${packet.$}`) @@ -189,24 +190,20 @@ export default class DirectLink extends Link { } } - step(deltaTime) { - // update the world (physics & gameplay) - super.step(deltaTime) - - // then calculate the priority of objects - // (TODO) - this.client.activeController.body.netPriority++; - - // send sync packets - const ourBody = this.client.activeController.body - if(ourBody.netPriority > 60) { - ourBody.netPriority = 0 - const ourBodySync = PacketEncoder.SYNC_BODY(this.client.activeController.body) - - this.broadcast(ourBodySync) + // ran after each DT world step + postUpdate() { + const body = this.world.netSyncQueue.next() + if(!body) return + const bodySync = PacketEncoder.SYNC_BODY(body) + + for(const client of this._clients) { + if(client.dataChannel.readyState === "open" && + client.body !== body) { // do not send a client a sync packet for their own body, they have the authoritative state of it + client.dataChannel.send(bodySync) + } } } - + stop() { this.ws.close(1000, "stopping client") for(const client of this._clients) { diff --git a/link/Link.js b/link/Link.js index 83b0174..739b31c 100644 --- a/link/Link.js +++ b/link/Link.js @@ -29,7 +29,8 @@ export default class Link { let maxSteps = 10; while(this.#accumulator > DT && maxSteps > 0) { - this.world.step(DT) + this.world.step() + this.postUpdate() this.#accumulator -= DT maxSteps-- } @@ -40,4 +41,7 @@ export default class Link { } } + + postUpdate() { + } } diff --git a/link/NetworkLink.js b/link/NetworkLink.js index 28d1910..f6eb4f5 100644 --- a/link/NetworkLink.js +++ b/link/NetworkLink.js @@ -5,10 +5,10 @@ import PacketEncoder from '/link/PacketEncoder.js' import PacketDecoder from '/link/PacketDecoder.js' import Link from '/link/Link.js' import PlayerController from '/client/PlayerController.js' -import { SIGNAL_ENDPOINT, PacketType } from '/link/Constants.js' +import { PacketType } from '/link/Constants.js' +import { parseSignalTarget } from '/link/util.js' const { CHAT, LOAD_WORLD, SET_CONTROLLER_STATE, SYNC_BODY } = PacketType -const JOIN_CODE_REGEX = /^([A-HJ-NP-Z0-9]{5})$/ // CONNECTING: waiting for WebSocket connect, join request, and WebRTC connect // LOADING: WebRTC connected, waiting for world to load @@ -29,23 +29,14 @@ export default class NetworkLink extends Link { this._readyReject = reject }) - // open a WebRTC data channel with the host of the specified game - if(target.match(JOIN_CODE_REGEX)) { // convert join code to full url - console.log(`prefixing ${target}`) - target = `${SIGNAL_ENDPOINT}/${target}` - } - // normalize url (URL constructor is allowed to throw an error) - const targetURL = new URL(target, document.location) - targetURL.protocol = targetURL.hostname === "localhost" ? "ws" : "wss" - targetURL.hash = "" - console.log(targetURL) - // create websocket & add msg handler + const targetURL = parseSignalTarget(target) + console.info("Connecting to ", targetURL.href) this.ws = new WebSocket(targetURL) - this.ws.onmessage = (e) => { + this.ws.addEventListener("message", e => { const data = JSON.parse(e.data) - console.log("[link Receive]", data) + console.log("[link signal receive]", data) switch(data.type) { case "join": if(data.approved) { // request to join was approved, continue with WebRTC @@ -81,24 +72,24 @@ export default class NetworkLink extends Link { default: break; } - } + }) this.client = client // finally, request to join - this.ws.onopen = () => { + this.ws.addEventListener("open", () => { this.ws.send(JSON.stringify({ type: "join", username: this.username, uuid: this.client.uuid })) - } - this.ws.onclose = ({ code, reason }) => { + }) + this.ws.addEventListener("close", ({ code, reason }) => { console.warn(`Websocket closed | ${code}: ${reason}`) if(this._readyState === CONNECTING) { this._readyReject(new Error("Websocket closed while connecting")) } - } + }) } get ready() { return this._readyPromise } @@ -134,17 +125,17 @@ export default class NetworkLink extends Link { this.client.setController(packet.type, body) break; - + case SYNC_BODY: const syncedBody = this.world.getBodyByNetID(packet.i) - + syncedBody.position.set(...packet.p) syncedBody.velocity.set(...packet.v) syncedBody.quaternion.set(...packet.q) syncedBody.angularVelocity.set(...packet.a) - + break; - + default: throw new TypeError(`Unknown packet type ${packet.$}`) } @@ -153,11 +144,11 @@ export default class NetworkLink extends Link { send(packet) { this.dataChannel.send(packet) } - + step(deltaTime) { // update the world (physics & gameplay) super.step(deltaTime) - + // then calculate the priority of syncing our own body this.bodyNetPriority++ if(this.bodyNetPriority > 30) { @@ -165,7 +156,7 @@ export default class NetworkLink extends Link { this.dataChannel.send(PacketEncoder.SYNC_BODY(this.client.activeController.body)) } } - + stop() { try { this.ws.close(1000, "stopping client") diff --git a/link/PeerConnection.js b/link/PeerConnection.js index 5fba008..9d4045d 100644 --- a/link/PeerConnection.js +++ b/link/PeerConnection.js @@ -1,10 +1,15 @@ // Class to encapsulate the RTCPeerConnection & communication with the signaling server export default class PeerConnection { + /** + * Creates a new PeerConnection that encapsulates the WebRTC negotiation and additional communication with the signaling server. + * @param {integer?} to optional int, ID of client this connection is to (for host) + * @param {WebSocket} ws WebSocket, for signaling server + */ constructor(ws, to) { - this.pc = new RTCPeerConnection() // RTCPeerConnection - this.ws = ws // WebSocket, for signaling server - this.to = to // optional int, ID of client this connection is to (for host) + this.pc = new RTCPeerConnection() + this.ws = ws + this.to = to this.polite = to === undefined // if to isn't given, we're the client, and therefore the polite peer this.makingOffer = false; @@ -15,7 +20,7 @@ export default class PeerConnection { this.makingOffer = true; await this.pc.setLocalDescription(); this.signal({ type: "description", content: this.pc.localDescription }) - } catch (err) { + } catch(err) { console.error(err); } finally { this.makingOffer = false; @@ -26,21 +31,33 @@ export default class PeerConnection { this.signal({ type: "candidate", content: candidate }) } - this.ws.onmessage = async (e) => { + this.ws.addEventListener("message", async (e) => { try { const data = JSON.parse(e.data) - console.log("[DC Signal Receive]", data) - switch (data.type) { + + // only handle this message if it's from this PeerConnection's client + if(this.to !== undefined) { + if(data.from === this.to) { + console.log(`[DC signal receive:${this.to}]`, data) + } else { + console.log(`[DC signal receive:${this.to}] ignoring message to different client '${data.from}'`) + return + } + } else { + console.log(`[DC signal receive]`, data) + } + + switch(data.type) { case "description": const description = data.content const offerCollision = (description.type === "offer") && (this.makingOffer || this.pc.signalingState !== "stable"); this.ignoreOffer = !this.polite && offerCollision; - if (this.ignoreOffer) { return; } + if(this.ignoreOffer) { return; } await this.pc.setRemoteDescription(description); - if (description.type === "offer") { + if(description.type === "offer") { await this.pc.setLocalDescription(); this.signal({ type: "description", content: this.pc.localDescription }) } @@ -49,8 +66,8 @@ export default class PeerConnection { case "candidate": try { await this.pc.addIceCandidate(data.content); - } catch (err) { - if (!this.ignoreOffer) { + } catch(err) { + if(!this.ignoreOffer) { throw err; } } @@ -60,19 +77,19 @@ export default class PeerConnection { //console.info("[]") break; } - } catch (err) { + } catch(err) { console.error(err) } - } + }) } signal(packet) { - if (this.to !== undefined) { packet.to = this.to } + if(this.to !== undefined) { packet.to = this.to } this.ws.send(JSON.stringify(packet)); } createDataChannel(label, options) { return this.pc.createDataChannel(label, options) } -} \ No newline at end of file +} diff --git a/link/util.js b/link/util.js new file mode 100644 index 0000000..b1642ec --- /dev/null +++ b/link/util.js @@ -0,0 +1,49 @@ +const URL_SCHEME_REGEX = /^[a-z][a-z0-9+\-.]*\:/i +const JOIN_CODE_REGEX = /^([A-HJ-NP-Z0-9]{5})$/ +const SIGNAL_ENDPOINT = new URL("/signal", document.location) + +/** + * Parses a URL string and converts it to a URL object that is valid to attempt a WebSocket connection to. + * @param {string} url + * @returns {URL} + */ +function normalizeSignalURL(url) { + try { + // if no scheme is given, assume secure WebSocket + if(!url.match(URL_SCHEME_REGEX)) { + url = `wss:${url}` + } + + // parse the url (URL constructor is allowed to throw an error) + const targetURL = new URL(url) + + // convert http & https schemes to ws & wss respectively + if(targetURL.protocol === "http:") { targetURL.protocol = "ws:" } + if(targetURL.protocol === "https:") { targetURL.protocol = "wss:" } + + // websockets ignore the hash. clean it here for clarity + targetURL.hash = "" + + return targetURL + + } catch(e) { + throw new Error(`Failed to validate signal URL: '${url}'`, { cause: e }) + } +} + +/** + * Parses a multiplayer join target: either a published game session's join code, or a URL of a dedicated multiplayer server('s signaling server). + * Converts it to a URL object that is valid to attempt a WebSocket connection to. + * @param {string} target + * @returns {URL} + */ +export function parseSignalTarget(target) { + if(target.match(JOIN_CODE_REGEX)) { // convert join code to full url + console.log(`prefixing ${target}`) + target = `${SIGNAL_ENDPOINT}/${target}` + } + return normalizeSignalURL(target) +} + +/** The URL to open a WebSocket connection with to create a new game session on the signaling server. */ +export const sessionPublishURL = normalizeSignalURL(`${SIGNAL_ENDPOINT}/new_session`)