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

+140
View File
@@ -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++) {
+17 -1
View File
@@ -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`).