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)
game-maker / gate (push) Successful in 1m52s
game-maker / suite (push) Successful in 6m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 22:56:25 +02:00
1 parent 1b5e386b60
commit 32057a96fc
25 files changed
+2341 -122

No files matched your search

+4
View File
@@ -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,
+58
View File
@@ -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 }));
}
+188 -9
View File
@@ -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))),
};
}
+12 -1
View File
@@ -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. */
+197 -7
View File
@@ -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 }));
}