const core = require('engine/core');
const EventEmitter = require('engine/EventEmitter');
const { removeItems } = require('engine/utils/array');
const Entity = require('engine/Entity');
/**
* Game is the main hub for a game. A game made with LesserPanda
* is a combination of different `Games`(menu, shop, game, game-over .etc).
*
* @class Game
* @extends {EvenetEmitter}
*/
class Game extends EventEmitter {
/**
* @constructor
*/
constructor() {
super();
/**
* Desired FPS this scene should run
* @property {Number} desiredFPS
* @default 60
*/
this.desiredFPS = 60;
/**
* Map of added systems
* @property {Object} systems
*/
this.systems = {};
/**
* List of system updating order.
* Note: `systemOrder` should only be modified after systems added!
* @property {Array<String>} systemOrder
*/
this.systemOrder = [];
/**
* List of entities in the game world.
* @type {Array<Entity>}
*/
this.entities = [];
/**
* Holding all the named entities(has `name` been set).
* @type {Object}
*/
this.namedEntities = {};
/**
* Holding all the tagged entities(has `tag` been set).
* @type {Object}
*/
this.taggedEntities = {};
/**
* Caches update informations
* @type {Object}
* @private
*/
this.updateInfo = {
spiraling: 0,
last: -1,
realDelta: 0,
deltaTime: 0,
lastCount: 0,
step: 0,
slowStep: 0,
count: 0,
};
}
/**
* Called each single frame by engine core, support both idle and fixed update.
* Using a modified fixed update implementation from Phaser by @photonstorm
* @param {Number} timestamp Timestamp at this invoking
* @protected
*/
run(timestamp) {
let updateInfo = this.updateInfo;
if (updateInfo.last > 0) {
updateInfo.realDelta = timestamp - updateInfo.last;
}
updateInfo.last = timestamp;
// If the logic time is spiraling upwards, skip a frame entirely
if (updateInfo.spiraling > 1) {
// Reset the deltaTime accumulator which will cause all pending dropped frames to be permanently skipped
updateInfo.deltaTime = 0;
updateInfo.spiraling = 0;
}
else {
// Step size
updateInfo.step = 1000.0 / this.desiredFPS;
updateInfo.slowStep = updateInfo.step * core.speed;
updateInfo.slowStepSec = updateInfo.step * 0.001 * core.speed;
// Accumulate time until the step threshold is met or exceeded... up to a limit of 3 catch-up frames at step intervals
updateInfo.deltaTime += Math.max(Math.min(updateInfo.step * 3, updateInfo.realDelta), 0);
// Call the game update logic multiple times if necessary to "catch up" with dropped frames
// unless forceSingleUpdate is true
updateInfo.count = 0;
while (updateInfo.deltaTime >= updateInfo.step) {
updateInfo.deltaTime -= updateInfo.step;
// Fixed update
this.fixedUpdate(updateInfo.slowStep, updateInfo.slowStepSec);
updateInfo.count += 1;
}
// Detect spiraling (if the catch-up loop isn't fast enough, the number of iterations will increase constantly)
if (updateInfo.count > updateInfo.lastCount) {
updateInfo.spiraling += 1;
}
else if (updateInfo.count < updateInfo.lastCount) {
// Looks like it caught up successfully, reset the spiral alert counter
updateInfo.spiraling = 0;
}
updateInfo.lastCount = updateInfo.count;
}
// Idle update
this.update(updateInfo.realDelta, updateInfo.realDelta * 0.001);
}
/**
* Awake is called when this scene is activated.
* @method awake
* @memberof Game#
*/
awake() {
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].awake();
}
this.emit('awake');
}
/**
* Update is called every single frame.
* @method update
* @memberof Game#
* @param {Number} delta Delta time in millisecond
* @param {Number} deltaSec Delta time in second
*/
update(delta, deltaSec) {
let i, sys, ent;
// Update entities
for (i = 0; i < this.entities.length; i++) {
ent = this.entities[i];
if (!ent.isRemoved && ent.canEverTick) {
ent.update(delta, deltaSec);
}
if (ent.isRemoved) {
if (ent.CTOR.canBePooled) {
ent.CTOR.recycle(ent);
}
removeItems(this.entities, i--, 1);
}
}
// Update systems
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].update(delta, deltaSec);
}
this.emit('update', delta, deltaSec);
}
/**
* Fixed update is called in a constant frenquence decided by `desiredFPS`.
* @method fixedUpdate
* @memberof Game#
* @param {Number} delta Delta time in millisecond
* @param {Number} deltaSec Delta time in second
*/
fixedUpdate(delta, deltaSec) {
let i, sys, ent;
// Update entities
for (i = 0; i < this.entities.length; i++) {
ent = this.entities[i];
if (!ent.isRemoved && ent.canFixedTick) {
ent.fixedUpdate(delta, deltaSec);
}
if (ent.isRemoved) {
if (ent.CTOR.canBePooled) {
ent.CTOR.recycle(ent);
}
removeItems(this.entities, i--, 1);
}
}
// Update systems
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].fixedUpdate(delta, deltaSec);
}
this.emit('fixedUpdate', delta, deltaSec);
}
/**
* Freeze is called when this scene is deactivated(switched to another one)
* @method freeze
* @memberof Game#
*/
freeze() {
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].freeze();
}
this.emit('freeze');
}
/**
* Add a system instance to this game.
* @method addSystem
* @memberof Game#
* @param {System} sys System instance to add
* @return {Game} Self for chaining
*/
addSystem(sys) {
if (sys.name.length === 0) {
console.log(`System name "${sys.name}" is invalid!`);
return this;
}
if (this.systemOrder.indexOf(sys.name) >= 0) {
console.log(`System "${sys.name}" already added!`);
return this;
}
this.systems[sys.name] = sys;
this.systemOrder.push(sys.name);
this[`sys${sys.name}`] = sys;
sys.game = this;
return this;
}
/**
* System pause callback.
* @method pause
* @memberof Game#
*/
pause() {
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].onPause();
}
this.emit('pause');
}
/**
* System resume callback.
* @method resume
* @memberof Game#
*/
resume() {
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].onResume();
}
this.emit('resume');
}
/**
* Spawn an `Entity` into game world.
* @method spawnEntity
* @memberof Game#
* @param {Class} type Entity class
* @param {Number} x X coordinate
* @param {Number} y Y coordinate
* @param {String} layer Name of the layer to added to
* @param {Object} settings Instance settings
* @return {Entity} Entity instance
*/
spawnEntity(type, x, y, layer, settings) {
let ctor = type;
if (typeof(type) === 'string') {
ctor = Entity.types[type];
if (!ctor) {
console.log(`[WARNING]: Entity type "${type}" does not exist!`);
return undefined;
}
}
// Create entity instance
let ent;
if (ctor.canBePooled) {
ent = ctor.create(x, y, settings);
}
else {
ent = new ctor(x, y, settings);
ent.CTOR = ctor;
}
ent.layer = layer;
ent.game = this;
// Add to list
this.entities.push(ent);
// Add to name list
if (ent.name) {
this.namedEntities[ent.name] = ent;
}
// Add to tag list
if (ent._tag) {
if (!this.taggedEntities.hasOwnProperty(ent._tag)) {
this.taggedEntities[ent._tag] = [];
}
this.taggedEntities[ent._tag].push(ent);
}
// Notify systems
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].onEntitySpawn(ent);
}
// Entity is ready to rock :D
ent.ready();
return ent;
}
/**
* Remove an entity instance from this game
* @memberof Game#
* @param {Entity} ent Entity instance
*/
removeEntity(ent) {
// Mark as removed
ent.isRemoved = true;
// Remove from name list
if (ent.name) {
delete this.namedEntities[ent.name];
}
// Remove from tag list
if (ent._tag && this.taggedEntities.hasOwnProperty(ent._tag)) {
let idx = this.taggedEntities[ent._tag].indexOf(ent);
if (idx !== -1) {
removeItems(this.taggedEntities[ent._tag], idx, 1);
}
}
// Notify systems
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].onEntityRemove(ent);
}
}
/**
* Change tag of an entity instance
* @memberof Game#
* @param {Entity} ent Entity instance
* @param {String} tag Tag to change to
*/
changeEntityTag(ent, tag) {
// Remove from tag list
if (ent._tag && this.taggedEntities.hasOwnProperty(tag)) {
let idx = this.taggedEntities[tag].indexOf(ent);
if (idx !== -1) {
removeItems(this.taggedEntities[tag], idx, 1);
}
}
// Add to new tag group
if (!this.taggedEntities.hasOwnProperty(tag)) {
this.taggedEntities[tag] = [];
}
this.taggedEntities[tag].push(ent);
// Notify systems
let i, sys;
for (i = 0; i < this.systemOrder.length; i++) {
sys = this.systemOrder[i];
this.systems[sys] && this.systems[sys].onEntityTagChange(ent, tag);
}
// Change entity tag value
ent._tag = tag;
}
/**
* Find an entity with specific name.
* @memberof Game#
* @param {string} name Name of the entity
* @return {Entity} Entity with the name
*/
getEntityByName(name) {
return this.namedEntities[name];
}
/**
* Find entities with a specific tag.
* @memberof Game#
* @param {string} tag Tag of the entities
* @return {Array<Entity>|null} Entities with the tag
*/
getEntitiesByTag(tag) {
if (this.taggedEntities.hasOwnProperty(tag)) {
return this.taggedEntities[tag];
}
return null;
}
/**
* Resize callback.
* @method resize
* @memberof Game#
* @param {Number} w New window width
* @param {Number} h New window height
*/
resize(w, h) {} /* eslint no-unused-vars:0 */
}
/**
* @example <captain>Create a new game class</captain>
* const Game = require('engine/Game');
* class MyGame extends Game {}
*
* @example <captain>Switch to another game</captain>
* const core = require('engine/core');
* const MyGame = require('engine/MyGame');
* core.setGame(MyGame);
*
* @exports engine/Game
* @requires engine/core
* @requires engine/EventEmitter
* @requires engine/utils/math
*/
module.exports = Game;