199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|