Show the camera's picture in the game tab without playing
The game tab held a "Press Play" hint where the picture belongs. It now previews the scene through the active camera, and says "No active camera" when none is switched on. Camera choice and its world position moved into activeCamera.ts so both are testable without Pixi. Entity.x/.y are parent-relative; the viewport used them as the camera's spot, which framed a parented camera at the wrong place.
This commit is contained in:
1 parent
ed455f1fd6
commit
c74037b6e1
7 files changed
+275
-16
No files matched your search
@@ -31,9 +31,10 @@ default 640×360) and a `zoom`; the frame it covers in world units is `size / zo
|
||||
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. 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.
|
||||
one, so switching it off hands the view to the next; with none on the tab stays empty and says
|
||||
*No active camera*. Without playing the tab previews the scene through that same camera, so it
|
||||
shows what Play would show, minus the editor symbols. 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
|
||||
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
|
||||
@@ -450,7 +451,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 # 337 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 343 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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/** The running world seen through the scene's camera, letterboxed into the widget. */
|
||||
/**
|
||||
* The scene seen through its active camera, letterboxed into the widget: the running world while
|
||||
* playing, the model as a still preview otherwise.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
import { textStyle } from '../ui';
|
||||
import { collectWorldItems } from './items';
|
||||
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from './activeCamera';
|
||||
import { collectHolderItems, collectWorldItems } from './items';
|
||||
import { cameraView, isInside, screenToWorld, type View } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export class GameViewport extends WorldViewport {
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') });
|
||||
private playWorld: World | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
@@ -35,27 +40,48 @@ export class GameViewport extends WorldViewport {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Skipped while the tab is hidden: the preview costs a collect of the whole scene and would
|
||||
* otherwise run on every selection change behind another tab. Showing the tab relayouts the
|
||||
* stack, which resizes this widget and thereby redraws it.
|
||||
*/
|
||||
override redraw(): void {
|
||||
if (!this.visible) return;
|
||||
super.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View | null {
|
||||
const camera = this.playWorld?.activeCamera();
|
||||
const camera = this.activeCamera();
|
||||
return camera ? cameraView(camera, this.w, this.h) : null;
|
||||
}
|
||||
|
||||
private activeCamera(): ActiveCamera | null {
|
||||
const world = this.playWorld;
|
||||
return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items);
|
||||
}
|
||||
|
||||
private modelFrame(): Frame {
|
||||
return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR);
|
||||
}
|
||||
|
||||
/** `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 ?? '' };
|
||||
return { ...super.debugInfo(), camera: this.activeCamera()?.id ?? '' };
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
// Only reached with a view, which requires a world with a switched-on camera.
|
||||
return this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR };
|
||||
if (this.playWorld) return collectWorldItems(this.playWorld);
|
||||
const frame = this.modelFrame();
|
||||
// Symbols are editor decoration, so the preview drops them: it shows what playing would show.
|
||||
return { ...frame, items: frame.items.filter((item) => !isSymbolType(item.type)) };
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return LETTERBOX_COLOR;
|
||||
}
|
||||
|
||||
protected override beforeItems(): void {
|
||||
this.hint.visible = !this.playWorld;
|
||||
protected override beforeItems(view: View | null): void {
|
||||
this.hint.visible = view === null;
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Which camera the game view frames, and where it sits – from the running world or the model. */
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import type { World } from '../runtime/World';
|
||||
import type { DrawItem } from './items';
|
||||
|
||||
/** As much of a camera as `cameraView` needs, from either source alike. */
|
||||
export interface ActiveCamera {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
props: PropertyBag;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model's camera, for the preview shown while not playing. Same rule as `World.activeCamera`:
|
||||
* `collectHolderItems` already delivers hierarchy order with the switch chain resolved, so the
|
||||
* first camera that is on is simply the first match.
|
||||
*/
|
||||
export function cameraOfItems(items: DrawItem[]): ActiveCamera | null {
|
||||
return items.find((item) => item.type === 'camera' && item.enabled) ?? null;
|
||||
}
|
||||
|
||||
/** The running camera. Its own `x`/`y` are relative to its parent, so it is placed by its world position. */
|
||||
export function cameraOfWorld(world: World): ActiveCamera | null {
|
||||
const entity = world.activeCamera();
|
||||
if (!entity) return null;
|
||||
const { x, y } = worldPositionOf(entity);
|
||||
return { id: entity.objectId, x, y, props: entity.props };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { cameraOfItems, cameraOfWorld } from '../../src/viewport/activeCamera';
|
||||
import { collectHolderItems } from '../../src/viewport/items';
|
||||
import { makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
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)),
|
||||
/** What the game view frames while not playing. */
|
||||
preview: () => cameraOfItems(collectHolderItems(fixture.project, fixture.sceneOf(), 0).items),
|
||||
};
|
||||
}
|
||||
|
||||
describe('the camera the game view frames', () => {
|
||||
it('previews through the very camera that playing would use', () => {
|
||||
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);
|
||||
const playedThrough = () => world.activeCamera()?.objectId;
|
||||
expect(f.preview()?.id).toBe(first);
|
||||
expect(playedThrough()).toBe(first);
|
||||
|
||||
f.switchOff(first);
|
||||
world.syncFromModel();
|
||||
expect(f.preview()?.id).toBe(second);
|
||||
expect(playedThrough()).toBe(second);
|
||||
|
||||
f.switchOff(second);
|
||||
world.syncFromModel();
|
||||
expect(f.preview()).toBeNull();
|
||||
expect(world.activeCamera()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows nothing rather than a switched-off camera, parents counted', () => {
|
||||
const f = sceneWithCamera();
|
||||
const holder = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const cam = place(f, f.cameraId, { x: 0, y: 0 }, holder);
|
||||
expect(f.preview()?.id).toBe(cam);
|
||||
|
||||
f.switchOff(holder);
|
||||
expect(f.preview()).toBeNull();
|
||||
});
|
||||
|
||||
it('has no camera to frame while the scene holds none', () => {
|
||||
const f = sceneWithCamera();
|
||||
place(f, f.spriteId, { x: 0, y: 0 });
|
||||
expect(f.preview()).toBeNull();
|
||||
});
|
||||
|
||||
it('places a nested camera at its world position, not at its offset below the parent', () => {
|
||||
const f = sceneWithCamera();
|
||||
const holder = place(f, f.spriteId, { x: 300, y: 200 });
|
||||
const cam = place(f, f.cameraId, { x: 320, y: 210 }, holder);
|
||||
const { world } = startWorld(f);
|
||||
|
||||
// The entity's own coordinates are the offset; framing by them would put the view on the parent.
|
||||
const entity = world.activeCamera()!;
|
||||
expect({ x: entity.x, y: entity.y }).toEqual({ x: 20, y: 10 });
|
||||
expect(cameraOfWorld(world)).toMatchObject({ id: cam, x: 320, y: 210 });
|
||||
expect(f.preview()).toMatchObject({ id: cam, x: 320, y: 210 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
In der Game View soll anstelle von "Press Play to run the scene here":
|
||||
- das aktuelle frame von der kamera dargestellt werden
|
||||
- gibt es keine aktive kamera, der text "No active camera"
|
||||
|
||||
---
|
||||
|
||||
**Umgesetzt (2026-09-09).** Der Game-Tab zeigt jetzt auch ohne Play das Bild der aktiven Kamera,
|
||||
und *"No active camera"*, wenn keine an ist. Der alte Hinweis *"Press ▶ Play to run the scene
|
||||
here"* entfaellt ersatzlos — er stand genau da, wo jetzt das Bild ist.
|
||||
|
||||
Der Tab hat damit zwei Quellen fuer dieselbe Darstellung:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **waehrend Play** | die laufende Welt, wie bisher |
|
||||
| **sonst** | das Modell der aktiven Szene, als stehendes Bild |
|
||||
|
||||
Beide werden durch **dieselbe** Kamera gerahmt (`activeCamera.ts`, siehe unten) und durch
|
||||
dieselbe Zeichenroutine gezeichnet. Editor-Symbole (Kamera, Collider, Behavior, Container-Icon)
|
||||
fallen in der Vorschau genauso weg wie beim Spielen — die Vorschau zeigt das Spiel, nicht den
|
||||
Editor.
|
||||
|
||||
## Zwei reine Funktionen statt einer Regel an zwei Stellen
|
||||
|
||||
`src/viewport/activeCamera.ts` beantwortet **welche Kamera und wo**, fuer beide Quellen:
|
||||
|
||||
- `cameraOfItems(items)` — die Modell-Kamera. `collectHolderItems` liefert bereits
|
||||
Hierarchie-Reihenfolge mit aufgeloester Schalterkette, also ist "die erste eingeschaltete"
|
||||
hier schlicht der erste Treffer. Damit ist es **dieselbe Regel** wie `World.activeCamera`,
|
||||
nicht eine zweite Formulierung davon.
|
||||
- `cameraOfWorld(world)` — die laufende Kamera, **an ihrer Weltposition**.
|
||||
|
||||
## Ein Fehler, den erst die Vorschau sichtbar gemacht hat
|
||||
|
||||
Der zweite Punkt ist kein Detail: `x`/`y` einer Entity sind **relativ zum Elternteil**. Der
|
||||
Game-View hat sie bisher direkt als Kamerastandort benutzt. Eine Kamera, die unter einem Objekt
|
||||
haengt (der uebliche Weg, sie einem Spieler folgen zu lassen), wurde deshalb an der falschen
|
||||
Stelle gerahmt — der Elternversatz fehlte. Aufgefallen ist es, weil die Modellseite die
|
||||
Weltposition liefert und die Vorschau damit ein **anderes** Bild gezeigt haette als Play. Der
|
||||
Unit-Test haelt genau diesen Unterschied fest: die Entity meldet `{x: 20, y: 10}`, gerahmt wird
|
||||
bei `{x: 320, y: 210}`.
|
||||
|
||||
## Die Vorschau kostet nichts, solange man sie nicht sieht
|
||||
|
||||
Sie erfordert ein Einsammeln der ganzen Szene. Ohne Bremse liefe das bei **jeder** Auswahl-
|
||||
oder Modelaenderung mit, auch waehrend der Scene-Tab offen ist — vorher tat der Game-View in
|
||||
dem Fall gar nichts. `redraw()` steigt deshalb aus, solange das Widget unsichtbar ist; das
|
||||
Einschalten des Tabs laeuft ohnehin ueber ein `relayout`, das per `resize` neu zeichnet.
|
||||
|
||||
## Belegt
|
||||
|
||||
Vier Unit-Tests (`tests/viewport/active-camera.test.ts`) — drei davon fallen um, wenn man die
|
||||
Schalter-Bedingung oder die Weltposition entfernt (gegengeprueft). Dazu sechs E2E-Checks in
|
||||
Modul 03. Der staerkste vergleicht Vorschau und Play direkt, damit "es rendert irgendwas" nicht
|
||||
als Vorschau durchgeht:
|
||||
|
||||
```
|
||||
PASS the game tab previews the scene through the active camera, without playing
|
||||
– {"rendering":true,"visibleItems":2,"camera":"o3"}
|
||||
PASS and the crate is really drawn there, not left as letterbox – #e0553f
|
||||
PASS and it is the picture Play produces: same camera, same items, same pixel on the crate
|
||||
– {…,"camera":"o3"} vs {…,"camera":"o3"}, #e0553f vs #e0553f
|
||||
PASS switching the camera off empties the preview and names the reason
|
||||
– {"rendering":false,"visibleItems":0,"camera":""}
|
||||
PASS and undo brings the preview back without a reload
|
||||
```
|
||||
|
||||
Der Pixel-Vergleich ist der Kern: dieselbe Farbe an derselben Bildschirmstelle wie im laufenden
|
||||
Spiel. Der vierte Check liest zusaetzlich ueber `__gm.locate('No active camera')` nach, dass der
|
||||
Text wirklich auf dem Schirm steht — `rendering: false` allein waere auch bei einem schwarzen
|
||||
Kasten ohne Text erfuellt.
|
||||
|
||||
Screenshots `10c-game-preview` und `10d-no-active-camera`.
|
||||
@@ -1,3 +0,0 @@
|
||||
In der Game View soll anstelle von "Press Play to run the scene here":
|
||||
- das aktuelle frame von der kamera dargestellt werden
|
||||
- gibt es keine aktive kamera, der text "No active camera"
|
||||
@@ -242,4 +242,65 @@ export async function play(t) {
|
||||
(await objects()).length === 7 && Object.keys((await object('o3')).overrides).length === 0,
|
||||
JSON.stringify((await object('o3')).overrides),
|
||||
);
|
||||
|
||||
// 7e. Not playing, the Game tab previews the scene through the same camera instead of waiting
|
||||
// for Play. Proven by comparing it with what Play then draws – identical readout and an
|
||||
// identical pixel on the red crate – so "it renders something" cannot pass for a preview.
|
||||
const crateSpot = async () => {
|
||||
const p = await json(`__gm.gameScreen('o1')`);
|
||||
return json(`__gm.pixelAt(${Math.round(p.x)}, ${Math.round(p.y)})`);
|
||||
};
|
||||
await evaluate(`__gm.showTab('game')`);
|
||||
await sleep(150);
|
||||
const preview = await gameInfo();
|
||||
const previewPixel = await crateSpot();
|
||||
check(
|
||||
'the game tab previews the scene through the active camera, without playing',
|
||||
preview.rendering && preview.camera === 'o3' && preview.visibleItems === 2,
|
||||
JSON.stringify(preview),
|
||||
);
|
||||
check(
|
||||
'and the crate is really drawn there, not left as letterbox',
|
||||
previewPixel !== '#000000',
|
||||
previewPixel,
|
||||
);
|
||||
await shot('10c-game-preview');
|
||||
|
||||
await play();
|
||||
info = await gameInfo();
|
||||
const playingPixel = await crateSpot();
|
||||
check(
|
||||
'and it is the picture Play produces: same camera, same items, same pixel on the crate',
|
||||
JSON.stringify(info) === JSON.stringify(preview) && playingPixel === previewPixel,
|
||||
`${JSON.stringify(preview)} vs ${JSON.stringify(info)}, ${previewPixel} vs ${playingPixel}`,
|
||||
);
|
||||
await stop();
|
||||
|
||||
// Switching the only camera off leaves nothing to preview – and says so, live on the tab.
|
||||
await evaluate(`__gm.showTab('game')`);
|
||||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
|
||||
const previewSwitch = await locate('Enabled');
|
||||
await click(previewSwitch.x + 55, previewSwitch.y);
|
||||
await sleep(200);
|
||||
info = await gameInfo();
|
||||
check(
|
||||
'switching the camera off empties the preview and names the reason',
|
||||
!info.rendering && info.camera === '' && Boolean(await json(`__gm.locate('No active camera')`)),
|
||||
JSON.stringify(info),
|
||||
);
|
||||
await shot('10d-no-active-camera');
|
||||
await undo();
|
||||
await sleep(200);
|
||||
info = await gameInfo();
|
||||
check(
|
||||
'and undo brings the preview back without a reload',
|
||||
info.rendering && info.camera === 'o3' && (await json(`__gm.locate('No active camera')`)) === null,
|
||||
JSON.stringify(info),
|
||||
);
|
||||
await evaluate(`__gm.showTab('scene')`);
|
||||
check(
|
||||
'the fixture is left as it was found',
|
||||
(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