Play through the first camera that is switched on

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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 16:13:30 +02:00
1 parent 75be133374
commit 37fff5dabf
8 files changed
+227 -13

No files matched your search

+5 -3
View File
@@ -29,8 +29,10 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
**The camera defines what is rendered.** A camera asset has a view size (`width` × `height`,
default 640×360) and a `zoom`; the frame it covers in world units is `size / zoom`. The Scene
tab draws that frame as an outline. The Game tab renders only that frame – fitted into the
view with black letterboxing – and objects outside it are culled. A scene without a camera
renders nothing in the Game tab. Play has no built-in behaviour (no input, no movement):
view with black letterboxing – and objects outside it are culled. Of several cameras the first
one in hierarchy order that is **switched on** (`enabled`, its parents included) is the active
one, so switching it off hands the view to the next; with none on nothing is rendered at all.
Play has no built-in behaviour (no input, no movement):
everything that happens is defined by behavior graphs.
**Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the
@@ -448,7 +450,7 @@ npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # where the browser runs; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 330 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 337 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
```
+2 -2
View File
@@ -79,9 +79,9 @@ export class PlaySession {
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(
world.firstOfType('camera')
world.activeCamera()
? 'Playing · edits are not saved · Esc stops'
: 'This scene has no camera – nothing is rendered · Esc stops',
: 'No camera is switched on – nothing is rendered · Esc stops',
);
if (world.stopRequested) this.stop();
}
+17 -3
View File
@@ -3,7 +3,7 @@ import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
import { bakeInstances, materializeInstance } from '../core/model/instance';
import { findObject } from '../core/model/hierarchy';
import { findObject, treeOrder } from '../core/model/hierarchy';
import { descendantsBelow } from '../core/model/walk';
import { findAsset, findScene, type Project } from '../core/model/Project';
import { createSceneObject, type SceneObject } from '../core/model/SceneObject';
@@ -274,8 +274,22 @@ export class World {
return this.entities.find((e) => 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. */
+7 -2
View File
@@ -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 };
}
+82
View File
@@ -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);
});
});
+46
View File
@@ -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`.
-2
View File
@@ -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
+68 -1
View File
@@ -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),
);
}