Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b5e386b60
commit
32057a96fc
25 files changed
+2341
-122
No files matched your search
@@ -311,6 +311,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
gameInfo: () => gameView.debugInfo(),
|
||||
/** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */
|
||||
gizmos: () => viewport.gizmoInfo(),
|
||||
/** The corner handles of the polygon collider being edited, in global pixels (#151). */
|
||||
polygonCorners: () => viewport.cornerInfo(),
|
||||
layerOf: (id: string) => viewport.layerOf(id),
|
||||
/** The icon a particle source wears in the Scene tab, with its name and its owner link. */
|
||||
sceneIcon: (id: string) => viewport.iconInfo(id),
|
||||
@@ -474,6 +476,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||||
rect: () => textureEditor.canvasRect(),
|
||||
/** Whether a press edits the selected frame's collision outline (#141). */
|
||||
collisionMode: () => textureEditor.canvas.collisionMode,
|
||||
},
|
||||
animation: {
|
||||
current: () => editor.animationEditor.currentAssetId,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Editing an outline by its corners (#141, #151), the same in the Scene view (a polygon collider)
|
||||
* and in the Texture tab (a frame's collision outline): drag a corner, press on an edge to put a
|
||||
* corner there, double-click a corner to take it away. Pure, so the rules are testable without a view.
|
||||
*/
|
||||
import type { Vec2 } from '../core/model/Properties';
|
||||
|
||||
/** What lies under the pointer: a corner, or the edge from corner `index` to the next one. */
|
||||
export type PolygonGrip = { kind: 'corner'; index: number } | { kind: 'edge'; index: number; at: Vec2 };
|
||||
|
||||
/** Fewer corners than this are no polygon; the last three cannot be taken away. */
|
||||
export const MIN_CORNERS = 3;
|
||||
|
||||
function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||
const ab = { x: b.x - a.x, y: b.y - a.y };
|
||||
const length = ab.x * ab.x + ab.y * ab.y;
|
||||
const t = length > 0 ? Math.min(1, Math.max(0, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / length)) : 0;
|
||||
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
||||
}
|
||||
|
||||
/**
|
||||
* The corner or edge within `tolerance` of `p`, the nearest one; a corner wins over an edge, since
|
||||
* every corner lies on two edges. An outline of one or two corners has no edge to press on yet.
|
||||
*/
|
||||
export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): PolygonGrip | null {
|
||||
let best: PolygonGrip | null = null;
|
||||
let nearest = tolerance;
|
||||
for (const [index, corner] of points.entries()) {
|
||||
const distance = Math.hypot(corner.x - p.x, corner.y - p.y);
|
||||
if (distance > nearest) continue;
|
||||
best = { kind: 'corner', index };
|
||||
nearest = distance;
|
||||
}
|
||||
if (best || points.length < MIN_CORNERS) return best;
|
||||
for (const [index, a] of points.entries()) {
|
||||
const at = closestOnSegment(p, a, points[(index + 1) % points.length]!);
|
||||
const distance = Math.hypot(at.x - p.x, at.y - p.y);
|
||||
if (distance > nearest) continue;
|
||||
best = { kind: 'edge', index, at };
|
||||
nearest = distance;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** A corner put at `at` on the edge after corner `index`; it becomes corner `index + 1`. */
|
||||
export function withCornerAfter(points: readonly Vec2[], index: number, at: Vec2): Vec2[] {
|
||||
return [...points.slice(0, index + 1), { ...at }, ...points.slice(index + 1)];
|
||||
}
|
||||
|
||||
export function withCornerMoved(points: readonly Vec2[], index: number, to: Vec2): Vec2[] {
|
||||
return points.map((p, i) => (i === index ? { ...to } : { ...p }));
|
||||
}
|
||||
|
||||
/** The outline without corner `index`; null when that would leave it fewer than `min` corners. */
|
||||
export function withoutCorner(points: readonly Vec2[], index: number, min = MIN_CORNERS): Vec2[] | null {
|
||||
if (points.length - 1 < min) return null;
|
||||
return points.filter((_, i) => i !== index).map((p) => ({ ...p }));
|
||||
}
|
||||
@@ -8,23 +8,28 @@ import {
|
||||
} from 'pixi.js';
|
||||
import {
|
||||
AddFrameCommand,
|
||||
SetFrameCollisionCommand,
|
||||
SetFrameRectCommand,
|
||||
SetFrameSliceCommand,
|
||||
} from '../../core/commands/frameCommands';
|
||||
import { convexPieces } from '../../core/model/polygon';
|
||||
import type { FrameBorders, TextureAsset, TextureFrame } from '../../core/model/Asset';
|
||||
import { textOf, type Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import {
|
||||
frameBorders,
|
||||
frameCollision,
|
||||
frameFill,
|
||||
type FrameRect,
|
||||
MIN_FRAME_SIZE,
|
||||
normalizeFrameRect,
|
||||
rawFrameSlice,
|
||||
restoreFrameSlice,
|
||||
setFrameCollision,
|
||||
setFrameSlice,
|
||||
} from '../../core/model/texture';
|
||||
import { type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||||
import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui';
|
||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -73,6 +78,20 @@ interface FrameDrag {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A corner of a frame's collision outline being dragged (#141) – also one just put down. The model
|
||||
* holds the preview; `before` is put back on release so the command records one undo step.
|
||||
*/
|
||||
interface CornerDrag {
|
||||
frameId: string;
|
||||
before: Vec2[];
|
||||
points: Vec2[];
|
||||
index: number;
|
||||
pointer: Vec2;
|
||||
added: boolean;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** A new frame being spanned, in image pixels; nothing is in the model yet. */
|
||||
interface NewFrame {
|
||||
start: Vec2;
|
||||
@@ -102,6 +121,9 @@ export class TextureCanvas extends Widget {
|
||||
private panning: { pointer: Vec2; panX: number; panY: number } | null = null;
|
||||
private dragging: FrameDrag | null = null;
|
||||
private spanning: NewFrame | null = null;
|
||||
private corner: CornerDrag | null = null;
|
||||
/** Whether a press edits the selected frame's collision outline instead of the frames (#141). */
|
||||
private collision = false;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
@@ -144,6 +166,16 @@ export class TextureCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
setCollisionMode(on: boolean): void {
|
||||
if (this.collision === on) return;
|
||||
this.collision = on;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get collisionMode(): boolean {
|
||||
return this.collision;
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
@@ -185,6 +217,11 @@ export class TextureCanvas extends Widget {
|
||||
ui().focus.focus(this);
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
if (this.collision) {
|
||||
this.pressOutline(asset, point, local, tolerance, e);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// The selected frame is tested first, so its handles stay reachable under a frame drawn later.
|
||||
// Its resize handles before its border lines – a border still sitting on the frame's edge must
|
||||
@@ -221,6 +258,74 @@ export class TextureCanvas extends Widget {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* A press in collision mode. On the selected frame: a double-click on a corner takes it away, a
|
||||
* press on a corner drags it, on an edge puts a corner there, anywhere else inside the frame adds
|
||||
* one after the last – how an outline is drawn from nothing. Another frame is only selected, and
|
||||
* no frame is moved or spanned while the mode is on.
|
||||
*/
|
||||
private pressOutline(
|
||||
asset: TextureAsset,
|
||||
point: Vec2,
|
||||
local: Vec2,
|
||||
tolerance: number,
|
||||
e: FederatedPointerEvent,
|
||||
): void {
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
if (selected) {
|
||||
const at = { x: point.x - selected.x, y: point.y - selected.y };
|
||||
const points = frameCollision(selected);
|
||||
const grip = gripAt(points, at, tolerance);
|
||||
if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${grip.index}`) === 2) {
|
||||
this.state.execute(
|
||||
new SetFrameCollisionCommand(asset.id, selected.id, withoutCorner(points, grip.index, 0)!),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A corner often belongs on the frame's edge: a press just beside it still counts as in it.
|
||||
const inside =
|
||||
at.x >= -tolerance &&
|
||||
at.y >= -tolerance &&
|
||||
at.x <= selected.w + tolerance &&
|
||||
at.y <= selected.h + tolerance;
|
||||
if (grip || inside) {
|
||||
const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at);
|
||||
const next =
|
||||
grip?.kind === 'corner'
|
||||
? points
|
||||
: grip?.kind === 'edge'
|
||||
? withCornerAfter(points, grip.index, corner)
|
||||
: [...points, corner];
|
||||
this.corner = {
|
||||
frameId: selected.id,
|
||||
before: points,
|
||||
points: next,
|
||||
index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : next.length - 1,
|
||||
pointer: { x: local.x, y: local.y },
|
||||
added: grip?.kind !== 'corner',
|
||||
moved: false,
|
||||
};
|
||||
if (this.corner.added) this.previewOutline();
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = asset.frames.length - 1; i >= 0; i--) {
|
||||
const frame = asset.frames[i]!;
|
||||
if (handleAt(frame, point, tolerance) === null) continue;
|
||||
this.state.select({ kind: 'frame', assetId: asset.id, frameId: frame.id });
|
||||
return;
|
||||
}
|
||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
}
|
||||
|
||||
private previewOutline(): void {
|
||||
const corner = this.corner;
|
||||
const frame = corner && this.frame(corner.frameId);
|
||||
if (!corner || !frame) return;
|
||||
setFrameCollision(frame, corner.points);
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
private startDrag(
|
||||
frame: TextureFrame,
|
||||
handle: FrameHandle | null,
|
||||
@@ -250,6 +355,25 @@ export class TextureCanvas extends Widget {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
if (this.corner) {
|
||||
const corner = this.corner;
|
||||
const frame = this.frame(corner.frameId);
|
||||
if (!frame) return;
|
||||
if (
|
||||
!corner.moved &&
|
||||
Math.hypot(local.x - corner.pointer.x, local.y - corner.pointer.y) < DRAG_THRESHOLD
|
||||
)
|
||||
return;
|
||||
corner.moved = true;
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
corner.points = withCornerMoved(
|
||||
corner.points,
|
||||
corner.index,
|
||||
inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }),
|
||||
);
|
||||
this.previewOutline();
|
||||
return;
|
||||
}
|
||||
if (this.dragging) {
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||||
@@ -290,6 +414,16 @@ export class TextureCanvas extends Widget {
|
||||
|
||||
private onPointerUp(): void {
|
||||
this.panning = null;
|
||||
const corner = this.corner;
|
||||
this.corner = null;
|
||||
const frame = corner && this.frame(corner.frameId);
|
||||
if (corner && frame) {
|
||||
setFrameCollision(frame, corner.before);
|
||||
if (corner.added || corner.moved)
|
||||
this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.points));
|
||||
else this.state.preview();
|
||||
return;
|
||||
}
|
||||
const drag = this.dragging;
|
||||
const span = this.spanning;
|
||||
this.dragging = null;
|
||||
@@ -343,6 +477,10 @@ export class TextureCanvas extends Widget {
|
||||
this.cursor = 'default';
|
||||
return;
|
||||
}
|
||||
if (this.collision) {
|
||||
this.cursor = 'crosshair';
|
||||
return;
|
||||
}
|
||||
const point = localToImage(this.view, local.x, local.y);
|
||||
const tolerance = HANDLE_SIZE / this.view.zoom;
|
||||
const selected = this.frame(this.selectedFrameId());
|
||||
@@ -419,8 +557,9 @@ export class TextureCanvas extends Widget {
|
||||
alive.add(frame.id);
|
||||
this.drawFrame(frame, frame.id === selectedId);
|
||||
}
|
||||
for (const frame of asset.frames) this.drawOutline(frame, this.collision && frame.id === selectedId);
|
||||
const selected = this.frame(selectedId);
|
||||
if (selected) {
|
||||
if (selected && !this.collision) {
|
||||
this.drawBorders(selected);
|
||||
this.drawHandles(selected);
|
||||
}
|
||||
@@ -464,6 +603,34 @@ export class TextureCanvas extends Widget {
|
||||
this.overlay.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame's collision outline (#141), always shown so one sees which frames block with one; the
|
||||
* edited one gets a handle on every corner. An outline that is no polygon is drawn in red.
|
||||
*/
|
||||
private drawOutline(frame: TextureFrame, edited: boolean): void {
|
||||
const points = frameCollision(frame);
|
||||
if (points.length === 0) return;
|
||||
const screen = points.map((p) => imageToLocal(this.view, frame.x + p.x, frame.y + p.y));
|
||||
const color = convexPieces(points).length > 0 ? Theme.colors.collider : Theme.colors.danger;
|
||||
if (screen.length >= 3)
|
||||
this.overlay
|
||||
.poly(screen, true)
|
||||
.fill({ color, alpha: 0.15 })
|
||||
.stroke({ width: edited ? 2 : 1, color });
|
||||
else if (screen.length === 2)
|
||||
this.overlay
|
||||
.moveTo(screen[0]!.x, screen[0]!.y)
|
||||
.lineTo(screen[1]!.x, screen[1]!.y)
|
||||
.stroke({ width: 2, color });
|
||||
if (!edited) return;
|
||||
const half = HANDLE_SIZE / 2;
|
||||
for (const c of screen)
|
||||
this.overlay
|
||||
.rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE)
|
||||
.fill(Theme.colors.accentText)
|
||||
.stroke({ width: 1, color });
|
||||
}
|
||||
|
||||
private drawHandles(frame: TextureFrame): void {
|
||||
const half = HANDLE_SIZE / 2;
|
||||
for (const handle of frameHandles()) {
|
||||
@@ -504,15 +671,27 @@ export class TextureCanvas extends Widget {
|
||||
private syncHint(asset: TextureAsset | null, hasPicture: boolean): void {
|
||||
const text = !asset
|
||||
? 'No texture selected – pick one in the Assets panel or import an image.'
|
||||
: !asset.file
|
||||
? 'This texture has no picture yet.'
|
||||
: !hasPicture
|
||||
? 'Loading…'
|
||||
: asset.frames.length === 0
|
||||
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
|
||||
: '';
|
||||
: this.collision && asset.file && hasPicture
|
||||
? this.selectedFrameId()
|
||||
? 'Collision: click in the frame to add a corner, drag one to move it, click an edge to insert, double-click to remove.'
|
||||
: 'Collision: select a frame to draw the outline its tiles block with.'
|
||||
: !asset.file
|
||||
? 'This texture has no picture yet.'
|
||||
: !hasPicture
|
||||
? 'Loading…'
|
||||
: asset.frames.length === 0
|
||||
? 'Drag on the picture to cut a frame, or use Slice to cut a grid at once.'
|
||||
: '';
|
||||
this.hint.text = text;
|
||||
this.hint.visible = text !== '';
|
||||
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
|
||||
}
|
||||
}
|
||||
|
||||
/** A corner of an outline: on whole pixels, and inside its frame. */
|
||||
function inFrame(frame: TextureFrame, p: Vec2): Vec2 {
|
||||
return {
|
||||
x: Math.min(frame.w, Math.max(0, Math.round(p.x))),
|
||||
y: Math.min(frame.h, Math.max(0, Math.round(p.y))),
|
||||
};
|
||||
}
|
||||
@@ -11,11 +11,22 @@ const INFO_WIDTH = 190;
|
||||
|
||||
export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
private readonly sliceButton: Button;
|
||||
/** Switches the canvas between editing frames and editing their collision outlines (#141). */
|
||||
private readonly collisionButton: Button;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
|
||||
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
|
||||
this.mount(new TextureCanvas(state), [this.sliceButton]);
|
||||
this.collisionButton = new Button('Collision', {
|
||||
toggle: true,
|
||||
onClick: () => this.setCollisionMode(!this.canvas.collisionMode),
|
||||
});
|
||||
this.mount(new TextureCanvas(state), [this.sliceButton, this.collisionButton]);
|
||||
}
|
||||
|
||||
setCollisionMode(on: boolean): void {
|
||||
this.canvas.setCollisionMode(on);
|
||||
this.collisionButton.setToggled(on);
|
||||
}
|
||||
|
||||
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import {
|
||||
MoveSceneObjectCommand,
|
||||
PlaceAssetCommand,
|
||||
SetOverrideCommand,
|
||||
} from '../../core/commands/sceneObjectCommands';
|
||||
import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands';
|
||||
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
|
||||
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
||||
@@ -21,7 +25,7 @@ import {
|
||||
scaleOf,
|
||||
} from '../../core/model/matrix';
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
import type { Position, Vec2 } from '../../core/model/Properties';
|
||||
import type { ListValue, Position, PropertyValue, Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
|
||||
import {
|
||||
cellAt,
|
||||
@@ -39,10 +43,21 @@ import {
|
||||
} from '../../core/model/tilemap';
|
||||
import type { CellRect } from '../../core/model/tilemapCollision';
|
||||
import { footprintCorners } from '../../core/properties/bounds';
|
||||
import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider';
|
||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||
import { type EditorState, objectSelection, type TilemapTool } from '../state';
|
||||
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
|
||||
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
|
||||
import {
|
||||
activeDrag,
|
||||
drawBand,
|
||||
drawGrid,
|
||||
endDrag,
|
||||
onDragChange,
|
||||
pressCount,
|
||||
Theme,
|
||||
textStyle,
|
||||
} from '../../ui';
|
||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||||
import type { LayerFocus } from '../../viewport/tilemap';
|
||||
import { dashedLine, drawSourceIcon, placeNode } from '../../viewport/symbols';
|
||||
@@ -50,6 +65,11 @@ import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
import { FreeViewport } from './FreeViewport';
|
||||
import type { Frame } from './WorldViewport';
|
||||
|
||||
/** Screen pixels around a polygon's corner or edge that grab it. */
|
||||
const CORNER_GRIP = 7;
|
||||
/** Side of a corner handle in screen pixels. */
|
||||
const CORNER_SIZE = 8;
|
||||
|
||||
/** Pixels of travel before a press turns into a drag. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
const DRAG_THRESHOLD = 2;
|
||||
@@ -65,6 +85,24 @@ interface ObjectDrag {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A corner of a polygon collider being dragged (#151) – one just put on an edge, too. Like a move
|
||||
* drag it writes the model as a preview; `before` is the override that was there, put back on
|
||||
* release so the command records the edit as one undo step.
|
||||
*/
|
||||
interface CornerDrag {
|
||||
holderId: string;
|
||||
objectId: string;
|
||||
before: PropertyValue | undefined;
|
||||
points: Vec2[];
|
||||
index: number;
|
||||
/** From the world into the collider's own units. */
|
||||
toLocal: Mat;
|
||||
pointer: Vec2;
|
||||
inserted: boolean;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
|
||||
interface DropPreview {
|
||||
assetId: string;
|
||||
@@ -124,6 +162,9 @@ export class SceneViewport extends FreeViewport {
|
||||
/** Where each placed tilemap blocks, outlined like a collider (#132). */
|
||||
private readonly solids = new Map<string, Graphics>();
|
||||
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;
|
||||
@@ -206,6 +247,7 @@ export class SceneViewport extends FreeViewport {
|
||||
this.startPan(e);
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 && this.startCornerEdit(e)) return;
|
||||
if (e.button === 0 && e.target === this) {
|
||||
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
|
||||
const local = this.toLocal(e.global);
|
||||
@@ -225,6 +267,10 @@ export class SceneViewport extends FreeViewport {
|
||||
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;
|
||||
@@ -315,6 +361,10 @@ export class SceneViewport extends FreeViewport {
|
||||
return;
|
||||
}
|
||||
this.endPan();
|
||||
if (this.corner) {
|
||||
this.endCorner();
|
||||
return;
|
||||
}
|
||||
if (this.stroke) {
|
||||
this.endStroke();
|
||||
return;
|
||||
@@ -504,8 +554,104 @@ export class SceneViewport extends FreeViewport {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/**
|
||||
* The polygon collider whose corners can be edited: the primary selection, if it is one and
|
||||
* belongs to this view's holder – a placed container's content is edited in its template.
|
||||
*/
|
||||
private editedPolygon(): DrawItem | null {
|
||||
const s = this.state.selection;
|
||||
const holder = this.holder();
|
||||
if (s?.kind !== 'object' || !holder || !objectRef(this.state.project, holder, s.objectId)) return null;
|
||||
const item = this.lastItems.find((i) => i.id === s.objectId);
|
||||
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A press on a corner or an edge of the edited polygon: a double-click takes the corner away,
|
||||
* a press on an edge puts a corner there, and either can then be dragged. True when it was one.
|
||||
*/
|
||||
private startCornerEdit(e: FederatedPointerEvent): boolean {
|
||||
const item = this.editedPolygon();
|
||||
const holder = this.holder();
|
||||
const ref = item && holder && objectRef(this.state.project, holder, item.id);
|
||||
if (!item || !holder || !ref) return false;
|
||||
const toLocal = invert(item.m);
|
||||
const pointer = this.toLocal(e.global);
|
||||
const at = applyPoint(toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
|
||||
const points = colliderPoints(item.props);
|
||||
const tolerance = CORNER_GRIP / (this.currentView().scale * (scaleOf(item.m) || 1));
|
||||
const grip = gripAt(points, at, tolerance);
|
||||
if (!grip) return false;
|
||||
e.stopPropagation();
|
||||
if (grip.kind === 'corner' && pressCount(e, `corner ${item.id} ${grip.index}`) === 2) {
|
||||
const next = withoutCorner(points, grip.index);
|
||||
if (next)
|
||||
this.state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next)));
|
||||
else this.state.setNotice('A polygon keeps at least three corners.');
|
||||
return true;
|
||||
}
|
||||
const inserted = grip.kind === 'edge';
|
||||
this.corner = {
|
||||
holderId: holder.id,
|
||||
objectId: item.id,
|
||||
before: ref.object.overrides[COLLIDER_POINTS_KEY],
|
||||
points: inserted ? withCornerAfter(points, grip.index, rounded(grip.at)) : points,
|
||||
index: inserted ? grip.index + 1 : grip.index,
|
||||
toLocal,
|
||||
pointer: { x: pointer.x, y: pointer.y },
|
||||
inserted,
|
||||
moved: false,
|
||||
};
|
||||
if (inserted) this.previewCorners();
|
||||
return true;
|
||||
}
|
||||
|
||||
private dragCorner(e: FederatedPointerEvent): void {
|
||||
const corner = this.corner!;
|
||||
const pointer = this.toLocal(e.global);
|
||||
if (
|
||||
!corner.moved &&
|
||||
Math.hypot(pointer.x - corner.pointer.x, pointer.y - corner.pointer.y) < DRAG_THRESHOLD
|
||||
)
|
||||
return;
|
||||
corner.moved = true;
|
||||
const at = applyPoint(corner.toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
|
||||
corner.points = withCornerMoved(corner.points, corner.index, rounded(at));
|
||||
this.previewCorners();
|
||||
}
|
||||
|
||||
private previewCorners(): void {
|
||||
const corner = this.corner;
|
||||
const holder = this.holder();
|
||||
const ref = corner && holder && objectRef(this.state.project, holder, corner.objectId);
|
||||
if (!corner || !ref) return;
|
||||
ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points));
|
||||
this.state.preview();
|
||||
}
|
||||
|
||||
/** Puts the preview back and lets the command do the edit, so one drag is one undo step. */
|
||||
private endCorner(): void {
|
||||
const corner = this.corner!;
|
||||
this.corner = null;
|
||||
const holder = this.holder();
|
||||
const ref = holder && objectRef(this.state.project, holder, corner.objectId);
|
||||
if (!ref) return;
|
||||
ref.setOverride(COLLIDER_POINTS_KEY, corner.before);
|
||||
if (corner.moved || corner.inserted)
|
||||
this.state.execute(
|
||||
new SetOverrideCommand(corner.holderId, corner.objectId, COLLIDER_POINTS_KEY, listOf(corner.points)),
|
||||
);
|
||||
else this.state.preview();
|
||||
}
|
||||
|
||||
/** The corners of the polygon being edited, where they are drawn (test hook). */
|
||||
cornerInfo(): { objectId: string; corners: Vec2[] } | null {
|
||||
return this.lastCorners;
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
if (this.startCornerEdit(e)) return;
|
||||
const holder = this.resolved();
|
||||
const object = holder && findObject(holder, objectId);
|
||||
if (!holder || !object) return;
|
||||
@@ -580,12 +726,16 @@ export class SceneViewport extends FreeViewport {
|
||||
this.solids.delete(id);
|
||||
}
|
||||
|
||||
/** The blocking rectangles of a placed tilemap as outlined, in cells (test hook). */
|
||||
solidInfo(id: string): { visible: boolean; rects: CellRect[] } | null {
|
||||
/** The blocking rectangles and outlined cells of a placed tilemap as outlined, in cells (test hook). */
|
||||
solidInfo(id: string): { visible: boolean; rects: CellRect[]; outlines: Vec2[][] } | 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 })) };
|
||||
return {
|
||||
visible: node.visible,
|
||||
rects: item.tilemap.solids.map((rect) => ({ ...rect })),
|
||||
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,7 +746,8 @@ export class SceneViewport extends FreeViewport {
|
||||
private syncSolids(item: DrawItem, show: boolean, scale: number): void {
|
||||
let node = this.solids.get(item.id);
|
||||
const rects = item.tilemap?.solids ?? [];
|
||||
if (!show || rects.length === 0) {
|
||||
const outlines = item.tilemap?.outlines ?? [];
|
||||
if (!show || rects.length + outlines.length === 0) {
|
||||
if (node) node.visible = false;
|
||||
return;
|
||||
}
|
||||
@@ -615,6 +766,11 @@ export class SceneViewport extends FreeViewport {
|
||||
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: Theme.colors.collider, alpha: 0.12 }).stroke({ width, color: Theme.colors.collider });
|
||||
}
|
||||
|
||||
@@ -778,6 +934,30 @@ export class SceneViewport extends FreeViewport {
|
||||
.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));
|
||||
}
|
||||
@@ -806,9 +986,19 @@ 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.drawDropPreview(view);
|
||||
this.drawPaintOverlay(items, view);
|
||||
const m = this.marquee;
|
||||
if (m?.active) drawBand(this.gizmos, m.start, m.current);
|
||||
}
|
||||
}
|
||||
|
||||
/** A corner lands on whole units, as a dragged object does. */
|
||||
function rounded(p: Vec2): Vec2 {
|
||||
return { x: Math.round(p.x), y: Math.round(p.y) };
|
||||
}
|
||||
|
||||
function listOf(points: readonly Vec2[]): ListValue {
|
||||
return points.map((p) => ({ x: p.x, y: p.y }));
|
||||
}
|
||||
Reference in new issue
Block a user