Tilemaps are a new placeable asset type that is painted with autotiles in the scene view, with layers, brush, eraser and rectangle, and is drawn at runtime and in the export (#128)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m51s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 06:41:47 +02:00
1 parent 0607221423
commit 33a7b1b63e
30 files changed
+2060 -20

No files matched your search

+23
View File
@@ -195,6 +195,28 @@ export interface AutotileAsset extends AssetBase {
pictures: string[];
}
/**
* One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the autotile lying there, and
* a layer nobody switched off is drawn. Which tile of that autotile a cell shows is worked out
* from its neighbours while drawing, never stored – see `model/tilemap.ts`.
*/
export interface TilemapLayer {
id: string;
name: string;
hidden?: boolean;
cells: Record<string, string>;
}
/**
* A grid painted with autotiles, placed in a scene as a sprite is. How wide the grid is and how
* big one cell is are properties, so the inspector edits them with undo like any other row; the
* layers are intrinsic data, as a texture's frames are (#128).
*/
export interface TilemapAsset extends AssetBase {
type: 'tilemap';
layers: TilemapLayer[];
}
/**
* The graph is the motion of one particle: every output of its `Output` node is a function of the
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
@@ -217,6 +239,7 @@ export type Asset =
| TextureAsset
| AnimationAsset
| AutotileAsset
| TilemapAsset
| StateMachineAsset
| ShaderAsset
| FontAsset
+4 -1
View File
@@ -10,6 +10,7 @@ export const ASSET_TYPES = [
'texture',
'animation',
'autotile',
'tilemap',
'statemachine',
'shader',
'font',
@@ -31,6 +32,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
texture: 'Texture',
animation: 'Animation',
autotile: 'Autotile',
tilemap: 'Tilemap',
statemachine: 'State machine',
shader: 'Shader',
font: 'Font',
@@ -43,7 +45,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. An autotile
* is a tile a tilemap draws with (#128), so it is placed no more directly than a texture is.
* is a tile a tilemap draws with, so it is placed no more directly than a texture is – what is
* placed is the tilemap.
*/
export type PlaceableAssetType = Exclude<
AssetType,
+202
View File
@@ -0,0 +1,202 @@
/**
* A tilemap is a grid of cells, and every cell names the autotile lying there. Which of that
* autotile's tiles 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 an autotile 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.
*/
import type { AutotileAsset, TilemapLayer } from './Asset';
import {
type AutotileKind,
autotileKind,
type Neighbours,
type TileQuadrants,
tileQuadrants,
} from './autotile';
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 autotile'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* autotile 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,
autotileId: string,
): Neighbours {
const same = (dx: number, dy: number): boolean =>
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === autotileId;
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 autotile it is and which quarters build it. */
export interface TileDraw {
x: number;
y: number;
autotileId: string;
quadrants: TileQuadrants;
}
/**
* Every cell of one layer that is inside the grid and names an autotile that exists, in reading
* order so the result is the same list twice. Cells naming a deleted autotile are skipped rather
* than dropped from the model: an undo brings the autotile, and with it the drawing, back.
*/
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] {
const kinds = new Map<string, AutotileKind>();
const kindOf = (id: string): AutotileKind | null => {
const known = kinds.get(id);
if (known) return known;
const autotile: AutotileAsset | undefined = findAssetOf(project, id, 'autotile');
if (!autotile) return null;
const kind = autotileKind(autotile);
kinds.set(id, kind);
return kind;
};
const tiles: TileDraw[] = [];
for (const [key, autotileId] of Object.entries(layer.cells)) {
const at = parseCellKey(key);
if (!at || !autotileId || !inGrid(props, at.x, at.y)) continue;
const kind = kindOf(autotileId);
if (!kind) continue;
tiles.push({
x: at.x,
y: at.y,
autotileId,
quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, autotileId)),
});
}
return tiles.sort((a, b) => a.y - b.y || a.x - b.x);
}
/** The autotiles a tilemap paints with, in the order they first appear – its palette in use. */
export function tilemapAutotileIds(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;
}
+10 -1
View File
@@ -6,7 +6,7 @@
import { isKindWith } from '../properties/kinds';
import { schemaForAsset } from '../properties/PropertySchema';
import { referencesAny, trackDef } from './animation';
import type { AnimationAsset, Asset, AutotileAsset } from './Asset';
import type { AnimationAsset, Asset, AutotileAsset, TilemapAsset } from './Asset';
import { overrideSites } from './instance';
import { type ObjectHolder, objectHolders, type Project } from './Project';
import type { SceneObject } from './SceneObject';
@@ -71,6 +71,15 @@ export function autotilesUsing(project: Project, ids: readonly string[]): Autoti
);
}
/** The tilemaps painted with any of these autotiles – intrinsic data, like an autotile's pictures. */
export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] {
const wanted = new Set(ids);
return project.assets.filter(
(a): a is TilemapAsset =>
a.type === 'tilemap' && a.layers.some((l) => Object.values(l.cells).some((id) => wanted.has(id))),
);
}
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
const wanted = new Set(ids);