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:
1 parent
75be133374
commit
37fff5dabf
8 files changed
+227
-13
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user