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 { 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 }));
|
|
||||||
}
|
|
||||||
@@ -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