From 33a7b1b63e4e8628ad2a98bcc07c34adf5110aea Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 06:41:47 +0200 Subject: [PATCH] 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) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/commands/assetCommands.ts | 3 + src/core/commands/naming.ts | 5 + src/core/commands/require.ts | 23 +- src/core/commands/tilemapCommands.ts | 184 +++++++++++ src/core/model/Asset.ts | 23 ++ src/core/model/AssetTypes.ts | 5 +- src/core/model/tilemap.ts | 202 ++++++++++++ src/core/model/usages.ts | 11 +- src/core/properties/PropertySchema.ts | 10 + src/core/properties/bounds.ts | 5 + src/core/store/serialize.ts | 32 +- src/editor/TabStrip.ts | 7 +- src/editor/debugHook.ts | 58 +++- src/editor/inspector/Inspector.ts | 2 +- src/editor/inspector/objectSection.ts | 12 +- src/editor/inspector/tilemapSection.ts | 152 +++++++++ src/editor/state.ts | 25 ++ src/editor/viewport/SceneViewport.ts | 230 ++++++++++++- src/editor/viewport/WorldViewport.ts | 52 +++ src/viewport/items.ts | 58 ++++ src/viewport/symbols.ts | 3 + src/viewport/tilemap.ts | 122 +++++++ src/viewport/tilemapMesh.ts | 98 ++++++ tests/core/tilemap.test.ts | 433 +++++++++++++++++++++++++ tools/e2e/04-assets.mjs | 4 +- tools/e2e/39-autotile.mjs | 2 +- tools/e2e/40-tilemap.mjs | 299 +++++++++++++++++ tools/e2e/index.mjs | 2 + tools/e2e/lib/scaffold.mjs | 16 +- 30 files changed, 2060 insertions(+), 20 deletions(-) create mode 100644 src/core/commands/tilemapCommands.ts create mode 100644 src/core/model/tilemap.ts create mode 100644 src/editor/inspector/tilemapSection.ts create mode 100644 src/viewport/tilemap.ts create mode 100644 src/viewport/tilemapMesh.ts create mode 100644 tests/core/tilemap.test.ts create mode 100644 tools/e2e/40-tilemap.mjs diff --git a/README.md b/README.md index 01287670..fc86b0c2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 989 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1006 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 2bbb04f6..70407a9a 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -41,6 +41,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'animation', root: '', tracks: [] }; case 'autotile': return { ...base, type: 'autotile', pictures: [] }; + // A tilemap without a layer could not be painted on, so it starts with one. + case 'tilemap': + return { ...base, type: 'tilemap', layers: [{ id: newId(), name: 'Layer 1', cells: {} }] }; case 'statemachine': return { ...base, type: 'statemachine', graph: createEmptyGraph() }; case 'shader': diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index 8fa862fd..27adef3a 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -14,6 +14,11 @@ export function nextAssetName(project: Project, base: string): string { return taken.has(base) ? firstFree(taken, (i) => `${base} ${i}`, 2) : base; } +/** "Layer 1", "Layer 2", … – the first numbered name none of `taken` holds. */ +export function nextNumberedName(taken: readonly string[], base: string): string { + return firstFree(new Set(taken), (i) => `${base} ${i}`, 1); +} + /** "var1", "var2", … – the first variable name not yet used by the behavior. */ export function nextVariableName(asset: BehaviorAsset | VariablesAsset, base = 'var'): string { return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1); diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index eab19d08..49120529 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -1,7 +1,16 @@ /** Lookups that throw a `CommandError` when the target does not exist. */ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; -import type { Asset, AutotileAsset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset'; +import type { + Asset, + AutotileAsset, + BehaviorAsset, + StateMachineAsset, + TilemapAsset, + TilemapLayer, + VariablesAsset, +} from '../model/Asset'; +import { findLayer } from '../model/tilemap'; import { findObject, type ObjectTree } from '../model/hierarchy'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { @@ -85,6 +94,18 @@ export function requireAutotile(project: Project, assetId: string): AutotileAsse return asset; } +export function requireTilemap(project: Project, assetId: string): TilemapAsset { + const asset = findAssetOf(project, assetId, 'tilemap'); + if (!asset) throw new CommandError(`Tilemap ${assetId} not found`); + return asset; +} + +export function requireTilemapLayer(project: Project, assetId: string, layerId: string): TilemapLayer { + const layer = findLayer(requireTilemap(project, assetId), layerId); + if (!layer) throw new CommandError(`Layer ${layerId} not found`); + return layer; +} + export function requireStateMachine(project: Project, assetId: string): StateMachineAsset { const asset = findAssetOf(project, assetId, 'statemachine'); if (!asset) throw new CommandError(`State machine ${assetId} not found`); diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts new file mode 100644 index 00000000..640c4dfc --- /dev/null +++ b/src/core/commands/tilemapCommands.ts @@ -0,0 +1,184 @@ +/** + * Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers: + * the pointer decides where a stroke ends, so an undo has to take back the whole line and not the + * last cell of it. The grid size and the cell size are ordinary properties and go through + * `SetAssetPropertyCommand`. + */ +import { newId } from '../ids'; +import type { TilemapLayer } from '../model/Asset'; +import { cellKey } from '../model/tilemap'; +import type { Project } from '../model/Project'; +import { nextNumberedName } from './naming'; +import { type Command, CommandError } from './Command'; +import { requireTilemap, requireTilemapLayer } from './require'; + +/** One cell as a stroke wants it: `autotileId` empty erases. */ +export interface CellEdit { + x: number; + y: number; + autotileId: string; +} + +/** + * Sets a run of cells at once. What each one held before is remembered per cell, so an undo puts + * a stroke over another autotile back exactly – not merely empty. + */ +export class PaintTilesCommand implements Command { + readonly label: string; + private previous = new Map(); + + constructor( + private readonly assetId: string, + private readonly layerId: string, + private readonly edits: readonly CellEdit[], + label?: string, + ) { + this.label = label ?? 'Paint tiles'; + } + + /** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */ + static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean { + return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.autotileId); + } + + execute(project: Project): void { + const layer = requireTilemapLayer(project, this.assetId, this.layerId); + this.previous = new Map(); + for (const edit of this.edits) { + const key = cellKey(edit.x, edit.y); + if (!this.previous.has(key)) this.previous.set(key, layer.cells[key] ?? ''); + if (edit.autotileId) layer.cells[key] = edit.autotileId; + else delete layer.cells[key]; + } + } + + undo(project: Project): void { + const layer = requireTilemapLayer(project, this.assetId, this.layerId); + for (const [key, value] of this.previous) { + if (value) layer.cells[key] = value; + else delete layer.cells[key]; + } + } +} + +export class AddTilemapLayerCommand implements Command { + readonly label = 'Add layer'; + readonly layerId = newId(); + + constructor(private readonly assetId: string) {} + + execute(project: Project): void { + const asset = requireTilemap(project, this.assetId); + const name = nextNumberedName( + asset.layers.map((layer) => layer.name), + 'Layer', + ); + asset.layers.push({ id: this.layerId, name, cells: {} }); + } + + undo(project: Project): void { + const asset = requireTilemap(project, this.assetId); + asset.layers = asset.layers.filter((layer) => layer.id !== this.layerId); + } +} + +/** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */ +export class RemoveTilemapLayerCommand implements Command { + readonly label = 'Remove layer'; + private removed: TilemapLayer | null = null; + private index = -1; + + constructor( + private readonly assetId: string, + private readonly layerId: string, + ) {} + + execute(project: Project): void { + const asset = requireTilemap(project, this.assetId); + if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer'); + this.index = asset.layers.findIndex((layer) => layer.id === this.layerId); + const layer = asset.layers[this.index]; + if (!layer) throw new CommandError(`Layer ${this.layerId} not found`); + this.removed = layer; + asset.layers.splice(this.index, 1); + } + + undo(project: Project): void { + if (this.removed) requireTilemap(project, this.assetId).layers.splice(this.index, 0, this.removed); + } +} + +/** Moves a layer in the drawing order; the last layer in the list is drawn on top. */ +export class MoveTilemapLayerCommand implements Command { + readonly label = 'Reorder layers'; + + constructor( + private readonly assetId: string, + private readonly from: number, + private readonly to: number, + ) {} + + execute(project: Project): void { + this.move(project, this.from, this.to); + } + + undo(project: Project): void { + this.move(project, this.to, this.from); + } + + private move(project: Project, from: number, to: number): void { + const asset = requireTilemap(project, this.assetId); + const layer = asset.layers[from]; + if (!layer || to < 0 || to >= asset.layers.length) throw new CommandError('No such layer'); + asset.layers.splice(from, 1); + asset.layers.splice(to, 0, layer); + } +} + +export class RenameTilemapLayerCommand implements Command { + readonly label = 'Rename layer'; + private previous = ''; + + constructor( + private readonly assetId: string, + private readonly layerId: string, + private readonly name: string, + ) {} + + execute(project: Project): void { + const layer = requireTilemapLayer(project, this.assetId, this.layerId); + this.previous = layer.name; + layer.name = this.name; + } + + undo(project: Project): void { + requireTilemapLayer(project, this.assetId, this.layerId).name = this.previous; + } +} + +/** A hidden layer keeps its cells and is left out of the drawing, in the editor and in the game alike. */ +export class SetTilemapLayerHiddenCommand implements Command { + readonly label: string; + + constructor( + private readonly assetId: string, + private readonly layerId: string, + private readonly hidden: boolean, + ) { + this.label = hidden ? 'Hide layer' : 'Show layer'; + } + + execute(project: Project): void { + this.apply(project, this.hidden); + } + + undo(project: Project): void { + this.apply(project, !this.hidden); + } + + private apply(project: Project, hidden: boolean): void { + const layer = requireTilemapLayer(project, this.assetId, this.layerId); + if (hidden) layer.hidden = true; + else delete layer.hidden; + } +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index b1122490..d2e77da8 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -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; +} + +/** + * 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 diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 9a4f2b3d..f89a505d 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -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 = { 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 = { * 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, diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts new file mode 100644 index 00000000..ed00feb5 --- /dev/null +++ b/src/core/model/tilemap.ts @@ -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(); + 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; +} diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index cd7c9714..a4b01fff 100644 --- a/src/core/model/usages.ts +++ b/src/core/model/usages.ts @@ -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); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index e4d7623b..95d58cc7 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -21,6 +21,7 @@ import { } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; import { AUTOTILE_FPS_KEY, AUTOTILE_KIND_KEY, AUTOTILE_PLAYBACK_KEY } from '../model/autotile'; +import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap'; import { SLICE_SCALE_KEY } from '../model/texture'; import type { SceneObject } from '../model/SceneObject'; import { flatListOf, listItemOf } from './listItems'; @@ -140,6 +141,7 @@ const TRANSFORM_TYPES: ReadonlySet = new Set([ 'collider', 'container', 'particles', + 'tilemap', ]); export function hasTransformProps(type: AssetType): boolean { @@ -244,6 +246,14 @@ const ASSET_SCHEMAS: Record = { ], }, ], + // The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say + // how far the grid reaches and how big one cell is drawn. An autotile's own pictures are scaled + // onto that cell, so one tilemap can mix autotiles drawn at different resolutions. + tilemap: [ + { key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: 1000, step: 1 }, + { key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: 1000, step: 1 }, + { key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 }, + ], // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], // Every row is declared on the asset itself – see `schemaForBag`. diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 3ad7659b..3d314e5f 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -15,6 +15,7 @@ import { translation, } from '../model/matrix'; import { type PropertyBag, textOf, type Vec2 } from '../model/Properties'; +import { tilemapSize } from '../model/tilemap'; import { cameraFrame } from './camera'; import { colliderSize } from './collider'; import { defaultsFor } from './PropertySchema'; @@ -64,6 +65,10 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h // framed is the source, which is a point with a handle around it. case 'particles': return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL }; + // The whole grid, centred on the object's position like every other footprint – so its cells + // are the same squares for drawing, for picking and for the outline around it. + case 'tilemap': + return tilemapSize(props); default: return cameraFrame(props); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index ddf43615..db30ea8e 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -19,7 +19,15 @@ import { import { isParticleNodeDef } from '../particles/nodes'; import { isShaderNodeDef } from '../shader/nodes'; import { isKeyInterpolation, normalizeKeys } from '../model/animation'; -import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset'; +import type { + AnimationKey, + AnimationTrack, + Asset, + StateMachineAsset, + TextureFrame, + TilemapLayer, +} from '../model/Asset'; +import { parseCellKey } from '../model/tilemap'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; import { isSoundFile } from '../model/sound'; @@ -175,6 +183,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { (id): id is string => typeof id === 'string' && id !== '', ); assets.push({ id, type, name, props, pictures }); + } else if (type === 'tilemap') { + assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) }); } else if (type === 'animation') { // A root or row aiming at something gone is kept: it reads as missing and keeps its keys. const root = typeof a.root === 'string' ? a.root : ''; @@ -493,6 +503,26 @@ function normalizeTracks(raw: unknown): AnimationTrack[] { return tracks; } +/** + * The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing, + * is dropped; one naming a deleted autotile is kept, like an animation's dangling row, so an undo + * brings the drawing back. A doubled layer id is dropped as a doubled node is. + */ +function normalizeLayers(raw: unknown): TilemapLayer[] { + const layers: TilemapLayer[] = []; + for (const item of Array.isArray(raw) ? raw : []) { + const l = asRecord(item); + if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue; + const cells: Record = {}; + for (const [key, value] of Object.entries(asRecord(l.cells))) + if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value; + const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells }; + if (l.hidden === true) layer.hidden = true; + layers.push(layer); + } + return layers; +} + function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag { const r = asRecord(raw); const bag: PropertyBag = {}; diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index d866dbbc..a4afd108 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -8,6 +8,9 @@ export interface TabPlace { tab: T; x: number; right: number; + /** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */ + y: number; + h: number; } /** @@ -84,8 +87,8 @@ export class TabStrip extends HStack { for (const tab of this.order) { const button = this.buttons.get(tab)!; if (!button.visible) continue; - const x = button.toGlobal({ x: 0, y: 0 }).x; - out.push({ tab, x, right: x + button.w }); + const at = button.toGlobal({ x: 0, y: 0 }); + out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h }); } return out; } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0e5e71f5..573a158c 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -4,6 +4,7 @@ import { previewObjectId } from '../core/model/animationPreview'; import { resolveObjects } from '../core/model/instance'; import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project'; import type { FrameRect } from '../core/model/texture'; +import { findLayer } from '../core/model/tilemap'; import { effectiveScale, gameClock } from '../core/time'; import { activeDrag, @@ -28,7 +29,8 @@ import { currentFontPicker } from './font/FontPicker'; import { currentNodePalette } from './graph/NodePalette'; import { currentSoundPicker } from './sound/SoundPicker'; import { currentImagePicker } from './texture/ImagePicker'; -import type { EditorState } from './state'; +import { currentTilemapSection } from './inspector/tilemapSection'; +import type { EditorState, TilemapTool } from './state'; /** * Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their @@ -248,6 +250,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E locate, locateAll, inspectorField: fieldOf, + /** The inspector panel on screen – what tells whether a row of it can be clicked at all. */ + inspectorRect: () => rectOf(inspector), inspectorInfo: () => inspector.debugInfo(), textColor, overlayTexts, @@ -523,6 +527,58 @@ export function installDebugHook(app: Application, state: EditorState, editor: E pictureRowAt: (index: number) => editor.autotileEditor.strip.list.rowGlobal(String(index)), fitAt: () => centreOf(editor.autotileEditor.fitButton), }, + tilemap: { + /** What the scene view painted a map with, and on which layer (#128). */ + paint: () => ({ ...state.tilemapPaint }), + /** The cells of one layer as the model holds them: `"x,y"` to autotile id. */ + cells: (assetId: string, layerId: string) => { + const asset = findAssetOf(state.project, assetId, 'tilemap'); + const layer = asset && findLayer(asset, layerId); + return layer ? { ...layer.cells } : null; + }, + layers: (assetId: string) => + findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({ + id: layer.id, + name: layer.name, + hidden: layer.hidden === true, + cells: Object.keys(layer.cells).length, + })) ?? null, + /** What the scene view drew: meshes (one draw call each), cells and visible layers. */ + info: (objectId: string) => viewport.tilemapInfo(objectId), + gameInfo: (objectId: string) => gameView.tilemapInfo(objectId), + /** Where the widgets of the inspector section sit: their texts are on screen elsewhere too. */ + toolAt: (tool: string) => { + const button = currentTilemapSection()?.tools.get(tool as TilemapTool); + return button ? centreOf(button) : null; + }, + autotileAt: (assetId: string) => { + const button = currentTilemapSection()?.autotiles.get(assetId); + return button ? centreOf(button) : null; + }, + addLayerAt: () => { + const button = currentTilemapSection()?.addLayer; + return button ? centreOf(button) : null; + }, + /** One row of the layer list, top-down as drawn: the layer, its switch and its buttons. */ + layerRowAt: (index: number) => { + const row = currentTilemapSection()?.layers[index]; + return row + ? { + id: row.id, + // `y` is the row's own line, so a check can tell whether it is inside the panel. + y: centreOf(row.name).y, + name: centreOf(row.name), + visible: centreOf(row.visible), + up: centreOf(row.up), + down: centreOf(row.down), + remove: centreOf(row.remove), + } + : null; + }, + /** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */ + cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y), + gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y), + }, container: { current: () => containerEditor.currentAssetId, pickerWidth: () => containerEditor.picker.w, diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index fa4a30c3..e8d4f654 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -105,7 +105,7 @@ export class Inspector extends Panel { if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId); if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId); if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); - return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId); + return objectSignature(this.state, s.holderId, s.objectIds, s.objectId); } /** Pushes current model values into the widgets (after undo, drag in viewport, …). */ diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index c7afa722..d4812f73 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -44,9 +44,10 @@ import { aspectActions, sizeCommand } from './aspectAction'; import { defSignature } from './assetSection'; import { actionIf, createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory'; import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host'; -import type { ListSlot } from '../state'; +import type { EditorState, ListSlot } from '../state'; import { parentRow } from './parentRow'; import { listOpener, previewingHandlers, structFields } from './previewHandlers'; +import { buildTilemapSection, tilemapSignature } from './tilemapSection'; import { overrideValue } from './valueSpecs'; import { buildVariable } from './variableRows'; import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries'; @@ -62,13 +63,14 @@ export function objectRows(project: Project, asset: Asset, object: SceneObject): : schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key)); } -/** Structure-defining facts of an object selection: parent, template link and schema. */ +/** Structure-defining facts of an object selection: parent, template link, schema and painting. */ export function objectSignature( - project: Project, + state: EditorState, holderId: string, objectIds: readonly string[], objectId: string, ): string { + const project = state.project; if (objectIds.length > 1) return `multi|${objectIds.join(',')}`; const holder = findObjectHolder(project, holderId); const view = holder && resolvedHolder(project, holder); @@ -81,7 +83,8 @@ export function objectSignature( ? 'instance' : ''; const rows = schemaForObject(project, asset, object); - return `${object.parentId}|${kind}|` + rows.map(defSignature).join(','); + const painting = asset.type === 'tilemap' ? `|${tilemapSignature(state, asset)}` : ''; + return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',') + painting; } /** Several objects: what they are, and the actions that apply to all of them at once. */ @@ -204,6 +207,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object markObjectRows(host, object, asset, target); if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length); + if (asset.type === 'tilemap') buildTilemapSection(host, asset); } function assetLink(host: InspectorHost, label: string, asset: Asset): void { diff --git a/src/editor/inspector/tilemapSection.ts b/src/editor/inspector/tilemapSection.ts new file mode 100644 index 00000000..5ac9c65f --- /dev/null +++ b/src/editor/inspector/tilemapSection.ts @@ -0,0 +1,152 @@ +/** + * The painting side of a placed tilemap (#128): which layer is painted on, with which autotile and + * with which tool. It hangs below the ordinary rows of the object, the way a container's content + * section does – the grid size and the cell size are plain properties and are shown above. + * + * Nothing here belongs to the model except the layers themselves: tool, autotile and layer are + * editor state (`state.tilemapPaint`), so opening a project does not decide what one paints with. + */ +import { + AddTilemapLayerCommand, + MoveTilemapLayerCommand, + RemoveTilemapLayerCommand, + SetTilemapLayerHiddenCommand, +} from '../../core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../core/model/Asset'; +import { isLayerVisible } from '../../core/model/tilemap'; +import { Button, Checkbox, HStack, Theme } from '../../ui'; +import type { EditorState, TilemapTool } from '../state'; +import { addGap, addNote, addTitle, type InspectorHost } from './host'; + +const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [ + { tool: 'select', label: 'Select', hint: 'Drag the map to move it, as any other object.' }, + { tool: 'brush', label: 'Brush', hint: 'Drag over the map to paint a line of cells.' }, + { tool: 'erase', label: 'Erase', hint: 'Drag over the map to clear cells.' }, + { tool: 'rect', label: 'Rect', hint: 'Drag to fill the box you span.' }, +]; + +/** + * The layer that is painted on. The one chosen in the inspector when it still exists, otherwise + * the topmost – so a fresh selection can be painted on without choosing anything first. + */ +export function paintedLayerId(state: EditorState, asset: TilemapAsset): string { + const chosen = state.tilemapPaint.layerId; + if (asset.layers.some((layer) => layer.id === chosen)) return chosen; + return asset.layers[asset.layers.length - 1]?.id ?? ''; +} + +/** + * The widgets of the section as they were last built. Every one of them carries an icon or a name + * that is on screen elsewhere too – the autotile's row in the asset panel, the layer's name – so a + * check cannot find them by their text; this is what it clicks instead. + */ +export interface TilemapSectionWidgets { + tools: Map; + autotiles: Map; + layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; + addLayer: Button; +} + +let built: TilemapSectionWidgets | null = null; + +export function currentTilemapSection(): TilemapSectionWidgets | null { + return built; +} + +export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void { + const { state } = host; + const paint = state.tilemapPaint; + const widgets: TilemapSectionWidgets = { + tools: new Map(), + autotiles: new Map(), + layers: [], + addLayer: new Button('Add layer', { + onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), + }), + }; + built = widgets; + + addTitle(host, 'PAINTING'); + const tools = new HStack({ gap: Theme.gap }); + for (const { tool, label } of TOOLS) { + const button = new Button(label, { onClick: () => state.setTilemapPaint({ tool }) }); + button.toggled = paint.tool === tool; + widgets.tools.set(tool, button); + tools.add(button, 1); + } + host.body.add(tools); + addNote(host, TOOLS.find((t) => t.tool === paint.tool)?.hint ?? '', 'small'); + addNote(host, 'Shift-drag still pans the view.', 'small'); + + addTitle(host, 'AUTOTILES'); + const autotiles = state.project.assets.filter((a) => a.type === 'autotile'); + if (autotiles.length === 0) addNote(host, 'The project has no autotile yet.'); + for (const autotile of autotiles) { + const button = new Button(autotile.name, { + align: 'left', + // Choosing a tile is how one starts painting, so it reaches for the brush by itself. + onClick: () => + state.setTilemapPaint({ + autotileId: autotile.id, + tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool, + }), + }); + button.toggled = paint.autotileId === autotile.id; + widgets.autotiles.set(autotile.id, button); + host.body.add(button); + } + if (autotiles.length > 0 && !autotiles.some((a) => a.id === paint.autotileId)) + addNote(host, 'Choose an autotile to paint with.', 'small'); + + addTitle(host, 'LAYERS'); + addNote(host, 'The last layer is drawn on top.', 'small'); + const painted = paintedLayerId(state, asset); + // Bottom-up on screen, as in every layer list: the topmost row is the one drawn last. + for (let index = asset.layers.length - 1; index >= 0; index--) { + const layer = asset.layers[index]!; + const row = new HStack({ gap: Theme.gap }); + const visible = new Checkbox(isLayerVisible(layer)); + visible.onChange = (checked) => + state.execute(new SetTilemapLayerHiddenCommand(asset.id, layer.id, !checked)); + row.add(visible); + const choose = new Button(layer.name, { + align: 'left', + onClick: () => state.setTilemapPaint({ layerId: layer.id }), + }); + choose.toggled = layer.id === painted; + row.add(choose, 1); + const up = new Button('↑', { + variant: 'ghost', + width: 24, + onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index + 1)), + }); + const down = new Button('↓', { + variant: 'ghost', + width: 24, + onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index - 1)), + }); + const remove = new Button('✕', { + variant: 'ghost', + width: 24, + onClick: () => state.execute(new RemoveTilemapLayerCommand(asset.id, layer.id)), + }); + row.add(up); + row.add(down); + row.add(remove); + widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove }); + host.body.add(row); + } + addGap(host); + host.body.add(widgets.addLayer); +} + +/** + * What the section is built from, for the inspector's rebuild check: the layers and what is + * painted with. The cells themselves are deliberately absent – a stroke must not rebuild the + * panel under the pointer that is drawing it. + */ +export function tilemapSignature(state: EditorState, asset: TilemapAsset): string { + const paint = state.tilemapPaint; + const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(','); + return `${paint.tool}|${paint.autotileId}|${paintedLayerId(state, asset)}|${layers}`; +} diff --git a/src/editor/state.ts b/src/editor/state.ts index a564d7d5..85f5accf 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -139,6 +139,19 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! }; } +/** + * Select leaves the scene view as it is – the map is picked up and moved like any other object. + * The other three paint: brush the chosen autotile cell by cell, erase clears, rect fills the box + * a drag spans. Painting is a mode on purpose; a map one cannot move would be worse than a click. + */ +export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect'; + +export interface TilemapPaint { + tool: TilemapTool; + autotileId: string; + layerId: string; +} + export type EditorMode = 'edit' | 'play'; export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict'; @@ -184,6 +197,12 @@ export class EditorState { notice = ''; /** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */ keyClipboard: KeyClipboard | null = null; + /** + * What the scene view paints a tilemap with (#128): the tool, the autotile and the layer it goes + * on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of + * working rather than something the project remembers. + */ + tilemapPaint: TilemapPaint = { tool: 'select', autotileId: '', layerId: '' }; /** The real (saved) model and its undo stack while `project` is the play-mode copy. */ private edit: { project: Project; commands: CommandStack } | null = null; @@ -603,6 +622,12 @@ export class EditorState { * Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is * for the play status lines, which state a mode rather than report an event. */ + /** Changes what the scene view paints with; the inspector marks it and the view repaints. */ + setTilemapPaint(patch: Partial): void { + this.tilemapPaint = { ...this.tilemapPaint, ...patch }; + this.emit('selection'); + } + setNotice(message: string, level: NoticeLevel = 'info'): void { this.notice = message; if (message !== '' && level !== 'quiet') consoleLog.push(level, message); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index dd34451b..cbea6090 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -6,16 +6,38 @@ import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; import { oneStep } from '../../core/commands/CompoundCommand'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; +import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands'; +import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset'; import { hasSceneIcon } from '../../core/model/AssetTypes'; import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { objectRef, resolvedHolder } from '../../core/model/instance'; -import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core/model/matrix'; +import { + apply as applyPoint, + applyVector, + type Extent, + invert, + linearOf, + type Mat, +} from '../../core/model/matrix'; import { placementRejection } from '../../core/model/placement'; import type { Position, Vec2 } from '../../core/model/Properties'; -import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project'; +import { + cellAtPoint, + cellKey, + cellOrigin, + cellsAlong, + cellsInRect, + findLayer, + inGrid, + tilemapColumns, + tilemapRows, + tilemapTileSize, +} from '../../core/model/tilemap'; import { footprintCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; -import { type EditorState, objectSelection } from '../state'; +import { type EditorState, objectSelection, type TilemapTool } from '../state'; +import { paintedLayerId } from '../inspector/tilemapSection'; import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; import { collectHolderItems, type DrawItem } from '../../viewport/items'; import { dashedLine, drawSourceIcon } from '../../viewport/symbols'; @@ -45,6 +67,26 @@ interface DropPreview { rejection: string; } +/** + * A stroke on a placed tilemap. `previous` is what the cells held before the live preview wrote + * into them – the same trick a move drag uses: the model shows the stroke at once, and on release + * it is put back so the command records it as one undo step. + */ +interface PaintStroke { + holderId: string; + objectId: string; + assetId: string; + layerId: string; + tool: TilemapTool; + autotileId: string; + edits: CellEdit[]; + previous: Map; + from: Vec2; + last: Vec2 | null; + /** Where the pointer is now, in cells – what the rectangle preview is drawn from. */ + to: Vec2; +} + /** A rubber-band selection in progress, in viewport-local pixels. */ interface Marquee { start: Vec2; @@ -74,6 +116,7 @@ export class SceneViewport extends FreeViewport { private readonly labels = new Map(); private readonly icons = new Map(); private dragging: ObjectDrag | null = null; + private stroke: PaintStroke | null = null; private marquee: Marquee | null = null; private dropPreview: DropPreview | null = null; private lastGizmos: GizmoInfo[] = []; @@ -162,6 +205,19 @@ export class SceneViewport extends FreeViewport { return; } if (this.panTo(e)) return; + if (this.stroke) { + const item = this.lastItems.find((i) => i.id === this.stroke!.objectId); + if (!item) return; + const at = this.cellUnder(item, e); + if (this.stroke.last && at.x === this.stroke.last.x && at.y === this.stroke.last.y) return; + const from = this.stroke.last ?? this.stroke.from; + this.stroke.last = at; + this.stroke.to = at; + // A pointer jumps between events, so the cells in between are filled in as well. + if (this.stroke.tool !== 'rect') this.paintCells(item, cellsAlong(from, at)); + this.redraw(); + return; + } if (this.dragging) { const local = this.toLocal(e.global); const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; @@ -239,6 +295,10 @@ export class SceneViewport extends FreeViewport { return; } this.endPan(); + if (this.stroke) { + this.endStroke(); + return; + } const marquee = this.marquee; this.marquee = null; if (marquee) { @@ -263,6 +323,120 @@ export class SceneViewport extends FreeViewport { if (moves.length > 0) this.state.execute(oneStep('Move objects', moves)); } + /** + * The tilemap a click paints on: the primary selection, placed here, with a painting tool + * chosen. Anything else – another object, the select tool, a modifier held – leaves the view + * doing what it always did. + */ + private paintTargetOf(objectId: string): { item: DrawItem; asset: TilemapAsset; layerId: string } | null { + const paint = this.state.tilemapPaint; + if (paint.tool === 'select') return null; + const s = this.state.selection; + if (s?.kind !== 'object' || s.objectId !== objectId) return null; + const item = this.lastItems.find((i) => i.id === objectId); + if (!item || item.type !== 'tilemap') return null; + const object = findObject(this.resolved() ?? { objects: [] }, objectId); + const asset = object && findAssetOf(this.state.project, object.assetId, 'tilemap'); + if (!asset) return null; + const layerId = paintedLayerId(this.state, asset); + return layerId ? { item, asset, layerId } : null; + } + + /** The cell under a pointer position, in the tilemap's own grid. */ + private cellUnder(item: DrawItem, e: FederatedPointerEvent): Vec2 { + const local = this.toLocal(e.global); + const world = screenToWorld(this.currentView(), local.x, local.y); + const inMap = applyPoint(invert(item.m), world); + return cellAtPoint(item.props, inMap.x, inMap.y); + } + + private startStroke( + target: { item: DrawItem; asset: TilemapAsset; layerId: string }, + holderId: string, + e: FederatedPointerEvent, + ): boolean { + const paint = this.state.tilemapPaint; + const autotileId = paint.tool === 'erase' ? '' : paint.autotileId; + if (paint.tool !== 'erase' && !autotileId) { + this.state.setNotice('Choose an autotile to paint with.'); + return false; + } + const at = this.cellUnder(target.item, e); + this.stroke = { + holderId, + objectId: target.item.id, + assetId: target.asset.id, + layerId: target.layerId, + tool: paint.tool, + autotileId, + edits: [], + previous: new Map(), + from: at, + last: null, + to: at, + }; + // A rectangle only shows where it would go until the button comes up; the other two draw at once. + if (paint.tool !== 'rect') this.paintCells(target.item, [at]); + this.redraw(); + return true; + } + + private strokeLayer(stroke: PaintStroke): TilemapLayer | undefined { + const asset = findAssetOf(this.state.project, stroke.assetId, 'tilemap'); + return asset && findLayer(asset, stroke.layerId); + } + + /** Writes cells into the model as a preview, remembering what they held. */ + private paintCells(item: DrawItem, cells: readonly Vec2[]): void { + const stroke = this.stroke; + const layer = stroke && this.strokeLayer(stroke); + if (!stroke || !layer) return; + let changed = false; + for (const at of cells) { + if (!inGrid(item.props, at.x, at.y)) continue; + const key = cellKey(at.x, at.y); + if (!stroke.previous.has(key)) stroke.previous.set(key, layer.cells[key] ?? ''); + if ((layer.cells[key] ?? '') === stroke.autotileId) continue; + stroke.edits.push({ x: at.x, y: at.y, autotileId: stroke.autotileId }); + if (stroke.autotileId) layer.cells[key] = stroke.autotileId; + else delete layer.cells[key]; + changed = true; + } + if (changed) this.state.preview(); + } + + /** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */ + private endStroke(): void { + const stroke = this.stroke; + this.stroke = null; + if (!stroke) return; + const layer = this.strokeLayer(stroke); + if (layer) + for (const [key, value] of stroke.previous) { + if (value) layer.cells[key] = value; + else delete layer.cells[key]; + } + const item = this.lastItems.find((i) => i.id === stroke.objectId); + const edits = + stroke.tool === 'rect' && item + ? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({ + ...at, + autotileId: stroke.autotileId, + })) + : stroke.edits; + if (edits.length > 0) + this.state.execute( + new PaintTilesCommand( + stroke.assetId, + stroke.layerId, + edits, + stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles', + ), + ); + else this.state.preview(); + this.redraw(); + } + protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void { if (e.button !== 0) return; const holder = this.resolved(); @@ -273,6 +447,9 @@ export class SceneViewport extends FreeViewport { this.state.toggleObject(holder.id, objectId); return; } + // A painting tool on the selected tilemap: the press starts a stroke instead of a move. + const target = this.paintTargetOf(objectId); + if (target && this.startStroke(target, holder.id, e)) return; // Clicking a selected object keeps the selection so the whole group can be dragged. if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(holder.id, [objectId])); @@ -447,6 +624,52 @@ export class SceneViewport extends FreeViewport { .stroke({ width: 1, color }); } + /** + * While a painting tool is chosen, the selected tilemap shows its cells – without the grid one + * paints blind – and a rectangle drag shows the box it would fill. + */ + private drawPaintOverlay(items: DrawItem[], view: View): void { + const s = this.state.selection; + if (this.state.tilemapPaint.tool === 'select' || s?.kind !== 'object') return; + const item = items.find((i) => i.id === s.objectId && i.type === 'tilemap'); + if (!item) return; + const tile = tilemapTileSize(item.props); + const columns = tilemapColumns(item.props); + const rows = tilemapRows(item.props); + const corner = (x: number, y: number): Vec2 => { + const at = cellOrigin(item.props, x, y); + return worldToScreen( + view, + item.m.tx + at.x * item.m.a + at.y * item.m.c, + item.m.ty + at.x * item.m.b + at.y * item.m.d, + ); + }; + // Below this the lines are noise, exactly as the scene grid is (see `syncGrid`). + if (tile * view.scale >= MIN_GRID_STEP) { + for (let x = 0; x <= columns; x++) { + const a = corner(x, 0); + const b = corner(x, rows); + this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y); + } + for (let y = 0; y <= rows; y++) { + const a = corner(0, y); + const b = corner(columns, y); + this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y); + } + this.gizmos.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.25 }); + } + const stroke = this.stroke; + if (!stroke || stroke.tool !== 'rect') return; + const x0 = Math.min(stroke.from.x, stroke.to.x); + const y0 = Math.min(stroke.from.y, stroke.to.y); + const x1 = Math.max(stroke.from.x, stroke.to.x) + 1; + const y1 = Math.max(stroke.from.y, stroke.to.y) + 1; + this.gizmos + .poly([corner(x0, y0), corner(x1, y0), corner(x1, y1), corner(x0, y1)], true) + .fill({ color: Theme.colors.accent, alpha: 0.2 }) + .stroke({ width: 2, color: Theme.colors.accent }); + } + private screenCorners(corners: readonly Vec2[], view: View): Vec2[] { return corners.map((c) => worldToScreen(view, c.x, c.y)); } @@ -476,6 +699,7 @@ export class SceneViewport extends FreeViewport { } } this.drawDropPreview(view); + this.drawPaintOverlay(items, view); const m = this.marquee; if (m?.active) drawBand(this.gizmos, m.start, m.current); } diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index ee64488b..ec64d4fd 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -29,7 +29,9 @@ import { placeNode, syncSpriteMesh, } from '../../viewport/symbols'; +import { cellCentre, TilemapField } from '../../viewport/tilemap'; import { textureLibrary } from '../../viewport/TextureLibrary'; +import { gameClock } from '../../core/time'; import { type View, worldBounds, worldToScreen } from '../../viewport/view'; export interface Frame { @@ -53,6 +55,8 @@ export abstract class WorldViewport extends Widget { private readonly sprites = new Map(); /** The drawn particles of each source, one container – and one draw call – per source. */ private readonly fields = new Map(); + /** The meshes of each placed tilemap, one container per map. */ + private readonly tilemaps = new Map(); private visibleCount = 0; private animated = false; protected lastItems: DrawItem[] = []; @@ -245,6 +249,7 @@ export abstract class WorldViewport extends Widget { this.syncText(item, onScreen, view.scale); this.syncSprite(item, onScreen, size, shaders); this.syncParticles(item, onScreen, shaders); + this.syncTilemap(item, onScreen); if (!onScreen) continue; this.visibleCount++; drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m))); @@ -259,6 +264,8 @@ export abstract class WorldViewport extends Widget { this.sprites.delete(id); this.fields.get(id)?.destroy(); this.fields.delete(id); + this.tilemaps.get(id)?.destroy(); + this.tilemaps.delete(id); node.destroy(); this.nodes.delete(id); this.disposeDecorations(id); @@ -284,6 +291,8 @@ export abstract class WorldViewport extends Widget { if (text) this.objects.addChild(text); const field = this.fields.get(item.id); if (field) this.objects.addChild(field.container); + const tilemap = this.tilemaps.get(item.id); + if (tilemap) this.objects.addChild(tilemap.container); } } @@ -323,6 +332,49 @@ export abstract class WorldViewport extends Widget { ); } + /** + * A painted map, drawn from its own meshes. It keeps redrawing the view while an animated + * autotile is on it, the same way a Time shader does. + */ + private syncTilemap(item: DrawItem, onScreen: boolean): void { + let field = this.tilemaps.get(item.id); + if (item.type !== 'tilemap' || !item.tilemap || !onScreen) { + if (field) field.container.visible = false; + return; + } + if (!field) { + field = new TilemapField(); + this.tilemaps.set(item.id, field); + this.objects.addChild(field.container); + } + field.container.visible = true; + field.sync(item, textureLibrary, gameClock.now); + if (field.animated) this.animated = true; + } + + /** Where the centre of one cell of a placed tilemap is on screen, so a check can sample it. */ + cellScreen(objectId: string, x: number, y: number): Vec2 | null { + const view = this.currentView(); + const item = this.lastItems.find((i) => i.id === objectId); + if (!view || !item || item.type !== 'tilemap') return null; + const centre = cellCentre(item, x, y); + const local = worldToScreen(view, centre.x, centre.y); + const origin = this.getGlobalPosition(); + return { x: origin.x + local.x, y: origin.y + local.y }; + } + + /** What a placed tilemap drew last redraw: how many meshes, and how many cells in them (test hook). */ + tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null { + const field = this.tilemaps.get(objectId); + const item = this.lastItems.find((i) => i.id === objectId); + if (!field || !field.container.visible || !item?.tilemap) return null; + return { + drawCalls: field.drawCalls, + tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0), + layers: item.tilemap.layers.length, + }; + } + private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void { let sprite = this.sprites.get(item.id); if (item.type !== 'sprite' || !onScreen) { diff --git a/src/viewport/items.ts b/src/viewport/items.ts index da5a069b..30874962 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -5,6 +5,14 @@ import { resolvedHolder } from '../core/model/instance'; import type { Mat } from '../core/model/matrix'; import type { PropertyBag } from '../core/model/Properties'; import type { ParticlesAsset } from '../core/model/Asset'; +import { + type AutotileInfo, + autotileFps, + type AutotilePlayback, + autotilePlayback, + readAutotile, +} from '../core/model/autotile'; +import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { fontFileOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; @@ -36,6 +44,8 @@ export interface DrawItem { font: string; /** The asset a particle source emits from, for the same reason again: it carries the graph. */ particles: ParticlesAsset | null; + /** What a placed tilemap draws: its layers already cut into tiles, with their pictures resolved. */ + tilemap: TilemapDraw | null; /** How often an `Emit` node has restarted this source; a change is what makes it start over. */ emits: number; } @@ -45,6 +55,52 @@ interface DrawList { background: number; } +/** One autotile a tilemap paints with, as drawing needs it: the blocks and how they run. */ +export interface TilemapAutotile { + info: AutotileInfo; + playback: AutotilePlayback; + fps: number; +} + +/** One layer, already worked out: which tile each cell shows and which autotile draws it. */ +export interface TilemapLayerDraw { + id: string; + tiles: TileDraw[]; +} + +export interface TilemapDraw { + layers: TilemapLayerDraw[]; + autotiles: Map; +} + +/** + * A placed tilemap as the viewport needs it. The cells are turned into tiles here, where the + * project is at hand, for the same reason a sprite's picture is resolved here: drawing has no + * access to it. A hidden layer is left out, in the editor and in the running game alike. + */ +function resolveTilemap(project: Project, assetId: string, props: PropertyBag): TilemapDraw | null { + const asset = findAssetOf(project, assetId, 'tilemap'); + if (!asset) return null; + const layers: TilemapLayerDraw[] = []; + const autotiles = new Map(); + for (const layer of asset.layers) { + if (!isLayerVisible(layer)) continue; + const tiles = layerTiles(project, props, layer); + layers.push({ id: layer.id, tiles }); + for (const tile of tiles) { + if (autotiles.has(tile.autotileId)) continue; + const autotile = findAssetOf(project, tile.autotileId, 'autotile'); + if (!autotile) continue; + autotiles.set(tile.autotileId, { + info: readAutotile(project, autotile), + playback: autotilePlayback(autotile), + fps: autotileFps(autotile), + }); + } + } + return { layers, autotiles }; +} + /** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source * that throws it. */ function wearsPicture(type: AssetType): boolean { @@ -83,6 +139,7 @@ export function collectHolderItems( texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null, font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '', particles: asset.type === 'particles' ? asset : null, + tilemap: asset.type === 'tilemap' ? resolveTilemap(project, asset.id, props) : null, emits: 0, }); } @@ -119,6 +176,7 @@ export function collectWorldItems(world: World): DrawList { texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null, font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '', particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null, + tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props) : null, emits: e.emits ?? 0, })); return { items, background: world.backgroundColor }; diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 4d31b766..c3c64804 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -118,8 +118,11 @@ export function drawItem( switch (item.type) { // A source draws nothing of its own – what is seen of it are its particles. It still owns the // square around its position, so it can be clicked and dragged like anything else. + // A tilemap, like a sprite, draws itself elsewhere – from its own meshes – and owns the + // rectangle of its grid: that is what is dragged, and what a painting tool paints on. case 'particles': case 'sprite': + case 'tilemap': node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h); return; case 'camera': diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts new file mode 100644 index 00000000..42134488 --- /dev/null +++ b/src/viewport/tilemap.ts @@ -0,0 +1,122 @@ +/** + * The drawn side of a placed tilemap (#128): one mesh per autotile and layer, so a whole map is a + * handful of draw calls instead of one sprite per cell. The geometry itself is `tilemapMesh.ts`; + * what happens here is Pixi – which texture a mesh wears, which animation step it shows, and + * throwing away the meshes of a layer that is gone. + */ +import { Container, Mesh, MeshGeometry, type Texture } from 'pixi.js'; +import { type AutotilePicture, pictureIndexAt } from '../core/model/autotile'; +import { cellOrigin, tilemapTileSize } from '../core/model/tilemap'; +import type { DrawItem, TilemapAutotile } from './items'; +import { placeNode } from './symbols'; +import type { TextureLibrary } from './TextureLibrary'; +import { type TileSheet, tileMesh, tilesByAutotile } from './tilemapMesh'; + +export class TilemapField { + readonly container = new Container(); + private readonly meshes = new Map(); + /** True after a sync that drew an autotile with more than one picture. */ + animated = false; + /** How many meshes the last sync drew – one draw call each (test hook). */ + drawCalls = 0; + + constructor() { + this.container.eventMode = 'none'; + } + + /** + * `now` is the global clock, so every tilemap showing the same autotile is on the same step – + * animated water does not run out of phase between two maps. + */ + sync(item: DrawItem, textures: TextureLibrary, now: number): void { + const draw = item.tilemap; + this.animated = false; + // The whole map sits under one node, so the object's own transform is applied once rather + // than baked into every vertex. + placeNode(this.container, item.m); + const tile = tilemapTileSize(item.props); + const origin = cellOrigin(item.props, 0, 0); + const drawn: Mesh[] = []; + const alive = new Set(); + for (const layer of draw?.layers ?? []) { + for (const [autotileId, tiles] of tilesByAutotile(layer.tiles)) { + const autotile = draw?.autotiles.get(autotileId); + const picture = autotile && pictureNow(autotile, now); + const texture = picture ? textures.get(picture.ref) : null; + if (!autotile || !picture || !texture) continue; + if (autotile.info.pictures.length > 1) this.animated = true; + const key = `${layer.id}|${autotileId}`; + alive.add(key); + const { positions, uvs, indices } = tileMesh( + tiles, + tile, + origin, + sheetOf(autotile, picture, texture), + ); + drawn.push(this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture)); + } + } + for (const [key, mesh] of this.meshes) { + if (alive.has(key)) continue; + mesh.destroy(); + this.meshes.delete(key); + } + // Re-attached in order rather than sorted lazily: the layer order is the drawing order, and + // the list is at most one entry per autotile and layer. + this.container.removeChildren(); + for (const mesh of drawn) this.container.addChild(mesh); + this.drawCalls = drawn.length; + } + + private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh { + const held = this.meshes.get(key); + if (held) { + held.geometry.destroy(); + held.geometry = geometry; + held.texture = texture; + return held; + } + const mesh = new Mesh({ geometry, texture }); + mesh.eventMode = 'none'; + this.meshes.set(key, mesh); + return mesh; + } + + destroy(): void { + this.container.destroy({ children: true }); + this.meshes.clear(); + } +} + +/** + * The block this autotile shows right now. A quarter is cut from it by uvs rather than by the + * texture library, so what is wanted here is the picture of the step and nothing finer. + */ +function pictureNow(autotile: TilemapAutotile, now: number): AutotilePicture | undefined { + const { pictures, problem } = autotile.info; + if (problem) return undefined; + return pictures[pictureIndexAt(pictures.length, autotile.playback, autotile.fps, now)]; +} + +/** + * The library hands out the block as a texture of its own, so the quadrants count from its corner + * whether the picture was a whole sheet or one frame cut from one. + */ +function sheetOf(autotile: TilemapAutotile, picture: AutotilePicture, texture: Texture): TileSheet { + return { + quad: autotile.info.quad, + block: { x: 0, y: 0, w: texture.width, h: texture.height }, + textureWidth: texture.width, + textureHeight: texture.height, + smooth: picture.ref.smooth, + }; +} + +/** Where the centre of one cell of a placed tilemap lands in world units – what a pixel check aims at. */ +export function cellCentre(item: DrawItem, x: number, y: number): { x: number; y: number } { + const tile = tilemapTileSize(item.props); + const origin = cellOrigin(item.props, x, y); + const local = { x: origin.x + tile / 2, y: origin.y + tile / 2 }; + const m = item.m; + return { x: m.tx + local.x * m.a + local.y * m.c, y: m.ty + local.x * m.b + local.y * m.d }; +} diff --git a/src/viewport/tilemapMesh.ts b/src/viewport/tilemapMesh.ts new file mode 100644 index 00000000..84599bfb --- /dev/null +++ b/src/viewport/tilemapMesh.ts @@ -0,0 +1,98 @@ +/** + * The geometry a painted layer becomes: one mesh per autotile instead of one sprite per cell, so a + * map of a thousand tiles costs as many draw calls as it has autotiles on that layer (#128). Kept a + * plain function of numbers – no Pixi, no project – so the quarter that lands in a corner can be + * checked without a browser. + */ +import type { FrameRect } from '../core/model/texture'; +import type { TileDraw } from '../core/model/tilemap'; + +/** Where a tile's quarters are cut from, in the pixels of the texture the mesh wears. */ +export interface TileSheet { + /** Side of one quarter tile in image pixels. */ + quad: number; + /** The block inside that texture; the quadrant positions count from its top-left corner. */ + block: FrameRect; + textureWidth: number; + textureHeight: number; + /** + * Whether the texture is sampled smoothly. It is, the uvs are pulled half a texel inwards: a + * linear sample right on the border of a quarter otherwise reaches into the one next to it and + * draws a seam along every tile. + */ + smooth: boolean; +} + +export interface MeshArrays { + positions: Float32Array; + uvs: Float32Array; + indices: Uint32Array; +} + +/** The corners of one quad, in reading order – the order `tileQuadrants` uses for the four quarters. */ +const CORNER_OFFSETS = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], +] as const; + +/** + * `origin` is where cell (0, 0) starts in the tilemap's own frame, `tile` the side of a drawn cell + * in world units. Every cell contributes four quads – its four quarters – so a tile whose corners + * come from four corners of the block is one thing to draw, not four. + */ +export function tileMesh( + tiles: readonly TileDraw[], + tile: number, + origin: { x: number; y: number }, + sheet: TileSheet, +): MeshArrays { + const positions = new Float32Array(tiles.length * 4 * 4 * 2); + const uvs = new Float32Array(positions.length); + const indices = new Uint32Array(tiles.length * 4 * 6); + const half = tile / 2; + const insetX = sheet.smooth ? 0.5 / sheet.textureWidth : 0; + const insetY = sheet.smooth ? 0.5 / sheet.textureHeight : 0; + let v = 0; + let i = 0; + for (const drawn of tiles) { + const left = origin.x + drawn.x * tile; + const top = origin.y + drawn.y * tile; + drawn.quadrants.forEach((position, corner) => { + const x = left + (corner % 2) * half; + const y = top + Math.floor(corner / 2) * half; + const u0 = (sheet.block.x + position.x * sheet.quad) / sheet.textureWidth + insetX; + const v0 = (sheet.block.y + position.y * sheet.quad) / sheet.textureHeight + insetY; + const u1 = (sheet.block.x + (position.x + 1) * sheet.quad) / sheet.textureWidth - insetX; + const v1 = (sheet.block.y + (position.y + 1) * sheet.quad) / sheet.textureHeight - insetY; + const base = v / 2; + for (const [dx, dy] of CORNER_OFFSETS) { + positions[v] = x + dx * half; + uvs[v] = dx ? u1 : u0; + v++; + positions[v] = y + dy * half; + uvs[v] = dy ? v1 : v0; + v++; + } + indices[i++] = base; + indices[i++] = base + 1; + indices[i++] = base + 2; + indices[i++] = base + 2; + indices[i++] = base + 1; + indices[i++] = base + 3; + }); + } + return { positions, uvs, indices }; +} + +/** The cells of one layer grouped by autotile – one group is one mesh, because it is one texture. */ +export function tilesByAutotile(tiles: readonly TileDraw[]): Map { + const groups = new Map(); + for (const drawn of tiles) { + const group = groups.get(drawn.autotileId); + if (group) group.push(drawn); + else groups.set(drawn.autotileId, [drawn]); + } + return groups; +} diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts new file mode 100644 index 00000000..7b451ac5 --- /dev/null +++ b/tests/core/tilemap.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateTextureCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { AddAutotilePictureCommand } from '../../src/core/commands/autotileCommands'; +import { + AddTilemapLayerCommand, + MoveTilemapLayerCommand, + PaintTilesCommand, + RemoveTilemapLayerCommand, + RenameTilemapLayerCommand, + SetTilemapLayerHiddenCommand, +} from '../../src/core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../src/core/model/Asset'; +import { isPlaceableType } from '../../src/core/model/AssetTypes'; +import { AUTOTILE_KIND_KEY } from '../../src/core/model/autotile'; +import { findAsset, type Project } from '../../src/core/model/Project'; +import { + cellAt, + cellAtPoint, + cellKey, + cellOrigin, + cellsAlong, + cellsInRect, + inGrid, + layerTiles, + neighboursOf, + TILEMAP_COLUMNS_KEY, + TILEMAP_ROWS_KEY, + TILEMAP_TILE_KEY, + tilemapAutotileIds, + tilemapSize, +} from '../../src/core/model/tilemap'; +import { tilemapsUsing } from '../../src/core/model/usages'; +import { tileMesh, tilesByAutotile } from '../../src/viewport/tilemapMesh'; +import { makeProject, roundTrip } from '../helpers'; + +const FILE = `${'a1b2c3d4'.repeat(8)}.png`; + +/** A tilemap with a floor autotile to paint with, both sized like the RPG-Maker sheets. */ +function makeTilemap(kind: 'floor' | 'wall' = 'floor') { + const fixture = makeProject(); + const texture = new CreateTextureCommand('Outside', { + file: FILE, + width: 96, + height: kind === 'floor' ? 144 : 96, + }); + fixture.stack.execute(texture); + const autotile = new CreateAssetCommand('autotile', 'Grass'); + fixture.stack.execute(autotile); + fixture.stack.execute(new SetAssetPropertyCommand(autotile.asset.id, AUTOTILE_KIND_KEY, kind)); + fixture.stack.execute(new AddAutotilePictureCommand(autotile.asset.id, texture.asset.id)); + const tilemap = new CreateAssetCommand('tilemap', 'Ground'); + fixture.stack.execute(tilemap); + const assetOf = () => findAsset(fixture.project, tilemap.asset.id) as TilemapAsset; + return { + ...fixture, + autotileId: autotile.asset.id, + tilemapId: tilemap.asset.id, + assetOf, + layerId: () => assetOf().layers[0]!.id, + paint: (cells: { x: number; y: number }[], autotileId = autotile.asset.id) => + fixture.stack.execute( + new PaintTilesCommand( + tilemap.asset.id, + assetOf().layers[0]!.id, + cells.map((at) => ({ ...at, autotileId })), + ), + ), + }; +} + +function cellsOf(project: Project, assetId: string): Record { + const asset = findAsset(project, assetId) as TilemapAsset; + return asset.layers[0]!.cells; +} + +describe('the tilemap asset', () => { + it('is placed, unlike the autotiles it draws with', () => { + expect(isPlaceableType('tilemap')).toBe(true); + expect(isPlaceableType('autotile')).toBe(false); + }); + + it('starts with one layer and no cells', () => { + const f = makeTilemap(); + expect(f.assetOf().layers).toHaveLength(1); + expect(f.assetOf().layers[0]!.cells).toEqual({}); + }); + + it('is as big as its grid says, centred on its position', () => { + const f = makeTilemap(); + const props = f.assetOf().props; + expect(tilemapSize(props)).toEqual({ w: 20 * 48, h: 15 * 48 }); + expect(cellOrigin(props, 0, 0)).toEqual({ x: -480, y: -360 }); + // The centre of the map is the corner of the four middle cells. + expect(cellAtPoint(props, 0, 0)).toEqual({ x: 10, y: 7 }); + expect(cellAtPoint(props, -480, -360)).toEqual({ x: 0, y: 0 }); + expect(cellAtPoint(props, -1, -1)).toEqual({ x: 9, y: 7 }); + }); + + it('follows the grid and the cell size when they change', () => { + const f = makeTilemap(); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 4)); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 3)); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_TILE_KEY, 10)); + expect(tilemapSize(f.assetOf().props)).toEqual({ w: 40, h: 30 }); + expect(inGrid(f.assetOf().props, 3, 2)).toBe(true); + expect(inGrid(f.assetOf().props, 4, 2)).toBe(false); + expect(inGrid(f.assetOf().props, -1, 0)).toBe(false); + }); +}); + +describe('painting a tilemap', () => { + it('paints, erases and takes both back as one step', () => { + const f = makeTilemap(); + f.paint([ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ]); + expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId }); + f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: '' }])); + expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.autotileId }); + f.stack.undo(); + expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId }); + f.stack.undo(); + expect(cellsOf(f.project, f.tilemapId)).toEqual({}); + }); + + it('puts back what a stroke painted over, not merely empty', () => { + const f = makeTilemap(); + const other = new CreateAssetCommand('autotile', 'Sand'); + f.stack.execute(other); + f.paint([{ x: 3, y: 3 }]); + f.paint([{ x: 3, y: 3 }], other.asset.id); + expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(other.asset.id); + f.stack.undo(); + expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.autotileId); + }); + + it('knows when a stroke would change nothing', () => { + const f = makeTilemap(); + f.paint([{ x: 0, y: 0 }]); + const layer = f.assetOf().layers[0]!; + expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: f.autotileId }])).toBe(false); + expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: '' }])).toBe(true); + expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, autotileId: f.autotileId }])).toBe(true); + }); + + it('covers the cells between two pointer events', () => { + expect(cellsAlong({ x: 0, y: 0 }, { x: 3, y: 0 })).toEqual([ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ]); + expect(cellsAlong({ x: 0, y: 0 }, { x: 2, y: 2 })).toEqual([ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + ]); + expect(cellsAlong({ x: 4, y: 2 }, { x: 4, y: 2 })).toEqual([{ x: 4, y: 2 }]); + }); + + it('clips a rectangle to the grid', () => { + const f = makeTilemap(); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 3)); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2)); + const cells = cellsInRect(f.assetOf().props, { x: -2, y: 1 }, { x: 9, y: 9 }); + expect(cells).toEqual([ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ]); + }); +}); + +describe('what a painted cell shows', () => { + it('joins only cells holding the same autotile', () => { + const f = makeTilemap(); + const other = new CreateAssetCommand('autotile', 'Sand'); + f.stack.execute(other); + f.paint([ + { x: 5, y: 5 }, + { x: 6, y: 5 }, + ]); + f.paint([{ x: 5, y: 4 }], other.asset.id); + const layer = f.assetOf().layers[0]!; + const props = f.assetOf().props; + const around = neighboursOf(props, layer, 5, 5, f.autotileId); + expect(around.e).toBe(true); + // The cell above holds a *different* autotile, so this one closes against it. + expect(around.n).toBe(false); + expect(around.w).toBe(false); + }); + + it('counts anything outside the grid as empty', () => { + const f = makeTilemap(); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2)); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2)); + f.paint([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ]); + const asset = f.assetOf(); + const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.autotileId); + expect(around.n).toBe(true); + expect(around.w).toBe(true); + expect(around.e).toBe(false); + expect(around.s).toBe(false); + }); + + it('gives a lone cell the four convex corners and a joined one the inside', () => { + const f = makeTilemap(); + f.paint([{ x: 2, y: 2 }]); + const asset = f.assetOf(); + const lone = layerTiles(f.project, asset.props, asset.layers[0]!)[0]!; + // The block's convex corners, as `FLOOR_CORNERS` names them. + expect(lone.quadrants).toEqual([ + { x: 0, y: 2 }, + { x: 3, y: 2 }, + { x: 0, y: 5 }, + { x: 3, y: 5 }, + ]); + f.paint([ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 3, y: 1 }, + { x: 1, y: 2 }, + { x: 3, y: 2 }, + { x: 1, y: 3 }, + { x: 2, y: 3 }, + { x: 3, y: 3 }, + ]); + const after = layerTiles(f.project, f.assetOf().props, f.assetOf().layers[0]!); + const middle = after.find((tile) => tile.x === 2 && tile.y === 2)!; + expect(middle.quadrants).toEqual([ + { x: 2, y: 4 }, + { x: 1, y: 4 }, + { x: 2, y: 3 }, + { x: 1, y: 3 }, + ]); + }); + + it('leaves out cells outside the grid and cells naming nothing', () => { + const f = makeTilemap(); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2)); + f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_ROWS_KEY, 2)); + f.paint([ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + ]); + f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: 'gone' }])); + const asset = f.assetOf(); + const tiles = layerTiles(f.project, asset.props, asset.layers[0]!); + expect(tiles.map((t) => `${t.x},${t.y}`)).toEqual(['0,0']); + // The cell naming a deleted autotile is kept in the model – an undo may bring it back. + expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone'); + }); + + it('reads the same tiles twice, in reading order', () => { + const f = makeTilemap(); + f.paint([ + { x: 4, y: 2 }, + { x: 1, y: 1 }, + { x: 2, y: 1 }, + ]); + const asset = f.assetOf(); + const order = layerTiles(f.project, asset.props, asset.layers[0]!).map((t) => `${t.x},${t.y}`); + expect(order).toEqual(['1,1', '2,1', '4,2']); + }); +}); + +describe('the layers of a tilemap', () => { + it('adds, renames, reorders, hides and removes, each with an undo', () => { + const f = makeTilemap(); + const add = new AddTilemapLayerCommand(f.tilemapId); + f.stack.execute(add); + expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']); + + f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, add.layerId, 'Roofs')); + expect(f.assetOf().layers[1]!.name).toBe('Roofs'); + + f.stack.execute(new MoveTilemapLayerCommand(f.tilemapId, 1, 0)); + expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Roofs', 'Layer 1']); + f.stack.undo(); + expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']); + + f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true)); + expect(f.assetOf().layers[1]!.hidden).toBe(true); + f.stack.undo(); + expect(f.assetOf().layers[1]!.hidden).toBeUndefined(); + + f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId)); + expect(f.assetOf().layers).toHaveLength(1); + f.stack.undo(); + expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']); + }); + + it('keeps the last layer, because there has to be one to paint on', () => { + const f = makeTilemap(); + expect(() => f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, f.layerId()))).toThrow( + 'A tilemap keeps at least one layer', + ); + expect(f.assetOf().layers).toHaveLength(1); + }); + + it('takes the cells of a removed layer with it and brings them back', () => { + const f = makeTilemap(); + const add = new AddTilemapLayerCommand(f.tilemapId); + f.stack.execute(add); + f.stack.execute( + new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, autotileId: f.autotileId }]), + ); + f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId)); + expect(f.assetOf().layers).toHaveLength(1); + f.stack.undo(); + expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.autotileId }); + }); +}); + +describe('a tilemap in the document', () => { + it('survives a round trip with its layers and cells', () => { + const f = makeTilemap(); + const add = new AddTilemapLayerCommand(f.tilemapId); + f.stack.execute(add); + f.stack.execute(new SetTilemapLayerHiddenCommand(f.tilemapId, add.layerId, true)); + f.paint([{ x: 1, y: 2 }]); + const back = roundTrip(f.project); + const asset = findAsset(back, f.tilemapId) as TilemapAsset; + expect(asset.layers).toHaveLength(2); + expect(asset.layers[0]!.cells).toEqual({ '1,2': f.autotileId }); + expect(asset.layers[1]!.hidden).toBe(true); + }); + + it('drops cells that are not a pair of whole numbers, and doubled layers', () => { + const f = makeTilemap(); + const asset = f.assetOf(); + asset.layers[0]!.cells['nonsense'] = f.autotileId; + asset.layers[0]!.cells['1.5,2'] = f.autotileId; + asset.layers[0]!.cells['3,3'] = f.autotileId; + asset.layers.push({ ...asset.layers[0]!, name: 'Twin' }); + const back = findAsset(roundTrip(f.project), f.tilemapId) as TilemapAsset; + expect(back.layers).toHaveLength(1); + expect(back.layers[0]!.cells).toEqual({ '3,3': f.autotileId }); + }); + + it('counts as a use of the autotiles painted on it', () => { + const f = makeTilemap(); + expect(tilemapsUsing(f.project, [f.autotileId])).toHaveLength(0); + f.paint([{ x: 0, y: 0 }]); + expect(tilemapsUsing(f.project, [f.autotileId]).map((a) => a.id)).toEqual([f.tilemapId]); + expect(tilemapAutotileIds(f.assetOf())).toEqual([f.autotileId]); + }); +}); + +describe('the mesh a layer becomes', () => { + const sheet = { quad: 24, block: { x: 0, y: 0, w: 96, h: 144 }, textureWidth: 96, textureHeight: 144 }; + + it('is one mesh per autotile, not one per cell', () => { + const f = makeTilemap(); + const other = new CreateAssetCommand('autotile', 'Sand'); + f.stack.execute(other); + f.paint([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]); + f.paint([{ x: 4, y: 0 }], other.asset.id); + const asset = f.assetOf(); + const groups = tilesByAutotile(layerTiles(f.project, asset.props, asset.layers[0]!)); + expect(groups.size).toBe(2); + expect(groups.get(f.autotileId)).toHaveLength(3); + }); + + it('builds four quads per cell, at the cell it belongs to', () => { + const tiles = [ + { + x: 1, + y: 0, + autotileId: 'a', + quadrants: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ] as [ + { x: number; y: number }, + { x: number; y: number }, + { x: number; y: number }, + { x: number; y: number }, + ], + }, + ]; + const { positions, uvs, indices } = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false }); + expect(positions).toHaveLength(4 * 4 * 2); + expect(indices).toHaveLength(4 * 6); + // The cell starts at x = 48, and its top-left quarter is 24 wide. + expect([positions[0], positions[1]]).toEqual([48, 0]); + expect([positions[2], positions[3]]).toEqual([72, 0]); + // Quadrant (0, 0) of a 96 × 144 sheet cut into 24 px quarters is the first quarter of the uvs. + expect([uvs[0], uvs[1]]).toEqual([0, 0]); + expect([uvs[2], uvs[3]]).toEqual([24 / 96, 0]); + }); + + it('pulls the uvs half a texel in when the picture is sampled smoothly', () => { + const tiles = [ + { + x: 0, + y: 0, + autotileId: 'a', + quadrants: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 0, y: 0 }, + ] as [ + { x: number; y: number }, + { x: number; y: number }, + { x: number; y: number }, + { x: number; y: number }, + ], + }, + ]; + const hard = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: false }); + const soft = tileMesh(tiles, 48, { x: 0, y: 0 }, { ...sheet, smooth: true }); + expect(hard.uvs[0]).toBe(0); + expect(soft.uvs[0]).toBeCloseTo(0.5 / 96, 6); + // The positions are untouched: only what is sampled moves, never where it lands. + expect([...soft.positions]).toEqual([...hard.positions]); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index dd21cdfa..f7f38621 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -74,7 +74,7 @@ export async function assets(t) { // 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups – // what can be placed in a scene, a line, then what only lives in the library – and it is - // tall enough to show all sixteen types without scrolling. + // tall enough to show all seventeen types without scrolling. await clickText('+'); const rows = await json('__gm.menuRows()'); const cut = rows.indexOf(null); @@ -87,7 +87,7 @@ export async function assets(t) { ); check( 'every asset type is offered once, each group sorted by label', - placeable.length + library.length === 16 && + placeable.length + library.length === 17 && String(placeable) === String([...placeable].sort()) && String(library) === String([...library].sort()), JSON.stringify({ placeable, library }), diff --git a/tools/e2e/39-autotile.mjs b/tools/e2e/39-autotile.mjs index 5d12f85d..c6216e85 100644 --- a/tools/e2e/39-autotile.mjs +++ b/tools/e2e/39-autotile.mjs @@ -39,7 +39,7 @@ const PASTE_SHEET = `(async () => { })()`; /** The colour a quarter of a block was drawn in – the same formula as the paste above. */ -function quadColour(kind, qx, qy, step = 0) { +export function quadColour(kind, qx, qy, step = 0) { const [r, g, b] = kind === 'wall' ? [30 + qy * 60, 40 + qx * 40, 20] : [40 + qx * 60, 30 + qy * 40, step ? 200 : 90]; return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs new file mode 100644 index 00000000..0a0a9e87 --- /dev/null +++ b/tools/e2e/40-tilemap.mjs @@ -0,0 +1,299 @@ +import { colourDistance } from '../lib/harness.mjs'; +import { LANES, openTab, pickAssetType } from './lib/scaffold.mjs'; +import { quadColour } from './39-autotile.mjs'; + +/** + * Tilemaps (#128): a placed grid painted with the autotiles of step 39, which is where the sheet + * and the `at-grass` autotile come from – this step needs it and is run after it. + * + * The point of the checks below is that a painted cell really shows the quarters its neighbours + * call for: they read pixels off the scene view, because everything between the model and the + * screen (the uvs, the mesh, the transform of the placed object) is exactly what a unit test + * cannot see through. + */ + +const LANE = LANES.tilemap; +const MAP_OBJECT = 'tm-object'; +const COLUMNS = 6; +const ROWS = 4; + +/** + * A widget of the painting section, scrolled into the inspector first. The section sits below the + * rows of the object, so on this window it starts out below the panel – and a click on a point + * outside the panel lands on whatever is drawn there instead. + */ +async function inInspector(t, hook) { + const anchor = await t.json("__gm.inspectorField('Columns')"); + for (let i = 0; i < 15; i++) { + const rect = await t.json('__gm.inspectorRect()'); + const point = await t.json(hook); + if (point && point.y > rect.y + 8 && point.y < rect.y + rect.h - 8) return point; + await t.wheel(anchor.x, anchor.y, 200); + await t.settle(); + } + return null; +} + +/** Clicks such a widget; returns false when it could not be brought into view. */ +async function clickInInspector(t, hook) { + const point = await inInspector(t, hook); + if (point) await t.click(point.x, point.y); + return Boolean(point); +} + +/** The four quarters of the cell, read in one call: a running animation must not step between them. */ +async function cellQuarters(t, x, y) { + const centre = await t.json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`); + if (!centre) return null; + // A quarter of a 48 px cell is 24 px, so these four points sit in the middle of each quarter. + const points = [ + [centre.x - 12, centre.y - 12], + [centre.x + 12, centre.y - 12], + [centre.x - 12, centre.y + 12], + [centre.x + 12, centre.y + 12], + ].map(([px, py]) => [Math.round(px), Math.round(py)]); + return t.json(`${JSON.stringify(points)}.map(([x, y]) => __gm.pixelAt(x, y))`); +} + +/** Whether a cell is drawn from these quadrants of the block, by the colours they were drawn in. */ +async function cellShows(t, x, y, quadrants) { + const read = await cellQuarters(t, x, y); + if (!read) return { ok: false, detail: 'the cell is not on screen' }; + const want = quadrants.map(([qx, qy]) => quadColour('floor', qx, qy)); + const off = read.filter((colour, i) => colourDistance(colour, want[i]) > 12); + return { ok: off.length === 0, detail: JSON.stringify({ read, want }) }; +} + +export async function tilemap(t) { + const { check, json, evaluate, mutate, click, clickText, drag, press, shot, settle, undo, select } = t; + + await press('Escape'); + await evaluate('__gm.state.select(null)'); + await openTab(t, 'scene'); + + // 40a. The asset is made the way any asset is made: through the + menu of the asset panel. + await clickText('+'); + await pickAssetType(t, 'Tilemap'); + await settle(); + const made = await json( + "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'tilemap').at(-1);" + + ' return a ? { id: a.id, name: a.name, layers: a.layers.length } : null; })()', + ); + check( + 'the + menu makes a tilemap, and it starts with one layer', + made?.name === 'Tilemap' && made.layers === 1, + JSON.stringify(made), + ); + if (!made) return; + + // A small grid in a lane of its own: the origin is crowded by the objects of earlier steps, and + // a 20 × 15 map at 48 px would cover half of them. + await mutate(` + const project = __gm.state.project; + const map = project.assets.find((a) => a.id === ${JSON.stringify(made.id)}); + map.props.columns = ${COLUMNS}; + map.props.rows = ${ROWS}; + __gm.state.activeScene.objects.push({ id: ${JSON.stringify(MAP_OBJECT)}, assetId: ${JSON.stringify(made.id)}, + name: 'Ground', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 }, + overrides: {}, parentId: '', instanceEdits: {} }); + __gm.lookAt(0, ${LANE});`); + await settle(); + await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT }); + await settle(); + + // 40b. The inspector paints: a tool, the autotiles of the project, the layers. + const section = await json( + '(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.autotileAt("at-grass") !== null,' + + ' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()', + ); + check( + 'a selected tilemap offers tools, autotiles and layers in the inspector', + section.tools && section.grass && Boolean(section.layers), + JSON.stringify(section), + ); + // Clicked through the hook rather than by its text: the autotile's own row in the asset panel + // reads "Grass" as well, and a click there would only select the autotile. + check( + 'the autotile can be picked in the section', + await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")'), + JSON.stringify(await json('__gm.inspectorRect()')), + ); + await settle(); + const paint = await json('__gm.tilemap.paint()'); + check( + 'picking an autotile reaches for the brush by itself', + paint.autotileId === 'at-grass' && paint.tool === 'brush', + JSON.stringify(paint), + ); + + // 40c. Painting: a drag across the map paints every cell it passes, in one undo step. + const scene = await json( + `(() => ({ tab: __gm.tab(), rect: __gm.objectRect(${JSON.stringify(MAP_OBJECT)}),` + + ' selection: __gm.state.selection, paint: __gm.tilemap.paint() }))()', + ); + check( + 'the map is on screen in the scene tab, selected, with a brush in hand', + scene.tab === 'scene' && Boolean(scene.rect) && scene.paint.tool === 'brush', + JSON.stringify(scene), + ); + const from = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`); + const to = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 3, 1)`); + await drag(from.x, from.y, to.x, to.y); + await settle(); + const layerId = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[0].id; + const painted = () => json(`__gm.tilemap.cells(${JSON.stringify(made.id)}, ${JSON.stringify(layerId)})`); + let cells = await painted(); + check( + 'dragging the brush paints every cell along the way', + Object.keys(cells).sort().join(' ') === '1,1 2,1 3,1', + JSON.stringify(cells), + ); + await undo(); + await settle(); + check( + 'and the whole stroke is one undo step', + Object.keys(await painted()).length === 0, + JSON.stringify(await painted()), + ); + + // 40d. The rectangle tool fills the box a drag spans. + await clickInInspector(t, '__gm.tilemap.toolAt("rect")'); + await settle(); + const corner = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`); + const far = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`); + await drag(corner.x, corner.y, far.x, far.y); + await settle(); + cells = await painted(); + check( + 'the rectangle tool fills the box between the two corners', + Object.keys(cells).length === 9 && cells['1,1'] === 'at-grass' && cells['2,2'] === 'at-grass', + JSON.stringify(Object.keys(cells).sort()), + ); + + // 40e. The pixels. While the animation runs the block under the pointer changes between the two + // steps, so it is cut down to one for the reading and put back afterwards (as step 39 does). + await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); + await settle(); + // The rest of 40e: the middle of a 3 × 3 block is surrounded, so all four of its quarters come + // from the inside of the block – taken turned by half a tile, as RPG Maker does it. The + // corner cell has neighbours east and south only, so it shows the convex corner up left. + const middle = await cellShows(t, 1, 1, [ + [2, 4], + [1, 4], + [2, 3], + [1, 3], + ]); + check('a cell surrounded by its own kind is drawn from the inside of the block', middle.ok, middle.detail); + const corner00 = await cellShows(t, 0, 0, [ + [0, 2], + [1, 2], + [0, 3], + [1, 3], + ]); + check('and the top-left cell of the block shows the convex corner there', corner00.ok, corner00.detail); + await shot('40-tilemap'); + await mutate( + "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", + ); + await settle(); + + // 40f. One mesh per autotile, not one per cell – the whole point of drawing it as a mesh. + const drawn = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); + check( + 'nine cells of one autotile are one draw call', + drawn.tiles === 9 && drawn.drawCalls === 1 && drawn.layers === 1, + JSON.stringify(drawn), + ); + + // 40g. Erasing takes cells out again. + await clickInInspector(t, '__gm.tilemap.toolAt("erase")'); + await settle(); + const one = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`); + await click(one.x, one.y); + await settle(); + cells = await painted(); + check( + 'the eraser clears the cell it is clicked on and leaves the rest', + Object.keys(cells).length === 8 && cells['2,2'] === undefined, + JSON.stringify(Object.keys(cells).sort()), + ); + await undo(); + await settle(); + + // 40h. A second layer draws above the first, and hiding it leaves its cells alone. + await clickInInspector(t, '__gm.tilemap.addLayerAt()'); + await settle(); + let layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + check( + 'Add layer appends a second layer, on top of the first', + layers.length === 2 && layers[1].cells === 0, + JSON.stringify(layers), + ); + await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")'); + await settle(); + const above = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 0)`); + await click(above.x, above.y); + await settle(); + layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + check( + 'painting goes onto the chosen layer, not the first one', + layers[0].cells === 9 && layers[1].cells === 1, + JSON.stringify(layers), + ); + // Hiding the upper layer (the list is drawn top-down, newest first) leaves its cell in the model + // and takes it out of the drawing. + const row = await inInspector(t, '__gm.tilemap.layerRowAt(0)'); + if (row) await click(row.visible.x, row.visible.y); + await settle(); + const hidden = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + const drawnNow = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`); + check( + 'switching a layer off keeps its cells and takes it out of the drawing', + hidden[1].hidden === true && hidden[1].cells === 1 && drawnNow.layers === 1 && drawnNow.tiles === 9, + JSON.stringify({ hidden, drawnNow }), + ); + await undo(); + await settle(); + + // 40i. Back to Select: the map is moved again instead of painted on. + await clickInInspector(t, '__gm.tilemap.toolAt("select")'); + await settle(); + const before = (await t.object(MAP_OBJECT)).position; + const grab = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`); + await drag(grab.x, grab.y, grab.x + 40, grab.y); + await settle(); + const after = (await t.object(MAP_OBJECT)).position; + cells = await painted(); + check( + 'with Select the map is dragged like any other object and nothing is painted', + Math.round(after.x - before.x) === 40 && Object.keys(cells).length === 9, + JSON.stringify({ before, after, cells: Object.keys(cells).length }), + ); + await undo(); + await settle(); + + // 40j. The running game draws the same map: it is scene content, not editor decoration. The map + // is moved in front of the camera for this, because an object off screen is culled there. + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}); + o.position = { x: 0, y: 0, z: 0 };`); + await settle(); + await t.play(); + await settle(); + const game = await json(`__gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)})`); + check( + 'the map is drawn in the running game as well', + game && game.tiles === 10 && game.layers === 2, + JSON.stringify(game), + ); + await t.stop(); + await settle(); + await mutate(` + const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}); + o.position = { x: 0, y: ${LANE}, z: 0 };`); + + // Leave the editor as the rest of the suite expects to find it. + await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })"); + await evaluate('__gm.state.select(null)'); + await mutate('__gm.lookAt(0, 0)'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index c67663c9..06be6270 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -42,6 +42,7 @@ import { graphGroups } from './36-graph-groups.mjs'; import { nineSlice } from './37-nine-slice.mjs'; import { plugins } from './38-plugins.mjs'; import { autotile } from './39-autotile.mjs'; +import { tilemap } from './40-tilemap.mjs'; export const STEPS = [ hierarchy, @@ -83,4 +84,5 @@ export const STEPS = [ nineSlice, plugins, autotile, + tilemap, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 33c75244..106c46f2 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -20,6 +20,7 @@ export const LANES = { font: -1900, particles: -2300, sound: -2700, + tilemap: -3100, }; /** @@ -124,11 +125,24 @@ export const TAB_LABELS = { * nothing and took the whole suite down with it. */ export async function openTab(t, tab) { + const label = TAB_LABELS[tab]; const row = await t.json('__gm.tabRow()'); if (row.hidden.includes(tab)) { await t.clickText('»'); + await t.clickText(label); + return; } - await t.clickText(TAB_LABELS[tab]); + // The label alone is not the tab: the toolbar labels its scene selector "Scene" too, and being + // drawn first that is what `clickText` had been hitting – so the tab stayed where it was + // (measured 2026-09-17: step 39 ended on the Autotile tab although it asks for the Scene tab). + // The hit is therefore picked by the box the tab row reports for that tab. + const entry = row.shown.find((e) => e.tab === tab); + const hits = entry ? await t.json(`__gm.locateAll(${JSON.stringify(label)})`) : []; + const hit = hits.find( + (p) => p.x >= entry.x && p.x <= entry.right && p.y >= entry.y && p.y <= entry.y + entry.h, + ); + if (hit) await t.click(hit.x, hit.y); + else await t.clickText(label); } /**