import { RenderController } from './RenderController';
import { AudioController } from './AudioController';
import { AssetManager } from './AssetManager';
import { InputManager } from './InputManager';
import * as THREE from 'three';
import { bindLightTransform } from '@ud-viz/utils_browser';
import {
Command,
Object3D,
State,
ExternalScriptComponent,
AudioComponent,
RenderComponent,
ScriptController,
} from '@ud-viz/game_shared';
import { arrayEquals, objectOverWrite } from '@ud-viz/utils_shared';
import * as frame3d from '@ud-viz/frame3d';
/** @class */
export class Context {
/**
* @memberof gameBrowser
* Handle rendering {@link RenderController}, inputs of user {@link InputManager}, audio {@link AudioController}, trigger {@link ScriptBase} event
* @param {frame3d.Base|frame3d.Planar} frame3D - frame3D view of the game
* @param {AssetManager} assetManager - asset manager {@link AssetManager}
* @param {InputManager} inputManager - input manager {@link InputManager}
* @param {Object<string,ScriptBase>} externalGameScriptClass - custom external script {@link ScriptBase}
* @param {object} options - options of context
* @param {object} options.userData - user data of context
* @param {object} options.socketIOWrapper - socket io wrapper if multi
* @param {object} options.interpolator - interpolator
*/
constructor(
frame3D,
assetManager,
inputManager,
externalGameScriptClass,
options = {}
) {
/**
* delta time of context
*
@type {number} */
this.dt = 0;
/**
*
* @returns {Object<string,ScriptBase>} - formated gamescript class
*/
const formatExternalGameScriptClass = () => {
const result = {};
const parse = (object) => {
for (const key in object) {
const value = object[key];
if (value.prototype instanceof ScriptBase) {
if (result[value.ID_SCRIPT])
throw new Error('no unique id ' + value.ID_SCRIPT);
result[value.ID_SCRIPT] = value;
} else if (value instanceof Object) {
parse(value);
} else {
console.error(object, value, key, object.name);
throw new Error(
'wrong value type ' + typeof object + ' key ' + key
);
}
}
};
parse(externalGameScriptClass);
return result;
};
/**
* custom {@link ScriptBase} that can be used by object3D
*
@type {Object<string,ScriptBase>} */
this.externalGameScriptClass = formatExternalGameScriptClass();
/**
* frame3D view of game
*
@type {frame3d.Base|frame3d.Planar} */
this.frame3D = null;
/**
* asset manager
*
@type {AssetManager} */
this.assetManager = assetManager;
/**
* input manager
*
@type {InputManager} */
this.inputManager = inputManager;
/**
* socket io wrapper
*
@type {import('../SocketIOWrapper')|null} */
this.socketIOWrapper = options.socketIOWrapper || null;
/**
* interpolator
*
@type {object|null} */
this.interpolator = options.interpolator || null;
/**
* root object3D
*
@type {THREE.Object3D} */
this.object3D = new THREE.Object3D();
this.object3D.name = 'External_Game_Context_Object3D';
/**
* register uuid of object3D in context to identify new one incoming
*
@type {Object<string,boolean>} */
this.currentUUID = {};
/**
* current root gameobject3D (child of this.object3D)
*
@type {Object3D} */
this.currentGameObject3D = null;
/**
* user data context
*
@type {object} */
this.userData = options.userData || {};
this.initFrame3D(frame3D);
}
/**
*
*
* @param {frame3d.Planar|frame3d.Base} frame3D - intialize frame3D of context
*/
initFrame3D(frame3D) {
if (this.frame3D) {
this.frame3D.scene.remove(this.object3D);
}
this.frame3D = frame3D;
// register listener
this.frame3D.on(frame3d.Base.EVENT.DISPOSE, () => {
if (this.currentGameObject3D) {
this.currentGameObject3D.traverse(function (child) {
if (!child.isGameObject3D) return;
const scriptComponent = child.getComponent(
ExternalScriptComponent.TYPE
);
if (scriptComponent) {
scriptComponent.getController().execute(Context.EVENT.DISPOSE);
}
const audioComponent = child.getComponent(AudioComponent.TYPE);
if (audioComponent) audioComponent.getController().dispose();
});
}
});
this.frame3D.on(frame3d.Base.EVENT.RESIZE, () => {
if (this.currentGameObject3D) {
this.currentGameObject3D.traverse(function (child) {
if (!child.isGameObject3D) return;
const scriptComponent = child.getComponent(
ExternalScriptComponent.TYPE
);
if (scriptComponent) {
scriptComponent.getController().execute(Context.EVENT.ON_RESIZE);
}
});
}
});
this.frame3D.scene.add(this.object3D); // add it to the frame3D scene
}
/**
* Reset context state and initialize the new frame3D
*
* @param {frame3d.Planar|frame3d.Base} newFrame3D - new frame3D to reset with
*/
reset(newFrame3D) {
if (this.currentGameObject3D) {
this.object3D.remove(this.currentGameObject3D);
this.currentGameObject3D = null;
}
this.currentUUID = {};
this.initFrame3D(newFrame3D);
}
/**
* Step context
*
* @param {number} dt - new delta time context
* @param {Array<State>} states - new states to update context current gameobject3D
*/
step(dt, states) {
this.dt = dt; // ref it for external scripts
/** @type {Object3D[]} */
const newGO = [];
const state = states[states.length - 1]; // The more current of states
// Update currentGameObject3D with the new states
if (this.currentGameObject3D) {
const object3DToRemove = [];
// remove gameobject 3D that has been removed in game context
this.currentGameObject3D.traverse((child) => {
if (!child.isGameObject3D) return;
const gameContextChild = state
.getObject3D()
.getObjectByProperty('uuid', child.uuid);
if (!gameContextChild) {
object3DToRemove.push(child);
}
});
object3DToRemove.forEach((object3D) => {
// Do not exist remove it
object3D.removeFromParent();
// external script event remove
const scriptComponent = object3D.getComponent(
ExternalScriptComponent.TYPE
);
if (scriptComponent) {
scriptComponent.getController().execute(Context.EVENT.ON_REMOVE);
}
// Audio removal
const audioComponent = object3D.getComponent(AudioComponent.TYPE);
if (audioComponent) {
audioComponent.getController().dispose();
}
// notify other that object3D is removed
this.currentGameObject3D.traverse((otherGameObject) => {
if (!otherGameObject.isGameObject3D) return;
const externalComp = otherGameObject.getComponent(
ExternalScriptComponent.TYPE
);
if (externalComp) {
externalComp
.getController()
.execute(Context.EVENT.ON_GAMEOBJECT_REMOVED, [object3D]);
}
});
delete this.currentUUID[object3D.uuid];
});
// update the others
this.currentGameObject3D.traverse((child) => {
if (!child.isGameObject3D) return;
const gameContextChild = state
.getObject3D()
.getObjectByProperty('uuid', child.uuid);
if (gameContextChild) {
// still present in game context
if (child.hasGameContextUpdate()) {
if (!child.isStatic()) {
// if no static update transform
child.position.copy(gameContextChild.position);
child.scale.copy(gameContextChild.scale);
child.rotation.copy(gameContextChild.rotation);
}
// visible
child.visible = gameContextChild.visible;
// Stack the same go of all states not consumed yet
const bufferedGO = [];
states.forEach((s) => {
const bGO = s
.getObject3D()
.getObjectByProperty('uuid', child.uuid);
if (bGO) bufferedGO.push(bGO);
});
const childRenderComp = child.getComponent(RenderComponent.TYPE);
const childExternalScriptComp = child.getComponent(
ExternalScriptComponent.TYPE
);
let renderCompHasChanged = false;
for (let index = 0; index < bufferedGO.length; index++) {
const gameContextGONotConsumned = bufferedGO[index];
// Render comp
if (childRenderComp) {
const bufferedRenderComp =
gameContextGONotConsumned.getComponent(RenderComponent.TYPE);
// Check if color change
if (
!arrayEquals(
childRenderComp.model.color,
bufferedRenderComp.model.color
)
) {
childRenderComp
.getController()
.setColor(bufferedRenderComp.model.color);
renderCompHasChanged = true;
}
// Check if idRenderData change
if (
childRenderComp.model.idRenderData !=
bufferedRenderComp.model.idRenderData
) {
childRenderComp
.getController()
.setIdRenderData(bufferedRenderComp.model.idRenderData);
renderCompHasChanged = true;
}
}
if (childExternalScriptComp && renderCompHasChanged) {
childExternalScriptComp
.getController()
.execute(Context.EVENT.ON_RENDER_COMPONENT_CHANGED);
}
// external script
if (
childExternalScriptComp &&
gameContextGONotConsumned.isOutdated()
) {
const bufferedExternalScriptComp =
gameContextGONotConsumned.getComponent(
ExternalScriptComponent.TYPE
);
// Replace variables in external script
childExternalScriptComp
.getController()
.setVariables(bufferedExternalScriptComp.model.variables);
// Launch event onOutdated
childExternalScriptComp
.getController()
.execute(Context.EVENT.ON_OUTDATED);
}
}
}
}
});
state.getObject3D().traverse((child) => {
if (!child.isGameObject3D) return; // => this one should be useless since State should be only compose of GameObject3D
const old = this.currentGameObject3D.getObjectByProperty(
'uuid',
child.uuid
);
if (!old) {
// New one add it
const parent = this.currentGameObject3D.getObjectByProperty(
'uuid',
child.parentUUID
);
const object3DToAdd = child.clone();
parent.add(object3DToAdd);
newGO.push(object3DToAdd);
if (this.currentUUID[object3DToAdd.uuid]) {
console.error('already in current uuid');
}
}
});
} else {
// first state
this.currentGameObject3D = state.getObject3D();
// add object3D to the context
this.object3D.add(this.currentGameObject3D);
this.currentGameObject3D.traverse((child) => {
if (!child.isGameObject3D) return; // => this one should be useless since State should be only compose of GameObject3D
newGO.push(child);
});
}
// Init Object3D component controllers of the new Object3D
newGO.forEach((go) => {
this.initComponentControllers(go);
// update matrix world so even object.static have a correct since the autoupdate is disable
go.updateMatrixWorld(true);
});
newGO.forEach((g) => {
this.currentUUID[g.uuid] = true;
const scriptComponent = g.getComponent(ExternalScriptComponent.TYPE);
if (scriptComponent) {
scriptComponent.getController().execute(Context.EVENT.INIT);
}
// Notify other go that a new go has been added
this.currentGameObject3D.traverse((child) => {
if (!child.isGameObject3D) return;
const otherScriptComponent = child.getComponent(
ExternalScriptComponent.TYPE
);
if (otherScriptComponent) {
otherScriptComponent
.getController()
.execute(Context.EVENT.ON_NEW_GAMEOBJECT, [g]);
}
});
});
// Update matrixWorld
this.object3D.updateMatrixWorld();
// Update shadow
if (newGO.length && this.frame3D.sceneConfig) {
bindLightTransform(
this.frame3D.sceneConfig.sky.sun_position.offset,
this.frame3D.sceneConfig.sky.sun_position.phi,
this.frame3D.sceneConfig.sky.sun_position.theta,
this.object3D,
this.frame3D.directionalLight
);
}
this.currentGameObject3D.traverse((child) => {
if (!child.isGameObject3D) return;
// Tick external script
const scriptComponent = child.getComponent(ExternalScriptComponent.TYPE);
if (scriptComponent) {
scriptComponent.getController().execute(Context.EVENT.TICK);
}
// Tick audio component
const audioComp = child.getComponent(AudioComponent.TYPE);
// Position in world referential
if (audioComp) {
const camera = this.frame3D.camera;
const cameraMatWorldInverse = camera.matrixWorldInverse;
audioComp.getController().tick(cameraMatWorldInverse);
}
// Render component
const renderComp = child.getComponent(RenderComponent.TYPE);
if (renderComp) renderComp.getController().tick(dt);
});
}
/**
*
* @param {Object3D} go - gameobject3D to init controllers
*/
initComponentControllers(go) {
const components = go.getComponents();
for (const type in components) {
const component = go.getComponent(type);
if (component.getController()) {
throw new Error('controller already init ' + go.name);
}
let scripts = null;
switch (type) {
case AudioComponent.TYPE:
component.initController(
new AudioController(component.getModel(), go, this.assetManager)
);
break;
case RenderComponent.TYPE:
component.initController(
new RenderController(component.getModel(), go, this.assetManager)
);
break;
case ExternalScriptComponent.TYPE:
scripts = new Map();
component.getModel().scriptParams.forEach((sParams) => {
scripts.set(
sParams.id,
this.createInstanceOf(
sParams.id,
go,
component.getModel().variables
)
);
});
scripts = new Map(
[...scripts.entries()].sort((a, b) => {
const aSParam = component
.getModel()
.scriptParams.filter((el) => el.id === a[0]);
const bSParam = component
.getModel()
.scriptParams.filter((el) => el.id === b[0]);
const aPrio = !isNaN(aSParam[0].priority)
? aSParam[0].priority
: -Infinity;
const bPrio = !isNaN(bSParam[0].priority)
? bSParam[0].priority
: -Infinity;
return bPrio - aPrio;
})
);
component.initController(
new ScriptController(component.getModel(), go, scripts)
);
break;
default:
// no need to initialize controller for this component
}
}
}
/**
* Create a class instance of external game script class for an object3D given an id
*
* @param {string} id - id of the class
* @param {Object3D} object3D - object3D that is going to use this instance
* @param {object} modelVariables - custom variables associated to this instance
* @returns {ScriptBase} - instance of the class bind with object3D and modelVariables
*/
createInstanceOf(id, object3D, modelVariables) {
const constructor = this.externalGameScriptClass[id];
if (!constructor) {
console.log('script loaded');
for (const key in this.externalGameScriptClass) {
console.log(this.externalGameScriptClass[key].name);
}
throw new Error('no script with id ' + id);
}
return new constructor(this, object3D, modelVariables);
}
/**
*
* @param {string} id - id of script
* @param {Object3D} [object3D=this.object3D] - object3D to traverse to find the external script (default is the root game object3D)
* @returns {ScriptBase|null} - first external script with id or null if none are found
*/
findExternalScriptWithID(id, object3D = this.object3D) {
let result = null;
object3D.traverse(function (child) {
if (!child.isGameObject3D) return;
const externalScriptComp = child.getComponent(
ExternalScriptComponent.TYPE
);
if (!externalScriptComp) return;
const scripts = externalScriptComp.getController().scripts;
if (scripts && scripts.has(id)) {
result = scripts.get(id);
return true;
}
return false;
});
return result;
}
/**
* This method need to be implemented by user
*
* @param {Command[]} cmds - commands to send to game context
*/
sendCommandsToGameContext(cmds) {
console.log(cmds, ' cant be sent');
console.error('this method has to be implement in your app template');
}
}
/**
* Event triggered by context to {@link ScriptBase}
*/
Context.EVENT = {
INIT: 'init',
TICK: 'tick',
ON_NEW_GAMEOBJECT: 'onNewGameObject',
ON_GAMEOBJECT_REMOVED: 'onGameObjectRemoved',
ON_OUTDATED: 'onOutdated',
DISPOSE: 'dispose',
ON_REMOVE: 'onRemove',
ON_RENDER_COMPONENT_CHANGED: 'onRenderComponentChanged',
ON_RESIZE: 'onResize',
};
export class ScriptBase extends THREE.EventDispatcher {
/**
* Skeleton of a game context script, different {@link Context.EVENT} are trigger by {@link Context}
*
* @param {Context} context - context of this script
* @param {Object3D} object3D - object3D bind (attach) to this script
* @param {object} variables - custom variables bind (attach) to this script
*/
constructor(context, object3D, variables) {
super();
/**
* context of this script
*
@type {Context} */
this.context = context;
/**
* object3D attach to this script
*
@type {Object3D} */
this.object3D = object3D;
/**
* custom variables attach to this script
*
* @type {object}
*/
this.variables = objectOverWrite(
JSON.parse(JSON.stringify(this.constructor.DEFAULT_VARIABLES)),
variables
);
}
/**
* call after an object3D has been added to context
*/
init() {}
/**
* call every step
*/
tick() {}
/**
* call when a new gameobject3D have been added to context
*
* @param {Object3D} newGameObject - new gameobject3D
*/
// eslint-disable-next-line no-unused-vars
onNewGameObject(newGameObject) {}
/**
* call when a gameobject3D have been removed from context
*
* @param {Object3D} gameobject3DRemoved - gameobject3D removed
*/
// eslint-disable-next-line no-unused-vars
onGameObjectRemoved(gameobject3DRemoved) {}
/**
* call every time your game object3D model has changed
*/
onOutdated() {}
/**
* call when this gameobject 3D is removed from context
*/
onRemove() {}
/**
* call when frame3D is disposed
*/
dispose() {}
/**
* call when frame3D is resized
*/
onResize() {}
/**
* call when the render component of the object has changed
*/
onRenderComponentChanged() {}
static get ID_SCRIPT() {
console.error(this.name);
throw new Error('this is abstract class you should override ID_SCRIPT');
}
/**
*
* @returns {object} - default variables of this script
*/
static get DEFAULT_VARIABLES() {
return {};
}
}