Skip to content
Snippets Groups Projects
SkribblClient.js 7.61 KiB
Newer Older
import DataChannel from "./networking/DataChannel.js";
import Signaler from "./networking/Signaler.js";
import Util, {CallbackHandler,Value,ReadOnlyValue} from "../util/Util.js";
/**
 * @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's avatar
Ben Eltschig committed
 * @typedef {import("../util/Util.js").DeepReadOnly<T>} DeepReadOnly
/**
 * Client/endpoint that manages a connection to a SkribblServer
Ben Eltschig's avatar
Ben Eltschig committed
 * @todo find a less redundant way to manage callbacks to avoid clutter
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;
		/** @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);
						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);
					}else if(message.action=="clearCanvas"||message.action=="draw"||message.action=="erase"){
						this._onDrawCallbacks.callAll(message);
					}else if(message.action=="revealWord"){
						this._onWordRevealCallbacks.callAll(message);
		})();
	}

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

	/**
	 * Joins the game with the given name.
	 * @param {string} name
	 */
	async join(name){
		this._dataChannel.send({action:"join",name});
		let message = await this._dataChannel.next();
		if (message!=="yup"){
			throw new Error(`Failed to join! Server response: "${message}"`);
		}
	}

	/**
	 * 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.
	/**
	 * The settings of the current game.
	 * @readonly
	 */
	get settings(){
	 * Asks the server to change the given settings to the given values.
	 * @param {{rounds?:number,drawTime?:number}} settings
	setSettings({rounds=this._settings.value.rounds,drawTime=this._settings.value.drawTime}){
		this._dataChannel.send({action:"changeSettings",rounds,drawTime});
	 * A readonly version of the current state that can be monitored for changes.
	 * @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});
	}

	/**
	 * 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
	 * @param {object} [options]
	 * @param {Value<boolean>|ReadOnlyValue<boolean>} [options.onlyWhen] when specified, calls the callback only when this is value is true.
	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});

	/**
	 * 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});
	}