import DataChannel from "./networking/DataChannel.js"; import Signaler from "./networking/Signaler.js"; import SkribblWords from "./SkribblWords.js"; /** * Format of the messages send to the server by the clients. * @typedef {{action:"join",name:string}|{action:"startGame"}|{action:"changeSettings",rounds:number,drawTime:number}|{action:"chooseWord",word:number}|{action:"guess",word:string}|ClearOrder|DrawOrder} MessageToServer */ /** * Format of the messages send to the clients by the server. * @typedef {"yup"|"nope"|{action:"stateUpdate",newState:GameState}|{action:"settingsUpdate",rounds:number,drawTime:number}|({action:"guessedWord"}&GuessResponse)|ClearOrder|DrawOrder} MessageToClient */ /** * Format in which the game state is send to the clients by the server. * @typedef {LobbyGameState|ActiveGameState} GameState */ /** * Format in which the game state is send to the clients by the server when the game hasn't started yet. * @typedef {object} LobbyGameState * @property {false} hasGameStarted whether or not the game has started already * @property {boolean} host whether this specific client has host permissions * @property {number} playerIndex the index of this specific player in the player list * @property {{name:string}[]} players list of all currently connected players */ /** * Format in which the game state is send to the clients by the server when the game has already started. * @typedef {object} ActiveGameState * @property {true} hasGameStarted whether or not the game has started already * @property {boolean} host whether this specific client has host permissions * @property {number} playerIndex the index of this specific player in the player list * @property {{name:string,points:number}[]} players list of all currently connected players * @property {number} round current round, ranging from `1` to `n` where `n` is the value of the `.rounds`-property * @property {number} rounds max number of rounds * @property {number} drawingPlayer index of the player that is currently drawing or choosing a word in the player list * @property {null|string|string[]} word word that is currently being drawn (with all letters replaced with underscores if it's someone elses turn drawing), or a list of words to draw next to choose from, or `null` if someone else is currently choosing a word */ /** * Format in which the server notifies everyone whenever someone has tried to guess the word. * @typedef {{player:number,correct:true,word?:string}|{player:number,correct:false,word:string,close?:boolean}} GuessResponse */ /** * Order to clear the canvas. * @typedef {{action:"clearCanvas"}} ClearOrder */ /** * Format in which orders to draw stuff on the canvas are passed between the clients. * @typedef {object} DrawOrder * @property {"draw"|"erase"} action * @property {string} color hex color value used when drawing, ignored when erasing * @property {number} radius radius in pixels * @property {{x:number,y:number}[]} points array of points between which lines should be drawn */ /** * A local server. Handles all the important game logic, and communicates with clients via DataChannels. */ export default class SkribblServer { /** * Starts a new SkribblServer. */ constructor(){ this._readyPromise = (async()=>{ /** @type {{name:string,dataChannel:DataChannel<MessageToClient,MessageToServer>,points:number}[]} */ this._clients = []; this._hasGameStarted = false; /** current round, starts counting at 1, only 0 when the game hasn't started yet */ this._round = 0; this._rounds = 3; this._drawTime = 180; /** index of the currently drawing player in the player list */ this._drawingPlayer = 0; /** * word that is currently being drawn, or list of words a word is currently getting choosen from * @type {string|string[]} */ this._word = null; this._id = await Signaler.host(dataChannel=>{ this.connect(DataChannel.from(dataChannel,JSON.stringify,JSON.parse)); }); /** @type {[DataChannel<MessageToServer,MessageToClient>,DataChannel<MessageToClient,MessageToServer>]} */ let [endpointA,endpointB] = DataChannel.createPair(); this._dataChannel = endpointA; this.connect(endpointB); })(); } /** * Waits until the server is ready. */ async waitUntilReady(){ return this._readyPromise; } /** * A dataChannel talking to this server like any other client. * @readonly */ get dataChannel(){ return this._dataChannel; } /** * The ID others can use to connect to this server. * @readonly */ get id(){ return this._id; } /** * Returns the full url others can use to connect to this server. * @readonly */ get url(){ return document.location.host+document.location.pathname+"#"+this._id; } /** * Adds an incoming connection as a client. * @param {DataChannel<MessageToClient,MessageToServer>} dataChannel */ connect(dataChannel){ (async()=>{ let message = await dataChannel.next(); console.log("message: ",message); if (message.action==="join"){ let name = message.name; if (name.length<30&&name.length>=1){ dataChannel.send("yup"); this._clients.push({name,dataChannel,points:0}); this._sendStateUpdate(); if (!this._hasGameStarted){ dataChannel.send({action:"settingsUpdate",rounds:this._rounds,drawTime:this._drawTime}); } dataChannel.onMessage(message=>{ let playerIndex = this._clients.map(({dataChannel})=>dataChannel).indexOf(dataChannel); let isHost = (playerIndex==0); if (message.action=="startGame"){ if (isHost&&!this._hasGameStarted){ this._startGame(); } }else if(message.action=="changeSettings"){ if (isHost&&!this._hasGameStarted){ this._rounds = message.rounds; this._drawTime = message.drawTime; this._sendToAll({action:"settingsUpdate",rounds:this._rounds,drawTime:this._drawTime}); } }else if (message.action=="guess"){ this._handleGuess(playerIndex,message.word); }else if (message.action=="clearCanvas"||message.action=="draw"||message.action=="erase"){ // sends the drawing order to all connected clients iff the player is currently drawing and has already chosen a word if (this._hasGameStarted&&this._drawingPlayer==playerIndex&&typeof this._word=="string"){ this._sendToAll(message); } } }); }else{ dataChannel.send("nope"); dataChannel.close(); } }else{ dataChannel.send("nope"); dataChannel.close(); } })(); } /** * Sends a message to all currently connected clients. * @param {MessageToClient} message */ _sendToAll(message){ this._clients.forEach(({dataChannel})=>{ dataChannel.send(message); }) } /** * Starts the game if it hasn't already started. */ _startGame(){ if (this._hasGameStarted){ console.warn("Tried to start a game that has already started."); }else{ this._hasGameStarted = true; (async()=>{ for (this._round=1;this._round<=this._rounds;this._round++){ for (this._drawingPlayer=0;this._drawingPlayer<this._clients.length;this._drawingPlayer++){ let words = await Promise.all([SkribblWords.get(),SkribblWords.get(),SkribblWords.get()]); this._word = words.map(({word})=>word); this._sendStateUpdate(); let wordIndex = await new Promise(async resolve=>{ let cancelled = false; setTimeout(()=>{ cancelled = true; resolve(Math.floor(Math.random()*3)); },10000); let dataChannel = this._clients[this._drawingPlayer].dataChannel; while (!cancelled){ let message = await dataChannel.next(); if (message.action=="chooseWord"){ resolve(message.word); } } }); this._word = words[wordIndex].word; this._sendToAll({action:"clearCanvas"}); this._sendStateUpdate(); await new Promise(resolve=>setTimeout(resolve,15000)); } } this._hasGameStarted = false; this._sendStateUpdate(); })(); } } /** * Sends a state update to all connected clients (that is, clients that have already joined the game). */ _sendStateUpdate(){ console.log("sending state update! players:",this._clients); if (this._hasGameStarted){ let players = this._clients.map(({name,points})=>({name,points})); this._clients.forEach(({dataChannel},index)=>{ let host = index==0; let word = this._drawingPlayer==index?this._word:(typeof this._word=="string"?this._word.replace(/[^ -]/g,"_"):null); /** @type {ActiveGameState} */ let state = {players,playerIndex:index,host,hasGameStarted:true,rounds:this._rounds,round:this._round,drawingPlayer:this._drawingPlayer,word}; dataChannel.send({action:"stateUpdate",newState:state}); }); }else{ let players = this._clients.map(({name})=>({name})); this._clients.forEach(({dataChannel},index)=>{ dataChannel.send({action:"stateUpdate",newState:{players,playerIndex:index,host:index==0,hasGameStarted:false}}); }); } } /** * Handles a guess send from a given dataChannel. * @param {number} playerIndex * @param {string} word * @todo actually do something when the guess is correct instead of just telling everyone and then forgetting */ _handleGuess(playerIndex,word){ if (this._hasGameStarted){ if (word==this._word){ this._clients.forEach((client,index)=>{ if (index==playerIndex){ client.dataChannel.send({action:"guessedWord",player:playerIndex,correct:true,word}); }else{ client.dataChannel.send({action:"guessedWord",player:playerIndex,correct:true}); } }); }else{ this._clients.forEach((client,index)=>{ let close = typeof this._word=="string"?SkribblServer._isClose(word,this._word):false; if (index==playerIndex){ client.dataChannel.send({action:"guessedWord",player:playerIndex,correct:false,word,close}); }else{ client.dataChannel.send({action:"guessedWord",player:playerIndex,correct:false,word}); } }); } } } /** * Checks whether a given guess is close to the given word. * @param {string} guess * @param {string} word * @todo implement */ static _isClose(guess,word){ return false; } }