diff --git a/README.md b/README.md index ba71351f..7ce5ba25 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/viewport/GameViewport.ts b/src/viewport/GameViewport.ts index 67938935..13bf87dd 100644 --- a/src/viewport/GameViewport.ts +++ b/src/viewport/GameViewport.ts @@ -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); } diff --git a/src/viewport/activeCamera.ts b/src/viewport/activeCamera.ts new file mode 100644 index 00000000..4e2f7418 --- /dev/null +++ b/src/viewport/activeCamera.ts @@ -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 }; +} diff --git a/tests/viewport/active-camera.test.ts b/tests/viewport/active-camera.test.ts new file mode 100644 index 00000000..04611112 --- /dev/null +++ b/tests/viewport/active-camera.test.ts @@ -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 }); + }); +}); diff --git a/todo-notes/done/game-view-preview.md b/todo-notes/done/game-view-preview.md new file mode 100644 index 00000000..6bd6b22e --- /dev/null +++ b/todo-notes/done/game-view-preview.md @@ -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`. diff --git a/todo-notes/game-view-preview.md b/todo-notes/game-view-preview.md deleted file mode 100644 index 083952af..00000000 --- a/todo-notes/game-view-preview.md +++ /dev/null @@ -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" diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index 5cda1232..286a816f 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -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), + ); }