Skip to content
Snippets Groups Projects
SkribblClient.js 7.12 KiB
Newer Older
import DataChannel from "./networking/DataChannel.js";
import Signaler from "./networking/Signaler.js";

/**
 * @typedef {import("./SkribblServer.js").MessageToServer} MessageToServer
 */
/**
 * @typedef {import("./SkribblServer.js").MessageToClient} MessageToClient
 */
/**
 * @typedef {import("./SkribblServer.js").GameState} GameState
 */
/**
 * Client/endpoint that manages a connection to a SkribblServer
 */
export default class SkribblClient {
	/**
	 * Constructs a new client for a given game ID or DataChannel.
	 * @param {string|DataChannel<MessageToServer,MessageToClient>} idOrDataChannel
	 */
	constructor(idOrDataChannel){
		/** @type {{name:string,points?:number}[]} */
		/** @type {((host:boolean)=>void)[]} */
		this._onHostChangeCallbacks = [];
		/** @type {((players:{name:string,points?:number}[])=>void)[]} */
		/** @type {((state:GameState)=>void)[]} */
		this._onStateChangeCallbacks = [];
		this._rounds = 3;
		this._drawTime = 180;
		/** @type {((settings:{rounds:number,drawTime:number})=>void)[]} */
		this._onSettingsChangeCallbacks = [];
		this._readyPromise = (async()=>{
			/** @type {DataChannel<MessageToServer,MessageToClient>} */
			this._dataChannel = idOrDataChannel instanceof DataChannel?idOrDataChannel:DataChannel.from(await Signaler.join(idOrDataChannel),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 hostChanged = this._host!==message.newState.host;
						let playersChanged = JSON.stringify(this._players)!==JSON.stringify(message.newState.players);
						this._hasGameStarted = message.newState.hasGameStarted;
						this._host = message.newState.host;
						this._players = message.newState.players.slice();
						if (hostChanged){
							this._onHostChangeCallbacks.slice().forEach(callback=>callback(this._host));
						}
						if (playersChanged){
							this._onPlayersListChangeCallbacks.forEach(callback=>callback(this._players));
						}
						this._onStateChangeCallbacks.forEach(callback=>callback(this._state));
					}else if(message.action=="settingsUpdate"){
						this._rounds = message.rounds;
						this._drawTime = message.drawTime;
						this._onSettingsChangeCallbacks.slice().forEach(callback=>callback(this.settings));
		})();
	}

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

	/**
	 * 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.
	 */
	get host(){
		return this._host;
	}

	/**
	 * Registers a callback to be called whenever the host of the game changes, and once when it is registered.
	 * @param {(host:boolean)=>void} callback
	 */
	onHostChange(callback){
		this._onHostChangeCallbacks.push(callback);
		callback(this._host);
	}

	/**
	 * Removes a callback registered using `onHostChange(callback)`.
	 * @param {(host:boolean)=>void} callback
	 */
	removeOnHostChangeCallback(callback){
		let index = this._onHostChangeCallbacks.indexOf(callback);
		if (index!==-1){
			this._onHostChangeCallbacks.splice(index,1);
		}
	}

	/**
	 * The settings of the current game.
	 * 
	 * This property will always refer to the same object - when assigning a new one, it's relevant properties will simply be copied to the old one.
	 * Changes to those properties will be send to the server, and be reflected in their values only once the server has confirmed them.
	 * @readonly
	 */
	get settings(){
		if (!this._settings){
			let _this = this;
			this._settings = {
				get rounds(){
					return _this._rounds;
				},
				set rounds(rounds){
					_this._dataChannel.send({action:"changeSettings",rounds,drawTime:_this._drawTime});
				},
				get drawTime(){
					return _this._drawTime;
				},
				set drawTime(drawTime){
					_this._dataChannel.send({action:"changeSettings",rounds:_this._rounds,drawTime});
				}
			};
		}
		return this._settings;
	}

	set settings(settings){
		this.settings.rounds = settings.rounds;
		this.settings.drawTime = settings.drawTime;
	}

	/**
	 * Registers a callback whenever the settings of the game change, and once when it is registered.
	 * @param {(settings:{rounds:number,drawTime:number})=>void} callback
	 */
		this._onSettingsChangeCallbacks.push(callback);
		callback(this.settings);
	}

	/**
	 * Removes a callback registered using `onSettingsChange(callback)`.
	 * @param {(settings:{rounds:number,drawTime:number})=>void} callback
	 */
	removeOnSettingsChangeCallback(callback){
		let index = this._onSettingsChangeCallbacks.indexOf(callback);
		if (index!==-1){
			this._onSettingsChangeCallbacks.splice(index,1);
		}
	}

	/**
	 * Registers a callback whenever the state of the game changes, and once when it is registered.
	 * 
	 * Should eventually get replaced by more specific methods, but works fine for now.
	 * @param {(state:GameState)=>void} callback
	 */
	onStateChange(callback){
		this._onStateChangeCallbacks.push(callback);
		callback(this._state);
	}

	/**
	 * Removes a callback registered using `onStateChange(callback)`.
	 * @param {(state:GameState)=>void} callback
	 */
	removeOnStateChangeCallback(callback){
		let index = this._onStateChangeCallbacks.indexOf(callback);
		if (index!==-1){
			this._onStateChangeCallbacks.splice(index,1);
		}
	}

	/**
	 * Registers a callback whenever the list of players in the game changes, and once when it is registered.
	 * @param {(players:{name:string,points?:number}[])=>void} callback
	 */
	onPlayersListChange(callback){
		this._onPlayersListChangeCallbacks.push(callback);
		callback(this._players);
	}