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),
+ );
}