Files
feuerwerk/src/viewport/Viewport.ts
T

478 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
import { treeOrder, worldPosition } from '../core/model/hierarchy';
import { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
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;
/** Draw order (see `Position.z`). `x`/`y`/`z` are world values (parents already applied). */
z: number;
props: PropertyBag;
/** Id of the parent item, empty when top-level. */
parentId: string;
}
/** World-unit size of the behavior symbol (a diamond). */
const BEHAVIOR_SYMBOL = 28;
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
const COLLIDER_GRAB_BAND = 6;
/** Hit area covering only the outline band of a collider shape (local coordinates, world units). */
function colliderOutlineHitArea(shape: ColliderShape, band: number): { contains(x: number, y: number): boolean } {
if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band };
const hw = shape.w / 2;
const hh = shape.h / 2;
return {
contains: (x, y) => {
const ax = Math.abs(x);
const ay = Math.abs(y);
return ax <= hw + band && ay <= hh + band && (ax >= hw - band || ay >= hh - band);
},
};
}
/**
* 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;
export interface ViewportOptions {
/**
* Game view: draws the running world through the scene's camera, no editing. The default
* (scene view) draws the model – in play mode that is the runtime copy, so it shows the
* live positions and stays fully editable.
*/
game?: boolean;
}
/**
* Draws a scene as coloured boxes. The **scene view** shows the model with a grid, selection
* outline, drag-to-move, middle-drag panning and wheel zoom – in edit and play mode alike.
* The **game view** shows the running world: the scene's camera defines what is visible,
* only its frame is drawn (letterboxed), everything outside it is masked and not rendered
* at all. Without a camera the game view renders nothing.
*/
export class Viewport extends Widget {
private readonly game: boolean;
private readonly bg = new Graphics();
private readonly grid = new Graphics();
/** Game view only: shown while no world is running. */
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
/** 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), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
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();
/** Dashed owner links (behavior → owner), drawn in world units beneath the symbols. */
private readonly links = new Graphics();
private readonly contentMask = new Graphics();
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: Position; pointer: Vec2; moved: boolean } | null = null;
private visibleCount = 0;
constructor(private readonly state: EditorState, options: ViewportOptions = {}) {
super();
this.game = options.game ?? false;
this.world.addChild(this.objects, this.links, this.overlay);
// Decorations must not swallow hits meant for the objects beneath them.
this.links.eventMode = 'none';
this.hint.eventMode = 'none';
this.hint.anchor.set(0.5);
this.content.addChild(this.world, this.gizmos);
this.addChild(this.bg, this.grid, this.content, this.contentMask, this.hint);
this.content.mask = this.contentMask;
this.cursor = 'default';
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (this.game) return;
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.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;
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy), z: this.dragging.start.z };
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.game) 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());
// Every tick moves things: redraw the live view when it is on screen.
state.on('runtime', () => { if (this.visible) this.redraw(); });
}
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
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) };
}
/** 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';
}
/** Object ids in the order they are drawn, back to front (test hook). */
drawOrder(): string[] {
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
}
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- transforms ------------------------------------------------------------------------
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 } };
}
/** The active mapping, or null when the game view has no world or no camera (nothing to show). */
private currentView(): View | null {
if (!this.game) 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 } };
}
/** 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 ---------------------------------------------------------------------------
private collectItems(): { items: DrawItem[]; background: number } {
if (this.game) {
// Symbols (camera, behaviors) are editor-only; the running game shows scene content.
const world = this.playWorld;
if (!world) return { items: [], background: LETTERBOX_COLOR };
return {
background: world.backgroundColor,
items: world.entities.filter((e) => !isSymbolType(e.type)).map((e) => ({ id: e.objectId, type: e.type, name: e.name, ...world.worldPosition(e), props: e.props, parentId: e.parent?.objectId ?? '' })),
};
}
const scene = this.state.activeScene;
if (!scene) return { items: [], background: Theme.colors.viewport };
const items: DrawItem[] = [];
// Tree order (parents before children) is the tie-break for equal z.
for (const { object } of treeOrder(scene)) {
const asset = findAsset(this.state.project, object.assetId);
if (!asset) continue;
items.push({ id: object.id, type: asset.type, name: object.name || asset.name, ...worldPosition(scene, object), props: resolveSceneObjectProps(asset, object), parentId: object.parentId });
}
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) };
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
if (item.type === 'collider') return colliderSize(item.props);
return cameraFrame(item.props);
}
override redraw(): void {
const editing = !this.game;
const view = this.currentView();
// Base fill: letterbox black in the game view, neutral in the scene view 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.hint.visible = this.game && !this.playWorld;
this.hint.position.set(this.w / 2, this.h / 2);
this.visibleCount = 0;
if (!view) return;
const { items, background } = this.collectItems();
const { rect } = view;
if (this.game || this.state.activeScene) 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);
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.label = item.id;
node.cursor = 'move';
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
this.nodes.set(item.id, 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') {
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
} else if (item.type === 'camera') {
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);
} else if (item.type === 'behavior') {
// A diamond with a small "circuit" mark: the logic symbol.
const r = BEHAVIOR_SYMBOL / 2;
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
node.circle(6, 3, 2).fill(Theme.colors.accentText);
} else if (item.type === 'collider') {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
// top of their body, so only the outline band is hit-testable: clicks inside still reach
// the sprite underneath, the edge grabs the collider.
const shape = colliderShape(item.props, { x: 0, y: 0 });
if (shape.kind === 'circle') node.circle(0, 0, shape.r);
else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h);
const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider;
node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / view.scale, color });
node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / view.scale);
}
}
this.drawParentLinks(items, editing, view.scale);
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.syncDrawOrder(items);
if (editing) this.drawGizmos(items, view);
}
/**
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
* deterministic and hierarchy changes are honoured too.
*/
private syncDrawOrder(items: DrawItem[]): void {
const ordered = sortByZ(items.filter((item) => !this.isSymbol(item)));
this.objects.removeChildren();
for (const item of ordered) {
const node = this.nodes.get(item.id);
if (node) this.objects.addChild(node);
}
}
/** Items drawn as editor symbols rather than as scene content. */
private isSymbol(item: DrawItem): boolean {
return isSymbolType(item.type);
}
/**
* Dashed line from every parented symbol (behavior, camera) to its parent (edit mode only).
* Sprites show their nesting in the hierarchy panel instead, to keep the scene uncluttered;
* colliders sit on their body anyway, so they get no line either.
*/
private drawParentLinks(items: DrawItem[], editing: boolean, scale: number): void {
this.links.clear();
if (!editing) return;
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) {
if (!this.isSymbol(item) || !item.parentId || item.type === 'collider') continue;
const parent = byId.get(item.parentId);
if (!parent) continue;
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
}
}
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
if (!show || !this.isSymbol(item)) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('small') });
label.eventMode = 'none';
this.labels.set(item.id, label);
this.overlay.addChild(label);
}
const size = this.itemSize(item);
label.visible = true;
label.text = item.name;
label.scale.set(1 / scale);
if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
}
private drawGrid(editing: boolean): void {
this.grid.clear();
if (!editing) return;
const step = GRID_STEP * this.view.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[], view: View): void {
const s = this.state.selection;
if (!s || s.kind !== 'object') return;
const item = items.find((i) => i.id === s.objectId);
if (!item) return;
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);
}
}
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (this.game || 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 };
}
}
/** Adds a dashed line path to `g` (Pixi has no dash style); the caller strokes it. */
export function dashedLine(g: Graphics, x1: number, y1: number, x2: number, y2: number, dash: number, gap: number): void {
const length = Math.hypot(x2 - x1, y2 - y1);
if (length === 0) return;
const ux = (x2 - x1) / length;
const uy = (y2 - y1) / length;
for (let d = 0; d < length; d += dash + gap) {
const end = Math.min(length, d + dash);
g.moveTo(x1 + ux * d, y1 + uy * d).lineTo(x1 + ux * end, y1 + uy * end);
}
}