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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 12:54:53 +02:00
1 parent 6a31f5e0ad
commit d4ea04d4d6
7 files changed
+365 -10

No files matched your search

+67
View File
@@ -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));
}
}
});
});
+59 -1
View File
@@ -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();