diff --git a/README.md b/README.md index fe19045b..5d78b16e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1067 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1068 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index 56a7ae95..07026230 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -122,26 +122,27 @@ export class SetFrameSliceCommand implements Command { } /** - * The collision outline of one frame (#141), whole: adding, moving and removing a corner are each - * one edit, and the outline to revert to is read at execution time – a caller that previews a drag - * in place restores the start before executing, as with `SetFrameRectCommand`. + * The collision outlines of one frame (#141), all of them: adding, moving and removing a corner, + * starting or removing an outline are each one edit, and the outlines to revert to are read at + * execution time – a caller that previews a drag in place restores the start before executing, as + * with `SetFrameRectCommand`. */ export class SetFrameCollisionCommand implements Command { readonly label = 'Collision outline'; - private previous: Vec2[] = []; + private previous: Vec2[][] = []; constructor( private readonly assetId: string, private readonly frameId: string, - private readonly points: readonly Vec2[], + private readonly outlines: readonly (readonly Vec2[])[], ) {} execute(project: Project): void { const frame = requireFrame(requireTexture(project, this.assetId), this.frameId); - if (this.points.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y))) + if (this.outlines.some((outline) => outline.some((p) => !Number.isFinite(p.x) || !Number.isFinite(p.y)))) throw new CommandError('A corner of the outline is not a number'); this.previous = frameCollision(frame); - setFrameCollision(frame, this.points); + setFrameCollision(frame, this.outlines); } undo(project: Project): void { diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 09b58d8e..46c81392 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -95,10 +95,11 @@ export interface TextureFrame { border?: FrameBorders; fill?: FrameFill; /** - * The outline a tile made of this frame blocks with (#141), in pixels from the frame's top-left - * corner, so it moves with the frame. Absent is no outline; fewer than three corners collide as none. + * The outlines a tile made of this frame blocks with (#141), in pixels from the frame's top-left + * corner, so they move with the frame. Several, because an autotile's block holds several shapes + * – its bordered tile and its notches. Absent is none; one of fewer than three corners collides as none. */ - collision?: Vec2[]; + collision?: Vec2[][]; } /** diff --git a/src/core/model/polygon.ts b/src/core/model/polygon.ts index c1726266..a153fa89 100644 --- a/src/core/model/polygon.ts +++ b/src/core/model/polygon.ts @@ -175,6 +175,45 @@ export function convexPieces(points: readonly Vec2[]): Vec2[][] { return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!))); } +/** + * The part of the outline inside the rectangle x0 … x1, y0 … y1 (Sutherland-Hodgman), cleaned; an + * empty list when nothing with area is left. Exact for a convex outline. A concave one that the cut + * splits in two comes back as one ring joined along the rectangle's edge – good enough to draw, but + * collision cuts the convex pieces instead. + */ +export function clipToRect(points: readonly Vec2[], x0: number, y0: number, x1: number, y1: number): Vec2[] { + const sides: [(p: Vec2) => number, (a: Vec2, b: Vec2) => Vec2][] = [ + [(p) => p.x - x0, (a, b) => ({ x: x0, y: at(a.y, b.y, (x0 - a.x) / (b.x - a.x)) })], + [(p) => x1 - p.x, (a, b) => ({ x: x1, y: at(a.y, b.y, (x1 - a.x) / (b.x - a.x)) })], + [(p) => p.y - y0, (a, b) => ({ x: at(a.x, b.x, (y0 - a.y) / (b.y - a.y)), y: y0 })], + [(p) => y1 - p.y, (a, b) => ({ x: at(a.x, b.x, (y1 - a.y) / (b.y - a.y)), y: y1 })], + ]; + let ring: Vec2[] = [...points]; + for (const [inside, cut] of sides) { + const next: Vec2[] = []; + for (let i = 0; i < ring.length; i++) { + const a = ring[i]!; + const b = ring[(i + 1) % ring.length]!; + const aIn = inside(a) >= 0; + const bIn = inside(b) >= 0; + if (aIn) next.push(a); + if (aIn !== bIn) next.push(cut(a, b)); + } + ring = next; + if (ring.length === 0) return []; + } + const cleaned = cleanOutline(ring); + return cleaned.length >= 3 && Math.abs(signedArea(cleaned)) > EPSILON ? cleaned : []; +} + +/** + * A share `t` of the way from a to b, on the axis the cut line crosses; the other axis is set to the + * line itself, so two neighbouring cuts along the same line meet exactly. + */ +function at(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + /** Two pieces (index rings of the same winding) joined across the edge they share, if they share one. */ function joinAlongSharedEdge(a: number[], b: number[]): number[] | null { for (let i = 0; i < a.length; i++) { diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index 2abb9544..1c12eb4e 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -112,15 +112,19 @@ export const DEFAULT_FILL: FrameFill = 'repeat'; /** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */ export const MIN_SLICE_MIDDLE = 1; -/** The frame's collision outline (#141), a copy; empty when it has none. */ -export function frameCollision(frame: TextureFrame): Vec2[] { - return (frame.collision ?? []).map((p) => ({ ...p })); +/** The frame's collision outlines (#141), a copy; empty when it has none. */ +export function frameCollision(frame: TextureFrame): Vec2[][] { + return (frame.collision ?? []).map((outline) => outline.map((p) => ({ ...p }))); } -/** Sets the outline; an empty one removes the key, so a frame without one keeps saying so. */ -export function setFrameCollision(frame: TextureFrame, points: readonly Vec2[]): void { - if (points.length === 0) delete frame.collision; - else frame.collision = points.map((p) => ({ x: p.x, y: p.y })); +/** + * Sets the outlines. One without corners is dropped, and none left removes the key, so a frame + * without an outline keeps saying so; one being drawn, with a corner or two, is kept. + */ +export function setFrameCollision(frame: TextureFrame, outlines: readonly (readonly Vec2[])[]): void { + const kept = outlines.filter((outline) => outline.length > 0); + if (kept.length === 0) delete frame.collision; + else frame.collision = kept.map((outline) => outline.map((p) => ({ x: p.x, y: p.y }))); } export function frameBorders(frame: TextureFrame): FrameBorders { diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index f73931cf..73073edc 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -12,17 +12,21 @@ * wall of bushes is a narrower wall and not a string of beads. A neighbour across the corner only * does not count – that corner stays drawn in. * - * A plain tile whose picture is a frame with a collision outline (#141) blocks with that outline - * instead, one polygon per cell, never merged: see `solidPolygons`. + * A tile whose picture is a frame with collision outlines (#141) blocks with those instead, as + * polygons, never merged: see `solidPolygons`. A plain tile lays its outlines over the cell. An + * autotile's cell is pieced together from four quarters of its block, picked by the neighbours – so + * its outlines are cut along the same quarters, and each cell takes the pieces of exactly the + * quarters it is drawn from (see `tileShape`). */ import type { TileAsset, TilemapAsset } from './Asset'; +import { tileQuadrants } from './autotile'; import { angleOf, apply, type Mat, scaleOf } from './matrix'; -import { convexPieces } from './polygon'; +import { clipToRect, convexPieces, signedArea } from './polygon'; import type { PropertyBag, Vec2 } from './Properties'; import { findAssetOf, type Project } from './Project'; import { findFrame } from './texture'; -import { isTileSolid, tileCollisionScale, tileKind } from './tile'; -import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap'; +import { isTileSolid, readTile, tileCollisionScale, tileKind, type TileKind } from './tile'; +import { cellOrigin, inGrid, neighboursOf, parseCellKey, tilemapTileSize } from './tilemap'; import type { ColliderShape } from '../properties/collider'; /** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */ @@ -48,45 +52,130 @@ export interface CellPolygon { pieces: Vec2[][]; } -/** A tile's outline across one cell, 0 to 1 on each axis, and its convex pieces. */ -interface TileOutline { +/** An outline and its convex pieces: 0 to 1 across a cell for a plain tile, across a quarter for an autotile. */ +export interface TileOutline { outline: Vec2[]; pieces: Vec2[][]; } +/** What one quarter of an autotile's block blocks with: all of it, or the parts of the outlines in it – maybe none. */ +export type QuarterBlock = 'full' | TileOutline[]; + /** - * The outline a tile blocks with, or null when it blocks as a box. Only a plain tile has one – an - * autotile's cell is pieced together from quarters of a block, where no single frame is the cell – - * and only its first picture counts: a blocking shape that twitched with the animation is rarely - * wanted. An outline that is no polygon (see `polygonProblem`) blocks as the box it replaced. + * How a solid tile with outlines blocks: a plain tile with its outlines across the cell, an autotile + * with a block of every quarter of its block, by `x,y` in quarters. */ -export function tileOutline(project: Project, tile: TileAsset): TileOutline | null { - if (tileKind(tile) !== 'none') return null; +export type TileShape = + | { kind: 'plain'; outlines: TileOutline[] } + | { kind: 'auto'; tileKind: Exclude; quarters: Map }; + +const SQUARE: TileOutline = { + outline: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 0, y: 1 }, + ], + pieces: [ + [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 0, y: 1 }, + ], + ], +}; + +/** A share of a quarter's area this close to all of it is all of it. */ +const FULL = 1 - 1e-9; + +/** The outlines that are polygons (see `polygonProblem`), with their pieces; the others collide as none. */ +function validOutlines(outlines: Vec2[][]): TileOutline[] { + return outlines + .map((outline) => ({ outline, pieces: convexPieces(outline) })) + .filter((outline) => outline.pieces.length > 0); +} + +/** + * How the tile blocks, or null when it blocks as a box – when its first picture has no outline that + * is a polygon. Only the first picture counts: a blocking shape that twitched with the animation is + * rarely wanted. + * + * An autotile's outlines are drawn over its whole block and cut into its quarters, since a cell is + * drawn from four of them. Where a tile of the block has nothing drawn in it at all, its quarters + * block whole – so the notches of a floor nobody outlined leave no hole at the inner corners of a + * lake – but once anything is drawn in it, only what is drawn blocks, and a quarter can be left open. + * The collision scale does not apply here: the outline is the shape. + */ +export function tileShape(project: Project, tile: TileAsset): TileShape | null { const id = tile.pictures[0]; const frame = id ? findFrame(project, id)?.frame : undefined; if (!frame?.collision || frame.w <= 0 || frame.h <= 0) return null; - const outline = frame.collision.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h })); - const pieces = convexPieces(outline); - return pieces.length > 0 ? { outline, pieces } : null; + const kind = tileKind(tile); + if (kind === 'none') { + const across = frame.collision.map((o) => o.map((p) => ({ x: p.x / frame.w, y: p.y / frame.h }))); + const outlines = validOutlines(across); + return outlines.length > 0 ? { kind: 'plain', outlines } : null; + } + const { quad } = readTile(project, tile); + if (quad <= 0) return null; + const outlines = validOutlines( + frame.collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))), + ); + if (outlines.length === 0) return null; + const columns = Math.round(frame.w / quad); + const rows = Math.round(frame.h / quad); + const quarters = new Map(); + for (let qy = 0; qy < rows; qy++) + for (let qx = 0; qx < columns; qx++) quarters.set(`${qx},${qy}`, quarterBlock(outlines, qx, qy)); + for (let ty = 0; ty < rows; ty += 2) + for (let tx = 0; tx < columns; tx += 2) { + const keys = [`${tx},${ty}`, `${tx + 1},${ty}`, `${tx},${ty + 1}`, `${tx + 1},${ty + 1}`]; + if (keys.every((key) => (quarters.get(key) as QuarterBlock).length === 0)) + for (const key of keys) quarters.set(key, 'full'); + } + return { kind: 'auto', tileKind: kind, quarters }; } -/** What blocks in each cell: the largest box scale of a plain solid tile, and the outlines of the others. */ +/** The parts of the outlines inside the quarter at (qx, qy), moved to 0 … 1 across it. */ +function quarterBlock(outlines: readonly TileOutline[], qx: number, qy: number): QuarterBlock { + const local = (p: Vec2) => ({ x: p.x - qx, y: p.y - qy }); + const parts: TileOutline[] = []; + for (const { outline, pieces } of outlines) { + const cut = pieces.map((piece) => clipToRect(piece, qx, qy, qx + 1, qy + 1)).filter((p) => p.length > 0); + if (cut.length === 0) continue; + // The pieces of one outline never overlap, so their areas add up to what it covers. + if (cut.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0) >= FULL) return 'full'; + const drawn = clipToRect(outline, qx, qy, qx + 1, qy + 1); + parts.push({ + outline: (drawn.length > 0 ? drawn : cut[0]!).map(local), + pieces: cut.map((p) => p.map(local)), + }); + } + return parts; +} + +/** What blocks in each cell: the largest box scale of the solid tiles blocking as boxes, and the outlines of the others, in cells. */ interface CellBlock { x: number; y: number; box: number | null; - outlines: { outline: TileOutline; scale: number }[]; + polygons: TileOutline[]; +} + +/** An outline of 0 to 1 across its square laid over the square of side `size` at (x, y). */ +function placed(outline: TileOutline, x: number, y: number, size: number): TileOutline { + const place = (p: Vec2) => ({ x: x + p.x * size, y: y + p.y * size }); + return { outline: outline.outline.map(place), pieces: outline.pieces.map((piece) => piece.map(place)) }; } function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] { - const tiles = new Map(); + const tiles = new Map(); const tileOf = (id: string) => { if (tiles.has(id)) return tiles.get(id)!; const tile = findAssetOf(project, id, 'tile'); const value = - tile && isTileSolid(tile) - ? { scale: tileCollisionScale(tile), outline: tileOutline(project, tile) } - : null; + tile && isTileSolid(tile) ? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) } : null; tiles.set(id, value); return value; }; @@ -99,10 +188,32 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset if (!cell) { const at = parseCellKey(key); if (!at || !inGrid(props, at.x, at.y)) continue; - found.set(key, (cell = { ...at, box: null, outlines: [] })); + found.set(key, (cell = { ...at, box: null, polygons: [] })); + } + const { shape, scale } = tile; + if (!shape) { + cell.box = Math.max(cell.box ?? 0, scale); + } else if (shape.kind === 'plain') { + // The collision scale draws the outline in or out about the cell's centre (#140). + const at = 0.5 - scale / 2; + for (const outline of shape.outlines) + cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale)); + } else { + const around = neighboursOf(props, layer, cell.x, cell.y, tileId); + const quarters = tileQuadrants(shape.tileKind, around).map( + (q) => shape.quarters.get(`${q.x},${q.y}`) ?? [], + ); + if (quarters.every((q) => q === 'full')) { + cell.box = Math.max(cell.box ?? 0, 1); + continue; + } + for (const [corner, quarter] of quarters.entries()) { + const x = cell.x + (corner % 2) / 2; + const y = cell.y + Math.floor(corner / 2) / 2; + for (const outline of quarter === 'full' ? [SQUARE] : quarter) + cell.polygons.push(placed(outline, x, y, 0.5)); + } } - if (tile.outline) cell.outlines.push({ outline: tile.outline, scale: tile.scale }); - else cell.box = Math.max(cell.box ?? 0, tile.scale); } } return [...found.values()]; @@ -111,7 +222,8 @@ function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset /** * Every cell of the map that blocks as a box, once each however many layers hold a solid tile * there. Where several do, the largest scale wins: each of them blocks, so the cell blocks as far - * as any does. A cell whose only solid tiles have outlines is left to `solidPolygons`. + * as any does. An autotile cell built only from quarters that block whole is a box too, at scale 1. + * A cell whose only solid tiles block with outlines is left to `solidPolygons`. */ export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required[] { return blockingCells(project, props, asset) @@ -120,26 +232,16 @@ export function solidCells(project: Project, props: PropertyBag, asset: TilemapA } /** - * The cells that block with an outline: one polygon per outlined tile lying there, sized by the - * tile's collision scale about the cell's centre (#140). A plain solid tile on another layer of the - * same cell covers the whole cell already, so there the outlines are left out. + * The polygons of the cells that block with outlines: for a plain tile one per outline, for an + * autotile one per part of an outline in each of its quarters, and a square for a quarter that + * blocks whole. A tile blocking as a box on another layer of the same cell covers the whole cell + * already, so there the outlines are left out. */ export function solidPolygons(project: Project, props: PropertyBag, asset: TilemapAsset): CellPolygon[] { const polygons: CellPolygon[] = []; for (const cell of blockingCells(project, props, asset)) { if (cell.box !== null) continue; - for (const { outline, scale } of cell.outlines) { - const place = (p: Vec2) => ({ - x: cell.x + 0.5 + (p.x - 0.5) * scale, - y: cell.y + 0.5 + (p.y - 0.5) * scale, - }); - polygons.push({ - x: cell.x, - y: cell.y, - outline: outline.outline.map(place), - pieces: outline.pieces.map((piece) => piece.map(place)), - }); - } + for (const { outline, pieces } of cell.polygons) polygons.push({ x: cell.x, y: cell.y, outline, pieces }); } return polygons.sort((a, b) => a.y - b.y || a.x - b.x); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 95f021c3..908c52a9 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -39,6 +39,7 @@ import { type PropertyValue, type StructValue, tagOf, + type Vec2, } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import { readScope } from '../model/Scope'; @@ -482,13 +483,23 @@ function normalizeTexture( fill: f.fill === 'stretch' ? 'stretch' : DEFAULT_FILL, }); // Corners that are no numbers fall away; what is left is kept only as an outline (#141). - const collision = pointsOf(f.collision as PropertyValue); - if (collision.length >= 3) setFrameCollision(frame, collision); + const outlines = collisionOutlines(f.collision).filter((outline) => outline.length >= 3); + if (outlines.length > 0) setFrameCollision(frame, outlines); frames.push(frame); } return { file, width, height, frames }; } +/** + * A frame's stored outlines: a list of corner lists, or – as saved before a frame held several – + * one corner list, which is one outline. + */ +function collisionOutlines(raw: unknown): Vec2[][] { + if (!Array.isArray(raw)) return []; + if (raw.some((item) => !Array.isArray(item))) return [pointsOf(raw as PropertyValue)]; + return raw.map((outline) => pointsOf(outline as PropertyValue)); +} + /** * The rows of an animation. Keys hold flat values only – a row animates one property – and are * sorted onto whole frames; a second row on the same target and property, or with an id already diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts index bbc3ee3a..1fc282e8 100644 --- a/src/editor/polygonEdit.ts +++ b/src/editor/polygonEdit.ts @@ -42,6 +42,18 @@ export function gripAt(points: readonly Vec2[], p: Vec2, tolerance: number): Pol return best; } +/** Whether `p` lies inside the outline (even-odd); fewer than three corners enclose nothing. */ +export function insideOutline(points: readonly Vec2[], p: Vec2): boolean { + if (points.length < MIN_CORNERS) return false; + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i++) { + const a = points[i]!; + const b = points[j]!; + if (a.y > p.y !== b.y > p.y && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) inside = !inside; + } + return inside; +} + /** 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)]; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 7b5022de..f150d090 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -29,7 +29,15 @@ import { setFrameSlice, } from '../../core/model/texture'; import { pressCount, type Size, Theme, textStyle, ui, Widget, zoomFactor } from '../../ui'; -import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit'; +import { + gripAt, + insideOutline, + MIN_CORNERS, + type PolygonGrip, + withCornerAfter, + withCornerMoved, + withoutCorner, +} from '../polygonEdit'; import { dashedLine } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; import type { EditorState } from '../state'; @@ -79,13 +87,15 @@ interface FrameDrag { } /** - * 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. + * A corner of one of a frame's collision outlines 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[]; + before: Vec2[][]; + outlines: Vec2[][]; + /** Which outline, and which corner of it. */ + outline: number; index: number; pointer: Vec2; added: boolean; @@ -122,8 +132,10 @@ export class TextureCanvas extends Widget { 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). */ + /** Whether a press edits the selected frame's collision outlines instead of the frames (#141). */ private collision = false; + /** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */ + private active = { frameId: '', index: -1 }; constructor(private readonly state: EditorState) { super(); @@ -202,6 +214,32 @@ export class TextureCanvas extends Widget { return this.asset()?.frames.find((f) => f.id === frameId); } + private activeOutline(frame: TextureFrame, outlines: readonly Vec2[][]): number { + const { frameId, index } = this.active; + return frameId === frame.id && index < outlines.length ? index : outlines.length - 1; + } + + /** Delete takes the active outline away whole, in collision mode – not the frame it belongs to. */ + override onKeyDown(event: KeyboardEvent): boolean { + if (!this.collision || (event.key !== 'Delete' && event.key !== 'Backspace')) + return super.onKeyDown(event); + const frame = this.frame(this.selectedFrameId()); + if (!frame) return super.onKeyDown(event); + const outlines = frameCollision(frame); + const index = this.activeOutline(frame, outlines); + // Nothing active is nothing to delete – and still not the frame, which Delete would take otherwise. + if (index < 0) return true; + this.state.execute( + new SetFrameCollisionCommand( + this.assetId, + frame.id, + outlines.filter((_, i) => i !== index), + ), + ); + this.active = { frameId: frame.id, index: -1 }; + return true; + } + private onPointerDown(e: FederatedPointerEvent): void { const asset = this.asset(); // Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame @@ -259,10 +297,12 @@ export class TextureCanvas extends Widget { } /** - * 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. + * A press in collision mode. On the selected frame, on any of its outlines: a double-click on a + * corner takes it away, a press on a corner drags it, on an edge puts a corner there – and makes + * that outline the active one. Anywhere else inside the frame adds a corner after the active + * outline's last, which is how an outline is drawn from nothing; with Ctrl (Cmd) held it starts a + * new outline, and a press inside another finished outline only makes it the active one. Another + * frame is only selected, and no frame is moved or spanned while the mode is on. */ private pressOutline( asset: TextureAsset, @@ -274,12 +314,23 @@ export class TextureCanvas extends Widget { 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)!), - ); + const outlines = frameCollision(selected); + const active = this.activeOutline(selected, outlines); + // The active outline first, so its corners stay reachable where two outlines meet. + const order = outlines.map((_, i) => i).sort((a, b) => Number(b === active) - Number(a === active)); + let target = active; + let grip: PolygonGrip | null = null; + for (const i of order) { + grip = gripAt(outlines[i]!, at, tolerance); + if (!grip) continue; + target = i; + break; + } + const activate = (index: number) => (this.active = { frameId: selected.id, index }); + if (grip?.kind === 'corner' && pressCount(e, `outline ${selected.id} ${target} ${grip.index}`) === 2) { + const next = outlines.map((o, i) => (i === target ? withoutCorner(o, grip.index, 0)! : o)); + activate(next[target]!.length > 0 ? target : -1); + this.state.execute(new SetFrameCollisionCommand(asset.id, selected.id, next)); return; } // A corner often belongs on the frame's edge: a press just beside it still counts as in it. @@ -288,24 +339,40 @@ export class TextureCanvas extends Widget { at.y >= -tolerance && at.x <= selected.w + tolerance && at.y <= selected.h + tolerance; + const fresh = e.ctrlKey || e.metaKey || target < 0; + if (!grip && inside && !fresh && (outlines[active]?.length ?? 0) >= MIN_CORNERS) { + const other = outlines.findIndex((o, i) => i !== active && insideOutline(o, at)); + if (other >= 0) { + activate(other); + this.redraw(); + return; + } + } if (grip || inside) { const corner = inFrame(selected, grip?.kind === 'edge' ? grip.at : at); - const next = + if (!grip && fresh) target = outlines.length; + const points = outlines[target] ?? []; + const changed = grip?.kind === 'corner' ? points : grip?.kind === 'edge' ? withCornerAfter(points, grip.index, corner) : [...points, corner]; + const next = [...outlines]; + next[target] = changed; + activate(target); this.corner = { frameId: selected.id, - before: points, - points: next, - index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : next.length - 1, + before: outlines, + outlines: next, + outline: target, + index: grip?.kind === 'corner' ? grip.index : grip ? grip.index + 1 : changed.length - 1, pointer: { x: local.x, y: local.y }, added: grip?.kind !== 'corner', moved: false, }; if (this.corner.added) this.previewOutline(); + else this.redraw(); return; } } @@ -322,7 +389,7 @@ export class TextureCanvas extends Widget { const corner = this.corner; const frame = corner && this.frame(corner.frameId); if (!corner || !frame) return; - setFrameCollision(frame, corner.points); + setFrameCollision(frame, corner.outlines); this.state.preview(); } @@ -366,10 +433,14 @@ export class TextureCanvas extends Widget { 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 }), + corner.outlines = corner.outlines.map((outline, i) => + i === corner.outline + ? withCornerMoved( + outline, + corner.index, + inFrame(frame, { x: point.x - frame.x, y: point.y - frame.y }), + ) + : outline, ); this.previewOutline(); return; @@ -420,7 +491,7 @@ export class TextureCanvas extends Widget { if (corner && frame) { setFrameCollision(frame, corner.before); if (corner.added || corner.moved) - this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.points)); + this.state.execute(new SetFrameCollisionCommand(this.assetId, corner.frameId, corner.outlines)); else this.state.preview(); return; } @@ -557,7 +628,7 @@ 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); + for (const frame of asset.frames) this.drawOutlines(frame, this.collision && frame.id === selectedId); const selected = this.frame(selectedId); if (selected && !this.collision) { this.drawBorders(selected); @@ -604,31 +675,32 @@ export class TextureCanvas extends Widget { } /** - * 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. + * A frame's collision outlines (#141), always shown so one sees which frames block with them; on + * the edited frame the active outline is drawn heavier and every corner of every outline gets a + * handle. 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 drawOutlines(frame: TextureFrame, edited: boolean): void { + const outlines = frameCollision(frame); + const active = edited ? this.activeOutline(frame, outlines) : -1; + for (const [index, points] of outlines.entries()) { + 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; + const width = index === active ? 2 : 1; + if (screen.length >= 3) + this.overlay.poly(screen, true).fill({ color, alpha: 0.15 }).stroke({ width, 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) continue; + const half = HANDLE_SIZE / 2; + for (const c of screen) + this.overlay + .rect(c.x - half, c.y - half, HANDLE_SIZE, HANDLE_SIZE) + .fill(index === active ? Theme.colors.accentText : Theme.colors.viewport) + .stroke({ width: 1, color }); + } } private drawHandles(frame: TextureFrame): void { @@ -673,8 +745,8 @@ export class TextureCanvas extends Widget { ? 'No texture selected – pick one in the Assets panel or import an image.' : 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.' + ? 'Collision: click adds a corner, Ctrl+click starts a new outline, Delete removes one.' + : 'Collision: select a frame to draw the outlines its tiles block with.' : !asset.file ? 'This texture has no picture yet.' : !hasPicture diff --git a/tests/core/shapes.test.ts b/tests/core/shapes.test.ts index 8d0a0ae7..b5f4451f 100644 --- a/tests/core/shapes.test.ts +++ b/tests/core/shapes.test.ts @@ -9,8 +9,9 @@ import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model import type { PropertyValue, Vec2 } from '../../src/core/model/Properties'; import { findAsset, type Project } from '../../src/core/model/Project'; import { TILE_COLLISION_SCALE_KEY, TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; +import { insideOutline } from '../../src/editor/polygonEdit'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; -import { solidCells, solidPolygons, tileOutline } from '../../src/core/model/tilemapCollision'; +import { solidCells, solidPolygons, solidRects, tileShape } from '../../src/core/model/tilemapCollision'; import { contactBetween, shapesOverlap } from '../../src/core/properties/collider'; import type { Entity } from '../../src/runtime/Entity'; import { loadPhysics } from '../../src/runtime/physics/engine'; @@ -174,16 +175,22 @@ function outlinedMap() { const texture = new CreateAssetCommand('texture', 'Sheet'); const tile = new CreateAssetCommand('tile', 'Slope'); const plain = new CreateAssetCommand('tile', 'Wall'); + const lake = new CreateAssetCommand('tile', 'Lake'); const map = new CreateAssetCommand('tilemap', 'Ground'); - for (const command of [texture, tile, plain, map]) f.stack.execute(command); + for (const command of [texture, tile, plain, lake, map]) f.stack.execute(command); const sheet = findAsset(f.project, texture.asset.id) as TextureAsset; sheet.file = `${'a'.repeat(64)}.png`; sheet.width = 64; - sheet.height = 64; + sheet.height = 128; const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 }); f.stack.execute(frame); f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId)); - for (const id of [tile.asset.id, plain.asset.id]) + // A floor block of 32 px tiles below it: 2 × 3 tiles, quarters of 16 px (#141). + const block = new AddFrameCommand(texture.asset.id, { x: 0, y: 32, w: 64, h: 96 }); + f.stack.execute(block); + f.stack.execute(new AddTilePictureCommand(lake.asset.id, block.frameId)); + f.stack.execute(new SetAssetPropertyCommand(lake.asset.id, TILE_KIND_KEY, 'floor')); + for (const id of [tile.asset.id, plain.asset.id, lake.asset.id]) f.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 12)); f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 4)); @@ -198,8 +205,10 @@ function outlinedMap() { cells.map(([x, y]) => ({ x, y, tileId })), ), ); - const outline = (points: Vec2[]) => - f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points)); + const outline = (...outlines: Vec2[][]) => + f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines)); + const blockOutline = (...outlines: Vec2[][]) => + f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, block.frameId, outlines)); const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset; return { ...f, @@ -208,8 +217,10 @@ function outlinedMap() { mapAsset, paint, outline, + blockOutline, tileAsset, plainId: plain.asset.id, + lakeId: lake.asset.id, textureId: texture.asset.id, frameId: frame.frameId, }; @@ -224,32 +235,69 @@ describe('collision outlines of tiles (#141)', () => { m.outline(WEDGE); const frameOf = (project: Project) => (findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!; - expect(frameOf(roundTrip(m.project)).collision).toEqual(WEDGE); + expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]); frameOf(m.project).collision = [ - { x: 1, y: 1 }, - { x: Number.NaN, y: 0 }, - { x: 3, y: 3 }, - ] as Vec2[]; + [ + { x: 1, y: 1 }, + { x: Number.NaN, y: 0 }, + { x: 3, y: 3 }, + ], + ]; expect(frameOf(roundTrip(m.project))).not.toHaveProperty('collision'); m.stack.undo(); expect(frameOf(m.project)).not.toHaveProperty('collision'); m.stack.redo(); - expect(frameOf(m.project).collision).toEqual(WEDGE); + expect(frameOf(m.project).collision).toEqual([WEDGE]); }); - it('a plain tile blocks with its first picture’s outline; an autotile, or an outline that is none, as a box', () => { + it('a frame holds several outlines; one saved before that, as a plain corner list, loads as one', () => { const m = outlinedMap(); - expect(tileOutline(m.project, m.tileAsset())).toBeNull(); + const SQUARE_AT_0 = P(0, 0, 8, 0, 8, 8, 0, 8); + m.outline(WEDGE, SQUARE_AT_0); + const frameOf = (project: Project) => + (findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!; + expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE, SQUARE_AT_0]); + // Of two outlines, the one with too few corners falls away on load and the other stays. + frameOf(m.project).collision = [P(1, 1, 2, 2), WEDGE]; + expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]); + // An outline emptied by the editor is dropped, and the frame without any keeps saying so. + m.outline([], []); + expect(frameOf(m.project)).not.toHaveProperty('collision'); + (frameOf(m.project) as { collision: unknown }).collision = WEDGE; + expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]); + }); + + it('a plain tile blocks with its first picture’s outlines; an outline that is none, or a block of the wrong size, as a box', () => { + const m = outlinedMap(); + expect(tileShape(m.project, m.tileAsset())).toBeNull(); m.outline(WEDGE); - expect(tileOutline(m.project, m.tileAsset())).toEqual({ - outline: P(0, 1, 1, 0, 1, 1), - pieces: [P(0, 1, 1, 0, 1, 1)], + expect(tileShape(m.project, m.tileAsset())).toEqual({ + kind: 'plain', + outlines: [{ outline: P(0, 1, 1, 0, 1, 1), pieces: [P(0, 1, 1, 0, 1, 1)] }], + }); + const BOW_TIE = P(0, 0, 32, 32, 32, 0, 0, 32); + m.outline(BOW_TIE); + expect(tileShape(m.project, m.tileAsset())).toBeNull(); + m.outline(BOW_TIE, WEDGE); // the bow tie collides as none, the wedge still blocks + expect(tileShape(m.project, m.tileAsset())).toMatchObject({ + kind: 'plain', + outlines: [{ outline: P(0, 1, 1, 0, 1, 1) }], }); - m.outline(P(0, 0, 32, 32, 32, 0, 0, 32)); // a bow tie - expect(tileOutline(m.project, m.tileAsset())).toBeNull(); m.outline(WEDGE); + // A floor needs a block of 2 × 3 tiles; the 32 px frame is none, so it blocks as a box. m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor')); - expect(tileOutline(m.project, m.tileAsset())).toBeNull(); + expect(tileShape(m.project, m.tileAsset())).toBeNull(); + }); + + it('a plain tile with two outlines blocks with both in every cell', () => { + const m = outlinedMap(); + m.outline(P(0, 0, 16, 0, 16, 16, 0, 16), P(16, 16, 32, 16, 32, 32, 16, 32)); + m.paint([[2, 3]]); + const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 }; + expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([ + P(2, 3, 2.5, 3, 2.5, 3.5, 2, 3.5), + P(2.5, 3.5, 3, 3.5, 3, 4, 2.5, 4), + ]); }); it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => { @@ -347,6 +395,90 @@ describe('collision outlines of tiles (#141)', () => { }); }); +/** + * The floor block's bordered 2 × 2 tile (frame y 32 … 96) outlined 8 px in from its edges – half a + * quarter. A cell of it blocks from a quarter of a cell in wherever it has no neighbour. + */ +const POND = P(8, 40, 56, 40, 56, 88, 8, 88); + +describe('collision outlines of autotiles (#141)', () => { + const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 }; + /** Whether a point of the map, in cells, lies inside anything the map blocks with. */ + const blocksAt = (m: ReturnType, p: Vec2) => + solidRects(m.project, props, m.mapAsset()).some( + (r) => p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h, + ) || + solidPolygons(m.project, props, m.mapAsset()).some((polygon) => + polygon.pieces.some((piece) => insideOutline(piece, p)), + ); + const cells = (x0: number, y0: number, x1: number, y1: number, skip = '') => + Array.from( + { length: (x1 - x0 + 1) * (y1 - y0 + 1) }, + (_, i) => [x0 + (i % (x1 - x0 + 1)), y0 + Math.floor(i / (x1 - x0 + 1))] as [number, number], + ).filter(([x, y]) => `${x},${y}` !== skip); + + it('a lake blocks with the block’s outline pieced together like its picture: in by a quarter cell all round', () => { + const m = outlinedMap(); + m.blockOutline(POND); + m.paint(cells(2, 0, 4, 2), m.lakeId); + const wrong: Vec2[] = []; + for (let x = 1.037; x < 6; x += 0.1) + for (let y = 0.037; y < 4; y += 0.1) { + const expected = x > 2.25 && x < 4.75 && y > 0.25 && y < 2.75; + if (blocksAt(m, { x, y }) !== expected) wrong.push({ x, y }); + } + expect(wrong).toEqual([]); + // The middle cell is drawn from inside quarters only, which block whole: a box, not polygons. + expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 3, y: 1, scale: 1 }]); + expect(solidPolygons(m.project, props, m.mapAsset()).some((p) => p.x === 3 && p.y === 1)).toBe(false); + }); + + it('a notch tile with nothing drawn in it blocks whole; once anything is drawn there, only that', () => { + const m = outlinedMap(); + m.blockOutline(POND); + m.paint(cells(2, 0, 4, 2, '3,1'), m.lakeId); // a ring round an empty cell: four inner corners + // Cell (2, 0)'s bottom right is a notch – the block's top right tile, which holds no outline. + expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(true); + expect(blocksAt(m, { x: 3.5, y: 1.5 })).toBe(false); + // A small triangle in the notch tile's top-left quarter: the inner corner of cell (4, 2). + m.blockOutline(POND, P(32, 0, 40, 0, 32, 8)); + expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(false); + expect(blocksAt(m, { x: 4.05, y: 2.05 })).toBe(true); + expect(blocksAt(m, { x: 4.2, y: 2.2 })).toBe(false); + }); + + it('an autotile block without an outline still blocks as boxes, with its collision scale', () => { + const m = outlinedMap(); + m.stack.execute(new SetAssetPropertyCommand(m.lakeId, TILE_COLLISION_SCALE_KEY, 0.5)); + m.paint([[2, 3]], m.lakeId); + expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 2, y: 3, scale: 0.5 }]); + expect(solidPolygons(m.project, props, m.mapAsset())).toEqual([]); + }); + + describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { + it('a crate slides along the shore of a row of lake cells without catching on a quarter seam', () => { + const m = outlinedMap(); + m.blockOutline(POND); + m.paint(cells(0, 3, 11, 3), m.lakeId); // shore at y = 3.25 cells, 40 in the world + const crate = m.add({ x: -170, y: 0 }, { width: 30, height: 30 }); + const r = run(m, physics); + const mover = r.entity(crate.id); + const steps: { dx: number; y: number; blockedX: boolean }[] = []; + for (let i = 0; i < 100; i++) { + const x = mover.x; + const { blockedX } = r.move(mover, 3, 1.5); + steps.push({ dx: mover.x - x, y: mover.y, blockedX }); + } + for (const step of steps.slice(20)) { + expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9); + expect(step.y).toBeCloseTo(40 - 15, 9); + expect(step.blockedX).toBe(false); + } + expect(mover.x).toBeGreaterThan(-170 + 5 * 32); + }); + }); +}); + describe('outlined tiles in the engine (#141)', () => { it('a crate falls onto a wedge tile and slides down it to the floor', () => { const m = outlinedMap(); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index 8aabb681..442b9df7 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -53,7 +53,7 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); export async function shapes(t) { const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t; - const { object, settle, sleep, play, stop, shot, showTab } = t; + const { object, settle, sleep, play, stop, shot, showTab, press } = t; const Y = LANES.shapes; const put = ` const put = (id, assetId, parentId, x, y, name, overrides = {}) => { @@ -227,9 +227,11 @@ export async function shapes(t) { (await json('__gm.texture.collisionMode()')) === true && JSON.stringify(drawn) === JSON.stringify([ - { x: 0, y: 32 }, - { x: 32, y: 0 }, - { x: 32, y: 32 }, + [ + { x: 0, y: 32 }, + { x: 32, y: 0 }, + { x: 32, y: 32 }, + ], ]), JSON.stringify(drawn), ); @@ -241,9 +243,39 @@ export async function shapes(t) { await undo(); check( 'a corner of the outline drags, and one undo puts it back', - moved?.[1]?.x === 16 && moved[1].y === 0 && (await outline())?.[1]?.x === 32, + moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32, JSON.stringify({ moved, after: await outline() }), ); + // A frame holds several outlines: Ctrl+click starts a second one, and Delete takes the active + // outline away whole – not the frame, which Delete removes outside the mode. + for (const [i, [x, y]] of [ + [2, 2], + [10, 2], + [2, 10], + ].entries()) { + const at = await json(`__gm.texture.pointAt(${x}, ${y})`); + await click(at.x, at.y, 'left', i === 0 ? { ctrl: true } : {}); + } + const two = await outline(); + await shot('42-texture-two-outlines'); + await press('Delete'); + const left = await outline(); + check( + 'Ctrl+click starts a second outline in the frame, and Delete removes only that one', + two?.length === 2 && + JSON.stringify(two[1]) === + JSON.stringify([ + { x: 2, y: 2 }, + { x: 10, y: 2 }, + { x: 2, y: 10 }, + ]) && + left?.length === 1 && + left[0].length === 3 && + (await json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}).frames.some((f) => f.id === 'sh-frame')`, + )) === true, + JSON.stringify({ two, left }), + ); await clickText('Collision'); await showTab('scene'); await settle();