Camera view frame, viewport clipping, overlay layer for editor symbols

- Camera assets get width/height properties; play mode renders only the
  camera frame (letterboxed, masked, culled) and nothing without a camera
- Remove demo arrow-key/camera-follow systems from play mode
- Clip world and gizmos to the viewport (selection frame no longer leaks
  over the left panel)
- Blur focused widget on pointerdown outside it so inspector edits commit
  before selection changes
- Separate objects/overlay layers so camera symbols always render on top
- Add tools/seed-demo.mjs for reproducible E2E runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 11:13:08 +02:00
1 parent 5a46124454
commit aaa8154adf
11 files changed
+307 -142

No files matched your search

+126 -51
View File
@@ -3,6 +3,7 @@ import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes';
import type { PropertyBag, Vec2 } from '../core/model/Properties';
import { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
@@ -18,19 +19,43 @@ interface DrawItem {
props: PropertyBag;
}
/** Reference frame drawn for camera objects in edit mode (world units at zoom 1). */
const CAMERA_FRAME = { w: 640, h: 360 };
/**
* How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the
* centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per
* world unit. In edit mode the rectangle is the whole viewport; in play mode it is the
* camera's frame fitted into the viewport (letterboxed).
*/
interface View {
x: number;
y: number;
scale: number;
rect: { x: number; y: number; w: number; h: number };
}
const GRID_STEP = 100;
const LETTERBOX_COLOR = 0x000000;
/**
* Draws the active scene (edit mode) or the running world (play mode) as coloured boxes.
* Edit mode adds a grid, selection outline, drag-to-move, middle-drag panning and wheel
* zoom. The camera object of the scene defines the view in play mode.
* zoom. In play mode the scene's camera defines what is visible: only its frame is drawn,
* everything outside it is masked and not rendered at all. Without a camera play mode
* renders nothing.
*/
export class Viewport extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
private readonly content = new Container();
private readonly world = new Container();
/** Scene objects (sprites) in hierarchy order. */
private readonly objects = new Container();
/**
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can never
* hide them, no matter what z-order the scene ends up with.
*/
private readonly overlay = new Container();
private readonly contentMask = new Graphics();
private readonly gizmos = new Graphics();
private readonly nodes = new Map<string, Graphics>();
private readonly labels = new Map<string, Text>();
@@ -39,10 +64,14 @@ export class Viewport extends Widget {
private view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
private dragging: { objectId: string; sceneId: string; start: Vec2; pointer: Vec2; moved: boolean } | null = null;
private visibleCount = 0;
constructor(private readonly state: EditorState) {
super();
this.addChild(this.bg, this.grid, this.world, this.gizmos);
this.world.addChild(this.objects, this.overlay);
this.content.addChild(this.world, this.gizmos);
this.addChild(this.bg, this.grid, this.content, this.contentMask);
this.content.mask = this.contentMask;
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
@@ -113,28 +142,55 @@ export class Viewport extends Widget {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
}
/** Read-only facts for tests: whether anything is rendered and how many items are on screen. */
debugInfo(): { rendering: boolean; visibleItems: number } {
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
}
/** Which layer an item is drawn in (test hook): symbols live in `overlay`, above all `objects`. */
layerOf(objectId: string): 'objects' | 'overlay' | null {
const node = this.nodes.get(objectId);
if (!node) return null;
return node.parent === this.overlay ? 'overlay' : 'objects';
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- transforms ------------------------------------------------------------------------
private currentCamera(): { x: number; y: number; zoom: number } {
if (this.playWorld) {
const camera = this.playWorld.firstOfType('camera');
return camera ? { x: camera.x, y: camera.y, zoom: Number(camera.props.zoom ?? 1) } : { x: 0, y: 0, zoom: 1 };
}
return this.view;
private editView(): View {
return { x: this.view.x, y: this.view.y, scale: this.view.zoom, rect: { x: 0, y: 0, w: this.w, h: this.h } };
}
private screenToWorld(sx: number, sy: number): Vec2 {
const cam = this.currentCamera();
return { x: (sx - this.w / 2) / cam.zoom + cam.x, y: (sy - this.h / 2) / cam.zoom + cam.y };
/** The active mapping, or null when playing a scene that has no camera (nothing to show). */
private currentView(): View | null {
if (!this.playWorld) return this.editView();
const camera = this.playWorld.firstOfType('camera');
if (!camera) return null;
const frame = cameraFrame(camera.props);
const scale = Math.min(this.w / frame.w, this.h / frame.h);
const w = frame.w * scale;
const h = frame.h * scale;
return { x: camera.x, y: camera.y, scale, rect: { x: (this.w - w) / 2, y: (this.h - h) / 2, w, h } };
}
private worldToScreen(wx: number, wy: number): Vec2 {
const cam = this.currentCamera();
return { x: (wx - cam.x) * cam.zoom + this.w / 2, y: (wy - cam.y) * cam.zoom + this.h / 2 };
/** World region covered by a view. */
private worldBounds(view: View): WorldRect {
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
}
private screenToWorld(sx: number, sy: number, view: View = this.editView()): Vec2 {
const cx = view.rect.x + view.rect.w / 2;
const cy = view.rect.y + view.rect.h / 2;
return { x: (sx - cx) / view.scale + view.x, y: (sy - cy) / view.scale + view.y };
}
private worldToScreen(wx: number, wy: number, view: View = this.editView()): Vec2 {
const cx = view.rect.x + view.rect.w / 2;
const cy = view.rect.y + view.rect.h / 2;
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
}
// --- drawing ---------------------------------------------------------------------------
@@ -157,46 +213,62 @@ export class Viewport extends Widget {
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
/** Size of an item's footprint in world units (sprite box or camera frame). */
private itemSize(item: DrawItem): { w: number; h: number } {
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
return cameraFrame(item.props);
}
override redraw(): void {
const { items, background } = this.collectItems();
const cam = this.currentCamera();
const editing = !this.playWorld;
const view = this.currentView();
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.state.activeScene || this.playWorld ? background : Theme.colors.viewport);
// Base fill: letterbox black in play mode, neutral in edit mode without a scene.
this.bg.clear().rect(0, 0, this.w, this.h).fill(editing ? Theme.colors.viewport : LETTERBOX_COLOR);
this.drawGrid(editing);
this.gizmos.clear();
this.contentMask.clear();
this.content.visible = view !== null;
this.visibleCount = 0;
if (!view) return;
this.world.position.set(this.w / 2 - cam.x * cam.zoom, this.h / 2 - cam.y * cam.zoom);
this.world.scale.set(cam.zoom);
const { items, background } = this.collectItems();
const { rect } = view;
if (this.state.activeScene || this.playWorld) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
// Sync display objects with items.
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
this.world.scale.set(view.scale);
const bounds = this.worldBounds(view);
// Sync display objects with items; anything outside the view is neither drawn nor hit-testable.
const seen = new Set<string>();
for (const item of items) {
seen.add(item.id);
const size = this.itemSize(item);
const onScreen = rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
let node = this.nodes.get(item.id);
if (!node) {
node = new Graphics();
node.eventMode = 'static';
node.cursor = 'move';
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
this.nodes.set(item.id, node);
this.world.addChild(node);
(this.isSymbol(item) ? this.overlay : this.objects).addChild(node);
}
node.visible = onScreen;
node.eventMode = editing && onScreen ? 'static' : 'none';
this.syncLabel(item, editing && onScreen, view.scale);
if (!onScreen) continue;
this.visibleCount++;
node.position.set(item.x, item.y);
node.clear();
if (item.type === 'sprite') {
const w = Number(item.props.width ?? 1);
const h = Number(item.props.height ?? 1);
node.rect(-w / 2, -h / 2, w, h).fill(Number(item.props.color ?? 0xffffff));
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
} else if (item.type === 'camera') {
const zoom = Number(item.props.zoom ?? 1);
const w = CAMERA_FRAME.w / zoom;
const h = CAMERA_FRAME.h / zoom;
node.rect(-w / 2, -h / 2, w, h).stroke({ width: 2 / cam.zoom, color: Theme.colors.textMuted });
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).stroke({ width: 2 / view.scale, color: Theme.colors.textMuted });
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
}
node.eventMode = editing ? 'static' : 'none';
this.syncLabel(item, editing, cam.zoom);
}
for (const [id, node] of this.nodes) {
if (!seen.has(id)) {
@@ -207,32 +279,36 @@ export class Viewport extends Widget {
}
}
this.drawGizmos(items, editing);
if (editing) this.drawGizmos(items, view);
}
private syncLabel(item: DrawItem, editing: boolean, zoom: number): void {
/** Items drawn as editor symbols rather than as scene content. */
private isSymbol(item: DrawItem): boolean {
return item.type === 'camera';
}
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
if (!editing || item.type !== 'camera') {
if (!show || item.type !== 'camera') {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
this.labels.set(item.id, label);
this.world.addChild(label);
this.overlay.addChild(label);
}
const frame = cameraFrame(item.props);
label.visible = true;
label.text = item.name;
label.scale.set(1 / zoom);
const h = CAMERA_FRAME.h / Number(item.props.zoom ?? 1);
label.position.set(item.x - (CAMERA_FRAME.w / Number(item.props.zoom ?? 1)) / 2, item.y - h / 2 - 16 / zoom);
label.scale.set(1 / scale);
label.position.set(item.x - frame.w / 2, item.y - frame.h / 2 - 16 / scale);
}
private drawGrid(editing: boolean): void {
this.grid.clear();
if (!editing) return;
const cam = this.view;
const step = GRID_STEP * cam.zoom;
const step = GRID_STEP * this.view.zoom;
if (step < 12) return;
const origin = this.worldToScreen(0, 0);
const startX = ((origin.x % step) + step) % step;
@@ -244,18 +320,17 @@ export class Viewport extends Widget {
if (origin.y >= 0 && origin.y <= this.h) this.grid.moveTo(0, origin.y).lineTo(this.w, origin.y).stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
}
private drawGizmos(items: DrawItem[], editing: boolean): void {
this.gizmos.clear();
private drawGizmos(items: DrawItem[], view: View): void {
const s = this.state.selection;
if (!editing || !s || s.kind !== 'object') return;
if (!s || s.kind !== 'object') return;
const item = items.find((i) => i.id === s.objectId);
if (!item) return;
const zoom = this.view.zoom;
const w = item.type === 'sprite' ? Number(item.props.width ?? 1) : CAMERA_FRAME.w / Number(item.props.zoom ?? 1);
const h = item.type === 'sprite' ? Number(item.props.height ?? 1) : CAMERA_FRAME.h / Number(item.props.zoom ?? 1);
const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2);
this.gizmos.rect(tl.x - 2, tl.y - 2, w * zoom + 4, h * zoom + 4).stroke({ width: 2, color: Theme.colors.gizmo });
for (const [cx, cy] of [[tl.x, tl.y], [tl.x + w * zoom, tl.y], [tl.x, tl.y + h * zoom], [tl.x + w * zoom, tl.y + h * zoom]] as const) {
const { w, h } = this.itemSize(item);
const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
const sw = w * view.scale;
const sh = h * view.scale;
this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) {
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
}
}