Skip to content
Snippets Groups Projects
SkribblServer.js 14.5 KiB
Newer Older
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._hasGameStarted = false;
			/** current round, starts counting at 1, only 0 when the game hasn't started yet */
			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});
					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){
						}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();
						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);
			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
Ben Eltschig's avatar
Ben Eltschig committed
			}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();
				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) {
Ben Eltschig's avatar
Ben Eltschig committed
		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) {
Ben Eltschig's avatar
Ben Eltschig committed
		guess = guess.toLowerCase().replace(/-/g," ");
		word = word.toLowerCase().replace(/-/g," ");
		return guess==word;