Files
feuerwerk/src/editor/viewport/SceneViewport.ts
T

1127 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 { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand';
import {
MoveSceneObjectCommand,
PlaceAssetCommand,
SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands';
import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands';
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
import { hasSceneIcon } from '../../core/model/AssetTypes';
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,
scaleOf,
} from '../../core/model/matrix';
import { placementRejection } from '../../core/model/placement';
import type { ListValue, Position, PropertyValue, Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
import {
cellAt,
cellAtPoint,
cellKey,
cellOrigin,
cellsAlong,
cellsInRect,
findLayer,
floodFill,
inGrid,
tilemapColumns,
tilemapRows,
tilemapTileSize,
} from '../../core/model/tilemap';
import type { BoundaryLine } from '../../core/model/polygon';
import { type CellPolygon, type CellRect, solidBoundary } from '../../core/model/tilemapCollision';
import { footprintCorners } from '../../core/properties/bounds';
import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { type EditorState, objectSelection, type TilemapTool } from '../state';
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
import {
activeDrag,
drawBand,
drawGrid,
endDrag,
onDragChange,
pressCount,
Theme,
textStyle,
} from '../../ui';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import type { LayerFocus } from '../../viewport/tilemap';
import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols';
import { drawSymbolIcon } from '../../viewport/symbolIcons';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport';
import type { Frame } from './WorldViewport';
/** Screen pixels around a polygon's corner or edge that grab it. */
const CORNER_GRIP = 7;
/** Side of a corner handle in screen pixels. */
const CORNER_SIZE = 8;
/** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3;
const DRAG_THRESHOLD = 2;
const GRID_STEP = 100;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 12;
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;
}
/**
* A corner of a polygon collider being dragged (#151) – one just put on an edge, too. Like a move
* drag it writes the model as a preview; `before` is the override that was there, put back on
* release so the command records the edit as one undo step.
*/
interface CornerDrag {
holderId: string;
objectId: string;
before: PropertyValue | undefined;
points: Vec2[];
index: number;
/** From the world into the collider's own units. */
toLocal: Mat;
pointer: Vec2;
inserted: boolean;
moved: boolean;
}
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
interface DropPreview {
assetId: string;
at: Vec2;
rejection: string;
}
/**
* A stroke on a placed tilemap. `previous` is what the cells held before the live preview wrote
* into them – the same trick a move drag uses: the model shows the stroke at once, and on release
* it is put back so the command records it as one undo step.
*/
interface PaintStroke {
holderId: string;
objectId: string;
assetId: string;
layerId: string;
tool: TilemapTool;
tileId: string;
edits: CellEdit[];
previous: Map<string, string>;
from: Vec2;
last: Vec2 | null;
/** Where the pointer is now, in cells – what the rectangle preview is drawn from. */
to: Vec2;
}
/** A rubber-band selection in progress, in viewport-local pixels. */
interface Marquee {
start: Vec2;
current: Vec2;
active: boolean;
}
/** One selection outline that was drawn – `alpha` is the value the stroke really got. */
export interface GizmoInfo {
id: string;
primary: boolean;
alpha: number;
}
const OFF_GIZMO_ALPHA = 0.45;
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
behavior: Theme.colors.behavior,
particles: Theme.colors.particles,
rigidbody: Theme.colors.collider,
};
/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */
type TilemapSolids = Pick<NonNullable<DrawItem['tilemap']>, 'solids' | 'outlines' | 'oneWay'>;
function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) =>
p.length === q.length &&
p.every((r, i) => r.x === q[i]!.x && r.y === q[i]!.y && r.w === q[i]!.w && r.h === q[i]!.h);
const samePolygons = (p: readonly CellPolygon[], q: readonly CellPolygon[]) =>
p.length === q.length &&
p.every(
(polygon, i) =>
polygon.outline.length === q[i]!.outline.length &&
polygon.outline.every((c, j) => c.x === q[i]!.outline[j]!.x && c.y === q[i]!.outline[j]!.y),
);
return (
sameRects(a.solids, b.solids) &&
samePolygons(a.outlines, b.outlines) &&
sameRects(a.oneWay.solids, b.oneWay.solids) &&
samePolygons(a.oneWay.outlines, b.oneWay.outlines)
);
}
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>();
/** Where each placed tilemap blocks, outlined like a collider (#132). */
private readonly solids = new Map<string, Graphics>();
/**
* The outer edges of each tilemap's solids and what they were worked out from (#173). A map is
* resolved afresh on every draw, so they are kept by what blocks rather than by the resolved map.
*/
private readonly boundaries = new Map<
string,
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] }
>();
private editorWorld: World | null = null;
private lastRays: CastRay[] = [];
private dragging: ObjectDrag | null = null;
private corner: CornerDrag | null = null;
/** The corners drawn for the edited polygon, in global pixels (test hook). */
private lastCorners: { objectId: string; corners: Vec2[] } | null = null;
private stroke: PaintStroke | null = null;
private marquee: Marquee | null = null;
private dropPreview: DropPreview | null = null;
private lastGizmos: GizmoInfo[] = [];
/** Ids that got an owner link this frame (test hook). */
private lastLinks: string[] = [];
constructor(state: EditorState) {
super(state);
// Grid behind the content, links below the symbols, gizmos above everything clipped.
this.addChildAt(this.grid, 1);
this.world.addChildAt(this.links, 1);
this.content.addChild(this.gizmos);
this.links.eventMode = 'none';
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
this.on('pointerup', () => this.onPointerUp());
this.on('pointerupoutside', () => this.onPointerUp());
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);
}
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 };
}
private onPointerDown(e: FederatedPointerEvent): void {
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.startPan(e);
return;
}
if (e.button === 0 && this.startCornerEdit(e)) return;
if (e.button === 0 && e.target === this) {
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
const local = this.toLocal(e.global);
this.marquee = {
start: { x: local.x, y: local.y },
current: { x: local.x, y: local.y },
active: false,
};
e.stopPropagation();
}
}
private onPointerMove(e: FederatedPointerEvent): void {
const incoming = activeDrag();
if (incoming) {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
return;
}
if (this.panTo(e)) return;
if (this.corner) {
this.dragCorner(e);
return;
}
if (this.stroke) {
const item = this.lastItems.find((i) => i.id === this.stroke!.objectId);
if (!item) return;
const at = this.cellUnder(item, e);
if (this.stroke.last && at.x === this.stroke.last.x && at.y === this.stroke.last.y) return;
const from = this.stroke.last ?? this.stroke.from;
this.stroke.last = at;
this.stroke.to = at;
// A pointer jumps between events, so the cells in between are filled in as well.
if (this.stroke.tool !== 'rect') this.paintCells(item, cellsAlong(from, at));
this.redraw();
return;
}
if (this.dragging) {
const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
this.dragging.moved = true;
const holder = this.holder();
if (!holder) return;
for (const item of this.dragging.items) {
const local = applyVector(item.toLocal, { x: dx, y: dy });
objectRef(this.state.project, holder, item.objectId)?.setPosition({
x: Math.round(item.start.x + local.x),
y: Math.round(item.start.y + local.y),
z: item.start.z,
});
}
this.state.preview();
} else if (this.marquee) {
const local = this.toLocal(e.global);
this.marquee.current = { x: local.x, y: local.y };
if (
!this.marquee.active &&
Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD
)
return;
this.marquee.active = true;
this.redraw();
}
}
/**
* Follows an asset dragged in from the panel. `assetId` is empty for payloads this view does
* not take; the preview is dropped whenever the pointer leaves it, so nothing lands blind.
*/
private trackIncomingAsset(assetId: string, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const holder = this.holder();
const asset = assetId ? findAsset(this.state.project, assetId) : undefined;
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
if (!asset || !holder || !inside) {
this.setDropPreview(null);
return;
}
const world = screenToWorld(this.currentView(), local.x, local.y);
this.setDropPreview({
assetId,
at: { x: Math.round(world.x), y: Math.round(world.y) },
rejection: placementRejection(this.state.project, holder, asset),
});
}
private setDropPreview(preview: DropPreview | null): void {
if (!preview && !this.dropPreview) return;
this.dropPreview = preview;
this.redraw();
}
private dropAsset(preview: DropPreview): void {
const holder = this.holder();
if (!holder) return;
if (preview.rejection) {
this.state.setNotice(preview.rejection);
return;
}
const command = new PlaceAssetCommand(holder.id, preview.assetId, preview.at);
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
private onPointerUp(): void {
const preview = this.dropPreview;
if (preview) {
this.setDropPreview(null);
endDrag();
this.dropAsset(preview);
return;
}
this.endPan();
if (this.corner) {
this.endCorner();
return;
}
if (this.stroke) {
this.endStroke();
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;
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 tilemap a click paints on: the one selected (see `paintedTilemap`), with a painting tool
* chosen. Anything else – another object, the select tool, a modifier held – leaves the view
* doing what it always did.
*/
private paintTargetOf(objectId: string): { item: DrawItem; asset: TilemapAsset; layerId: string } | null {
if (this.state.tilemapPaint.tool === 'select') return null;
const target = paintedTilemap(this.state);
if (!target || target.objectId !== objectId) return null;
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || item.type !== 'tilemap') return null;
const layerId = paintedLayerId(this.state, target.asset);
return layerId ? { item, asset: target.asset, layerId } : null;
}
/** The cell under a pointer position, in the tilemap's own grid. */
private cellUnder(item: DrawItem, e: FederatedPointerEvent): Vec2 {
const local = this.toLocal(e.global);
const world = screenToWorld(this.currentView(), local.x, local.y);
const inMap = applyPoint(invert(item.m), world);
return cellAtPoint(item.props, inMap.x, inMap.y);
}
/**
* What a press with a painting tool does. Fill and pick are done with the click – a drag adds
* nothing to either – so only the other three start a stroke. True when the press was used.
*/
private usePaintTool(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
holderId: string,
e: FederatedPointerEvent,
): boolean {
const tool = this.state.tilemapPaint.tool;
if (tool !== 'fill' && tool !== 'pick') return this.startStroke(target, holderId, e);
const layer = findLayer(target.asset, target.layerId);
const at = this.cellUnder(target.item, e);
if (!layer || !inGrid(target.item.props, at.x, at.y)) return true;
if (tool === 'pick') this.pickCell(layer, at);
else this.fillFrom(target, layer, at);
return true;
}
/** Takes the tile of a cell on the painted layer – the one painted on next (#133). */
private pickCell(layer: TilemapLayer, at: Vec2): void {
const tileId = cellAt(layer, at.x, at.y);
if (!tileId) this.state.setNotice('This cell is empty on the active layer.');
else if (!findAssetOf(this.state.project, tileId, 'tile'))
this.state.setNotice('This cell names a tile that no longer exists.');
else pickTile(this.state, tileId);
}
/** Fills the connected cells of one tile in a single command, so one click is one undo step. */
private fillFrom(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
layer: TilemapLayer,
at: Vec2,
): void {
const tileId = this.state.tilemapPaint.tileId;
if (!tileId) {
this.state.setNotice('Choose a tile to paint with.');
return;
}
const cells = floodFill(target.item.props, layer, at, tileId);
if (cells.length > 0)
this.state.execute(
new PaintTilesCommand(
target.asset.id,
target.layerId,
cells.map((cell) => ({ ...cell, tileId })),
'Fill tiles',
),
);
}
private startStroke(
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
holderId: string,
e: FederatedPointerEvent,
): boolean {
const paint = this.state.tilemapPaint;
const tileId = paint.tool === 'erase' ? '' : paint.tileId;
if (paint.tool !== 'erase' && !tileId) {
this.state.setNotice('Choose a tile to paint with.');
return false;
}
const at = this.cellUnder(target.item, e);
this.stroke = {
holderId,
objectId: target.item.id,
assetId: target.asset.id,
layerId: target.layerId,
tool: paint.tool,
tileId,
edits: [],
previous: new Map(),
from: at,
last: null,
to: at,
};
// A rectangle only shows where it would go until the button comes up; the other two draw at once.
if (paint.tool !== 'rect') this.paintCells(target.item, [at]);
this.redraw();
return true;
}
private strokeLayer(stroke: PaintStroke): TilemapLayer | undefined {
const asset = findAssetOf(this.state.project, stroke.assetId, 'tilemap');
return asset && findLayer(asset, stroke.layerId);
}
/** Writes cells into the model as a preview, remembering what they held. */
private paintCells(item: DrawItem, cells: readonly Vec2[]): void {
const stroke = this.stroke;
const layer = stroke && this.strokeLayer(stroke);
if (!stroke || !layer) return;
let changed = false;
for (const at of cells) {
if (!inGrid(item.props, at.x, at.y)) continue;
const key = cellKey(at.x, at.y);
if (!stroke.previous.has(key)) stroke.previous.set(key, layer.cells[key] ?? '');
if ((layer.cells[key] ?? '') === stroke.tileId) continue;
stroke.edits.push({ x: at.x, y: at.y, tileId: stroke.tileId });
if (stroke.tileId) layer.cells[key] = stroke.tileId;
else delete layer.cells[key];
changed = true;
}
if (changed) this.state.preview();
}
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
private endStroke(): void {
const stroke = this.stroke;
this.stroke = null;
if (!stroke) return;
const layer = this.strokeLayer(stroke);
if (layer)
for (const [key, value] of stroke.previous) {
if (value) layer.cells[key] = value;
else delete layer.cells[key];
}
const item = this.lastItems.find((i) => i.id === stroke.objectId);
const edits =
stroke.tool === 'rect' && item
? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({
...at,
tileId: stroke.tileId,
}))
: stroke.edits;
if (edits.length > 0)
this.state.execute(
new PaintTilesCommand(
stroke.assetId,
stroke.layerId,
edits,
stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles',
),
);
else this.state.preview();
this.redraw();
}
/**
* The polygon collider whose corners can be edited: the primary selection, if it is one and
* belongs to this view's holder – a placed container's content is edited in its template.
*/
private editedPolygon(): DrawItem | null {
const s = this.state.selection;
const holder = this.holder();
if (s?.kind !== 'object' || !holder || !objectRef(this.state.project, holder, s.objectId)) return null;
const item = this.lastItems.find((i) => i.id === s.objectId);
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
}
/**
* A press on a corner or an edge of the edited polygon: a double-click takes the corner away,
* a press on an edge puts a corner there, and either can then be dragged. True when it was one.
*/
private startCornerEdit(e: FederatedPointerEvent): boolean {
const item = this.editedPolygon();
const holder = this.holder();
const ref = item && holder && objectRef(this.state.project, holder, item.id);
if (!item || !holder || !ref) return false;
const toLocal = invert(item.m);
const pointer = this.toLocal(e.global);
const at = applyPoint(toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
const points = colliderPoints(item.props);
const tolerance = CORNER_GRIP / (this.currentView().scale * (scaleOf(item.m) || 1));
const grip = gripAt(points, at, tolerance);
if (!grip) return false;
e.stopPropagation();
if (grip.kind === 'corner' && pressCount(e, `corner ${item.id} ${grip.index}`) === 2) {
const next = withoutCorner(points, grip.index);
if (next)
this.state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next)));
else this.state.setNotice('A polygon keeps at least three corners.');
return true;
}
const inserted = grip.kind === 'edge';
this.corner = {
holderId: holder.id,
objectId: item.id,
before: ref.object.overrides[COLLIDER_POINTS_KEY],
points: inserted ? withCornerAfter(points, grip.index, rounded(grip.at)) : points,
index: inserted ? grip.index + 1 : grip.index,
toLocal,
pointer: { x: pointer.x, y: pointer.y },
inserted,
moved: false,
};
if (inserted) this.previewCorners();
return true;
}
private dragCorner(e: FederatedPointerEvent): void {
const corner = this.corner!;
const pointer = this.toLocal(e.global);
if (
!corner.moved &&
Math.hypot(pointer.x - corner.pointer.x, pointer.y - corner.pointer.y) < DRAG_THRESHOLD
)
return;
corner.moved = true;
const at = applyPoint(corner.toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
corner.points = withCornerMoved(corner.points, corner.index, rounded(at));
this.previewCorners();
}
private previewCorners(): void {
const corner = this.corner;
const holder = this.holder();
const ref = corner && holder && objectRef(this.state.project, holder, corner.objectId);
if (!corner || !ref) return;
ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points));
this.state.preview();
}
/** Puts the preview back and lets the command do the edit, so one drag is one undo step. */
private endCorner(): void {
const corner = this.corner!;
this.corner = null;
const holder = this.holder();
const ref = holder && objectRef(this.state.project, holder, corner.objectId);
if (!ref) return;
ref.setOverride(COLLIDER_POINTS_KEY, corner.before);
if (corner.moved || corner.inserted)
this.state.execute(
new SetOverrideCommand(corner.holderId, corner.objectId, COLLIDER_POINTS_KEY, listOf(corner.points)),
);
else this.state.preview();
}
/** The corners of the polygon being edited, where they are drawn (test hook). */
cornerInfo(): { objectId: string; corners: Vec2[] } | null {
return this.lastCorners;
}
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
if (this.startCornerEdit(e)) return;
const holder = this.resolved();
const object = holder && findObject(holder, objectId);
if (!holder || !object) return;
e.stopPropagation();
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.state.toggleObject(holder.id, objectId);
return;
}
// A painting tool on the selected tilemap: the press starts a stroke instead of a move.
const target = this.paintTargetOf(objectId);
if (target && this.usePaintTool(target, holder.id, e)) return;
// Clicking a selected object keeps the selection so the whole group can be dragged.
if (!this.state.selectedObjectIds.includes(objectId))
this.state.select(objectSelection(holder.id, [objectId]));
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
const items = subtreeRoots(holder, this.state.selectedObjectIds)
.map((id) => findObject(holder, id))
.filter((o): o is NonNullable<typeof o> => Boolean(o))
.map((o) => ({
objectId: o.id,
start: { ...o.position },
toLocal: invert(linearOf(parentTransform(this.state.project, holder, o.parentId).m)),
}));
const local = this.toLocal(e.global);
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
}
/** Selects every object whose position – not its bounds – lies inside the band. */
private selectInMarquee(marquee: Marquee): void {
const holder = this.holder();
if (!holder) return;
const view = this.currentView();
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
const minX = Math.min(a.x, b.x);
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const ids = this.lastItems
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
.map((i) => i.id);
this.state.select(objectSelection(holder.id, ids));
}
protected override beforeItems(): void {
this.syncGrid();
this.gizmos.clear();
}
/** The selection outlines currently drawn (test hook). */
gizmoInfo(): GizmoInfo[] {
return this.lastGizmos;
}
protected override afterItems(items: DrawItem[], view: View): void {
this.drawParentLinks(items, view.scale);
this.drawGizmos(items, view);
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);
this.syncSolids(item, onScreen, scale);
this.syncLabel(item, onScreen, scale, extent);
}
protected override disposeDecorations(id: string): void {
this.labels.get(id)?.destroy();
this.labels.delete(id);
this.icons.get(id)?.destroy();
this.icons.delete(id);
this.solids.get(id)?.destroy();
this.solids.delete(id);
this.boundaries.delete(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): {
visible: boolean;
rects: CellRect[];
outlines: Vec2[][];
oneWay: CellRect[];
oneWayOutlines: Vec2[][];
boundary: BoundaryLine[];
} | null {
const node = this.solids.get(id);
const item = this.lastItems.find((i) => i.id === id);
if (!node || !item?.tilemap) return null;
return {
visible: node.visible,
rects: item.tilemap.solids.map((rect) => ({ ...rect })),
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })),
oneWayOutlines: item.tilemap.oneWay.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
boundary: this.boundaryOf(item).solid.map((line) => ({
points: line.points.map((p) => ({ ...p })),
closed: line.closed,
})),
};
}
private boundaryOf(item: DrawItem): { solid: BoundaryLine[]; oneWay: BoundaryLine[] } {
const from: TilemapSolids = item.tilemap!;
let boundary = this.boundaries.get(item.id);
if (!boundary || !sameSolids(boundary.from, from)) {
boundary = {
from,
solid: solidBoundary(from.solids, from.outlines),
oneWay: solidBoundary(from.oneWay.solids, from.oneWay.outlines),
};
this.boundaries.set(item.id, boundary);
}
return boundary;
}
/**
* The solid cells of a tilemap, drawn the way a collider is: tinted and outlined, above the
* content so a painted wall cannot hide them. Editor-only for the same reason as the icon: the
* game view never syncs decorations.
*/
private syncSolids(item: DrawItem, show: boolean, scale: number): void {
let node = this.solids.get(item.id);
const rects = item.tilemap?.solids ?? [];
const outlines = item.tilemap?.outlines ?? [];
const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] };
if (!show || rects.length + outlines.length + oneWay.solids.length + oneWay.outlines.length === 0) {
if (node) node.visible = false;
return;
}
if (!node) {
node = new Graphics();
node.eventMode = 'none';
this.solids.set(item.id, node);
this.overlay.addChild(node);
}
node.visible = true;
placeNode(node, item.m);
const tile = tilemapTileSize(item.props);
const width = 2 / (scale * scaleOf(item.m));
node.clear();
const color = Theme.colors.collider;
const boundary = this.boundaryOf(item);
// Filled piece by piece, but stroked only along the outer edges, so no seam shows (#173).
const fill = (rects: readonly CellRect[], outlines: readonly CellPolygon[], alpha: number) => {
if (rects.length + outlines.length === 0) return;
for (const rect of rects) {
const corner = cellOrigin(item.props, rect.x, rect.y);
node.rect(corner.x, corner.y, rect.w * tile, rect.h * tile);
}
for (const polygon of outlines)
node.poly(
polygon.outline.map((p) => cellOrigin(item.props, p.x, p.y)),
true,
);
node.fill({ color, alpha });
};
const stroke = (lines: readonly BoundaryLine[], lineWidth: number) => {
if (lines.length === 0) return;
for (const line of lines)
node.poly(
line.points.map((p) => cellOrigin(item.props, p.x, p.y)),
line.closed,
);
node.stroke({ width: lineWidth, color });
};
fill(rects, outlines, 0.12);
stroke(boundary.solid, width);
fill(oneWay.solids, oneWay.outlines, 0.06);
stroke(boundary.oneWay, width / 2);
// A platform's top is what blocks, so that is what stands out, with the cone it lands within.
const length = CONE_LENGTH / (scale * scaleOf(item.m));
for (const rect of oneWay.solids) {
const a = cellOrigin(item.props, rect.x, rect.y);
const b = cellOrigin(item.props, rect.x + rect.w, rect.y + rect.h);
markBlockingSide(
node,
{ left: a.x, top: a.y, right: b.x, bottom: b.y },
{ x: 0, y: -1 },
width * 2,
color,
{ angle: rect.angle, length },
);
}
}
/** The icon of a particle source, the name beside it and whether it hangs off its owner (test hook). */
iconInfo(id: string): { layer: string; visible: boolean; label: string; link: boolean } | null {
const icon = this.icons.get(id);
if (!icon) return null;
const label = this.labels.get(id);
return {
layer: icon.parent === this.overlay ? 'overlay' : 'objects',
visible: icon.visible,
label: label?.visible ? label.text : '',
link: this.lastLinks.includes(id),
};
}
/**
* A source draws only its particles, so without a mark of its own an empty one is invisible and
* cannot be found (#89). The mark is a decoration rather than a symbol: `GameViewport` never syncs
* decorations, so it is editor-only without the source leaving the running game.
*/
private syncIcon(item: DrawItem, show: boolean): void {
let icon = this.icons.get(item.id);
if (!show || item.type !== 'particles') {
if (icon) icon.visible = false;
return;
}
if (!icon) {
icon = new Graphics();
icon.eventMode = 'none';
drawSymbolIcon(icon, 'particles');
this.icons.set(item.id, icon);
this.overlay.addChild(icon);
}
icon.visible = true;
icon.position.set(item.x, item.y);
}
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.view.zoom;
if (step < MIN_GRID_STEP) return;
const origin = worldToScreen(this.currentView(), 0, 0);
drawGrid(this.grid, {
originX: origin.x,
originY: origin.y,
step,
w: this.w,
h: this.h,
alpha: 0.05,
axisAlpha: 0.15,
});
}
/**
* Dashed line from every parented symbol to its parent. Sprites show their nesting in the
* hierarchy panel instead, and colliders sit on their body, so neither gets a line.
*/
private drawParentLinks(items: DrawItem[], scale: number): void {
this.links.clear();
this.lastLinks = [];
const byId = new Map(items.map((i) => [i.id, i]));
for (const item of items) {
if (!hasSceneIcon(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
const parent = byId.get(item.parentId);
if (!parent || !parent.enabled) continue;
this.lastLinks.push(item.id);
const color = LINK_COLORS[item.type] ?? Theme.colors.textMuted;
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
}
}
private syncLabel(item: DrawItem, show: boolean, scale: number, extent: Extent): void {
let label = this.labels.get(item.id);
if (!show || !hasSceneIcon(item.type)) {
if (label) label.visible = false;
return;
}
if (!label) {
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
label.eventMode = 'none';
this.labels.set(item.id, label);
this.overlay.addChild(label);
}
label.visible = true;
label.text = item.name;
label.scale.set(1 / scale);
if (item.type === 'camera' || item.type === 'collider')
label.position.set(extent.left, extent.top - 16 / scale);
else label.position.set(extent.right + 4 / scale, item.y - 8 / scale);
}
/**
* Outline at the size the object will have – the footprint answers "does it fit here", which a
* cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps.
*/
private drawDropPreview(view: View): void {
const preview = this.dropPreview;
const asset = preview && findAsset(this.state.project, preview.assetId);
if (!preview || !asset) return;
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
const m = localMatrixOf(preview.at, transformOf(asset.props));
const centre = worldToScreen(view, preview.at.x, preview.at.y);
this.gizmos
.poly(this.screenCorners(footprintCorners(asset.type, asset.props, m), view), true)
.fill({ color, alpha: 0.12 })
.stroke({ width: 2, color });
this.gizmos
.moveTo(centre.x - 6, centre.y)
.lineTo(centre.x + 6, centre.y)
.moveTo(centre.x, centre.y - 6)
.lineTo(centre.x, centre.y + 6)
.stroke({ width: 1, color });
}
/**
* While a painting tool is chosen, the selected tilemap shows its cells – without the grid one
* paints blind – and a rectangle drag shows the box it would fill.
*/
private drawPaintOverlay(items: DrawItem[], view: View): void {
const target = this.state.tilemapPaint.tool === 'select' ? null : paintedTilemap(this.state);
if (!target) return;
const item = items.find((i) => i.id === target.objectId && i.type === 'tilemap');
if (!item) return;
const tile = tilemapTileSize(item.props);
const columns = tilemapColumns(item.props);
const rows = tilemapRows(item.props);
const corner = (x: number, y: number): Vec2 => {
const at = cellOrigin(item.props, x, y);
return worldToScreen(
view,
item.m.tx + at.x * item.m.a + at.y * item.m.c,
item.m.ty + at.x * item.m.b + at.y * item.m.d,
);
};
// Below this the lines are noise, exactly as the scene grid is (see `syncGrid`).
if (tile * view.scale >= MIN_GRID_STEP) {
for (let x = 0; x <= columns; x++) {
const a = corner(x, 0);
const b = corner(x, rows);
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
}
for (let y = 0; y <= rows; y++) {
const a = corner(0, y);
const b = corner(columns, y);
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
}
this.gizmos.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.25 });
}
const stroke = this.stroke;
if (!stroke || stroke.tool !== 'rect') return;
const x0 = Math.min(stroke.from.x, stroke.to.x);
const y0 = Math.min(stroke.from.y, stroke.to.y);
const x1 = Math.max(stroke.from.x, stroke.to.x) + 1;
const y1 = Math.max(stroke.from.y, stroke.to.y) + 1;
this.gizmos
.poly([corner(x0, y0), corner(x1, y0), corner(x1, y1), corner(x0, y1)], true)
.fill({ color: Theme.colors.accent, alpha: 0.2 })
.stroke({ width: 2, color: Theme.colors.accent });
}
/** A handle on every corner of the polygon collider being edited (#151). */
private drawCorners(view: View): void {
const item = this.editedPolygon();
this.lastCorners = null;
if (!item) return;
const half = CORNER_SIZE / 2;
const corners = colliderPoints(item.props).map((p) => {
const world = applyPoint(item.m, p);
return worldToScreen(view, world.x, world.y);
});
for (const c of corners)
this.gizmos
.rect(c.x - half, c.y - half, CORNER_SIZE, CORNER_SIZE)
.fill(Theme.colors.accentText)
.stroke({ width: 1, color: Theme.colors.accent });
this.lastCorners = {
objectId: item.id,
corners: corners.map((c) => {
const g = this.gizmos.toGlobal(c);
return { x: g.x, y: g.y };
}),
};
}
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
return corners.map((c) => worldToScreen(view, c.x, c.y));
}
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
private drawGizmos(items: DrawItem[], view: View): void {
this.lastGizmos = [];
const s = this.state.selection;
if (s?.kind === 'object') {
for (const id of s.objectIds) {
const item = items.find((i) => i.id === id);
if (!item) continue;
// Two screen pixels outside the footprint, whichever way it is turned.
const corners = this.screenCorners(
footprintCorners(item.type, item.props, item.m, 2 / view.scale),
view,
);
// A switched-off object draws nothing, so its outline is the only way to see where it
// sits – dimmed, because the empty frame would otherwise read as a drawing that failed.
const alpha = item.enabled ? 1 : OFF_GIZMO_ALPHA;
const primary = id === s.objectId;
this.lastGizmos.push({ id, primary, alpha });
this.gizmos.poly(corners, true).stroke({ width: 2, color: Theme.colors.gizmo, alpha });
if (!primary) continue;
for (const c of corners)
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
}
}
this.drawCorners(view);
this.drawDropPreview(view);
this.drawPaintOverlay(items, view);
const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current);
}
}
/** A corner lands on whole units, as a dragged object does. */
function rounded(p: Vec2): Vec2 {
return { x: Math.round(p.x), y: Math.round(p.y) };
}
function listOf(points: readonly Vec2[]): ListValue {
return points.map((p) => ({ x: p.x, y: p.y }));
}