Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
628 lines
24 KiB
TypeScript
628 lines
24 KiB
TypeScript
/**
|
||
* Where a placed tilemap blocks (#132). A cell blocks when the tile lying there is solid – on any
|
||
* layer, a hidden one too: hiding is how a map is looked at, and an invisible blocking layer is
|
||
* a common thing to want. Only cells inside the grid count, the same ones that are drawn.
|
||
*
|
||
* The blocking cells are merged into rectangles rather than handed on one box each, so a wall is
|
||
* one shape and the collision tests stay few – and so an object sliding along a wall does not
|
||
* catch on the seam between two of its cells.
|
||
*
|
||
* A tile's collision scale (#140) draws the box in or out, but only on the sides *without* a
|
||
* 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 tile whose picture is a frame with collision outlines (#141) blocks with those instead, as
|
||
* polygons, never merged: see `solidPolygons`. A plain tile lays its outlines over the cell. An
|
||
* autotile's cell is pieced together from four quarters of its block, picked by the neighbours – so
|
||
* its outlines are cut along the same quarters, and each cell takes the pieces of exactly the
|
||
* quarters it is drawn from (see `tileShape`).
|
||
*
|
||
* One-way tiles (#155) block only from above. They are worked out on their own, by the same rules –
|
||
* each function takes `oneWay` – so a platform never merges with a wall into one rectangle, nor with
|
||
* a platform of another angle (#159). The same holds for a tile's surface (#193): with a `material`,
|
||
* a function looks only at the tiles of that friction and bounciness.
|
||
*/
|
||
import type { TileAsset, TilemapAsset } from './Asset';
|
||
import { tileQuadrants } from './autotile';
|
||
import { angleOf, apply, invert, type Mat, scaleOf } from './matrix';
|
||
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';
|
||
import {
|
||
isTileOneWay,
|
||
isTileSolid,
|
||
readTile,
|
||
TILE_ONE_WAY_ANGLE_KEY,
|
||
tileCollisionScale,
|
||
tileKind,
|
||
type TileKind,
|
||
} from './tile';
|
||
import {
|
||
cellOrigin,
|
||
inGrid,
|
||
neighboursOf,
|
||
parseCellKey,
|
||
tilemapColumns,
|
||
tilemapRows,
|
||
tilemapSize,
|
||
tilemapTileSize,
|
||
} from './tilemap';
|
||
import { cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo';
|
||
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider';
|
||
import { colliderMaterial } from '../properties/rigidbody';
|
||
|
||
/** A rectangle in cells: top-left corner and size. Whole cells at collision scale 1, parts of one otherwise. */
|
||
export interface CellRect {
|
||
x: number;
|
||
y: number;
|
||
w: number;
|
||
h: number;
|
||
}
|
||
|
||
/** A blocking cell and the collision scale of its tile; left out, the scale is 1. */
|
||
export interface SolidCell {
|
||
x: number;
|
||
y: number;
|
||
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[][];
|
||
}
|
||
|
||
/** An outline and its convex pieces: 0 to 1 across a cell for a plain tile, across a quarter for an autotile. */
|
||
export interface TileOutline {
|
||
outline: Vec2[];
|
||
pieces: Vec2[][];
|
||
}
|
||
|
||
/** What one quarter of an autotile's block blocks with: all of it, or the parts of the outlines in it – maybe none. */
|
||
export type QuarterBlock = 'full' | TileOutline[];
|
||
|
||
/**
|
||
* How a solid tile with outlines blocks: a plain tile with its outlines across the cell, an autotile
|
||
* with a block of every quarter of its block, by `x,y` in quarters.
|
||
*/
|
||
export type TileShape =
|
||
| { kind: 'plain'; outlines: TileOutline[] }
|
||
| { kind: 'auto'; tileKind: Exclude<TileKind, 'none'>; quarters: Map<string, QuarterBlock> };
|
||
|
||
const SQUARE: TileOutline = {
|
||
outline: [
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 0 },
|
||
{ x: 1, y: 1 },
|
||
{ x: 0, y: 1 },
|
||
],
|
||
pieces: [
|
||
[
|
||
{ x: 0, y: 0 },
|
||
{ x: 1, y: 0 },
|
||
{ x: 1, y: 1 },
|
||
{ x: 0, y: 1 },
|
||
],
|
||
],
|
||
};
|
||
|
||
/** A share of a quarter's area this close to all of it is all of it. */
|
||
const FULL = 1 - 1e-9;
|
||
|
||
/** The outlines that are polygons (see `polygonProblem`), with their pieces; the others collide as none. */
|
||
function validOutlines(outlines: Vec2[][]): TileOutline[] {
|
||
return outlines
|
||
.map((outline) => ({ outline, pieces: convexPieces(outline) }))
|
||
.filter((outline) => outline.pieces.length > 0);
|
||
}
|
||
|
||
/**
|
||
* How the tile blocks, or null when it blocks as a box – when its first picture has no outline that
|
||
* is a polygon. Only the first picture counts: a blocking shape that twitched with the animation is
|
||
* rarely wanted.
|
||
*
|
||
* An autotile's outlines are drawn over its whole block and cut into its quarters, since a cell is
|
||
* drawn from four of them. Where a tile of the block has nothing drawn in it at all, its quarters
|
||
* block whole – so the notches of a floor nobody outlined leave no hole at the inner corners of a
|
||
* lake – but once anything is drawn in it, only what is drawn blocks, and a quarter can be left open.
|
||
* The collision scale does not apply here: the outline is the shape.
|
||
*/
|
||
export function tileShape(project: Project, tile: TileAsset): TileShape | 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 kind = tileKind(tile);
|
||
const quad = kind === 'none' ? 0 : readTile(project, tile).quad;
|
||
// Outlines are replaced, never edited in place, so the array stands for what is drawn.
|
||
let known = shapes.get(frame.collision);
|
||
if (!known) shapes.set(frame.collision, (known = new Map<string, TileShape | null>()));
|
||
const key = `${kind}|${quad}|${frame.w}|${frame.h}`;
|
||
if (!known.has(key)) known.set(key, workOutShape(frame.collision, frame.w, frame.h, kind, quad));
|
||
return known.get(key)!;
|
||
}
|
||
|
||
const shapes = new WeakMap<Vec2[][], Map<string, TileShape | null>>();
|
||
|
||
function workOutShape(
|
||
collision: Vec2[][],
|
||
w: number,
|
||
h: number,
|
||
kind: TileKind,
|
||
quad: number,
|
||
): TileShape | null {
|
||
if (kind === 'none') {
|
||
const across = collision.map((o) => o.map((p) => ({ x: p.x / w, y: p.y / h })));
|
||
const outlines = validOutlines(across);
|
||
return outlines.length > 0 ? { kind: 'plain', outlines } : null;
|
||
}
|
||
if (quad <= 0) return null;
|
||
const outlines = validOutlines(collision.map((o) => o.map((p) => ({ x: p.x / quad, y: p.y / quad }))));
|
||
if (outlines.length === 0) return null;
|
||
const columns = Math.round(w / quad);
|
||
const rows = Math.round(h / quad);
|
||
const quarters = new Map<string, QuarterBlock>();
|
||
for (let qy = 0; qy < rows; qy++)
|
||
for (let qx = 0; qx < columns; qx++) quarters.set(`${qx},${qy}`, quarterBlock(outlines, qx, qy));
|
||
for (let ty = 0; ty < rows; ty += 2)
|
||
for (let tx = 0; tx < columns; tx += 2) {
|
||
const keys = [`${tx},${ty}`, `${tx + 1},${ty}`, `${tx},${ty + 1}`, `${tx + 1},${ty + 1}`];
|
||
if (keys.every((key) => (quarters.get(key) as QuarterBlock).length === 0))
|
||
for (const key of keys) quarters.set(key, 'full');
|
||
}
|
||
return { kind: 'auto', tileKind: kind, quarters };
|
||
}
|
||
|
||
/** The parts of the outlines inside the quarter at (qx, qy), moved to 0 … 1 across it. */
|
||
function quarterBlock(outlines: readonly TileOutline[], qx: number, qy: number): QuarterBlock {
|
||
const local = (p: Vec2) => ({ x: p.x - qx, y: p.y - qy });
|
||
const parts: TileOutline[] = [];
|
||
for (const { outline, pieces } of outlines) {
|
||
const cut = pieces.map((piece) => clipToRect(piece, qx, qy, qx + 1, qy + 1)).filter((p) => p.length > 0);
|
||
if (cut.length === 0) continue;
|
||
// The pieces of one outline never overlap, so their areas add up to what it covers.
|
||
if (cut.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0) >= FULL) return 'full';
|
||
const drawn = clipToRect(outline, qx, qy, qx + 1, qy + 1);
|
||
parts.push({
|
||
outline: (drawn.length > 0 ? drawn : cut[0]!).map(local),
|
||
pieces: cut.map((p) => p.map(local)),
|
||
});
|
||
}
|
||
return parts;
|
||
}
|
||
|
||
/** What blocks in each cell: the largest box scale of the solid tiles blocking as boxes, and the outlines of the others, in cells. */
|
||
interface CellBlock {
|
||
x: number;
|
||
y: number;
|
||
box: number | null;
|
||
polygons: TileOutline[];
|
||
}
|
||
|
||
/** An outline of 0 to 1 across its square laid over the square of side `size` at (x, y). */
|
||
function placed(outline: TileOutline, x: number, y: number, size: number): TileOutline {
|
||
const place = (p: Vec2) => ({ x: x + p.x * size, y: y + p.y * size });
|
||
return { outline: outline.outline.map(place), pieces: outline.pieces.map((piece) => piece.map(place)) };
|
||
}
|
||
|
||
/**
|
||
* Which solid tiles a function looks at: false the ones blocking from everywhere, true every one-way
|
||
* tile, a number the one-way tiles of that angle (#159) – so tiles of two angles never merge.
|
||
*/
|
||
export type OneWayFilter = boolean | number;
|
||
|
||
/** The angle a one-way tile lands things within, held to 0 … 90°. */
|
||
function tileOneWayAngle(tile: TileAsset): number {
|
||
return oneWayAngle(tile.props[TILE_ONE_WAY_ANGLE_KEY]);
|
||
}
|
||
|
||
/** The surface of a solid tile (#193), read like a collider's. A map never moves, so no density. */
|
||
export interface TileMaterial {
|
||
friction: number;
|
||
bounciness: number;
|
||
}
|
||
|
||
export function tileMaterial(tile: TileAsset): TileMaterial {
|
||
const { friction, bounciness } = colliderMaterial(tile.props);
|
||
return { friction, bounciness };
|
||
}
|
||
|
||
function sameMaterial(a: TileMaterial, b: TileMaterial): boolean {
|
||
return a.friction === b.friction && a.bounciness === b.bounciness;
|
||
}
|
||
|
||
/**
|
||
* Which solid tiles a function looks at: those passing `oneWay`, and with a `material` only the
|
||
* tiles of that surface – so ice and rubber never merge into one rectangle (#193).
|
||
*/
|
||
function passesFilter(tile: TileAsset, oneWay: OneWayFilter, material?: TileMaterial): boolean {
|
||
if (material && !sameMaterial(tileMaterial(tile), material)) return false;
|
||
if (!isTileOneWay(tile)) return oneWay === false;
|
||
return oneWay === true || tileOneWayAngle(tile) === oneWay;
|
||
}
|
||
|
||
/** The solid tiles painted on the map, each once, in the order the layers first name them. */
|
||
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||
return [...ids].flatMap((id) => {
|
||
const tile = findAssetOf(project, id, 'tile');
|
||
return tile && isTileSolid(tile) ? [tile] : [];
|
||
});
|
||
}
|
||
|
||
/** The surfaces of the solid tiles painted on the map that pass `oneWay`, each once. */
|
||
export function tileMaterials(
|
||
project: Project,
|
||
asset: TilemapAsset,
|
||
oneWay: OneWayFilter = false,
|
||
): TileMaterial[] {
|
||
const materials: TileMaterial[] = [];
|
||
for (const tile of solidTiles(project, asset)) {
|
||
if (!passesFilter(tile, oneWay)) continue;
|
||
const material = tileMaterial(tile);
|
||
if (!materials.some((known) => sameMaterial(known, material))) materials.push(material);
|
||
}
|
||
return materials.sort((a, b) => a.friction - b.friction || a.bounciness - b.bounciness);
|
||
}
|
||
|
||
/** The angles of the solid one-way tiles painted on the map, each once, smallest first. */
|
||
export function oneWayAngles(project: Project, asset: TilemapAsset): number[] {
|
||
const angles = new Set(solidTiles(project, asset).filter(isTileOneWay).map(tileOneWayAngle));
|
||
return [...angles].sort((a, b) => a - b);
|
||
}
|
||
|
||
type BlockingTile = { scale: number; shape: TileShape | null } | null;
|
||
|
||
const blocking = new KeyedMemo<readonly CellBlock[]>();
|
||
|
||
/**
|
||
* Worked out once per content: the key holds the grid, the filter, the version of every layer's
|
||
* cells and how each tile painted there blocks, which is all the answer depends on.
|
||
*/
|
||
function blockingCells(
|
||
project: Project,
|
||
props: PropertyBag,
|
||
asset: TilemapAsset,
|
||
oneWay: OneWayFilter,
|
||
material?: TileMaterial,
|
||
): readonly CellBlock[] {
|
||
const tiles = new Map<string, BlockingTile>();
|
||
for (const layer of asset.layers)
|
||
for (const id of cellsTileIds(layer.cells)) {
|
||
if (tiles.has(id)) continue;
|
||
const tile = findAssetOf(project, id, 'tile');
|
||
tiles.set(
|
||
id,
|
||
tile && isTileSolid(tile) && passesFilter(tile, oneWay, material)
|
||
? { scale: tileCollisionScale(tile), shape: tileShape(project, tile) }
|
||
: null,
|
||
);
|
||
}
|
||
const key = [
|
||
tilemapColumns(props),
|
||
tilemapRows(props),
|
||
oneWay,
|
||
material ? `${material.friction}/${material.bounciness}` : '',
|
||
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
|
||
[...tiles]
|
||
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
|
||
.join(','),
|
||
].join('|');
|
||
return blocking.get(key, () => workOutBlockingCells(props, asset, tiles));
|
||
}
|
||
|
||
function workOutBlockingCells(
|
||
props: PropertyBag,
|
||
asset: TilemapAsset,
|
||
tiles: Map<string, BlockingTile>,
|
||
): CellBlock[] {
|
||
const tileOf = (id: string) => tiles.get(id) ?? null;
|
||
const found = new Map<string, CellBlock>();
|
||
for (const layer of asset.layers) {
|
||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||
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, polygons: [] }));
|
||
}
|
||
const { shape, scale } = tile;
|
||
if (!shape) {
|
||
cell.box = Math.max(cell.box ?? 0, scale);
|
||
} else if (shape.kind === 'plain') {
|
||
// The collision scale draws the outline in or out about the cell's centre (#140).
|
||
const at = 0.5 - scale / 2;
|
||
for (const outline of shape.outlines)
|
||
cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale));
|
||
} else {
|
||
const around = neighboursOf(props, layer, cell.x, cell.y, tileId);
|
||
const quarters = tileQuadrants(shape.tileKind, around).map(
|
||
(q) => shape.quarters.get(`${q.x},${q.y}`) ?? [],
|
||
);
|
||
if (quarters.every((q) => q === 'full')) {
|
||
cell.box = Math.max(cell.box ?? 0, 1);
|
||
continue;
|
||
}
|
||
for (const [corner, quarter] of quarters.entries()) {
|
||
const x = cell.x + (corner % 2) / 2;
|
||
const y = cell.y + Math.floor(corner / 2) / 2;
|
||
for (const outline of quarter === 'full' ? [SQUARE] : quarter)
|
||
cell.polygons.push(placed(outline, x, y, 0.5));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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. An autotile cell built only from quarters that block whole is a box too, at scale 1.
|
||
* A cell whose only solid tiles block with outlines is left to `solidPolygons`.
|
||
*/
|
||
export function solidCells(
|
||
project: Project,
|
||
props: PropertyBag,
|
||
asset: TilemapAsset,
|
||
oneWay: OneWayFilter = false,
|
||
material?: TileMaterial,
|
||
): Required<SolidCell>[] {
|
||
return boxCells(blockingCells(project, props, asset, oneWay, material));
|
||
}
|
||
|
||
function boxCells(cells: readonly CellBlock[]): Required<SolidCell>[] {
|
||
return cells.filter((cell) => cell.box !== null).map(({ x, y, box }) => ({ x, y, scale: box! }));
|
||
}
|
||
|
||
/**
|
||
* The polygons of the cells that block with outlines: for a plain tile one per outline, for an
|
||
* autotile one per part of an outline in each of its quarters, and a square for a quarter that
|
||
* blocks whole. A tile blocking as a box 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,
|
||
oneWay: OneWayFilter = false,
|
||
material?: TileMaterial,
|
||
): readonly CellPolygon[] {
|
||
return derived(polygonsOf, blockingCells(project, props, asset, oneWay, material), (cells) => {
|
||
const polygons: CellPolygon[] = [];
|
||
for (const cell of cells) {
|
||
if (cell.box !== null) continue;
|
||
for (const { outline, pieces } of cell.polygons)
|
||
polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||
}
|
||
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The same cells hand out the same rectangles and polygons, the very same arrays – so whoever builds
|
||
* on them can tell by identity that nothing changed (the physics does, every tick).
|
||
*/
|
||
function derived<T>(
|
||
held: WeakMap<readonly CellBlock[], T>,
|
||
cells: readonly CellBlock[],
|
||
make: (cells: readonly CellBlock[]) => T,
|
||
): T {
|
||
if (!held.has(cells)) held.set(cells, make(cells));
|
||
return held.get(cells)!;
|
||
}
|
||
|
||
const polygonsOf = new WeakMap<readonly CellBlock[], readonly CellPolygon[]>();
|
||
const rectsOf = new WeakMap<readonly CellBlock[], readonly CellRect[]>();
|
||
|
||
/** A rectangle by its four sides, which is what the merge compares. */
|
||
interface Piece {
|
||
x0: number;
|
||
x1: number;
|
||
y0: number;
|
||
y1: number;
|
||
}
|
||
|
||
/**
|
||
* The part of the map one cell blocks, as pieces. At scale 1 that is the cell. Below 1 the cell is
|
||
* cut at the inset `e` into a 3 × 3 grid: the middle always blocks, a side strip only towards a
|
||
* blocking neighbour, a corner only when both its sides *and* the cell across the corner block –
|
||
* which is what closes the inner corner of an L cleanly instead of leaving a notch filled. Above 1
|
||
* the cell grows by the same amount on every side without a blocking neighbour.
|
||
*/
|
||
function cellPieces(cell: SolidCell, blocks: (x: number, y: number) => boolean): Piece[] {
|
||
const { x, y } = cell;
|
||
const e = (1 - (cell.scale ?? 1)) / 2;
|
||
const left = blocks(x - 1, y);
|
||
const right = blocks(x + 1, y);
|
||
const up = blocks(x, y - 1);
|
||
const down = blocks(x, y + 1);
|
||
if (e <= 0) {
|
||
const g = -e;
|
||
return [
|
||
{
|
||
x0: x - (left ? 0 : g),
|
||
x1: x + 1 + (right ? 0 : g),
|
||
y0: y - (up ? 0 : g),
|
||
y1: y + 1 + (down ? 0 : g),
|
||
},
|
||
];
|
||
}
|
||
// Bands along each axis: [start, end, neighbour side (-1, 0, 1)]; the same expressions on both
|
||
// sides of a cut keep touching pieces exactly equal, so they merge.
|
||
const bands = (at: number): [number, number, -1 | 0 | 1][] => [
|
||
[at, at + e, -1],
|
||
[at + e, at + (1 - e), 0],
|
||
[at + (1 - e), at + 1, 1],
|
||
];
|
||
const pieces: Piece[] = [];
|
||
for (const [y0, y1, dy] of bands(y)) {
|
||
for (const [x0, x1, dx] of bands(x)) {
|
||
const side = (dx === 0 || (dx < 0 ? left : right)) && (dy === 0 || (dy < 0 ? up : down));
|
||
if (side && (dx === 0 || dy === 0 || blocks(x + dx, y + dy))) pieces.push({ x0, x1, y0, y1 });
|
||
}
|
||
}
|
||
return pieces;
|
||
}
|
||
|
||
/**
|
||
* The cells merged into rectangles. Each cell is cut into its pieces (see `cellPieces`); pieces on
|
||
* the same strip – same top and bottom – that touch or overlap join into one, and then strips of
|
||
* exactly the same left and right that meet top to bottom join into one taller rectangle. At scale 1
|
||
* every piece is a cell, so a run joins across a row and runs of the same columns stack. The result
|
||
* is ordered top-down, then left to right.
|
||
*/
|
||
export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||
const unique = new Map<string, SolidCell>();
|
||
for (const cell of cells) unique.set(`${cell.x},${cell.y}`, cell);
|
||
const blocks = (x: number, y: number) => unique.has(`${x},${y}`);
|
||
const strips = new Map<string, Piece[]>();
|
||
for (const cell of unique.values()) {
|
||
for (const piece of cellPieces(cell, blocks)) {
|
||
const key = `${piece.y0},${piece.y1}`;
|
||
const strip = strips.get(key);
|
||
if (strip) strip.push(piece);
|
||
else strips.set(key, [piece]);
|
||
}
|
||
}
|
||
const runs: Piece[] = [];
|
||
for (const strip of strips.values()) {
|
||
strip.sort((a, b) => a.x0 - b.x0);
|
||
let run: Piece | null = null;
|
||
for (const piece of strip) {
|
||
if (run && piece.x0 <= run.x1) run.x1 = Math.max(run.x1, piece.x1);
|
||
else runs.push((run = { ...piece }));
|
||
}
|
||
}
|
||
runs.sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0);
|
||
const done: Piece[] = [];
|
||
// Rectangles still open at the bottom, by the left and right they span.
|
||
const open = new Map<string, Piece>();
|
||
for (const run of runs) {
|
||
const key = `${run.x0},${run.x1}`;
|
||
const above = open.get(key);
|
||
if (above && above.y1 === run.y0) {
|
||
above.y1 = run.y1;
|
||
continue;
|
||
}
|
||
if (above) done.push(above);
|
||
open.set(key, run);
|
||
}
|
||
done.push(...open.values());
|
||
return done
|
||
.sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0)
|
||
.map((p) => ({ x: p.x0, y: p.y0, w: p.x1 - p.x0, h: p.y1 - p.y0 }));
|
||
}
|
||
|
||
/** The blocking rectangles of a map, in cells. */
|
||
export function solidRects(
|
||
project: Project,
|
||
props: PropertyBag,
|
||
asset: TilemapAsset,
|
||
oneWay: OneWayFilter = false,
|
||
material?: TileMaterial,
|
||
): readonly CellRect[] {
|
||
return derived(rectsOf, blockingCells(project, props, asset, oneWay, material), (cells) =>
|
||
mergeCells(boxCells(cells)),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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`).
|
||
*/
|
||
export function cellRectShape(props: PropertyBag, rect: CellRect, m: Mat): ColliderShape {
|
||
const tile = tilemapTileSize(props);
|
||
const corner = cellOrigin(props, rect.x, rect.y);
|
||
const centre = apply(m, { x: corner.x + (rect.w * tile) / 2, y: corner.y + (rect.h * tile) / 2 });
|
||
const scale = scaleOf(m);
|
||
return {
|
||
kind: 'box',
|
||
x: centre.x,
|
||
y: centre.y,
|
||
w: rect.w * tile * scale,
|
||
h: rect.h * tile * scale,
|
||
angle: angleOf(m),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Which cells of a placed map can reach into a world rectangle (#184): the rectangle taken back
|
||
* into the map's own units, as cell coordinates – a turned map gives the rectangle around its
|
||
* turned self, which is wider than needed but never too narrow.
|
||
*/
|
||
export function cellsWithin(props: PropertyBag, m: Mat, bounds: ShapeBounds): CellBounds {
|
||
const back = invert(m);
|
||
const tile = tilemapTileSize(props);
|
||
const size = tilemapSize(props);
|
||
const corners = [
|
||
{ x: bounds.left, y: bounds.top },
|
||
{ x: bounds.right, y: bounds.top },
|
||
{ x: bounds.right, y: bounds.bottom },
|
||
{ x: bounds.left, y: bounds.bottom },
|
||
].map((p) => apply(back, p));
|
||
const cell = (values: number[], half: number) => values.map((v) => (v + half) / tile);
|
||
const xs = cell(
|
||
corners.map((p) => p.x),
|
||
size.w / 2,
|
||
);
|
||
const ys = cell(
|
||
corners.map((p) => p.y),
|
||
size.h / 2,
|
||
);
|
||
return {
|
||
left: Math.min(...xs),
|
||
top: Math.min(...ys),
|
||
right: Math.max(...xs),
|
||
bottom: Math.max(...ys),
|
||
};
|
||
}
|
||
|
||
/** A rectangle in cell coordinates, as `cellsWithin` reports it. */
|
||
export interface CellBounds {
|
||
left: number;
|
||
top: number;
|
||
right: number;
|
||
bottom: number;
|
||
}
|
||
|
||
/** Whether a rectangle of cells touches the range at all – touching counts, so nothing near is lost. */
|
||
export function cellsMeet(range: CellBounds, x: number, y: number, w = 1, h = 1): boolean {
|
||
return x <= range.right && x + w >= range.left && y <= range.bottom && y + h >= range.top;
|
||
}
|
||
|
||
/** 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)),
|
||
};
|
||
}
|