Newer
Older
import DataChannel from "./networking/DataChannel.js";
import Signaler from "./networking/Signaler.js";
import Util, {CallbackHandler,Value,ReadOnlyValue} from "../util/Util.js";

Ben Eltschig
committed
/**
* @typedef {import("./SkribblServer.js").MessageToServer} MessageToServer
* @typedef {import("./SkribblServer.js").MessageToClient} MessageToClient
* @typedef {import("./SkribblServer.js").GameState} GameState
* @typedef {import("./SkribblServer.js").GuessResponse} GuessResponse
* @typedef {import("./SkribblServer.js").ClearOrder} ClearOrder
* @typedef {import("./SkribblServer.js").DrawOrder} DrawOrder
* @typedef {import("./SkribblServer.js").WordReveal} WordReveal
*/

Ben Eltschig
committed
/**
* @typedef {import("../util/Util.js").DeepReadOnly<T>} DeepReadOnly

Ben Eltschig
committed
* @template {unknown} T
*/

Ben Eltschig
committed
/**
* Client/endpoint that manages a connection to a SkribblServer
* @todo find a less redundant way to manage callbacks to avoid clutter

Ben Eltschig
committed
*/
export default class SkribblClient {
/**
* Constructs a new client for a given game ID or DataChannel.
* @param {string} id
* @param {DataChannel<MessageToServer,MessageToClient>} [dataChannel] dataChannel to connect to. If none is given, automatically tries to create one based on the given id.
constructor(id,dataChannel){
this._id = id;

Ben Eltschig
committed
/** @type {Value<DeepReadOnly<{name:string,points?:number}[]>>} */
this._players = new Value([]);
this._isHost = new Value(false);
/** @type {Value<DeepReadOnly<GameState>>} */
this._state = new Value(null);
/** @type {CallbackHandler<[guessData:GuessResponse]>} */
this._onGuessCallbacks = new CallbackHandler();
/** @type {CallbackHandler<[order:ClearOrder|DrawOrder]>} */
this._onDrawCallbacks = new CallbackHandler();
/** @type {CallbackHandler<[message:WordReveal]>} */
this._onWordRevealCallbacks = new CallbackHandler();
this._settings = new Value(Util.deepFreeze({rounds:3,drawTime:180}));
this._readyPromise = (async()=>{
/** @type {DataChannel<MessageToServer,MessageToClient>} */
this._dataChannel = dataChannel||DataChannel.from(await Signaler.join(id),JSON.stringify,JSON.parse);
await this._dataChannel.waitUntilReady();
this._dataChannel.onMessage(message=>{
console.log("message from server:",message);
if (message!=="yup"&&message!=="nope"){
if (message.action=="stateUpdate"){
let state = Util.deepFreeze(message.newState);

Ben Eltschig
committed
this._state.value = state;
this._hasGameStarted = state.hasGameStarted;
this._players.value = state.players;
this._isHost.value = state.host;
}else if(message.action=="settingsUpdate"){
this._settings.value = Util.deepFreeze({rounds:message.rounds,drawTime:message.drawTime});
}else if(message.action=="guessedWord"){
let data = (({action,...data})=>data)(message);

Ben Eltschig
committed
this._onGuessCallbacks.callAll(data);
}else if(message.action=="clearCanvas"||message.action=="draw"||message.action=="erase"){
this._onDrawCallbacks.callAll(message);
}else if(message.action=="revealWord"){
this._onWordRevealCallbacks.callAll(message);

Ben Eltschig
committed
}

Ben Eltschig
committed
}
});
})();
}
/**
* Waits until the connection to the server is ready.
*/
async waitUntilReady(){
return this._readyPromise;
}

Ben Eltschig
committed
/**
* Joins the game with the given name.
* @param {string} name
*/
async join(name){

Ben Eltschig
committed
this._dataChannel.send({action:"join",name});

Ben Eltschig
committed
let message = await this._dataChannel.next();
if (message!=="yup"){
throw new Error(`Failed to join! Server response: "${message}"`);
}
}
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
/**
* Tells the server to start the game and then immediately returns, without waiting for a response.
*/
startGame(){
this._dataChannel.send({action:"startGame"});
}
/**
* Whether the game has started yet.
*/
get hasGameStarted(){
return this._hasGameStarted;
}
/**
* If the game hasn't started yet, waits until the host of the game has clicked the start button in the lobby, otherwise immediately returns.
*/
async waitUntilGameStarted(){
if (!this._hasGameStarted){
while(true){
let message = await this._dataChannel.next();
if (message!=="yup"&&message!=="nope"&&message.action=="stateUpdate"&&message.newState.hasGameStarted){
break;
}
}
}
}
/**
* If a game has already started, waits until it ends again.
*/
async waitUntilGameEnded(){
if (this._hasGameStarted){
while(true){
let message = await this._dataChannel.next();
if (message!=="yup"&&message!=="nope"&&message.action=="stateUpdate"&&!message.newState.hasGameStarted){
break;
}
}
}
}
/**
* The ID of the current game.
* @readonly
*/
get id(){
return this._id;
}
/**
* The url of the current game.
* @readonly
*/
get url(){
return document.location.host+document.location.pathname+"#"+this._id;
}
/**
* Whether this client is currently "host" in the sense that they have permissions to alter settings and start the game.
* Doesn't actually always correlate with who is hosting the game.

Ben Eltschig
committed
* @readonly

Ben Eltschig
committed
get isHost(){
return this._isHost.readOnly;
/**
* The settings of the current game.
* @readonly
*/
get settings(){

Ben Eltschig
committed
return this._settings.readOnly;
/**

Ben Eltschig
committed
* Asks the server to change the given settings to the given values.
* @param {{rounds?:number,drawTime?:number}} settings
*/

Ben Eltschig
committed
setSettings({rounds=this._settings.value.rounds,drawTime=this._settings.value.drawTime}){
this._dataChannel.send({action:"changeSettings",rounds,drawTime});
}
/**

Ben Eltschig
committed
* A readonly version of the current state that can be monitored for changes.
* @readonly
*/

Ben Eltschig
committed
get state(){
return this._state.readOnly;
}
/**
* All currently connected players.
* @readonly
*/
get players(){
return this._players.readOnly;
}
/**
* Sends the index of the choosen word to the server.
*
* Also temporary, should be replaced by something cleaner later on.
* @param {number} index
*/
chooseWord(index){
this._dataChannel.send({action:"chooseWord",word:index});
}
/**
* Sends a guess to the server and then immediately returns, without waiting for a response.
* @param {string} word
*/
sendGuess(word){
this._dataChannel.send({action:"guess",word});
}
/**

Ben Eltschig
committed
* Registers a callback to be called whenever someone guesses a word (not necessarily correctly - just whenever anyone writes anything in the chat, basically).
* @param {(guessData:GuessResponse)=>void} callback

Ben Eltschig
committed
* @param {object} [options]
* @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true.

Ben Eltschig
committed
onGuess(callback,{onlyWhen=null}={}){
this._onGuessCallbacks.addCallback(callback,{onlyWhen});
* Sends the given drawing order to the server and the immediately returns, without waiting for a response.
* @param {ClearOrder|DrawOrder} order
draw(order){
this._dataChannel.send(order);
}
/**
* Registers a callback to be called whenever someone draws something on the canvas.
* @param {(order:ClearOrder|DrawOrder)=>void} callback
* @param {object} [options]
* @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true.
*/
onDraw(callback,{onlyWhen=null}={}){
this._onDrawCallbacks.addCallback(callback,{onlyWhen});

Ben Eltschig
committed
}
/**
* Registers a callback to be called whenever the server reveals which word has just been drawn and how many points were awarded to each player.
* @param {(message:WordReveal)=>void} callback
* @param {object} [options]
* @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true.
*/
onWordReveal(callback,{onlyWhen=null}={}){
this._onWordRevealCallbacks.addCallback(callback,{onlyWhen});
}