const Collider = require('./component/Collider');
const Script = require('./component/Script');
const GameScript = require('./component/GameScript');
const Object3D = require('./Object3D');
const State = require('./state/State');
const Command = require('./Command');
const { Collisions } = require('detect-collisions');
const THREE = require('three');
const { objectOverWrite } = require('@ud-viz/utils_shared');
/**
* @callback ContextListener
* @param {*} params - params pass when event is dispatched
*/
/**
* @callback CommandCallback
* @param {object} data - command data
* @returns {boolean} if true the command is removed from context.commands
*/
/** @class */
const Context = class {
/**
* Handle collisions, add/remove gameobject3D, process commands + trigger {@link ScriptBase} event
*
* @param {Object<string,import("./Context").ScriptBase>} gameScriptClass - map of class extended {@link ScriptBase}
* @param {Object3D} object3D - root game object3D
*/
constructor(gameScriptClass, object3D) {
/**
*
* @returns {Object<string,import("./Context").ScriptBase>} - formated gamescript class
*/
const formatGameScriptClass = () => {
const result = {};
const parse = (object) => {
for (const key in object) {
const value = object[key];
if (value.IS_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(gameScriptClass);
return result;
};
/**
* class that can be reference by {@link GameScript} of an object3D
*
* @type {Object<string,import("./Context").ScriptBase>}
*/
this.gameScriptClass = formatGameScriptClass();
/**
* root game object3D
*
* @type {import("./Object3D").Object3D}
*/
this.object3D = object3D;
this.object3D.updateMatrixWorld(true);
/**
* Collisions system {@link https://www.npmjs.com/package/detect-collisions}
*
* @type {Collisions}
*/
this.collisions = new Collisions();
/**
* Buffer to handle collision events {@link Context.EVENT}
*
* @type {Object<string,string>}
*/
this.collisionsBuffer = {};
/**
* Listeners of custom events
*
* @type {Object<string,ContextListener[]>}
*/
this.listeners = {};
/**
* delta time
*
* @type {number}
*/
this.dt = 0;
/**
* commands buffer
*
@type {Map<string,Array>} */
this.commands = new Map();
}
/**
* Create a class instance of 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 {import("./Context").ScriptBase} - instance of the class bind with object3D and modelVariables
*/
createInstanceOf(id, object3D, modelVariables) {
const constructor = this.gameScriptClass[id];
if (!constructor) {
console.log('script loaded');
for (const key in this.gameScriptClass) {
console.log(this.gameScriptClass[key]);
}
throw new Error('no script with id ' + id);
}
return new constructor(this, object3D, modelVariables);
}
/**
* Load its object3D
*
* @returns {Promise} - promise resolving at the end of the load
*/
load() {
return this.loadObject3D(this.object3D);
}
/**
* Load an object3D into context
*
* @param {import("./Object3D").Object3D} obj - object3D to load
* @returns {Promise} - promise resolving at the end of the load
*/
loadObject3D(obj) {
return new Promise((resolve) => {
// init game component controllers of object3D
this.initComponentControllers(obj);
// compute promises
const promises = [];
obj.traverse(function (child) {
const scriptC = child.getComponent(GameScript.Component.TYPE);
if (scriptC) {
const scripts = scriptC.getController().scripts;
for (const [, script] of scripts) {
const result = scriptC
.getController()
.executeScript(script, Context.EVENT.LOAD);
if (result) promises.push(result);
}
}
});
Promise.all(promises).then(() => {
this.registerObject3DCollision(obj);
// trigger Context.EVENT.INIT
this.dispatchScriptEvent(obj, Context.EVENT.INIT);
resolve();
});
}).catch((error) => {
console.error(error);
});
}
/**
* Step context
*
* @param {number} dt - new delta time of step
*/
step(dt) {
this.dt = dt;
this.dispatchScriptEvent(this.object3D, Context.EVENT.TICK);
// clear command
for (const [, arrayData] of this.commands) {
arrayData.length = 0;
}
this.updateCollision(this.object3D);
this.object3D.traverse((child) => {
if (child.isStatic()) return;
const colliderComponent = child.getComponent(Collider.Component.TYPE);
if (colliderComponent) {
const collidedObject3D = [];
const buffer = this.collisionsBuffer[child.uuid];
colliderComponent
.getController()
.getShapeWrappers()
.forEach((wrapper) => {
const shape = wrapper.getShape();
const potentials = shape.potentials();
const result = this.collisions.createResult();
for (const p of potentials) {
/** In {@link ShapeWrapper} shape are link to shapewrapper */
const potentialObject3D = p.getWrapper().getObject3D();
if (!potentialObject3D.isStatic()) continue;
if (shape.collides(p, result)) {
collidedObject3D.push(potentialObject3D.uuid);
// child collides with potentialObject3D
if (buffer.includes(potentialObject3D.uuid)) {
// Already collided
this.dispatchScriptEvent(
child,
Context.EVENT.IS_COLLIDING,
[potentialObject3D],
false
);
this.dispatchScriptEvent(
potentialObject3D,
Context.EVENT.IS_COLLIDING,
[child],
false
);
} else {
// OnEnter
buffer.push(potentialObject3D.uuid); // Register in buffer
// notify both gameobject
this.dispatchScriptEvent(
child,
Context.EVENT.ON_ENTER_COLLISION,
[potentialObject3D],
false
);
this.dispatchScriptEvent(
potentialObject3D,
Context.EVENT.ON_ENTER_COLLISION,
[child],
false
);
}
// move position of the no static object3D according the collide result
if (
colliderComponent.getModel().isBody() &&
p.getWrapper().isBody()
) {
child.position.sub(
new THREE.Vector3(
result.overlap * result.overlap_x,
result.overlap * result.overlap_y,
0
)
);
child.setOutdated(true);
// child position has changed updated collider
this.updateCollision(child);
}
}
}
});
// Notify onLeave
for (let i = buffer.length - 1; i >= 0; i--) {
const uuid = buffer[i];
if (!collidedObject3D.includes(uuid)) {
const gameObjectCollided = this.object3D.getObjectByProperty(
'uuid',
uuid
);
this.dispatchScriptEvent(
child,
Context.EVENT.ON_LEAVE_COLLISION,
[gameObjectCollided],
false
);
this.dispatchScriptEvent(
gameObjectCollided,
Context.EVENT.ON_LEAVE_COLLISION,
[child],
false
);
buffer.splice(i, 1); // Remove from buffer
}
}
}
});
}
/**
* It will dispatch an event to all {@link ScriptBase} in object3D
*
* @param {import("./Object3D").Object3D} object3D - object3D that you want to dispatch the event to.
* @param {string} event - name of the event to dispatch see possible value in {@link Context.EVENT}
* @param {any[]} params - params to pass to {@link ScriptBase}
* @param {boolean} [recursive=true] - traverse object3D child if true
*/
dispatchScriptEvent(object3D, event, params = [], recursive = true) {
if (recursive) {
object3D.traverse(function (child) {
const scriptComponent = child.getComponent(GameScript.Component.TYPE);
if (scriptComponent) {
scriptComponent.getController().execute(event, params);
}
});
} else {
const scriptComponent = object3D.getComponent(GameScript.Component.TYPE);
if (scriptComponent) {
scriptComponent.getController().execute(event, params);
}
}
}
/**
* Initialize controllers used in context
*
* @param {Object3D} obj - object3D to initialize controllers
*/
initComponentControllers(obj) {
obj.traverse((child) => {
const components = child.getComponents();
for (const type in components) {
const component = child.getComponent(type);
if (component.getController())
throw new Error('controller already init ' + child.name);
let scripts = null;
switch (type) {
case GameScript.Component.TYPE:
scripts = new Map();
component.getModel().scriptParams.forEach((sParams) => {
scripts.set(
sParams.id,
this.createInstanceOf(
sParams.id,
child,
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 Script.Controller(component.getModel(), child, scripts)
);
break;
case Collider.Component.TYPE:
component.initController(
new Collider.Controller(component.getModel(), child)
);
break;
default:
// no need to initialize controller for this component
}
}
});
}
/**
* Add a object3D into the collision system
*
* @param {import("./Object3D").Object3D} object3D - object3D to register
*/
registerObject3DCollision(object3D) {
object3D.traverse((child) => {
if (this.collisionsBuffer[child.uuid]) return; // Already add
this.collisionsBuffer[child.uuid] = [];
const colliderComponent = child.getComponent(Collider.Component.TYPE);
if (colliderComponent) {
colliderComponent
/** @type {Context} */ .getController()
.getShapeWrappers()
.forEach((wrapper) => {
this.collisions.insert(wrapper.getShape());
});
}
});
this.updateCollisionBuffer();
}
/**
* Update object3D collider controller + update collisions system
*
* @param {Object3D} object3D - object3D to update
*/
updateCollision(object3D) {
object3D.traverse((child) => {
const colliderComponent = child.getComponent(Collider.Component.TYPE);
if (colliderComponent)
colliderComponent.getController().update(this.object3D.position);
});
this.collisions.update();
}
/**
* Update the collision buffer
*/
updateCollisionBuffer() {
this.updateCollision(this.object3D);
for (const uuid in this.collisionsBuffer) {
this.collisionsBuffer[uuid].length = 0; // reset buffer
}
this.object3D.traverse((child) => {
if (child.isStatic()) return;
const colliderComponent = child.getComponent(Collider.Component.TYPE);
if (colliderComponent) {
colliderComponent
.getController()
.getShapeWrappers()
.forEach((wrapper) => {
const shape = wrapper.getShape();
const potentials = shape.potentials();
const result = this.collisions.createResult();
for (const p of potentials) {
/** In {@link ShapeWrapper} shape are link to gameObject*/
const potentialObject3D = p.getWrapper().getObject3D();
if (!potentialObject3D.isStatic()) continue;
if (shape.collides(p, result)) {
if (
!this.collisionsBuffer[child.uuid].includes(
potentialObject3D.uuid
)
)
this.collisionsBuffer[child.uuid].push(
potentialObject3D.uuid
);
}
}
});
}
});
// this.logCollisionBuffer('update collision buffer');
}
logCollisionBuffer(tag) {
console.log('**************************** LOG_COLLISION_BUFFER' + tag);
for (const id in this.collisionsBuffer) {
const bufferArray = this.collisionsBuffer[id];
if (bufferArray.length) {
console.log(
this.object3D.getObjectByProperty('uuid', id).name,
'collide with'
);
bufferArray.forEach((idc) => {
console.log(this.object3D.getObjectByProperty('uuid', idc).name);
});
}
}
console.log('****************************');
}
/**
* Remove a GameObject from the collision system
*
* @param {Object3D} object3D - object3D to remove
*/
unregisterObject3DCollision(object3D) {
object3D.traverse((child) => {
const comp = child.getComponent(Collider.Component.TYPE);
if (comp) {
comp
.getController()
.getShapeWrappers()
.forEach((wrapper) => {
wrapper.getShape().remove();
});
// Delete from buffer
delete this.collisionsBuffer[child.uuid];
for (const id in this.collisionsBuffer) {
const index = this.collisionsBuffer[id].indexOf(object3D.uuid);
if (index >= 0) this.collisionsBuffer[id].splice(index, 1); // Remove from the other
}
}
});
}
/**
* Add an object3D in context. If a parentUUID is specifed it will be add to its, root otherwise
*
* @param {Object3D} obj - object3D to add
* @param {string=} parentUUID - uuid of parent object3D
* @returns {Promise} - promise resolving when add
*/
addObject3D(obj, parentUUID = null) {
if (parentUUID) {
const parent = this.object3D.getObjectByProperty('uuid', parentUUID);
parent.add(obj);
} else {
this.object3D.add(obj);
}
return this.loadObject3D(obj);
}
/**
* Remove a object3D of context
*
* @param {string} uuid - uuid of the object3D to remove
*/
removeObject3D(uuid) {
const object3D = this.object3D.getObjectByProperty('uuid', uuid);
if (object3D) {
object3D.removeFromParent();
this.unregisterObject3DCollision(object3D);
} else {
console.warn('no object with uuid = ', uuid);
}
}
/**
* Register a custom event
*
* @param {string} eventID - Id of the event
* @param {Function} cb - Callback to be called when the event is dispatched
*/
on(eventID, cb) {
if (!this.listeners[eventID]) this.listeners[eventID] = [];
this.listeners[eventID].push(cb);
}
/**
* Dispatch custom event to listeners
*
* @param {string} eventID - Id of the event to dispatch
* @param {Array} args - Params to passed to listeners
*/
dispatch(eventID, args) {
if (this.listeners[eventID]) {
this.listeners[eventID].forEach(function (cb) {
cb(args);
});
}
}
/**
* Pass new commands to apply at the next step
*
* @param {Command[]} cmds - new commands to apply at the next step
*/
onCommands(cmds) {
cmds.forEach((cmd) => {
if (!this.commands.has(cmd.type)) this.commands.set(cmd.type, []);
this.commands.get(cmd.type).push(cmd.data);
});
}
/**
* Convert context root object3D to {@link State} and reset outdated attributes of all object3D
*
* @param {boolean} full - model of object3D with controllers should be export
* @returns {State} - current state of context
*/
toState(full = true) {
const result = new State(
new Object3D(this.object3D.toJSON(full)),
Date.now()
);
// Everything is not outdated yet
this.object3D.traverse(function (child) {
child.setOutdated(false);
});
return result;
}
/**
*
* @param {string} id - id of script
* @param {Object3D} [object3D=this.object3D] - object3D to traverse to find the game script (default is the root game object3D)
* @returns {ScriptBase|null} - first game script with id or null if none are found
*/
findGameScriptWithID(id, object3D = this.object3D) {
let result = null;
object3D.traverse(function (child) {
if (!child.isGameObject3D) return;
const gameScriptComp = child.getComponent(GameScript.Component.TYPE);
if (!gameScriptComp) return;
const scripts = gameScriptComp.getController().scripts;
if (scripts && scripts.has(id)) {
result = scripts.get(id);
return true;
}
return false;
});
return result;
}
};
/**
* Events triggered by context to {@link ScriptBase}
*
* @type {Object<string,string>}
*/
Context.EVENT = {
LOAD: 'load',
INIT: 'init',
TICK: 'tick',
ON_ENTER_COLLISION: 'onEnterCollision',
IS_COLLIDING: 'isColliding',
ON_LEAVE_COLLISION: 'onLeaveCollision',
};
/**
* @class
*/
const ScriptBase = class 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 object3D controllers initialized
*
* @returns {Promise=} - promise when object3D has loaded
*/
load() {
// return null by default
return null;
}
/**
* call after object3D load and register in collision system
*/
init() {}
/**
* call every step
*/
tick() {}
/**
* call if object3D is not static and first collide a static object3D (object3D must have {@link Collider})
*
* @param {Object3D} object3D - object3D collided
*/
// eslint-disable-next-line no-unused-vars
onEnterCollision(object3D) {}
/**
* call if object3D is not static and is colliding a static object3D (object3D must have {@link Collider})
*
* @param {Object3D} object3D - object3D collided
*/
// eslint-disable-next-line no-unused-vars
isColliding(object3D) {}
/**
* call if object3D is not static and was colliding a static object3D (object3D must have {@link Collider})
*
* @param {Object3D} object3D - object3D collided leaving
*/
// eslint-disable-next-line no-unused-vars
onLeaveCollision(object3D) {}
/**
*
* @param {string} type - type of command treated
* @param {CommandCallback} callback - callback to apply to command
*/
applyCommandCallbackOf(type, callback) {
if (!this.context.commands.has(type)) return;
for (
let index = this.context.commands.get(type).length - 1;
index >= 0;
index--
) {
if (callback(this.context.commands.get(type)[index])) {
// true mean command has been consumned
this.context.commands.get(type).splice(index, 1);
}
}
}
static get ID_SCRIPT() {
console.error(this.name);
throw new Error('this is abstract class you should override ID_SCRIPT');
}
static get IS_SCRIPTBASE() {
return true;
}
/**
*
* @returns {object} - default variables of this script
*/
static get DEFAULT_VARIABLES() {
return {};
}
};
module.exports = {
Context: Context,
ScriptBase: ScriptBase,
};