import { SkribblWord } from "../docs/CustomElements.js"; import Util, {ConditionFulfilledPromise} from "../util/Util.js"; 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|WordReveal} 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,guessedWord:boolean}[]} 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 */ /** * @typedef {object} WordReveal * @property {"revealWord"} action * @property {string} word the word that was just drawn * @property {string} description description of the word that was just drawn * @property {{[key:string]:string}} macros macros used in the description * @property {number[]} points how many points were awarded to each player */ /** * 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,guessedWord:boolean,guessedIndex: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,guessedWord:false,guessedIndex: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; this._clients.forEach(client=>{ client.points = 0; client.guessedWord = false; client.guessedIndex = 0; }); (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(); /** @type {number} */ 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(); this._allGuessedPromise = new ConditionFulfilledPromise(()=>{ return this._clients.every((client,index)=>client.guessedWord||index==this._drawingPlayer); }); let timeOverPromise = Util.wait(this._drawTime); await Promise.race([this._allGuessedPromise,timeOverPromise]); let points = this._awardPoints(); this._sendStateUpdate(); this._sendToAll({action:"revealWord",word:this._word,description:words[wordIndex].description,macros:await SkribblWords.macros,points}); await Util.wait(8); } } 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,guessedWord})=>({name,points,guessedWord})); 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 */ _handleGuess(playerIndex,word){ let player = this._clients[playerIndex]; if (this._hasGameStarted){ if (player.guessedWord||playerIndex==this._drawingPlayer){ // TODO let people who already know the word send ghost messages to others who already know it too }else if (typeof this._word=="string"&&SkribblServer._isCorrect(word,this._word)){ player.guessedWord = true; player.guessedIndex = Math.max(...this._clients.map(client=>client.guessedIndex))+1; 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}); } }); this._allGuessedPromise.checkCondition(); }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}); } }); } } } /** * Awards points to each player based on if and when they guessed the word, resets the corresponding properties and then returns the list of points each player got. * @todo find better formula for the number of points each player should get */ _awardPoints(){ let guessingPlayers = this._clients.length-1; let successfullPlayers = Math.max(...this._clients.map(client=>client.guessedIndex)); let points = this._clients.map((client,index)=>{ let points; if (this._drawingPlayer==index){ points = successfullPlayers*50; }else if(client.guessedIndex!=0){ points = ((guessingPlayers-client.guessedIndex+1)**2)/guessingPlayers*100; }else{ points = 0; } points = Math.round(points/5)*5; client.points += points; client.guessedWord = false; client.guessedIndex = 0; return points; }); return points; } /** * Checks whether a given guess is close to the given word. A guess counts as close, if one letter is wrong, * two letters are swapped or the guess has one letter too much or too little. * @param {string} guess * @param {string} word */ static _isClose(guess,word) { guess = guess.toLowerCase().replace(/-/g," "); word = word.toLowerCase().replace(/-/g," "); const wordArray = Array.from(word); const guessArray = Array.from(guess); //if equal if (guess == word) { return true; } //either one letter wrong or two letters swapped if (guess.length == word.length) { //Counts the mistakes and their position let errorCounter = 0; let errorPos = 0; for (var i = 0; i < wordArray.length; i++) { if (wordArray[i] != guessArray[i]) { if (errorCounter == 0) { errorPos = i; } if (errorCounter == 1) { //if a second mistake occurs, either the letters are swapped or it is not correct //but there could be a third mistake, so in the case of a swap, true is not directly returned. if ((wordArray[i] != guessArray[errorPos]) || (wordArray[errorPos] != guessArray[i])) { return false; } } if (errorCounter >= 2) { //with two or more mistakes, the word is not close return false; } errorCounter++; } } //if it hasnt returned false by now, the guess is close return true; } //if one letter too much if (guess.length - 1 == word.length) { let errorCounter = 0; //also the offset for (var i = 0; i < wordArray.length; i++) { if (wordArray[i] != guessArray[i + errorCounter]) { errorCounter++; if (errorCounter >= 2) { return false; } } } return true; } //if one letter too little if (guess.length + 1 == word.length) { let errorCounter = 0; //also the offset for (var i = 0; i < guessArray.length; i++) { if (wordArray[i + errorCounter] != guessArray[i]) { errorCounter++; if (errorCounter >= 2) { return false; } } } return true; } return false; } /** * Checks if the guess is correct. It is not case-sensitive. * @param {string} guess * @param {string} word */ static _isCorrect(guess, word) { guess = guess.toLowerCase().replace(/-/g," "); word = word.toLowerCase().replace(/-/g," "); return guess==word; } }