Tilemaps can block movement: a tile gains a Solid switch, the solid cells of all layers are merged into rectangles and the placed tilemap takes part in the collision system as one body, reported as other in collision events (#132)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10b49fac46
commit
4c411c1ff3
10 files changed
+715
-43
No files matched your search
@@ -18,6 +18,8 @@ export type TileKind = (typeof TILE_KINDS)[number];
|
||||
export const TILE_KIND_KEY = 'kind';
|
||||
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';
|
||||
|
||||
/** 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;
|
||||
@@ -39,6 +41,10 @@ export function tilePlayback(asset: TileAsset): TilePlayback {
|
||||
return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop';
|
||||
}
|
||||
|
||||
export function isTileSolid(asset: TileAsset): boolean {
|
||||
return asset.props[TILE_SOLID_KEY] === true;
|
||||
}
|
||||
|
||||
export function tileFps(asset: TileAsset): number {
|
||||
const fps = Number(asset.props[TILE_FPS_KEY]);
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : 0;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 { cellOrigin, inGrid, parseCellKey, tilemapTileSize } from './tilemap';
|
||||
import type { ColliderShape } from '../properties/collider';
|
||||
|
||||
/** A rectangle of cells: top-left cell and size, all in cells. */
|
||||
export interface CellRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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]);
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** The blocking rectangles of a map, in cells. */
|
||||
export function solidRects(project: Project, props: PropertyBag, asset: TilemapAsset): CellRect[] {
|
||||
return mergeCells(solidCells(project, props, asset));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user