/** * 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; 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> = { 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, '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(); private readonly icons = new Map(); /** Where each placed tilemap blocks, outlined like a collider (#132). */ private readonly solids = new Map(); /** * 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 => 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, 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) 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 })); }