Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1eb28314cf
commit
f140c2fd77
58 files changed
+1969
-1171
No files matched your search
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The playing view: the running world seen through the scene's camera. The camera's frame
|
||||
* defines what is visible and is the only thing drawn – it is letterboxed into the widget and
|
||||
* everything outside it is masked away. Without a world or without a camera nothing renders.
|
||||
*
|
||||
* Nothing here edits: the view reports the pointer to the world (so graphs can use Mouse
|
||||
* position, Is hovered and On click) and otherwise only draws.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
import { textStyle } from '../ui';
|
||||
import { collectWorldItems } from './items';
|
||||
import { cameraView, isInside, screenToWorld, type View } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export class GameViewport extends WorldViewport {
|
||||
/** Shown while no world is running. */
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
private playWorld: World | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
this.hint.eventMode = 'none';
|
||||
this.hint.anchor.set(0.5);
|
||||
this.addChild(this.hint);
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
this.reportPointer(e);
|
||||
this.playWorld?.setPointerDown(true);
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e));
|
||||
this.on('pointerup', () => this.playWorld?.setPointerDown(false));
|
||||
this.on('pointerupoutside', () => this.playWorld?.setPointerDown(false));
|
||||
}
|
||||
|
||||
/** The world to draw (null = not playing). */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View | null {
|
||||
const camera = this.playWorld?.firstOfType('camera');
|
||||
return camera ? cameraView(camera, this.w, this.h) : null;
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
// Only reached with a view, which requires a world with a camera.
|
||||
return this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR };
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return LETTERBOX_COLOR;
|
||||
}
|
||||
|
||||
protected override beforeItems(): void {
|
||||
this.hint.visible = !this.playWorld;
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the mouse position to the running world in world units. Outside the camera frame
|
||||
* counts as "not in view" – that region is letterbox, not game.
|
||||
*/
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.playWorld;
|
||||
const view = this.currentView();
|
||||
if (!world || !view) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const point = screenToWorld(view, local.x, local.y);
|
||||
world.movePointer(point.x, point.y, isInside(view, local.x, local.y));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* The editable view of a scene: the model drawn with a grid, selection outlines, drag-to-move
|
||||
* (all selected objects at once), rubber-band selection on empty space, Ctrl-click to add or
|
||||
* remove, middle-drag or Shift-drag panning and wheel zoom.
|
||||
*
|
||||
* It shows `state.project` – in play mode that is the runtime copy, so the live positions are
|
||||
* visible and stay fully editable.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../core/model/hierarchy';
|
||||
import type { Position, Vec2 } from '../core/model/Properties';
|
||||
import { footprintOf } from '../core/properties/bounds';
|
||||
import { type EditorState, objectSelection } from '../editor/state';
|
||||
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui';
|
||||
import { collectSceneItems, type DrawItem } from './items';
|
||||
import { dashedLine } from './symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
/** Pixels the pointer must travel before a press on an object starts moving it. */
|
||||
const DRAG_THRESHOLD = 2;
|
||||
const GRID_STEP = 100;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
/** A drag of the selected objects: every moved root with the local position it started from. */
|
||||
interface ObjectDrag {
|
||||
sceneId: string;
|
||||
items: { objectId: string; start: Position }[];
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress (viewport-local pixels). */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class SceneViewport extends WorldViewport {
|
||||
private readonly grid = new Graphics();
|
||||
/** Dashed parent links (behavior → owner), drawn in world units beneath the symbols. */
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
/** 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: ObjectDrag | null = null;
|
||||
private marquee: Marquee | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
// Grid behind the content, links below the symbols, gizmos above everything clipped.
|
||||
this.addChildAt(this.grid, 1);
|
||||
this.world.addChildAt(this.links, 1);
|
||||
this.content.addChild(this.gizmos);
|
||||
this.links.eventMode = 'none';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
state.on('load', () => {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
this.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/** World coordinates at the centre of the view (used to place assets). */
|
||||
worldCenter(): Vec2 {
|
||||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||||
}
|
||||
|
||||
// --- the view -------------------------------------------------------------------------------
|
||||
|
||||
protected override currentView(): View {
|
||||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
return collectSceneItems(this.state, Theme.colors.viewport);
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return Theme.colors.viewport;
|
||||
}
|
||||
|
||||
/** Without a scene there is nothing to frame – the neutral base colour stands alone. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return Boolean(this.state.activeScene);
|
||||
}
|
||||
|
||||
protected override get itemsInteractive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- pointer --------------------------------------------------------------------------------
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
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) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band (decided on release).
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false };
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
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) < DRAG_THRESHOLD) return;
|
||||
this.dragging.moved = true;
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
|
||||
for (const item of this.dragging.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z };
|
||||
}
|
||||
this.state.preview();
|
||||
} else if (this.marquee) {
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee.current = { x: local.x, y: local.y };
|
||||
if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return;
|
||||
this.marquee.active = true;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
this.panning = null;
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee) {
|
||||
if (marquee.active) this.selectInMarquee(marquee);
|
||||
else this.state.select(null);
|
||||
this.redraw();
|
||||
}
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (!drag || !drag.moved) return;
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// The preview moved the objects already; put them back so the command records the start.
|
||||
const moves: MoveSceneObjectCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
if (!object) continue;
|
||||
const next = { ...object.position };
|
||||
object.position = { ...item.start };
|
||||
moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const scene = this.state.activeScene;
|
||||
const object = scene && findObject(scene, objectId);
|
||||
if (!scene || !object) return;
|
||||
e.stopPropagation();
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleObject(scene.id, objectId);
|
||||
return;
|
||||
}
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId]));
|
||||
// Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, id))
|
||||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||||
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
|
||||
const local = this.toLocal(e.global);
|
||||
this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Rubber band released: selects every object whose drawn position lies inside it. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
const view = this.currentView();
|
||||
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
|
||||
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
|
||||
const minX = Math.min(a.x, b.x);
|
||||
const maxX = Math.max(a.x, b.x);
|
||||
const minY = Math.min(a.y, b.y);
|
||||
const maxY = Math.max(a.y, b.y);
|
||||
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
|
||||
this.state.select(objectSelection(scene.id, ids));
|
||||
}
|
||||
|
||||
// --- decorations ----------------------------------------------------------------------------
|
||||
|
||||
protected override beforeItems(): void {
|
||||
this.syncGrid();
|
||||
this.gizmos.clear();
|
||||
}
|
||||
|
||||
protected override afterItems(items: DrawItem[], view: View): void {
|
||||
this.drawParentLinks(items, view.scale);
|
||||
this.drawGizmos(items, view);
|
||||
}
|
||||
|
||||
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, size: Size): void {
|
||||
this.syncLabel(item, onScreen, scale, size);
|
||||
}
|
||||
|
||||
protected override disposeDecorations(id: string): void {
|
||||
this.labels.get(id)?.destroy();
|
||||
this.labels.delete(id);
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.view.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
const origin = worldToScreen(this.currentView(), 0, 0);
|
||||
drawGrid(this.grid, { originX: origin.x, originY: origin.y, step, w: this.w, h: this.h, alpha: 0.05, axisAlpha: 0.15 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed line from every parented symbol (behavior, camera) to its parent. 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[], scale: number): void {
|
||||
this.links.clear();
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
for (const item of items) {
|
||||
if (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
|
||||
const parent = byId.get(item.parentId);
|
||||
if (!parent || !parent.enabled) 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Name label next to a symbol. */
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: Size): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** Selection outlines (the primary object gets the corner handles) and the rubber band. */
|
||||
private drawGizmos(items: DrawItem[], view: View): void {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
// Nothing is outlined for a switched-off object – it is not drawn either.
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
const tl = worldToScreen(view, item.x - w / 2, item.y - h / 2);
|
||||
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 });
|
||||
if (id !== s.objectId) continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
const m = this.marquee;
|
||||
if (m?.active) {
|
||||
const x = Math.min(m.start.x, m.current.x);
|
||||
const y = Math.min(m.start.y, m.current.y);
|
||||
const w = Math.abs(m.current.x - m.start.x);
|
||||
const h = Math.abs(m.current.y - m.start.y);
|
||||
this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../core/model/hierarchy';
|
||||
import { type Position, sortByZ, type Vec2 } from '../core/model/Properties';
|
||||
import { footprintOf, textAnchor } from '../core/properties/bounds';
|
||||
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||||
import { type EditorState, objectSelection } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
import { type Size, Theme, textStyle, Widget } from '../ui';
|
||||
import { collectSceneItems, collectWorldItems, type DrawItem } from './items';
|
||||
import { dashedLine, drawItem } from './symbols';
|
||||
|
||||
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
const GRID_STEP = 100;
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
/**
|
||||
* 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 the scene view the rectangle is the whole viewport; in the game view 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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** A drag of the selected objects: every moved root with the local position it started from. */
|
||||
interface ObjectDrag {
|
||||
sceneId: string;
|
||||
items: { objectId: string; start: Position }[];
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress (viewport-local pixels). */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
current: Vec2;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a scene as coloured boxes. The **scene view** shows the model with a grid, selection
|
||||
* outlines, drag-to-move (all selected objects at once), rubber-band selection on empty
|
||||
* space, Ctrl-click to add/remove, middle-drag or Shift-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 parent 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>();
|
||||
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private playWorld: World | null = null;
|
||||
/** Scene 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: ObjectDrag | null = null;
|
||||
private marquee: Marquee | null = null;
|
||||
private visibleCount = 0;
|
||||
/** Items of the last redraw (for hit tests such as the rubber band). */
|
||||
private lastItems: DrawItem[] = [];
|
||||
|
||||
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) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
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 scene 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) };
|
||||
}
|
||||
|
||||
// --- test hooks ------------------------------------------------------------------------------
|
||||
|
||||
/** 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: 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. */
|
||||
drawOrder(): string[] {
|
||||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||
}
|
||||
|
||||
/** Global screen position of an object's drawn position, null when not drawn. */
|
||||
objectScreen(objectId: string): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!item || !view) return null;
|
||||
const local = this.worldToScreen(item.x, item.y, view);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
// --- pointer ---------------------------------------------------------------------------------
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (this.game) {
|
||||
if (e.button === 0) {
|
||||
this.reportPointer(e);
|
||||
this.playWorld?.setPointerDown(true);
|
||||
}
|
||||
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) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band (decided on release).
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false };
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.game) {
|
||||
this.reportPointer(e);
|
||||
return;
|
||||
}
|
||||
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 scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
|
||||
for (const item of this.dragging.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z };
|
||||
}
|
||||
this.state.preview();
|
||||
} else if (this.marquee) {
|
||||
const local = this.toLocal(e.global);
|
||||
this.marquee.current = { x: local.x, y: local.y };
|
||||
if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return;
|
||||
this.marquee.active = true;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
if (this.game) {
|
||||
this.playWorld?.setPointerDown(false);
|
||||
return;
|
||||
}
|
||||
this.panning = null;
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee) {
|
||||
if (marquee.active) this.selectInMarquee(marquee);
|
||||
else this.state.select(null);
|
||||
this.redraw();
|
||||
}
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (!drag || !drag.moved) return;
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// The preview moved the objects already; put them back so the command records the start.
|
||||
const moves: MoveSceneObjectCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
if (!object) continue;
|
||||
const next = { ...object.position };
|
||||
object.position = { ...item.start };
|
||||
moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
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();
|
||||
}
|
||||
|
||||
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (this.game || e.button !== 0) return;
|
||||
const scene = this.state.activeScene;
|
||||
const object = scene && findObject(scene, objectId);
|
||||
if (!scene || !object) return;
|
||||
e.stopPropagation();
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleObject(scene.id, objectId);
|
||||
return;
|
||||
}
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId]));
|
||||
// Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, id))
|
||||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||||
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
|
||||
const local = this.toLocal(e.global);
|
||||
this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Rubber band released: selects every object whose drawn position lies inside it. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
const a = this.screenToWorld(marquee.start.x, marquee.start.y);
|
||||
const b = this.screenToWorld(marquee.current.x, marquee.current.y);
|
||||
const minX = Math.min(a.x, b.x);
|
||||
const maxX = Math.max(a.x, b.x);
|
||||
const minY = Math.min(a.y, b.y);
|
||||
const maxY = Math.max(a.y, b.y);
|
||||
const ids = this.lastItems.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
|
||||
this.state.select(objectSelection(scene.id, ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Game view: hands the mouse position to the running world in world units, so graphs can
|
||||
* use it (Mouse position, Is hovered, On click). Outside the camera frame counts as "not
|
||||
* in view" – that region is letterbox, not game.
|
||||
*/
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.playWorld;
|
||||
const view = this.currentView();
|
||||
if (!world || !view) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const point = this.screenToWorld(local.x, local.y, view);
|
||||
const inside = local.x >= view.rect.x && local.x <= view.rect.x + view.rect.w && local.y >= view.rect.y && local.y <= view.rect.y + view.rect.h;
|
||||
world.movePointer(point.x, point.y, inside);
|
||||
}
|
||||
|
||||
// --- 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 ---------------------------------------------------------------------------------
|
||||
|
||||
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.game
|
||||
? this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR }
|
||||
: collectSceneItems(this.state, Theme.colors.viewport);
|
||||
this.lastItems = items;
|
||||
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 = footprintOf(item.type, item.props);
|
||||
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
|
||||
// in the hierarchy panel, which is where it is selected and switched back on.
|
||||
const onScreen = item.enabled && 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);
|
||||
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
|
||||
}
|
||||
node.visible = onScreen;
|
||||
node.eventMode = editing && onScreen ? 'static' : 'none';
|
||||
this.syncLabel(item, editing && onScreen, view.scale, size);
|
||||
this.syncText(item, onScreen, view.scale);
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
node.position.set(item.x, item.y);
|
||||
drawItem(node, item, size, view.scale);
|
||||
}
|
||||
this.drawParentLinks(items, editing, view.scale);
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (seen.has(id)) continue;
|
||||
this.texts.get(id)?.destroy();
|
||||
this.texts.delete(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) => !isSymbolType(item.type)));
|
||||
this.objects.removeChildren();
|
||||
for (const item of ordered) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (node) this.objects.addChild(node);
|
||||
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
|
||||
const parent = byId.get(item.parentId);
|
||||
if (!parent || !parent.enabled) 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows
|
||||
* the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be
|
||||
* wasteful, blurry text is worse.
|
||||
*/
|
||||
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
||||
let text = this.texts.get(item.id);
|
||||
if (item.type !== 'text' || !onScreen) {
|
||||
if (text) text.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
|
||||
text.eventMode = 'none';
|
||||
this.texts.set(item.id, text);
|
||||
this.objects.addChild(text);
|
||||
}
|
||||
text.visible = true;
|
||||
text.position.set(item.x, item.y);
|
||||
text.text = String(item.props.text ?? '');
|
||||
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
||||
if (text.style.fontSize !== size) text.style.fontSize = size;
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
if (text.style.fill !== color) text.style.fill = color;
|
||||
text.anchor.set(textAnchor(item.props), 0.5);
|
||||
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
|
||||
if (text.resolution !== resolution) text.resolution = resolution;
|
||||
}
|
||||
|
||||
/** Name label next to a symbol (scene view only). */
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: { w: number; h: number }): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
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);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
/** Selection outlines (the primary object gets the corner handles) and the rubber band. */
|
||||
private drawGizmos(items: DrawItem[], view: View): void {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
// Nothing is outlined for a switched-off object – it is not drawn either.
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
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 });
|
||||
if (id !== s.objectId) continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
const m = this.marquee;
|
||||
if (m?.active) {
|
||||
const x = Math.min(m.start.x, m.current.x);
|
||||
const y = Math.min(m.start.y, m.current.y);
|
||||
const w = Math.abs(m.current.x - m.start.x);
|
||||
const h = Math.abs(m.current.y - m.start.y);
|
||||
this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* What the scene view and the game view have in common: turning a list of `DrawItem`s into
|
||||
* Pixi display objects, keeping them in z order, culling what is off screen and clipping the
|
||||
* whole thing to the view rectangle.
|
||||
*
|
||||
* The two differ in *where* the items come from and in what may be done with them, not in how
|
||||
* they are drawn – so the differences are the abstract members below, not `if` branches.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { sortByZ, type Vec2 } from '../core/model/Properties';
|
||||
import { footprintOf, textAnchor } from '../core/properties/bounds';
|
||||
import { rectAround, rectsIntersect } from '../core/properties/camera';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import { type Size, Theme, Widget } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import { drawItem } from './symbols';
|
||||
import { type View, worldBounds, worldToScreen } from './view';
|
||||
|
||||
/** What a redraw draws: the items and the colour behind them. */
|
||||
export interface Frame {
|
||||
items: DrawItem[];
|
||||
background: number;
|
||||
}
|
||||
|
||||
export abstract class WorldViewport extends Widget {
|
||||
protected readonly bg = new Graphics();
|
||||
/** Everything that may extend past the viewport is clipped through this container. */
|
||||
protected readonly content = new Container();
|
||||
protected readonly world = new Container();
|
||||
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||||
protected 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.
|
||||
*/
|
||||
protected readonly overlay = new Container();
|
||||
protected readonly contentMask = new Graphics();
|
||||
protected readonly nodes = new Map<string, Graphics>();
|
||||
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private visibleCount = 0;
|
||||
/** Items of the last redraw (for hit tests such as the rubber band). */
|
||||
protected lastItems: DrawItem[] = [];
|
||||
|
||||
protected constructor(protected readonly state: EditorState) {
|
||||
super();
|
||||
this.world.addChild(this.objects, this.overlay);
|
||||
this.content.addChild(this.world);
|
||||
this.addChild(this.bg, this.content, this.contentMask);
|
||||
this.content.mask = this.contentMask;
|
||||
this.cursor = 'default';
|
||||
|
||||
state.on('project', () => 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(); });
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- what the two views differ in ------------------------------------------------------------
|
||||
|
||||
/** The active mapping, or null when there is nothing to show. */
|
||||
protected abstract currentView(): View | null;
|
||||
|
||||
/** The items to draw and the colour behind them. Only called when `currentView()` is set. */
|
||||
protected abstract collectFrame(): Frame;
|
||||
|
||||
/** Colour of the whole widget, behind and around the view rectangle. */
|
||||
protected abstract get baseColor(): number;
|
||||
|
||||
/** Whether the view rectangle is filled with the frame's background colour. */
|
||||
protected paintsBackground(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether item nodes take pointer events (only the editable view lets objects be grabbed). */
|
||||
protected get itemsInteractive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runs before the items are drawn, with the view (null = nothing to show). */
|
||||
protected beforeItems(_view: View | null): void {}
|
||||
|
||||
/** Runs after the items are drawn – decorations that belong on top. */
|
||||
protected afterItems(_items: DrawItem[], _view: View): void {}
|
||||
|
||||
/** Per item, every redraw: anything drawn beside the item itself (labels). */
|
||||
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _size: Size): void {}
|
||||
|
||||
/** An item disappeared: drop whatever `syncDecorations` created for it. */
|
||||
protected disposeDecorations(_id: string): void {}
|
||||
|
||||
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
|
||||
|
||||
// --- test hooks ------------------------------------------------------------------------------
|
||||
|
||||
/** 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: 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. */
|
||||
drawOrder(): string[] {
|
||||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||
}
|
||||
|
||||
/** Global screen position of an object's drawn position, null when not drawn. */
|
||||
objectScreen(objectId: string): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!item || !view) return null;
|
||||
const local = worldToScreen(view, item.x, item.y);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
// --- drawing ---------------------------------------------------------------------------------
|
||||
|
||||
override redraw(): void {
|
||||
const view = this.currentView();
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor);
|
||||
this.beforeItems(view);
|
||||
this.contentMask.clear();
|
||||
this.content.visible = view !== null;
|
||||
this.visibleCount = 0;
|
||||
if (!view) return;
|
||||
|
||||
const { items, background } = this.collectFrame();
|
||||
this.lastItems = items;
|
||||
const { rect } = view;
|
||||
if (this.paintsBackground()) 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 = 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 = footprintOf(item.type, item.props);
|
||||
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
|
||||
// in the hierarchy panel, which is where it is selected and switched back on.
|
||||
const onScreen = item.enabled && 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);
|
||||
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
|
||||
}
|
||||
node.visible = onScreen;
|
||||
node.eventMode = this.itemsInteractive && onScreen ? 'static' : 'none';
|
||||
this.syncDecorations(item, onScreen, view.scale, size);
|
||||
this.syncText(item, onScreen, view.scale);
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
node.position.set(item.x, item.y);
|
||||
drawItem(node, item, size, view.scale);
|
||||
}
|
||||
this.afterItems(items, view);
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (seen.has(id)) continue;
|
||||
this.texts.get(id)?.destroy();
|
||||
this.texts.delete(id);
|
||||
node.destroy();
|
||||
this.nodes.delete(id);
|
||||
this.disposeDecorations(id);
|
||||
}
|
||||
this.syncDrawOrder(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => !isSymbolType(item.type)));
|
||||
this.objects.removeChildren();
|
||||
for (const item of ordered) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (node) this.objects.addChild(node);
|
||||
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows
|
||||
* the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be
|
||||
* wasteful, blurry text is worse.
|
||||
*/
|
||||
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
||||
let text = this.texts.get(item.id);
|
||||
if (item.type !== 'text' || !onScreen) {
|
||||
if (text) text.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
|
||||
text.eventMode = 'none';
|
||||
this.texts.set(item.id, text);
|
||||
this.objects.addChild(text);
|
||||
}
|
||||
text.visible = true;
|
||||
text.position.set(item.x, item.y);
|
||||
text.text = String(item.props.text ?? '');
|
||||
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
||||
if (text.style.fontSize !== size) text.style.fontSize = size;
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
if (text.style.fill !== color) text.style.fill = color;
|
||||
text.anchor.set(textAnchor(item.props), 0.5);
|
||||
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
|
||||
if (text.resolution !== resolution) text.resolution = resolution;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { isHandleEnabled, worldPositionOf } from '../core/graph';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||||
import { editorPosition, treeOrder } from '../core/model/hierarchy';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { enabledAlongChain } from '../core/properties/enabled';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { World } from '../runtime/World';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
|
||||
/** What the viewport draws: the same shape whether it comes from the model or the world. */
|
||||
export interface DrawItem {
|
||||
@@ -29,7 +28,7 @@ export interface DrawItem {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DrawList {
|
||||
interface DrawList {
|
||||
items: DrawItem[];
|
||||
background: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* How world coordinates map onto a viewport. Pure geometry – no Pixi, no editor state – so
|
||||
* both viewports share one definition of "where does this world point land on screen".
|
||||
*/
|
||||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||||
import { cameraFrame, rectAround, type WorldRect } from '../core/properties/camera';
|
||||
|
||||
/**
|
||||
* The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local
|
||||
* pixels) at `scale` pixels per world unit. In the scene view the rectangle is the whole
|
||||
* viewport; in the game view it is the camera's frame fitted into it (letterboxed).
|
||||
*/
|
||||
export interface View {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Free-panning view: the whole viewport shows the world around (`x`, `y`) at `zoom`. */
|
||||
export function freeView(x: number, y: number, zoom: number, w: number, h: number): View {
|
||||
return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's frame scaled to fit inside `w`×`h` and centred – the remaining margin is the
|
||||
* letterbox, which is not part of the game area (see `isInside`).
|
||||
*/
|
||||
export function cameraView(camera: { x: number; y: number; props: PropertyBag }, w: number, h: number): View {
|
||||
const frame = cameraFrame(camera.props);
|
||||
const scale = Math.min(w / frame.w, h / frame.h);
|
||||
const fw = frame.w * scale;
|
||||
const fh = frame.h * scale;
|
||||
return { x: camera.x, y: camera.y, scale, rect: { x: (w - fw) / 2, y: (h - fh) / 2, w: fw, h: fh } };
|
||||
}
|
||||
|
||||
export function screenToWorld(view: View, sx: number, sy: number): 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 };
|
||||
}
|
||||
|
||||
export function worldToScreen(view: View, wx: number, wy: number): 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 };
|
||||
}
|
||||
|
||||
/** World region the view covers – what a redraw has to consider at all. */
|
||||
export function worldBounds(view: View): WorldRect {
|
||||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||||
}
|
||||
|
||||
/** Whether a viewport-local point lies on the drawn rectangle rather than in the letterbox. */
|
||||
export function isInside(view: View, sx: number, sy: number): boolean {
|
||||
const { x, y, w, h } = view.rect;
|
||||
return sx >= x && sx <= x + w && sy >= y && sy <= y + h;
|
||||
}
|
||||
Reference in new issue
Block a user