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:
1 parent
5a46124454
commit
aaa8154adf
11 files changed
+307
-142
No files matched your search
@@ -16,8 +16,17 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
||||
| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||||
| **Play mode** | A `World` is instantiated from the active scene (one entity per scene object, resolved properties as mutable state). Systems tick it; nothing writes back. **Stop discards the world.** |
|
||||
|
||||
Phase-1 demo behaviour in Play: the arrow keys move the sprite that was selected when Play was
|
||||
pressed (or the first sprite); the first camera in the scene follows it.
|
||||
**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`. In edit
|
||||
mode that frame is drawn as an outline. In play mode only that frame is rendered – fitted into
|
||||
the viewport with black letterboxing – and objects outside it are culled. A scene without a
|
||||
camera renders nothing in play mode. Play has no built-in behaviour (no input, no movement):
|
||||
behaviour will come with scripting in a later phase.
|
||||
|
||||
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
||||
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
||||
sprite can never hide a symbol regardless of hierarchy order. Z-order between sprites is not
|
||||
configurable yet.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -34,12 +43,12 @@ pressed (or the first sprite); the first camera in the scene follows it.
|
||||
```
|
||||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||||
src/core/ model, property schema, resolve, commands, store – no Pixi imports
|
||||
src/runtime/ World, Entity, systems (Input, PlayerControl, CameraFollow)
|
||||
src/runtime/ World, Entity, System interface, Input tracker (no systems yet)
|
||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||
src/viewport/ scene renderer: model in edit mode, world in play mode
|
||||
src/editor/ App composition, state, panels, inspector field factory
|
||||
api/ PHP backend data/projects/ persisted projects
|
||||
tests/ Vitest unit tests tools/e2e.mjs headless CDP smoke test
|
||||
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
|
||||
```
|
||||
|
||||
Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees
|
||||
@@ -57,7 +66,8 @@ npm run dev # http://localhost:5175/game-maker/dist/ – API calls are
|
||||
npm test # unit tests (core)
|
||||
npm run typecheck
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
node tools/e2e.mjs <projectId> # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-demo.mjs <projectId> # reset the demo project to the state the e2e run expects
|
||||
node tools/e2e.mjs <projectId> # 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.
|
||||
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+4
-9
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+4
-1
@@ -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());
|
||||
|
||||
+126
-51
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+63
-9
@@ -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) {
|
||||
|
||||
@@ -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 <projectId> [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 <projectId> [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}`);
|
||||
Reference in new issue
Block a user