@game-vir/multiplayer
Version:
Client side definitions and types for @game-vir/multiplayer-server
390 lines (389 loc) • 16.6 kB
JavaScript
import { assert, waitUntil } from '@augment-vir/assert';
import { PromiseQueue, createUuidV4, ensureErrorAndPrependMessage, extractErrorMessage, filterMap, getObjectTypedValues, log, makeWritable, mergeDefinedProperties, randomString, stringify, } from '@augment-vir/common';
import { ListenTarget, defineTypedCustomEvent } from 'typed-event-target';
import { MultiplayerWebSocketMessageType } from './web-rtc-communication.js';
import { WebrtcConnectEvent, WebrtcController, WebrtcMessageEvent } from './webrtc-controller.js';
/**
* An event that is omitted from {@link WebrtcController} when a WebRTC message is received.
*
* @category Internal
*/
export class WebrtcMultiplayerMessageEvent extends defineTypedCustomEvent()('webrtc-multiplayer-message') {
sourceClientId;
constructor(sourceClientId, detail) {
super({ detail });
this.sourceClientId = sourceClientId;
}
}
/**
* An event that is omitted from {@link WebrtcMultiplayerController} when the multiplayer room host
* is updated.
*
* @category Internal
*/
export class WebrtcMultiplayerConnectionUpdateEvent extends defineTypedCustomEvent()('webrtc-multiplayer-connection-update') {
}
/**
* A helper for creating a new empty room.
*
* @category Internal
*/
export function createNewRoom(params = {}) {
return mergeDefinedProperties({
roomId: createUuidV4(),
roomName: '',
roomPassword: '',
}, params);
}
/**
* A controller that connects to the multiplayer api and establishes a WebRTC connection to the
* selected room, or, if the room does not exist yet, creates the room and becomes the host.
*
* Make sure, after constructing this class, to call
* {@link WebrtcMultiplayerController.initConnection} when you're ready to being the connection.
*
* @category Internal
*/
export class WebrtcMultiplayerController extends ListenTarget {
gameId;
multiplayerApi;
stunServerUrls;
multiplayerRoom;
clientId;
shouldAllowConnectionCheck;
hostClientId;
/**
* Connections between multiple WebRTC peers.
*
* A connection with the current client's id is the init connection.
*/
connections = {};
webSocket;
clientSecret = randomString(32);
isDestroyed = false;
constructor(gameId, multiplayerApi,
/**
* - 'stun.l.google.com:19302'
* - 'stun.stunprotocol.org'
* - 'stun.cloudflare.com:3478'
*/
stunServerUrls, multiplayerRoom,
/** The randomized client id for this controller and client. */
clientId = createUuidV4(),
/**
* This is fired when a WebRTC peer attempts to connect to the host client (this will only
* be fired if your client is the host). Return `true` to accept the connection. Return
* `false` to reject it.
*
* @default accept all connections
*/
shouldAllowConnectionCheck = () => true) {
super();
this.gameId = gameId;
this.multiplayerApi = multiplayerApi;
this.stunServerUrls = stunServerUrls;
this.multiplayerRoom = multiplayerRoom;
this.clientId = clientId;
this.shouldAllowConnectionCheck = shouldAllowConnectionCheck;
}
/**
* If this controller is the host, it'll behave differently:
*
* - Hosts hold WebRTC connections to all clients (non-hosts only hold a WebRTC connection to the
* host).
* - Hosts hold a WebSocket connection to the signal server so they can receive more clients at
* any time.
*/
isHost() {
return this.hostClientId === this.clientId;
}
/**
* Get all connected client ids.
*
* - For host clients, this will indicate how many member clients are connected to the host
* client, _not_ including the host itself.
* - For non-host clients, this will only list the host's client.
*
* For host clients, this does ont include the host client id whereas
* {@link WebrtcMultiplayerController.getAllClientIds} does.
*/
getConnectedClientIds() {
const connectedClientIds = filterMap(getObjectTypedValues(this.connections), (connection) => connection.clientId, (clientId, connection) => {
return connection.isConnected;
});
return connectedClientIds;
}
/**
* Get all room client ids.
*
* - For host clients, this will indicate how many clients are connected to the room, including
* the host client itself.
* - For non-host clients, this will only list the host's client.
*
* For host clients, this includes the host client id whereas
* {@link WebrtcMultiplayerController.getConnectedClientIds} does not.
*/
getAllClientIds() {
const connectedClientIds = this.getConnectedClientIds();
const allClients = [
...connectedClientIds,
...(this.isHost() ? [this.clientId] : []),
];
return allClients;
}
/** Indicates whether ths client is connected to a multiplayer room. */
isConnected() {
return this.isHost() || !!this.getConnectedClientIds().length;
}
/** Destroy this controller and clean everything up. */
destroy() {
makeWritable(this).isDestroyed = true;
Object.values(this.connections).forEach((connection) => connection.destroy());
void this.webSocket?.close();
this.connectionQueue.destroy();
super.destroy();
}
/**
* Send a message to the room participants.
*
* - If the current client is the room host, this message is sent to all other room clients.
* - If the current client is just a room member (not the host), the message is sent to the host.
*/
sendMessage(data) {
/**
* When this client is the host, this set of connections will be the all the member clients.
* When this client is a member client, there will only be one connection and it will be the
* host client.
*/
Object.values(this.connections).forEach((connection) => {
if (!connection.isConnected) {
return;
}
try {
connection.sendMessage(data);
}
catch (error) {
log.error(extractErrorMessage(error));
}
});
}
/** Send a message to just a single client. This is only allowed on a host client. */
sendToOnlyOneClient(clientId, data) {
if (!this.isHost()) {
log.error(new Error(`Cannot send to an individual client as not a host.`));
return;
}
const client = this.connections[clientId];
if (!client || !client.isConnected) {
log.error(new Error(`Cannot send to missing or disconnected client ('${clientId}')`));
return;
}
client.sendMessage(data);
}
/**
* Call this to connect to the multiplayer server.
*
* @returns Whether or not the connection was initialized (it won't be initialized, for example,
* if the WebRTC connections already exist).
*/
async initConnection() {
if (Object.values(this.connections).length) {
// connections already exist
return false;
}
const newConnection = this.createNewConnection(this.clientId);
const newOffer = await newConnection.createOffer(this.stunServerUrls);
const webSocket = await this.setupWebSocket();
const reply = await webSocket.sendAndWaitForReply({
message: {
messageId: createUuidV4(),
type: MultiplayerWebSocketMessageType.Offer,
clientId: this.clientId,
clientSecret: this.clientSecret,
data: newOffer,
...this.multiplayerRoom,
},
replyCheck(message) {
return message.type === MultiplayerWebSocketMessageType.OfferResult;
},
});
assert.strictEquals(reply.type, MultiplayerWebSocketMessageType.OfferResult);
/**
* `hostClientId` will be set by the already attached listener. We just need to wait until
* it does, because we need to know who the host is before calling `sendHostPing`.
*/
await waitUntil.isDefined(() => this.hostClientId);
this.sendHostPing();
return true;
}
sendHostPing() {
if (this.isHost() && this.webSocket) {
this.webSocket.send({
messageId: createUuidV4(),
type: MultiplayerWebSocketMessageType.HostPing,
clientCount: this.getAllClientIds().length,
clientId: this.clientId,
clientSecret: this.clientSecret,
...this.multiplayerRoom,
});
setTimeout(() => this.sendHostPing(), 1000);
}
}
connectionQueue = new PromiseQueue();
async setupWebSocket() {
if (this.webSocket &&
(this.webSocket.readyState === WebSocket.OPEN ||
this.webSocket.readyState === WebSocket.CONNECTING)) {
return this.webSocket;
}
const webSocket = await this.multiplayerApi.webSockets['/connect'].connect({
searchParams: {
gameId: [this.gameId],
},
listeners: {
message: async ({ message, webSocket }) => {
try {
if (message.type === MultiplayerWebSocketMessageType.Offer) {
if (!this.isHost()) {
throw new Error(`Non-host multiplayer client received a WebRTC offer.`);
}
const baseAnswerMessageProperties = {
messageId: message.messageId,
type: MultiplayerWebSocketMessageType.Answer,
roomId: message.roomId,
roomName: message.roomName,
clientId: message.clientId,
};
await this.connectionQueue.add(async () => {
if (!this.shouldAllowConnectionCheck({
connectingClientId: message.clientId,
controller: this,
})) {
log.warning('offer rejected');
webSocket.send({
...baseAnswerMessageProperties,
data: { rejected: true },
});
return;
}
log.faint('received offer');
const newConnection = this.createNewConnection(message.clientId);
const answer = await newConnection.createAnswer(message.data, this.stunServerUrls);
webSocket.send({
...baseAnswerMessageProperties,
data: answer,
});
await waitUntil.isTrue(() => newConnection.isConnected);
});
}
else if (message.type === MultiplayerWebSocketMessageType.Answer) {
if (this.isHost()) {
throw new Error(`Host multiplayer client received a WebRTC answer.`);
}
/** A connection with the current uuid is the init connection. */
const initConnection = this.connections[this.clientId];
if ('rejected' in message.data) {
log.warning('offer was rejected');
this.destroy();
}
else {
log.faint('received answer');
if (!initConnection) {
throw new Error('Cannot accept answer, no init connection found.');
}
await initConnection.acceptAnswer(message.data);
/**
* This client does not need a WebSocket connection anymore if it is
* not the host.
*/
await webSocket.close();
}
}
else if (message.type === MultiplayerWebSocketMessageType.OfferResult) {
if (message.hostClientId !== this.hostClientId) {
makeWritable(this).hostClientId = message.hostClientId;
if (this.isHost()) {
const initConnection = this.connections[this.clientId];
/**
* Remove the init connection since it won't be used now that
* this instance is the host.
*/
delete this.connections[this.clientId];
initConnection?.destroy();
}
this.dispatch(new WebrtcMultiplayerConnectionUpdateEvent({
detail: {
newHost: message.hostClientId,
},
}));
}
}
else if (message.type === MultiplayerWebSocketMessageType.Error) {
throw new Error(message.errorMessage);
}
else {
throw new Error(`Unexpected ${WebrtcMultiplayerController.name} WebSocket message type: ${message.type}`);
}
}
catch (error) {
log.error(ensureErrorAndPrependMessage(error, `WebSocket message failed: ${stringify(message)}`));
}
},
error: (error) => {
log.error(error);
},
close: () => {
this.webSocket = undefined;
},
},
});
this.webSocket = webSocket;
return webSocket;
}
createNewConnection(uuid) {
const newController = new WebrtcController(uuid);
this.connections[uuid] = newController;
newController.listen(WebrtcConnectEvent, (event) => {
const connectionEstablished = event.detail;
if (connectionEstablished) {
if (uuid !== this.clientId) {
this.dispatch(new WebrtcMultiplayerConnectionUpdateEvent({
detail: {
newMember: uuid,
},
}));
}
}
else {
newController.destroy();
delete this.connections[uuid];
if (this.isHost()) {
this.dispatch(new WebrtcMultiplayerConnectionUpdateEvent({
detail: {
lostMember: uuid,
},
}));
}
else {
this.dispatch(new WebrtcMultiplayerConnectionUpdateEvent({
detail: {
lostHost: uuid,
},
}));
/**
* If this member client has lost connection to its host, we've got to get it
* back!
*/
void this.initConnection();
}
}
});
newController.listen(WebrtcMessageEvent, (event) => {
const sourceUuid = uuid === this.clientId ? this.hostClientId : uuid;
if (sourceUuid) {
this.dispatch(new WebrtcMultiplayerMessageEvent(sourceUuid, event.detail));
}
});
return newController;
}
}