diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 019f644e..70fee04f 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -5,13 +5,7 @@ */ 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, paintTiles } from '../../core/commands/tilemapCommands'; -import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset'; +import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; import { hasSceneIcon } from '../../core/model/AssetTypes'; import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { objectRef, resolvedHolder } from '../../core/model/instance'; @@ -22,65 +16,35 @@ import { 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 type { Position, Vec2 } from '../../core/model/Properties'; +import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { cellAtPoint } from '../../core/model/tilemap'; 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 { type EditorState, objectSelection } from '../state'; +import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; +import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; import { collectHolderItems, type DrawItem } from '../../viewport/items'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { openObjectMenu } from '../objectMenu'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; -import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols'; +import { dashedLine } 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 { PolygonCornerEditor } from './polygonCorners'; +import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost'; +import { type PaintTarget, TilemapPainter } from './tilemapPainting'; +import { TilemapSolidOutlines } from './tilemapSolids'; 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; @@ -92,24 +56,6 @@ interface ObjectDrag { onClick: string | null; } -/** - * 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; @@ -117,26 +63,6 @@ interface DropPreview { 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; @@ -160,51 +86,29 @@ const LINK_COLORS: Partial> = { 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 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); + }, + scale: () => this.currentView().scale, + redraw: () => this.redraw(), + }; + private readonly painter = new TilemapPainter(this.host); + private readonly corners = new PolygonCornerEditor(this.host); 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[] = []; @@ -286,7 +190,7 @@ export class SceneViewport extends FreeViewport { this.startPan(e); return; } - if (e.button === 0 && this.startCornerEdit(e)) return; + if (e.button === 0 && this.corners.press(e)) return; if (e.button === 0 && e.target === this) this.pressEmpty(e); } @@ -311,24 +215,7 @@ export class SceneViewport extends FreeViewport { 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.panTo(e) || this.corners.move(e.global) || this.painter.move(e.global)) return; if (this.dragging) { const local = this.toLocal(e.global); const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; @@ -406,14 +293,7 @@ export class SceneViewport extends FreeViewport { return; } this.endPan(); - if (this.corner) { - this.endCorner(); - return; - } - if (this.stroke) { - this.endStroke(); - return; - } + if (this.corners.release() || this.painter.release()) return; const marquee = this.marquee; this.marquee = null; if (marquee) { @@ -440,253 +320,9 @@ export class SceneViewport extends FreeViewport { 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); - const command = paintTiles( - layer, - target.asset.id, - cells.map((cell) => ({ ...cell, tileId })), - 'Fill tiles', - ); - if (command) this.state.execute(command); - } - - 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; - const command = - layer && - paintTiles(layer, stroke.assetId, edits, stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles'); - if (command) this.state.execute(command); - 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; + return this.corners.info(); } /** @@ -700,7 +336,7 @@ export class SceneViewport extends FreeViewport { return; } if (e.button !== 0) return; - if (this.startCornerEdit(e)) return; + if (this.corners.press(e)) return; const holder = this.resolved(); if (!holder) return; const stack = this.stackUnder(e); @@ -715,7 +351,7 @@ export class SceneViewport extends FreeViewport { 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.usePaintTool(target, holder.id, e)) return; + if (target && this.painter.press(target, e.global)) return; const press = pressOn(stack, this.state.selectedObjectIds, this.primaryObjectId()); if (!press) { this.pressEmpty(e); @@ -745,7 +381,8 @@ export class SceneViewport extends FreeViewport { private openMenuUnder(e: FederatedPointerEvent): void { const source = this.holder(); const holder = this.resolved(); - if (!source || !holder || this.dragging || this.stroke || this.corner || this.marquee) return; + if (!source || !holder || this.dragging || this.painter.active || this.corners.active || this.marquee) + return; const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId()); if (!target) return; e.stopPropagation(); @@ -790,11 +427,11 @@ export class SceneViewport extends FreeViewport { } /** The painted tilemap when the pointer is on its grid, whatever lies above it there. */ - private paintTargetUnder(e: FederatedPointerEvent): ReturnType { + 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.paintTargetOf(target.objectId) : null; + 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). */ @@ -863,7 +500,7 @@ export class SceneViewport extends FreeViewport { protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void { this.syncIcon(item, onScreen); - this.syncSolids(item, onScreen, scale); + this.solids.sync(item, onScreen, scale); this.syncLabel(item, onScreen, scale, extent); } @@ -872,118 +509,12 @@ export class SceneViewport extends FreeViewport { 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); + 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): { - 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 }, - ); - } + solidInfo(id: string): ReturnType { + return this.solids.info(this.lastItems.find((i) => i.id === id)); } /** The icon of a particle source, the name beside it and whether it hangs off its owner (test hook). */ @@ -1100,76 +631,6 @@ export class SceneViewport extends FreeViewport { .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)); } @@ -1198,19 +659,10 @@ export class SceneViewport extends FreeViewport { this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha }); } } - this.drawCorners(view); + this.corners.draw(this.gizmos, view); this.drawDropPreview(view); - this.drawPaintOverlay(items, view); + this.painter.draw(this.gizmos, 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 })); -} diff --git a/src/editor/viewport/polygonCorners.ts b/src/editor/viewport/polygonCorners.ts new file mode 100644 index 00000000..e0647058 --- /dev/null +++ b/src/editor/viewport/polygonCorners.ts @@ -0,0 +1,186 @@ +import type { FederatedPointerEvent, Graphics } from 'pixi.js'; +import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands'; +import { objectRef } from '../../core/model/instance'; +import { apply as applyPoint, invert, type Mat, scaleOf } from '../../core/model/matrix'; +import type { ListValue, PropertyValue, Vec2 } from '../../core/model/Properties'; +import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider'; +import { pressCount, Theme } from '../../ui'; +import type { DrawItem } from '../../viewport/items'; +import { type View, worldToScreen } from '../../viewport/view'; +import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit'; +import type { SceneViewHost } from './sceneViewHost'; + +/** 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 DRAG_THRESHOLD = 2; + +/** + * 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; + /** Global pointer position of the press. */ + pointer: Vec2; + inserted: boolean; + moved: boolean; +} + +/** Handles on the corners of the selected polygon collider, to move, add and take away (#151). */ +export class PolygonCornerEditor { + 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; + + constructor(private readonly host: SceneViewHost) {} + + /** A corner is being dragged. */ + get active(): boolean { + return this.corner !== null; + } + + /** + * The polygon collider whose corners can be edited: the primary selection, if it is one and + * belongs to the view's holder – a placed container's content is edited in its template. + */ + private editedPolygon(): DrawItem | null { + const state = this.host.state; + const s = state.selection; + const holder = this.host.holder(); + if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null; + const item = this.host.items().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. + */ + press(e: FederatedPointerEvent): boolean { + const state = this.host.state; + const item = this.editedPolygon(); + const holder = this.host.holder(); + const ref = item && holder && objectRef(state.project, holder, item.id); + if (!item || !holder || !ref) return false; + const toLocal = invert(item.m); + const at = applyPoint(toLocal, this.host.worldAt(e.global)); + const points = colliderPoints(item.props); + const tolerance = CORNER_GRIP / (this.host.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) state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next))); + else 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: e.global.x, y: e.global.y }, + inserted, + moved: false, + }; + if (inserted) this.preview(); + return true; + } + + /** Follows the pointer with the corner under way; false when there is none. */ + move(global: Vec2): boolean { + const corner = this.corner; + if (!corner) return false; + if ( + !corner.moved && + Math.hypot(global.x - corner.pointer.x, global.y - corner.pointer.y) < DRAG_THRESHOLD + ) + return true; + corner.moved = true; + const at = applyPoint(corner.toLocal, this.host.worldAt(global)); + corner.points = withCornerMoved(corner.points, corner.index, rounded(at)); + this.preview(); + return true; + } + + private preview(): void { + const corner = this.corner; + const holder = this.host.holder(); + const ref = corner && holder && objectRef(this.host.state.project, holder, corner.objectId); + if (!corner || !ref) return; + ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points)); + this.host.state.preview(); + } + + /** + * Puts the preview back and lets the command do the edit, so one drag is one undo step. False + * when no corner was under way. + */ + release(): boolean { + const corner = this.corner; + if (!corner) return false; + this.corner = null; + const state = this.host.state; + const holder = this.host.holder(); + const ref = holder && objectRef(state.project, holder, corner.objectId); + if (!ref) return true; + ref.setOverride(COLLIDER_POINTS_KEY, corner.before); + if (corner.moved || corner.inserted) + state.execute( + new SetOverrideCommand(corner.holderId, corner.objectId, COLLIDER_POINTS_KEY, listOf(corner.points)), + ); + else state.preview(); + return true; + } + + /** The corners of the polygon being edited, where they are drawn (test hook). */ + info(): { objectId: string; corners: Vec2[] } | null { + return this.lastCorners; + } + + /** A handle on every corner of the polygon collider being edited. */ + draw(g: Graphics, 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) + g.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 p = g.toGlobal(c); + return { x: p.x, y: p.y }; + }), + }; + } +} + +/** 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 })); +} diff --git a/src/editor/viewport/sceneViewHost.ts b/src/editor/viewport/sceneViewHost.ts new file mode 100644 index 00000000..84a9bbd4 --- /dev/null +++ b/src/editor/viewport/sceneViewHost.ts @@ -0,0 +1,21 @@ +import type { ObjectHolder } from '../../core/model/Project'; +import type { Vec2 } from '../../core/model/Properties'; +import type { DrawItem } from '../../viewport/items'; +import type { EditorState } from '../state'; + +/** Below this on-screen spacing a grid is noise rather than orientation, so it is dropped. */ +export const MIN_GRID_STEP = 12; + +/** What the scene view lends the tools that work inside it. */ +export interface SceneViewHost { + readonly state: EditorState; + /** What the view shows and edits. */ + holder(): ObjectHolder | undefined; + /** What the view drew last. */ + items(): readonly DrawItem[]; + /** The world point under a global pointer position. */ + worldAt(global: Vec2): Vec2; + /** Screen pixels per world unit. */ + scale(): number; + redraw(): void; +} diff --git a/src/editor/viewport/tilemapPainting.ts b/src/editor/viewport/tilemapPainting.ts new file mode 100644 index 00000000..529fe6a8 --- /dev/null +++ b/src/editor/viewport/tilemapPainting.ts @@ -0,0 +1,275 @@ +import type { Graphics } from 'pixi.js'; +import { type CellEdit, paintTiles } from '../../core/commands/tilemapCommands'; +import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset'; +import { apply as applyPoint, invert } from '../../core/model/matrix'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAssetOf } from '../../core/model/Project'; +import { + cellAt, + cellAtPoint, + cellKey, + cellOrigin, + cellsAlong, + cellsInRect, + findLayer, + floodFill, + inGrid, + tilemapColumns, + tilemapRows, + tilemapTileSize, +} from '../../core/model/tilemap'; +import { Theme } from '../../ui'; +import type { DrawItem } from '../../viewport/items'; +import { type View, worldToScreen } from '../../viewport/view'; +import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel'; +import type { TilemapTool } from '../state'; +import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost'; + +/** The selected tilemap a painting tool works on, and the layer it paints. */ +export interface PaintTarget { + item: DrawItem; + asset: TilemapAsset; + layerId: 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 { + 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; +} + +/** The tilemap tools of the scene view: brush, eraser, rectangle, fill and pick. */ +export class TilemapPainter { + private stroke: PaintStroke | null = null; + + constructor(private readonly host: SceneViewHost) {} + + /** A stroke is under way. */ + get active(): boolean { + return this.stroke !== null; + } + + /** + * 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. + */ + targetOf(objectId: string): PaintTarget | null { + const state = this.host.state; + if (state.tilemapPaint.tool === 'select') return null; + const target = paintedTilemap(state); + if (!target || target.objectId !== objectId) return null; + const item = this.host.items().find((i) => i.id === objectId); + if (!item || item.type !== 'tilemap') return null; + const layerId = paintedLayerId(state, target.asset); + return layerId ? { item, asset: target.asset, layerId } : null; + } + + /** + * 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. + */ + press(target: PaintTarget, global: Vec2): boolean { + const tool = this.host.state.tilemapPaint.tool; + if (tool !== 'fill' && tool !== 'pick') return this.startStroke(target, global); + const layer = findLayer(target.asset, target.layerId); + const at = this.cellUnder(target.item, global); + 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; + } + + /** Follows the pointer with the stroke under way; false when there is none. */ + move(global: Vec2): boolean { + const stroke = this.stroke; + if (!stroke) return false; + const item = this.host.items().find((i) => i.id === stroke.objectId); + if (!item) return true; + const at = this.cellUnder(item, global); + if (stroke.last && at.x === stroke.last.x && at.y === stroke.last.y) return true; + const from = stroke.last ?? stroke.from; + stroke.last = at; + stroke.to = at; + // A pointer jumps between events, so the cells in between are filled in as well. + if (stroke.tool !== 'rect') this.paintCells(item, cellsAlong(from, at)); + this.host.redraw(); + return true; + } + + /** Ends the stroke under way; false when there was none. */ + release(): boolean { + if (!this.stroke) return false; + this.endStroke(); + return true; + } + + /** The cell under a pointer position, in the tilemap's own grid. */ + private cellUnder(item: DrawItem, global: Vec2): Vec2 { + const inMap = applyPoint(invert(item.m), this.host.worldAt(global)); + return cellAtPoint(item.props, inMap.x, inMap.y); + } + + /** Takes the tile of a cell on the painted layer – the one painted on next (#133). */ + private pickCell(layer: TilemapLayer, at: Vec2): void { + const state = this.host.state; + const tileId = cellAt(layer, at.x, at.y); + if (!tileId) state.setNotice('This cell is empty on the active layer.'); + else if (!findAssetOf(state.project, tileId, 'tile')) + state.setNotice('This cell names a tile that no longer exists.'); + else pickTile(state, tileId); + } + + /** Fills the connected cells of one tile in a single command, so one click is one undo step. */ + private fillFrom(target: PaintTarget, layer: TilemapLayer, at: Vec2): void { + const state = this.host.state; + const tileId = state.tilemapPaint.tileId; + if (!tileId) { + state.setNotice('Choose a tile to paint with.'); + return; + } + const cells = floodFill(target.item.props, layer, at, tileId); + const command = paintTiles( + layer, + target.asset.id, + cells.map((cell) => ({ ...cell, tileId })), + 'Fill tiles', + ); + if (command) state.execute(command); + } + + private startStroke(target: PaintTarget, global: Vec2): boolean { + const paint = this.host.state.tilemapPaint; + const tileId = paint.tool === 'erase' ? '' : paint.tileId; + if (paint.tool !== 'erase' && !tileId) { + this.host.state.setNotice('Choose a tile to paint with.'); + return false; + } + const at = this.cellUnder(target.item, global); + this.stroke = { + 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.host.redraw(); + return true; + } + + private strokeLayer(stroke: PaintStroke): TilemapLayer | undefined { + const asset = findAssetOf(this.host.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.host.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.host.items().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; + const command = + layer && + paintTiles(layer, stroke.assetId, edits, stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles'); + if (command) this.host.state.execute(command); + else this.host.state.preview(); + this.host.redraw(); + } + + /** + * 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. + */ + draw(g: Graphics, items: readonly DrawItem[], view: View): void { + const state = this.host.state; + const target = state.tilemapPaint.tool === 'select' ? null : paintedTilemap(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, + ); + }; + if (tile * view.scale >= MIN_GRID_STEP) { + for (let x = 0; x <= columns; x++) { + const a = corner(x, 0); + const b = corner(x, rows); + g.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); + g.moveTo(a.x, a.y).lineTo(b.x, b.y); + } + g.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; + g.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 }); + } +} diff --git a/src/editor/viewport/tilemapSolids.ts b/src/editor/viewport/tilemapSolids.ts new file mode 100644 index 00000000..11838830 --- /dev/null +++ b/src/editor/viewport/tilemapSolids.ts @@ -0,0 +1,162 @@ +import { type Container, Graphics } from 'pixi.js'; +import { scaleOf } from '../../core/model/matrix'; +import type { BoundaryLine } from '../../core/model/polygon'; +import type { Vec2 } from '../../core/model/Properties'; +import { cellOrigin, tilemapTileSize } from '../../core/model/tilemap'; +import { type CellPolygon, type CellRect, solidBoundary } from '../../core/model/tilemapCollision'; +import { Theme } from '../../ui'; +import type { DrawItem } from '../../viewport/items'; +import { CONE_LENGTH, markBlockingSide, placeNode } from '../../viewport/symbols'; + +/** 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) + ); +} + +/** + * Where each placed tilemap blocks, outlined like a collider (#132). Editor-only for the same + * reason as a particle source's icon: the game view never syncs decorations. + */ +export class TilemapSolidOutlines { + private readonly nodes = 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[] } + >(); + + constructor(private readonly layer: Container) {} + + dispose(id: string): void { + this.nodes.get(id)?.destroy(); + this.nodes.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). */ + info(item: DrawItem | undefined): { + visible: boolean; + rects: CellRect[]; + outlines: Vec2[][]; + oneWay: CellRect[]; + oneWayOutlines: Vec2[][]; + boundary: BoundaryLine[]; + } | null { + const node = item && this.nodes.get(item.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. + */ + sync(item: DrawItem, show: boolean, scale: number): void { + let node = this.nodes.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.nodes.set(item.id, node); + this.layer.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 }, + ); + } + } +}