Files
feuerwerk/src/editor/viewport/SceneViewport.ts
T
rootandClaude Opus 5 78baf0c719
game-maker / suite (push) Successful in 9m49s
A source wears an icon without becoming decoration (#89)
Behavior and Container draw an icon because isSymbolType() is true for them,
but that predicate does not mean "draws an icon" — it means "is editor
decoration". Four places read it that way: collectWorldItems, GameViewport.
collectFrame and syncDrawOrder all drop symbols out of the running world and
the game picture, and placementOf() gives them an upright unscaled box.
Listing 'particles' there would have handed the source an icon and taken away
its particles, in the scene view as well as in the game.

So the two questions are two functions now. hasSceneIcon() says a type draws
an icon; isSymbolType() keeps meaning decoration. The icon itself is a
SceneViewport decoration, which is editor-only for free because GameViewport
never syncs decorations.

A ring (r 10, #c26a1f) with four sparks, upright and unscaled like the other
symbols, its middle left open — unlike Behavior and Container a source draws
something of its own, and the field is densest exactly there. The name sits
beside it and a dashed line runs to the parent, same colour.

Found while writing the tests: the source's grab box scaled with its transform
while the icon did not, so the two came apart. placementOf() returns only the
translation for 'particles', making the box the upright 24 x 24 that covers
its icon. The source's own transform still feeds every particle untouched.

824 checks (821 + 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:25:49 +02:00

552 lines
21 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.
/**
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
* copy, so live positions are visible and stay editable. A container's template is the same
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { hasSceneIcon } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core/model/matrix';
import { placementRejection } from '../../core/model/placement';
import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { footprintCorners, footprintExtent } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { boundsOf } from '../framing';
import { type EditorState, objectSelection } from '../state';
import {
activeDrag,
clamp,
drawBand,
drawGrid,
endDrag,
onDragChange,
Theme,
textStyle,
zoomFactor,
} from '../../ui';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import { dashedLine, drawSourceIcon } from '../../viewport/symbols';
import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type Frame, WorldViewport } from './WorldViewport';
/** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3;
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;
/** Breathing room left around the content by `fit()`, in screen pixels. */
const FIT_MARGIN = 40;
interface ObjectDrag {
holderId: string;
/** `toLocal` turns a world delta into one in the object's parent frame (the parent may be turned or sized). */
items: { objectId: string; start: Position; toLocal: Mat }[];
pointer: Vec2;
moved: boolean;
}
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
interface DropPreview {
assetId: string;
at: Vec2;
rejection: string;
}
/** A rubber-band selection in progress, in viewport-local pixels. */
interface Marquee {
start: Vec2;
current: Vec2;
active: boolean;
}
/** One selection outline that was drawn – `alpha` is the value the stroke really got. */
export interface GizmoInfo {
id: string;
primary: boolean;
alpha: number;
}
const OFF_GIZMO_ALPHA = 0.45;
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
behavior: Theme.colors.behavior,
particles: Theme.colors.particles,
};
export class SceneViewport extends WorldViewport {
private readonly grid = new Graphics();
private readonly links = new Graphics();
private readonly gizmos = new Graphics();
private readonly labels = new Map<string, Text>();
private readonly icons = new Map<string, Graphics>();
/** 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 dropPreview: DropPreview | null = null;
private lastGizmos: GizmoInfo[] = [];
/** Ids that got an owner link this frame (test hook). */
private lastLinks: string[] = [];
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();
});
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
// about it, so the preview would stay on screen without this.
onDragChange(() => {
if (!activeDrag()) this.setDropPreview(null);
});
}
/** What this view shows and edits; the Container tab returns its container asset instead. */
protected holder(): ObjectHolder | undefined {
return this.state.activeScene;
}
/** The holder with the content of placed containers resolved in – what the view shows. */
private resolved(): ObjectHolder | undefined {
const holder = this.holder();
return holder && resolvedHolder(this.state.project, holder);
}
/** The pending drop: where a dragged asset would land and whether it may (test hook). */
dropInfo(): { assetId: string; x: number; y: number; rejection: string } | null {
const p = this.dropPreview;
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
}
/**
* Frames everything the holder contains. The origin is always included: it is where a container
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
*/
fit(): void {
const { items } = this.collectFrame();
const boxes = items.map((item) => {
const e = footprintExtent(item.type, item.props, item.m);
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
});
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
const width = Math.max(maxX - minX, 1);
const height = Math.max(maxY - minY, 1);
this.view = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
zoom: clamp(
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
MIN_ZOOM,
MAX_ZOOM,
),
};
this.redraw();
}
/** Centres the view on a world point, keeping the zoom (test hook). */
lookAt(x: number, y: number): void {
this.view.x = x;
this.view.y = y;
this.redraw();
}
protected override currentView(): View {
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
}
protected override collectFrame(): Frame {
return collectHolderItems(this.state.project, this.holder(), 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.holder());
}
protected override get itemsInteractive(): boolean {
return true;
}
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 {
const incoming = activeDrag();
if (incoming) {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', 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) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
const holder = this.holder();
if (!holder) return;
for (const item of this.dragging.items) {
const local = applyVector(item.toLocal, { x: dx, y: dy });
objectRef(this.state.project, holder, item.objectId)?.setPosition({
x: Math.round(item.start.x + local.x),
y: Math.round(item.start.y + local.y),
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();
}
}
/**
* Follows an asset dragged in from the panel. `assetId` is empty for payloads this view does
* not take; the preview is dropped whenever the pointer leaves it, so nothing lands blind.
*/
private trackIncomingAsset(assetId: string, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const holder = this.holder();
const asset = assetId ? findAsset(this.state.project, assetId) : undefined;
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
if (!asset || !holder || !inside) {
this.setDropPreview(null);
return;
}
const world = screenToWorld(this.currentView(), local.x, local.y);
this.setDropPreview({
assetId,
at: { x: Math.round(world.x), y: Math.round(world.y) },
rejection: placementRejection(this.state.project, holder, asset),
});
}
private setDropPreview(preview: DropPreview | null): void {
if (!preview && !this.dropPreview) return;
this.dropPreview = preview;
this.redraw();
}
private dropAsset(preview: DropPreview): void {
const holder = this.holder();
if (!holder) return;
if (preview.rejection) {
this.state.setNotice(preview.rejection);
return;
}
const command = new PlaceAssetCommand(holder.id, preview.assetId, preview.at);
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
private onPointerUp(): void {
const preview = this.dropPreview;
if (preview) {
this.setDropPreview(null);
endDrag();
this.dropAsset(preview);
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 holder = this.holder();
if (!holder) 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 ref = objectRef(this.state.project, holder, item.objectId);
if (!ref) continue;
const next = { ...ref.object.position };
ref.setPosition(item.start);
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
}
if (moves.length > 0) this.state.execute(oneStep('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 holder = this.resolved();
const object = holder && findObject(holder, objectId);
if (!holder || !object) return;
e.stopPropagation();
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.state.toggleObject(holder.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(holder.id, [objectId]));
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
const items = subtreeRoots(holder, this.state.selectedObjectIds)
.map((id) => findObject(holder, id))
.filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => ({
objectId: o.id,
start: { ...o.position },
toLocal: invert(linearOf(parentTransform(this.state.project, holder, o.parentId).m)),
}));
const local = this.toLocal(e.global);
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
}
/** Selects every object whose position – not its bounds – lies inside the band. */
private selectInMarquee(marquee: Marquee): void {
const holder = this.holder();
if (!holder) 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(holder.id, ids));
}
protected override beforeItems(): void {
this.syncGrid();
this.gizmos.clear();
}
/** The selection outlines currently drawn (test hook). */
gizmoInfo(): GizmoInfo[] {
return this.lastGizmos;
}
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, extent: Extent): void {
this.syncIcon(item, onScreen);
this.syncLabel(item, onScreen, scale, extent);
}
protected override disposeDecorations(id: string): void {
this.labels.get(id)?.destroy();
this.labels.delete(id);
this.icons.get(id)?.destroy();
this.icons.delete(id);
}
/** The icon of a particle source, the name beside it and whether it hangs off its owner (test hook). */
iconInfo(id: string): { layer: string; visible: boolean; label: string; link: boolean } | null {
const icon = this.icons.get(id);
if (!icon) return null;
const label = this.labels.get(id);
return {
layer: icon.parent === this.overlay ? 'overlay' : 'objects',
visible: icon.visible,
label: label?.visible ? label.text : '',
link: this.lastLinks.includes(id),
};
}
/**
* A source draws only its particles, so without a mark of its own an empty one is invisible and
* cannot be found (#89). The mark is a decoration rather than a symbol: `GameViewport` never syncs
* decorations, so it is editor-only without the source leaving the running game.
*/
private syncIcon(item: DrawItem, show: boolean): void {
let icon = this.icons.get(item.id);
if (!show || item.type !== 'particles') {
if (icon) icon.visible = false;
return;
}
if (!icon) {
icon = new Graphics();
icon.eventMode = 'none';
drawSourceIcon(icon);
this.icons.set(item.id, icon);
this.overlay.addChild(icon);
}
icon.visible = true;
icon.position.set(item.x, item.y);
}
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 to its parent. Sprites show their nesting in the
* hierarchy panel instead, and colliders sit on their body, so neither gets a line.
*/
private drawParentLinks(items: DrawItem[], scale: number): void {
this.links.clear();
this.lastLinks = [];
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) {
if (!hasSceneIcon(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
const parent = byId.get(item.parentId);
if (!parent || !parent.enabled) continue;
this.lastLinks.push(item.id);
const color = LINK_COLORS[item.type] ?? 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, extent: Extent): void {
let label = this.labels.get(item.id);
if (!show || !hasSceneIcon(item.type)) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
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(extent.left, extent.top - 16 / scale);
else label.position.set(extent.right + 4 / scale, item.y - 8 / scale);
}
/**
* Outline at the size the object will have – the footprint answers "does it fit here", which a
* cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps.
*/
private drawDropPreview(view: View): void {
const preview = this.dropPreview;
const asset = preview && findAsset(this.state.project, preview.assetId);
if (!preview || !asset) return;
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
const m = localMatrixOf(preview.at, transformOf(asset.props));
const centre = worldToScreen(view, preview.at.x, preview.at.y);
this.gizmos
.poly(this.screenCorners(footprintCorners(asset.type, asset.props, m), view), true)
.fill({ color, alpha: 0.12 })
.stroke({ width: 2, color });
this.gizmos
.moveTo(centre.x - 6, centre.y)
.lineTo(centre.x + 6, centre.y)
.moveTo(centre.x, centre.y - 6)
.lineTo(centre.x, centre.y + 6)
.stroke({ width: 1, color });
}
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
return corners.map((c) => worldToScreen(view, c.x, c.y));
}
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
private drawGizmos(items: DrawItem[], view: View): void {
this.lastGizmos = [];
const s = this.state.selection;
if (s?.kind === 'object') {
for (const id of s.objectIds) {
const item = items.find((i) => i.id === id);
if (!item) continue;
// Two screen pixels outside the footprint, whichever way it is turned.
const corners = this.screenCorners(
footprintCorners(item.type, item.props, item.m, 2 / view.scale),
view,
);
// A switched-off object draws nothing, so its outline is the only way to see where it
// sits – dimmed, because the empty frame would otherwise read as a drawing that failed.
const alpha = item.enabled ? 1 : OFF_GIZMO_ALPHA;
const primary = id === s.objectId;
this.lastGizmos.push({ id, primary, alpha });
this.gizmos.poly(corners, true).stroke({ width: 2, color: Theme.colors.gizmo, alpha });
if (!primary) continue;
for (const c of corners)
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
}
this.drawDropPreview(view);
const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
}