Split tile painting and polygon corners out of SceneViewport (#215)

SceneViewport had grown to 1216 lines. Three blocks carried their own
state and only needed a few things from the view, so they now live in
their own classes. Behaviour is unchanged.

- SceneViewHost is the narrow interface the new classes see of the
  view: state, coordinate mapping, redraw and the preview trigger.
- TilemapPainting owns the paint stroke, the rectangle preview and
  the flood fill, with press/move/release called in the old order.
- PolygonCorners owns hovering, dragging, inserting and deleting the
  corners of a polygon collider.
- TilemapSolids draws the solid outlines of tilemap layers.
- SceneViewport is down to 668 lines.

Findings:
- PaintStroke.holderId was written but never read. It is gone.

Tests:
- ./check.sh: 145 files, 2190 tests, CHECK EXIT 0 (unchanged)
- e2e 1-16,29,31,39-43: ALL PASSED, 679 checks
- Counter-checks in the browser: bending the brush, the corner drag
  and the solid outline each fails exactly the expected checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-23 10:20:03 +02:00
1 parent 487b3aa5b4
commit e31db3df1d
5 files changed
+686 -590

No files matched your search

+42 -590
View File
@@ -5,13 +5,7 @@
*/ */
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import { oneStep } from '../../core/commands/CompoundCommand'; import { oneStep } from '../../core/commands/CompoundCommand';
import { import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
MoveSceneObjectCommand,
PlaceAssetCommand,
SetOverrideCommand,
} from '../../core/commands/sceneObjectCommands';
import { type CellEdit, paintTiles } from '../../core/commands/tilemapCommands';
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
import { hasSceneIcon } from '../../core/model/AssetTypes'; import { hasSceneIcon } from '../../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { findObject, subtreeRoots } from '../../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../../core/model/instance'; import { objectRef, resolvedHolder } from '../../core/model/instance';
@@ -22,65 +16,35 @@ import {
invert, invert,
linearOf, linearOf,
type Mat, type Mat,
scaleOf,
} from '../../core/model/matrix'; } from '../../core/model/matrix';
import { placementRejection } from '../../core/model/placement'; import { placementRejection } from '../../core/model/placement';
import type { ListValue, Position, PropertyValue, Vec2 } from '../../core/model/Properties'; import type { Position, Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project'; import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { import { cellAtPoint } from '../../core/model/tilemap';
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 { footprintCorners } from '../../core/properties/bounds';
import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { type EditorState, objectSelection, type TilemapTool } from '../state'; import { type EditorState, objectSelection } from '../state';
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
import { import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
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 { collectHolderItems, type DrawItem } from '../../viewport/items';
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
import { openObjectMenu } from '../objectMenu'; import { openObjectMenu } from '../objectMenu';
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; 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 { drawSymbolIcon } from '../../viewport/symbolIcons';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport'; 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'; 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. */ /** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3; const MARQUEE_THRESHOLD = 3;
const DRAG_THRESHOLD = 2; const DRAG_THRESHOLD = 2;
const GRID_STEP = 100; 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 { interface ObjectDrag {
holderId: string; holderId: string;
@@ -92,24 +56,6 @@ interface ObjectDrag {
onClick: string | null; 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. */ /** An asset hovering over the view on its way in: where it would land, and whether it may. */
interface DropPreview { interface DropPreview {
assetId: string; assetId: string;
@@ -117,26 +63,6 @@ interface DropPreview {
rejection: string; rejection: string;
} }
/**
* A stroke on a placed tilemap. `previous` is what the cells held before the live preview wrote
* into them – the same trick a move drag uses: the model shows the stroke at once, and on release
* it is put back so the command records it as one undo step.
*/
interface PaintStroke {
holderId: string;
objectId: string;
assetId: string;
layerId: string;
tool: TilemapTool;
tileId: string;
edits: CellEdit[];
previous: Map<string, string>;
from: Vec2;
last: Vec2 | null;
/** Where the pointer is now, in cells – what the rectangle preview is drawn from. */
to: Vec2;
}
/** A rubber-band selection in progress, in viewport-local pixels. */ /** A rubber-band selection in progress, in viewport-local pixels. */
interface Marquee { interface Marquee {
start: Vec2; start: Vec2;
@@ -160,51 +86,29 @@ const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
rigidbody: Theme.colors.collider, rigidbody: Theme.colors.collider,
}; };
/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */
type TilemapSolids = Pick<NonNullable<DrawItem['tilemap']>, 'solids' | 'outlines' | 'oneWay'>;
function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) =>
p.length === q.length &&
p.every((r, i) => r.x === q[i]!.x && r.y === q[i]!.y && r.w === q[i]!.w && r.h === q[i]!.h);
const samePolygons = (p: readonly CellPolygon[], q: readonly CellPolygon[]) =>
p.length === q.length &&
p.every(
(polygon, i) =>
polygon.outline.length === q[i]!.outline.length &&
polygon.outline.every((c, j) => c.x === q[i]!.outline[j]!.x && c.y === q[i]!.outline[j]!.y),
);
return (
sameRects(a.solids, b.solids) &&
samePolygons(a.outlines, b.outlines) &&
sameRects(a.oneWay.solids, b.oneWay.solids) &&
samePolygons(a.oneWay.outlines, b.oneWay.outlines)
);
}
export class SceneViewport extends FreeViewport { export class SceneViewport extends FreeViewport {
private readonly grid = new Graphics(); private readonly grid = new Graphics();
private readonly links = new Graphics(); private readonly links = new Graphics();
private readonly gizmos = new Graphics(); private readonly gizmos = new Graphics();
private readonly labels = new Map<string, Text>(); private readonly labels = new Map<string, Text>();
private readonly icons = new Map<string, Graphics>(); private readonly icons = new Map<string, Graphics>();
/** Where each placed tilemap blocks, outlined like a collider (#132). */ private readonly solids = new TilemapSolidOutlines(this.overlay);
private readonly solids = new Map<string, Graphics>(); private readonly host: SceneViewHost = {
/** state: this.state,
* The outer edges of each tilemap's solids and what they were worked out from (#173). A map is holder: () => this.holder(),
* resolved afresh on every draw, so they are kept by what blocks rather than by the resolved map. items: () => this.lastItems,
*/ worldAt: (global) => {
private readonly boundaries = new Map< const local = this.toLocal(global);
string, return screenToWorld(this.currentView(), local.x, local.y);
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] } },
>(); 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 editorWorld: World | null = null;
private lastRays: CastRay[] = []; private lastRays: CastRay[] = [];
private dragging: ObjectDrag | null = null; 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 marquee: Marquee | null = null;
private dropPreview: DropPreview | null = null; private dropPreview: DropPreview | null = null;
private lastGizmos: GizmoInfo[] = []; private lastGizmos: GizmoInfo[] = [];
@@ -286,7 +190,7 @@ export class SceneViewport extends FreeViewport {
this.startPan(e); this.startPan(e);
return; 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); 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); this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
return; return;
} }
if (this.panTo(e)) return; if (this.panTo(e) || this.corners.move(e.global) || this.painter.move(e.global)) 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) { if (this.dragging) {
const local = this.toLocal(e.global); const local = this.toLocal(e.global);
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
@@ -406,14 +293,7 @@ export class SceneViewport extends FreeViewport {
return; return;
} }
this.endPan(); this.endPan();
if (this.corner) { if (this.corners.release() || this.painter.release()) return;
this.endCorner();
return;
}
if (this.stroke) {
this.endStroke();
return;
}
const marquee = this.marquee; const marquee = this.marquee;
this.marquee = null; this.marquee = null;
if (marquee) { if (marquee) {
@@ -440,253 +320,9 @@ export class SceneViewport extends FreeViewport {
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves)); 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). */ /** The corners of the polygon being edited, where they are drawn (test hook). */
cornerInfo(): { objectId: string; corners: Vec2[] } | null { cornerInfo(): { objectId: string; corners: Vec2[] } | null {
return this.lastCorners; return this.corners.info();
} }
/** /**
@@ -700,7 +336,7 @@ export class SceneViewport extends FreeViewport {
return; return;
} }
if (e.button !== 0) return; if (e.button !== 0) return;
if (this.startCornerEdit(e)) return; if (this.corners.press(e)) return;
const holder = this.resolved(); const holder = this.resolved();
if (!holder) return; if (!holder) return;
const stack = this.stackUnder(e); const stack = this.stackUnder(e);
@@ -715,7 +351,7 @@ export class SceneViewport extends FreeViewport {
e.stopPropagation(); e.stopPropagation();
// A painting tool on the selected tilemap paints anywhere on its grid, empty cells included. // A painting tool on the selected tilemap paints anywhere on its grid, empty cells included.
const target = this.paintTargetUnder(e); 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()); const press = pressOn(stack, this.state.selectedObjectIds, this.primaryObjectId());
if (!press) { if (!press) {
this.pressEmpty(e); this.pressEmpty(e);
@@ -745,7 +381,8 @@ export class SceneViewport extends FreeViewport {
private openMenuUnder(e: FederatedPointerEvent): void { private openMenuUnder(e: FederatedPointerEvent): void {
const source = this.holder(); const source = this.holder();
const holder = this.resolved(); 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()); const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId());
if (!target) return; if (!target) return;
e.stopPropagation(); 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. */ /** The painted tilemap when the pointer is on its grid, whatever lies above it there. */
private paintTargetUnder(e: FederatedPointerEvent): ReturnType<SceneViewport['paintTargetOf']> { private paintTargetUnder(e: FederatedPointerEvent): PaintTarget | null {
const target = paintedTilemap(this.state); const target = paintedTilemap(this.state);
if (!target) return null; if (!target) return null;
const onGrid = this.hitsUnder(e.global).some((hit) => hit.id === target.objectId); 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). */ /** 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 { protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void {
this.syncIcon(item, onScreen); this.syncIcon(item, onScreen);
this.syncSolids(item, onScreen, scale); this.solids.sync(item, onScreen, scale);
this.syncLabel(item, onScreen, scale, extent); this.syncLabel(item, onScreen, scale, extent);
} }
@@ -872,118 +509,12 @@ export class SceneViewport extends FreeViewport {
this.labels.delete(id); this.labels.delete(id);
this.icons.get(id)?.destroy(); this.icons.get(id)?.destroy();
this.icons.delete(id); this.icons.delete(id);
this.solids.get(id)?.destroy(); this.solids.dispose(id);
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). */ /** The blocking rectangles and outlined cells of a placed tilemap, and the outer edges drawn round them, in cells (test hook). */
solidInfo(id: string): { solidInfo(id: string): ReturnType<TilemapSolidOutlines['info']> {
visible: boolean; return this.solids.info(this.lastItems.find((i) => i.id === id));
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). */ /** 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 }); .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[] { private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
return corners.map((c) => worldToScreen(view, c.x, c.y)); 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.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.drawDropPreview(view);
this.drawPaintOverlay(items, view); this.painter.draw(this.gizmos, items, view);
const m = this.marquee; const m = this.marquee;
if (m?.active) drawBand(this.gizmos, m.start, m.current); 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 }));
}
+186
View File
@@ -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 }));
}
+21
View File
@@ -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;
}
+275
View File
@@ -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<string, string>;
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 });
}
}
+162
View File
@@ -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<NonNullable<DrawItem['tilemap']>, 'solids' | 'outlines' | 'oneWay'>;
function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) =>
p.length === q.length &&
p.every((r, i) => r.x === q[i]!.x && r.y === q[i]!.y && r.w === q[i]!.w && r.h === q[i]!.h);
const samePolygons = (p: readonly CellPolygon[], q: readonly CellPolygon[]) =>
p.length === q.length &&
p.every(
(polygon, i) =>
polygon.outline.length === q[i]!.outline.length &&
polygon.outline.every((c, j) => c.x === q[i]!.outline[j]!.x && c.y === q[i]!.outline[j]!.y),
);
return (
sameRects(a.solids, b.solids) &&
samePolygons(a.outlines, b.outlines) &&
sameRects(a.oneWay.solids, b.oneWay.solids) &&
samePolygons(a.oneWay.outlines, b.oneWay.outlines)
);
}
/**
* 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<string, Graphics>();
/**
* The outer edges of each tilemap's solids and what they were worked out from (#173). A map is
* resolved afresh on every draw, so they are kept by what blocks rather than by the resolved map.
*/
private readonly boundaries = new Map<
string,
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] }
>();
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 },
);
}
}
}