Files
feuerwerk/src/core/model/tilemap.ts
T

199 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* A tilemap is a grid of cells, and every cell names the tile lying there. Which of that tile's
* quarters a cell shows is *not* stored: it follows from the neighbours and is worked out
* while drawing (#128), so painting one cell fixes the edges of the ones around it by itself.
*
* Cells are sparse – a map from `"x,y"` to a tile id – rather than one entry per grid square.
* A map is what painting, erasing and reading a neighbour all want, and changing the grid size then
* moves nothing: what falls outside is simply not drawn and comes back when the grid grows again.
* The project file stores them more compactly (#134, `store/tilemapCells.ts`).
*/
import type { TileAsset, TilemapLayer } from './Asset';
import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile';
import { type TileKind, tileKind } from './tile';
import type { PropertyBag } from './Properties';
import { findAssetOf, type Project } from './Project';
export const TILEMAP_COLUMNS_KEY = 'columns';
export const TILEMAP_ROWS_KEY = 'rows';
export const TILEMAP_TILE_KEY = 'tileSize';
/**
* The grid is read from a property bag rather than from the asset: a placed tilemap may override
* its size like any other row, and the footprint the scene view picks and frames by has nothing
* but the bag (see `properties/bounds.ts`).
*/
export function tilemapColumns(props: PropertyBag): number {
return wholeAbove(props[TILEMAP_COLUMNS_KEY], 20);
}
export function tilemapRows(props: PropertyBag): number {
return wholeAbove(props[TILEMAP_ROWS_KEY], 15);
}
/** Side of one cell in world units; the tile's own pictures are scaled onto it. */
export function tilemapTileSize(props: PropertyBag): number {
const size = Number(props[TILEMAP_TILE_KEY]);
return Number.isFinite(size) && size > 0 ? size : 48;
}
function wholeAbove(value: unknown, fallback: number): number {
const count = Math.floor(Number(value));
return Number.isFinite(count) && count >= 1 ? count : fallback;
}
export function tilemapSize(props: PropertyBag): { w: number; h: number } {
const tile = tilemapTileSize(props);
return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile };
}
export function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
export function parseCellKey(key: string): { x: number; y: number } | null {
const [x, y] = key.split(',').map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? { x: x as number, y: y as number } : null;
}
export function cellAt(layer: TilemapLayer, x: number, y: number): string {
return layer.cells[cellKey(x, y)] ?? '';
}
export function isLayerVisible(layer: TilemapLayer): boolean {
return layer.hidden !== true;
}
export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined {
return asset.layers.find((layer) => layer.id === layerId);
}
export function inGrid(props: PropertyBag, x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < tilemapColumns(props) && y < tilemapRows(props);
}
/**
* Which neighbours a cell is joined to. Only the *same* tile joins: a different one counts as
* empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets
* are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge.
*/
export function neighboursOf(
props: PropertyBag,
layer: TilemapLayer,
x: number,
y: number,
tileId: string,
): Neighbours {
const same = (dx: number, dy: number): boolean =>
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === tileId;
return {
n: same(0, -1),
e: same(1, 0),
s: same(0, 1),
w: same(-1, 0),
ne: same(1, -1),
se: same(1, 1),
sw: same(-1, 1),
nw: same(-1, -1),
};
}
/** One cell as it is drawn: where it sits, which tile it is and which quarters build it. */
export interface TileDraw {
x: number;
y: number;
tileId: string;
quadrants: TileQuadrants;
}
/**
* Every cell of one layer that is inside the grid and names a tile that exists, in reading order
* so the result is the same list twice. Cells naming a deleted tile are skipped rather than
* dropped from the model: an undo brings the tile, and with it the drawing, back.
*/
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] {
const kinds = new Map<string, TileKind>();
const kindOf = (id: string): TileKind | null => {
const known = kinds.get(id);
if (known) return known;
const tile: TileAsset | undefined = findAssetOf(project, id, 'tile');
if (!tile) return null;
const kind = tileKind(tile);
kinds.set(id, kind);
return kind;
};
const tiles: TileDraw[] = [];
for (const [key, tileId] of Object.entries(layer.cells)) {
const at = parseCellKey(key);
if (!at || !tileId || !inGrid(props, at.x, at.y)) continue;
const kind = kindOf(tileId);
if (!kind) continue;
tiles.push({
x: at.x,
y: at.y,
tileId,
quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, tileId)),
});
}
return tiles.sort((a, b) => a.y - b.y || a.x - b.x);
}
/** The tiles a tilemap paints with, in the order they first appear – its palette in use. */
export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] {
const ids: string[] = [];
for (const layer of asset.layers)
for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id);
return ids;
}
/**
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
*/
export function cellAtPoint(props: PropertyBag, x: number, y: number): { x: number; y: number } {
const tile = tilemapTileSize(props);
const { w, h } = tilemapSize(props);
return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) };
}
/** Where the top-left corner of a cell sits in the tilemap's own frame, in world units. */
export function cellOrigin(props: PropertyBag, x: number, y: number): { x: number; y: number } {
const tile = tilemapTileSize(props);
const { w, h } = tilemapSize(props);
return { x: x * tile - w / 2, y: y * tile - h / 2 };
}
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
export function cellsInRect(
props: PropertyBag,
a: { x: number; y: number },
b: { x: number; y: number },
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
const x0 = Math.max(0, Math.min(a.x, b.x));
const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x));
const y0 = Math.max(0, Math.min(a.y, b.y));
const y1 = Math.min(tilemapRows(props) - 1, Math.max(a.y, b.y));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) cells.push({ x, y });
return cells;
}
/**
* The cells a stroke from one cell to another covers. A pointer jumps between events, so without
* this a fast drag would paint dots instead of a line.
*/
export function cellsAlong(
from: { x: number; y: number },
to: { x: number; y: number },
): { x: number; y: number }[] {
const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y));
if (steps === 0) return [{ ...to }];
const cells: { x: number; y: number }[] = [];
for (let i = 1; i <= steps; i++)
cells.push({
x: Math.round(from.x + ((to.x - from.x) * i) / steps),
y: Math.round(from.y + ((to.y - from.y) * i) / steps),
});
return cells;
}