diff --git a/README.md b/README.md index 3bddfce2..f9dac6a0 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,17 @@ Live: · Widget playground: # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png +node tools/seed-demo.mjs # reset the demo project to the state the e2e run expects +node tools/e2e.mjs # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png ``` The workspace folder itself is what nginx serves, so `npm run build` is the deployment. diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index b87da5b1..09fe57bc 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -18,7 +18,11 @@ export type PropertySchema = readonly PropertyDef[]; /** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */ export const ASSET_SCHEMAS: Record = { scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }], - camera: [{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 }], + camera: [ + { key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1 }, + { key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1 }, + { key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 }, + ], sprite: [ { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, diff --git a/src/core/properties/camera.ts b/src/core/properties/camera.ts new file mode 100644 index 00000000..46e78df7 --- /dev/null +++ b/src/core/properties/camera.ts @@ -0,0 +1,28 @@ +import type { PropertyBag } from '../model/Properties'; +import { defaultsFor } from './PropertySchema'; + +/** Axis-aligned rectangle in world units. */ +export interface WorldRect { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * Size of the world region a camera shows, in world units: its view size divided by its + * zoom. This is the frame drawn in edit mode and the only region rendered in play mode. + */ +export function cameraFrame(props: PropertyBag): { w: number; h: number } { + const d = defaultsFor('camera'); + const zoom = Math.max(0.01, Number(props.zoom ?? d.zoom)); + return { w: Number(props.width ?? d.width) / zoom, h: Number(props.height ?? d.height) / zoom }; +} + +export function rectAround(cx: number, cy: number, w: number, h: number): WorldRect { + return { left: cx - w / 2, top: cy - h / 2, right: cx + w / 2, bottom: cy + h / 2 }; +} + +export function rectsIntersect(a: WorldRect, b: WorldRect): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index fb3c0729..94872659 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -4,8 +4,6 @@ import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; import { listScenes } from '../core/model/Project'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; import type { ProjectStore } from '../core/store/ProjectStore'; -import { CameraFollowSystem } from '../runtime/systems/CameraFollowSystem'; -import { PlayerControlSystem } from '../runtime/systems/PlayerControlSystem'; import { World } from '../runtime/World'; import { Dialog, HStack, Menu, type MenuItem, Splitter, ui, VStack, type Widget } from '../ui'; import { Viewport } from '../viewport/Viewport'; @@ -52,7 +50,7 @@ function installDebugHook(app: Application, state: EditorState, viewport: Viewpo const p = node.getGlobalPosition(); return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; }; - (window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter }; + (window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter, viewportInfo: () => viewport.debugInfo(), layerOf: (id: string) => viewport.layerOf(id) }; } class Editor { @@ -187,14 +185,11 @@ class Editor { const scene = this.state.activeScene; if (!scene) return; ui().focus.blur(); - const selected = this.state.selection?.kind === 'object' ? this.state.selection.objectId : null; - const control = new PlayerControlSystem(selected); - this.world = World.instantiate(this.state.project, scene.id) - .addSystem(control) - .addSystem(new CameraFollowSystem(() => control.controlled)); + // No systems yet: the world is static. Behaviour arrives with scripting in a later phase. + this.world = World.instantiate(this.state.project, scene.id); this.viewport.setWorld(this.world); this.state.setMode('play'); - this.state.setNotice('Arrow keys move the sprite · Esc stops'); + this.state.setNotice(this.world.firstOfType('camera') ? 'Playing · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops'); } private stop(): void { diff --git a/src/runtime/systems/CameraFollowSystem.ts b/src/runtime/systems/CameraFollowSystem.ts deleted file mode 100644 index 4617db4d..00000000 --- a/src/runtime/systems/CameraFollowSystem.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Entity } from '../Entity'; -import type { World } from '../World'; -import type { System } from './System'; - -/** - * Makes the first camera entity follow a target entity (smoothly). If the scene has no - * camera the viewport falls back to a fixed view at the origin. - */ -export class CameraFollowSystem implements System { - private camera: Entity | undefined; - - constructor(private readonly target: () => Entity | undefined, private readonly smoothing = 8) {} - - start(world: World): void { - this.camera = world.firstOfType('camera'); - } - - update(_world: World, dt: number): void { - const target = this.target(); - if (!this.camera || !target) return; - const t = 1 - Math.exp(-this.smoothing * dt); - this.camera.x += (target.x - this.camera.x) * t; - this.camera.y += (target.y - this.camera.y) * t; - } -} diff --git a/src/runtime/systems/PlayerControlSystem.ts b/src/runtime/systems/PlayerControlSystem.ts deleted file mode 100644 index 75d867ac..00000000 --- a/src/runtime/systems/PlayerControlSystem.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Entity } from '../Entity'; -import type { World } from '../World'; -import { Input } from './Input'; -import type { System } from './System'; - -/** - * Phase-1 demo behaviour: the arrow keys (or WASD) move one sprite entity. Which one is - * decided at start: the entity of the scene object that was selected when Play was - * pressed, otherwise the first sprite in the scene. - */ -export class PlayerControlSystem implements System { - private readonly input = new Input(); - private player: Entity | undefined; - - constructor(private readonly preferredObjectId: string | null, private readonly speed = 240) {} - - start(world: World): void { - this.input.attach(); - const preferred = this.preferredObjectId ? world.findByObjectId(this.preferredObjectId) : undefined; - this.player = preferred && preferred.type === 'sprite' ? preferred : world.firstOfType('sprite'); - } - - update(_world: World, dt: number): void { - if (!this.player) return; - const dx = this.input.axis('ArrowLeft', 'ArrowRight') || this.input.axis('a', 'd'); - const dy = this.input.axis('ArrowUp', 'ArrowDown') || this.input.axis('w', 's'); - if (dx === 0 && dy === 0) return; - const length = Math.hypot(dx, dy); - this.player.x += (dx / length) * this.speed * dt; - this.player.y += (dy / length) * this.speed * dt; - } - - stop(): void { - this.input.detach(); - this.player = undefined; - } - - get controlled(): Entity | undefined { - return this.player; - } -} diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts index e29f374c..d9e164e9 100644 --- a/src/ui/FocusManager.ts +++ b/src/ui/FocusManager.ts @@ -29,6 +29,20 @@ export class FocusManager { this.focus(null); } + /** + * A pointer went down on `target`. If that is outside the focused widget, the widget + * loses focus *before* the press is handled, so a text field commits its edit before + * whatever the click triggers (selection change, inspector rebuild, …) can destroy it. + */ + blurIfOutside(target: Container | null): void { + const w = this.focusedWidget; + if (!w) return; + for (let node = target; node; node = node.parent) { + if (node === w) return; + } + this.focus(null); + } + /** Drop focus if the focused widget was removed from the tree or hidden. */ validate(): void { const w = this.focusedWidget; diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts index 6be7e5ab..e6326d71 100644 --- a/src/ui/UIContext.ts +++ b/src/ui/UIContext.ts @@ -1,4 +1,4 @@ -import type { Application, Container } from 'pixi.js'; +import type { Application, Container, FederatedPointerEvent } from 'pixi.js'; import { FocusManager } from './FocusManager'; import { KeyboardBridge } from './KeyboardBridge'; @@ -18,6 +18,9 @@ let context: UIContext | null = null; export function initUI(app: Application, root: Container, overlay: Container): UIContext { context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay }; + // Capture phase on the stage runs before any widget handler: a press outside the focused + // widget blurs it (committing text edits) before the press can change selection. + app.stage.addEventListener('pointerdown', (e) => context?.focus.blurIfOutside((e as FederatedPointerEvent).target as Container), { capture: true }); // Clicking the canvas must not steal DOM focus from the keyboard bridge input. app.canvas.addEventListener('mousedown', (e) => e.preventDefault()); app.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts index bdd1e31f..e589d60f 100644 --- a/src/viewport/Viewport.ts +++ b/src/viewport/Viewport.ts @@ -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(); private readonly labels = new Map(); @@ -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(); 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); } } diff --git a/tools/e2e.mjs b/tools/e2e.mjs index e41e2506..9c955f03 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -159,9 +159,18 @@ try { check('scrubbing X moved the object', xNow > 120, `x=${xNow}`); await shot('05-scrubbed'); + // 3b. Type into X, then click straight into the empty viewport: the typed value must be + // committed (not reverted) even though the click deselects and rebuilds the inspector. + const center = await evaluate('__gm.viewportCenter()'); + await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected + await typeText('300'); + await click(center.x, center.y + 250); + const xTyped = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); + s = JSON.parse(await stateJson()); + check('typed X survives clicking into the viewport', xTyped === 300 && s.selection === null, `x=${xTyped} selection=${JSON.stringify(s.selection)}`); + // 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred // on the origin, so compute its screen position from the viewport widget bounds. - const center = await evaluate('__gm.viewportCenter()'); await drag(center.x - 150, center.y, center.x - 50, center.y - 70); const o1Now = await evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === 'o1').position)`); check('viewport drag moved the red crate', JSON.parse(o1Now).x > -150, o1Now); @@ -176,40 +185,69 @@ try { check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides)); check('save status is saved', (JSON.parse(await stateJson())).save === 'saved'); - // 6. Undo three times (drag, scrub, rename) with Ctrl+Z, then redo once. + // 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once. await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus - for (let i = 0; i < 3; i++) { + for (let i = 0; i < 4; i++) { await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); await sleep(80); } const afterUndo = JSON.parse(await evaluate(`JSON.stringify(__gm.state.activeScene.objects.map(o => ({ id: o.id, name: o.name, x: o.position.x })))`)); - check('undo reverted drag, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo)); + check('undo reverted drag, typed X, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo)); await keyPress('z', 'KeyZ', { modifiers: 10, windowsVirtualKeyCode: 90 }); // Ctrl+Shift+Z const afterRedo = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`); check('redo restored the rename', afterRedo === 'Renamed crate', afterRedo); await shot('07-undo-redo'); - // 7. Play with the renamed crate selected; arrow keys move it; Esc stops. + // 7. Play: the camera frame is rendered (both crates lie inside the 640×360 frame at the + // origin), arrow keys do nothing, Esc stops without touching the model. await clickText('Renamed crate'); const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); await clickText('▶ Play'); await sleep(300); s = JSON.parse(await stateJson()); check('play mode entered', s.mode === 'play', JSON.stringify(s)); + let info = await evaluate('JSON.stringify(__gm.viewportInfo())'); + check('play renders the camera frame with both crates', JSON.parse(info).rendering && JSON.parse(info).visibleItems === 2, info); await shot('08-playing'); await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await sleep(700); + await sleep(300); await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); - await sleep(100); - await shot('09-moved-in-play'); await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); await sleep(300); s = JSON.parse(await stateJson()); check('escape stopped play', s.mode === 'edit', JSON.stringify(s)); - await shot('10-stopped'); + await shot('09-stopped'); const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`); + // 7b. Culling: move the blue crate far outside the camera frame → only one item visible. + await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000; __gm.state.preview()`); + await clickText('▶ Play'); + await sleep(300); + info = await evaluate('JSON.stringify(__gm.viewportInfo())'); + check('objects outside the camera frame are culled', JSON.parse(info).visibleItems === 1, info); + await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); + await sleep(200); + await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}; __gm.state.preview()`); + + // 7c. Without a camera nothing is rendered: delete the camera object, play, then undo. + await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o3' })`); + await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 }); + await sleep(200); + check('camera object deleted', (await evaluate('__gm.state.activeScene.objects.length')) === 2); + await clickText('▶ Play'); + await sleep(300); + info = await evaluate('JSON.stringify(__gm.viewportInfo())'); + s = JSON.parse(await stateJson()); + check('play without camera renders nothing', s.mode === 'play' && !JSON.parse(info).rendering && JSON.parse(info).visibleItems === 0, info); + check('play without camera shows a notice', s.notice.includes('no camera'), s.notice); + await shot('10-play-no-camera'); + await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); + await sleep(300); + await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); + await sleep(200); + check('undo restored the camera object', (await evaluate('__gm.state.activeScene.objects.length')) === 3); + // 8. Context menu on the Crate asset → menu shows "Place in scene". await clickText('Crate', 'right'); await sleep(200); @@ -230,6 +268,22 @@ try { check('+ menu creates a sprite asset', JSON.parse(assets).includes('Sprite'), assets); await shot('12-new-asset'); + // 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate + // right on top of its icon → the icon must still be drawn above the sprite. + await evaluate(`(() => { + const objs = __gm.state.activeScene.objects; + const cam = objs.splice(objs.findIndex(o => o.id === 'o3'), 1)[0]; + objs.unshift(cam); + const crate = objs.find(o => o.id === 'o1'); + crate.position.x = 0; crate.position.y = 0; + __gm.state.select(null); + __gm.state.preview(); + })()`); + await sleep(200); + const layers = await evaluate(`JSON.stringify([__gm.layerOf('o3'), __gm.layerOf('o1'), __gm.layerOf('o2')])`); + check('camera symbol is drawn in the overlay layer above sprites', layers === '["overlay","objects","objects"]', layers); + await shot('13-symbol-on-top'); + check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | ')); ws.close(); } catch (error) { diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs new file mode 100644 index 00000000..fc64f55d --- /dev/null +++ b/tools/seed-demo.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Resets the demo project to the state tools/e2e.mjs expects (the e2e run mutates it). + * Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs [baseUrl] + */ +const projectId = process.argv[2]; +const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/'; +if (!projectId) { + console.error('usage: node tools/seed-demo.mjs [baseUrl]'); + process.exit(2); +} + +const project = { + formatVersion: 1, + id: projectId, + name: 'Demo project', + activeSceneId: 'scene1', + assets: [ + { + id: 'scene1', + type: 'scene', + name: 'Level 1', + props: { backgroundColor: 0x22252c }, + objects: [ + { id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0 }, overrides: {} }, + { id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80 }, overrides: { width: 140, color: 0x4488ff } }, + { id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0 }, overrides: {} }, + ], + }, + { id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } }, + { id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } }, + ], +}; + +const url = `${baseUrl}api/index.php?r=projects/${projectId}`; +const current = await fetch(url); +if (!current.ok) { + console.error(`GET failed: ${current.status}`); + process.exit(1); +} +const { revision } = await current.json(); +const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) }); +const body = await res.json(); +if (!res.ok) { + console.error('PUT failed', res.status, body); + process.exit(1); +} +console.log(`seeded ${projectId} → revision ${body.revision}`);