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}} 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)} 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
*/
/**
* 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 isHost = this._clients[0].dataChannel==dataChannel;
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._clients.forEach(({dataChannel})=>{
dataChannel.send({action:"settingsUpdate",rounds:this._rounds,drawTime:this._drawTime});
});
}
}else if (message.action=="guess"){
this._handleGuess(dataChannel,message.word);

Ben Eltschig
committed
}else{
dataChannel.send("nope");
dataChannel.close();
}
}else{
dataChannel.send("nope");
dataChannel.close();
}
})();
/**
* 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._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}});
});
}
}
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
/**
* Handles a guess send from a given dataChannel.
* @param {DataChannel<MessageToClient,MessageToServer>} dataChannel
* @param {string} word
* @todo actually do something when the guess is correct instead of just telling everyone and then forgetting
*/
_handleGuess(dataChannel,word){
let playerIndex = this._clients.map(({dataChannel})=>dataChannel).indexOf(dataChannel);
if (this._hasGameStarted){
if (word==this._word){
this._clients.forEach(client=>{
if (client.dataChannel==dataChannel){
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=>{
let close = typeof this._word=="string"?SkribblServer._isClose(word,this._word):false;
if (client.dataChannel==dataChannel){
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;
}