import DataChannel from "./networking/DataChannel.js"; import Signaler from "./networking/Signaler.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 */ /** * Client/endpoint that manages a connection to a SkribblServer */ export default class SkribblClient { /** * Constructs a new client for a given game ID or DataChannel. * @param {string|DataChannel<MessageToServer,MessageToClient>} idOrDataChannel */ constructor(idOrDataChannel){ /** @type {{name:string,points?:number}[]} */ this._players = []; /** @type {((host:boolean)=>void)[]} */ this._onHostChangeCallbacks = []; /** @type {((players:{name:string,points?:number}[])=>void)[]} */ this._onPlayersListChangeCallbacks = []; /** @type {((state:GameState)=>void)[]} */ this._onStateChangeCallbacks = []; this._rounds = 3; this._drawTime = 180; /** @type {((guessData:GuessResponse)=>void)[]} */ this._onGuessCallbacks = []; /** @type {((settings:{rounds:number,drawTime:number})=>void)[]} */ this._onSettingsChangeCallbacks = []; this._readyPromise = (async()=>{ /** @type {DataChannel<MessageToServer,MessageToClient>} */ this._dataChannel = idOrDataChannel instanceof DataChannel?idOrDataChannel:DataChannel.from(await Signaler.join(idOrDataChannel),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 hostChanged = this._host!==message.newState.host; let playersChanged = JSON.stringify(this._players)!==JSON.stringify(message.newState.players); this._state = message.newState; this._hasGameStarted = message.newState.hasGameStarted; this._host = message.newState.host; this._players = message.newState.players.slice(); if (hostChanged){ this._onHostChangeCallbacks.slice().forEach(callback=>callback(this._host)); } if (playersChanged){ this._onPlayersListChangeCallbacks.forEach(callback=>callback(this._players)); } this._onStateChangeCallbacks.forEach(callback=>callback(this._state)); }else if(message.action=="settingsUpdate"){ this._rounds = message.rounds; this._drawTime = message.drawTime; this._onSettingsChangeCallbacks.slice().forEach(callback=>callback(this.settings)); }else if(message.action=="guessedWord"){ let data = (({action,...data})=>data)(message); this._onGuessCallbacks.slice().forEach(callback=>callback(data)); } } }); })(); } /** * 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; } } } } /** * 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. */ get host(){ return this._host; } /** * Registers a callback to be called whenever the host of the game changes, and once when it is registered. * @param {(host:boolean)=>void} callback */ onHostChange(callback){ this._onHostChangeCallbacks.push(callback); callback(this._host); } /** * Removes a callback registered using `onHostChange(callback)`. * @param {(host:boolean)=>void} callback */ removeOnHostChangeCallback(callback){ let index = this._onHostChangeCallbacks.indexOf(callback); if (index!==-1){ this._onHostChangeCallbacks.splice(index,1); } } /** * The settings of the current game. * * This property will always refer to the same object - when assigning a new one, it's relevant properties will simply be copied to the old one. * Changes to those properties will be send to the server, and be reflected in their values only once the server has confirmed them. * @readonly */ get settings(){ if (!this._settings){ let _this = this; this._settings = { get rounds(){ return _this._rounds; }, set rounds(rounds){ _this._dataChannel.send({action:"changeSettings",rounds,drawTime:_this._drawTime}); }, get drawTime(){ return _this._drawTime; }, set drawTime(drawTime){ _this._dataChannel.send({action:"changeSettings",rounds:_this._rounds,drawTime}); } }; } return this._settings; } set settings(settings){ this.settings.rounds = settings.rounds; this.settings.drawTime = settings.drawTime; } /** * Registers a callback to be called whenever the settings of the game change, and once when it is registered. * @param {(settings:{rounds:number,drawTime:number})=>void} callback */ onSettingsChange(callback){ this._onSettingsChangeCallbacks.push(callback); callback(this.settings); } /** * Removes a callback registered using `onSettingsChange(callback)`. * @param {(settings:{rounds:number,drawTime:number})=>void} callback */ removeOnSettingsChangeCallback(callback){ let index = this._onSettingsChangeCallbacks.indexOf(callback); if (index!==-1){ this._onSettingsChangeCallbacks.splice(index,1); } } /** * Registers a callback to be called whenever the state of the game changes, and once when it is registered. * * Should eventually get replaced by more specific methods, but works fine for now. * @param {(state:GameState)=>void} callback */ onStateChange(callback){ this._onStateChangeCallbacks.push(callback); callback(this._state); } /** * Removes a callback registered using `onStateChange(callback)`. * @param {(state:GameState)=>void} callback */ removeOnStateChangeCallback(callback){ let index = this._onStateChangeCallbacks.indexOf(callback); if (index!==-1){ this._onStateChangeCallbacks.splice(index,1); } } /** * 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 whenever someone guesses a word (not necessarily correctly - just whenever anyone writes anything in the chat, basically). * @param {(guessData:GuessResponse)=>void} callback */ onGuess(callback){ this._onGuessCallbacks.push(callback); } /** * Removes a callback registered using `onGuess(callback)`. * @param {(guessData:GuessResponse)=>void} callback */ removeOnGuessCallback(callback){ let index = this._onGuessCallbacks.indexOf(callback); if (index!==-1){ this._onGuessCallbacks.splice(index,1); } } /** * All currently connected players. */ get players(){ return this._players; } /** * Registers a callback whenever the list of players in the game changes, and once when it is registered. * @param {(players:{name:string,points?:number}[])=>void} callback */ onPlayersListChange(callback){ this._onPlayersListChangeCallbacks.push(callback); callback(this._players); } }