import { BasicScript } from "../scripts/basic-script.js";
import { VRSPACE } from "../client/vrspace.js";
import { ID } from "../client/vrspace.js";
import { VRSPACEUI } from '../ui/vrspace-ui.js';
import { Dialogue } from "../ui/widget/dialogue.js";
import { GameStatusForm } from "./game-status-form.js";
/**
* Base class for a simple multiuser game.
* Contains utility methods to create, join and quit the game, that open appropriate forms and dialogues.
* Whoever created the game owns it: only their browser executes all of game logic,
* and is allowed to change game state by sending events to the game object.
* All players receive game all events, and execute the same presentation logic.
*/
export class BasicGame extends BasicScript {
constructor( world, vrObject ) {
super(world,vrObject);
/** Object containing all URLs of all sounds */
this.sounds = {};
/** GameStatusForm, created and destroyed by show/close game status methods */
this.gameStatus = null;
/** Dialogue, created and destroyed by invitePlayers/joinGame methods */
this.joinDlg = null;
/** Status flag, set in joinGame method */
this.playing = false;
/** Game status flag, supposed to be set on remote event */
this.gameStarted = false;
/** Callback executed with true/false when game starts/ends */
this.callback = null;
/** Number of players at the moment of creation, copied from the shared object */
this.totalPlayers = vrObject.numberOfPlayers;
/** Shared delay, set initially and updated once the game starts */
this.delay = 10;
/** Minumum delay, limits the slider */
this.minDelay = 10;
/** Maximum delay, limits the slider */
this.maxDelay = 30;
/** Start time in milliseconds, set once the game starts - owner only */
this.startTime = 0;
/** Player list, contains User objects */
this.players = [];
this.indicators = [];
}
/**
* Creates a new HUD row, and opens a new GameStatusForm to start or join the game.
*/
showGameStatus() {
this.closeGameStatus(); // just in case
VRSPACEUI.hud.showButtons(false);
VRSPACEUI.hud.newRow();
this.gameStatus = new GameStatusForm(this.isMine(), (start)=>{
if ( start ) {
this.startGame();
} else {
this.quitGame();
}
});
this.gameStatus.delayMin = this.minDelay;
this.gameStatus.delayMax = this.maxDelay;
this.gameStatus.delay = this.delay;
this.gameStatus.gameStarted = this.gameStarted;
this.gameStatus.init();
this.gameStatus.numberOfPlayers(this.totalPlayers);
}
/**
* Sets number of players in game status form, if currently open.
*/
updateStatus() {
// may not be displayed before this player joins/after quit/etc
if ( this.gameStatus ) {
this.gameStatus.numberOfPlayers(this.totalPlayers);
}
}
/**
* If the game is owned by current user, sends start event.
*/
startGame() {
// and then
if ( this.isMine() ) {
this.startTime = Date.now();
VRSPACE.sendEvent(this.vrObject, {status: "started", starting: this.gameStatus.getDelay() });
}
}
/**
* Close GameStatusForm if currently open, and restore the HUD.
*/
closeGameStatus() {
if ( this.gameStatus ) {
VRSPACEUI.hud.clearRow();
VRSPACEUI.hud.showButtons(true);
this.gameStatus.dispose();
this.gameStatus = null;
}
}
/**
* Join the game - shows game status and sends game join command to the server.
* @param {boolean} yes
*/
joinGame(yes) {
this.playing = yes;
if ( yes ) {
this.showGameStatus();
VRSPACE.sendCommand("Game", {id: this.vrObject.id, action:"join"});
}
this.joinDlg = null;
}
/**
* Quit the game - sends the command to the server, optionally calls this.callback with false.
*/
quitGame() {
this.playing = false;
VRSPACE.sendCommand("Game", {id: this.vrObject.id, action:"quit"});
if ( this.callback ) {
this.callback(false);
}
}
/**
* Typically the first thing to do. Game owner joins the game at once, everyone else gets the dialogue,
* that triggers this.joinGame() as callback.
*/
invitePlayers() {
if ( this.isMine() ) {
this.joinGame(true);
} else if ( this.vrObject.status != "started" ) {
this.joinDlg = new Dialogue("Join "+this.vrObject.name+" ?", (yes)=>this.joinGame(yes));
this.joinDlg.init();
}
}
/**
* Calls either showGameStatus, or invitePlayers, as appropriate.
* Supposed to be executed on button click, if game instance already exists.
*/
startRequested() {
if ( this.isMine() || this.gameStarted ) {
// player has already joined
this.showGameStatus();
} else if ( ! this.gameStarted ) {
// player wants to join
this.invitePlayers();
}
}
/**
* Clean up dialogue and status form, indicators, detach sounds
*/
dispose() {
if ( this.joinDlg ) {
this.joinDlg.close();
this.joinDlg = null;
}
this.closeGameStatus();
this.indicators.forEach( i => {
if ( i.parent ) {
i.parent.GameIndicator = null;
delete i.parent.GameIndicator;
}
i.dispose();
});
this.players.filter(client=>client.avatar).forEach(client=>this.detachSounds(client.avatar.baseMesh()));
this.detachSounds(VRSPACEUI.hud.root);
}
/**
* Helper method, returns current position the avatar, in either 1st or 3rd person view.
*/
avatarPosition() {
if ( typeof this.world.camera3p !== "undefined" && this.scene.activeCamera == this.world.camera3p ) {
return this.world.avatar.baseMesh().position;
}
let camera = this.scene.activeCamera;
return new BABYLON.Vector3(camera.position.x,camera.position.y-2*camera.ellipsoid.y-camera.ellipsoidOffset.y,camera.position.z);
}
/**
* Helper method to attach an icon above the avatar.
* @param {Mesh} baseMesh avatar parent mesh
* @param {String} icon URL of the icon to attach
* @param {*} [color=new BABYLON.Color4(1,1,1,1)] optional color
*/
addIndicator(baseMesh,icon,color=new BABYLON.Color4(1,1,1,1)) {
if ( typeof baseMesh.GameIndicator !== "undefined") {
//baseMesh.GameIndicator.material.emissiveTexture = new BABYLON.Texture(icon, this.scene);
baseMesh.GameIndicator.material.diffuseTexture = new BABYLON.Texture(icon, this.scene);
} else {
let indicator = BABYLON.MeshBuilder.CreatePlane("IndicatorIcon", {}, this.scene);
indicator.billboardMode = BABYLON.Mesh.BILLBOARDMODE_Y;
let material = new BABYLON.StandardMaterial("IndicatorMaterial",this.scene);
//material.emissiveTexture = new BABYLON.Texture(icon, this.scene);
material.diffuseTexture = new BABYLON.Texture(icon, this.scene);
material.disableLighting = true;
material.alpha = 0;
material.alphaMode = BABYLON.Constants.ALPHA_ONEONE;
//material.backFaceCulling = false;
indicator.material = material;
indicator.position = new BABYLON.Vector3(0,2.8,0);
//indicator.rotation = new BABYLON.Vector3(Math.PI,0,0);
indicator.parent = baseMesh;
baseMesh.GameIndicator = indicator;
this.indicators.push(indicator);
}
baseMesh.GameIndicator.material.emissiveColor = color;
}
/**
* Removes and disposes indicator from the base mesh and from list of indicators.
*/
removeIndicator(baseMesh) {
if ( typeof baseMesh.GameIndicator != undefined ) {
let indicator = baseMesh.GameIndicator;
let pos = this.indicators.indexOf(indicator);
if ( pos > -1 ) {
// TODO also remove indicators
this.indicators.splice(pos,1);
}
indicator.dispose();
delete baseMesh.GameIndicator;
}
}
/**
* Stops currently playing sound, and plays a sound attached to the avatar, if available. Sets SoundPlaying member field of the avatar mesh.
*/
playSound( avatarBase, soundName ) {
if ( typeof avatarBase.SoundPlaying !== "undefined" ) {
avatarBase.SoundPlaying.stop();
delete avatarBase.SoundPlaying;
}
if ( soundName && avatarBase[soundName] ) {
avatarBase[soundName].play();
avatarBase.SoundPlaying = avatarBase[soundName];
}
}
/**
* Adds and indicator to an avatar, and optionally plays a sound.
* @param {Object} playerEvent object containing className and id of the player (can be User object)
* @param {String} soundName name of the sound, one of this.sound, passed to playSound
* @param {String} icon URL of the texture, passed to addIndicator
* @param {*} color passed to addIndicator
*/
changePlayerStatus( playerEvent, soundName, icon, color ) {
if ( playerEvent.className == VRSPACE.me.className && playerEvent.id == VRSPACE.me.id ) {
// my avatar
this.addIndicator( this.world.avatar.baseMesh(), icon, color);
return VRSPACE.me;
} else {
// someone else
let user = this.players.find(user => user.id == playerEvent.id);
// CHECKME in some cases this avatar may not exist
this.addIndicator( user.avatar.baseMesh(), icon, color );
this.playSound( user.avatar.baseMesh(), soundName);
return user;
}
}
/**
* Attach sounds to avatar mesh, called when player joins; this implementation does nothing.
*/
attachSounds( avatarMesh ) {
}
/**
* If the mesh contains field specified by soundName, detaches it, disposes, and deletes.
*/
removeSound(baseMesh, soundName) {
// non-existing sound is fine, it may have been removed (user quit)
// or was never attached (SoundPlaying)
if ( typeof baseMesh[soundName] != "undefined") {
//console.log("Removing sound "+soundName+" from ",baseMesh);
baseMesh[soundName].detachFromMesh();
baseMesh[soundName].dispose();
delete baseMesh[soundName];
} else {
//console.error("Undefined sound "+soundName+" for ",baseMesh);
}
}
/**
* Remove all sounds from avatar mesh, called when player quits.
* Iterates over all keys of this.sounds object, and calls removeSound for each.
*/
detachSounds(avatarMesh) {
for ( let soundName of Object.keys(this.sounds) ) {
this.removeSound(avatarMesh, soundName);
}
}
/**
* Helper method to be executed when a player joins the game, including the one that starts the game.
* Adds the player to the player list, and calls attachSounds witht he player avatar.
* When local player joins the game, the callback is called with true (if set).
* Requires player avatar to be already loaded - may not be safe for async usage.
* @param {Object} player object containing className and id of the player (User object will do)
*/
playerJoins(player) {
let id = new ID(player.className,player.id);
if ( id.className == VRSPACE.me.className && id.id == VRSPACE.me.id ) {
this.attachSounds(VRSPACEUI.hud.root);
this.players.push(VRSPACE.me);
if ( this.callback ) {
this.callback(true);
}
} else {
let user = VRSPACE.getScene().get(id.toString());
if ( user ) {
this.players.push(user);
this.attachSounds(user.avatar.baseMesh());
} else {
console.error( id +" joined the game but is not in local scene");
}
}
}
/**
* Enables or disables movement for any kind of camera.
*/
enableMovement(enable) {
if ( this.world.inXR()) {
if ( enable ) {
this.world.xrHelper.enableMovement(this.movementMode);
} else {
this.movementMode = this.world.xrHelper.movementMode;
this.world.xrHelper.disableMovement();
}
} else if ( this.scene.activeCamera == this.world.camera1p ) {
if ( enable ) {
this.camera.attachControl();
} else {
this.camera.detachControl();
}
} else if (this.scene.activeCamera == this.world.camera3p) {
this.world.avatarController.enableTracking(enable);
}
}
/**
* Helper method to be executed when a player quits the game, including the game owner.
* Finds the player avatar and detaches attached sounds and indicator, then removes it from the player list.
* If this player has quit, also closes the the game status.
* @param {Object} player object containing className and id of the player (User object will do)
*/
playerQuits(player) {
let id = new ID(player.className,player.id);
if ( id.className == VRSPACE.me.className && id.id == VRSPACE.me.id ) {
// I quit
this.closeGameStatus();
this.detachSounds(VRSPACEUI.hud.root);
} else {
// CHECKME this may fail if user has disconnected (avatar removed from the scene)
let user = VRSPACE.getScene().get(id.toString());
if ( user ) {
let pos = this.players.indexOf(user);
if ( pos > -1 ) {
this.detachSounds(user.avatar.baseMesh());
this.removeIndicator(user.avatar.baseMesh());
this.players.splice(pos,1);
}
} else {
console.error( id +" quit the game but is not in local scene");
}
}
}
/**
* Main method of the game. Receives all events that happen in the game, and is supposed to implement scene changes.
* @param {Game} vrObject game object
* @param {Object} changes custom game event
*/
remoteChange(vrObject, changes) {
console.log("TODO Remote changes for "+vrObject.id, changes);
}
}