diff --git a/README.md b/README.md index 8009c1f5..b4ae2957 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, 1073 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1074 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/model/polygon.ts b/src/core/model/polygon.ts index a153fa89..b7ee7c68 100644 --- a/src/core/model/polygon.ts +++ b/src/core/model/polygon.ts @@ -214,6 +214,146 @@ function at(a: number, b: number, t: number): number { return a + (b - a) * t; } +/** A line of an outline to draw: its points, and whether it comes back round to the first. */ +export interface BoundaryLine { + points: Vec2[]; + closed: boolean; +} + +/** Lines closer than this in angle and offset are one line, and points closer than this one point. */ +const SAME = 1e-7; + +/** An edge on its line: `u` the line's direction, `c` its offset, `sign` +1 along `u` and -1 against it. */ +interface LineEdge { + angle: number; + c: number; + u: Vec2; + a: Vec2; + b: Vec2; + sign: number; +} + +/** + * The outer edges of rings lying side by side (#173), for drawing. On every line the edges along it + * are counted with their direction, all rings turned the same way first: where two rings meet, the + * shared stretch runs once each way and adds up to nothing, so it is left out – and so is a stretch + * a ring runs down and back up, which is what `clipToRect` leaves where it joins a concave cut. A + * stretch two rings share the same way is drawn once. Rings that overlap keep the edges lying inside + * each other. What is left is chained into lines, closed where they come back round. + */ +export function boundaryOf(rings: readonly (readonly Vec2[])[]): BoundaryLine[] { + const edges: LineEdge[] = []; + for (const ring of rings) { + const area = signedArea(ring); + if (Math.abs(area) <= EPSILON) continue; + const turned = area > 0 ? ring : [...ring].reverse(); + for (let i = 0; i < turned.length; i++) { + const a = turned[i]!; + const b = turned[(i + 1) % turned.length]!; + const length = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); + if (length <= SAME) continue; + let u = { x: (b.x - a.x) / length, y: (b.y - a.y) / length }; + // One direction per line, so both ways along it land together. + const sign = u.x < -EPSILON || (Math.abs(u.x) <= EPSILON && u.y < 0) ? -1 : 1; + if (sign < 0) u = { x: -u.x, y: -u.y }; + edges.push({ angle: Math.atan2(u.y, u.x), c: u.x * a.y - u.y * a.x, u, a, b, sign }); + } + } + // Grouped by angle first and then by offset, each within `SAME` of its neighbour: sorting by both + // at once would let a hair's difference in angle split a line. + const segments: [Vec2, Vec2][] = []; + edges.sort((p, q) => p.angle - q.angle); + for (let start = 0, i = 1; i <= edges.length; i++) { + if (i < edges.length && edges[i]!.angle - edges[i - 1]!.angle <= SAME) continue; + const bundle = edges.slice(start, i).sort((p, q) => p.c - q.c); + for (let from = 0, j = 1; j <= bundle.length; j++) { + if (j < bundle.length && bundle[j]!.c - bundle[j - 1]!.c <= SAME) continue; + uncovered(bundle.slice(from, j), segments); + from = j; + } + start = i; + } + return chain(segments); +} + +/** Adds the stretches of one line where the edges along it do not add up to nothing, each the way the count runs. */ +function uncovered(line: LineEdge[], out: [Vec2, Vec2][]): void { + const u = line[0]!.u; + const events: { s: number; p: Vec2; delta: number }[] = []; + for (const edge of line) { + const from = edge.sign > 0 ? edge.a : edge.b; + const to = edge.sign > 0 ? edge.b : edge.a; + events.push( + { s: from.x * u.x + from.y * u.y, p: from, delta: edge.sign }, + { s: to.x * u.x + to.y * u.y, p: to, delta: -edge.sign }, + ); + } + events.sort((p, q) => p.s - q.s); + // Events within `SAME` of each other are one stop, at the point of the first. + let count = 0; + let stop = events[0]!; + for (let i = 0; i < events.length; i++) { + const e = events[i]!; + if (e.s - events[Math.max(i - 1, 0)]!.s > SAME) { + if (count > 0) out.push([stop.p, e.p]); + else if (count < 0) out.push([e.p, stop.p]); + stop = e; + } + count += e.delta; + } +} + +/** The segments joined end to start into lines; a line that meets its first point again is closed. */ +function chain(segments: [Vec2, Vec2][]): BoundaryLine[] { + const cell = (v: number) => Math.round(v / (SAME * 10)); + const byStart = new Map(); + const byEnd = new Map(); + const file = (map: Map, p: Vec2, i: number) => { + const key = `${cell(p.x)},${cell(p.y)}`; + const list = map.get(key); + if (list) list.push(i); + else map.set(key, [i]); + }; + segments.forEach(([a, b], i) => { + file(byStart, a, i); + file(byEnd, b, i); + }); + const used = segments.map(() => false); + const near = (p: Vec2, q: Vec2) => (p.x - q.x) ** 2 + (p.y - q.y) ** 2 <= SAME * SAME; + const find = (map: Map, p: Vec2, end: 0 | 1): number => { + for (let dx = -1; dx <= 1; dx++) + for (let dy = -1; dy <= 1; dy++) + for (const i of map.get(`${cell(p.x) + dx},${cell(p.y) + dy}`) ?? []) + if (!used[i] && near(segments[i]![end], p)) return i; + return -1; + }; + const lines: BoundaryLine[] = []; + for (let first = 0; first < segments.length; first++) { + if (used[first]) continue; + used[first] = true; + const points = [...segments[first]!]; + for ( + let i = find(byStart, points[points.length - 1]!, 0); + i >= 0; + i = find(byStart, points[points.length - 1]!, 0) + ) { + used[i] = true; + points.push(segments[i]![1]); + } + if (points.length > 2 && near(points[0]!, points[points.length - 1]!)) { + points.pop(); + lines.push({ points: cleanOutline(points), closed: true }); + continue; + } + for (let i = find(byEnd, points[0]!, 1); i >= 0; i = find(byEnd, points[0]!, 1)) { + used[i] = true; + points.unshift(segments[i]![0]); + } + lines.push({ points, closed: false }); + } + return lines; +} + /** Two pieces (index rings of the same winding) joined across the edge they share, if they share one. */ function joinAlongSharedEdge(a: number[], b: number[]): number[] | null { for (let i = 0; i < a.length; i++) { diff --git a/src/core/model/tilemapCollision.ts b/src/core/model/tilemapCollision.ts index 520e589f..24dc2f1f 100644 --- a/src/core/model/tilemapCollision.ts +++ b/src/core/model/tilemapCollision.ts @@ -24,7 +24,7 @@ import type { TileAsset, TilemapAsset } from './Asset'; import { tileQuadrants } from './autotile'; import { angleOf, apply, type Mat, scaleOf } from './matrix'; -import { clipToRect, convexPieces, signedArea } from './polygon'; +import { boundaryOf, type BoundaryLine, clipToRect, convexPieces, signedArea } from './polygon'; import type { PropertyBag, Vec2 } from './Properties'; import { findAssetOf, type Project } from './Project'; import { findFrame } from './texture'; @@ -375,6 +375,22 @@ export function solidRects( return mergeCells(solidCells(project, props, asset, oneWay)); } +/** + * The outline of where the map blocks, in cells, as the scene view draws it (#173): only the outer + * edges of the rectangles and outlined cells together, so the quarters an autotile is pieced from + * and the rectangles of a merged wall do not show as strokes across what blocks. Drawing only – + * collision keeps the pieces. + */ +export function solidBoundary(rects: readonly CellRect[], polygons: readonly CellPolygon[]): BoundaryLine[] { + const rings = rects.map(({ x, y, w, h }) => [ + { x, y }, + { x: x + w, y }, + { x: x + w, y: y + h }, + { x, y: y + h }, + ]); + return boundaryOf([...rings, ...polygons.map((polygon) => polygon.outline)]); +} + /** * One rectangle as it sits in the world: a box turning and growing with the placed map, the same * shape a collider on it would have (see `properties/collider.ts`). diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index ac336e17..00b99aef 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -41,7 +41,8 @@ import { tilemapRows, tilemapTileSize, } from '../../core/model/tilemap'; -import type { CellPolygon, CellRect } from '../../core/model/tilemapCollision'; +import type { BoundaryLine } from '../../core/model/polygon'; +import { type CellPolygon, type CellRect, solidBoundary } 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'; @@ -154,6 +155,28 @@ const LINK_COLORS: Partial> = { rigidbody: Theme.colors.collider, }; +/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */ +type TilemapSolids = Pick, 'solids' | 'outlines' | 'oneWay'>; + +function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean { + const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) => + p.length === q.length && + p.every((r, i) => r.x === q[i]!.x && r.y === q[i]!.y && r.w === q[i]!.w && r.h === q[i]!.h); + const samePolygons = (p: readonly CellPolygon[], q: readonly CellPolygon[]) => + p.length === q.length && + p.every( + (polygon, i) => + polygon.outline.length === q[i]!.outline.length && + polygon.outline.every((c, j) => c.x === q[i]!.outline[j]!.x && c.y === q[i]!.outline[j]!.y), + ); + return ( + sameRects(a.solids, b.solids) && + samePolygons(a.outlines, b.outlines) && + sameRects(a.oneWay.solids, b.oneWay.solids) && + samePolygons(a.oneWay.outlines, b.oneWay.outlines) + ); +} + export class SceneViewport extends FreeViewport { private readonly grid = new Graphics(); private readonly links = new Graphics(); @@ -162,6 +185,14 @@ export class SceneViewport extends FreeViewport { private readonly icons = new Map(); /** Where each placed tilemap blocks, outlined like a collider (#132). */ private readonly solids = new Map(); + /** + * The outer edges of each tilemap's solids and what they were worked out from (#173). A map is + * resolved afresh on every draw, so they are kept by what blocks rather than by the resolved map. + */ + private readonly boundaries = new Map< + string, + { from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] } + >(); private editorWorld: World | null = null; private lastRays: CastRay[] = []; private dragging: ObjectDrag | null = null; @@ -752,15 +783,17 @@ export class SceneViewport extends FreeViewport { this.icons.delete(id); this.solids.get(id)?.destroy(); this.solids.delete(id); + this.boundaries.delete(id); } - /** The blocking rectangles and outlined cells of a placed tilemap as outlined, in cells (test hook). */ + /** The blocking rectangles and outlined cells of a placed tilemap, and the outer edges drawn round them, in cells (test hook). */ solidInfo(id: string): { visible: boolean; rects: CellRect[]; outlines: Vec2[][]; oneWay: CellRect[]; oneWayOutlines: Vec2[][]; + boundary: BoundaryLine[]; } | null { const node = this.solids.get(id); const item = this.lastItems.find((i) => i.id === id); @@ -771,9 +804,27 @@ export class SceneViewport extends FreeViewport { outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))), oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })), oneWayOutlines: item.tilemap.oneWay.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))), + boundary: this.boundaryOf(item).solid.map((line) => ({ + points: line.points.map((p) => ({ ...p })), + closed: line.closed, + })), }; } + private boundaryOf(item: DrawItem): { solid: BoundaryLine[]; oneWay: BoundaryLine[] } { + const from: TilemapSolids = item.tilemap!; + let boundary = this.boundaries.get(item.id); + if (!boundary || !sameSolids(boundary.from, from)) { + boundary = { + from, + solid: solidBoundary(from.solids, from.outlines), + oneWay: solidBoundary(from.oneWay.solids, from.oneWay.outlines), + }; + this.boundaries.set(item.id, boundary); + } + return boundary; + } + /** * The solid cells of a tilemap, drawn the way a collider is: tinted and outlined, above the * content so a painted wall cannot hide them. Editor-only for the same reason as the icon: the @@ -800,7 +851,10 @@ export class SceneViewport extends FreeViewport { const width = 2 / (scale * scaleOf(item.m)); node.clear(); const color = Theme.colors.collider; - const trace = (rects: readonly CellRect[], outlines: readonly CellPolygon[]) => { + const boundary = this.boundaryOf(item); + // Filled piece by piece, but stroked only along the outer edges, so no seam shows (#173). + const fill = (rects: readonly CellRect[], outlines: readonly CellPolygon[], alpha: number) => { + if (rects.length + outlines.length === 0) return; for (const rect of rects) { const corner = cellOrigin(item.props, rect.x, rect.y); node.rect(corner.x, corner.y, rect.w * tile, rect.h * tile); @@ -810,11 +864,21 @@ export class SceneViewport extends FreeViewport { polygon.outline.map((p) => cellOrigin(item.props, p.x, p.y)), true, ); + node.fill({ color, alpha }); }; - trace(rects, outlines); - node.fill({ color, alpha: 0.12 }).stroke({ width, color }); - trace(oneWay.solids, oneWay.outlines); - node.fill({ color, alpha: 0.06 }).stroke({ width: width / 2, color }); + const stroke = (lines: readonly BoundaryLine[], lineWidth: number) => { + if (lines.length === 0) return; + for (const line of lines) + node.poly( + line.points.map((p) => cellOrigin(item.props, p.x, p.y)), + line.closed, + ); + node.stroke({ width: lineWidth, color }); + }; + fill(rects, outlines, 0.12); + stroke(boundary.solid, width); + fill(oneWay.solids, oneWay.outlines, 0.06); + stroke(boundary.oneWay, width / 2); // A platform's top is what blocks, so that is what stands out. for (const rect of oneWay.solids) { const a = cellOrigin(item.props, rect.x, rect.y); diff --git a/tests/core/polygon.test.ts b/tests/core/polygon.test.ts index 6768cf5a..733076ea 100644 --- a/tests/core/polygon.test.ts +++ b/tests/core/polygon.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { transformMatrix, translation } from '../../src/core/model/matrix'; import { + boundaryOf, cleanOutline, + clipToRect, convexPieces, isConvex, pointsOf, @@ -264,3 +266,68 @@ describe('editing an outline by its corners', () => { expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0)); }); }); + +/** A ring as a set of corners, whichever corner it starts at and whichever way it runs. */ +const corners = (ring: Vec2[]) => ring.map((p) => `${+p.x.toFixed(6)},${+p.y.toFixed(6)}`).sort(); + +describe('the outer edges of rings side by side (#173)', () => { + it('two squares side by side are one rectangle, whichever way each runs', () => { + const lines = boundaryOf([P(0, 0, 1, 0, 1, 1, 0, 1), P(1, 0, 1, 1, 2, 1, 2, 0)]); + expect(lines).toHaveLength(1); + expect(lines[0]!.closed).toBe(true); + expect(corners(lines[0]!.points)).toEqual(corners(P(0, 0, 2, 0, 2, 1, 0, 1))); + }); + + it('a long edge meeting several short ones drops only where they meet', () => { + // A 3-wide bar with two unit squares on top of its left two cells: an L lying down. + const lines = boundaryOf([ + P(0, 1, 3, 1, 3, 2, 0, 2), + P(0, 0, 1, 0, 1, 1, 0, 1), + P(1, 0, 2, 0, 2, 1, 1, 1), + ]); + expect(lines).toHaveLength(1); + expect(corners(lines[0]!.points)).toEqual(corners(P(0, 0, 2, 0, 2, 1, 3, 1, 3, 2, 0, 2))); + }); + + it('the same ring twice is drawn once', () => { + const lines = boundaryOf([P(0, 0, 1, 0, 1, 1, 0, 1), P(0, 0, 0, 1, 1, 1, 1, 0)]); + expect(lines).toHaveLength(1); + expect(corners(lines[0]!.points)).toEqual(corners(P(0, 0, 1, 0, 1, 1, 0, 1))); + }); + + it('rings apart, or overlapping, keep all their edges', () => { + expect(boundaryOf([P(0, 0, 1, 0, 1, 1, 0, 1), P(3, 0, 4, 0, 4, 1, 3, 1)])).toHaveLength(2); + const lines = boundaryOf([P(0, 0, 2, 0, 2, 2, 0, 2), P(1, 1, 3, 1, 3, 3, 1, 3)]); + expect(lines.map((line) => line.points.length)).toEqual([4, 4]); + }); + + it('the stretch where a clipped concave outline joins itself along the cut is left out', () => { + // A U cut below its bottom: two arms, joined by `clipToRect` along the top edge of the cut. + const cut = clipToRect(P(0, 0, 1, 0, 1, 2, 2, 2, 2, 0, 3, 0, 3, 3, 0, 3), -1, -1, 4, 1); + const lines = boundaryOf([cut]); + expect(lines.map((line) => line.closed)).toEqual([true, true]); + expect(lines.map((line) => corners(line.points)).sort()).toEqual( + [corners(P(0, 0, 1, 0, 1, 1, 0, 1)), corners(P(2, 0, 3, 0, 3, 1, 2, 1))].sort(), + ); + }); + + it('a star cut into convex pieces, or along a grid, comes back as its own outline', () => { + for (let n = 0; n < 50; n++) { + const star = cleanOutline(randomStar(6 + (n % 10))); + const pieces = convexPieces(star); + const cells: Vec2[][] = []; + for (let x = -100; x < 100; x += 25) + for (let y = -100; y < 100; y += 25) + for (const piece of pieces) { + const part = clipToRect(piece, x, y, x + 25, y + 25); + if (part.length > 0) cells.push(part); + } + for (const rings of [pieces, cells]) { + const lines = boundaryOf(rings); + expect(lines).toHaveLength(1); + expect(lines[0]!.closed).toBe(true); + expect(corners(lines[0]!.points)).toEqual(corners(star)); + } + } + }); +}); diff --git a/tests/core/shapes.test.ts b/tests/core/shapes.test.ts index b5f4451f..f39423a0 100644 --- a/tests/core/shapes.test.ts +++ b/tests/core/shapes.test.ts @@ -11,7 +11,13 @@ import { findAsset, type Project } from '../../src/core/model/Project'; import { TILE_COLLISION_SCALE_KEY, TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; import { insideOutline } from '../../src/editor/polygonEdit'; import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap'; -import { solidCells, solidPolygons, solidRects, tileShape } from '../../src/core/model/tilemapCollision'; +import { + solidBoundary, + solidCells, + solidPolygons, + solidRects, + tileShape, +} from '../../src/core/model/tilemapCollision'; import { contactBetween, shapesOverlap } from '../../src/core/properties/collider'; import type { Entity } from '../../src/runtime/Entity'; import { loadPhysics } from '../../src/runtime/physics/engine'; @@ -455,6 +461,58 @@ describe('collision outlines of autotiles (#141)', () => { expect(solidPolygons(m.project, props, m.mapAsset())).toEqual([]); }); + describe('as the scene view outlines it (#173)', () => { + const boundary = (m: ReturnType) => + solidBoundary( + solidRects(m.project, props, m.mapAsset()), + solidPolygons(m.project, props, m.mapAsset()), + ); + const sorted = (points: Vec2[]) => points.map((p) => `${p.x},${p.y}`).sort(); + + it('a lake of 3 × 3 cells is one closed outline, a quarter cell in all round', () => { + const m = outlinedMap(); + m.blockOutline(POND); + m.paint(cells(2, 0, 4, 2), m.lakeId); + // Drawn piece by piece it was the middle box and 32 quarter pieces. + expect(solidPolygons(m.project, props, m.mapAsset()).length).toBeGreaterThan(8); + const lines = boundary(m); + expect(lines).toHaveLength(1); + expect(lines[0]!.closed).toBe(true); + expect(sorted(lines[0]!.points)).toEqual(sorted(P(2.25, 0.25, 4.75, 0.25, 4.75, 2.75, 2.25, 2.75))); + }); + + it('a ring round an empty cell is two closed outlines, the hole a cross where the notches block whole', () => { + const m = outlinedMap(); + m.blockOutline(POND); + m.paint(cells(2, 0, 4, 2, '3,1'), m.lakeId); + const lines = boundary(m); + expect(lines.map((line) => line.closed)).toEqual([true, true]); + const hole = lines.find((line) => line.points.every((p) => p.x > 2.5 && p.x < 4.5))!; + expect(sorted(hole.points)).toEqual( + sorted( + P(3, 0.75, 4, 0.75, 4, 1, 4.25, 1, 4.25, 2, 4, 2, 4, 2.25, 3, 2.25, 3, 2, 2.75, 2, 2.75, 1, 3, 1), + ), + ); + }); + + it('a merged wall of plain boxes is one outline too, not its rectangles', () => { + const m = outlinedMap(); + m.paint( + [ + [0, 0], + [1, 0], + [2, 0], + [0, 1], + [0, 2], + ], + m.plainId, + ); // an L: three rectangles merged + const lines = boundary(m); + expect(lines).toHaveLength(1); + expect(sorted(lines[0]!.points)).toEqual(sorted(P(0, 0, 3, 0, 3, 1, 1, 1, 1, 3, 0, 3))); + }); + }); + describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => { it('a crate slides along the shore of a row of lake cells without catching on a quarter seam', () => { const m = outlinedMap(); diff --git a/tools/e2e/42-shapes.mjs b/tools/e2e/42-shapes.mjs index 442b9df7..c66c277e 100644 --- a/tools/e2e/42-shapes.mjs +++ b/tools/e2e/42-shapes.mjs @@ -320,6 +320,16 @@ export async function shapes(t) { ]), JSON.stringify(solids), ); + // Drawn along the outer edges only (#173): the wedge's foot on the floor is no stroke. + const cornersOf = (line) => (line?.points ?? []).map((p) => `${p.x},${p.y}`).sort(); + check( + 'the scene view draws the wedge and the floor as one closed outline, without the edge they share', + solids?.boundary?.length === 1 && + solids.boundary[0].closed === true && + JSON.stringify(cornersOf(solids.boundary[0])) === + JSON.stringify(['0,3', '0,4', '12,3', '12,4', '6,3', '7,2', '7,3'].sort()), + JSON.stringify(solids?.boundary), + ); await shot('42-wedge-map'); await play(); await evaluate(`(() => {