/** * 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; quarters: Map }; 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())); 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>(); 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(); 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(); /** * 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(); 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, ): CellBlock[] { const tileOf = (id: string) => tiles.get(id) ?? null; const found = new Map(); 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[] { return boxCells(blockingCells(project, props, asset, oneWay, material)); } function boxCells(cells: readonly CellBlock[]): Required[] { 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( held: WeakMap, 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(); const rectsOf = new WeakMap(); /** 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(); 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(); 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(); 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)), }; }