From 37fff5dabf448b1c7d31be72dc3522f926bb5967 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 9 Sep 2026 16:13:30 +0200 Subject: [PATCH] Play through the first camera that is switched on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game view took whatever camera came first in the scene, ignoring the switch, so `enabled = false` on a camera did nothing and a second camera never got its turn. `World.activeCamera()` now decides: of the cameras switched on – the whole parent chain counted, as for any object – the first in hierarchy order, and none on renders nothing at all. It is asked per frame, so a graph switching cameras hands the view over immediately. Co-Authored-By: Claude Opus 5 --- README.md | 8 +-- src/editor/PlaySession.ts | 4 +- src/runtime/World.ts | 20 ++++++-- src/viewport/GameViewport.ts | 9 +++- tests/core/camera.test.ts | 82 ++++++++++++++++++++++++++++++ todo-notes/done/mehrere-kameras.md | 46 +++++++++++++++++ todo-notes/mehrere-kameras.md | 2 - tools/e2e/03-play.mjs | 69 ++++++++++++++++++++++++- 8 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 tests/core/camera.test.ts create mode 100644 todo-notes/done/mehrere-kameras.md delete mode 100644 todo-notes/mehrere-kameras.md diff --git a/README.md b/README.md index 0c38e740..ba71351f 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,10 @@ Live: · Widget playground: e.name === name); } - firstOfType(type: Entity['type']): Entity | undefined { - return this.entities.find((e) => e.type === type); + /** + * The camera the game is seen through: of the cameras that are switched on (itself and every + * parent, see `properties/enabled.ts`) the first one in hierarchy order. Undefined while none + * is on, and then nothing is rendered – switching one off is how a scene hands the view over. + */ + activeCamera(): Entity | undefined { + const on = this.entities.filter((e) => e.type === 'camera' && isHandleEnabled(e)); + if (on.length < 2) return on[0]; + // Ranked only when the order can decide something, since this runs per frame. + const scene = findScene(this.project, this.sceneId); + const order = scene ? treeOrder(scene).map(({ object }) => object.id) : []; + const rank = (camera: Entity) => { + const index = order.indexOf(camera.objectId); + return index < 0 ? order.length : index; + }; + return on.reduce((first, camera) => (rank(camera) < rank(first) ? camera : first)); } /** Removes an entity and, recursively, everything parented to it. */ diff --git a/src/viewport/GameViewport.ts b/src/viewport/GameViewport.ts index 52d154a7..67938935 100644 --- a/src/viewport/GameViewport.ts +++ b/src/viewport/GameViewport.ts @@ -36,12 +36,17 @@ export class GameViewport extends WorldViewport { } protected override currentView(): View | null { - const camera = this.playWorld?.firstOfType('camera'); + const camera = this.playWorld?.activeCamera(); return camera ? cameraView(camera, this.w, this.h) : null; } + /** `camera`: object id of the camera the view is through, empty when none is switched on. */ + override debugInfo(): { rendering: boolean; visibleItems: number; camera: string } { + return { ...super.debugInfo(), camera: this.playWorld?.activeCamera()?.objectId ?? '' }; + } + protected override collectFrame(): Frame { - // Only reached with a view, which requires a world with a camera. + // Only reached with a view, which requires a world with a switched-on camera. return this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR }; } diff --git a/tests/core/camera.test.ts b/tests/core/camera.test.ts new file mode 100644 index 00000000..01298293 --- /dev/null +++ b/tests/core/camera.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { treeOrder } from '../../src/core/model/hierarchy'; +import { makeSpriteScene, place, startWorld } from '../helpers'; + +/** The fixture's scene with a camera asset, and switching a placed object off the way the inspector does. */ +function sceneWithCamera() { + const fixture = makeSpriteScene(); + const camera = new CreateAssetCommand('camera', 'Cam'); + fixture.stack.execute(camera); + return { + ...fixture, + cameraId: camera.asset.id, + switchOff: (objectId: string) => + fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, objectId, 'enabled', false)), + }; +} + +describe('the camera the game is seen through', () => { + it('skips a camera that is switched off, so nothing is rendered while it is the only one', () => { + const f = sceneWithCamera(); + const cam = place(f, f.cameraId, { x: 0, y: 0 }); + const { world } = startWorld(f); + expect(world.activeCamera()?.objectId).toBe(cam); + + // Switching it off reaches the running world through the same sync an inspector edit uses. + f.switchOff(cam); + world.syncFromModel(); + expect(world.activeCamera()).toBeUndefined(); + + f.stack.undo(); + world.syncFromModel(); + expect(world.activeCamera()?.objectId).toBe(cam); + }); + + it('is off as well when a parent above it is off', () => { + const f = sceneWithCamera(); + const holder = place(f, f.spriteId, { x: 0, y: 0 }); + const cam = place(f, f.cameraId, { x: 0, y: 0 }, holder); + const { world } = startWorld(f); + expect(world.activeCamera()?.objectId).toBe(cam); + + f.switchOff(holder); + world.syncFromModel(); + expect(world.activeCamera()).toBeUndefined(); + }); + + it('takes the first switched-on one, and hands the view over when that one goes off', () => { + const f = sceneWithCamera(); + const first = place(f, f.cameraId, { x: 0, y: 0 }); + const second = place(f, f.cameraId, { x: 500, y: 0 }); + const { world } = startWorld(f); + expect(world.activeCamera()?.objectId).toBe(first); + + f.switchOff(first); + world.syncFromModel(); + expect(world.activeCamera()?.objectId).toBe(second); + + // And back: the order decides again as soon as both are on. + f.stack.undo(); + world.syncFromModel(); + expect(world.activeCamera()?.objectId).toBe(first); + + f.switchOff(second); + world.syncFromModel(); + expect(world.activeCamera()?.objectId).toBe(first); + }); + + it('reads "first" in hierarchy order – what the panel shows – not in storage order', () => { + const f = sceneWithCamera(); + const holder = place(f, f.spriteId, { x: 0, y: 0 }); + const stored = place(f, f.cameraId, { x: 500, y: 0 }); + const nested = place(f, f.cameraId, { x: 0, y: 0 }, holder); + // The nested camera is stored last but listed first, right below its parent. + expect(f.sceneOf().objects.map((o) => o.id)).toEqual([holder, stored, nested]); + expect(treeOrder(f.sceneOf()).map(({ object }) => object.id)).toEqual([holder, nested, stored]); + + const { world } = startWorld(f); + expect(world.activeCamera()?.objectId).toBe(nested); + }); +}); diff --git a/todo-notes/done/mehrere-kameras.md b/todo-notes/done/mehrere-kameras.md new file mode 100644 index 00000000..8b978cc8 --- /dev/null +++ b/todo-notes/done/mehrere-kameras.md @@ -0,0 +1,46 @@ +1. kamera enabled auf false setzen bewirkt nichts, wird trotzdem gerendert +2. wenn mehrere kammeras in der scene sind, dann soll die erste gefundene die enabled ist die aktive kamera sein + +--- + +**Umgesetzt (2026-09-09).** Beide Punkte waren dieselbe Stelle: der Game-View nahm die Kamera +mit `world.firstOfType('camera')` — die **erste ueberhaupt**, ohne den Schalter anzusehen. Ein +ausgeschaltetes `enabled` konnte deshalb nichts bewirken (Punkt 1), und eine zweite Kamera kam +nie zum Zug, egal wie die Schalter standen (Punkt 2). + +An ihre Stelle tritt **`World.activeCamera()`** — die einzige Stelle, die entscheidet, durch +welche Kamera gespielt wird: + +- **geschaltet** zaehlt wie bei jedem Objekt die ganze Kette nach oben (`isHandleEnabled`): eine + Kamera unter einem ausgeschalteten Container ist ebenfalls aus. +- **"erste"** heisst in **Hierarchie-Reihenfolge**, also von oben nach unten im Hierarchie-Panel, + nicht in Speicherreihenfolge. Die beiden koennen auseinanderlaufen, sobald eine Kamera unter + einem Elternteil haengt, das spaeter gespeichert ist — dann gewinnt das, was der User sieht. +- **keine an → gar nichts wird gerendert**, wie bei einer Szene ohne Kamera. Der Hinweis beim + Start sagt jetzt in beiden Faellen dasselbe: *"No camera is switched on – nothing is rendered"*. + +Ausgewertet wird pro Frame, also wirkt ein Umschalten waehrend des Spielens sofort — auch das +per `Set enabled`-Node aus einem Graphen heraus. Das ist der Weg, wie eine Szene die Ansicht +von einer Kamera an die naechste uebergibt. + +`firstOfType` ist damit unbenutzt und geloescht. + +**Belegt** — vier Unit-Tests (`tests/core/camera.test.ts`, jeder faellt ohne den Fix um) und +sieben E2E-Checks in Modul 03. Der Test-Hook `__gm.gameInfo()` nennt dafuer neu die **objectId +der aktiven Kamera**, sonst waere "durch welche Kamera" nur zu erraten: + +``` +PASS a switched-off camera renders nothing although it is still in the scene + – {"rendering":false,"visibleItems":0,"camera":""} +PASS the switched-on camera takes over, and the game is seen through it + – {"rendering":true,"visibleItems":0,"camera":"m8ngyi2lkc"} +PASS with both switched on the first camera in hierarchy order is the active one again + – {"rendering":true,"visibleItems":2,"camera":"o3"} +``` + +Der mittlere Check ist der eigentliche Beweis fuer Punkt 2: die zweite Kamera steht bei +x = 5000, die beiden Kisten liegen am Ursprung. `visibleItems: 0` bei `rendering: true` heisst, +dass wirklich durch **ihren** Ausschnitt gesehen wird — der blosse Name der Kamera haette auch +dann gestimmt, wenn das Bild von der alten Kamera gekommen waere. + +Screenshot `10b-second-camera`. diff --git a/todo-notes/mehrere-kameras.md b/todo-notes/mehrere-kameras.md deleted file mode 100644 index 8e28e15a..00000000 --- a/todo-notes/mehrere-kameras.md +++ /dev/null @@ -1,2 +0,0 @@ -1. kamera enabled auf false setzen bewirkt nichts, wird trotzdem gerendert -2. wenn mehrere kammeras in der scene sind, dann soll die erste gefundene die enabled ist die aktive kamera sein diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 92dbbae1..5cda1232 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -13,7 +13,10 @@ export async function play(t) { evaluate, json, shot, + click, clickText, + dragAsset, + locate, press, holdKey, undo, @@ -170,9 +173,73 @@ export async function play(t) { s.mode === 'play' && !info.rendering && info.visibleItems === 0, JSON.stringify(info), ); - check('play without camera shows a notice', s.notice.includes('no camera'), s.notice); + check('play without camera shows a notice', s.notice.includes('No camera is switched on'), s.notice); await shot('10-play-no-camera'); await stop(); await undo(); check('undo restored the camera object', (await objects()).length === 7); + + // 7d. Which camera renders: a camera that is switched off is skipped like any other object, and + // of those left the first in hierarchy order takes the view. The second camera is parked far + // from the origin, so "seen through it" is provable – both crates fall outside its frame. + await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); + const enabledRow = await locate('Enabled'); + // The checkbox sits right of the 64 px label column; locate() gives the label's text centre. + await click(enabledRow.x + 55, enabledRow.y); + await sleep(150); + check( + 'the camera has the enabled switch like any other object', + (await object('o3')).overrides.enabled === false, + JSON.stringify((await object('o3')).overrides), + ); + await play(); + info = await gameInfo(); + s = await state(); + check( + 'a switched-off camera renders nothing although it is still in the scene', + !info.rendering && info.camera === '' && info.visibleItems === 0, + JSON.stringify(info), + ); + check('and the notice names the switch, not a missing camera', s.notice.includes('switched on'), s.notice); + await stop(); + + const centre = await json('__gm.viewportCenter()'); + await dragAsset('cam', centre.x + 250, centre.y + 150); + const second = (await objects()).find((o) => !FIXTURE_IDS.includes(o.id)); + check('a second camera is placed in the scene', Boolean(second), JSON.stringify(second)); + await mutate( + `__gm.state.activeScene.objects.find(o => o.id === ${JSON.stringify(second.id)}).position.x = 5000`, + ); + await play(); + info = await gameInfo(); + check( + 'the switched-on camera takes over, and the game is seen through it: the crates at the origin are outside its frame', + info.camera === second.id && info.rendering && info.visibleItems === 0, + JSON.stringify(info), + ); + await shot('10b-second-camera'); + await stop(); + + await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' }); + const switchBackOn = await locate('Enabled'); + await click(switchBackOn.x + 55, switchBackOn.y); + await sleep(150); + await play(); + info = await gameInfo(); + check( + 'with both switched on the first camera in hierarchy order is the active one again', + info.camera === 'o3' && info.visibleItems === 2, + JSON.stringify(info), + ); + await stop(); + + // Back to the fixture: the switch-on, the placement and the switch-off, newest first. + await undo(); + await undo(); + await undo(); + check( + 'undo leaves the fixture as it was, camera on and without an override', + (await objects()).length === 7 && Object.keys((await object('o3')).overrides).length === 0, + JSON.stringify((await object('o3')).overrides), + ); }