@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
579 lines (443 loc) • 16.4 kB
JavaScript
/**
*
*/
import { PerspectiveCamera as ThreePerspectiveCamera } from 'three';
import { assert } from "../core/assert.js";
import Vector1 from "../core/geom/Vector1.js";
import { Localization } from "../core/localization/Localization.js";
import { ModuleRegistry } from "../core/model/ModuleRegistry.js";
import ObservedBoolean from "../core/model/ObservedBoolean.js";
import ConcurrentExecutor from '../core/process/executor/ConcurrentExecutor.js';
import EmptyView from "../view/elements/EmptyView.js";
import { ViewStack } from "../view/elements/navigation/ViewStack.js";
import { AssetManager } from './asset/AssetManager.js';
import Preloader from "./asset/preloader/Preloader.js";
import { MetricCollection } from "./development/performance/MetricCollection.js";
import { MetricStatistics } from "./development/performance/MetricStatistics.js";
import { PeriodicConsolePrinter } from "./development/performance/monitor/PeriodicConsolePrinter.js";
import { EntityManager } from "./ecs/EntityManager.js";
import { BinarySerializationRegistry } from "./ecs/storage/binary/BinarySerializationRegistry.js";
import { GraphicsEngine } from './graphics/GraphicsEngine.js';
import KeyboardDevice from "./input/devices/KeyboardDevice.js";
import { PointerDevice } from "./input/devices/PointerDevice.js";
import { StaticKnowledgeDatabase } from "./knowledge/database/StaticKnowledgeDatabase.js";
import { logger } from "./logging/GlobalLogger.js";
import { OptionGroup } from "./options/OptionGroup.js";
import { EnginePluginManager } from "./plugin/EnginePluginManager.js";
import SceneManager from "./scene/SceneManager.js";
import Ticker from "./simulation/Ticker.js";
import SoundEngine from './sound/SoundEngine.js';
import GUIEngine from './ui/GUIEngine.js';
class EngineSettings {
graphics_control_viewport_size = new ObservedBoolean(true);
simulation_speed = new Vector1(1);
input_mouse_sensitivity = new Vector1(5);
}
const METRIC_ID_FRAME = 'frame_delay';
const METRIC_ID_RENDER = 'render_time';
const METRIC_ID_SIMULATION = 'simulation_time';
class Engine {
#last_frame_timestamp = 0;
viewStack = new ViewStack();
/**
*
* Static database
* Can contain tables of structured data that the application often works with.
* Engine startup will load all tables ensuring that data is ready once startup finishes
* Use sparingly, this is not suitable for very large amounts of data as all the data will be in memory and deserialization will stall engine startup
* Example: inventory item table, monster table
* @readonly
* @type {StaticKnowledgeDatabase}
*/
staticKnowledge = new StaticKnowledgeDatabase();
/**
* @readonly
* @type {EnginePluginManager}
*/
plugins = new EnginePluginManager();
/**
* Main simulation ticker
* @readonly
* @type {Ticker}
*/
ticker = new Ticker();
/**
* Default executor, enables time-sharing concurrency
* @readonly
* @type {ConcurrentExecutor}
*/
executor = new ConcurrentExecutor(1, 10);
/**
*
* @param {EnginePlatform} platform
* @param {EntityManager} [entityManager]
* @param {boolean} [enableGraphics]
* @param {boolean} [enableAudio]
* @param {boolean} [debug]
* @constructor
*/
constructor(platform, {
entityManager,
enableGraphics = true,
enableAudio = true,
debug = true
} = {}) {
assert.defined(platform, 'platform');
/**
*
* @type {EnginePlatform}
*/
this.platform = platform;
this.staticKnowledge.validation_enabled = debug;
/**
*
* @type {MetricCollection}
*/
this.performance = new MetricCollection();
this.__using_external_entity_manager = entityManager !== undefined;
//setup entity component system
if (this.__using_external_entity_manager) {
// external entity manager provided
this.entityManager = entityManager;
} else {
this.entityManager = new EntityManager();
}
/**
*
* @type {AssetManager<Engine>}
*/
this.assetManager = new AssetManager({
context: this,
executor: this.executor
});
this.__performacne_monitor = new PeriodicConsolePrinter(15, () => {
const metrics = this.performance;
const stats = new MetricStatistics();
// RENDER
const m_render = metrics.get(METRIC_ID_RENDER);
m_render.computeStats(stats);
const v_render = (stats.mean * 1000).toFixed(2);
// SIMULATION
const m_sim = metrics.get(METRIC_ID_SIMULATION);
m_sim.computeStats(stats);
const v_sim = (stats.mean * 1000).toFixed(2);
// FPS
const m_frame = metrics.get(METRIC_ID_FRAME);
m_frame.computeStats(stats);
const v_fps = (1 / stats.mean).toFixed(2);
// clear stats
m_render.clear();
m_sim.clear();
m_frame.clear();
return `FPS: ${v_fps}, RENDER: ${v_render}ms, SIMULATION: ${v_sim}ms`;
});
if (!this.__using_external_entity_manager) {
// subscribe simulator to common ticker
this.ticker.onTick.add(timeDelta => {
const t0 = performance.now();
this.entityManager.simulate(timeDelta);
const t1 = performance.now();
const duration = (t1 - t0) / 1000;
// record frame time
this.performance.get(METRIC_ID_SIMULATION).record(duration);
});
}
// initialize performance metrics
this.performance.create({
name: METRIC_ID_RENDER
});
this.performance.create({
name: METRIC_ID_SIMULATION
});
this.performance.create({
name: METRIC_ID_FRAME
});
/**
*
* @type {OptionGroup}
*/
this.options = new OptionGroup("options");
/**
*
* @type {ModuleRegistry}
*/
this.moduleRegistry = new ModuleRegistry();
/**
* @readonly
* @type {BinarySerializationRegistry}
*/
this.binarySerializationRegistry = new BinarySerializationRegistry();
this.settings = new EngineSettings();
/**
* @deprecated use plugins instead
*/
this.services = {};
/**
*
* @type {Storage}
*/
this.storage = this.platform.getStorage();
this.localization = new Localization();
this.localization.setAssetManager(this.assetManager);
const innerWidth = window.innerWidth / 3;
const innerHeight = window.innerHeight / 3;
this.camera = new ThreePerspectiveCamera(45, innerWidth / innerHeight, 1, 50);
if (enableGraphics !== false) {
const graphicsEngine = new GraphicsEngine({
camera: this.camera,
debug
});
/**
*
* @type {GraphicsEngine}
*/
this.graphics = graphicsEngine;
try {
graphicsEngine.start();
} catch (e) {
logger.error(`Failed to start GraphicEngine: ${e}`);
}
} else {
logger.info('enableGraphics option is not set, no graphics engine will be created');
}
if (enableAudio !== false) {
//sound engine
const soundEngine = new SoundEngine();
soundEngine.volume = 1;
/**
*
* @type {SoundEngine}
*/
this.sound = soundEngine;
} else {
logger.info('enableAudio option is not set, no audio engine will be created');
}
/**
* Graphical User Interface engine
* @type {GUIEngine}
*/
this.gui = new GUIEngine();
/**
* @readonly
* @type {SceneManager}
*/
this.sceneManager = new SceneManager(this.entityManager, this.ticker.clock);
this.initializeViews();
this.devices = {
pointer: new PointerDevice(this.viewStack.el),
keyboard: new KeyboardDevice(this.viewStack.el)
};
//init level engine
this.devices.pointer.start();
this.devices.keyboard.start();
//process settings
this.initializeSettings();
logger.info("engine initialized");
/**
* Toggles GraphicsEngine rendering on and off
* @type {boolean}
*/
this.renderingEnabled = true;
this.plugins.initialize(this);
}
#initialize_audio() {
const sound = this.sound;
if (sound === undefined || sound === null) {
// no sound engine
return;
}
// hook up context resumption to input
if (this.devices.pointer !== undefined) {
this.devices.pointer.on.down.addOne(sound.resumeContext, sound);
this.devices.keyboard.on.down.addOne(sound.resumeContext, sound);
}
}
initializeViews() {
const gameView = new EmptyView();
gameView.addClass('game-view');
gameView.css({
left: 0,
top: 0,
position: "absolute",
pointerEvents: "none"
});
this.gameView = gameView;
this.viewStack.push(gameView, 'game-view');
if (this.graphics !== undefined) {
const viewport = this.graphics.viewport;
viewport.css({
pointerEvents: "auto"
});
gameView.addChild(viewport);
//bind size of renderer viewport to game view
viewport.bindSignal(gameView.size.onChanged, viewport.size.set.bind(viewport.size));
gameView.on.linked.add(function () {
viewport.size.copy(gameView.size);
});
}
}
initializeSettings() {
logger.info('Initializing engine settings...');
const engine = this;
function setViewportToWindowSize() {
// get parent element
let parentElement = engine.viewStack.el.parentElement;
while (parentElement !== null && parentElement.innerWidth === undefined) {
// traverse up until we find an element with defined dimensions
parentElement = parentElement.parentElement;
}
if (parentElement === null || parentElement === undefined) {
// no parent element, default to window
parentElement = window;
}
engine.viewStack.size.set(parentElement.innerWidth, parentElement.innerHeight);
}
this.settings.graphics_control_viewport_size.process(function (value) {
if (value) {
setViewportToWindowSize();
window.addEventListener("resize", setViewportToWindowSize, false);
} else {
window.removeEventListener("resize", setViewportToWindowSize);
}
});
logger.info('Engine settings initilized.');
}
benchmark() {
const duration = 2000;
let count = 0;
const t0 = Date.now();
let t1;
while (true) {
this.entityManager.simulate(0.0000000001);
t1 = Date.now();
if (t1 - t0 > duration) {
break;
}
count++;
}
//normalize
const elapsed = (t1 - t0) / 1000;
const rate = (count / elapsed);
return rate;
}
/**
* Returns preloader object
* @param {String} listURL
*/
loadAssetList(listURL) {
const preloader = new Preloader();
const assetManager = this.assetManager;
assetManager.get({
path: listURL, type: "json", callback: function (asset) {
preloader.addAll(asset.create());
preloader.load(assetManager);
}
});
return preloader;
}
render() {
assert.isInstanceOf(this, Engine, 'this', 'Engine');
const graphics = this.graphics;
if (graphics.autoDraw) {
// request redraw
graphics.needDraw = true;
}
if (graphics && this.renderingEnabled && graphics.needDraw) {
const metrics = this.performance;
const t0 = performance.now();
graphics.render();
const t1 = performance.now();
const frame_time = (t1 - t0) / 1000;
// record frame time
metrics.get(METRIC_ID_RENDER).record(frame_time);
}
}
/**
* Startup
* @returns {Promise}
*/
start() {
this.assetManager.startup();
this.#initialize_audio();
const promiseEntityManager = () => {
return new Promise((resolve, reject) => {
//initialize entity manager
this.entityManager.startup(resolve, reject);
});
}
this.__performacne_monitor.start();
return Promise.all([
this.sound.start()
.then(promiseEntityManager),
this.staticKnowledge.load(this.assetManager, this.executor),
this.gui.startup(this),
this.plugins.startup()
]).then(async () => {
this.#last_frame_timestamp = performance.now();
/**
* Starting the engine
*/
requestAnimationFrame(this.#animation_frame);
//start simulation
this.ticker.start({ maxTimeout: 200 });
//self.uiController.init(self);
//load options
await this.options.attachToStorage('lazykitty.komrade.options', this.storage);
logger.info('engine started');
}, function (e) {
logger.error(`Engine Failed to start: ${e}`);
});
}
/**
* Fired every animation frame via {@link requestAnimationFrame}
*/
#animation_frame = () => {
const t0 = this.#last_frame_timestamp;
const t1 = performance.now();
requestAnimationFrame(this.#animation_frame);
this.render();
const frame_delay = (t1 - t0) / 1000;
// swap variables
this.#last_frame_timestamp = t1;
// record metric
this.performance.get(METRIC_ID_FRAME).record(frame_delay);
}
/**
* Shutdown the engine, disposing all relevant resources
*/
async stop() {
this.__performacne_monitor.stop();
// stop the ticker
this.ticker.pause();
// shutdown entity manager
if (!this.__using_external_entity_manager) {
await new Promise((resolve, reject) => this.entityManager.shutdown(resolve, reject));
}
// shutdown plugins
await this.plugins.shutdown();
// stop gui
await this.gui.shutdown();
// TODO shutdown executor
await this.assetManager.shutdown();
}
exit() {
window.close();
}
/**
* @returns {Promise}
*/
requestExit() {
return this.gui.confirmTextDialog({
title: this.localization.getString('system_confirm_exit_to_system.title'),
text: this.localization.getString('system_confirm_exit_to_system.text')
}).then(() => {
this.exit();
});
}
}
/**
* @readonly
* @type {boolean}
*/
Engine.prototype.isEngine = true;
export default Engine;