From ea2850d3f11715242e6c67deae26e6dd89ff4fd5 Mon Sep 17 00:00:00 2001 From: Exoridus Date: Sat, 20 Jun 2026 06:11:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(core)!:=20simplify=20Scene=20model=20?= =?UTF-8?q?=E2=80=94=20remove=20scene=20stack=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.14 Phase 4A, PR2 of 2 (BREAKING). Completes the Scene-stack simplification that the UI-Core (PR1) unblocked. BREAKING CHANGES: - Removed the scene stack: SceneManager.pushScene/popScene/scenes + PushSceneOptions/PopSceneOptions are gone. One active scene; switch with app.scene.setScene(scene, { transition }) (fade preserved). - Removed Scene participation: SceneStackMode, scene.stackMode, SceneParticipationPolicy, scene.setParticipationPolicy. - New: scene.paused — skips the scene update() + systems while it keeps drawing (the freeze / former "modal" replacement). Overlays/HUDs/menus live on scene.ui (PR1). Migration: overlay/HUD scenes -> widgets on scene.ui; pause (push+pop) -> scene.paused + a scene.ui overlay. Examples (pause-blur, hud-overlay-scene, pause-and-resume) and 5 guides migrated; new "UI & widgets" guide added. --- .../application-scenes/hud-overlay-scene.js | 44 ++- .../application-scenes/hud-overlay-scene.ts | 51 ++-- .../application-scenes/pause-and-resume.js | 6 +- .../application-scenes/pause-and-resume.ts | 7 +- examples/examples.json | 2 +- examples/showcase/pause-blur.js | 68 ++--- examples/showcase/pause-blur.ts | 77 ++--- site/src/content/api/scene-manager.mdx | 28 +- site/src/content/api/scene.mdx | 9 +- .../guide/input/keyboard-and-actions.mdx | 4 +- site/src/content/guide/recipes/cinematics.mdx | 52 +++- .../src/content/guide/recipes/hud-overlay.mdx | 59 ++-- site/src/content/guide/recipes/pause-menu.mdx | 119 ++++---- .../guide/runtime/scenes-and-lifecycle.mdx | 102 +++++-- .../content/guide/runtime/ui-and-widgets.mdx | 151 ++++++++++ site/src/lib/guide-structure.ts | 12 + src/core/Scene.ts | 38 +-- src/core/SceneManager.ts | 263 ++++-------------- .../root-index-type-inventory.test.ts.snap | 4 - test/core/scene-manager.test.ts | 160 ++++------- 20 files changed, 601 insertions(+), 655 deletions(-) create mode 100644 site/src/content/guide/runtime/ui-and-widgets.mdx diff --git a/examples/application-scenes/hud-overlay-scene.js b/examples/application-scenes/hud-overlay-scene.js index 48c526f3..ed1a2727 100644 --- a/examples/application-scenes/hud-overlay-scene.js +++ b/examples/application-scenes/hud-overlay-scene.js @@ -1,5 +1,5 @@ // Auto-generated from hud-overlay-scene.ts — edit the .ts source, not this file. -import { Application, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Color, Graphics, Label, ProgressBar, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { width: 1280, @@ -8,18 +8,31 @@ const app = new Application({ sizingMode: 'fit', }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); +/** + * A screen-fixed HUD on `scene.ui` sits above the world automatically — no + * separate overlay scene or stack. The world (a spinning arc) is drawn from + * `scene.root`; the HUD (a label + a live health bar) lives on `scene.ui` and + * is auto-rendered on top. + */ class GameScene extends Scene { angle = 0; + time = 0; ring; + health; init() { this.ring = new Graphics(); + const title = new Label('HUD Overlay', { fontSize: 22 }); + title.anchorIn(this.ui, 'top-left', 18, 14); + this.ui.addChild(title); + this.health = new ProgressBar({ width: 240, height: 12, value: 1 }); + this.health.anchorIn(this.ui, 'top-left', 18, 48); + this.ui.addChild(this.health); } update(delta) { this.angle += delta.seconds * 90; + this.time += delta.seconds; + this.health.value = (Math.sin(this.time) + 1) / 2; } draw(context) { const { width, height } = this.app.canvas; @@ -31,25 +44,4 @@ class GameScene extends Scene { context.render(this.ring); } } -class HudScene extends Scene { - bar; - text; - init() { - this.bar = new Graphics(); - this.text = new Text('HUD Overlay', { fillColor: Color.white, fontSize: 22 }); - this.text.setPosition(18, 14); - } - draw(context) { - const { width } = this.app.canvas; - this.bar.clear(); - this.bar.fillColor = new Color(0, 0, 0, 0.45); - this.bar.drawRectangle(0, 0, width, 56); - this.bar.fillColor = new Color(80, 220, 120); - this.bar.drawRectangle(18, 40, 220, 8); - context.render(this.bar); - context.render(this.text); - } -} -const gameScene = new GameScene(); -const hudScene = new HudScene(); -void app.start(gameScene).then(() => app.scene.pushScene(hudScene, { mode: 'overlay' })); +void app.start(new GameScene()); diff --git a/examples/application-scenes/hud-overlay-scene.ts b/examples/application-scenes/hud-overlay-scene.ts index 7c3cddea..aa681e43 100644 --- a/examples/application-scenes/hud-overlay-scene.ts +++ b/examples/application-scenes/hud-overlay-scene.ts @@ -1,4 +1,4 @@ -import { Application, Color, Graphics, Scene, Text } from '@codexo/exojs'; +import { Application, Color, Graphics, Label, ProgressBar, Scene } from '@codexo/exojs'; const app = new Application({ canvas: { @@ -8,21 +8,36 @@ const app = new Application({ sizingMode: 'fit', }, clearColor: Color.black, - loader: { - basePath: 'assets/', - }, }); +/** + * A screen-fixed HUD on `scene.ui` sits above the world automatically — no + * separate overlay scene or stack. The world (a spinning arc) is drawn from + * `scene.root`; the HUD (a label + a live health bar) lives on `scene.ui` and + * is auto-rendered on top. + */ class GameScene extends Scene { private angle = 0; + private time = 0; private ring!: Graphics; + private health!: ProgressBar; override init(): void { this.ring = new Graphics(); + + const title = new Label('HUD Overlay', { fontSize: 22 }); + title.anchorIn(this.ui, 'top-left', 18, 14); + this.ui.addChild(title); + + this.health = new ProgressBar({ width: 240, height: 12, value: 1 }); + this.health.anchorIn(this.ui, 'top-left', 18, 48); + this.ui.addChild(this.health); } override update(delta): void { this.angle += delta.seconds * 90; + this.time += delta.seconds; + this.health.value = (Math.sin(this.time) + 1) / 2; } override draw(context): void { @@ -37,30 +52,4 @@ class GameScene extends Scene { } } -class HudScene extends Scene { - private bar!: Graphics; - private text!: Text; - - override init(): void { - this.bar = new Graphics(); - this.text = new Text('HUD Overlay', { fillColor: Color.white, fontSize: 22 }); - this.text.setPosition(18, 14); - } - - override draw(context): void { - const { width } = this.app.canvas; - - this.bar.clear(); - this.bar.fillColor = new Color(0, 0, 0, 0.45); - this.bar.drawRectangle(0, 0, width, 56); - this.bar.fillColor = new Color(80, 220, 120); - this.bar.drawRectangle(18, 40, 220, 8); - context.render(this.bar); - context.render(this.text); - } -} - -const gameScene = new GameScene(); -const hudScene = new HudScene(); - -void app.start(gameScene).then(() => app.scene.pushScene(hudScene, { mode: 'overlay' })); +void app.start(new GameScene()); diff --git a/examples/application-scenes/pause-and-resume.js b/examples/application-scenes/pause-and-resume.js index 51a8326b..b1a631b6 100644 --- a/examples/application-scenes/pause-and-resume.js +++ b/examples/application-scenes/pause-and-resume.js @@ -13,7 +13,6 @@ const app = new Application({ }, }); class PauseResumeScene extends Scene { - paused = false; sprite; label; async load(loader) { @@ -27,14 +26,13 @@ class PauseResumeScene extends Scene { this.label.setAnchor(0.5, 0); this.label.setPosition(width / 2, 16); this.inputs.onTrigger(Keyboard.Space, () => { + // scene.paused skips update() + systems each frame; drawing continues. this.paused = !this.paused; this.label.text = this.paused ? 'Paused (draw running)' : 'Running'; }); } update(delta) { - if (this.paused) { - return; - } + // Not called while paused — the SceneManager skips a paused scene's update(). this.sprite.rotate(delta.seconds * 180); } draw(context) { diff --git a/examples/application-scenes/pause-and-resume.ts b/examples/application-scenes/pause-and-resume.ts index 55efa901..0622360e 100644 --- a/examples/application-scenes/pause-and-resume.ts +++ b/examples/application-scenes/pause-and-resume.ts @@ -14,7 +14,6 @@ const app = new Application({ }); class PauseResumeScene extends Scene { - private paused = false; private sprite!: Sprite; private label!: Text; @@ -33,16 +32,14 @@ class PauseResumeScene extends Scene { this.label.setPosition(width / 2, 16); this.inputs.onTrigger(Keyboard.Space, () => { + // scene.paused skips update() + systems each frame; drawing continues. this.paused = !this.paused; this.label.text = this.paused ? 'Paused (draw running)' : 'Running'; }); } override update(delta): void { - if (this.paused) { - return; - } - + // Not called while paused — the SceneManager skips a paused scene's update(). this.sprite.rotate(delta.seconds * 180); } diff --git a/examples/examples.json b/examples/examples.json index cf820f3a..4f927645 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -37,7 +37,7 @@ "path": "application-scenes/hud-overlay-scene.js", "language": "typescript", "title": "Hud Overlay Scene", - "description": "Layer a fixed-position HUD Scene on top of a world Scene using separate cameras and render priorities.", + "description": "Build a screen-fixed HUD on scene.ui — a Label and a live ProgressBar auto-rendered above the world, with no separate scene or stack.", "backend": "core", "tags": [ "hud", diff --git a/examples/showcase/pause-blur.js b/examples/showcase/pause-blur.js index 2cde6266..a40a988f 100644 --- a/examples/showcase/pause-blur.js +++ b/examples/showcase/pause-blur.js @@ -1,5 +1,5 @@ // Auto-generated from pause-blur.ts — edit the .ts source, not this file. -import { Application, BlurFilter, Color, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, BlurFilter, Color, Keyboard, Label, Panel, Scene, Sprite, Texture } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ canvas: { @@ -15,9 +15,18 @@ const app = new Application({ }); const PAUSE_BLUR_RADIUS = 6; const PAUSE_FADE_SECONDS = 0.35; +/** + * Pause without a scene stack: a pause overlay lives on `scene.ui` (always + * above the world) and is toggled together with `scene.paused`, which skips the + * scene's `update` + systems while it keeps drawing. The blur tween runs on the + * app-level TweenManager, so it still animates while the scene is frozen. + */ class GameScene extends Scene { sprite; time = 0; + blur = new BlurFilter({ radius: 0, quality: 2 }); + pausePanel; + pauseLabel; hud; async load(loader) { await loader.load(Texture, { ship: 'image/ship-a.png' }); @@ -26,18 +35,24 @@ class GameScene extends Scene { const { width, height } = this.app.canvas; this.sprite = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(2).setPosition(width / 2, height / 2); this.addChild(this.sprite); + // Pause overlay on the UI layer, hidden until paused. + this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18, color: new Color(0, 0, 0, 0.6) }); + this.pausePanel.anchorIn(this.ui, 'center'); + this.pausePanel.visible = false; + this.ui.addChild(this.pausePanel); + this.pauseLabel = new Label('PAUSED', { fontSize: 56, fontWeight: 'bold' }); + this.pauseLabel.anchorIn(this.ui, 'center'); + this.pauseLabel.visible = false; + this.ui.addChild(this.pauseLabel); this.hud = mountControls({ title: 'Pause Blur', controls: [{ keys: 'Esc', action: 'pause / resume' }], hint: 'Press Esc to pause — the scene blurs up behind the menu.', }); - this.inputs.onTrigger(Keyboard.Escape, async () => { - if (pauseScene.app !== null) - return; - await this.app.scene.pushScene(pauseScene, { mode: 'overlay' }); - }); + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); } update(delta) { + // Not called while paused — the SceneManager skips update() + systems. this.time += delta.seconds; this.sprite.setRotation(this.time * 80); } @@ -45,33 +60,22 @@ class GameScene extends Scene { context.backend.clear(new Color(20, 24, 34)); context.render(this.root); } -} -class PauseScene extends Scene { - blur; - text; - init() { - const { width, height } = this.app.canvas; - // Start fully sharp and tween the radius up so the blur genuinely fades - // in rather than snapping on. The global TweenManager keeps ticking while - // this overlay scene is on the stack. - this.blur = new BlurFilter({ radius: 0, quality: 2 }); - gameScene.root.filters = [this.blur]; - this.tweens.create(this.blur).to({ radius: PAUSE_BLUR_RADIUS }, PAUSE_FADE_SECONDS).start(); - this.text = new Text('PAUSED', { fillColor: Color.white, fontSize: 64, fontWeight: 'bold', align: 'center' }); - this.text.setAnchor(0.5, 0.5); - this.text.setPosition(width / 2, height / 2); - this.inputs.onTrigger(Keyboard.Escape, async () => { - await this.app.scene.popScene(); - }); - } - draw(context) { - context.render(this.text); - } destroy() { - gameScene.root.clearFilters(); + this.root.clearFilters(); super.destroy(); } + togglePause() { + this.paused = !this.paused; + this.pausePanel.visible = this.paused; + this.pauseLabel.visible = this.paused; + if (this.paused) { + this.blur.radius = 0; + this.root.filters = [this.blur]; + this.tweens.create(this.blur).to({ radius: PAUSE_BLUR_RADIUS }, PAUSE_FADE_SECONDS).start(); + } + else { + this.root.clearFilters(); + } + } } -const gameScene = new GameScene(); -const pauseScene = new PauseScene(); -void app.start(gameScene); +void app.start(new GameScene()); diff --git a/examples/showcase/pause-blur.ts b/examples/showcase/pause-blur.ts index 15047513..7ae37b1c 100644 --- a/examples/showcase/pause-blur.ts +++ b/examples/showcase/pause-blur.ts @@ -1,4 +1,4 @@ -import { Application, BlurFilter, Color, Keyboard, Scene, Sprite, Text, Texture } from '@codexo/exojs'; +import { Application, BlurFilter, Color, Keyboard, Label, Panel, Scene, Sprite, Texture } from '@codexo/exojs'; import { mountControls } from '@examples/runtime'; const app = new Application({ @@ -17,9 +17,18 @@ const app = new Application({ const PAUSE_BLUR_RADIUS = 6; const PAUSE_FADE_SECONDS = 0.35; +/** + * Pause without a scene stack: a pause overlay lives on `scene.ui` (always + * above the world) and is toggled together with `scene.paused`, which skips the + * scene's `update` + systems while it keeps drawing. The blur tween runs on the + * app-level TweenManager, so it still animates while the scene is frozen. + */ class GameScene extends Scene { private sprite!: Sprite; private time = 0; + private readonly blur = new BlurFilter({ radius: 0, quality: 2 }); + private pausePanel!: Panel; + private pauseLabel!: Label; private hud!: ReturnType; override async load(loader): Promise { @@ -32,19 +41,28 @@ class GameScene extends Scene { this.sprite = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(2).setPosition(width / 2, height / 2); this.addChild(this.sprite); + // Pause overlay on the UI layer, hidden until paused. + this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18, color: new Color(0, 0, 0, 0.6) }); + this.pausePanel.anchorIn(this.ui, 'center'); + this.pausePanel.visible = false; + this.ui.addChild(this.pausePanel); + + this.pauseLabel = new Label('PAUSED', { fontSize: 56, fontWeight: 'bold' }); + this.pauseLabel.anchorIn(this.ui, 'center'); + this.pauseLabel.visible = false; + this.ui.addChild(this.pauseLabel); + this.hud = mountControls({ title: 'Pause Blur', controls: [{ keys: 'Esc', action: 'pause / resume' }], hint: 'Press Esc to pause — the scene blurs up behind the menu.', }); - this.inputs.onTrigger(Keyboard.Escape, async () => { - if (pauseScene.app !== null) return; - await this.app.scene.pushScene(pauseScene, { mode: 'overlay' }); - }); + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); } override update(delta): void { + // Not called while paused — the SceneManager skips update() + systems. this.time += delta.seconds; this.sprite.setRotation(this.time * 80); } @@ -53,42 +71,25 @@ class GameScene extends Scene { context.backend.clear(new Color(20, 24, 34)); context.render(this.root); } -} - -class PauseScene extends Scene { - private blur!: BlurFilter; - private text!: Text; - - override init(): void { - const { width, height } = this.app.canvas; - - // Start fully sharp and tween the radius up so the blur genuinely fades - // in rather than snapping on. The global TweenManager keeps ticking while - // this overlay scene is on the stack. - this.blur = new BlurFilter({ radius: 0, quality: 2 }); - gameScene.root.filters = [this.blur]; - this.tweens.create(this.blur).to({ radius: PAUSE_BLUR_RADIUS }, PAUSE_FADE_SECONDS).start(); - - this.text = new Text('PAUSED', { fillColor: Color.white, fontSize: 64, fontWeight: 'bold', align: 'center' }); - this.text.setAnchor(0.5, 0.5); - this.text.setPosition(width / 2, height / 2); - - this.inputs.onTrigger(Keyboard.Escape, async () => { - await this.app.scene.popScene(); - }); - } - - override draw(context): void { - context.render(this.text); - } override destroy(): void { - gameScene.root.clearFilters(); + this.root.clearFilters(); super.destroy(); } -} -const gameScene = new GameScene(); -const pauseScene = new PauseScene(); + private togglePause(): void { + this.paused = !this.paused; + this.pausePanel.visible = this.paused; + this.pauseLabel.visible = this.paused; + + if (this.paused) { + this.blur.radius = 0; + this.root.filters = [this.blur]; + this.tweens.create(this.blur).to({ radius: PAUSE_BLUR_RADIUS }, PAUSE_FADE_SECONDS).start(); + } else { + this.root.clearFilters(); + } + } +} -void app.start(gameScene); +void app.start(new GameScene()); diff --git a/site/src/content/api/scene-manager.mdx b/site/src/content/api/scene-manager.mdx index 37d25d02..2d0e4891 100644 --- a/site/src/content/api/scene-manager.mdx +++ b/site/src/content/api/scene-manager.mdx @@ -1,30 +1,29 @@ --- title: "SceneManager" -description: "Stack-based scene controller owned by Application. Maintains an ordered stack of Scene instances, each tagged with its participation policy (SceneStackMode). Scenes higher on the stack overlay scenes lower; the policy of each scene determines whether scenes below continue to update / render. Use SceneManager.setScene to replace the entire stack with one scene, SceneManager.pushScene to overlay a new scene on top, and SceneManager.popScene to remove the topmost. All three accept an optional fade transition." +description: "Single-active-scene controller owned by Application. Holds at most one active Scene (the current \"screen\"); SceneManager.setScene switches to a new scene — unloading the previous one — with an optional fade transition. There is no scene stack: overlays, HUDs and pause menus belong on Scene.ui (the screen-fixed UI layer), and \"freeze the game but keep drawing it\" is `scene.paused = true` (skips the scene's `update` + systems while it keeps rendering)." symbol: "SceneManager" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 12 +memberCount: 9 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/core/SceneManager.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/SceneManager.ts#L96" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/SceneManager.ts#L70" --- ## Import `import { SceneManager } from '@codexo/exojs'` -Stack-based scene controller owned by Application. Maintains an -ordered stack of Scene instances, each tagged with its -participation policy (SceneStackMode). -Scenes higher on the stack overlay scenes lower; the policy of each -scene determines whether scenes below continue to update / render. +Single-active-scene controller owned by Application. Holds at most one +active Scene (the current "screen"); SceneManager.setScene +switches to a new scene — unloading the previous one — with an optional fade +transition. -Use SceneManager.setScene to replace the entire stack with one -scene, SceneManager.pushScene to overlay a new scene on top, and -SceneManager.popScene to remove the topmost. All three accept an -optional fade transition. +There is no scene stack: overlays, HUDs and pause menus belong on +Scene.ui (the screen-fixed UI layer), and "freeze the game but keep +drawing it" is `scene.paused = true` (skips the scene's `update` + systems +while it keeps rendering). ## Constructors @@ -33,15 +32,12 @@ optional fade transition. ## Methods - `destroy(): void` -- `popScene(options: PopSceneOptions): Promise` -- `pushScene(scene: Scene, options: PushSceneOptions): Promise` - `setScene(scene: Scene | null, options: SetSceneOptions): Promise` - `update(delta: Time): this` ## Properties - `currentScene: Scene | null` -- `scenes: readonly Scene[]` ## Events @@ -52,4 +48,4 @@ optional fade transition. ## Source -[src/core/SceneManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/SceneManager.ts#L96) +[src/core/SceneManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/SceneManager.ts#L70) diff --git a/site/src/content/api/scene.mdx b/site/src/content/api/scene.mdx index c92b6bf3..8f8a5dbb 100644 --- a/site/src/content/api/scene.mdx +++ b/site/src/content/api/scene.mdx @@ -5,11 +5,11 @@ symbol: "Scene" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 18 +memberCount: 17 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/core/Scene.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L119" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L106" --- ## Import @@ -44,21 +44,20 @@ For one-off scenes, an anonymous subclass works just as well: - `init(_loader: Loader): void | Promise` - `load(_loader: Loader): void | Promise` - `removeChild(child: RenderNode): this` -- `setParticipationPolicy(policy: SceneParticipationPolicy): this` - `track(item: T): T` - `unload(_loader: Loader): void | Promise` - `update(_delta: Time): void` ## Properties +- `paused: boolean` - `app: Application | null` - `inputs: SceneInputs` - `root: Container` -- `stackMode: SceneStackMode` - `systems: SystemRegistry` - `tweens: SceneTweens` - `ui: UIRoot` ## Source -[src/core/Scene.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L119) +[src/core/Scene.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L106) diff --git a/site/src/content/guide/input/keyboard-and-actions.mdx b/site/src/content/guide/input/keyboard-and-actions.mdx index 96d0d08a..f8f7621d 100644 --- a/site/src/content/guide/input/keyboard-and-actions.mdx +++ b/site/src/content/guide/input/keyboard-and-actions.mdx @@ -33,7 +33,7 @@ class GameScene extends Scene { }); this.inputs.onTrigger(Keyboard.Escape, () => { - this.app.scene.pushScene(new PauseScene()); + this.togglePause(); }, { threshold: 200 }); } } @@ -297,7 +297,7 @@ init(loader) { update(delta) { if (this.actions.pause) { - this.app.scene.pushScene(new PauseScene()); + this.togglePause(); // sets this.paused + shows/hides the pause overlay on scene.ui this.actions.pause = 0; } diff --git a/site/src/content/guide/recipes/cinematics.mdx b/site/src/content/guide/recipes/cinematics.mdx index 5947abbd..d0eda7df 100644 --- a/site/src/content/guide/recipes/cinematics.mdx +++ b/site/src/content/guide/recipes/cinematics.mdx @@ -7,11 +7,11 @@ import ExamplePreview from '../../../components/ExamplePreview.astro'; # Cinematics -A cinematic (or cutscene) is a choreographed sequence where the camera moves, dialogue appears, characters animate, music swells, and the player's input is temporarily suspended — all driven by timing, not by gameplay logic. ExoJS does not ship a timeline or cutscene system, but you can build one from the primitives that already exist: tweens (for timed animation), `View` (for camera movement), `AudioStream` (for music), and scene-stack management (for input gating). +A cinematic (or cutscene) is a choreographed sequence where the camera moves, dialogue appears, characters animate, music swells, and the player's input is temporarily suspended — all driven by timing, not by gameplay logic. ExoJS does not ship a timeline or cutscene system, but you can build one from the primitives that already exist: tweens (for timed animation), `View` (for camera movement), `AudioStream` (for music), and `app.scene.setScene(...)` for transitioning between game states. ## Approach -A cinematic is a scene. It owns the sequence. Tweens drive every timed element — camera pans, title reveals, character entrances, music fades. Push the cinematic scene with `mode: 'opaque'` so it fully covers the game, and pop it when the sequence ends. +A cinematic is a scene. It owns the sequence. Tweens drive every timed element — camera pans, title reveals, character entrances, music fades. Navigate to the cinematic scene via `app.scene.setScene(new CinematicScene())` to fully replace the game scene while the cutscene plays, and call `app.scene.setScene(...)` again when the sequence ends to return to gameplay. The key technique: **tween chains and delays**. Each tween starts at a specific time offset. Together they form a timeline: @@ -103,45 +103,67 @@ Each tween operates independently — they don't need to know about each other. ## Gating input -Push the cinematic scene with `mode: 'opaque'` to hide and freeze the game while the cutscene plays: +Navigate to the cinematic scene from the game to fully replace the active scene while the cutscene plays: ```js -// From the game scene: -this.app.scene.pushScene(new CinematicScene(), { - mode: 'opaque', // game scene neither renders nor updates -}); +// From the game scene — switch to the cinematic: +this.app.scene.setScene(new CinematicScene()); ``` -`mode: 'opaque'` gives clean separation — the underlying game scene is invisible and frozen for the duration. When the cinematic ends, pop the scene: +Because the cinematic is now the only active scene, the game scene neither renders nor updates for the duration. When the cinematic ends, switch back: ```js -// At the end of the sequence — chain a tween to pop the scene: +// At the end of the sequence — chain a tween to transition back to the game: this.app.tweens.create(this.barSize) .to({ v: 70 }, 0.6) .delay(3.5) // start closing bars after the sequence plays .onComplete(() => { - this.app.scene.popScene(); + this.app.scene.setScene(new GameScene(), { transition: { type: 'fade' } }); }) .start(); ``` -The closing shutter bars animate over the scene, then `popScene()` returns to the game. +The closing shutter bars animate over the scene, then `setScene(...)` transitions back to the game with a fade. + +## Letterbox overlays on `scene.ui` + +If you want the shutter bars or subtitle text to stay in screen space and be immune to camera transforms, put them on `scene.ui` instead of rendering them in `draw`: + +```js +init(loader) { + // ... main cinematic setup ... + + // Letterbox bars as UI nodes — always screen-aligned. + this.topBar = new Panel({ width: 1280, height: 0, color: Color.black }); + this.topBar.anchorIn(this.ui, 'top-left'); + this.ui.addChild(this.topBar); + + this.bottomBar = new Panel({ width: 1280, height: 0, color: Color.black }); + this.bottomBar.anchorIn(this.ui, 'bottom-left'); + this.ui.addChild(this.bottomBar); + + this.app.tweens.create(this.topBar).to({ height: 70 }, 0.6).start(); + this.app.tweens.create(this.bottomBar).to({ height: 70 }, 0.6).start(); +} +``` + +Use `scene.ui` for any overlay that must remain fixed to the screen — subtitles, dialogue boxes, skip prompts. ## Skip support -Add a skip mechanism — press any confirm key (Space, Enter, gamepad Start) to jump to the end and pop: +Add a skip mechanism — press any confirm key (Space, Enter, gamepad Start) to jump to the end and switch scenes: ```js init(loader) { // ... cinematic setup ... this.inputs.onTrigger(Keyboard.Space, () => { - this.app.scene.popScene(); + this.app.scene.setScene(new GameScene()); }); const pad = this.app.input.getGamepad(0); pad.onTrigger(GamepadButton.Start, () => { - this.app.scene.popScene(); + this.app.scene.setScene(new GameScene()); }); } ``` @@ -155,7 +177,7 @@ this.inputs.onTrigger(Keyboard.Space, () => { this.musicVoice.volume = 0.85; this.boss.setScale(2.1, 2.1); // ... snap other properties ... - this.app.scene.popScene(); + this.app.scene.setScene(new GameScene()); }); ``` diff --git a/site/src/content/guide/recipes/hud-overlay.mdx b/site/src/content/guide/recipes/hud-overlay.mdx index a9fb755a..77434ec9 100644 --- a/site/src/content/guide/recipes/hud-overlay.mdx +++ b/site/src/content/guide/recipes/hud-overlay.mdx @@ -7,16 +7,25 @@ import ExamplePreview from '../../../components/ExamplePreview.astro'; # HUD overlay -A HUD (heads-up display) renders UI elements on top of the game world — health bars, score text, mini-maps, debug readouts — without those elements moving or scaling with the game camera. The recipe separates world-space content from screen-space content by using two scenes on the stack. +A HUD (heads-up display) renders UI elements on top of the game world — health bars, score text, mini-maps, debug readouts — without those elements moving or scaling with the game camera. The recipe separates world-space content from screen-space content using `scene.ui`, a screen-fixed layer that every scene owns and that is automatically rendered above the scene's world content. ## Approach -Push a second scene on top of the game scene. The game scene renders the world. The HUD scene renders only UI elements — positioned in screen-pixel coordinates, independent of any camera view. The HUD scene uses `'overlay'` mode so both scenes render and update. +Add HUD elements directly to `scene.ui` instead of creating a separate overlay scene. The world (game nodes, sprites, graphics) lives under `scene.root`; the HUD (labels, progress bars, panels) lives on `scene.ui` and is composited on top automatically — no extra scene or stack needed. ```js class GameScene extends Scene { init() { this._angle = 0; + + // HUD elements on scene.ui — screen-space, always on top. + this._scoreText = new Label('SCORE 1240', { fontSize: 22 }); + this._scoreText.anchorIn(this.ui, 'top-left', 18, 14); + this.ui.addChild(this._scoreText); + + this._health = new ProgressBar({ width: 220, height: 8, value: 1 }); + this._health.anchorIn(this.ui, 'top-left', 18, 48); + this.ui.addChild(this._health); } update(delta) { @@ -28,47 +37,28 @@ class GameScene extends Scene { // ... draw game world ... } } - -class HudScene extends Scene { - init() { - this._bar = new Graphics(); - this._text = new Text('SCORE 1240', { fill: 'white', fontSize: 22 }); - this._text.setPosition(18, 14); - } - - draw(context) { - this._bar.clear(); - this._bar.fillColor = new Color(0, 0, 0, 0.45); - this._bar.drawRectangle(0, 0, 800, 56); - this._bar.drawRectangle(18, 40, 220, 8); // health bar - context.render(this._bar); - context.render(this._text); - } -} ``` -Push the HUD scene after the game scene starts: +Start the scene as normal — `scene.ui` is rendered automatically: ```js const game = new GameScene(); -const hud = new HudScene(); - await app.start(game); -await app.scene.pushScene(hud, { mode: 'overlay' }); ``` -## Why two scenes, not draw-order +## Why `scene.ui`, not manual draw-order -The alternative — rendering HUD elements after the world in a single scene's `draw` method — works for simple cases. The two-scene approach gives you: -- Independent `init`/`destroy` lifecycles — the HUD can be pushed, popped, and replaced without touching game code. -- Camera independence — the HUD scene's root is always in screen space, never affected by the game view. +The alternative — rendering HUD elements after the world in a single scene's `draw` method — works for simple cases. `scene.ui` gives you: +- **Screen-space anchoring** via `widget.anchorIn(this.ui, 'top-left' | 'center' | 'bottom-right' | …, offsetX, offsetY)` — widgets stay pinned to corners or the center regardless of canvas size. +- **Hit-testing in screen space** — UI widgets are clickable and focusable without needing camera-inverse transforms. +- **Independent lifecycle** — widgets added in `init` are automatically cleaned up when the scene is destroyed. ## Mini-map with a mask -A mini-map is a special HUD element: it needs a second `View` to render the world from a zoomed-out perspective into a `RenderTexture`, which is then displayed as a sprite in the corner of the HUD scene. The capture is a [`CallbackRenderPass`](/ExoJS/en/api/callback-render-pass/) with a `{ target }`, run once per frame from the game scene's pipeline: +A mini-map is a special HUD element: it needs a second `View` to render the world from a zoomed-out perspective into a `RenderTexture`, which is then displayed as a sprite in the corner of the HUD. The capture is a [`CallbackRenderPass`](/ExoJS/en/api/callback-render-pass/) with a `{ target }`, run once per frame from the scene's pipeline: ```js -// In GameScene: build the capture pass once, run it each frame from the pipeline. +// In GameScene.init: build the capture pass once. const miniRt = new RenderTexture(260, 260); const capturePass = new CallbackRenderPass( (context) => { @@ -78,12 +68,15 @@ const capturePass = new CallbackRenderPass( { target: miniRt }, // redirects the callback's output off-screen; clears the target first if you pass `clear` ); -// In HudScene: -const miniSprite = new Sprite(miniRt).setPosition(530, 20); +// Add the mini-map sprite to scene.ui so it stays in screen space. +const miniSprite = new Sprite(miniRt); +miniSprite.anchorIn(this.ui, 'top-right', -280, 20); +this.ui.addChild(miniSprite); + // Clamp to a circle with a mask const mask = new Graphics(); mask.fillColor = Color.white; -mask.drawCircle(660, 150, 120); +mask.drawCircle(130, 130, 120); miniSprite.mask = mask; ``` @@ -98,7 +91,7 @@ In-canvas HUD works well when the UI is tightly coupled to game state — health -A game scene with a rotating ring, a HUD scene with a top-bar overlay, and transparent input so the HUD is visible but doesn't block interaction. +A game scene with a rotating ring, a Label and a live ProgressBar on `scene.ui` — the HUD is always on top with no separate overlay scene. ## Where to go next diff --git a/site/src/content/guide/recipes/pause-menu.mdx b/site/src/content/guide/recipes/pause-menu.mdx index 07a2a17a..9835d88f 100644 --- a/site/src/content/guide/recipes/pause-menu.mdx +++ b/site/src/content/guide/recipes/pause-menu.mdx @@ -7,113 +7,104 @@ import ExamplePreview from '../../../components/ExamplePreview.astro'; # Pause menu -A pause menu can freeze the game, display a menu overlay, and resume cleanly when dismissed. In ExoJS, this is a scene-stack operation: push a `PauseScene` on top of the game scene with `'overlay'` mode so the game remains visible. +A pause menu can freeze the game, display a menu overlay, and resume cleanly when dismissed. In ExoJS, this is done entirely within a single scene: set `scene.paused = true` to freeze updates, show a pause overlay on `scene.ui`, and reverse both on resume. ## Approach -Two scenes, one reference. The game scene holds a reference so the pause scene can apply visual effects (like a blur) to the game scene's root before pushing itself. The pause scene pops itself on Esc to resume. +One scene, one UI layer. The pause overlay (a `Panel` + `Label`) lives on `scene.ui` and is hidden until paused. Toggling `scene.paused` stops `update()` and all scene systems while the scene keeps drawing — so the world stays visible behind the overlay. A `BlurFilter` on `scene.root` provides visual separation. ```js class GameScene extends Scene { init(loader) { this.player = new Sprite(loader.get(Texture, 'hero')); + this.addChild(this.player); // ... game setup ... - this.inputs.onTrigger(Keyboard.Escape, async () => { - if (pauseScene.app !== null) return; // already paused - await this.app.scene.pushScene(pauseScene, { - mode: 'overlay', - }); - }); + this.blur = new BlurFilter({ radius: 0, quality: 2 }); + + // Pause overlay on the UI layer, hidden until paused. + this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18, color: new Color(0, 0, 0, 0.6) }); + this.pausePanel.anchorIn(this.ui, 'center'); + this.pausePanel.visible = false; + this.ui.addChild(this.pausePanel); + + this.pauseLabel = new Label('PAUSED', { fontSize: 56, fontWeight: 'bold' }); + this.pauseLabel.anchorIn(this.ui, 'center'); + this.pauseLabel.visible = false; + this.ui.addChild(this.pauseLabel); + + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); } update(delta) { - // Game logic — this runs even while the pause scene is up, - // but you can gate it on a flag if you prefer to freeze. + // Not called while paused — SceneManager skips update() + systems. + // ... normal game logic ... } draw(context) { context.backend.clear(new Color(20, 24, 34)); context.render(this.root); } -} - -class PauseScene extends Scene { - init() { - // Apply blur to the game scene for visual separation - this._blur = new BlurFilter({ radius: 5, quality: 2 }); - gameScene.root.filters = [this._blur]; - - this._text = new Text('PAUSED', { fill: 'white', fontSize: 64 }); - this._text.setPosition(280, 250); - - this.inputs.onTrigger(Keyboard.Escape, async () => { - await this.app.scene.popScene(); - }); - } destroy() { - gameScene.root.clearFilters(); // remove blur on resume + this.root.clearFilters(); super.destroy(); } + + togglePause() { + this.paused = !this.paused; + this.pausePanel.visible = this.paused; + this.pauseLabel.visible = this.paused; + + if (this.paused) { + this.blur.radius = 0; + this.root.filters = [this.blur]; + this.tweens.create(this.blur).to({ radius: 6 }, 0.35).start(); + } else { + this.root.clearFilters(); + } + } } -const gameScene = new GameScene(); -const pauseScene = new PauseScene(); -await app.start(gameScene); +await app.start(new GameScene()); ``` -## Stack semantics +## How `scene.paused` works -`pushScene(pauseScene, { mode: 'overlay' })`: +Setting `scene.paused = true`: -- `mode: 'overlay'` — the game scene below continues to render, so the player sees gameplay behind the pause text. If you prefer the game to stop rendering, use `'opaque'` instead (but then the blur effect on the game root becomes invisible — you'd apply the blur to a captured `RenderTexture` of the last frame instead). +- **Freezes `update()`** — the SceneManager skips `update()` and all scene-level systems. No game logic runs. +- **Keeps drawing** — the scene still renders every frame, so the world remains visible behind the overlay. +- **Tweens on `app.tweens` still run** — tween managers at the application level are unaffected, so blur animations and UI transitions animate smoothly while the game is frozen. -Input bindings created via `this.inputs` on both the game scene and the pause scene are always active — use an explicit flag to gate gameplay logic while paused (shown in the example above with `if (pauseScene.app !== null) return`). - -If you want the game to truly freeze (no `update` calls), set the game scene's own `stackMode` or use a flag: - -```js -// In GameScene.update: -update(delta) { - if (pauseScene.app !== null) return; // freeze when paused - // ... normal update logic ... -} -``` - -This is simpler than changing stack semantics at runtime and gives you explicit control. +`scene.ui` widgets are always interactive regardless of `scene.paused`, so the pause panel's buttons remain clickable during the pause. ## Cleanup -The `destroy` hook on `PauseScene` removes the blur filter. Without this, the game scene would remain blurred after resume. `popScene()` calls `destroy` on the popped scene automatically. +The `destroy` hook clears the blur filter from `scene.root`. Without this, the filter array would linger if the scene is ever replaced via `app.scene.setScene(...)`. As a rule, any filter applied in `init` or `togglePause` should be cleaned up in `destroy`. + +## Switching scenes from the pause menu -If you need to capture the last game frame and blur it without leaving the game scene's filter array polluted, render the game scene root into a `RenderTexture` during `PauseScene.init`, apply the blur, and display the result. A one-pass `RenderNodePass` captures the root subtree off-screen; the blur is a plain `filter.apply` into a second texture: +If the pause menu offers options like "Return to main menu" or "Quit", use `app.scene.setScene(...)` to navigate away — it unloads the current scene and loads the next one, optionally with a fade transition: ```js -// Alternative: capture + blur without touching game scene's own filters -init() { - this._capture = new RenderTexture(800, 600); - this._blurred = new RenderTexture(800, 600); - this._blur = new BlurFilter({ radius: 5, quality: 2 }); - - // Capture the game's last frame: render the root subtree into _capture off-screen. - const context = this.app.rendering; // the Application's RenderingContext - new RenderNodePass(gameScene.root, { target: this._capture, clear: Color.black }).execute(context); - this._blur.apply(context.backend, this._capture, this._blurred); - - this._bg = new Sprite(this._blurred); - this._text = new Text('PAUSED', { fill: 'white', fontSize: 64 }); -} +// Inside a resume button's onClick handler: +resumeButton.onClick.add(() => { + this.togglePause(); // unpause first +}); + +// Inside a "main menu" button's onClick handler: +mainMenuButton.onClick.add(() => { + this.app.scene.setScene(new MainMenuScene(), { transition: { type: 'fade' } }); +}); ``` -`RenderNodePass` with `{ target }` redirects the node's render into the texture (and `clear` wipes it first). Here it is executed directly — a single off-screen capture — rather than added to a long-lived pipeline. This approach leaves the game scene's filters untouched and is more robust if multiple systems may add or remove filters from the game root. - ## Examples -A rotating sprite with a pause overlay — press Esc to freeze the game under a blurred snapshot, Esc again to resume. +A rotating sprite with a pause overlay — press Esc to freeze the game under a blurred background with a PAUSED label, Esc again to resume. ## Where to go next diff --git a/site/src/content/guide/runtime/scenes-and-lifecycle.mdx b/site/src/content/guide/runtime/scenes-and-lifecycle.mdx index bd66eb75..20698fbe 100644 --- a/site/src/content/guide/runtime/scenes-and-lifecycle.mdx +++ b/site/src/content/guide/runtime/scenes-and-lifecycle.mdx @@ -1,6 +1,6 @@ --- title: 'Scenes & lifecycle' -description: 'How scenes split runtime work, compose into a stack, and the order their lifecycle hooks run in.' +description: 'How scenes split runtime work, switch with transitions, layer a UI on top, and the order their lifecycle hooks run in.' --- import ExamplePreview from '../../../components/ExamplePreview.astro'; @@ -10,7 +10,7 @@ import TryIt from '../../../components/TryIt.astro'; A [`Scene`](/ExoJS/en/api/scene/) is the unit of runtime work. It loads the resources it needs, builds the objects it draws, advances state each frame, and renders the result. Most game code lives inside scene subclasses. -The split between application and scene matters: the application is the long-lived host that owns the canvas, the renderer, the input system, and the frame loop. Scenes are the changeable layer on top — menus, levels, cutscenes, pause overlays. The application keeps running as you push, pop, and replace scenes. +The split between application and scene matters: the application is the long-lived host that owns the canvas, the renderer, the input system, and the frame loop. Scenes are the changeable layer on top — menus, levels, cutscenes. The application keeps running as you switch between scenes. ## Defining a scene @@ -52,39 +52,79 @@ From this point on, the application runs the scene's `update` and `draw` once pe ## Switching scenes -Most non-trivial projects have more than one scene. The application owns a scene manager (`app.scene`) that drives the active stack. Three operations cover the common cases: +Most non-trivial projects have more than one scene. The application owns a scene manager (`app.scene`) that drives the active scene. Use `setScene` to swap in a new scene: ```js // Replace the active scene with a new one await app.scene.setScene(new GameScene()); -// Stack a new scene on top (pause menu, dialog, modal) -await app.scene.pushScene(new PauseScene()); - -// Remove the top scene and return to the one below -await app.scene.popScene(); +// Replace with a fade transition +await app.scene.setScene(new GameScene(), { transition: { type: 'fade' } }); ``` -Calling `setScene` ends the current scene and starts the new one. `pushScene` keeps the underlying scene loaded and overlays the new one. `popScene` removes the top scene and resumes the one below. +`setScene` ends the current scene — running `unload` and `destroy` — and starts the new one. The optional `transition` argument adds a brief cross-fade so the cut is not jarring. + +For a typical app: a `MenuScene` calls `setScene(new GameScene())` when the player clicks Start. `app.scene.currentScene` always reflects the currently active scene. + +## Scene UI layer + +Every scene has a built-in `scene.ui` layer that sits screen-fixed above the scene's world content. Nodes added to `scene.ui` are always rendered on top — no explicit render call in `draw` required — and they are anchored relative to the canvas, not the world. + +```js +import { Label, ProgressBar, Scene } from '@codexo/exojs'; + +class GameScene extends Scene { + init() { + const title = new Label('Score: 0', { fontSize: 22 }); + title.anchorIn(this.ui, 'top-left', 18, 14); + this.ui.addChild(title); + + this.healthBar = new ProgressBar({ width: 240, height: 12, value: 1 }); + this.healthBar.anchorIn(this.ui, 'top-left', 18, 48); + this.ui.addChild(this.healthBar); + } +} +``` -For a typical app: a `MenuScene` calls `setScene(new GameScene())` when the player clicks Start. The game pushes `new PauseScene()` when Esc is hit, and pops it when the player resumes. +`anchorIn(this.ui, anchor, offsetX, offsetY)` positions a node relative to a named corner or edge of the UI container. Available anchors: `'top-left'`, `'top'`, `'top-right'`, `'left'`, `'center'`, `'right'`, `'bottom-left'`, `'bottom'`, `'bottom-right'`. The offsets are in pixels. -## How stacked scenes interact +Available UI widgets: `Panel`, `Button`, `Label`, `ProgressBar`, `Stack`. Wire up button interactions with `button.onClick.add(callback)`. -When more than one scene is on the stack, each scene declares how it composes with the scenes below it: +## Pausing and overlays -- `'overlay'` (default) — both render and update; the scene below stays active. -- `'modal'` — the scene below renders but does not update. Use for dialog boxes that should pause world physics. -- `'opaque'` — the scene below neither renders nor updates. Use when the top scene fully covers the viewport. +To freeze a scene without leaving it — the canonical pause menu — set `scene.paused = true`. The SceneManager skips `update()` and the scene's systems while paused, but the scene keeps drawing. Set it back to `false` to resume. -Set the policy when you push a scene, or change it later via `scene.stackMode`: +A pause overlay is just nodes on `scene.ui` that you show and hide together with `scene.paused`: ```js -const pause = new PauseScene(); -pause.stackMode = 'modal'; -await app.scene.pushScene(pause); +import { Keyboard, Label, Panel, Scene } from '@codexo/exojs'; + +class GameScene extends Scene { + init() { + // Pause overlay — hidden until paused + this.pausePanel = new Panel({ width: 360, height: 120, cornerRadius: 12 }); + this.pausePanel.anchorIn(this.ui, 'center'); + this.pausePanel.visible = false; + this.ui.addChild(this.pausePanel); + + this.pauseLabel = new Label('PAUSED', { fontSize: 48, fontWeight: 'bold' }); + this.pauseLabel.anchorIn(this.ui, 'center'); + this.pauseLabel.visible = false; + this.ui.addChild(this.pauseLabel); + + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); + } + + togglePause() { + this.paused = !this.paused; + this.pausePanel.visible = this.paused; + this.pauseLabel.visible = this.paused; + } +} ``` +Because `draw` still runs while paused, the world stays visible behind the overlay. Tweens and app-level systems that do not belong to the scene (for example, a blur tween running on `this.tweens`) continue animating while the scene is frozen. + ## The lifecycle hooks, in order A scene has a small set of hooks the engine calls in a defined order. Knowing which hook runs when keeps state setup clean and avoids subtle "this object is undefined here" bugs. @@ -99,9 +139,9 @@ Then, every frame while the scene is active: 3. `update(delta)` — advance state. `delta.seconds` is the elapsed time since the last frame. 4. `draw(context)` — render the current state. -When the scene is removed from the stack: +When the scene is replaced or the application shuts down: -5. `async unload(loader)` — release scene-private assets that aren't shared with another active scene. +5. `async unload(loader)` — release scene-private assets that aren't needed by the next scene. 6. `destroy()` — drop scene-graph references and cancel input bindings. You override the hooks you need. Empty hooks like `update` and `draw` do nothing by default, so there is no setup ceremony for scenes that only need one or two phases. Cleanup hooks are different: if you override `destroy`, keep the built-in cleanup path intact unless the API reference for your version says otherwise. @@ -138,7 +178,7 @@ init(loader) { } ``` -The `init` hook runs once per scene-start. If you push the scene again later it runs again on the new instance, not on the old one. +The `init` hook runs once per scene-start. If you start the scene again later it runs again on the new instance, not on the old one. ## update: per-frame logic @@ -192,8 +232,8 @@ init() { this.player.jump(); }); - this.inputs.onTrigger(Keyboard.Escape, async () => { - await this.app.scene.pushScene(new PauseScene()); + this.inputs.onTrigger(Keyboard.Escape, () => { + this.togglePause(); }); } ``` @@ -202,32 +242,34 @@ Bindings created on `this.inputs` are automatically disposed when the scene is d ## unload and destroy -The `unload` hook runs when the scene leaves the stack permanently. Use it to release assets the scene was holding that no other active scene needs. +The `unload` hook runs when the scene is replaced by `setScene` or when the application shuts down. Use it to release assets the scene was holding that no other scene needs. The `destroy` hook is the synchronous final step after `unload`. The default cleanup path disposes scene-owned bindings and graph references. Override only when you have additional cleanup, and keep the built-in cleanup behavior intact. -For overlay scenes (pause menus, dialogs) that you push and pop repeatedly, the default cleanup keeps things lean: you don't need to manually unload textures shared with the underlying game scene. - ## Examples -Switching between two scenes — a menu and a game — using the scene manager. +Switching between two scenes — a menu and a game — using `app.scene.setScene`. A walkthrough of every hook firing, in order, with on-screen logging. + + +A screen-fixed HUD on `scene.ui` — a label and a live health bar above the world, no separate overlay scene required. + -The `update` hook is gated; `draw` keeps running. Demonstrates that the loop continues even when scene state freezes. +`scene.paused` gates `update`; `draw` keeps running. A pause overlay on `scene.ui` is toggled in sync so the player sees a menu while the world freezes. The minimum lifecycle that produces motion: `init` builds the sprite, `update` rotates it, `draw` renders. diff --git a/site/src/content/guide/runtime/ui-and-widgets.mdx b/site/src/content/guide/runtime/ui-and-widgets.mdx new file mode 100644 index 00000000..ea6d44c7 --- /dev/null +++ b/site/src/content/guide/runtime/ui-and-widgets.mdx @@ -0,0 +1,151 @@ +--- +title: 'UI & widgets' +description: 'Build HUDs and menus on scene.ui with Panel, Button, Label, and ProgressBar widgets — anchoring, layout, clicks, and keyboard focus.' +--- + +import ExamplePreview from '../../../components/ExamplePreview.astro'; +import TryIt from '../../../components/TryIt.astro'; + +# UI & widgets + +Every scene owns a screen-fixed UI layer, [`scene.ui`](/ExoJS/en/api/uiroot/), that is rendered automatically on top of the world. It lives in screen space (origin top-left, `0..width` × `0..height`), so its contents never scroll or zoom with the camera. Add anything to it with `scene.ui.addChild(...)`. + +On top of that layer, ExoJS ships a small set of widgets — [`Panel`](/ExoJS/en/api/panel/), [`Button`](/ExoJS/en/api/button/), [`Label`](/ExoJS/en/api/label/), [`ProgressBar`](/ExoJS/en/api/progress-bar/), and a [`Stack`](/ExoJS/en/api/stack/) layout container — for HUDs, menus, and pause screens without hand-built hit-tests. + +## Building blocks + +Each widget takes an options object and exposes the handful of properties you tend to change at runtime: + +```js +import { Button, Label, Panel, ProgressBar } from '@codexo/exojs'; + +const score = new Label('Score: 0', { fontSize: 24 }); +score.text = 'Score: 120'; + +const health = new ProgressBar({ width: 240, height: 14, value: 1 }); +health.value = 0.6; // fill fraction, clamped to [0, 1] + +const panel = new Panel({ width: 280, height: 160, cornerRadius: 16 }); + +const start = new Button({ label: 'Start', width: 160, height: 44 }); +start.onClick.add(() => console.log('clicked')); +``` + +A `Label` wraps a runtime [`Text`](/ExoJS/en/api/text/), so it accepts the same style options (`fontSize`, `fillColor`, `align`, …). A `Panel` is a rounded background you can add children to. A `Button` is a clickable panel with a centered label and hover / pressed / disabled states. + +## Anchoring to the screen + +Widgets extend [`Widget`](/ExoJS/en/api/widget/), which adds an explicit layout size and screen-edge anchoring. `widget.anchorIn(scene.ui, anchor, offsetX, offsetY)` pins a widget to a corner or edge and re-applies the position whenever the canvas resizes: + +```js +import { Label, ProgressBar, Scene } from '@codexo/exojs'; + +class HudScene extends Scene { + init() { + const score = new Label('Score: 0', { fontSize: 24 }); + score.anchorIn(this.ui, 'top-left', 24, 20); // top-left, 24×20 px margin + this.ui.addChild(score); + + const health = new ProgressBar({ width: 240, height: 14, value: 1 }); + health.anchorIn(this.ui, 'bottom-right', -24, -24); // 24 px from the bottom-right + this.ui.addChild(health); + } +} +``` + +The anchor accepts `'top-left'`, `'top'`, `'top-right'`, `'left'`, `'center'`, `'right'`, `'bottom-left'`, `'bottom'`, and `'bottom-right'`. + +## Stacking widgets + +A `Stack` flows its children in a row or column with even spacing and sizes itself to fit. Use it for menus and button groups: + +```js +import { Button, Panel, Scene, Stack } from '@codexo/exojs'; + +class MenuScene extends Scene { + init() { + const menu = new Stack({ direction: 'column', spacing: 10, padding: 14 }); + menu.addItem(new Button({ label: 'Resume' })); + menu.addItem(new Button({ label: 'Restart' })); + menu.addItem(new Button({ label: 'Quit' })); + + const panel = new Panel(); + panel.setSize(menu.uiWidth, menu.uiHeight); + panel.addChild(menu); + panel.anchorIn(this.ui, 'center'); + this.ui.addChild(panel); + } +} +``` + +## Clicks and keyboard focus + +UI nodes are hit-tested in screen space *before* the world, so a `Button` on `scene.ui` is clickable even under a panned or zoomed camera. Listen to `button.onClick`: + +```js +import { Button, Scene } from '@codexo/exojs'; + +class GameScene extends Scene { + init() { + const pause = new Button({ label: 'Pause' }); + pause.anchorIn(this.ui, 'top-right', -16, 16); + pause.onClick.add(() => { + this.paused = !this.paused; + }); + this.ui.addChild(pause); + } +} +``` + +Widgets are also keyboard-focusable. Mark any node `focusable` and give it a `tabIndex`; it then receives focus and routed key events: + +```js +import { Button, Keyboard } from '@codexo/exojs'; + +const field = new Button({ label: 'Name' }); +field.focusable = true; +field.tabIndex = 1; // lower values are visited first +field.onFocus.add(() => { /* highlight */ }); +field.onKeyDown.add(event => { + if (event.channel === Keyboard.Enter) { /* submit */ } +}); +``` + +The per-application focus service, [`app.focus`](/ExoJS/en/api/focus-manager/), tracks the focused node, moves focus with `Tab` / `Shift+Tab`, activates a focused button on `Enter` / `Space`, and exposes `app.focus.focus(node)` to focus programmatically. + +## Pause overlays + +A pause menu is the canonical combination: freeze the world with `scene.paused = true` — which skips `update` and the scene's systems while it keeps drawing — and show an overlay built from widgets on `scene.ui`: + +```js +import { Keyboard, Label, Panel, Scene } from '@codexo/exojs'; + +class GameScene extends Scene { + init() { + this.pausePanel = new Panel({ width: 420, height: 140, cornerRadius: 18 }); + this.pausePanel.anchorIn(this.ui, 'center'); + this.pausePanel.visible = false; + this.ui.addChild(this.pausePanel); + + this.pauseLabel = new Label('PAUSED', { fontSize: 56 }); + this.pauseLabel.anchorIn(this.ui, 'center'); + this.pauseLabel.visible = false; + this.ui.addChild(this.pauseLabel); + + this.inputs.onTrigger(Keyboard.Escape, () => this.togglePause()); + } + + togglePause() { + this.paused = !this.paused; + this.pausePanel.visible = this.paused; + this.pauseLabel.visible = this.paused; + } +} +``` + + + + diff --git a/site/src/lib/guide-structure.ts b/site/src/lib/guide-structure.ts index 37d3c469..259bfa6d 100644 --- a/site/src/lib/guide-structure.ts +++ b/site/src/lib/guide-structure.ts @@ -205,6 +205,18 @@ const RAW_PARTS: ReadonlyArray = [ ], apiLinks: ['view', 'camera'], }, + { + slug: 'ui-and-widgets', + level: 'intermediate', + learningGoals: [ + 'build a screen-fixed HUD and menus on scene.ui', + 'compose Panel, Button, Label, and ProgressBar widgets', + 'anchor and stack widgets, and route clicks and keyboard focus', + ], + prerequisites: ['runtime/scenes-and-lifecycle'], + examples: ['ui/hud-and-widgets', 'application-scenes/hud-overlay-scene'], + apiLinks: ['uiroot', 'widget', 'button', 'panel', 'label', 'progress-bar', 'focus-manager'], + }, ], }, { diff --git a/src/core/Scene.ts b/src/core/Scene.ts index 21ba223f..fe07ce04 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -84,19 +84,6 @@ class SceneTweens implements Destroyable { } } -/** - * How a {@link Scene} composes with scenes already on the stack. - * - `'overlay'`: render on top, scenes below also render and update. - * - `'modal'`: render on top, scenes below render but do not update. - * - `'opaque'`: render on top, scenes below neither render nor update. - */ -export type SceneStackMode = 'overlay' | 'modal' | 'opaque'; - -/** Bag of overrides for {@link Scene.setParticipationPolicy}. */ -export interface SceneParticipationPolicy { - mode?: SceneStackMode; -} - /** * A scene's lifecycle host. Subclass to define scene behavior: * @@ -119,7 +106,14 @@ export interface SceneParticipationPolicy { export class Scene { protected _app: Application | null = null; protected readonly _root = new Container(); - protected _stackMode: SceneStackMode = 'overlay'; + + /** + * When `true`, the scene's `update` and systems are skipped each frame while + * it keeps drawing — the simple way to freeze gameplay behind a pause menu + * (show a panel on `scene.ui`, then set `scene.paused = true`). + */ + public paused = false; + private _inputs: SceneInputs | null = null; private _tweens: SceneTweens | null = null; private _systems: SystemRegistry | null = null; @@ -250,14 +244,6 @@ export class Scene { return this._ui; } - public get stackMode(): SceneStackMode { - return this._stackMode; - } - - public set stackMode(mode: SceneStackMode) { - this._stackMode = mode; - } - public addChild(child: RenderNode): this { this._root.addChild(child); @@ -270,14 +256,6 @@ export class Scene { return this; } - public setParticipationPolicy(policy: SceneParticipationPolicy): this { - if (policy.mode) { - this._stackMode = policy.mode; - } - - return this; - } - /** * Async asset preload hook. Called once before {@link Scene.init} the * first time the scene is pushed. Use the loader to register and diff --git a/src/core/SceneManager.ts b/src/core/SceneManager.ts index 24171267..2038c5d5 100644 --- a/src/core/SceneManager.ts +++ b/src/core/SceneManager.ts @@ -3,20 +3,10 @@ import type { RenderBackend } from '#rendering/RenderBackend'; import type { Application } from './Application'; import { Color } from './Color'; -import type { SceneParticipationPolicy, SceneStackMode } from './Scene'; import { Scene } from './Scene'; import { Signal } from './Signal'; import type { Time } from './Time'; -interface ResolvedSceneParticipationPolicy { - readonly mode: SceneStackMode; -} - -interface SceneStackEntry { - readonly scene: Scene; - readonly policy: ResolvedSceneParticipationPolicy; -} - /** * Fade-to-color scene transition. The screen fades to `color` (default black) * over `duration` ms (default 220), the scene change happens at full @@ -36,20 +26,6 @@ export interface SetSceneOptions { transition?: SceneTransition; } -/** - * Options passed to {@link SceneManager.pushScene}. Inherits - * {@link SceneParticipationPolicy} so the pushed scene's stack mode - * can be overridden at the call site without subclassing. - */ -export interface PushSceneOptions extends SceneParticipationPolicy { - transition?: SceneTransition; -} - -/** Options passed to {@link SceneManager.popScene}. */ -export interface PopSceneOptions { - transition?: SceneTransition; -} - interface ActiveFadeTransition { readonly type: 'fade'; readonly durationMs: number; @@ -81,29 +57,27 @@ const createOverlayMesh = (): TransitionOverlayMesh => const defaultFadeTransitionDuration = 220; /** - * Stack-based scene controller owned by {@link Application}. Maintains an - * ordered stack of {@link Scene} instances, each tagged with its - * participation policy ({@link SceneStackMode}). - * Scenes higher on the stack overlay scenes lower; the policy of each - * scene determines whether scenes below continue to update / render. - * - * Use {@link SceneManager.setScene} to replace the entire stack with one - * scene, {@link SceneManager.pushScene} to overlay a new scene on top, and - * {@link SceneManager.popScene} to remove the topmost. All three accept an - * optional fade transition. + * Single-active-scene controller owned by {@link Application}. Holds at most one + * active {@link Scene} (the current "screen"); {@link SceneManager.setScene} + * switches to a new scene — unloading the previous one — with an optional fade + * transition. * + * There is no scene stack: overlays, HUDs and pause menus belong on + * {@link Scene.ui} (the screen-fixed UI layer), and "freeze the game but keep + * drawing it" is `scene.paused = true` (skips the scene's `update` + systems + * while it keeps rendering). */ export class SceneManager { private readonly _app: Application; - private readonly _stack: SceneStackEntry[] = []; + private _activeScene: Scene | null = null; private readonly _transitionOverlay: TransitionOverlayMesh = createOverlayMesh(); private _transition: ActiveFadeTransition | null = null; - /** Fires whenever the topmost scene changes (push, pop, set, or clear). Payload is the new top, or `null` when the stack becomes empty. */ + /** Fires whenever the active scene changes (set or clear). Payload is the new scene, or `null` when cleared. */ public readonly onChangeScene = new Signal<[Scene | null]>(); - /** Fires after a scene's `init` resolves and it joins the stack. */ + /** Fires after a scene's `init` resolves and it becomes active. */ public readonly onStartScene = new Signal<[Scene]>(); - /** Fires once per frame for the topmost scene after its `update` ran. */ + /** Fires once per frame for the active scene after its `update` ran. */ public readonly onUpdateScene = new Signal<[Scene]>(); /** Fires just before a scene is unloaded (`unload` then `destroy`). */ public readonly onStopScene = new Signal<[Scene]>(); @@ -111,145 +85,80 @@ export class SceneManager { private readonly _asyncUpdateWarned = new WeakSet(); private readonly _asyncDrawWarned = new WeakSet(); - private readonly _updateScratch: Scene[] = []; - private readonly _drawScratch: Scene[] = []; - public constructor(app: Application) { this._app = app; } + /** The active scene, or `null` when none is set. */ public get currentScene(): Scene | null { - return this._stack.at(-1)?.scene ?? null; + return this._activeScene; } public set currentScene(scene: Scene | null) { void this.setScene(scene); } - public get scenes(): readonly Scene[] { - return this._stack.map(entry => entry.scene); - } - /** - * Replace the entire scene stack with `scene`, or clear it when `scene` - * is `null`. Existing scenes are unloaded in reverse order. If `scene` - * is already the topmost, only the scenes underneath are unloaded - * (no-op when it is also the only scene). - * - * Throws if `scene` is somewhere in the stack but not the top. + * Switch to `scene` (or clear to `null`), unloading the previously active + * scene. No-op when `scene` is already active. An optional fade transition + * runs the swap at full opacity. The new scene is loaded before the old one + * is torn down, so there is no blank frame between them. */ public async setScene(scene: Scene | null, options: SetSceneOptions = {}): Promise { await this._runWithTransition(async () => { - if (scene === null) { - await this._unloadAllScenes(); - this.onChangeScene.dispatch(null); - - return; - } - - if (this.currentScene === scene) { - if (this._stack.length > 1) { - await this._unloadCoveredScenes(); - } - + if (scene === this._activeScene) { return; } - if (this._stack.some(entry => entry.scene === scene)) { - throw new Error('Cannot set a scene that is already present in the scene stack.'); + if (scene !== null) { + await this._prepareScene(scene); } - const policy = this._resolveParticipationPolicy(scene); - - await this._prepareScene(scene); - await this._unloadAllScenes(); - this._stack.push({ scene, policy }); - this.onChangeScene.dispatch(scene); - this.onStartScene.dispatch(scene); - }, options.transition); + const previous = this._activeScene; - return this; - } + this._activeScene = scene; - /** - * Push `scene` onto the stack, leaving any underlying scenes intact. - * Resolves once the pushed scene's async `load` + `init` complete. - * `options` may override the scene's declared participation policy - * (stack mode, input mode) for this particular push. - * - * Throws if `scene` is already present in the stack. - */ - public async pushScene(scene: Scene, options: PushSceneOptions = {}): Promise { - await this._runWithTransition(async () => { - if (this._stack.some(entry => entry.scene === scene)) { - throw new Error('Cannot push a scene instance that is already present in the stack.'); + if (previous !== null) { + await this._disposeScene(previous); } - const policy = this._resolveParticipationPolicy(scene, options); - - await this._prepareScene(scene); - this._stack.push({ scene, policy }); this.onChangeScene.dispatch(scene); - this.onStartScene.dispatch(scene); - }, options.transition); - - return this; - } - - /** - * Remove the topmost scene from the stack. Resolves once the scene's - * `unload` finishes. No-op when the stack is empty. - */ - public async popScene(options: PopSceneOptions = {}): Promise { - await this._runWithTransition(async () => { - if (this._stack.length === 0) { - return; - } - - const removed = this._stack.at(-1); - if (!removed) { - return; + if (scene !== null) { + this.onStartScene.dispatch(scene); } - - await this._disposeScene(removed.scene); - this._stack.pop(); - this.onChangeScene.dispatch(this.currentScene); }, options.transition); return this; } /** - * Per-frame entry point called by {@link Application.update}. Advances - * any active fade transition, then iterates the stack top-to-bottom - * deciding which scenes update and which draw based on each scene's - * participation policy (`opaque` covers everything below, `modal` - * covers only updates). + * Per-frame entry point called by {@link Application.update}. Advances any + * active fade transition, then — for the active scene — runs `update` and + * ticks its systems (unless {@link Scene.paused}), draws it, and renders its + * UI layer on top. */ public update(delta: Time): this { this._advanceTransition(delta.milliseconds); - this._resolveParticipants(); + const scene = this._activeScene; - for (const scene of this._updateScratch) { - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - const updateResult = scene.update(delta); + if (scene !== null) { + if (!scene.paused) { + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + const updateResult = scene.update(delta); - if (!this._asyncUpdateWarned.has(scene) && (updateResult as unknown) instanceof Promise) { - this._asyncUpdateWarned.add(scene); - console.warn( - `[ExoJS] Scene.update() returned a Promise. update() must be synchronous — async logic here breaks frame timing and silently drops errors. Move async work into load() or init() instead.`, - ); - } + if (!this._asyncUpdateWarned.has(scene) && (updateResult as unknown) instanceof Promise) { + this._asyncUpdateWarned.add(scene); + console.warn( + `[ExoJS] Scene.update() returned a Promise. update() must be synchronous — async logic here breaks frame timing and silently drops errors. Move async work into load() or init() instead.`, + ); + } - // Tick the scene's systems (e.g. a physics world) after its update() and - // before the draw phase. Covered (modal/opaque) scenes are absent from - // _updateScratch, so their systems pause automatically. - scene._tickSystems(delta); - } + // Tick the scene's systems (e.g. a physics world) after its update(). + scene._tickSystems(delta); + } - for (const scene of this._drawScratch) { // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression const drawResult = scene.draw(this._app.rendering); @@ -270,8 +179,8 @@ export class SceneManager { this._renderTransitionOverlay(transitionAlpha); } - if (this.currentScene !== null) { - this.onUpdateScene.dispatch(this.currentScene); + if (scene !== null) { + this.onUpdateScene.dispatch(scene); } return this; @@ -286,7 +195,7 @@ export class SceneManager { transition.reject(new Error('SceneManager was destroyed while a transition was active.')); } - void this._unloadAllScenesOnDestroy(); + void this._unloadActiveSceneOnDestroy(); this._transitionOverlay.destroy(); this.onChangeScene.destroy(); @@ -363,80 +272,20 @@ export class SceneManager { scene.app = null; } - private async _unloadAllScenes(): Promise { - for (let index = this._stack.length - 1; index >= 0; index--) { - await this._disposeScene(this._stack[index].scene); - } - - this._stack.length = 0; - } - - private async _unloadCoveredScenes(): Promise { - if (this._stack.length <= 1) { - return; - } - - const activeEntry = this._stack.at(-1); + private async _unloadActiveSceneOnDestroy(): Promise { + const scene = this._activeScene; - if (!activeEntry) { + if (scene === null) { return; } - for (let index = this._stack.length - 2; index >= 0; index--) { - await this._disposeScene(this._stack[index].scene); - } - - this._stack.length = 0; - this._stack.push(activeEntry); - } - - private async _unloadAllScenesOnDestroy(): Promise { - for (let index = this._stack.length - 1; index >= 0; index--) { - try { - await this._disposeScene(this._stack[index].scene); - } catch (error) { - console.error('SceneManager.destroy() failed to unload the active scene.', error); - } - } - - this._stack.length = 0; - } - - private _resolveParticipationPolicy(scene: Scene, overrides: SceneParticipationPolicy = {}): ResolvedSceneParticipationPolicy { - const mode = overrides.mode ?? scene.stackMode ?? 'overlay'; - - return { mode }; - } + this._activeScene = null; - private _resolveParticipants(): void { - const updateScenes = this._updateScratch; - const drawScenes = this._drawScratch; - updateScenes.length = 0; - drawScenes.length = 0; - let allowBelowUpdate = true; - let allowBelowDraw = true; - - for (let index = this._stack.length - 1; index >= 0; index--) { - const entry = this._stack[index]; - - if (allowBelowUpdate) { - updateScenes.push(entry.scene); - } - - if (allowBelowDraw) { - drawScenes.push(entry.scene); - } - - if (entry.policy.mode === 'opaque') { - allowBelowUpdate = false; - allowBelowDraw = false; - } else if (entry.policy.mode === 'modal') { - allowBelowUpdate = false; - } + try { + await this._disposeScene(scene); + } catch (error) { + console.error('SceneManager.destroy() failed to unload the active scene.', error); } - - updateScenes.reverse(); - drawScenes.reverse(); } private async _runWithTransition(action: () => Promise, transition?: SceneTransition): Promise { diff --git a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap index 7b1ac189..2a9bbe25 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -249,10 +249,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "PolarVector: class", "Polygon: class", "PolygonLike: interface", - "PopSceneOptions: interface", "ProgressBar: class", "ProgressBarOptions: interface", - "PushSceneOptions: interface", "Quadtree: class", "QuadtreeItem: interface", "RadialGradient: class", @@ -292,8 +290,6 @@ exports[`root index type-level export inventory > all exported symbols with kind "Scene: class", "SceneManager: class", "SceneNode: class", - "SceneParticipationPolicy: interface", - "SceneStackMode: type alias", "SceneTransition: type alias", "Seekable: interface", "Segment: class", diff --git a/test/core/scene-manager.test.ts b/test/core/scene-manager.test.ts index 92553577..40363edc 100644 --- a/test/core/scene-manager.test.ts +++ b/test/core/scene-manager.test.ts @@ -188,136 +188,72 @@ describe('SceneManager', () => { consoleErrorSpy.mockRestore(); }); - test('push/pop preserves underlying scene state without reload', async () => { + test('setScene switches the active scene and unloads the previous one', async () => { const manager = new SceneManager(createApplicationStub()); - const baseLoad = vi.fn(async () => undefined); - const baseInit = vi.fn(async () => undefined); - const baseUpdate = vi.fn(); - const baseDraw = vi.fn(); - const baseUnload = vi.fn(async () => undefined); - const overlayUpdate = vi.fn(); - const overlayUnload = vi.fn(async () => undefined); - const base = makeScene({ - load: baseLoad, - init: baseInit, - update: baseUpdate, - draw: baseDraw, - unload: baseUnload, - }); - const overlay = makeScene({ - update: overlayUpdate, - unload: overlayUnload, - }); - - await manager.setScene(base); - tick(manager); - await manager.pushScene(overlay, { mode: 'modal' }); - tick(manager); - - expect(baseLoad).toHaveBeenCalledTimes(1); - expect(baseInit).toHaveBeenCalledTimes(1); - expect(baseUpdate).toHaveBeenCalledTimes(1); - expect(overlayUpdate).toHaveBeenCalledTimes(1); - - await manager.popScene(); - tick(manager); - - expect(baseInit).toHaveBeenCalledTimes(1); - expect(baseUpdate).toHaveBeenCalledTimes(2); - expect(baseUnload).toHaveBeenCalledTimes(0); - expect(overlayUnload).toHaveBeenCalledTimes(1); - }); + const firstUnload = vi.fn(async () => undefined); + const secondInit = vi.fn(async () => undefined); + const first = makeScene({ unload: firstUnload }); + const second = makeScene({ init: secondInit }); - test('overlay mode keeps lower scene updating and drawing', async () => { - const manager = new SceneManager(createApplicationStub()); - const baseUpdate = vi.fn(); - const baseDraw = vi.fn(); - const overlayUpdate = vi.fn(); - const overlayDraw = vi.fn(); - const base = makeScene({ update: baseUpdate, draw: baseDraw }); - const overlay = makeScene({ update: overlayUpdate, draw: overlayDraw }); - - await manager.setScene(base); - await manager.pushScene(overlay, { mode: 'overlay' }); - tick(manager); + await manager.setScene(first); + expect(manager.currentScene).toBe(first); - expect(baseUpdate).toHaveBeenCalledTimes(1); - expect(baseDraw).toHaveBeenCalledTimes(1); - expect(overlayUpdate).toHaveBeenCalledTimes(1); - expect(overlayDraw).toHaveBeenCalledTimes(1); + await manager.setScene(second); + expect(manager.currentScene).toBe(second); + expect(firstUnload).toHaveBeenCalledTimes(1); + expect(secondInit).toHaveBeenCalledTimes(1); }); - test('modal mode blocks lower updates but keeps lower drawing', async () => { + test('setScene to the already-active scene is a no-op', async () => { const manager = new SceneManager(createApplicationStub()); - const baseUpdate = vi.fn(); - const baseDraw = vi.fn(); - const modalUpdate = vi.fn(); - const modalDraw = vi.fn(); - const base = makeScene({ update: baseUpdate, draw: baseDraw }); - const modal = makeScene({ update: modalUpdate, draw: modalDraw }); - - await manager.setScene(base); - await manager.pushScene(modal, { mode: 'modal' }); - tick(manager); - - expect(baseUpdate).toHaveBeenCalledTimes(0); - expect(baseDraw).toHaveBeenCalledTimes(1); - expect(modalUpdate).toHaveBeenCalledTimes(1); - expect(modalDraw).toHaveBeenCalledTimes(1); - }); + const init = vi.fn(async () => undefined); + const unload = vi.fn(async () => undefined); + const scene = makeScene({ init, unload }); - test('opaque mode blocks both lower updates and lower drawing', async () => { - const manager = new SceneManager(createApplicationStub()); - const baseUpdate = vi.fn(); - const baseDraw = vi.fn(); - const opaqueUpdate = vi.fn(); - const opaqueDraw = vi.fn(); - const base = makeScene({ update: baseUpdate, draw: baseDraw }); - const opaque = makeScene({ update: opaqueUpdate, draw: opaqueDraw }); - - await manager.setScene(base); - await manager.pushScene(opaque, { mode: 'opaque' }); - tick(manager); + await manager.setScene(scene); + await manager.setScene(scene); - expect(baseUpdate).toHaveBeenCalledTimes(0); - expect(baseDraw).toHaveBeenCalledTimes(0); - expect(opaqueUpdate).toHaveBeenCalledTimes(1); - expect(opaqueDraw).toHaveBeenCalledTimes(1); + expect(init).toHaveBeenCalledTimes(1); + expect(unload).toHaveBeenCalledTimes(0); + expect(manager.currentScene).toBe(scene); }); - test('failed push keeps active scene stack intact', async () => { + test('setScene(null) clears the active scene', async () => { const manager = new SceneManager(createApplicationStub()); - const baseUnload = vi.fn(async () => undefined); - const failedUnload = vi.fn(async () => undefined); - const base = makeScene({ unload: baseUnload }); - const failingOverlay = makeScene({ - async init() { - throw new Error('overlay init failed'); - }, - unload: failedUnload, - }); + const unload = vi.fn(async () => undefined); + const scene = makeScene({ unload }); - await manager.setScene(base); + await manager.setScene(scene); + await manager.setScene(null); - await expect(manager.pushScene(failingOverlay)).rejects.toThrow('overlay init failed'); - expect(manager.currentScene).toBe(base); - expect(manager.scenes).toEqual([base]); - expect(baseUnload).toHaveBeenCalledTimes(0); - expect(failedUnload).toHaveBeenCalledTimes(1); + expect(manager.currentScene).toBeNull(); + expect(unload).toHaveBeenCalledTimes(1); }); - test('single-scene setScene flow still works with stack-enabled manager', async () => { + test('paused scene skips update and systems but keeps drawing', async () => { const manager = new SceneManager(createApplicationStub()); - const firstUnload = vi.fn(async () => undefined); - const first = makeScene({ unload: firstUnload }); - const second = makeScene({}); + const update = vi.fn(); + const draw = vi.fn(); + const scene = makeScene({ update, draw }); + const tickSystems = vi.spyOn(scene, '_tickSystems'); - await manager.setScene(first); - await manager.setScene(second); + await manager.setScene(scene); + tick(manager); + expect(update).toHaveBeenCalledTimes(1); + expect(tickSystems).toHaveBeenCalledTimes(1); + expect(draw).toHaveBeenCalledTimes(1); - expect(firstUnload).toHaveBeenCalledTimes(1); - expect(manager.currentScene).toBe(second); - expect(manager.scenes).toEqual([second]); + scene.paused = true; + tick(manager); + expect(update).toHaveBeenCalledTimes(1); + expect(tickSystems).toHaveBeenCalledTimes(1); + expect(draw).toHaveBeenCalledTimes(2); + + scene.paused = false; + tick(manager); + expect(update).toHaveBeenCalledTimes(2); + expect(tickSystems).toHaveBeenCalledTimes(2); + expect(draw).toHaveBeenCalledTimes(3); }); test('fade transition runs and completes around setScene', async () => {