UNPKG

@game-vir/multiplayer

Version:

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

260 lines (259 loc) 9.27 kB
import { waitUntil } from '@augment-vir/assert'; import { createUuidV4, makeWritable } from '@augment-vir/common'; import { convertDuration } from 'date-vir'; import { defineTypedCustomEvent, ListenTarget } from 'typed-event-target'; import { WebrtcMultiplayerConnectionUpdateEvent, WebrtcMultiplayerController, WebrtcMultiplayerMessageEvent, } from '../webrtc/webrtc-multiplayer-controller.js'; /** * Message type for {@link LockStepMessage}. * * @category Internal */ export var LockStepMessageType; (function (LockStepMessageType) { LockStepMessageType["Actions"] = "actions"; LockStepMessageType["Frame"] = "frame"; })(LockStepMessageType || (LockStepMessageType = {})); /** * An event that is omitted from {@link LockStepGameStateController} when a frame is finalized. * * @category Internal */ export class LockStepFrameEvent extends defineTypedCustomEvent()('lock-step-multiplayer-frame') { } /** * A wrapper for {@link WebrtcMultiplayerController} that ensures messages are sent and arrive in a * lock-step fashion. Also supports singleplayer mode. * * @category Internal */ export class LockStepGameStateController extends ListenTarget { allowMultiplayerConnectionCheck; webrtcController; /** The current client id. */ clientId; /** The current data flow FPS. */ currentFps = 0; /** This is only used if the current controller is the host. */ clientsResponded = {}; frameActions = []; timeoutId; frameTickReady = true; frameMs; lastFpsCalculation = { timestamp: 0, frameCount: 0, }; singleplayer = false; constructor(frameDuration, allowMultiplayerConnectionCheck = () => true) { super(); this.allowMultiplayerConnectionCheck = allowMultiplayerConnectionCheck; this.clientId = createUuidV4(); if (frameDuration) { this.frameMs = convertDuration(frameDuration, { milliseconds: true }).milliseconds; } } /** * 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 LockStepGameStateController.getAllClientIds} does. */ getConnectedClientIds() { return this.webrtcController?.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 LockStepGameStateController.getConnectedClientIds} does not. */ getAllClientIds() { return this.webrtcController?.getAllClientIds() || []; } /** Checks if the current controller is the room host. */ isHost() { return this.singleplayer || this.webrtcController?.isHost(); } /** Checks if the current controller is connected to the room. */ isConnected() { return this.singleplayer || this.webrtcController?.isConnected(); } /** Perform an action for the current client. */ act(actions) { this.frameActions.push(...actions); } /** * Manually run the next frame. * * @throws Error if `frameDuration` has been set. */ runFrame(actions) { if (this.frameMs != undefined) { throw new Error('Cannot manually run frame when frameDuration has been set.'); } if (actions) { this.act(actions); } if (this.isHost()) { this.frameTickReady = true; this.maybeFinishFrame(); } } /** Cleanup everything. */ destroy() { globalThis.clearInterval(this.timeoutId); this.webrtcController?.destroy(); this.webrtcController = undefined; super.destroy(); } /** * Startup the controller in singleplayer mode. * * @see {@link LockStepGameStateController.multiplayerConnect} for starting the controller in multiplayer mode. */ startSingleplayer() { this.singleplayer = true; this.finishFrame(); } /** * Startup the controller in multiplayer mode and connect to a room. * * @returns Whether the connection was a successful or not. * @see {@link LockStepGameStateController.startSingleplayer} for starting the controller in singleplayer mode. */ async multiplayerConnect(gameId, multiplayerApi, /** * - 'stun.l.google.com:19302' * - 'stun.stunprotocol.org' * - 'stun.cloudflare.com:3478' */ stunServerUrls, multiplayerRoom) { const webrtcController = new WebrtcMultiplayerController(gameId, multiplayerApi, stunServerUrls, multiplayerRoom, this.clientId, (data) => { return this.allowMultiplayerConnectionCheck({ ...data, controller: this, }); }); this.webrtcController = webrtcController; this.webrtcController.listen(WebrtcMultiplayerMessageEvent, (event) => { this.handleReceivedMessage(event); }); this.webrtcController.listen(WebrtcMultiplayerConnectionUpdateEvent, (event) => { this.handleConnection(event); }); await this.webrtcController.initConnection(); const connectionResult = await waitUntil.isDefined(() => { const connected = webrtcController.isConnected(); const destroyed = webrtcController.isDestroyed; if (!connected && !destroyed) { return undefined; } else { return { connected, destroyed, }; } }); if (connectionResult.destroyed) { this.destroy(); return false; } else { this.finishFrame(); return true; } } handleConnection(event) { if (this.webrtcController && this.isHost() && 'newMember' in event.detail) { this.webrtcController.sendToOnlyOneClient(event.detail.newMember, { type: LockStepMessageType.Frame, actions: [], }); } this.dispatch(event); } calculateFps() { const now = Date.now(); const diff = Date.now() - this.lastFpsCalculation.timestamp; if (diff > 1000) { makeWritable(this).currentFps = this.lastFpsCalculation.frameCount / (diff / 1000); this.lastFpsCalculation = { frameCount: 0, timestamp: now, }; } else { this.lastFpsCalculation.frameCount++; } } handleReceivedMessage({ sourceClientId, detail: message, }) { if (!this.webrtcController) { return; } if (this.isHost() && message.type === LockStepMessageType.Actions) { this.clientsResponded[sourceClientId] = true; this.frameActions.push(...message.actions); this.maybeFinishFrame(); } else if (!this.isHost() && message.type === LockStepMessageType.Frame) { const currentFrameActions = this.frameActions; /** * This must be cleared before {@link LockStepFrameEvent} is emitted in case that event * triggers more actions. */ this.frameActions = []; this.calculateFps(); this.webrtcController.sendMessage({ actions: currentFrameActions, sourceClientId: this.clientId, type: LockStepMessageType.Actions, }); this.dispatch(new LockStepFrameEvent({ detail: message.actions })); } } finishFrame() { const currentFrameActions = this.frameActions; /** * This must be cleared before {@link LockStepFrameEvent} is emitted in case that event * triggers more actions. */ this.frameActions = []; this.webrtcController?.sendMessage({ type: LockStepMessageType.Frame, actions: currentFrameActions, }); this.dispatch(new LockStepFrameEvent({ detail: currentFrameActions })); this.frameTickReady = false; this.calculateFps(); if (this.frameMs) { this.timeoutId = globalThis.setTimeout(() => { this.frameTickReady = true; this.maybeFinishFrame(); }, this.frameMs); } } maybeFinishFrame() { if (!this.isHost()) { return; } const clientsReady = this.singleplayer || this.webrtcController?.getConnectedClientIds().every((clientId) => { return this.clientsResponded[clientId]; }); if (!this.frameTickReady || /** Still waiting on clients */ !clientsReady) { return; } this.finishFrame(); } }