The scene view outlines a tilemap's collision only along its outer edges: where two pieces meet, their shared stretch runs once in each direction and is not drawn, so an autotile lake is one outline without cross strokes at the quarters, merged rectangles of plain tiles and one-way tiles follow the same rule, the bridges from splitting concave outlines disappear and a piece doubled on two layers is outlined once; fill and collision stay as they were, and the result is cached per tilemap and rebuilt only when its collision data changes (#173)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a31f5e0ad
commit
d4ea04d4d6
7 files changed
+365
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string, number[]>();
|
||||
const byEnd = new Map<string, number[]>();
|
||||
const file = (map: Map<string, number[]>, 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<string, number[]>, 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++) {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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<Record<DrawItem['type'], number>> = {
|
||||
rigidbody: Theme.colors.collider,
|
||||
};
|
||||
|
||||
/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */
|
||||
type TilemapSolids = Pick<NonNullable<DrawItem['tilemap']>, '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<string, Graphics>();
|
||||
/** Where each placed tilemap blocks, outlined like a collider (#132). */
|
||||
private readonly solids = new Map<string, Graphics>();
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof outlinedMap>) =>
|
||||
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();
|
||||
|
||||
@@ -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(`(() => {
|
||||
|
||||
Reference in new issue
Block a user