Files
feuerwerk/src/editor/viewport/SceneViewport.ts
T
adminandClaude Opus 5.5 0c41cb97b1
game-maker / gate (push) Successful in 3m43s
game-maker / suite (push) Failing after 11m7s
Clip the children of an object to a clip area (#260)
An inventory grid could not scroll, because nothing could cut a child
off at an edge. A game also had no way to hear the mouse wheel. Both
are needed for a scrolling list, a dialog text or a window with more
content than room.

- A new object type Clip area has width and height and turns, scales
  and mirrors with its parent. The size gizmo drags width and height.
- Everything below a clip area, on every level, is drawn only inside
  its rectangle, in the Scene view as in the game. Nested areas cut to
  their intersection. The rule works on a convex polygon, so a turned
  area cuts right as well.
- On click, Is hovered and On mouse wheel do not hit a cut-off part.
  The Scene view does not pick it either.
- In the Scene view the area is a dashed frame that is grabbed only at
  its edge, so a click inside lands on the content. The eye under
  Shape hides the frame. In the game the area is invisible.
- While an object below an area or one of its parents is selected, the
  Scene view shows the cut-off part at 30 % opacity and it can be
  grabbed there. The rule lives in core/properties/clip.ts.
- The Properties node knows the type Clip area.
- The new event node On mouse wheel fires by the rule of On click. Its
  delta counts in notches, positive is down, and a frame's turns
  arrive as one value. It also gives x and y.

Limits:

- Only sprites and texts show the faded rest. Tilemaps and particles
  are cut and can be grabbed when open, but are not shown faded.
- Masks by texture shape are out, as noted in the issue.

Tests:

- ./check.sh: 2773 tests, CHECK EXIT 0.
- Full e2e run (local): ALL PASSED, 1549 checks, measured.
- tests/core/clip-areas.test.ts: 12 tests for the cut rule, scene and
  game, save and load, hits, the wheel with and without owner, the
  notch conversion and the open rule.
- e2e 58, 5 checks with real mouse and wheel: the scene cuts (74 of 74
  red inside, 0 outside), a click on the cut part picks nothing, the
  selected rest is faded and grabbable, the game cuts, one notch
  logs 1.
- e2e 04 and 46 count the object types and the eye rows. They now
  include Clip area.
- Counter-checks: 6 in unit tests and 3 in the browser, all fail as
  expected. The originals were restored and checked with cmp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 07:08:00 +02:00

969 lines
39 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 { Container, type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { hasSceneIcon, isSymbolType } from '../../core/model/AssetTypes';
import { opensClip, regionContains } from '../../core/properties/clip';
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance';
import {
apply as applyPoint,
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 { cellAtPoint } from '../../core/model/tilemap';
import { footprintCorners, iconCorners } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { FramePreview } from '../framePreview';
import { type EditorState, objectSelection } from '../state';
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
import { activeDrag, drawBand, drawGrid, endDrag, Menu, onDragChange, Theme, textStyle } from '../../ui';
import { canPasteObjects, pasteObjects } from '../clipboard';
import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items';
import { sortRowOf } from '../../viewport/drawOrder';
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
import { openObjectMenu } from '../objectMenu';
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
import { shows, type VisibilityPart } from '../../core/model/sceneVisibility';
import { dashedLine } from '../../viewport/symbols';
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows';
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
import { cameraOfItems } from '../../viewport/activeCamera';
import { freeViewOfCamera, screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport';
import { PolygonCornerEditor } from './polygonCorners';
import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor';
import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost';
import { type PaintTarget, TilemapPainter } from './tilemapPainting';
import { TilemapSolidOutlines } from './tilemapSolids';
import type { Frame } from './WorldViewport';
/** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3;
const DRAG_THRESHOLD = 2;
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;
/** Selected on release when the press did not move: the next object below (#203). */
onClick: string | null;
}
/** 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;
/** Side of a symbol's icon in world units: it grows and shrinks with the zoom like the scene. */
const SYMBOL_SIZE = 18;
/** World units between the centres of icons that share a spot – an icon and its outline, and a hair. */
const SYMBOL_SPACING = 21;
/** Screen pixels round a symbol's name inside its plate, and between two rows of names. */
const LABEL_PAD_X = 3;
const LABEL_PAD_Y = 1;
const LABEL_ROW_GAP = 1;
const LABEL_PLATE_ALPHA = 0.7;
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody']);
/** 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,
rigidbody: Theme.colors.collider,
};
export class SceneViewport extends FreeViewport {
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>();
/** Names under icons, icons above everything else in the overlay – outlines and frames included. */
private readonly labelLayer = new Container();
/** The dark plate under every name shown, so it reads on any ground (see `placeLabels`). */
private readonly plates = new Graphics();
private readonly iconLayer = new Container();
/** Where each icon stood at the last redraw, in world units (see `layoutSymbols`). */
private spots = new Map<string, SymbolSpot>();
/** The plates of the names shown at the last redraw, in world units (test hook). */
private lastLabelBoxes: (WantedLabel & { left: boolean })[] = [];
private readonly solids = new TilemapSolidOutlines(this.overlay);
private readonly host: SceneViewHost = {
state: this.state,
holder: () => this.holder(),
items: () => this.lastItems,
worldAt: (global) => {
const local = this.toLocal(global);
return screenToWorld(this.currentView(), local.x, local.y);
},
local: (global) => {
const local = this.toLocal(global);
return { x: local.x, y: local.y };
},
view: () => this.currentView(),
scale: () => this.currentView().scale,
redraw: () => this.redraw(),
};
private readonly painter = new TilemapPainter(this.host);
private readonly corners = new PolygonCornerEditor(this.host);
private readonly sizer = new SizeGizmoEditor(this.host);
private editorWorld: World | null = null;
private lastRays: CastRay[] = [];
private dragging: ObjectDrag | null = null;
private readonly perFrame = new FramePreview(this.state, 'scene-drag');
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[] = [];
/** Last place the pointer was seen, in global coordinates – where a paste lands. */
private pointerGlobal: Vec2 | 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.labelLayer.eventMode = 'none';
this.labelLayer.addChild(this.plates);
this.overlay.addChild(this.labelLayer, this.iconLayer);
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());
state.on('load', () => {
this.resetView();
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;
}
protected override collectFrame(): Frame {
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport, this.editorWorld);
}
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;
}
/**
* The painted tilemap shows its layers the way its panel asks (#138), whatever the tool: the
* painted layer is worth seeing alone before the brush is taken up, too. Every other map, and
* this one once it is deselected, is drawn whole.
*/
protected override tilemapFocus(item: DrawItem): LayerFocus | null {
const mode = this.state.tilemapPaint.focus;
const target = mode === 'all' ? null : paintedTilemap(this.state);
if (!target || target.objectId !== item.id) return null;
return { layerId: paintedLayerId(this.state, target.asset), mode };
}
/**
* With a camera switched on, shows the world exactly as the game view does at its scale, so
* switching between the two tabs moves nothing. Without one, frames the whole content.
*/
fitScene(): void {
const camera = cameraOfItems(this.collectFrame().items);
if (!camera) return this.fit();
this.view = freeViewOfCamera(camera, this.w, this.h, this.state.gameScale, this.ratio());
this.redraw();
}
/** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */
pasteHere(): boolean {
const holder = this.holder();
return holder ? pasteObjects(this.state, holder.id, this.pointerWorld()) : false;
}
/** The pointer in world units, or null when it is not over this view. */
private pointerWorld(): Vec2 | null {
const global = this.pointerGlobal;
if (!global) return null;
const local = this.toLocal(global);
if (local.x < 0 || local.y < 0 || local.x > this.w || local.y > this.h) return null;
return screenToWorld(this.currentView(), local.x, local.y);
}
/** Empty space has nothing to act on but the clipboard. */
private openEmptyMenu(e: FederatedPointerEvent): void {
const holder = this.holder();
if (!holder) return;
const at = this.host.worldAt(e.global);
new Menu([
{
label: 'Paste',
disabled: !canPasteObjects(this.state),
onSelect: () => pasteObjects(this.state, holder.id, at),
},
]).open(e.global.x, e.global.y);
}
private onPointerDown(e: FederatedPointerEvent): void {
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.startPan(e);
return;
}
if (e.button === 2 && e.target === this) {
this.openEmptyMenu(e);
return;
}
if (e.button === 0 && (this.corners.press(e) || this.sizer.press(e))) return;
if (e.button === 0 && e.target === this) this.pressEmpty(e);
}
/** Empty space: a click deselects, a drag becomes a rubber band – decided on release. */
private pressEmpty(e: FederatedPointerEvent): void {
if (e.shiftKey) {
this.startPan(e);
return;
}
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 {
this.pointerGlobal = { x: e.global.x, y: e.global.y };
const incoming = activeDrag();
if (incoming) {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
return;
}
if (
this.panTo(e) ||
this.corners.move(e.global) ||
this.sizer.move(e.global, e.shiftKey) ||
this.painter.move(e.global)
)
return;
if (this.dragging) {
const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.worldScale();
const dy = (local.y - this.dragging.pointer.y) / this.worldScale();
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
this.state.heldObjectIds = new Set(this.dragging.items.map((item) => item.objectId));
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.perFrame.request();
} 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.endPan();
if (this.corners.release() || this.sizer.release() || this.painter.release()) return;
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;
this.state.heldObjectIds = new Set();
this.perFrame.cancel();
if (drag && !drag.moved && drag.onClick)
this.state.select(objectSelection(drag.holderId, [drag.onClick]));
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));
}
/** The size gizmo's handles on the selection, where they are drawn (test hook). */
sizeGizmoInfo(): SizeGizmoInfo | null {
return this.sizer.info();
}
/** The corners of the polygon being edited, where they are drawn (test hook). */
cornerInfo(): { objectId: string; corners: Vec2[] } | null {
return this.corners.info();
}
protected override isSelected(objectId: string): boolean {
const s = this.state.selection;
return s?.kind === 'object' && s.objectIds.includes(objectId);
}
/** A clip area opens for what is selected and everything below it (#260): the area itself, a row, a slot. */
protected override opensClip(item: DrawItem): boolean {
const s = this.state.selection;
if (s?.kind !== 'object') return false;
if (this.itemsById.items !== this.lastItems)
this.itemsById = { items: this.lastItems, byId: new Map(this.lastItems.map((i) => [i.id, i])) };
const { byId } = this.itemsById;
return opensClip(item, s.objectIds, (at) => byId.get(at.parentId));
}
/** `lastItems` by id, built once per redraw for `opensClip`. */
private itemsById: { items: DrawItem[]; byId: Map<string, DrawItem> } = { items: [], byId: new Map() };
/**
* Pixi only says which hit area was pressed; what is really under the pointer is worked out
* here, in the order it is drawn (#203): a tilemap's grid is empty where it has no tile, and a
* layer below a sprite does not put the map in front of it.
*/
protected override onItemPointerDown(_objectId: string, e: FederatedPointerEvent): void {
if (e.button === 2) {
this.openMenuUnder(e);
return;
}
if (e.button !== 0) return;
if (this.corners.press(e) || this.sizer.press(e)) return;
const holder = this.resolved();
if (!holder) return;
const stack = this.stackUnder(e);
if (e.ctrlKey || e.metaKey || e.shiftKey) {
if (stack.length === 0) this.pressEmpty(e);
else {
e.stopPropagation();
this.state.toggleObject(holder.id, stack[0]!);
}
return;
}
e.stopPropagation();
// A painting tool on the selected tilemap paints anywhere on its grid, empty cells included.
const target = this.paintTargetUnder(e);
if (target && this.painter.press(target, e.global)) return;
const press = pressOn(stack, this.state.selectedObjectIds, this.primaryObjectId());
if (!press) {
this.pressEmpty(e);
return;
}
if (press.select) this.state.select(objectSelection(holder.id, [press.select]));
// 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,
onClick: press.onClick,
};
}
/** The hierarchy's menu for what is under the pointer (#204); empty space opens none. */
private openMenuUnder(e: FederatedPointerEvent): void {
const source = this.holder();
const holder = this.resolved();
if (
!source ||
!holder ||
this.dragging ||
this.painter.active ||
this.corners.active ||
this.sizer.active ||
this.marquee
)
return;
e.stopPropagation();
const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId());
if (!target) {
this.openEmptyMenu(e);
return;
}
if (target.select) this.state.select(objectSelection(holder.id, [target.id]));
const at = this.host.worldAt(e.global);
openObjectMenu(this.state, source, holder, target.id, e.global.x, e.global.y, [], at);
}
private primaryObjectId(): string | null {
const s = this.state.selection;
return s?.kind === 'object' ? s.objectId : null;
}
/** Every object whose hit area holds the pointer, a tilemap with the layers that have a tile there. */
private hitsUnder(global: Vec2): PointerHit[] {
const hits: PointerHit[] = [];
const pointer = this.toLocal(global);
const world = screenToWorld(this.currentView(), pointer.x, pointer.y);
for (const item of this.lastItems) {
const node = this.nodes.get(item.id);
if (!node?.visible || node.eventMode === 'none') continue;
// What a clip area cuts off is not there to click, unless the area is open (#260). Symbols
// are drawn above everything, uncut.
if (item.clip && !isSymbolType(item.type) && !this.opensClip(item) && !regionContains(item.clip, world))
continue;
const p = node.toLocal(global);
if (
!this.onIcon(item.id, global) &&
!(node.hitArea ? node.hitArea.contains(p.x, p.y) : node.containsPoint(p))
)
continue;
if (item.type !== 'tilemap') {
hits.push({ id: item.id, layers: null });
continue;
}
const inMap = applyPoint(invert(item.m), world);
const cell = cellAtPoint(item.props, inMap.x, inMap.y);
const focus = this.tilemapFocus(item);
const layers: string[] = [];
const rows = new Map<string, number>();
for (const layer of item.tilemap?.layers ?? []) {
if (layerAlpha(layer.id, focus) === 0) continue;
const tile = layer.tiles.find((t) => t.x === cell.x && t.y === cell.y);
if (!tile) continue;
layers.push(layer.id);
if (layer.sortByY) rows.set(layer.id, sortRowOf(item.tilemap!, tile));
}
hits.push({ id: item.id, layers, rows });
}
return hits;
}
/** Whether the pointer is on this object's icon, wherever `placeIcons` put it. */
private onIcon(id: string, global: Vec2): boolean {
const icon = this.icons.get(id);
if (!icon?.visible || icon.eventMode === 'none' || !icon.hitArea) return false;
const p = icon.toLocal(global);
return icon.hitArea.contains(p.x, p.y);
}
/** What is under the pointer, front to back as drawn (#203). */
private stackUnder(e: FederatedPointerEvent): string[] {
return stackAt(this.lastItems, this.hitsUnder(e.global));
}
/** The painted tilemap when the pointer is on its grid, whatever lies above it there. */
private paintTargetUnder(e: FederatedPointerEvent): PaintTarget | null {
const target = paintedTilemap(this.state);
if (!target) return null;
const onGrid = this.hitsUnder(e.global).some((hit) => hit.id === target.objectId);
return onGrid ? this.painter.targetOf(target.objectId) : null;
}
/** What lies under a point of the screen, front to back – what a click there picks from (test hook). */
stackInfo(x: number, y: number): string[] {
return stackAt(this.lastItems, this.hitsUnder({ x, y }));
}
/** 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));
}
/** The selection outlines currently drawn (test hook). */
gizmoInfo(): GizmoInfo[] {
return this.lastGizmos;
}
/** Icons and links are in world units, so a pan leaves them where they are. */
protected override afterItems(items: DrawItem[], view: View): void {
this.placeIcons(items, view.scale);
this.drawParentLinks(items, view.scale);
}
/** Grid, outlines and rays are drawn in screen pixels: these follow every pan. */
protected override drawOnScreen(items: DrawItem[], view: View): void {
this.syncGrid();
this.gizmos.clear();
this.drawGizmos(items, view);
this.drawRays(view);
}
/** The editor run's world, whose rays are drawn over the scene (#153); null when nothing runs. */
setEditorWorld(world: World | null): void {
this.editorWorld = world;
this.redraw();
}
/** The rays drawn right now, in world units (test hook). */
rayInfo(): { from: Vec2; to: Vec2; hit: boolean }[] {
return this.lastRays.map(({ from, to, hit }) => ({ from: { ...from }, to: { ...to }, hit }));
}
/** The rays the editor run cast in the last moment: to what they met, or as far as they reached. */
private drawRays(view: View): void {
const world = this.editorWorld;
this.lastRays = world ? world.rays.filter((ray) => ray.time >= world.time - RAY_SHOWN) : [];
for (const { from, to, hit } of this.lastRays) {
const color = hit ? Theme.colors.collider : Theme.colors.textMuted;
const a = worldToScreen(view, from.x, from.y);
const b = worldToScreen(view, to.x, to.y);
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y).stroke({ width: 1.5, color });
if (hit) this.gizmos.circle(b.x, b.y, 3).fill(color);
}
}
protected override syncDecorations(
item: DrawItem,
onScreen: boolean,
scale: number,
_extent: Extent,
): void {
this.syncIcon(item, onScreen);
const solids = onScreen && this.shows(item, 'shape');
this.solids.sync(item, solids, scale, solids ? this.paintedSolids(item) : null);
this.syncLabel(item, onScreen, scale);
}
/** Where the painted layer blocks, when `item` is the tilemap selected alone – that part is tinted. */
private paintedSolids(item: DrawItem): TilemapSolids | null {
const target = item.tilemap ? paintedTilemap(this.state) : null;
if (!target || target.objectId !== item.id) return null;
return item.tilemap!.layerSolids(paintedLayerId(this.state, target.asset));
}
/** What the project's scene visibility (the eye in the tab header) says about this part of `item`. */
private shows(item: DrawItem, part: VisibilityPart): boolean {
return shows(this.state.project.sceneVisibility, item.type, part);
}
/** A tilemap's shape switch hides only the outline of its solid cells, never the map itself. */
protected override hidesShape(item: DrawItem): boolean {
return (item.type === 'collider' || item.type === 'clip') && !this.shows(item, 'shape');
}
protected override disposeDecorations(id: string): void {
this.labels.get(id)?.destroy();
this.labels.delete(id);
this.icons.get(id)?.destroy();
this.icons.delete(id);
this.solids.dispose(id);
}
/** The blocking rectangles and outlined cells of a placed tilemap, and the outer edges drawn round them, in cells (test hook). */
solidInfo(id: string): ReturnType<TilemapSolidOutlines['info']> {
return this.solids.info(this.lastItems.find((i) => i.id === id));
}
/** The icon of a symbol in world units (its side as `size`), the name beside it and whether it hangs off its owner (test hook). */
iconInfo(id: string): {
layer: string;
visible: boolean;
label: string;
link: boolean;
x: number;
y: number;
screen: Vec2;
clickable: boolean;
size: number;
} | null {
const icon = this.icons.get(id);
if (!icon) return null;
const label = this.labels.get(id);
const screen = icon.getGlobalPosition();
return {
layer: icon.parent === this.iconLayer ? 'overlay' : 'objects',
visible: icon.visible,
label: label?.visible ? label.text : '',
link: this.lastLinks.includes(id),
x: icon.position.x,
y: icon.position.y,
screen: { x: screen.x, y: screen.y },
clickable: icon.eventMode !== 'none',
size: icon.scale.x * SYMBOL_GRID,
};
}
/**
* Every symbol shows the asset panel's icon of its type, above everything in the scene, and is
* picked by it: a behavior, container or rigidbody is nothing else, and a thin collider outline
* or an empty particle source is easily missed (#89). The icons are decorations, so
* `GameViewport` – which never syncs any – leaves them out, and a source's particles still reach
* the running game. Where an icon stands is decided for all of them at once (`placeIcons`).
*/
private syncIcon(item: DrawItem, show: boolean): void {
let icon = this.icons.get(item.id);
if (!show || !hasSceneIcon(item.type) || !this.shows(item, 'symbol')) {
if (icon) {
icon.visible = false;
icon.eventMode = 'none';
}
return;
}
if (!icon) {
icon = new Graphics();
drawSymbolIcon(icon, item.type as SymbolIcon);
const half = SYMBOL_GRID / 2 + SYMBOL_OUTLINE;
icon.hitArea = new Rectangle(-half, -half, half * 2, half * 2);
icon.cursor = 'move';
icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
this.icons.set(item.id, icon);
this.iconLayer.addChild(icon);
}
icon.scale.set((SYMBOL_SIZE * this.symbolScale()) / SYMBOL_GRID);
icon.visible = true;
icon.eventMode = 'static';
}
/** The project's factor for icon size, spacing and the outline of an icon-only symbol. */
private symbolScale(): number {
return this.state.project.symbolScale;
}
/**
* Puts every icon where `layoutSymbols` says – on its object, or on a ring round a spot several
* want – and each name beside its icon, on the outer side of a ring. Switched-off objects draw no
* icon and take no room; ones off screen do, so an icon does not jump while the view pans.
*/
private placeIcons(items: DrawItem[], scale: number): void {
const last = this.overlay.children.length - 1;
this.overlay.setChildIndex(this.labelLayer, last);
this.overlay.setChildIndex(this.iconLayer, last);
const anchors = items
.filter((item) => item.enabled && hasSceneIcon(item.type) && this.shows(item, 'symbol'))
.map((item) => ({ id: item.id, x: item.x, y: item.y }));
this.spots = layoutSymbols(anchors, SYMBOL_SPACING * this.symbolScale());
for (const [id, spot] of this.spots) this.icons.get(id)?.position.set(spot.x, spot.y);
this.placeLabels(items, scale);
}
/**
* Each name beside its icon – on the outer side of a ring – on a dark plate, and a row up or down
* where it would cover another name or icon (`stackLabels`). A name without an icon beside it – a
* sprite's, or a symbol's whose icon is hidden – is centred on its object. Names off screen take
* their row as well, so none jumps while the view pans.
*/
private placeLabels(items: DrawItem[], scale: number): void {
const side = SYMBOL_SIZE * this.symbolScale();
const gap = side / 2 + 4 / scale;
const padX = LABEL_PAD_X / scale;
const padY = LABEL_PAD_Y / scale;
const wanted: WantedLabel[] = [];
const icons: LabelBox[] = [];
const lefts = new Map<string, boolean>();
for (const [id, spot] of this.spots) {
icons.push({ x: spot.x - side / 2, y: spot.y - side / 2, w: side, h: side });
const label = this.labels.get(id);
if (!label) continue;
const left = spot.angle !== null && Math.cos(spot.angle) < -1e-6;
lefts.set(id, left);
const w = label.width + padX * 2;
const h = label.height + padY * 2;
const x = left ? spot.x - gap - label.width - padX : spot.x + gap - padX;
wanted.push({ id, x, y: spot.y - h / 2, w, h });
}
for (const item of items) {
const label = this.labels.get(item.id);
if (!label || !item.enabled || this.spots.has(item.id)) continue;
lefts.set(item.id, false);
const w = label.width + padX * 2;
const h = label.height + padY * 2;
wanted.push({ id: item.id, x: item.x - w / 2, y: item.y - h / 2, w, h });
}
const step = (wanted[0]?.h ?? 0) + LABEL_ROW_GAP / scale;
const tops = stackLabels(wanted, icons, step);
this.plates.clear();
for (const box of wanted) {
const label = this.labels.get(box.id)!;
const top = tops.get(box.id) ?? box.y;
label.anchor.set(0, 0);
label.position.set(box.x + padX, top + padY);
if (!label.visible) continue;
this.plates.roundRect(box.x, top, box.w, box.h, 2 / scale).fill({
color: Theme.colors.symbolOutline,
alpha: LABEL_PLATE_ALPHA,
});
if (this.isSelected(box.id)) this.plates.stroke({ width: 1 / scale, color: Theme.colors.accent });
}
this.lastLabelBoxes = wanted
.filter((box) => this.labels.get(box.id)!.visible)
.map((box) => ({ ...box, y: tops.get(box.id) ?? box.y, left: lefts.get(box.id)! }));
}
private syncGrid(): void {
this.grid.clear();
const step = SCENE_GRID_STEP * this.worldScale();
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 (!this.spots.has(item.id) || !item.parentId || item.type === 'collider') 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;
const from = this.spots.get(item.id) ?? item;
const to = this.spots.get(parent.id) ?? parent;
dashedLine(this.links, from.x, from.y, to.x, to.y, 8 / scale, 6 / scale);
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
this.links.circle(to.x, to.y, 3 / scale).fill(color);
}
}
/**
* The name of an object whose type shows names; `placeLabels` puts it beside the icon once every
* icon has its spot. One off screen is kept up to date but hidden: it still takes its row.
*/
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
let label = this.labels.get(item.id);
if (!this.shows(item, 'label')) {
label?.destroy();
this.labels.delete(item.id);
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('viewportLabel') });
label.eventMode = 'none';
this.labels.set(item.id, label);
this.labelLayer.addChild(label);
}
label.visible = show;
if (label.text !== item.name) label.text = item.name;
label.scale.set(1 / scale);
}
/** Whether an object's own drawing – a collider's outline – is on screen and takes a click (test hook). */
shapeInfo(id: string): { visible: boolean; clickable: boolean } | null {
const node = this.nodes.get(id);
return node ? { visible: node.visible, clickable: node.eventMode !== 'none' } : null;
}
/** The names shown and their plates in world units, `left` for one on the left of its icon (test hook). */
labelInfo(): (WantedLabel & { left: boolean; screen: { x: number; y: number; w: number; h: number } })[] {
const view = this.currentView();
return this.lastLabelBoxes.map((box) => {
const a = worldToScreen(view, box.x, box.y);
return { ...box, screen: { x: a.x, y: a.y, w: box.w * view.scale, h: box.h * view.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. A symbol that is
// only its icon is outlined where the icon stands, which need not be its position, and
// at the icon's size.
const spot = ICON_ONLY.has(item.type) ? this.spots.get(id) : undefined;
const corners = this.screenCorners(
spot
? iconCorners(item.type, item.props, spot, this.symbolScale(), 2 / view.scale)
: footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame),
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 || this.sizer.handles(id)) continue;
for (const c of corners)
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
}
this.sizer.draw(this.gizmos, view);
this.corners.draw(this.gizmos, view);
this.drawDropPreview(view);
this.painter.draw(this.gizmos, items, view);
const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
}