@game-vir/multiplayer
Version:
Client side definitions and types for @game-vir/multiplayer-server
321 lines (320 loc) • 13.1 kB
JavaScript
import { ensureError, makeWritable, } from '@augment-vir/common';
import { convertDuration } from 'date-vir';
import { defineTypedCustomEvent, ListenTarget } from 'typed-event-target';
import { WebrtcMultiplayerConnectionUpdateEvent, } from '../webrtc/webrtc-multiplayer-controller.js';
import { RoomRejectionError } from './errors.js';
import { LockStepFrameEvent, LockStepGameStateController } from './lock-step-controller.js';
import { createMultiplayerApi } from './multiplayer-api.js';
/**
* Connection state for {@link MultiplayerController}.
*
* @category Internal
*/
export var MultiplayerConnectionState;
(function (MultiplayerConnectionState) {
MultiplayerConnectionState["Connecting"] = "connecting";
MultiplayerConnectionState["Connected"] = "connected";
/** The connection has not been started or has been gracefully terminated. */
MultiplayerConnectionState["Disconnected"] = "disconnected";
})(MultiplayerConnectionState || (MultiplayerConnectionState = {}));
/**
* Empty or totally disconnected state for {@link ServiceAndRoomConnectionState}.
*
* @category Internal
*/
export const emptyServiceAndRoomConnectionState = {
room: MultiplayerConnectionState.Disconnected,
service: MultiplayerConnectionState.Disconnected,
};
/**
* This is fired whenever a new frame is received from the host client.
*
* @category Events
*/
export class ControllerFrameEvent extends defineTypedCustomEvent()('controller-frame') {
}
/**
* This is called whenever the room list updates, even if there were no changes to the room list.
* Note that room list updates are paused while the controller is connected to an actual room.
*
* @category Events
*/
export class ControllerRoomListEvent extends defineTypedCustomEvent()('controller-room-list') {
}
/**
* This is fired in the following situations:
*
* - A new host for the room was selected
* - The room host was lost
* - A new room client was added (only fired on the host client)
* - A room client was lost (only fired on the host client)
*
* @category Events
*/
export class ControllerClientEvent extends defineTypedCustomEvent()('controller-client') {
}
/**
* Fires when the controller's connection state is updated.
*
* @category Events
*/
export class ControllerConnectionEvent extends defineTypedCustomEvent()('controller-connection') {
}
/**
* An all-in-one controller for singleplayer or lock-step multiplayer game state. Singleplayer mode
* requires no servers. Multiplayer mode requires a backend service running the
* {@link MultiplayerApi}.
*
* @category Main
*/
export class MultiplayerController extends ListenTarget {
params;
/** All events emitted by this controller. */
static events = {
ControllerFrameEvent,
ControllerRoomListEvent,
ControllerClientEvent,
ControllerConnectionEvent,
};
/** All events emitted by this controller. */
events = MultiplayerController.events;
static knownErrors = {
RoomRejectionError,
};
knownErrors = MultiplayerController.knownErrors;
/**
* Set to `false` to disable room updates, even when still not connected to a room in
* multiplayer mode.
*/
enableRoomUpdates = true;
/** Currently joined room id. If a room has not been joined yet, this will be empty. */
roomId;
/** The current connection state of the controller's connection to a backend service. */
serviceConnectionState = MultiplayerConnectionState.Disconnected;
/** The current connection state of the controller's connection to a multiplayer room. */
roomConnectionState = MultiplayerConnectionState.Disconnected;
/**
* Current WebRTC lock step connection with the room host (when not the host) or all room
* participants (when the host). This will only be initialized after calling
* {@link MultiplayerController.joinOrCreateRoom}.
*/
currentConnection;
/**
* Rooms that have rejected the current player, so the player doesn't keep trying to connect to
* them.
*/
rejectedRoomIds = new Set();
/** The current MultiplayerApi. This will be `undefined` if playing in single player. */
multiplayerApi;
/**
* Used to keep track of the room update interval. This will be set when the controller is
* constructed in multiplayer mode or when a room is left. This will be cleared when a room is
* joined or if the controller is destroyed.
*/
roomUpdateIntervalId;
/** This is populated if `.startMultiplayer` is called. */
multiplayerParams;
/**
* Get the current client's WebRTC client id. This will return `undefined` if there is no
* current connection.
*/
getClientId() {
return this.currentConnection?.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 MultiplayerController.getAllClientIds} does.
*/
getConnectedClientIds() {
return this.currentConnection?.getConnectedClientIds() || [];
}
/**
* 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 MultiplayerController.getConnectedClientIds} does not.
*/
getAllClientIds() {
return this.currentConnection?.getAllClientIds() || [];
}
constructor(params) {
super();
this.params = params;
}
/**
* Start multiplayer mode. This initializes {@link MultiplayerController.multiplayerApi} and
* {@link MultiplayerController.roomUpdateIntervalId}.
*/
startMultiplayer(params) {
if (this.currentConnection) {
throw new Error(`Cannot start multiplayer mode again when a multiplayer connection already present.`);
}
this.multiplayerParams = params;
this.updateConnectionState({ service: MultiplayerConnectionState.Connecting });
this.multiplayerApi = createMultiplayerApi({
portScanOptions: params.portScanOptions,
backendOrigin: params.backendOrigin,
})
.then(async (api) => {
const output = await api.endpoints['/health'].fetch();
if (!output.ok) {
throw new Error(`Failed to find multiplayer service at ${api.serviceOrigin}`);
}
this.updateConnectionState({ service: MultiplayerConnectionState.Connected });
return api;
})
.catch((error) => {
this.updateConnectionState({ service: ensureError(error) });
throw error;
});
this.startRoomInterval();
}
/** Start singleplayer mode. */
startSingleplayer() {
if (this.currentConnection) {
throw new Error(`Cannot start singleplayer with a connection already present.`);
}
this.multiplayerParams = undefined;
this.multiplayerApi = undefined;
this.updateConnectionState({ service: MultiplayerConnectionState.Connecting });
this.currentConnection = new LockStepGameStateController(this.params.frameDuration, () => false);
this.currentConnection.listen(LockStepFrameEvent, (event) => {
this.dispatch(new ControllerFrameEvent({ detail: event.detail }));
});
this.currentConnection.startSingleplayer();
globalThis.clearInterval(this.roomUpdateIntervalId);
this.updateConnectionState({ service: MultiplayerConnectionState.Connected });
}
/**
* Manually run the next frame.
*
* @throws Error if `frameDuration` has been set.
*/
runFrame(actions) {
this.currentConnection?.runFrame(actions);
}
/** The current FPS of the data flow. */
getFps() {
return this.currentConnection?.currentFps || 0;
}
/** Fire an action. This will be sent to all clients in the room so they can process it. */
act(actions) {
if (!this.currentConnection || !this.currentConnection.isConnected()) {
throw new Error(`Cannot perform action: not connected to a room.`);
}
this.currentConnection.act(Array.isArray(actions) ? actions : [actions]);
}
/** Detects if this controller is the room host or not. */
isHost() {
return this.currentConnection?.isHost() || false;
}
/** Cleanup everything. */
destroy() {
super.destroy();
this.updateConnectionState({
room: MultiplayerConnectionState.Disconnected,
service: MultiplayerConnectionState.Disconnected,
});
this.currentConnection?.destroy();
globalThis.clearInterval(this.roomUpdateIntervalId);
}
/**
* Join or create a room.
*
* @throws `Error` if this controller is already connected to a room.
*/
async joinOrCreateRoom(room) {
if (this.currentConnection) {
throw new Error(`Cannot join room: connection already established.`);
}
else if (!this.multiplayerApi || !this.multiplayerParams) {
throw new Error('Cannot join room. Please construct this controller in multiplayer mode to join rooms.');
}
else if (this.rejectedRoomIds.has(room.roomId)) {
throw new RoomRejectionError(room);
}
this.updateConnectionState({ room: MultiplayerConnectionState.Connecting });
const acceptConnectionListener = this.params.acceptConnection;
this.currentConnection = new LockStepGameStateController(this.params.frameDuration || { milliseconds: 10 }, acceptConnectionListener
? (data) => {
return acceptConnectionListener(data.connectingClientId, this);
}
: undefined);
this.currentConnection.listen(LockStepFrameEvent, (event) => {
this.dispatch(new ControllerFrameEvent({ detail: event.detail }));
});
this.currentConnection.listen(WebrtcMultiplayerConnectionUpdateEvent, (event) => {
this.dispatch(new ControllerClientEvent({ detail: event.detail }));
});
if (await this.currentConnection.multiplayerConnect(this.params.gameId, await this.multiplayerApi, this.multiplayerParams.stunServerUrls || [], room)) {
makeWritable(this).roomId = room.roomId;
globalThis.clearInterval(this.roomUpdateIntervalId);
this.updateConnectionState({ room: MultiplayerConnectionState.Connected });
}
else {
this.rejectedRoomIds.add(room.roomId);
this.currentConnection = undefined;
const error = new RoomRejectionError(room);
this.updateConnectionState({ room: error });
throw error;
}
}
/** Leave the current room or single player connection. */
leaveRoom() {
if (!this.currentConnection) {
return;
}
makeWritable(this).roomId = undefined;
this.currentConnection.destroy();
this.currentConnection = undefined;
this.startRoomInterval();
this.updateConnectionState({ room: MultiplayerConnectionState.Disconnected });
}
/** Set the current connection state and fire listeners. */
updateConnectionState(state) {
if (state.service) {
makeWritable(this).serviceConnectionState = state.service;
}
if (state.room) {
makeWritable(this).roomConnectionState = state.room;
}
this.dispatch(new ControllerConnectionEvent({
detail: {
room: this.roomConnectionState,
service: this.serviceConnectionState,
},
}));
}
/** Starts polling the multiplayer server for room updates and fires listeners. */
startRoomInterval() {
if (this.multiplayerApi) {
const roomUpdateMs = this.multiplayerParams?.roomUpdateInterval
? convertDuration(this.multiplayerParams.roomUpdateInterval, { milliseconds: true })
.milliseconds
: 10_000;
this.roomUpdateIntervalId = globalThis.setInterval(async () => {
if (this.currentConnection || !this.multiplayerApi || !this.enableRoomUpdates) {
return;
}
const output = await (await this.multiplayerApi).endpoints['/rooms'].fetch({
searchParams: {
gameId: [this.params.gameId],
},
});
if (output.ok) {
this.dispatch(new ControllerRoomListEvent({ detail: output.data }));
}
}, roomUpdateMs);
}
}
}