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:
1 parent
487b3aa5b4
commit
e31db3df1d
5 files changed
+686
-590
No files matched your search
@@ -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<string, string>;
|
||||
from: Vec2;
|
||||
last: Vec2 | null;
|
||||
/** Where the pointer is now, in cells – what the rectangle preview is drawn from. */
|
||||
to: Vec2;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress, in viewport-local pixels. */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
@@ -160,51 +86,29 @@ const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||||
rigidbody: Theme.colors.collider,
|
||||
};
|
||||
|
||||
/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */
|
||||
type TilemapSolids = Pick<NonNullable<DrawItem['tilemap']>, 'solids' | 'outlines' | 'oneWay'>;
|
||||
|
||||
function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
|
||||
const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) =>
|
||||
p.length === q.length &&
|
||||
p.every((r, i) => r.x === q[i]!.x && r.y === q[i]!.y && r.w === q[i]!.w && r.h === q[i]!.h);
|
||||
const samePolygons = (p: readonly CellPolygon[], q: readonly CellPolygon[]) =>
|
||||
p.length === q.length &&
|
||||
p.every(
|
||||
(polygon, i) =>
|
||||
polygon.outline.length === q[i]!.outline.length &&
|
||||
polygon.outline.every((c, j) => c.x === q[i]!.outline[j]!.x && c.y === q[i]!.outline[j]!.y),
|
||||
);
|
||||
return (
|
||||
sameRects(a.solids, b.solids) &&
|
||||
samePolygons(a.outlines, b.outlines) &&
|
||||
sameRects(a.oneWay.solids, b.oneWay.solids) &&
|
||||
samePolygons(a.oneWay.outlines, b.oneWay.outlines)
|
||||
);
|
||||
}
|
||||
|
||||
export class SceneViewport extends FreeViewport {
|
||||
private readonly grid = new Graphics();
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly icons = new Map<string, Graphics>();
|
||||
/** Where each placed tilemap blocks, outlined like a collider (#132). */
|
||||
private readonly solids = new Map<string, Graphics>();
|
||||
/**
|
||||
* The outer edges of each tilemap's solids and what they were worked out from (#173). A map is
|
||||
* resolved afresh on every draw, so they are kept by what blocks rather than by the resolved map.
|
||||
*/
|
||||
private readonly boundaries = new Map<
|
||||
string,
|
||||
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] }
|
||||
>();
|
||||
private 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<SceneViewport['paintTargetOf']> {
|
||||
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<TilemapSolidOutlines['info']> {
|
||||
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 }));
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user