Files
feuerwerk/src/editor/viewport/SceneViewport.ts
T
adminandClaude Opus 5.5 0f85476b93
game-maker / gate (push) Successful in 2m39s
game-maker / suite (push) Successful in 13m8s
Add points by double-click and delete the picked one (#288)
Four editors edit points, and all of them put a point down on a single
press on the line and took one away on a double-click. A stray click
added a point, and there was no way to see which point a key would hit.
All four now work the same way: the Curve tab, the Vector art tab, a
polygon collider in the scene view and a frame's collision outline in
the Texture tab.

- A double-click on a line, edge or segment puts a point there, in the
  Curve tab on free space too. The point is picked and stays in place.
  It can be dragged with the next press, as answered in the issue.
- A click on a point picks it, and it is drawn filled. A double-click
  on a point takes nothing away any more.
- Delete or Backspace takes the picked point away. Without a picked
  point the key removes the shape, the object or the outline as before.
- A right-click on a point picks it and opens a menu with Delete
  point. In the Curve and Vector art tab the four point types stand
  below it. Alt-click stays.
- A polygon keeps three corners and a curve keeps its ends. The entry
  is greyed out then.
- The pick ends with a click beside the points, with another object,
  shape or frame, and after the point is gone.
- editor/pointGestures.ts holds the rule as a pure function, and
  editor/pointMenu.ts builds the menu for all four.

Decided here:

- A single click on a line no longer adds anything. In Vector art it
  drags the shape, on a polygon it drops the pick, and in the Texture
  tab it makes that outline the active one and drops the pick.
- In the Texture tab a click inside the frame still appends a corner,
  and that corner is picked. Delete takes the last corner first. To
  remove the whole outline, click one of its edges before.
- The point the Pen put down last is picked, so Delete takes it and
  not the shape.
- Delete hits the picked point also when the tab has lost the keyboard
  focus, after a click in the shape list or after the menu.
- A vector shape that loses its last point is removed whole. Before,
  an empty shape stayed in the list.

Findings:

- Fixed: the new hint line in the Texture tab was cut off at the edge.
  The shorter text was not looked at in a picture again.
- Not fixed: in the scene view the collider's name label covers a
  corner that sits near the centre. That was so before.

Tests:
- Gate: 200 files, 2967 tests (2944 + 23), exit 0
- e2e, full run: 1629 checks passed (1625 + 4), exit 0
- Smokes: 24 / 19 / 21, exit 0
- After the full run an unused parameter and a comment were tidied.
  For that final state the gate and e2e 42,47,53 (105 checks) ran
  again, the full run did not.
- 23 unit tests in tests/editor/point-gestures.test.ts, for the rule
  and for each of the four editors
- Four e2e checks with a real mouse, two in module 42 and one each in
  47 and 53. The existing checks for adding and removing a point use
  the new gestures.
- 20 unit counter-probes all fail tests, run on stein2
- Two browser counter-probes fail, the build went through both times.
  Without the Delete wiring the key removes the collider. Without the
  right-click hook the menu is missing, and that run stops in module
  42, so 47 and 53 were not reached with it.
- Three screenshots looked at, the picked point is filled. None of
  the Vector art tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:36:13 +02:00

1066 lines
42 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 { panelPolicy } from '../playThrottle';
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 { pickAt } from '../objectPick';
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 '../../viewport/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[] = [];
private lastPickOutline: string | null = null;
/** 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();
});
state.on('pick', () => 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);
});
}
/** Set when a redraw was skipped behind the game tab (#265); the next time on screen draws it. */
private missedRedraw = false;
override redraw(): void {
if (
!this.visible &&
this.state.mode === 'play' &&
!panelPolicy(this.state.project.whilePlaying).hiddenSceneView
) {
this.missedRedraw = true;
return;
}
this.missedRedraw = false;
super.redraw();
}
/** Coming back on screen: draws what was skipped while hidden. */
catchUp(): void {
if (this.missedRedraw) this.redraw();
}
/** 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 (this.picking()) {
// Empty space holds nothing to pick: a click there keeps the candidate.
if (e.button === 2) this.state.endObjectPick('cancel');
return;
}
if (e.button === 2 && this.corners.context(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, and the picked one (test hook). */
cornerInfo(): { objectId: string; corners: Vec2[]; picked: number } | null {
return this.corners.info();
}
/** Delete on a picked corner of the edited polygon (#288); false while none is picked. */
removePickedCorner(): boolean {
return this.corners.removePicked();
}
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 (this.picking()) {
this.pickUnder(e);
return;
}
if (e.button === 2) {
if (!this.corners.context(e)) 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,
};
}
/** Whether the eyedropper picks in this view (#276) – nothing but panning and zooming does then. */
private picking(): boolean {
const pick = this.state.objectPick;
return pick !== null && pick.holderId === this.holder()?.id;
}
/** A press on an object while picking: a click moves the candidate, a right-click cancels, Shift pans. */
private pickUnder(e: FederatedPointerEvent): void {
if (e.button === 0 && e.shiftKey) this.startPan(e);
else if (e.button === 0) pickAt(this.state, this.stackUnder(e));
else if (e.button === 2) this.state.endObjectPick('cancel');
else return;
e.stopPropagation();
}
/** The object the eyedropper's candidate outline is drawn round, null for none (test hook). */
pickOutline(): string | null {
return this.lastPickOutline;
}
/** 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);
this.syncCursors();
}
/** A crosshair over everything while the eyedropper picks here; Pixi shows the cursor of what is hit. */
private syncCursors(): void {
const picking = this.picking();
this.cursor = picking ? 'crosshair' : 'default';
for (const node of [...this.nodes.values(), ...this.icons.values()])
node.cursor = picking ? 'crosshair' : 'move';
}
/** 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;
const corners = this.outlineOf(item, view, 2);
// 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 });
}
}
const picking = this.drawPickCandidate(items, view);
if (!picking) {
this.sizer.draw(this.gizmos, view);
this.corners.draw(this.gizmos, view);
}
this.drawDropPreview(view);
if (picking) return;
this.painter.draw(this.gizmos, items, view);
const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
/**
* The eyedropper's candidate, outlined further out than the selection and in its own colour – the
* object with the place stays selected all along. While it picks, no handle or brush is drawn:
* none of them takes a press then. False when it does not pick here.
*/
private drawPickCandidate(items: DrawItem[], view: View): boolean {
this.lastPickOutline = null;
if (!this.picking()) return false;
const id = this.state.objectPick?.candidate;
const item = id ? items.find((i) => i.id === id) : undefined;
if (!item) return true;
this.lastPickOutline = item.id;
this.gizmos
.poly(this.outlineOf(item, view, 5), true)
.stroke({ width: 2, color: Theme.colors.objectPick });
return true;
}
/**
* Screen corners `pad` 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.
*/
private outlineOf(item: DrawItem, view: View, pad: number): Vec2[] {
const spot = ICON_ONLY.has(item.type) ? this.spots.get(item.id) : undefined;
return this.screenCorners(
spot
? iconCorners(item.type, item.props, spot, this.symbolScale(), pad / view.scale)
: footprintCorners(item.type, item.props, item.m, pad / view.scale, item.frame),
view,
);
}
}