From 32057a96fc8e83d98ecb298efed9f509b389a224 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 22:56:25 +0200 Subject: [PATCH] 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 --- README.md | 4 +- src/core/commands/frameCommands.ts | 31 +++ src/core/model/Asset.ts | 7 +- src/core/model/polygon.ts | 192 ++++++++++++++ src/core/model/texture.ts | 13 +- src/core/model/tilemapCollision.ts | 135 ++++++++-- src/core/properties/PropertySchema.ts | 14 + src/core/properties/collider.ts | 185 +++++++++++-- src/core/properties/convex.ts | 180 +++++++++++++ src/core/store/serialize.ts | 5 + src/editor/debugHook.ts | 4 + src/editor/polygonEdit.ts | 58 ++++ src/editor/texture/TextureCanvas.ts | 197 +++++++++++++- src/editor/texture/TextureEditor.ts | 13 +- src/editor/viewport/SceneViewport.ts | 204 +++++++++++++- src/runtime/physics/PhysicsSystem.ts | 154 ++++++++--- src/runtime/systems/solids.ts | 49 ++-- src/ui/widgets/Button.ts | 6 +- src/viewport/items.ts | 11 +- src/viewport/symbols.ts | 52 +++- tests/core/polygon.test.ts | 266 +++++++++++++++++++ tests/core/shapes.test.ts | 365 ++++++++++++++++++++++++++ tools/e2e/42-shapes.mjs | 315 ++++++++++++++++++++++ tools/e2e/index.mjs | 2 + tools/e2e/lib/scaffold.mjs | 1 + 25 files changed, 2341 insertions(+), 122 deletions(-) create mode 100644 src/core/model/polygon.ts create mode 100644 src/core/properties/convex.ts create mode 100644 src/editor/polygonEdit.ts create mode 100644 tests/core/polygon.test.ts create mode 100644 tests/core/shapes.test.ts create mode 100644 tools/e2e/42-shapes.mjs diff --git a/README.md b/README.md index de88f6d3..fe19045b 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, 1057 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1067 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 @@ -56,7 +56,7 @@ beside it – so locally take the gate plus the step your change touches and lea | [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph | | [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset | | [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing | -| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Collision events and blocking movement | +| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events and blocking movement | | [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step | | [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits | | [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to | diff --git a/src/core/commands/frameCommands.ts b/src/core/commands/frameCommands.ts index 679c6494..56a7ae95 100644 --- a/src/core/commands/frameCommands.ts +++ b/src/core/commands/frameCommands.ts @@ -5,6 +5,7 @@ import { newId } from '../ids'; import type { TextureAsset, TextureFrame } from '../model/Asset'; import type { Project } from '../model/Project'; +import type { Vec2 } from '../model/Properties'; import { frameRect, type FrameRect, @@ -12,7 +13,9 @@ import { nextFrameNames, normalizeFrameRect, rawFrameSlice, + frameCollision, restoreFrameSlice, + setFrameCollision, setFrameRect, setFrameSlice, sliceFrames, @@ -118,6 +121,34 @@ 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`. + */ +export class SetFrameCollisionCommand implements Command { + readonly label = 'Collision outline'; + private previous: Vec2[] = []; + + constructor( + private readonly assetId: string, + private readonly frameId: string, + private readonly points: 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))) + throw new CommandError('A corner of the outline is not a number'); + this.previous = frameCollision(frame); + setFrameCollision(frame, this.points); + } + + undo(project: Project): void { + setFrameCollision(requireFrame(requireTexture(project, this.assetId), this.frameId), this.previous); + } +} + export class RenameFrameCommand implements Command { readonly label = 'Rename frame'; private previous = ''; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 26a3bdb7..09b58d8e 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -1,7 +1,7 @@ import type { Graph } from '../graph/Graph'; import type { PropertyDef } from '../properties/PropertySchema'; import type { AssetType } from './AssetTypes'; -import type { PropertyBag, PropertyValue } from './Properties'; +import type { PropertyBag, PropertyValue, Vec2 } from './Properties'; import type { Scope } from './Scope'; import type { SceneObject } from './SceneObject'; @@ -94,6 +94,11 @@ export interface TextureFrame { h: number; 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. + */ + collision?: Vec2[]; } /** diff --git a/src/core/model/polygon.ts b/src/core/model/polygon.ts new file mode 100644 index 00000000..c1726266 --- /dev/null +++ b/src/core/model/polygon.ts @@ -0,0 +1,192 @@ +/** + * Plain polygons as outlines of points: checking one, and cutting a concave one into convex pieces + * – what collision works with (#151), since the overlap of two convex shapes is a matter of a few + * axes and the overlap of two concave ones is not. + */ +import { isList, isStruct, type PropertyValue, type Vec2 } from './Properties'; + +/** Points closer than this are one point, and a turn smaller than this is a straight line. */ +const EPSILON = 1e-9; + +/** A coordinate as stored: a number, or a text spelling one. `Number(null)` would be 0, not missing. */ +function coordinate(value: unknown): number { + if (typeof value === 'number') return value; + return typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN; +} + +/** The points a stored list holds: `{x, y}` items with finite numbers; anything else is left out. */ +export function pointsOf(value: PropertyValue | undefined): Vec2[] { + if (!isList(value)) return []; + const points: Vec2[] = []; + for (const item of value) { + if (!isStruct(item)) continue; + const x = coordinate(item.x); + const y = coordinate(item.y); + if (Number.isFinite(x) && Number.isFinite(y)) points.push({ x, y }); + } + return points; +} + +/** Positive when the points run clockwise on screen (y grows downwards), negative the other way. */ +export function signedArea(points: readonly Vec2[]): number { + let sum = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]!; + const b = points[(i + 1) % points.length]!; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +} + +function cross(o: Vec2, a: Vec2, b: Vec2): number { + return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x); +} + +/** The outline without repeated points and without points in the middle of a straight edge. */ +export function cleanOutline(points: readonly Vec2[]): Vec2[] { + let ring = points.filter((p, i) => { + const next = points[(i + 1) % points.length]!; + return points.length < 2 || Math.hypot(p.x - next.x, p.y - next.y) > EPSILON; + }); + let changed = true; + while (changed && ring.length >= 3) { + changed = false; + for (let i = 0; i < ring.length; i++) { + const prev = ring[(i + ring.length - 1) % ring.length]!; + const next = ring[(i + 1) % ring.length]!; + const p = ring[i]!; + const span = Math.hypot(next.x - prev.x, next.y - prev.y) * Math.hypot(p.x - prev.x, p.y - prev.y); + if (Math.abs(cross(prev, p, next)) <= EPSILON * Math.max(1, span)) { + ring = ring.filter((_, j) => j !== i); + changed = true; + break; + } + } + } + return ring; +} + +/** Whether the open segments ab and cd cross or touch. */ +function segmentsMeet(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { + const d1 = cross(c, d, a); + const d2 = cross(c, d, b); + const d3 = cross(a, b, c); + const d4 = cross(a, b, d); + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true; + const on = (p: Vec2, q: Vec2, r: Vec2, side: number) => + Math.abs(side) <= EPSILON && + Math.min(p.x, q.x) - EPSILON <= r.x && + r.x <= Math.max(p.x, q.x) + EPSILON && + Math.min(p.y, q.y) - EPSILON <= r.y && + r.y <= Math.max(p.y, q.y) + EPSILON; + return on(c, d, a, d1) || on(c, d, b, d2) || on(a, b, c, d3) || on(a, b, d, d4); +} + +/** + * Why the outline is no polygon one can collide with, or '' when it is one: at least three corners, + * some area, and no edge crossing or touching another that is not its neighbour. + */ +export function polygonProblem(points: readonly Vec2[]): string { + const ring = cleanOutline(points); + if (ring.length < 3) return 'A polygon needs at least three corners.'; + if (Math.abs(signedArea(ring)) <= EPSILON) return 'The polygon has no area.'; + const n = ring.length; + for (let i = 0; i < n; i++) + for (let j = i + 1; j < n; j++) { + if (j === i + 1 || (i === 0 && j === n - 1)) continue; + if (segmentsMeet(ring[i]!, ring[(i + 1) % n]!, ring[j]!, ring[(j + 1) % n]!)) + return 'The outline crosses itself.'; + } + return ''; +} + +/** Whether every corner turns the same way. */ +export function isConvex(ring: readonly Vec2[]): boolean { + const sign = Math.sign(signedArea(ring)); + for (let i = 0; i < ring.length; i++) { + const turn = cross(ring[i]!, ring[(i + 1) % ring.length]!, ring[(i + 2) % ring.length]!); + if (turn * sign < -EPSILON) return false; + } + return true; +} + +/** Whether `p` lies inside the triangle abc of the given winding, its edges counting as inside. */ +function inTriangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2, sign: number): boolean { + return ( + cross(a, b, p) * sign >= -EPSILON && + cross(b, c, p) * sign >= -EPSILON && + cross(c, a, p) * sign >= -EPSILON + ); +} + +/** Cuts a simple outline into triangles by clipping ears, as indices into `ring`. */ +function triangulate(ring: readonly Vec2[]): [number, number, number][] { + const sign = Math.sign(signedArea(ring)); + const left = ring.map((_, i) => i); + const triangles: [number, number, number][] = []; + let guard = ring.length * ring.length; + while (left.length > 3 && guard-- > 0) { + let clipped = false; + for (let k = 0; k < left.length; k++) { + const i0 = left[(k + left.length - 1) % left.length]!; + const i1 = left[k]!; + const i2 = left[(k + 1) % left.length]!; + const [a, b, c] = [ring[i0]!, ring[i1]!, ring[i2]!]; + if (cross(a, b, c) * sign <= EPSILON) continue; + const blocked = left.some( + (j) => j !== i0 && j !== i1 && j !== i2 && inTriangle(ring[j]!, a, b, c, sign), + ); + if (blocked) continue; + triangles.push([i0, i1, i2]); + left.splice(k, 1); + clipped = true; + break; + } + if (!clipped) break; + } + if (left.length === 3) triangles.push([left[0]!, left[1]!, left[2]!]); + return triangles; +} + +/** + * The outline as convex pieces that together cover it exactly: itself when it is convex, else its + * triangles merged back along every shared edge whose removal leaves the piece convex + * (Hertel-Mehlhorn) – at most four times as many pieces as the fewest possible, and few in practice. + * An outline with a problem (see `polygonProblem`) has no pieces. + */ +export function convexPieces(points: readonly Vec2[]): Vec2[][] { + if (polygonProblem(points)) return []; + const ring = cleanOutline(points); + if (isConvex(ring)) return [ring]; + let pieces = triangulate(ring).map((t) => [...t]); + let merged = true; + while (merged) { + merged = false; + search: for (let p = 0; p < pieces.length; p++) + for (let q = p + 1; q < pieces.length; q++) { + const joined = joinAlongSharedEdge(pieces[p]!, pieces[q]!); + if (!joined || !isConvex(cleanOutline(joined.map((i) => ring[i]!)))) continue; + pieces[p] = joined; + pieces = pieces.filter((_, k) => k !== q); + merged = true; + break search; + } + } + return pieces.map((piece) => cleanOutline(piece.map((i) => ring[i]!))); +} + +/** 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++) { + const u = a[i]!; + const v = a[(i + 1) % a.length]!; + const j = b.findIndex((x, k) => x === v && b[(k + 1) % b.length] === u); + if (j < 0) continue; + // Walk a from v round to u, then b from u round to v, skipping the shared edge. + const out: number[] = []; + for (let k = 0; k < a.length; k++) out.push(a[(i + 1 + k) % a.length]!); + for (let k = 2; k < b.length; k++) out.push(b[(j + k) % b.length]!); + return out; + } + return null; +} diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts index d926a748..2abb9544 100644 --- a/src/core/model/texture.ts +++ b/src/core/model/texture.ts @@ -1,5 +1,5 @@ import type { FrameBorders, FrameFill, TextureAsset, TextureFrame } from './Asset'; -import { type PropertyBag, textOf } from './Properties'; +import { type PropertyBag, textOf, type Vec2 } from './Properties'; import { findAsset, type Project } from './Project'; /** Mirrors `TextureStore::isValidName` on the server, where the name is derived from the bytes. */ @@ -112,6 +112,17 @@ 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 })); +} + +/** 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 })); +} + export function frameBorders(frame: TextureFrame): FrameBorders { return frame.border ? { ...frame.border } : { ...NO_BORDERS }; } diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 93518f3d..f73931cf 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -11,12 +11,17 @@ * blocking neighbour: between two blocking cells the side stays flush whatever their values, so a * 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`. */ -import type { TilemapAsset } from './Asset'; +import type { TileAsset, TilemapAsset } from './Asset'; import { angleOf, apply, type Mat, scaleOf } from './matrix'; -import type { PropertyBag } from './Properties'; +import { convexPieces } from './polygon'; +import type { PropertyBag, Vec2 } from './Properties'; import { findAssetOf, type Project } from './Project'; -import { isTileSolid, tileCollisionScale } from './tile'; +import { findFrame } from './texture'; +import { isTileSolid, tileCollisionScale, tileKind } from './tile'; import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap'; import type { ColliderShape } from '../properties/collider'; @@ -35,38 +40,110 @@ export interface SolidCell { scale?: number; } +/** A cell that blocks with an outline (#141): the outline and its convex pieces, in cells of the map. */ +export interface CellPolygon { + x: number; + y: number; + outline: Vec2[]; + pieces: Vec2[][]; +} + +/** A tile's outline across one cell, 0 to 1 on each axis, and its convex pieces. */ +interface TileOutline { + outline: Vec2[]; + pieces: Vec2[][]; +} + /** - * Every blocking cell of the map, 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. + * 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. */ -export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required[] { - const scales = new Map(); - const scaleOf = (id: string): number | null => { - const known = scales.get(id); - if (known !== undefined) return known; +export function tileOutline(project: Project, tile: TileAsset): TileOutline | null { + if (tileKind(tile) !== 'none') return 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; +} + +/** What blocks in each cell: the largest box scale of a plain solid tile, and the outlines of the others. */ +interface CellBlock { + x: number; + y: number; + box: number | null; + outlines: { outline: TileOutline; scale: number }[]; +} + +function blockingCells(project: Project, props: PropertyBag, asset: TilemapAsset): CellBlock[] { + 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) ? tileCollisionScale(tile) : null; - scales.set(id, value); + const value = + tile && isTileSolid(tile) + ? { scale: tileCollisionScale(tile), outline: tileOutline(project, tile) } + : null; + tiles.set(id, value); return value; }; - const found = new Map>(); + const found = new Map(); for (const layer of asset.layers) { for (const [key, tileId] of Object.entries(layer.cells)) { - const scale = tileId ? scaleOf(tileId) : null; - if (scale === null) continue; - const known = found.get(key); - if (known) { - known.scale = Math.max(known.scale, scale); - continue; + const tile = tileId ? tileOf(tileId) : null; + if (!tile) continue; + let cell = found.get(key); + if (!cell) { + const at = parseCellKey(key); + if (!at || !inGrid(props, at.x, at.y)) continue; + found.set(key, (cell = { ...at, box: null, outlines: [] })); } - const at = parseCellKey(key); - if (!at || !inGrid(props, at.x, at.y)) continue; - found.set(key, { ...at, scale }); + 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()]; } +/** + * 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`. + */ +export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required[] { + return blockingCells(project, props, asset) + .filter((cell) => cell.box !== null) + .map(({ x, y, box }) => ({ x, y, scale: box! })); +} + +/** + * 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. + */ +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)), + }); + } + } + return polygons.sort((a, b) => a.y - b.y || a.x - b.x); +} + /** A rectangle by its four sides, which is what the merge compares. */ interface Piece { x0: number; @@ -189,3 +266,17 @@ export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): Colli angle: angleOf(m), }; } + +/** An outlined cell as it sits in the world: a polygon turning and growing with the placed map. */ +export function cellPolygonShape(props: PropertyBag, polygon: CellPolygon, m: Mat): ColliderShape { + const world = (p: Vec2) => apply(m, cellOrigin(props, p.x, p.y)); + const centre = world({ x: polygon.x + 0.5, y: polygon.y + 0.5 }); + return { + kind: 'polygon', + x: centre.x, + y: centre.y, + angle: angleOf(m), + outline: polygon.outline.map(world), + pieces: polygon.pieces.map((piece) => piece.map(world)), + }; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 680e0c1a..0a32057e 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -349,11 +349,25 @@ const ASSET_SCHEMAS: Record = { options: [ { value: 'box', label: 'Box' }, { value: 'circle', label: 'Circle' }, + { value: 'capsule', label: 'Capsule' }, + { value: 'polygon', label: 'Polygon' }, ], }, + // A box and a capsule fill width × height; the capsule is rounded along the longer side. { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 }, + // A polygon's corners in the collider's own units, edited on the outline in the scene (#151). + { + key: 'points', + label: 'Points', + kind: 'list', + default: [ + { x: 0, y: -50 }, + { x: 50, y: 50 }, + { x: -50, y: 50 }, + ], + }, { key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false }, // The material, read by the physics engine where a rigidbody takes part (#146). { key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 }, diff --git a/src/core/properties/collider.ts b/src/core/properties/collider.ts index b2bc008b..7f24a588 100644 --- a/src/core/properties/collider.ts +++ b/src/core/properties/collider.ts @@ -1,9 +1,20 @@ /** * Collider geometry in world units, shared by the editor and the runtime so both agree on what - * "overlaps" means. A box is oriented: it turns and grows with the body it sits on. + * "overlaps" means. A box is oriented: it turns and grows with the body it sits on. A capsule and a + * polygon (#151) carry their points in the world already, and a polygon its convex pieces. */ -import { angleOf, type Mat, scaleOf } from '../model/matrix'; -import type { PropertyBag, Vec2 } from '../model/Properties'; +import { angleOf, apply, type Mat, scaleOf } from '../model/matrix'; +import { convexPieces, pointsOf, signedArea } from '../model/polygon'; +import type { PropertyBag, PropertyValue, Vec2 } from '../model/Properties'; +import { + roundedContact, + roundedContains, + roundedExit, + roundedExtent, + type Contact, + preferredOnTie, + type Rounded, +} from './convex'; import { defaultsFor } from './PropertySchema'; const DEFAULTS = defaultsFor('collider'); @@ -13,12 +24,43 @@ const EPSILON = 1e-9; export type ColliderShape = | { kind: 'box'; x: number; y: number; w: number; h: number; angle: number } - | { kind: 'circle'; x: number; y: number; r: number }; + | { kind: 'circle'; x: number; y: number; r: number } + /** The segment `a`–`b` grown by `r`. */ + | { kind: 'capsule'; x: number; y: number; angle: number; a: Vec2; b: Vec2; r: number } + /** `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see `polygonProblem`). */ + | { kind: 'polygon'; x: number; y: number; angle: number; outline: Vec2[]; pieces: Vec2[][] }; type Box = Extract; type Circle = Extract; +type Classic = Box | Circle; -/** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */ +/** Where a polygon collider keeps its corners (#151). */ +export const COLLIDER_POINTS_KEY = 'points'; + +/** A polygon's pieces in its own units, cut once per stored list rather than on every test. */ +const piecesCache = new WeakMap(); + +/** The corners of a polygon collider in its own units – the stored ones, or the default triangle. */ +export function colliderPoints(props: PropertyBag): Vec2[] { + return pointsOf(props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY]); +} + +function localPieces(value: PropertyValue | undefined): Vec2[][] { + const key = typeof value === 'object' ? value : null; + const known = key && piecesCache.get(key); + if (known) return known; + const pieces = convexPieces(pointsOf(value)); + if (key) piecesCache.set(key, pieces); + return pieces; +} + +/** The capsule in a `w` × `h` box: rounded along the longer side, the radius half the shorter one. */ +function capsuleCore(w: number, h: number): { a: Vec2; b: Vec2; r: number } { + if (h >= w) return { a: { x: 0, y: -(h - w) / 2 }, b: { x: 0, y: (h - w) / 2 }, r: w / 2 }; + return { a: { x: -(w - h) / 2, y: 0 }, b: { x: (w - h) / 2, y: 0 }, r: h / 2 }; +} + +/** The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it. */ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape { const scale = scaleOf(m); if (props.shape === 'circle') @@ -28,14 +70,32 @@ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape { y: m.ty, r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale, }; - return { - kind: 'box', - x: m.tx, - y: m.ty, - w: Math.max(0, Number(props.width ?? DEFAULTS.width)) * scale, - h: Math.max(0, Number(props.height ?? DEFAULTS.height)) * scale, - angle: angleOf(m), - }; + const w = Math.max(0, Number(props.width ?? DEFAULTS.width)); + const h = Math.max(0, Number(props.height ?? DEFAULTS.height)); + if (props.shape === 'capsule') { + const core = capsuleCore(w, h); + return { + kind: 'capsule', + x: m.tx, + y: m.ty, + angle: angleOf(m), + a: apply(m, core.a), + b: apply(m, core.b), + r: core.r * scale, + }; + } + if (props.shape === 'polygon') { + const value = props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY]; + return { + kind: 'polygon', + x: m.tx, + y: m.ty, + angle: angleOf(m), + outline: pointsOf(value).map((p) => apply(m, p)), + pieces: localPieces(value).map((piece) => piece.map((p) => apply(m, p))), + }; + } + return { kind: 'box', x: m.tx, y: m.ty, w: w * scale, h: h * scale, angle: angleOf(m) }; } /** Whether the collider only reports overlaps (true) or also blocks movement (false). */ @@ -45,6 +105,14 @@ export function isTrigger(props: PropertyBag): boolean { /** The size in the collider's own units, before any transform. */ export function colliderSize(props: PropertyBag): { w: number; h: number } { + // Centred like every footprint, so it reaches as far as the farthest corner on each axis. + if (props.shape === 'polygon') { + const points = colliderPoints(props); + return { + w: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.x)), 0), + h: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.y)), 0), + }; + } if (props.shape === 'circle') { const d = Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * 2; return { w: d, h: d }; @@ -82,11 +150,63 @@ function reach(box: Box, n: Vec2): number { } /** How wide the shape is along the unit direction `n` – a turned box measures its own slant. */ +/** + * How wide the shape is along the unit direction `n` – a turned box measures its own slant. A + * polygon measures its thinnest piece: that is what a step along `n` must not jump over. + */ export function shapeExtent(shape: ColliderShape, n: Vec2): number { - return shape.kind === 'circle' ? shape.r * 2 : reach(shape, n) * 2; + if (shape.kind === 'circle') return shape.r * 2; + if (shape.kind === 'box') return reach(shape, n) * 2; + const pieces = piecesOf(shape); + return pieces.length ? Math.min(...pieces.map((piece) => roundedExtent(piece, n))) : 0; +} + +/** + * The shape as rounded convex pieces (see `convex.ts`): a box its corners, a circle its centre and + * a capsule its segment, each grown by its radius, a polygon its pieces. + */ +export function piecesOf(shape: ColliderShape): Rounded[] { + switch (shape.kind) { + case 'box': { + const [ux, uy] = axesOf(shape); + const corner = (sx: number, sy: number) => ({ + x: shape.x + (sx * shape.w * ux.x + sy * shape.h * uy.x) / 2, + y: shape.y + (sx * shape.w * ux.y + sy * shape.h * uy.y) / 2, + }); + return [{ points: [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)], r: 0 }]; + } + case 'circle': + return [{ points: [{ x: shape.x, y: shape.y }], r: shape.r }]; + case 'capsule': + return [{ points: [shape.a, shape.b], r: shape.r }]; + case 'polygon': + return shape.pieces.map((points) => ({ points, r: 0 })); + } +} + +/** The area the shape covers, in world units squared. */ +export function shapeArea(shape: ColliderShape): number { + switch (shape.kind) { + case 'box': + return shape.w * shape.h; + case 'circle': + return Math.PI * shape.r * shape.r; + case 'capsule': + return ( + Math.PI * shape.r * shape.r + 2 * shape.r * Math.hypot(shape.b.x - shape.a.x, shape.b.y - shape.a.y) + ); + case 'polygon': + return shape.pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0); + } +} + +/** Box and circle keep their own exact tests; everything else goes through the rounded pieces. */ +function isClassic(shape: ColliderShape): shape is Classic { + return shape.kind === 'box' || shape.kind === 'circle'; } export function shapeContains(shape: ColliderShape, point: Vec2): boolean { + if (!isClassic(shape)) return piecesOf(shape).some((piece) => roundedContains(piece, point)); if (shape.kind === 'circle') return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r; const local = toBoxFrame(shape, point); @@ -102,6 +222,12 @@ function distanceToBox(p: Vec2, hw: number, hh: number): number { /** Shapes that merely touch along an edge do not count as overlapping. */ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean { + if (!isClassic(a) || !isClassic(b)) { + const others = piecesOf(b); + return piecesOf(a).some((p) => + others.some((q) => roundedContact(p, q, { x: 0, y: 0 }).separation < -EPSILON), + ); + } if (a.kind === 'box' && b.kind === 'box') { // Separating axes: the sides of both boxes. Apart along any one of them is apart. const centres = { x: a.x - b.x, y: a.y - b.y }; @@ -128,6 +254,7 @@ export function contactBetween( obstacle: ColliderShape, travel: Vec2, ): { separation: number; normal: Vec2 } { + if (!isClassic(mover) || !isClassic(obstacle)) return piecesContact(mover, obstacle, travel); const against = (n: Vec2, offset: number): Vec2 => { const sign = offset !== 0 ? Math.sign(offset) : dot(n, travel) > 0 ? -1 : 1; return { x: sign * n.x + 0, y: sign * n.y + 0 }; @@ -206,6 +333,13 @@ export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, dire const length = Math.hypot(direction.x, direction.y); if (length === 0 || !shapesOverlap(mover, obstacle)) return 0; const d = { x: direction.x / length, y: direction.y / length }; + if (!isClassic(mover) || !isClassic(obstacle)) { + // Every overlapping pair of pieces has to come clear; once out, a pair stays out along the line. + const others = piecesOf(obstacle); + let exit = 0; + for (const p of piecesOf(mover)) for (const q of others) exit = Math.max(exit, roundedExit(p, q, d)); + return exit; + } if (mover.kind === 'box' && obstacle.kind === 'box') return boxesExit(mover, obstacle, d); if (mover.kind === 'circle' && obstacle.kind === 'circle') { const sep = { x: mover.x - obstacle.x, y: mover.y - obstacle.y }; @@ -260,6 +394,27 @@ function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): nu return hi; } -function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } { +/** + * The contact of two shapes made of pieces: the pair of pieces that overlaps deepest (or lies + * closest); on a tie see `preferredOnTie`. A shape without pieces – a + * polygon that is none – meets nothing. + */ +function piecesContact(mover: ColliderShape, obstacle: ColliderShape, travel: Vec2): Contact { + let best: Contact = { separation: Infinity, normal: { x: 0, y: 0 } }; + const others = piecesOf(obstacle); + for (const p of piecesOf(mover)) + for (const q of others) { + const contact = roundedContact(p, q, travel); + if ( + contact.separation < best.separation - EPSILON || + (contact.separation < best.separation + EPSILON && + preferredOnTie(contact.normal, best.normal, travel)) + ) + best = { separation: Math.min(contact.separation, best.separation), normal: contact.normal }; + } + return best; +} + +function boxAndCircle(a: Classic, b: Classic): { box: Box; circle: Circle } { return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a }; } diff --git a/src/core/properties/convex.ts b/src/core/properties/convex.ts new file mode 100644 index 00000000..b573dbf1 --- /dev/null +++ b/src/core/properties/convex.ts @@ -0,0 +1,180 @@ +/** + * Collision between rounded convex pieces (#151): a core – one point, a segment or a convex + * polygon – grown by a radius. A circle is a point grown by its radius, a capsule a segment, a box + * or a piece of a polygon a polygon grown by nothing. Every pair is worked out the same way: the + * separating axes of the two cores decide whether the cores overlap and, if so, how deep; apart, + * the real distance between them is taken. The radii are then taken off. + */ +import type { Vec2 } from '../model/Properties'; + +export interface Rounded { + points: readonly Vec2[]; + r: number; +} + +/** A contact as `contactBetween` reports it: gap (negative = overlap) and the normal towards the mover. */ +export interface Contact { + separation: number; + normal: Vec2; +} + +/** Ties between axes closer than this are settled by the travel. */ +const TIE = 1e-9; + +function dot(a: Vec2, b: Vec2): number { + return a.x * b.x + a.y * b.y; +} + +function unit(v: Vec2): Vec2 { + const length = Math.hypot(v.x, v.y); + return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 0, y: 0 }; +} + +/** + * Which of two normals a contact takes when both are as good (a corner on a corner, where any + * direction between them is right): the one that stands least in the travel's way while still + * standing in it – so a crate walking along the floor into the foot of a slope climbs it, and one + * that meets a block exactly corner to corner goes on along its top. A normal that does not stand + * against the travel at all only wins over another such one. + */ +export function preferredOnTie(a: Vec2, b: Vec2, travel: Vec2): boolean { + const da = dot(a, travel); + const db = dot(b, travel); + return da < 0 && db < 0 ? da > db : da < db; +} + +/** + * The axes a core can be told apart along: a polygon's edge normals, a segment's normal and + * direction (so a point beyond its end is found apart too), nothing for a point. + */ +function axesOf(points: readonly Vec2[]): Vec2[] { + if (points.length < 2) return []; + const axes: Vec2[] = []; + const edges = points.length === 2 ? 1 : points.length; + for (let i = 0; i < edges; i++) { + const a = points[i]!; + const b = points[(i + 1) % points.length]!; + const d = unit({ x: b.x - a.x, y: b.y - a.y }); + if (d.x === 0 && d.y === 0) continue; + axes.push({ x: -d.y, y: d.x }); + if (points.length === 2) axes.push(d); + } + return axes; +} + +function span(points: readonly Vec2[], n: Vec2): [number, number] { + let min = Infinity; + let max = -Infinity; + for (const p of points) { + const v = dot(p, n); + if (v < min) min = v; + if (v > max) max = v; + } + return [min, max]; +} + +/** The closest point to `p` on the segment ab. */ +function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { + const ab = { x: b.x - a.x, y: b.y - a.y }; + const length = dot(ab, ab); + const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0; + return { x: a.x + ab.x * t, y: a.y + ab.y * t }; +} + +/** The edges of a core as segments; a point is one segment of no length. */ +function edgesOf(points: readonly Vec2[]): [Vec2, Vec2][] { + if (points.length === 1) return [[points[0]!, points[0]!]]; + if (points.length === 2) return [[points[0]!, points[1]!]]; + return points.map((p, i) => [p, points[(i + 1) % points.length]!]); +} + +/** The closest points of two cores that lie apart: every corner of one against every edge of the other. */ +function closestPoints(a: readonly Vec2[], b: readonly Vec2[]): { onA: Vec2; onB: Vec2; distance: number } { + let best = { onA: a[0]!, onB: b[0]!, distance: Infinity }; + for (const p of a) + for (const [s, t] of edgesOf(b)) { + const q = closestOnSegment(p, s, t); + const distance = Math.hypot(p.x - q.x, p.y - q.y); + if (distance < best.distance) best = { onA: p, onB: q, distance }; + } + for (const q of b) + for (const [s, t] of edgesOf(a)) { + const p = closestOnSegment(q, s, t); + const distance = Math.hypot(p.x - q.x, p.y - q.y); + if (distance < best.distance) best = { onA: p, onB: q, distance }; + } + return best; +} + +/** + * How `mover` meets `obstacle`: the gap between the rounded pieces (negative when they overlap, its + * size then the shortest way out) and the unit normal from the obstacle towards the mover. Where + * nothing gives a direction – two cores on the same spot – the normal runs against `travel`. + */ +export function roundedContact(mover: Rounded, obstacle: Rounded, travel: Vec2): Contact { + const radii = mover.r + obstacle.r; + const against = (n: Vec2) => (dot(n, travel) > 0 ? { x: -n.x, y: -n.y } : n); + let best: Contact = { separation: -Infinity, normal: { x: 0, y: 0 } }; + for (const n of [...axesOf(mover.points), ...axesOf(obstacle.points)]) { + const [a0, a1] = span(mover.points, n); + const [b0, b1] = span(obstacle.points, n); + // Pushed out along +n the mover needs to clear b1; along -n, b0. + const plus = a0 - b1; + const minus = b0 - a1; + const separation = Math.max(plus, minus); + const normal = Math.abs(plus - minus) <= TIE ? against(n) : plus > minus ? n : { x: -n.x, y: -n.y }; + if ( + separation > best.separation + TIE || + (separation > best.separation - TIE && preferredOnTie(normal, best.normal, travel)) + ) + best = { separation: Math.max(separation, best.separation), normal }; + } + // Apart, or two points (no axis at all): the real distance between the cores. + if (best.separation > 0 || best.separation === -Infinity) { + const { onA, onB, distance } = closestPoints(mover.points, obstacle.points); + if (distance > 0) + return { + separation: distance - radii, + normal: { x: (onA.x - onB.x) / distance + 0, y: (onA.y - onB.y) / distance + 0 }, + }; + // Two points on the same spot: out against the travel. + const back = unit({ x: -travel.x, y: -travel.y }); + return { separation: -radii, normal: back.x === 0 && back.y === 0 ? { x: 0, y: -1 } : back }; + } + return { separation: best.separation - radii, normal: { x: best.normal.x + 0, y: best.normal.y + 0 } }; +} + +/** Whether `p` lies on the rounded piece, its outline counting as on it. */ +export function roundedContains(piece: Rounded, p: Vec2): boolean { + return roundedContact({ points: [p], r: 0 }, piece, { x: 0, y: 0 }).separation <= 0; +} + +/** How wide the piece is along the unit direction `n`. */ +export function roundedExtent(piece: Rounded, n: Vec2): number { + const [min, max] = span(piece.points, n); + return max - min + 2 * piece.r; +} + +export function translated(piece: Rounded, by: Vec2): Rounded { + return { points: piece.points.map((p) => ({ x: p.x + by.x, y: p.y + by.y })), r: piece.r }; +} + +/** + * How far `mover` has to travel along the unit direction `d` until it clears `obstacle`; 0 when + * they are apart. Along a line the overlap of two convex pieces is one stretch, so its end is + * found by bisection. + */ +export function roundedExit(mover: Rounded, obstacle: Rounded, d: Vec2): number { + const inside = (t: number) => + roundedContact(translated(mover, { x: d.x * t, y: d.y * t }), obstacle, d).separation < 0; + if (!inside(0)) return 0; + let hi = 1; + for (let i = 0; i < 64 && inside(hi); i++) hi *= 2; + let lo = 0; + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2; + if (inside(mid)) lo = mid; + else hi = mid; + } + return hi; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 4b1c1edd..95f021c3 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -48,8 +48,10 @@ import { isTextureFile, normalizeBorders, normalizeFrameRect, + setFrameCollision, setFrameSlice, } from '../model/texture'; +import { pointsOf } from '../model/polygon'; import { hasParentCycle } from '../model/walk'; import { DECLARED_PORT_KINDS, @@ -479,6 +481,9 @@ 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); frames.push(frame); } return { file, width, height, frames }; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a2f15f84..7ec4c89c 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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, diff --git a/src/editor/polygonEdit.ts b/src/editor/polygonEdit.ts new file mode 100644 index 00000000..bbc3ee3a --- /dev/null +++ b/src/editor/polygonEdit.ts @@ -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 })); +} diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 18eb0c39..7b5022de 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -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))), + }; +} diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts index a3753448..23b481f1 100644 --- a/src/editor/texture/TextureEditor.ts +++ b/src/editor/texture/TextureEditor.ts @@ -11,11 +11,22 @@ const INFO_WIDTH = 190; export class TextureEditor extends AssetTabEditor { 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. */ diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 5712ded7..66ea494b 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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(); 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 })); +} diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 0c44f6ba..a9336c67 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -8,8 +8,9 @@ import type { import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; +import { signedArea } from '../../core/model/polygon'; import { parentChain } from '../../core/model/walk'; -import { type ColliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider'; +import { type ColliderShape, isTrigger, shapeArea, shapesOverlap } from '../../core/properties/collider'; import { hasTransformProps } from '../../core/properties/PropertySchema'; import { type BodyType, @@ -55,7 +56,9 @@ interface Pose { interface Body { kind: 'rigid' | 'free' | 'tilemap'; body: RigidBody; + /** The engine's colliders – several for a polygon, one per convex piece – and whose each is. */ colliders: Collider[]; + sources: Entity[]; /** What the colliders were built from; they are built again when it changes (a scaled object). */ shapeKey: string; settingsKey: string; @@ -277,7 +280,7 @@ export class PhysicsSystem implements System, CollisionSource { const mass = rigidbody ? rigidbodySettings(rigidbody.props).mass : 0; if (mass > 0) return mass; return blockingCollidersOf(world, entity).reduce( - (sum, c) => sum + colliderMaterial(c.props).density * areaOf(shapeOf(c)) * toMetres(1) ** 2, + (sum, c) => sum + colliderMaterial(c.props).density * shapeArea(shapeOf(c)) * toMetres(1) ** 2, 0, ); } @@ -351,6 +354,7 @@ export class PhysicsSystem implements System, CollisionSource { kind: want.kind, body: this.engine!.createRigidBody(desc), colliders: [], + sources: [], shapeKey: '', settingsKey: '', turns: false, @@ -372,7 +376,7 @@ export class PhysicsSystem implements System, CollisionSource { if (want.kind === 'tilemap') return { x: 0, y: 0, angle: 0 }; if (want.kind === 'free') { const shape = shapeOf(key); - return { x: shape.x, y: shape.y, angle: shape.kind === 'box' ? shape.angle : 0 }; + return { x: shape.x, y: shape.y, angle: shape.kind === 'circle' ? 0 : shape.angle }; } return matrixPose(worldMatrixOf(key)); } @@ -433,15 +437,12 @@ export class PhysicsSystem implements System, CollisionSource { private build(body: Body, want: Wanted, key: Entity, world: World): void { const shapes = this.shapesWanted(want, key, world); const origin = body.placed; - const local = shapes.map(({ entity, shape }) => ({ entity, shape, at: relativeTo(origin, shape) })); + const local = shapes.map(({ entity, shape }) => ({ entity, shape, pieces: piecesInBody(origin, shape) })); const shapeKey = JSON.stringify( - local.map(({ entity, shape, at }) => [ + local.map(({ entity, pieces }) => [ entity.id, - shape.kind === 'box' ? [round(shape.w), round(shape.h)] : round(shape.r), - round(at.x), - round(at.y), - round(at.angle), + pieces.map(pieceKey), entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0, ]), ) + JSON.stringify(want.settings?.mass ?? 0); @@ -452,26 +453,28 @@ export class PhysicsSystem implements System, CollisionSource { this.engine!.removeCollider(collider, true); } body.colliders = []; + body.sources = []; const mass = want.settings?.mass ?? 0; - const area = local.reduce((sum, { entity, shape }) => sum + (isSolid(entity) ? areaOf(shape) : 0), 0); - for (const { entity, shape, at } of local) { - if (areaOf(shape) <= 0) continue; - const desc = this.colliderDesc( - entity, - shape, - at, - mass > 0 && area > 0 ? (mass * areaOf(shape)) / area : 0, - ); - const collider = this.engine!.createCollider(desc, body.body); - body.colliders.push(collider); - this.parts.set(collider.handle, { entity, shape, owner: body }); - } + const area = local.reduce( + (sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0), + 0, + ); + for (const { entity, shape, pieces } of local) + for (const piece of pieces) { + const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0; + const desc = this.colliderDesc(entity, piece, share); + if (!desc) continue; + const collider = this.engine!.createCollider(desc, body.body); + body.colliders.push(collider); + body.sources.push(entity); + this.parts.set(collider.handle, { entity, shape, owner: body }); + } } // Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. - const built = local.filter(({ shape }) => areaOf(shape) > 0); + const shapeOfEntity = new Map(local.map(({ entity, shape }) => [entity, shape])); body.colliders.forEach((collider, i) => { - const { entity, shape } = built[i]!; - this.parts.set(collider.handle, { entity, shape, owner: body }); + const entity = body.sources[i]!; + this.parts.set(collider.handle, { entity, shape: shapeOfEntity.get(entity)!, owner: body }); const on = isHandleEnabled(entity); if (collider.isEnabled() === on) return; collider.setEnabled(on); @@ -480,7 +483,7 @@ export class PhysicsSystem implements System, CollisionSource { }); if (body.kind !== 'rigid') return; // Without a collider the engine would weigh it nothing, and a thing of no weight never falls. - const weighed = built.some(({ entity }) => isSolid(entity) && isHandleEnabled(entity)); + const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity)); const own = weighed ? 0 : want.settings?.mass || 1; if (own !== body.ownMass) { body.ownMass = own; @@ -494,13 +497,30 @@ export class PhysicsSystem implements System, CollisionSource { return want.colliders.map((entity) => ({ entity, shape: shapeOf(entity) })); } - private colliderDesc(entity: Entity, shape: ColliderShape, at: Pose, mass: number): ColliderDesc { + /** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */ + private colliderDesc(entity: Entity, piece: Piece, mass: number): ColliderDesc | null { const R = this.rapier; - const desc = - shape.kind === 'circle' - ? R.ColliderDesc.ball(toMetres(shape.r)) - : R.ColliderDesc.cuboid(toMetres(shape.w / 2), toMetres(shape.h / 2)); - desc.setTranslation(toMetres(at.x), toMetres(at.y)).setRotation(at.angle); + if (pieceArea(piece) <= 0) return null; + let desc: ColliderDesc | null; + switch (piece.kind) { + case 'ball': + desc = R.ColliderDesc.ball(toMetres(piece.r)); + break; + case 'cuboid': + desc = R.ColliderDesc.cuboid(toMetres(piece.hw), toMetres(piece.hh)); + break; + case 'capsule': + desc = R.ColliderDesc.capsule(toMetres(piece.half), toMetres(piece.r)); + break; + case 'convex': + desc = R.ColliderDesc.convexHull( + new Float32Array(piece.points.flatMap((p) => [toMetres(p.x), toMetres(p.y)])), + ); + break; + } + if (!desc) return null; + if (piece.kind !== 'convex') + desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle); // A tile has no material of its own; it gets what a new collider starts with. const material = colliderMaterial(entity.type === 'collider' ? entity.props : {}); desc.setFriction(material.friction).setRestitution(material.bounciness); @@ -639,21 +659,71 @@ function blend(a: Pose, b: Pose, t: number): Pose { }; } -/** A world shape as seen from a body at `origin`: offset and turn in the body's own frame. */ -function relativeTo(origin: Pose, shape: ColliderShape): Pose { - const dx = shape.x - origin.x; - const dy = shape.y - origin.y; +/** + * One engine collider in the body's own frame (world units): a ball, a box or a capsule placed and + * turned there, or a convex piece given by its corners. + */ +type Piece = + | { kind: 'ball'; x: number; y: number; angle: number; r: number } + | { kind: 'cuboid'; x: number; y: number; angle: number; hw: number; hh: number } + | { kind: 'capsule'; x: number; y: number; angle: number; half: number; r: number } + | { kind: 'convex'; points: Vec2[] }; + +/** A world shape as seen from a body at `origin`, as the engine colliders that make it up. */ +function piecesInBody(origin: Pose, shape: ColliderShape): Piece[] { const cos = Math.cos(origin.angle); const sin = Math.sin(origin.angle); - return { - x: dx * cos + dy * sin, - y: -dx * sin + dy * cos, - angle: (shape.kind === 'box' ? shape.angle : 0) - origin.angle, + const toBody = (p: Vec2): Vec2 => { + const dx = p.x - origin.x; + const dy = p.y - origin.y; + return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos }; }; + switch (shape.kind) { + case 'circle': + return [{ kind: 'ball', ...toBody(shape), angle: 0, r: shape.r }]; + case 'box': + return [ + { + kind: 'cuboid', + ...toBody(shape), + angle: shape.angle - origin.angle, + hw: shape.w / 2, + hh: shape.h / 2, + }, + ]; + case 'capsule': { + const a = toBody(shape.a); + const b = toBody(shape.b); + const half = Math.hypot(b.x - a.x, b.y - a.y) / 2; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + if (half === 0) return [{ kind: 'ball', ...mid, angle: 0, r: shape.r }]; + // The engine's capsule lies along its own y axis. + const angle = Math.atan2(b.y - a.y, b.x - a.x) - Math.PI / 2; + return [{ kind: 'capsule', ...mid, angle, half, r: shape.r }]; + } + case 'polygon': + return shape.pieces.map((piece) => ({ kind: 'convex', points: piece.map(toBody) })); + } } -function areaOf(shape: ColliderShape): number { - return shape.kind === 'circle' ? Math.PI * shape.r * shape.r : shape.w * shape.h; +function pieceArea(piece: Piece): number { + switch (piece.kind) { + case 'ball': + return Math.PI * piece.r * piece.r; + case 'cuboid': + return 4 * piece.hw * piece.hh; + case 'capsule': + return Math.PI * piece.r * piece.r + 4 * piece.half * piece.r; + case 'convex': + return Math.abs(signedArea(piece.points)); + } +} + +/** What a piece was built from, rounded, so a rebuild happens when – and only when – it changed. */ +function pieceKey(piece: Piece): unknown { + if (piece.kind === 'convex') return piece.points.map((p) => [round(p.x), round(p.y)]); + const { kind, ...values } = piece; + return [kind, ...Object.values(values).map(round)]; } /** A tilemap's cells always block; a collider unless it is a trigger. */ diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 39ae78ff..72206804 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -2,7 +2,14 @@ import { isHandleEnabled, worldMatrixOf } from '../../core/graph'; import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix'; import type { Vec2 } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; -import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision'; +import { + type CellPolygon, + cellPolygonShape, + type CellRect, + cellRectShape, + solidPolygons, + solidRects, +} from '../../core/model/tilemapCollision'; import { parentChain } from '../../core/model/walk'; import { type ColliderShape, @@ -97,35 +104,45 @@ export interface CollisionSource { */ export class Solids { /** - * The blocking rectangles of each tilemap, in cells. Worked out once per tick – painting, or a - * behavior changing the map, is picked up by the next one – and turned into world shapes only - * when asked, since the map may move in between. + * The blocking rectangles and outlined cells of each tilemap, in cells. Worked out once per tick – + * painting, or a behavior changing the map, is picked up by the next one – and turned into world + * shapes only when asked, since the map may move in between. */ - private rects = new Map(); + private blocks = new Map(); - /** Forgets the rectangles, so the next tick works them out afresh. */ + /** Forgets the cells, so the next tick works them out afresh. */ reset(): void { - this.rects = new Map(); + this.blocks = new Map(); } - rectsOf(world: World, tilemap: Entity): CellRect[] { - let rects = this.rects.get(tilemap); - if (!rects) { + private blocksOf(world: World, tilemap: Entity): { rects: CellRect[]; polygons: CellPolygon[] } { + let blocks = this.blocks.get(tilemap); + if (!blocks) { const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap'); - rects = asset ? solidRects(world.project, tilemap.props, asset) : []; - this.rects.set(tilemap, rects); + blocks = asset + ? { + rects: solidRects(world.project, tilemap.props, asset), + polygons: solidPolygons(world.project, tilemap.props, asset), + } + : { rects: [], polygons: [] }; + this.blocks.set(tilemap, blocks); } - return rects; + return blocks; } /** - * The shapes a body occupies in the world: one for a collider, one box per merged rectangle - * for a tilemap. A tilemap placed before the first tick is worked out on the spot. + * The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged + * rectangle and one polygon per outlined cell (#141). A tilemap placed before the first tick is + * worked out on the spot. */ shapesOf(world: World, body: Entity): ColliderShape[] { if (body.type !== 'tilemap') return [shapeOf(body)]; const m = worldMatrixOf(body); - return this.rectsOf(world, body).map((rect) => cellRectShape(body.props, rect, m)); + const { rects, polygons } = this.blocksOf(world, body); + return [ + ...rects.map((rect) => cellRectShape(body.props, rect, m)), + ...polygons.map((polygon) => cellPolygonShape(body.props, polygon, m)), + ]; } /** diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts index 58b089cb..b11ed54f 100644 --- a/src/ui/widgets/Button.ts +++ b/src/ui/widgets/Button.ts @@ -16,6 +16,8 @@ export interface ButtonOptions { padding?: number; /** Text style of the label when the button is not on an accent fill. */ style?: TextStyleToken; + /** Switched on and off by its owner (`setToggled`): measured for the bold label it shows when on. */ + toggle?: boolean; onClick?: () => void; } @@ -32,6 +34,7 @@ export class Button extends Widget { protected readonly fixedWidth: number; private readonly padding: number; private readonly style: TextStyleToken; + private readonly toggle: boolean; onClick?: () => void; constructor(text: string, options: ButtonOptions = {}) { @@ -47,6 +50,7 @@ export class Button extends Widget { this.padding = options.padding ?? Theme.padding; this.fullText = text; this.style = options.style ?? 'body'; + this.toggle = options.toggle ?? false; this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : this.style) }); this.addChild(this.bg, this.labelText); @@ -104,7 +108,7 @@ export class Button extends Widget { // From the full label, never the on-screen one: measuring truncated text would keep a button // that was once too narrow too narrow for good. // Measured bold where it can be drawn bold (on an accent fill), or a danger or toggled tab truncates. - const style = textStyle(this.variant === 'default' ? this.style : 'accent'); + const style = textStyle(this.variant === 'default' && !this.toggle ? this.style : 'accent'); const metrics = CanvasTextMetrics.measureText(this.fullText, style); return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h }; } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 2e3583f7..9aa38c80 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -7,7 +7,7 @@ import type { PropertyBag } from '../core/model/Properties'; import type { ParticlesAsset } from '../core/model/Asset'; import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile'; import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap'; -import { type CellRect, solidRects } from '../core/model/tilemapCollision'; +import { type CellPolygon, type CellRect, solidPolygons, solidRects } from '../core/model/tilemapCollision'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { fontFileOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; @@ -68,6 +68,8 @@ export interface TilemapDraw { tiles: Map; /** Where the map blocks, in cells – the scene view outlines it like a collider (#132). */ solids: CellRect[]; + /** The cells that block with an outline (#141), outlined the same way. */ + outlines: CellPolygon[]; } /** @@ -95,7 +97,12 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag): }); } } - return { layers, tiles: used, solids: solidRects(project, props, asset) }; + return { + layers, + tiles: used, + solids: solidRects(project, props, asset), + outlines: solidPolygons(project, props, asset), + }; } /** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 69253ca1..4065edd9 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -12,6 +12,7 @@ import { import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; import { SHEET_EDGE } from '../core/shader/nodes'; import type { NineSlice } from '../core/model/texture'; +import type { Vec2 } from '../core/model/Properties'; import { Theme } from '../ui'; import type { DrawItem } from './items'; import { sliceGeometry, sliceKey } from './nineSlice'; @@ -182,9 +183,14 @@ export function drawItem( // Only the outline band is hit-testable, so clicks inside still reach the sprite that the // collider sits on. const shape = colliderShape(item.props, IDENTITY); - if (shape.kind === 'circle') node.circle(0, 0, shape.r); - else node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h); - const color = isTrigger(item.props) ? Theme.colors.trigger : Theme.colors.collider; + traceShape(node, shape); + // A polygon that crosses itself collides with nothing, and says so in red. + const color = + shape.kind === 'polygon' && shape.pieces.length === 0 + ? Theme.colors.danger + : isTrigger(item.props) + ? Theme.colors.trigger + : Theme.colors.collider; node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / scale, color }); node.hitArea = colliderOutlineHitArea(shape, COLLIDER_GRAB_BAND / scale); return; @@ -217,12 +223,52 @@ const DIAGONALS = [ [-Math.SQRT1_2, -Math.SQRT1_2], ] as const; +/** Adds the outline of a collider shape in its own units (see `colliderShape` with `IDENTITY`). */ +function traceShape(node: Graphics, shape: ColliderShape): void { + switch (shape.kind) { + case 'circle': + node.circle(0, 0, shape.r); + return; + case 'box': + node.rect(-shape.w / 2, -shape.h / 2, shape.w, shape.h); + return; + // In its own units a capsule's segment lies along an axis, so it is a fully rounded rectangle. + case 'capsule': { + const x = Math.min(shape.a.x, shape.b.x) - shape.r; + const y = Math.min(shape.a.y, shape.b.y) - shape.r; + const w = Math.abs(shape.b.x - shape.a.x) + 2 * shape.r; + const h = Math.abs(shape.b.y - shape.a.y) + 2 * shape.r; + node.roundRect(x, y, w, h, shape.r); + return; + } + case 'polygon': + if (shape.outline.length >= 2) node.poly(shape.outline, true); + return; + } +} + +function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { + 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 Math.hypot(p.x - (a.x + ab.x * t), p.y - (a.y + ab.y * t)); +} + /** Hit area covering only the outline band, in local coordinates and world units. */ function colliderOutlineHitArea( shape: ColliderShape, band: number, ): { contains(x: number, y: number): boolean } { if (shape.kind === 'circle') return { contains: (x, y) => Math.abs(Math.hypot(x, y) - shape.r) <= band }; + if (shape.kind === 'capsule') + return { contains: (x, y) => Math.abs(distanceToSegment({ x, y }, shape.a, shape.b) - shape.r) <= band }; + if (shape.kind === 'polygon') { + const ring = shape.outline; + return { + contains: (x, y) => + ring.some((a, i) => distanceToSegment({ x, y }, a, ring[(i + 1) % ring.length]!) <= band), + }; + } const hw = shape.w / 2; const hh = shape.h / 2; return { diff --git a/tests/core/polygon.test.ts b/tests/core/polygon.test.ts new file mode 100644 index 00000000..6768cf5a --- /dev/null +++ b/tests/core/polygon.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest'; +import { transformMatrix, translation } from '../../src/core/model/matrix'; +import { + cleanOutline, + convexPieces, + isConvex, + pointsOf, + polygonProblem, + signedArea, +} from '../../src/core/model/polygon'; +import type { Vec2 } from '../../src/core/model/Properties'; +import { + type ColliderShape, + colliderShape, + colliderSize, + contactBetween, + exitDistance, + shapeArea, + shapeContains, + shapesOverlap, +} from '../../src/core/properties/collider'; +import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit'; + +const P = (...xy: number[]) => + Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! })); +const area = (points: Vec2[]) => Math.abs(signedArea(points)); + +/** An L: 3 wide at the bottom, 1 wide going up – concave at (1, 1). Area 3 + 2 = 5. */ +const L = P(0, 0, 1, 0, 1, 2, 3, 2, 3, 3, 0, 3); + +let seed = 11; +const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647; + +/** A star-shaped outline: corners round a centre at random radii, so it is simple and mostly concave. */ +function randomStar(corners: number): Vec2[] { + return Array.from({ length: corners }, (_, i) => { + const angle = (i / corners) * 2 * Math.PI; + const r = 20 + random() * 80; + return { x: Math.cos(angle) * r, y: Math.sin(angle) * r }; + }); +} + +describe('polygon outlines (#151)', () => { + it('reads {x, y} items and leaves out what is no point', () => { + expect( + pointsOf([ + { x: 1, y: 2 }, + { x: 'a', y: 3 }, + 5, + { x: 4 }, + { x: '6', y: 7 }, + { x: '', y: 1 }, + { x: true, y: 1 }, + ]), + ).toEqual([ + { x: 1, y: 2 }, + { x: 6, y: 7 }, + ]); + expect(pointsOf(undefined)).toEqual([]); + expect(pointsOf('nope')).toEqual([]); + }); + + it('says what is wrong: too few corners, no area, an outline that crosses itself', () => { + expect(polygonProblem(P(0, 0, 10, 0))).toMatch(/three corners/); + expect(polygonProblem(P(0, 0, 10, 0, 20, 0))).toMatch(/three corners/); // straight: cleaned to two + expect(polygonProblem(P(0, 0, 10, 0, 10, 10, 10, 10, 0, 10))).toBe(''); // a repeated corner is fine + expect(polygonProblem(P(0, 0, 10, 10, 10, 0, 0, 10))).toMatch(/no area/); // a bow tie: halves cancel + // A pentagram: every other corner of a pentagon, so each edge crosses two others. + const pentagram = [0, 2, 4, 1, 3].map((i) => ({ + x: Math.cos((i * 2 * Math.PI) / 5) * 50, + y: Math.sin((i * 2 * Math.PI) / 5) * 50, + })); + expect(polygonProblem(pentagram)).toMatch(/crosses itself/); + // Touching itself at a corner counts as crossing: the two halves would share only a point. + expect(polygonProblem(P(0, 0, 10, 0, 5, 5, 10, 10, 0, 10, 5, 5))).toMatch(/crosses itself/); + expect(polygonProblem(L)).toBe(''); + expect(cleanOutline(P(0, 0, 5, 0, 10, 0, 10, 10))).toEqual(P(0, 0, 10, 0, 10, 10)); + }); + + it('a convex outline is one piece, in either winding; one with a problem has none', () => { + const square = P(0, 0, 10, 0, 10, 10, 0, 10); + expect(convexPieces(square)).toEqual([square]); + expect(convexPieces([...square].reverse())).toHaveLength(1); + expect(convexPieces(P(0, 0, 10, 10, 10, 0, 0, 10))).toEqual([]); + }); + + it('an L is cut into two convex pieces that cover it exactly', () => { + for (const outline of [L, [...L].reverse()]) { + const pieces = convexPieces(outline); + expect(pieces).toHaveLength(2); + expect(pieces.every(isConvex)).toBe(true); + expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(5, 12); + } + }); + + it('random star outlines: convex pieces, the whole area, only the outline’s own corners, few pieces', () => { + for (let round = 0; round < 200; round++) { + const outline = randomStar(5 + Math.floor(random() * 12)); + const pieces = convexPieces(outline); + expect(pieces.length).toBeGreaterThan(0); + expect(pieces.every(isConvex)).toBe(true); + expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(area(outline), 6); + const corners = new Set(outline.map((p) => `${p.x},${p.y}`)); + expect(pieces.flat().every((p) => corners.has(`${p.x},${p.y}`))).toBe(true); + // Triangles alone would be n − 2; merging must have joined some whenever there was room. + expect(pieces.length).toBeLessThanOrEqual(outline.length - 2); + } + }); +}); + +const box = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape => ({ + kind: 'box', + x, + y, + w, + h, + angle: (degrees * Math.PI) / 180, +}); +const circle = (x: number, y: number, r: number): ColliderShape => ({ kind: 'circle', x, y, r }); +/** The same box as a polygon collider, so the new path can be held against the old exact one. */ +const boxAsPolygon = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape => + colliderShape( + { shape: 'polygon', points: P(-w / 2, -h / 2, w / 2, -h / 2, w / 2, h / 2, -w / 2, h / 2) }, + transformMatrix(x, y, degrees, 1, false, false), + ); + +describe('collider geometry of capsules and polygons (#151)', () => { + it('a capsule fills width × height, rounded along the longer side, and turns with the object', () => { + expect(colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(5, 0))).toMatchObject({ + kind: 'capsule', + a: { x: 5, y: -20 }, + b: { x: 5, y: 20 }, + r: 10, + }); + expect(colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0))).toMatchObject({ + a: { x: -20, y: 0 }, + b: { x: 20, y: 0 }, + r: 10, + }); + const turned = colliderShape( + { shape: 'capsule', width: 20, height: 60 }, + transformMatrix(0, 0, 90, 2, false, false), + ); + expect(turned.kind === 'capsule' && turned.a.x).toBeCloseTo(40, 9); + expect(turned.kind === 'capsule' && turned.r).toBe(20); + // As wide as tall: no segment left, a circle. + const round = colliderShape({ shape: 'capsule', width: 30, height: 30 }, translation(0, 0)); + expect(round).toMatchObject({ a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, r: 15 }); + expect(shapeArea(round)).toBeCloseTo(Math.PI * 225, 9); + }); + + it('a polygon keeps its corners in its own units: moved, sized, turned and mirrored with the object', () => { + const props = { shape: 'polygon', points: P(0, -10, 10, 10, -10, 10) }; + expect(colliderShape(props, translation(100, 0))).toMatchObject({ + outline: P(100, -10, 110, 10, 90, 10), + pieces: [P(100, -10, 110, 10, 90, 10)], + }); + const mirrored = colliderShape( + { shape: 'polygon', points: P(0, 0, 10, 0, 0, 10) }, + transformMatrix(0, 0, 0, 2, true, false), + ); + expect(mirrored.kind === 'polygon' && mirrored.outline).toEqual(P(0, 0, -20, 0, 0, 20)); + expect(colliderSize(props)).toEqual({ w: 20, h: 20 }); + // Without points of its own it is the default triangle. + expect(colliderShape({ shape: 'polygon' }, translation(0, 0))).toMatchObject({ + outline: P(0, -50, 50, 50, -50, 50), + }); + // One that crosses itself is drawn but has no pieces, so it meets nothing. + const bowTie = colliderShape( + { shape: 'polygon', points: P(0, 0, 10, 10, 10, 0, 0, 10) }, + translation(0, 0), + ); + expect(bowTie).toMatchObject({ pieces: [] }); + expect(shapesOverlap(bowTie, box(5, 5, 100, 100))).toBe(false); + expect(contactBetween(bowTie, box(5, 5, 100, 100), { x: 1, y: 0 }).separation).toBe(Infinity); + }); + + it('a square polygon meets boxes and circles exactly as the box it stands for', () => { + for (let round = 0; round < 400; round++) { + const [x, y, deg] = [random() * 160 - 80, random() * 160 - 80, random() * 360]; + const [w, h] = [20 + random() * 60, 20 + random() * 60]; + const other = random() < 0.5 ? box(0, 0, 50, 70, random() * 90) : circle(0, 0, 10 + random() * 40); + const classic = box(x, y, w, h, deg); + const polygon = boxAsPolygon(x, y, w, h, deg); + const travel = { x: random() - 0.5, y: random() - 0.5 }; + expect(shapesOverlap(polygon, other)).toBe(shapesOverlap(classic, other)); + const want = contactBetween(classic, other, travel); + const got = contactBetween(polygon, other, travel); + // Apart, box against box measures along its best axis rather than the real distance. + if (other.kind === 'circle' || want.separation <= 0) { + expect(got.separation).toBeCloseTo(want.separation, 9); + expect(got.normal.x).toBeCloseTo(want.normal.x, 9); + expect(got.normal.y).toBeCloseTo(want.normal.y, 9); + } + expect(exitDistance(polygon, other, travel)).toBeCloseTo(exitDistance(classic, other, travel), 7); + } + }); + + it('a capsule standing 10 above a floor, and sunk 5 into it: the gap, the normal and the way out', () => { + const floor = box(0, 100, 400, 40); // top face at 80 + const above = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 40)); // bottom at 70 + expect(contactBetween(above, floor, { x: 0, y: 1 })).toEqual({ separation: 10, normal: { x: 0, y: -1 } }); + const sunk = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 55)); + expect(shapesOverlap(sunk, floor)).toBe(true); + expect(contactBetween(sunk, floor, { x: 0, y: 1 }).separation).toBeCloseTo(-5, 12); + expect(exitDistance(sunk, floor, { x: 0, y: -1 })).toBeCloseTo(5, 9); + // On its side against a wall, the round end is what touches. + const side = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0)); + const wall = box(40, 0, 20, 200); // left face at 30 + expect(shapesOverlap(side, wall)).toBe(false); + expect(shapesOverlap(side, box(39, 0, 20, 200))).toBe(true); + expect(shapeContains(side, { x: 29, y: 0 })).toBe(true); + expect(shapeContains(side, { x: 29, y: 9 })).toBe(false); // outside the rounding + }); + + it('a circle in line with a capsule, beyond its end: apart by the real distance, not by the side axis', () => { + const capsule = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0)); // ends at ±30 + expect(contactBetween(circle(45, 0, 10), capsule, { x: -1, y: 0 })).toEqual({ + separation: 5, + normal: { x: 1, y: 0 }, + }); + expect(shapesOverlap(circle(45, 0, 10), capsule)).toBe(false); + expect(shapesOverlap(circle(35, 0, 10), capsule)).toBe(true); + // Two capsules end to end on one line. + const next = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(65, 0)); + expect(contactBetween(next, capsule, { x: -1, y: 0 }).separation).toBeCloseTo(5, 12); + }); + + it('a concave polygon: a box in the notch of a U touches nothing, one on its rim does', () => { + // A U 120 wide, 100 tall, walls 20 thick: the notch is 80 wide and 80 deep. + const u = colliderShape( + { shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) }, + translation(0, 0), + ); + expect(u.kind === 'polygon' && u.pieces.length).toBeGreaterThan(1); + expect(shapesOverlap(box(0, -10, 78, 78), u)).toBe(false); // inside the notch, 1 clear of every face + expect(shapesOverlap(box(0, -10, 82, 60), u)).toBe(true); // wider than the notch + // A box resting on the inner floor (top face at 30): touching, not overlapping; its way out is up. + const resting = box(0, 10, 40, 40); + expect(shapesOverlap(resting, u)).toBe(false); + expect(contactBetween(resting, u, { x: 0, y: 1 }).normal).toEqual({ x: 0, y: -1 }); + expect(exitDistance(box(0, 13, 40, 40), u, { x: 0, y: -1 })).toBeCloseTo(3, 9); + }); +}); + +describe('editing an outline by its corners', () => { + const square = P(0, 0, 100, 0, 100, 100, 0, 100); + + it('a corner wins over the edges through it; an edge gives the point on it', () => { + expect(gripAt(square, { x: 3, y: 2 }, 5)).toEqual({ kind: 'corner', index: 0 }); + expect(gripAt(square, { x: 50, y: 3 }, 5)).toEqual({ kind: 'edge', index: 0, at: { x: 50, y: 0 } }); + expect(gripAt(square, { x: 3, y: 50 }, 5)).toEqual({ kind: 'edge', index: 3, at: { x: 0, y: 50 } }); + expect(gripAt(square, { x: 50, y: 50 }, 5)).toBeNull(); + // Two corners are no outline yet: only the corners can be taken. + expect(gripAt(P(0, 0, 100, 0), { x: 50, y: 0 }, 5)).toBeNull(); + }); + + it('puts, moves and takes away corners; the last three stay unless told otherwise', () => { + expect(withCornerAfter(square, 3, { x: 0, y: 50 })).toEqual([...square, { x: 0, y: 50 }]); + expect(withCornerAfter(square, 0, { x: 50, y: 0 })[1]).toEqual({ x: 50, y: 0 }); + expect(withCornerMoved(square, 2, { x: 90, y: 80 })[2]).toEqual({ x: 90, y: 80 }); + expect(withoutCorner(square, 1)).toEqual(P(0, 0, 100, 100, 0, 100)); + expect(withoutCorner(P(0, 0, 1, 0, 0, 1), 0)).toBeNull(); + expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0)); + }); +}); diff --git a/tests/core/shapes.test.ts b/tests/core/shapes.test.ts new file mode 100644 index 00000000..8d0a0ae7 --- /dev/null +++ b/tests/core/shapes.test.ts @@ -0,0 +1,365 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; +import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands'; +import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { AddTilePictureCommand } from '../../src/core/commands/tileCommands'; +import { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands'; +import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model/Asset'; +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 { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; +import { solidCells, solidPolygons, tileOutline } 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'; +import { shapeOf, Solids } from '../../src/runtime/systems/solids'; +import { + COLLISION_SYSTEMS, + makeSpriteScene, + place, + roundTrip, + type SceneFixture, + startWorld, +} from '../helpers'; + +beforeAll(async () => { + await loadPhysics(); +}); + +const P = (...xy: number[]) => + Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! })); + +/** A scene of objects with one collider each; `add` sets the collider's properties and returns the ids. */ +function scene() { + const f = makeSpriteScene('Thing'); + const add = (at: Vec2, collider: Record, body?: Record) => { + const id = place(f, f.spriteId, at); + const command = new AddColliderCommand(f.sceneId, id); + f.stack.execute(command); + for (const [key, value] of Object.entries(collider)) + f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value)); + if (body) { + const rigidbody = new AddRigidbodyCommand(f.sceneId, id); + f.stack.execute(rigidbody); + for (const [key, value] of Object.entries(body)) + f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value)); + } + return { id, collider: command.objectId }; + }; + return { ...f, add }; +} + +function run(f: SceneFixture, physics: boolean) { + const started = startWorld(f, { physics, collisions: true }); + const entity = (id: string) => started.world.findByObjectId(id)!; + const colliderOf = (e: Entity) => started.world.childrenOf(e).find((c) => c.type === 'collider')!; + const move = (e: Entity, dx: number, dy: number) => + started.collisions.moveAndSlide(started.world, e, dx, dy); + const seconds = (s: number) => { + for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60); + }; + return { ...started, entity, colliderOf, move, seconds }; +} + +/** A ramp at 30°, falling to the right: its top face runs through the origin. */ +const TAN30 = Math.tan(Math.PI / 6); +const RAMP = P(-500, -500 * TAN30, 500, 500 * TAN30, 500, 400, -500, 400); + +describe.each(COLLISION_SYSTEMS)('$system: Move against the new shapes (#151)', ({ physics }) => { + it('a circle pressed onto a polygon ramp slides as far as friction lets it: g·sin − μ·g·cos', () => { + const sin = Math.sin(Math.PI / 6); + const cos = Math.cos(Math.PI / 6); + for (const friction of [0, 0.5, 0.6]) { + const s = scene(); + const ramp = s.add({ x: 0, y: 0 }, { shape: 'polygon', points: RAMP, friction }); + const ball = s.add({ x: 0, y: -100 }, { shape: 'circle', radius: 10, friction }); + const r = run(s, physics); + const mover = r.entity(ball.id); + for (let i = 0; i < 30; i++) r.move(mover, 0, 5); + const from = { x: mover.x, y: mover.y }; + const results = Array.from({ length: 10 }, () => r.move(mover, 0, 5)); + const along = 10 * Math.max(0, 5 * sin - friction * 5 * cos); + expect(mover.x - from.x, `friction ${friction}`).toBeCloseTo(along * cos, 6); + expect(mover.y - from.y, `friction ${friction}`).toBeCloseTo(along * sin, 6); + expect(results[0]!.normal.x).toBeCloseTo(sin, 9); + expect(results[0]!.normal.y).toBeCloseTo(-cos, 9); + const surface = shapeOf(r.colliderOf(r.entity(ramp.id))); + const gap = contactBetween(shapeOf(r.colliderOf(mover)), surface, { x: 0, y: 1 }).separation; + expect(Math.abs(gap)).toBeLessThan(1e-6); + } + }); + + it('a capsule runs into a box wall, stops flush and slides down it', () => { + const s = scene(); + s.add({ x: 100, y: 0 }, { width: 20, height: 400 }); // face at x = 90 + const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60, friction: 0 }); + const r = run(s, physics); + const mover = r.entity(hero.id); + const hit = r.move(mover, 100, 0); + expect(hit).toMatchObject({ hit: true, blockedX: true, blockedY: false }); + expect(hit.normal.x).toBeCloseTo(-1, 12); + expect(hit.normal.y).toBeCloseTo(0, 12); + expect(mover.x).toBeCloseTo(80, 9); + r.move(mover, 3, 4); // the wall's friction is the default 0.5, the capsule's 0: the pair has 0.25 + expect(mover.x).toBeCloseTo(80, 9); + expect(mover.y).toBeCloseTo(4 - 0.25 * 3, 9); + }); + + it('a box dropped into a concave U lands on its inner floor, clear of the walls', () => { + const s = scene(); + s.add( + { x: 0, y: 0 }, + { shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) }, + ); + const crate = s.add({ x: 0, y: -120 }, { width: 40, height: 40 }); + const r = run(s, physics); + const mover = r.entity(crate.id); + for (let i = 0; i < 40; i++) r.move(mover, 0, 5); + expect(mover.y).toBeCloseTo(10, 9); // bottom face on the inner floor at 30 + expect(mover.x).toBe(0); + }); +}); + +describe('the physics engine with the new shapes (#151)', () => { + it('a capsule falls onto a box floor and comes to rest standing on its round end', () => { + const s = scene(); + s.add({ x: 0, y: 200 }, { width: 400, height: 40 }); // top at 180 + const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60 }, { fixedRotation: true }); + const r = run(s, true); + r.seconds(3); + expect(r.entity(hero.id).y).toBeCloseTo(180 - 30, 0); + }); + + it('a crate falls into a concave U and rests on its inner floor – not on the hull over the notch', () => { + const s = scene(); + s.add( + { x: 0, y: 200 }, + { shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) }, + ); + const crate = s.add({ x: 0, y: 0 }, { width: 40, height: 40 }, { fixedRotation: true }); + const r = run(s, true); + r.seconds(3); + expect(r.entity(crate.id).y).toBeCloseTo(200 + 30 - 20, 0); + expect(Math.abs(r.entity(crate.id).x)).toBeLessThan(0.5); + }); + + it('a ball rolls down a polygon ramp; a polygon body falls and rests with its own mass', () => { + const s = scene(); + const ramp = s.add({ x: 0, y: 300 }, { shape: 'polygon', points: RAMP }); + const ball = s.add({ x: -100, y: 0 }, { shape: 'circle', radius: 10 }, {}); + const triangle = s.add({ x: 300, y: -200 }, { shape: 'polygon', points: P(0, -20, 20, 20, -20, 20) }, {}); + const r = run(s, true); + const start = r.entity(ball.id).x; + r.seconds(1); + expect(r.entity(ball.id).x).toBeGreaterThan(start + 50); // downhill is to the right + r.seconds(4); + // The triangle came to rest on the ramp, and did not sink into it. + const surface = shapeOf(r.colliderOf(r.entity(ramp.id))); + const resting = r.entity(triangle.id); + expect(resting.y).toBeGreaterThan(-100); // it fell + expect( + contactBetween(shapeOf(r.colliderOf(resting)), surface, { x: 0, y: 1 }).separation, + ).toBeGreaterThan(-1); + }); +}); + +/** + * A texture with one 32 × 32 frame, a plain tile showing it, a solid switch on that tile and a + * 12 × 4 map of 32 px cells at the origin – cell (x, y) spans x·32 − 192 … and y·32 − 64 …. + */ +function outlinedMap() { + const f = scene(); + const texture = new CreateAssetCommand('texture', 'Sheet'); + const tile = new CreateAssetCommand('tile', 'Slope'); + const plain = new CreateAssetCommand('tile', 'Wall'); + const map = new CreateAssetCommand('tilemap', 'Ground'); + for (const command of [texture, tile, plain, 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; + 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]) + 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)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32)); + const mapObject = place(f, map.asset.id, { x: 0, y: 0 }); + const mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset; + const paint = (cells: [number, number][], tileId = tile.asset.id, layer = 0) => + f.stack.execute( + new PaintTilesCommand( + map.asset.id, + mapAsset().layers[layer]!.id, + cells.map(([x, y]) => ({ x, y, tileId })), + ), + ); + const outline = (points: Vec2[]) => + f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points)); + const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset; + return { + ...f, + mapId: map.asset.id, + mapObject, + mapAsset, + paint, + outline, + tileAsset, + plainId: plain.asset.id, + textureId: texture.asset.id, + frameId: frame.frameId, + }; +} + +/** The lower right half of a cell: a slope rising to the right, as a frame outline in pixels. */ +const WEDGE = P(0, 32, 32, 0, 32, 32); + +describe('collision outlines of tiles (#141)', () => { + it('a frame keeps its outline through a round trip; broken corners and short outlines fall away', () => { + const m = outlinedMap(); + 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); + frameOf(m.project).collision = [ + { x: 1, y: 1 }, + { x: Number.NaN, y: 0 }, + { x: 3, y: 3 }, + ] as Vec2[]; + 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); + }); + + it('a plain tile blocks with its first picture’s outline; an autotile, or an outline that is none, as a box', () => { + const m = outlinedMap(); + expect(tileOutline(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)], + }); + m.outline(P(0, 0, 32, 32, 32, 0, 0, 32)); // a bow tie + expect(tileOutline(m.project, m.tileAsset())).toBeNull(); + m.outline(WEDGE); + m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor')); + expect(tileOutline(m.project, m.tileAsset())).toBeNull(); + }); + + it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => { + const m = outlinedMap(); + m.outline(WEDGE); + m.paint([ + [2, 3], + [3, 3], + ]); + m.paint([[5, 3]], m.plainId); + const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 }; + expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 5, y: 3, scale: 1 }]); + expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([ + P(2, 4, 3, 3, 3, 4), + P(3, 4, 4, 3, 4, 4), + ]); + m.stack.execute(new AddTilemapLayerCommand(m.mapId)); + m.paint([[2, 3]], m.plainId, 1); + expect(solidPolygons(m.project, props, m.mapAsset())).toHaveLength(1); + expect(solidCells(m.project, props, m.mapAsset())).toHaveLength(2); + // The collision scale draws the outline in about the cell's centre (#140). + m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_COLLISION_SCALE_KEY, 0.5)); + expect(solidPolygons(m.project, props, m.mapAsset())[0]!.outline).toEqual( + P(3.25, 3.75, 3.75, 3.25, 3.75, 3.75), + ); + }); + + describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { + it('a crate on a floor of outlined cells slides over every seam', () => { + const m = outlinedMap(); + m.outline(P(0, 0, 32, 0, 32, 32, 0, 32)); // every cell outlined as a whole square: twelve polygons + m.paint(Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number])); // top face at y = 32 + const crate = m.add({ x: -180, 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(32 - 15, 9); + expect(step.blockedX).toBe(false); + } + expect(mover.x).toBeGreaterThan(-180 + 5 * 32); + }); + + it('a crate walking along the floor into the foot of a wedge tile climbs it instead of stopping there', () => { + const m = outlinedMap(); + m.outline(WEDGE); + m.paint([[6, 2]]); // x 0 … 32, its foot on the floor at (0, 32), its top at (32, 0) + m.paint( + Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]), + m.plainId, + ); + const crate = m.add({ x: -100, y: 22 }, { width: 20, height: 20, friction: 0 }); + const r = run(m, physics); + const mover = r.entity(crate.id); + let highest = mover.y; + for (let i = 0; i < 300; i++) { + r.move(mover, 2, 0.6); + highest = Math.min(highest, mover.y); + } + // At the foot the crate's corner meets the wedge's corner: the slope, not a wall, is the contact. + // Its bottom reached the wedge's top at y = 0; the rest of that frame's slide ran on up-slope. + expect(highest).toBeLessThanOrEqual(-10 + 1e-9); + expect(highest).toBeGreaterThan(-10 - 0.5); + expect(mover.x).toBeGreaterThan(45); // and it went on past the wedge's right side at x = 32 + }); + + it('a ball rolls up and over a wedge tile instead of stopping at it', () => { + const m = outlinedMap(); + m.outline(WEDGE); + m.paint([[6, 2]]); // x 0 … 32, rising from the floor at its left to its top at its right + m.paint( + Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]), + m.plainId, + ); // floor top at 32 + const ball = m.add({ x: -100, y: 22 }, { shape: 'circle', radius: 10, friction: 0 }); + const r = run(m, physics); + const mover = r.entity(ball.id); + let highest = mover.y; + for (let i = 0; i < 200; i++) { + r.move(mover, 3, 1); + highest = Math.min(highest, mover.y); + } + expect(highest).toBeLessThan(0); // it climbed to the wedge's top at y = 0 + expect(mover.x).toBeGreaterThan(60); // and went on past its right side at x = 32 + const map = r.world.entities.find((e) => e.type === 'tilemap')!; + const own = shapeOf(r.colliderOf(mover)); + expect(new Solids().shapesOf(r.world, map).some((shape) => shapesOverlap(own, shape))).toBe(false); + }); + }); +}); + +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(); + m.outline(P(0, 0, 32, 32, 0, 32)); // falling to the right + m.paint([[5, 2]]); // x −32 … 0: from (−32, 0) down to (0, 32) + m.paint( + Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]), + m.plainId, + ); + const crate = m.add({ x: -26, y: -30 }, { width: 10, height: 10, friction: 0 }, {}); + const r = run(m, true); + r.seconds(3); + expect(r.entity(crate.id).x).toBeGreaterThan(5); // off the wedge's foot at x = 0 + expect(r.entity(crate.id).y).toBeCloseTo(32 - 5, 0); // on the floor + }); +}); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs new file mode 100644 index 00000000..8aabb681 --- /dev/null +++ b/tools/e2e/42-shapes.mjs @@ -0,0 +1,315 @@ +import { edge, node } from '../lib/builders.mjs'; +import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; + +/** + * Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with + * (#141). What only a running editor can show: the corners of a polygon collider are edited on + * its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab + * draws a frame's outline with clicks, and a painted map blocks with that outline in the game. + */ + +/** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */ +const PASTE_PICTURE = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = 32; + canvas.height = 32; + const g = canvas.getContext('2d'); + g.fillStyle = '#3c9a4a'; g.fillRect(0, 0, 32, 32); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'wedge.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; + +/** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */ +const climbGraph = { + nodes: [ + node('sc-update', 'OnUpdate', 0, 0), + node('sc-owner', 'Owner', 0, 160), + node('sc-move', 'Move', 300, 0, { dx: 1, dy: 0.3, scale: 120, collide: true }), + ], + edges: [ + edge('sc-e1', 'sc-update', 'exec', 'sc-move', 'exec'), + edge('sc-e2', 'sc-update', 'dt', 'sc-move', 'dt'), + edge('sc-e3', 'sc-owner', 'object', 'sc-move', 'object'), + ], +}; + +const TRIANGLE = [ + { x: 0, y: -80 }, + { x: 80, y: 80 }, + { x: -80, y: 80 }, +]; +const TAN30 = Math.tan(Math.PI / 6); +/** A ramp falling right at 30°; its top face runs through the object's origin. */ +const RAMP = [ + { x: -200, y: -200 * TAN30 }, + { x: 200, y: 200 * TAN30 }, + { x: 200, y: 150 }, + { x: -200, y: 150 }, +]; + +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 Y = LANES.shapes; + const put = ` + const put = (id, assetId, parentId, x, y, name, overrides = {}) => { + const o = make(id, assetId, parentId, y, name); + o.position.x = x; + o.overrides = overrides; + scene.objects.push(o); + };`; + + // 42a. A polygon collider on a crate, selected: a handle on each corner. A corner is dragged, a + // press on an edge puts a corner there, a double-click takes it away – one undo step each. + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + ${MAKE_OBJECT} + ${put} + assets.push({ id: 'sh-col-poly', type: 'collider', name: 'sh-col-poly', + props: { shape: 'polygon', points: ${JSON.stringify(TRIANGLE)}, isTrigger: false } }); + put('sh-poly', 'crate', '', 0, ${Y}, 'Polygon crate', { width: 160, height: 160 }); + put('sh-poly-col', 'sh-col-poly', 'sh-poly', 0, 0, 'Polygon collider'); + __gm.state.select(null)`); + await evaluate(`__gm.lookAt(0, ${Y})`); + await select({ kind: 'object', holderId: 'scene1', objectId: 'sh-poly-col', objectIds: ['sh-poly-col'] }); + await settle(); + const corners = async () => (await json('__gm.polygonCorners()'))?.corners ?? []; + const points = async () => (await object('sh-poly-col'))?.overrides?.points; + const start = await corners(); + check( + 'a selected polygon collider shows a handle on each of its corners', + start.length === 3 && start[0].y < start[1].y && start[1].x > start[2].x, + JSON.stringify(start), + ); + const zoom = distance(start[0], start[1]) / distance(TRIANGLE[0], TRIANGLE[1]); + await drag(start[1].x, start[1].y, start[1].x + 40, start[1].y); + const dragged = await points(); + check( + "dragging a corner moves it in the collider's own units, and one undo puts it back", + dragged?.length === 3 && + Math.abs(dragged[1].x - (80 + 40 / zoom)) <= 1 && + dragged[1].y === 80 && + (await undo(), (await points()) === undefined), + JSON.stringify({ dragged, zoom }), + ); + const [a, b] = await corners(); + await click((a.x + b.x) / 2, (a.y + b.y) / 2); + const inserted = await points(); + check( + 'a press on an edge puts a corner there', + inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1, + JSON.stringify(inserted), + ); + await shot('42-polygon-corners'); + const fourth = (await corners())[1]; + await doubleClick(fourth.x, fourth.y); + check( + 'a double-click on a corner takes it away again', + JSON.stringify(await points()) === JSON.stringify(TRIANGLE), + JSON.stringify(await points()), + ); + const clearShapes = () => + mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('sh-')); + p.assets = p.assets.filter((a) => !a.id.startsWith('sh-')); + __gm.state.select(null)`); + await clearShapes(); + + // Every step clears its objects, so each has the lane to itself. + const RAMP_Y = Y + 100; + + // 42b. The engine with the new shapes: a capsule with a rigidbody (no turning) falls onto a + // polygon ramp and slides down it, on the surface and never into it. + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + ${MAKE_OBJECT} + ${put} + assets.push({ id: 'sh-col-ramp', type: 'collider', name: 'sh-col-ramp', + props: { shape: 'polygon', points: ${JSON.stringify(RAMP)}, isTrigger: false } }); + assets.push({ id: 'sh-col-capsule', type: 'collider', name: 'sh-col-capsule', + props: { shape: 'capsule', width: 20, height: 40, friction: 0, isTrigger: false } }); + assets.push({ id: 'sh-rb', type: 'rigidbody', name: 'sh-rb', + props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } }); + put('sh-ramp', 'crate', '', 0, ${RAMP_Y}, 'Ramp', { width: 20, height: 20 }); + put('sh-ramp-col', 'sh-col-ramp', 'sh-ramp', 0, 0); + put('sh-capsule', 'crate', '', -150, ${RAMP_Y - 200}, 'Capsule', { width: 20, height: 40 }); + put('sh-capsule-col', 'sh-col-capsule', 'sh-capsule', 0, 0); + put('sh-capsule-rb', 'sh-rb', 'sh-capsule', 0, 0); + __gm.state.select(null)`); + await evaluate(`__gm.lookAt(0, ${RAMP_Y - 100})`); + await settle(); + await shot('42-capsule-ramp'); + await play(); + await evaluate(`(() => { + const out = (window.__shapeSamples = []); + const begin = performance.now(); + const sample = () => { + const w = __gm.world(); + if (!w) return; + const e = w.entities.find((x) => x.objectId === 'sh-capsule'); + out.push({ x: e?.worldX, y: e?.worldY }); + if (performance.now() - begin < 2500) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })()`); + await sleep(2800); + const samples = await json('window.__shapeSamples'); + await stop(); + // The lower round end's centre is 10 above the capsule's centre; its distance to the ramp's face + // (through (0, RAMP_Y), normal (sin 30°, −cos 30°)) is what keeps it on the surface. + const n = { x: 0.5, y: -Math.cos(Math.PI / 6) }; + const gap = (s) => (s.x - 0) * n.x + (s.y + 10 - RAMP_Y) * n.y - 10; + // Only while it is over the ramp, which ends at x = 200: past it the capsule falls free. + const landed = samples.filter((s) => s.x < 190 && gap(s) < 1); + check( + 'a capsule falls onto a polygon ramp and slides down it on the surface', + samples.length > 60 && + landed.length > 30 && + Math.max(...landed.map((s) => s.x)) > -150 + 60 && + // The landing sinks it in a little (measured 0.9 at ~6 a frame); the engine pushes it back out. + landed.every((s) => gap(s) > -2) && + landed.slice(30).every((s) => Math.abs(gap(s)) < 0.1), + JSON.stringify({ + frames: samples.length, + landed: landed.length, + furthest: Math.max(...landed.map((s) => s.x)), + deepest: Math.min(...landed.map(gap)), + settled: Math.max(...landed.slice(30).map((s) => Math.abs(gap(s)))), + }), + ); + await clearShapes(); + + // 42c. The Texture tab draws a frame's collision outline (#141): with "Collision" on, three + // clicks in the selected frame are three corners; a corner drags; undo takes the drag back. + await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); + await evaluate(PASTE_PICTURE); + await sleep(2500); + const texture = await json( + "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" + + ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', + ); + check( + 'the picture is imported at its own size', + texture?.w === 32 && texture?.h === 32, + JSON.stringify(texture), + ); + await mutate(` + const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}); + texture.frames.push({ id: 'sh-frame', name: 'Wedge', x: 0, y: 0, w: 32, h: 32 });`); + await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' }); + await showTab('texture'); + await settle(); + await clickText('Collision'); + const outline = () => + json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)})` + + `.frames.find((f) => f.id === 'sh-frame').collision ?? null`, + ); + for (const [x, y] of [ + [0, 32], + [32, 0], + [32, 32], + ]) { + const at = await json(`__gm.texture.pointAt(${x}, ${y})`); + await click(at.x, at.y); + } + const drawn = await outline(); + check( + 'with Collision on, clicks in the selected frame draw its outline corner by corner', + (await json('__gm.texture.collisionMode()')) === true && + JSON.stringify(drawn) === + JSON.stringify([ + { x: 0, y: 32 }, + { x: 32, y: 0 }, + { x: 32, y: 32 }, + ]), + JSON.stringify(drawn), + ); + const from = await json('__gm.texture.pointAt(32, 0)'); + const to = await json('__gm.texture.pointAt(16, 0)'); + await drag(from.x, from.y, to.x, to.y); + const moved = await outline(); + await shot('42-texture-outline'); + 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, + JSON.stringify({ moved, after: await outline() }), + ); + await clickText('Collision'); + await showTab('scene'); + await settle(); + + // 42d. A plain tile of that frame blocks with the outline: a wedge on a floor of plain solid + // tiles. The scene view outlines it, and a walker whose Move collides climbs over it. + const MAP_Y = Y; // cell (x, y) spans x·32 − 192 … and y·32 − 64 + MAP_Y …; the floor's top is MAP_Y + 32 + const floor = Object.fromEntries(Array.from({ length: 12 }, (_, x) => [`${x},3`, 'sh-wall'])); + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + ${MAKE_OBJECT} + ${put} + const behavior = structuredClone(assets.find((a) => a.id === 'mover')); + Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} }); + assets.push(behavior); + assets.push({ id: 'sh-wedge', type: 'tile', name: 'Wedge', props: { kind: 'none', solid: true }, pictures: ['sh-frame'] }); + assets.push({ id: 'sh-wall', type: 'tile', name: 'Floor', props: { kind: 'none', solid: true }, pictures: [] }); + assets.push({ id: 'sh-map', type: 'tilemap', name: 'Wedge map', props: { columns: 12, rows: 4, tileSize: 32 }, + layers: [{ id: 'sh-layer', name: 'Layer 1', cells: { ...${JSON.stringify(floor)}, '6,2': 'sh-wedge' } }] }); + assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker', + props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } }); + put('sh-map-obj', 'sh-map', '', 0, ${MAP_Y}, 'Wedge map'); + put('sh-walker', 'crate', '', -100, ${MAP_Y + 22}, 'Walker', { width: 20, height: 20 }); + put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0); + put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb'); + __gm.state.select(null)`); + await evaluate(`__gm.lookAt(0, ${MAP_Y})`); + await settle(); + const solids = await json(`__gm.tilemap.solids('sh-map-obj')`); + check( + 'the scene view outlines the wedge cell with its outline, the floor as one rectangle', + solids?.visible === true && + JSON.stringify(solids.rects) === JSON.stringify([{ x: 0, y: 3, w: 12, h: 1 }]) && + JSON.stringify(solids.outlines) === + JSON.stringify([ + [ + { x: 6, y: 3 }, + { x: 7, y: 2 }, + { x: 7, y: 3 }, + ], + ]), + JSON.stringify(solids), + ); + await shot('42-wedge-map'); + await play(); + await evaluate(`(() => { + const out = (window.__climb = []); + const begin = performance.now(); + const sample = () => { + const w = __gm.world(); + if (!w) return; + const e = w.entities.find((x) => x.objectId === 'sh-walker'); + out.push({ x: e?.worldX, y: e?.worldY }); + if (performance.now() - begin < 4500) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })()`); + await sleep(4800); + const climb = await json('window.__climb'); + await stop(); + const highest = Math.min(...climb.map((s) => s.y)); + check( + "a walker whose Move collides climbs the wedge tile's slope and goes on past it", + climb.length > 100 && highest < MAP_Y - 10 + 1 && highest > MAP_Y - 10 - 3 && climb.at(-1).x > 45, + JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }), + ); + await clearShapes(); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index b68285ec..46c0bf11 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -44,6 +44,7 @@ import { plugins } from './38-plugins.mjs'; import { tile } from './39-tile.mjs'; import { tilemap } from './40-tilemap.mjs'; import { physics } from './41-physics.mjs'; +import { shapes } from './42-shapes.mjs'; export const STEPS = [ hierarchy, @@ -87,4 +88,5 @@ export const STEPS = [ tile, tilemap, physics, + shapes, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index cf5952f1..f7c1b175 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -22,6 +22,7 @@ export const LANES = { sound: -2700, tilemap: -3100, physics: -3500, + shapes: -3900, }; /**