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";

Ben Eltschig
committed
/**
* 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

Ben Eltschig
committed
*/
/**
* 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

Ben Eltschig
committed
*/
/**
* 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}[]} */

Ben Eltschig
committed
this._clients = [];
/** 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=>{

Ben Eltschig
committed
this.connect(DataChannel.from(dataChannel,JSON.stringify,JSON.parse));

Ben Eltschig
committed
/** @type {[DataChannel<MessageToServer,MessageToClient>,DataChannel<MessageToClient,MessageToServer>]} */

Ben Eltschig
committed
let [endpointA,endpointB] = DataChannel.createPair();
this._dataChannel = endpointA;
this.connect(endpointB);
})();
}
/**
* Waits until the server is ready.
*/
async waitUntilReady(){
return this._readyPromise;
}

Ben Eltschig
committed
/**
* 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.

Ben Eltschig
committed
* @param {DataChannel<MessageToClient,MessageToServer>} dataChannel
*/
connect(dataChannel){

Ben Eltschig
committed
(async()=>{
let message = await dataChannel.next();
console.log("message: ",message);

Ben Eltschig
committed
if (message.action==="join"){
let name = message.name;

Ben Eltschig
committed
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});
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);
}

Ben Eltschig
committed
}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._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.
let player = this._clients[playerIndex];
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();
let close = typeof this._word=="string"?SkribblServer._isClose(word,this._word):false;
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
*/
guess = guess.toLowerCase().replace(/-/g," ");
word = word.toLowerCase().replace(/-/g," ");
const wordArray = Array.from(word);
const guessArray = Array.from(guess);
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
//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;
}
/**
* 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;