Files
feuerwerk/src/viewport/Viewport.ts
T
rootandClaude Fable 5.1 5a46124454 Initial commit: Game Maker phase 1
Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript.

- core: asset model (Scene/Camera/Sprite), scene objects with sparse
  overrides, property schema + resolve, command stack (undo/redo),
  serialization and project store interface (16 unit tests)
- ui: custom in-canvas widget toolkit (panels, fields, color picker,
  list/scroll, popup) with a hidden <input> keyboard bridge
- editor: asset/hierarchy panels, schema-driven inspector, toolbar
- viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan
- runtime: world/entity instantiation for play mode, input, camera follow
- api: PHP JSON project store with revision counter (409 on conflict)
- tools: CDP-based e2e smoke test

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 10:07:06 +02:00

274 lines
12 KiB
TypeScript

import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
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 { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
import type { EditorState } from '../editor/state';
/** What the viewport draws: the same shape whether it comes from the model or the world. */
interface DrawItem {
id: string;
type: AssetType;
name: string;
x: number;
y: number;
props: PropertyBag;
}
/** Reference frame drawn for camera objects in edit mode (world units at zoom 1). */
const CAMERA_FRAME = { w: 640, h: 360 };
const GRID_STEP = 100;
/**
* 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.
*/
export class Viewport extends Widget {
private readonly bg = new Graphics();
private readonly grid = new Graphics();
private readonly world = new Container();
private readonly gizmos = new Graphics();
private readonly nodes = new Map<string, Graphics>();
private readonly labels = new Map<string, Text>();
private playWorld: World | null = null;
/** Edit-mode view: world point at the viewport centre and zoom. */
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;
constructor(private readonly state: EditorState) {
super();
this.addChild(this.bg, this.grid, this.world, this.gizmos);
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
e.stopPropagation();
return;
}
if (e.button === 0 && e.target === this && this.state.mode === 'edit') this.state.select(null);
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (this.panning) {
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
this.redraw();
} else if (this.dragging) {
const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < 2) return;
this.dragging.moved = true;
const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
if (!object) return;
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy) };
this.state.preview();
}
});
const finish = () => {
this.panning = null;
const drag = this.dragging;
this.dragging = null;
if (!drag || !drag.moved) return;
const object = this.state.activeScene?.objects.find((o) => o.id === drag.objectId);
if (!object) return;
const next = { ...object.position };
object.position = { ...drag.start };
this.state.execute(new MoveSceneObjectCommand(drag.sceneId, drag.objectId, next, drag.start));
};
this.on('pointerup', finish);
this.on('pointerupoutside', finish);
this.on('wheel', (e: FederatedWheelEvent) => {
if (this.state.mode !== 'edit') return;
const local = this.toLocal(e.global);
const before = this.screenToWorld(local.x, local.y);
const factor = Math.exp(-e.deltaY * 0.0015);
this.view.zoom = Math.max(0.1, Math.min(8, this.view.zoom * factor));
const after = this.screenToWorld(local.x, local.y);
this.view.x += before.x - after.x;
this.view.y += before.y - after.y;
this.redraw();
e.preventDefault();
});
state.on('project', () => this.redraw());
state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; this.redraw(); });
state.on('selection', () => this.redraw());
state.on('mode', () => this.redraw());
}
/** Switch between drawing the model (null) and a running world. */
setWorld(world: World | null): void {
this.playWorld = world;
this.redraw();
}
/** World coordinates at the centre of the current edit view (used to place assets). */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
}
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 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 };
}
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 };
}
// --- drawing ---------------------------------------------------------------------------
private collectItems(): { items: DrawItem[]; background: number } {
if (this.playWorld) {
return {
background: this.playWorld.backgroundColor,
items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, props: e.props })),
};
}
const scene = this.state.activeScene;
if (!scene) return { items: [], background: Theme.colors.viewport };
const items: DrawItem[] = [];
for (const object of scene.objects) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, props: resolveSceneObjectProps(asset, object) });
}
return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) };
}
override redraw(): void {
const { items, background } = this.collectItems();
const cam = this.currentCamera();
const editing = !this.playWorld;
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.state.activeScene || this.playWorld ? background : Theme.colors.viewport);
this.drawGrid(editing);
this.world.position.set(this.w / 2 - cam.x * cam.zoom, this.h / 2 - cam.y * cam.zoom);
this.world.scale.set(cam.zoom);
// Sync display objects with items.
const seen = new Set<string>();
for (const item of items) {
seen.add(item.id);
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);
}
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));
} 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(-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)) {
node.destroy();
this.nodes.delete(id);
this.labels.get(id)?.destroy();
this.labels.delete(id);
}
}
this.drawGizmos(items, editing);
}
private syncLabel(item: DrawItem, editing: boolean, zoom: number): void {
let label = this.labels.get(item.id);
if (!editing || 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);
}
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);
}
private drawGrid(editing: boolean): void {
this.grid.clear();
if (!editing) return;
const cam = this.view;
const step = GRID_STEP * cam.zoom;
if (step < 12) return;
const origin = this.worldToScreen(0, 0);
const startX = ((origin.x % step) + step) % step;
const startY = ((origin.y % step) + step) % step;
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.05 });
if (origin.x >= 0 && origin.x <= this.w) this.grid.moveTo(origin.x, 0).lineTo(origin.x, this.h).stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
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();
const s = this.state.selection;
if (!editing || !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) {
this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
}
}
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (this.state.mode !== 'edit' || e.button !== 0) return;
const scene = this.state.activeScene;
const object = scene?.objects.find((o) => o.id === objectId);
if (!scene || !object) return;
e.stopPropagation();
this.state.select({ kind: 'object', sceneId: scene.id, objectId });
const local = this.toLocal(e.global);
this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false };
}
}