Newer
Older
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} 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

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

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});
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;
(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"});
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 {string} word
* @todo actually do something when the guess is correct instead of just telling everyone and then forgetting
*/
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{
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});
}
});
}
}
}
/**
* 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
*/
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
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
static _isClose(guess,word) {
guess = guess.toLowerCase();
word = word.toLowerCase();
word.replace(/-/g," ");
guess.replace(/-/g," ");
//if equal
if (guess == word) {
return true;
}
//either one letter wrong or two letters swapped
if (guess.length == word.length) {
let wordArray = [];
let guessArray = [];
//makes the string into an array
for (var i = 0; i < word.length; i++) {
wordArray[i] = word.charAt(i);
guessArray[i] = guess.charAt(i);
}
//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 wordArray = [];
let guessArray = [];
for (var i = 0; i < word.length; i++) {
wordArray[i] = word.charAt(i);
guessArray[i] = guess.charAt(i);
}
guessArray[guessArray.length] = guess.charAt(guessArray.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 wordArray = [];
let guessArray = [];
for (var i = 0; i < guess.length; i++) {
wordArray[i] = word.charAt(i);
guessArray[i] = guess.charAt(i);
}
wordArray[wordArray.length] = word.charAt(wordArray.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();
word = word.toLowerCase();
word.replace(/-/g," ");
guess.replace(/-/g," ");
if (guess == word) {
return true;
} else {
return true;
}
}