UNPKG

@game-vir/multiplayer

Version:

Client side definitions and types for @game-vir/multiplayer-server

321 lines (320 loc) 13.1 kB
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); } } }