A tile gains Collision scale: below 1 each blocking cell pulls in on the sides without a blocking neighbour and stays flush towards its neighbours, so a wall of bushes becomes a narrower wall without gaps and the inner corner of an L closes cleanly; above 1 the cell grows at its open sides; on several layers the largest value counts, and the merged rectangles work in fractions of a cell with exactly the old result at 1 (#140)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4728f4fab5
commit
bffa508ee7
6 files changed
+366
-74
No files matched your search
@@ -20,6 +20,10 @@ export const TILE_FPS_KEY = 'fps';
|
||||
export const TILE_PLAYBACK_KEY = 'playback';
|
||||
/** Whether a cell of this tile blocks movement in the running game (#132). */
|
||||
export const TILE_SOLID_KEY = 'solid';
|
||||
/** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */
|
||||
export const TILE_COLLISION_SCALE_KEY = 'collisionScale';
|
||||
export const TILE_COLLISION_SCALE_MIN = 0.1;
|
||||
export const TILE_COLLISION_SCALE_MAX = 2;
|
||||
|
||||
/** Only the two that make sense for a tile that is always on screen; there is no end to hold. */
|
||||
export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const;
|
||||
@@ -45,6 +49,13 @@ export function isTileSolid(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_SOLID_KEY] === true;
|
||||
}
|
||||
|
||||
/** The collision scale, held to the range the field offers; anything unreadable is the whole cell. */
|
||||
export function tileCollisionScale(asset: TileAsset): number {
|
||||
const scale = Number(asset.props[TILE_COLLISION_SCALE_KEY] ?? 1);
|
||||
if (!Number.isFinite(scale)) return 1;
|
||||
return Math.min(TILE_COLLISION_SCALE_MAX, Math.max(TILE_COLLISION_SCALE_MIN, scale));
|
||||
}
|
||||
|
||||
export function tileFps(asset: TileAsset): number {
|
||||
const fps = Number(asset.props[TILE_FPS_KEY]);
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : 0;
|
||||
|
||||
@@ -6,16 +6,21 @@
|
||||
* 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.
|
||||
*/
|
||||
import type { TilemapAsset } from './Asset';
|
||||
import { angleOf, apply, type Mat, scaleOf } from './matrix';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { isTileSolid } from './tile';
|
||||
import { isTileSolid, tileCollisionScale } from './tile';
|
||||
import { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import type { ColliderShape } from '../properties/collider';
|
||||
|
||||
/** A rectangle of cells: top-left cell and size, all in cells. */
|
||||
/** 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;
|
||||
@@ -23,84 +28,142 @@ export interface CellRect {
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Every blocking cell of the map, once each however many layers hold a solid tile there. */
|
||||
export function solidCells(
|
||||
project: Project,
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
): { x: number; y: number }[] {
|
||||
const solid = new Map<string, boolean>();
|
||||
const isSolid = (id: string): boolean => {
|
||||
const known = solid.get(id);
|
||||
if (known !== undefined) return known;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value = Boolean(tile && isTileSolid(tile));
|
||||
solid.set(id, value);
|
||||
return value;
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
for (const layer of asset.layers) {
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||||
if (!tileId || seen.has(key) || !isSolid(tileId)) continue;
|
||||
const at = parseCellKey(key);
|
||||
if (!at || !inGrid(props, at.x, at.y)) continue;
|
||||
seen.add(key);
|
||||
cells.push(at);
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cells merged into rectangles, row by row: each row is cut into runs, and a run that spans
|
||||
* exactly the columns of a rectangle ending right above it makes that rectangle one row taller.
|
||||
* Every cell lands in exactly one rectangle; the result is ordered top-down, then left to right.
|
||||
* Every blocking cell of the map, 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.
|
||||
*/
|
||||
export function mergeCells(cells: readonly { x: number; y: number }[]): CellRect[] {
|
||||
const rows = new Map<number, number[]>();
|
||||
for (const { x, y } of cells) {
|
||||
const row = rows.get(y);
|
||||
if (row) row.push(x);
|
||||
else rows.set(y, [x]);
|
||||
}
|
||||
const done: CellRect[] = [];
|
||||
// Rectangles still open at the bottom, by the columns they span.
|
||||
let open = new Map<string, CellRect>();
|
||||
let previous = Number.NaN;
|
||||
for (const y of [...rows.keys()].sort((a, b) => a - b)) {
|
||||
if (y !== previous + 1) {
|
||||
done.push(...open.values());
|
||||
open = new Map();
|
||||
export function solidCells(project: Project, props: PropertyBag, asset: TilemapAsset): Required<SolidCell>[] {
|
||||
const scales = new Map<string, number | null>();
|
||||
const scaleOf = (id: string): number | null => {
|
||||
const known = scales.get(id);
|
||||
if (known !== undefined) return known;
|
||||
const tile = findAssetOf(project, id, 'tile');
|
||||
const value = tile && isTileSolid(tile) ? tileCollisionScale(tile) : null;
|
||||
scales.set(id, value);
|
||||
return value;
|
||||
};
|
||||
const found = new Map<string, Required<SolidCell>>();
|
||||
for (const layer of asset.layers) {
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||||
const scale = tileId ? scaleOf(tileId) : null;
|
||||
if (scale === null) continue;
|
||||
const known = found.get(key);
|
||||
if (known) {
|
||||
known.scale = Math.max(known.scale, scale);
|
||||
continue;
|
||||
}
|
||||
const at = parseCellKey(key);
|
||||
if (!at || !inGrid(props, at.x, at.y)) continue;
|
||||
found.set(key, { ...at, scale });
|
||||
}
|
||||
const next = new Map<string, CellRect>();
|
||||
for (const [x0, w] of runsOf(rows.get(y)!)) {
|
||||
const key = `${x0},${w}`;
|
||||
const above = open.get(key);
|
||||
if (above) {
|
||||
above.h++;
|
||||
open.delete(key);
|
||||
next.set(key, above);
|
||||
} else next.set(key, { x: x0, y, w, h: 1 });
|
||||
}
|
||||
done.push(...open.values());
|
||||
open = next;
|
||||
previous = y;
|
||||
}
|
||||
done.push(...open.values());
|
||||
return done.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
/** Runs of neighbouring columns as `[first column, length]`; doubles count once. */
|
||||
function runsOf(columns: number[]): [number, number][] {
|
||||
const sorted = [...new Set(columns)].sort((a, b) => a - b);
|
||||
const runs: [number, number][] = [];
|
||||
for (const x of sorted) {
|
||||
const last = runs[runs.length - 1];
|
||||
if (last && last[0] + last[1] === x) last[1]++;
|
||||
else runs.push([x, 1]);
|
||||
/** 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
return runs;
|
||||
// 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. */
|
||||
|
||||
Reference in new issue
Block a user