diff --git a/packages/melonjs/src/index.ts b/packages/melonjs/src/index.ts index ea05e0743..2e7f9b32f 100644 --- a/packages/melonjs/src/index.ts +++ b/packages/melonjs/src/index.ts @@ -43,7 +43,7 @@ import Trigger from "./renderable/trigger.js"; import UIBaseElement from "./renderable/ui/uibaseelement.ts"; import UISpriteElement from "./renderable/ui/uispriteelement.ts"; import UITextButton from "./renderable/ui/uitextbutton.ts"; -import Stage from "./state/stage.js"; +import Stage from "./state/stage.ts"; import state from "./state/state.ts"; import { boot } from "./system/bootstrap.ts"; import { DOMContentLoaded } from "./system/dom.ts"; diff --git a/packages/melonjs/src/loader/loadingscreen.js b/packages/melonjs/src/loader/loadingscreen.js index 6bcda9a0e..5744e6029 100644 --- a/packages/melonjs/src/loader/loadingscreen.js +++ b/packages/melonjs/src/loader/loadingscreen.js @@ -1,6 +1,6 @@ import Renderable from "./../renderable/renderable.js"; import Sprite from "./../renderable/sprite.js"; -import Stage from "./../state/stage.js"; +import Stage from "./../state/stage.ts"; import { LOADER_COMPLETE, LOADER_PROGRESS, diff --git a/packages/melonjs/src/state/stage.js b/packages/melonjs/src/state/stage.js deleted file mode 100644 index d963e9610..000000000 --- a/packages/melonjs/src/state/stage.js +++ /dev/null @@ -1,226 +0,0 @@ -import Camera2d from "./../camera/camera2d.ts"; -import { Color } from "./../math/color.ts"; -import { emit, STAGE_RESET } from "../system/event.ts"; - -// a default camera instance to use across all stages -let default_camera; - -// default stage settings -const default_settings = { - cameras: [], -}; - -/** - * a default "Stage" object. - * every "stage" object (title screen, credits, ingame, etc...) to be managed - * through the state manager must inherit from this base class. - * @category Application - * @see state - */ -export default class Stage { - /** - * @param {object} [settings] - The stage` parameters - * @param {Camera2d[]} [settings.cameras=[new me.Camera2d()]] - a list of cameras (experimental) - * @param {Function} [settings.onResetEvent] - called by the state manager when reseting the object - * @param {Function} [settings.onDestroyEvent] - called by the state manager before switching to another state - */ - constructor(settings) { - /** - * The list of active cameras in this stage. - * Cameras will be renderered based on this order defined in this list. - * Only the "default" camera will be resized when the window or canvas is resized. - * @public - * @type {Map} - * @name cameras - * @memberof Stage - */ - this.cameras = new Map(); - - /** - * The list of active lights in this stage. - * (Note: Canvas Renderering mode will only properly support one light per stage) - * @public - * @type {Map} - * @name lights - * @memberof Stage - * @see Light2d - * @see Stage.ambientLight - * @example - * // create a white spot light - * let whiteLight = new me.Light2d(0, 0, 140, "#fff", 0.7); - * // and add the light to this current stage - * this.lights.set("whiteLight", whiteLight); - * // set a dark ambient light - * this.ambientLight.parseCSS("#1117"); - * // make the light follow the mouse - * me.input.registerPointerEvent("pointermove", app.viewport, (event) => { - * whiteLight.centerOn(event.gameX, event.gameY); - * }); - */ - this.lights = new Map(); - - /** - * an ambient light that will be added to the stage rendering - * @public - * @type {Color} - * @name ambientLight - * @memberof Stage - * @default "#000000" - * @see Light2d - */ - this.ambientLight = new Color(0, 0, 0, 0); - - /** - * The given constructor options - * @public - * @name settings - * @memberof Stage - * @type {object} - */ - this.settings = Object.assign(default_settings, settings || {}); - } - - /** - * Object reset function - * @ignore - */ - reset(app, ...extraArgs) { - // add all defined cameras - this.settings.cameras.forEach((camera) => { - this.cameras.set(camera.name, camera); - }); - - // use the application's default camera if no "default" camera is defined - if (this.cameras.has("default") === false) { - if (typeof default_camera === "undefined" && app) { - const width = app.renderer.width; - const height = app.renderer.height; - default_camera = new Camera2d(0, 0, width, height); - } - if (typeof default_camera !== "undefined") { - this.cameras.set("default", default_camera); - } - } - - // reset the game - emit(STAGE_RESET, this); - - // call the onReset Function with the app reference and any extra args - this.onResetEvent.call(this, app, ...extraArgs); - } - - /** - * update function - * @name update - * @memberof Stage - * @ignore - * @param {number} dt - time since the last update in milliseconds. - * @returns {boolean} - */ - update(dt) { - let isDirty = false; - - // update the camera/viewport - // iterate through all cameras - this.cameras.forEach((camera) => { - if (camera.update(dt) === true) { - isDirty = true; - } - }); - - // update all lights - this.lights.forEach((light) => { - if (light.update(dt) === true) { - isDirty = true; - } - }); - - return isDirty; - } - - /** - * draw the current stage - * @name draw - * @memberof Stage - * @ignore - * @param {Renderer} renderer - the renderer object to draw with - * @param {World} world - the world object to draw - */ - draw(renderer, world) { - // iterate through all cameras - this.cameras.forEach((camera) => { - // render the root container - camera.draw(renderer, world); - - // render the ambient light - if (this.ambientLight.alpha !== 0) { - renderer.save(); - // iterate through all lights - this.lights.forEach((light) => { - // cut out all lights visible areas - renderer.setMask(light.getVisibleArea(), true); - }); - // fill the screen with the ambient color - renderer.setColor(this.ambientLight); - renderer.fillRect(0, 0, camera.width, camera.height); - // clear all masks - renderer.clearMask(); - renderer.restore(); - } - - // render all lights - this.lights.forEach((light) => { - light.preDraw(renderer, world); - light.draw(renderer, world); - light.postDraw(renderer, world); - }); - }); - } - - /** - * destroy function - * @ignore - */ - destroy(app) { - // clear all cameras - this.cameras.clear(); - // clear all lights - this.lights.forEach((light) => { - light.destroy(); - }); - this.lights.clear(); - // notify the object - this.onDestroyEvent(app); - } - - /** - * onResetEvent function
- * called by the state manager when reseting the object - * this is typically where you will load a level, add renderables, etc... - * @name onResetEvent - * @memberof Stage - * @param {Application} app - the current application instance - * @param {...*} [args] - optional arguments passed when switching state - * @see state#change - */ - onResetEvent(/* app, ...args */) { - // execute onResetEvent function if given through the constructor - if (typeof this.settings.onResetEvent === "function") { - this.settings.onResetEvent.apply(this, arguments); - } - } - - /** - * onDestroyEvent function
- * called by the state manager before switching to another state - * @name onDestroyEvent - * @memberof Stage - * @param {Application} [app] - the current application instance - */ - onDestroyEvent(app) { - // execute onDestroyEvent function if given through the constructor - if (typeof this.settings.onDestroyEvent === "function") { - this.settings.onDestroyEvent.call(this, app); - } - } -} diff --git a/packages/melonjs/src/state/stage.ts b/packages/melonjs/src/state/stage.ts new file mode 100644 index 000000000..e5f8f8a60 --- /dev/null +++ b/packages/melonjs/src/state/stage.ts @@ -0,0 +1,220 @@ +import type Application from "./../application/application.ts"; +import Camera2d from "./../camera/camera2d.ts"; +import { Color } from "./../math/color.ts"; +import type World from "./../physics/world.js"; +import type Light2d from "./../renderable/light2d.js"; +import { emit, STAGE_RESET } from "../system/event.ts"; +import type Renderer from "./../video/renderer.js"; + +interface StageSettings { + cameras: Camera2d[]; + onResetEvent?: (app: Application, ...args: unknown[]) => void; + onDestroyEvent?: (app: Application) => void; +} + +// a default camera instance to use across all stages +let default_camera: Camera2d | undefined; + +// default stage settings +const default_settings: StageSettings = { + cameras: [], +}; + +/** + * a default "Stage" object. + * every "stage" object (title screen, credits, ingame, etc...) to be managed + * through the state manager must inherit from this base class. + * @category Application + * @see state + */ +export default class Stage { + /** + * The list of active cameras in this stage. + * Cameras will be rendered based on this order defined in this list. + * Only the "default" camera will be resized when the window or canvas is resized. + */ + cameras: Map; + + /** + * The list of active lights in this stage. + * (Note: Canvas Rendering mode will only properly support one light per stage) + * @see Light2d + * @see Stage.ambientLight + * @example + * // create a white spot light + * const whiteLight = new Light2d(0, 0, 140, "#fff", 0.7); + * // and add the light to this current stage + * this.lights.set("whiteLight", whiteLight); + * // set a dark ambient light + * this.ambientLight.parseCSS("#1117"); + * // make the light follow the mouse + * input.registerPointerEvent("pointermove", app.viewport, (event) => { + * whiteLight.centerOn(event.gameX, event.gameY); + * }); + */ + lights: Map; + + /** + * an ambient light that will be added to the stage rendering + * @default "#000000" + * @see Light2d + */ + ambientLight: Color; + + /** + * The given constructor options + */ + settings: StageSettings; + + /** + * @param settings - The stage parameters + * @param [settings.cameras=[]] - a list of cameras (experimental) + * @param [settings.onResetEvent] - called by the state manager when reseting the object + * @param [settings.onDestroyEvent] - called by the state manager before switching to another state + */ + constructor(settings?: Partial) { + this.cameras = new Map(); + this.lights = new Map(); + this.ambientLight = new Color(0, 0, 0, 0); + this.settings = Object.assign({}, default_settings, settings || {}); + } + + /** + * Object reset function + * @ignore + */ + reset(app: Application, ...extraArgs: unknown[]): void { + // add all defined cameras + this.settings.cameras.forEach((camera) => { + this.cameras.set(camera.name, camera); + }); + + // use the application's default camera if no "default" camera is defined + if (!this.cameras.has("default")) { + if (typeof default_camera === "undefined" && app) { + const width = app.renderer.width; + const height = app.renderer.height; + default_camera = new Camera2d(0, 0, width, height); + } + if (typeof default_camera !== "undefined") { + this.cameras.set("default", default_camera); + } + } + + // reset the game + emit(STAGE_RESET, this); + + // call the onReset Function with the app reference and any extra args + this.onResetEvent(app, ...extraArgs); + } + + /** + * update function + * @ignore + * @param dt - time since the last update in milliseconds. + * @returns true if the stage needs to be redrawn + */ + update(dt: number): boolean { + let isDirty = false; + + // update the camera/viewport + // iterate through all cameras + this.cameras.forEach((camera) => { + if (camera.update(dt)) { + isDirty = true; + } + }); + + // update all lights + this.lights.forEach((light) => { + if (light.update()) { + isDirty = true; + } + }); + + return isDirty; + } + + /** + * draw the current stage + * @ignore + * @param renderer - the renderer object to draw with + * @param world - the world object to draw + */ + draw(renderer: Renderer, world: World): void { + // cast to any to access canvas/webgl renderer-specific methods + const r = renderer as any; + + // iterate through all cameras + this.cameras.forEach((camera) => { + // render the root container + camera.draw(renderer, world); + + // render the ambient light + if (this.ambientLight.alpha !== 0) { + r.save(); + // iterate through all lights + this.lights.forEach((light) => { + // cut out all lights visible areas + r.setMask(light.getVisibleArea(), true); + }); + // fill the screen with the ambient color + r.setColor(this.ambientLight); + r.fillRect(0, 0, camera.width, camera.height); + // clear all masks + r.clearMask(); + r.restore(); + } + + // render all lights + this.lights.forEach((light) => { + light.preDraw(r); + light.draw(r); + light.postDraw(r); + }); + }); + } + + /** + * destroy function + * @ignore + */ + destroy(app: Application): void { + // clear all cameras + this.cameras.clear(); + // clear all lights + this.lights.forEach((light) => { + light.destroy(); + }); + this.lights.clear(); + // notify the object + this.onDestroyEvent(app); + } + + /** + * onResetEvent function
+ * called by the state manager when resetting the object + * this is typically where you will load a level, add renderables, etc... + * @param app - the current application instance + * @param args - optional arguments passed when switching state + * @see state#change + */ + onResetEvent(app: Application, ...args: unknown[]): void { + // execute onResetEvent function if given through the constructor + if (typeof this.settings.onResetEvent === "function") { + this.settings.onResetEvent(app, ...args); + } + } + + /** + * onDestroyEvent function
+ * called by the state manager before switching to another state + * @param app - the current application instance + */ + onDestroyEvent(app: Application): void { + // execute onDestroyEvent function if given through the constructor + if (typeof this.settings.onDestroyEvent === "function") { + this.settings.onDestroyEvent(app); + } + } +} diff --git a/packages/melonjs/src/state/state.ts b/packages/melonjs/src/state/state.ts index 7a85ec3d3..ed4d36bc5 100644 --- a/packages/melonjs/src/state/state.ts +++ b/packages/melonjs/src/state/state.ts @@ -1,7 +1,7 @@ import type Application from "../application/application.ts"; import { pauseTrack, resumeTrack } from "./../audio/audio.ts"; import DefaultLoadingScreen from "./../loader/loadingscreen.js"; -import Stage from "./../state/stage.js"; +import Stage from "./../state/stage.ts"; import { BOOT, emit, @@ -18,10 +18,6 @@ import { } from "../system/event.ts"; import { defer } from "../utils/function.ts"; -/** - * @import {Color} from "./../math/color.ts"; - */ - interface StageEntry { stage: Stage; transition: boolean; @@ -225,10 +221,10 @@ const state = { /** * default state ID for user defined constants
* @example - * let STATE_INFO = me.state.USER + 0; - * let STATE_WARN = me.state.USER + 1; - * let STATE_ERROR = me.state.USER + 2; - * let STATE_CUTSCENE = me.state.USER + 3; + * const STATE_INFO = state.USER + 0; + * const STATE_WARN = state.USER + 1; + * const STATE_ERROR = state.USER + 2; + * const STATE_CUTSCENE = state.USER + 3; */ USER: 100 as const, @@ -341,41 +337,27 @@ const state = { * @param stage - Instantiated Stage to associate with state ID * @param [start = false] - if true the state will be changed immediately after adding it. * @example - * class MenuButton extends me.GUI_Object { - * onClick() { - * // Change to the PLAY state when the button is clicked - * me.state.change(me.state.PLAY); - * return true; - * } - * }; - * - * class MenuScreen extends me.Stage { - * onResetEvent() { + * class MenuScreen extends Stage { + * onResetEvent(app) { * // Load background image * app.world.addChild( - * new me.ImageLayer(0, 0, { + * new ImageLayer(0, 0, { * image : "bg", * z: 0 // z-index - * } - * ); - * - * // Add a button - * app.world.addChild( - * new MenuButton(350, 200, { "image" : "start" }), - * 1 // z-index + * }) * ); * * // Play music - * me.audio.playTrack("menu"); + * audio.playTrack("menu"); * } * * onDestroyEvent() { * // Stop music - * me.audio.stopTrack(); + * audio.stopTrack(); * } - * }; + * } * - * me.state.set(me.state.MENU, new MenuScreen()); + * state.set(state.MENU, new MenuScreen()); */ set(stateId: number, stage: Stage, start: boolean = false): void { if (!(stage instanceof Stage)) { @@ -444,7 +426,7 @@ const state = { * @example * // The onResetEvent method on the play screen will receive two args: * // "level_1" and the number 3 - * me.state.change(me.state.PLAY, "level_1", 3); + * state.change(state.PLAY, false, "level_1", 3); */ change( stateId: number, diff --git a/packages/melonjs/src/system/event.ts b/packages/melonjs/src/system/event.ts index cee61a4ab..f74a04ab8 100644 --- a/packages/melonjs/src/system/event.ts +++ b/packages/melonjs/src/system/event.ts @@ -6,7 +6,7 @@ import type Application from "../application/application.ts"; import Pointer from "../input/pointer.ts"; import { Vector2d } from "../math/vector2d.ts"; import { Draggable } from "../renderable/draggable.js"; -import Stage from "../state/stage.js"; +import type Stage from "../state/stage.ts"; import Renderer from "../video/renderer.js"; import { EventEmitter } from "./eventEmitter.js"; diff --git a/packages/melonjs/tests/state.spec.js b/packages/melonjs/tests/state.spec.js index 9accab6e0..b3a36dcd3 100644 --- a/packages/melonjs/tests/state.spec.js +++ b/packages/melonjs/tests/state.spec.js @@ -107,7 +107,70 @@ describe("state", () => { }); }); - // TODO: add tests for onResetEvent receiving the Application instance - // (requires fixing test state isolation — state.change is a no-op - // when the target state is already current from a previous test) + describe("Stage", () => { + it("should initialize with empty cameras and lights maps", () => { + const myStage = new Stage(); + expect(myStage.cameras).toBeInstanceOf(Map); + expect(myStage.lights).toBeInstanceOf(Map); + expect(myStage.cameras.size).toEqual(0); + expect(myStage.lights.size).toEqual(0); + }); + + it("should have a default ambient light with zero alpha", () => { + const myStage = new Stage(); + expect(myStage.ambientLight.alpha).toEqual(0); + }); + + it("should accept settings via constructor", () => { + const onReset = () => {}; + const onDestroy = () => {}; + const myStage = new Stage({ + onResetEvent: onReset, + onDestroyEvent: onDestroy, + }); + expect(myStage.settings.onResetEvent).toBe(onReset); + expect(myStage.settings.onDestroyEvent).toBe(onDestroy); + }); + + it("should call settings.onResetEvent from onResetEvent", () => { + let called = false; + let receivedApp = null; + const myStage = new Stage({ + onResetEvent: (app) => { + called = true; + receivedApp = app; + }, + }); + const fakeApp = { renderer: { width: 800, height: 600 } }; + myStage.onResetEvent(fakeApp); + expect(called).toEqual(true); + expect(receivedApp).toBe(fakeApp); + }); + + it("should call settings.onDestroyEvent from onDestroyEvent", () => { + let called = false; + const myStage = new Stage({ + onDestroyEvent: () => { + called = true; + }, + }); + myStage.onDestroyEvent(); + expect(called).toEqual(true); + }); + + it("should not throw when onResetEvent/onDestroyEvent have no callbacks", () => { + const myStage = new Stage(); + expect(() => { + myStage.onResetEvent(); + }).not.toThrow(); + expect(() => { + myStage.onDestroyEvent(); + }).not.toThrow(); + }); + + it("should return false from update when no cameras or lights", () => { + const myStage = new Stage(); + expect(myStage.update(16)).toEqual(false); + }); + }); });