UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

579 lines (443 loc) • 16.4 kB
/** * */ 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;