import DataChannel from "./networking/DataChannel.js"; import Signaler from "./networking/Signaler.js"; import Util, {CallbackHandler,Value,ReadOnlyValue} from "../util/Util.js"; /** * @typedef {import("./SkribblServer.js").MessageToServer} MessageToServer * @typedef {import("./SkribblServer.js").MessageToClient} MessageToClient * @typedef {import("./SkribblServer.js").GameState} GameState * @typedef {import("./SkribblServer.js").GuessResponse} GuessResponse * @typedef {import("./SkribblServer.js").ClearOrder} ClearOrder * @typedef {import("./SkribblServer.js").DrawOrder} DrawOrder * @typedef {import("./SkribblServer.js").WordReveal} WordReveal */ /** * @typedef {import("../util/Util.js").DeepReadOnly<T>} DeepReadOnly * @template {unknown} T */ /** * Client/endpoint that manages a connection to a SkribblServer * @todo find a less redundant way to manage callbacks to avoid clutter */ export default class SkribblClient { /** * Constructs a new client for a given game ID or DataChannel. * @param {string} id * @param {DataChannel<MessageToServer,MessageToClient>} [dataChannel] dataChannel to connect to. If none is given, automatically tries to create one based on the given id. */ constructor(id,dataChannel){ this._id = id; /** @type {Value<DeepReadOnly<{name:string,points?:number}[]>>} */ this._players = new Value([]); this._isHost = new Value(false); /** @type {Value<DeepReadOnly<GameState>>} */ this._state = new Value(null); /** @type {CallbackHandler<[guessData:GuessResponse]>} */ this._onGuessCallbacks = new CallbackHandler(); /** @type {CallbackHandler<[order:ClearOrder|DrawOrder]>} */ this._onDrawCallbacks = new CallbackHandler(); /** @type {CallbackHandler<[message:WordReveal]>} */ this._onWordRevealCallbacks = new CallbackHandler(); this._settings = new Value(Util.deepFreeze({rounds:3,drawTime:180})); this._readyPromise = (async()=>{ /** @type {DataChannel<MessageToServer,MessageToClient>} */ this._dataChannel = dataChannel||DataChannel.from(await Signaler.join(id),JSON.stringify,JSON.parse); await this._dataChannel.waitUntilReady(); this._dataChannel.onMessage(message=>{ console.log("message from server:",message); if (message!=="yup"&&message!=="nope"){ if (message.action=="stateUpdate"){ let state = Util.deepFreeze(message.newState); this._state.value = state; this._hasGameStarted = state.hasGameStarted; this._players.value = state.players; this._isHost.value = state.host; }else if(message.action=="settingsUpdate"){ this._settings.value = Util.deepFreeze({rounds:message.rounds,drawTime:message.drawTime}); }else if(message.action=="guessedWord"){ let data = (({action,...data})=>data)(message); this._onGuessCallbacks.callAll(data); }else if(message.action=="clearCanvas"||message.action=="draw"||message.action=="erase"){ this._onDrawCallbacks.callAll(message); }else if(message.action=="revealWord"){ this._onWordRevealCallbacks.callAll(message); } } }); })(); } /** * Waits until the connection to the server is ready. */ async waitUntilReady(){ return this._readyPromise; } /** * Joins the game with the given name. * @param {string} name */ async join(name){ this._dataChannel.send({action:"join",name}); let message = await this._dataChannel.next(); if (message!=="yup"){ throw new Error(`Failed to join! Server response: "${message}"`); } } /** * Tells the server to start the game and then immediately returns, without waiting for a response. */ startGame(){ this._dataChannel.send({action:"startGame"}); } /** * Whether the game has started yet. */ get hasGameStarted(){ return this._hasGameStarted; } /** * If the game hasn't started yet, waits until the host of the game has clicked the start button in the lobby, otherwise immediately returns. */ async waitUntilGameStarted(){ if (!this._hasGameStarted){ while(true){ let message = await this._dataChannel.next(); if (message!=="yup"&&message!=="nope"&&message.action=="stateUpdate"&&message.newState.hasGameStarted){ break; } } } } /** * If a game has already started, waits until it ends again. */ async waitUntilGameEnded(){ if (this._hasGameStarted){ while(true){ let message = await this._dataChannel.next(); if (message!=="yup"&&message!=="nope"&&message.action=="stateUpdate"&&!message.newState.hasGameStarted){ break; } } } } /** * The ID of the current game. * @readonly */ get id(){ return this._id; } /** * The url of the current game. * @readonly */ get url(){ return document.location.host+document.location.pathname+"#"+this._id; } /** * Whether this client is currently "host" in the sense that they have permissions to alter settings and start the game. * Doesn't actually always correlate with who is hosting the game. * @readonly */ get isHost(){ return this._isHost.readOnly; } /** * The settings of the current game. * @readonly */ get settings(){ return this._settings.readOnly; } /** * Asks the server to change the given settings to the given values. * @param {{rounds?:number,drawTime?:number}} settings */ setSettings({rounds=this._settings.value.rounds,drawTime=this._settings.value.drawTime}){ this._dataChannel.send({action:"changeSettings",rounds,drawTime}); } /** * A readonly version of the current state that can be monitored for changes. * @readonly */ get state(){ return this._state.readOnly; } /** * All currently connected players. * @readonly */ get players(){ return this._players.readOnly; } /** * Sends the index of the choosen word to the server. * * Also temporary, should be replaced by something cleaner later on. * @param {number} index */ chooseWord(index){ this._dataChannel.send({action:"chooseWord",word:index}); } /** * Sends a guess to the server and then immediately returns, without waiting for a response. * @param {string} word */ sendGuess(word){ this._dataChannel.send({action:"guess",word}); } /** * Registers a callback to be called whenever someone guesses a word (not necessarily correctly - just whenever anyone writes anything in the chat, basically). * @param {(guessData:GuessResponse)=>void} callback * @param {object} [options] * @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true. */ onGuess(callback,{onlyWhen=null}={}){ this._onGuessCallbacks.addCallback(callback,{onlyWhen}); } /** * Sends the given drawing order to the server and the immediately returns, without waiting for a response. * @param {ClearOrder|DrawOrder} order */ draw(order){ this._dataChannel.send(order); } /** * Registers a callback to be called whenever someone draws something on the canvas. * @param {(order:ClearOrder|DrawOrder)=>void} callback * @param {object} [options] * @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true. */ onDraw(callback,{onlyWhen=null}={}){ this._onDrawCallbacks.addCallback(callback,{onlyWhen}); } /** * Registers a callback to be called whenever the server reveals which word has just been drawn and how many points were awarded to each player. * @param {(message:WordReveal)=>void} callback * @param {object} [options] * @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true. */ onWordReveal(callback,{onlyWhen=null}={}){ this._onWordRevealCallbacks.addCallback(callback,{onlyWhen}); } }