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>
969 lines
39 KiB
TypeScript
969 lines
39 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|