From 732f6518ae4624a5edf4fe9327f932de995937cb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 08:01:07 +0200 Subject: [PATCH] =?UTF-8?q?The=20autotile=20asset=20is=20renamed=20to=20ti?= =?UTF-8?q?le=20and=20gains=20a=20plain=20None=20(1=20=C3=97=201)=20kind,?= =?UTF-8?q?=20which=20is=20now=20the=20default;=20older=20projects=20load?= =?UTF-8?q?=20their=20autotiles=20as=20floor=20tiles=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/commands/assetCommands.ts | 4 +- src/core/commands/require.ts | 8 +- .../{autotileCommands.ts => tileCommands.ts} | 26 +- src/core/commands/tilemapCommands.ts | 10 +- src/core/model/Asset.ts | 21 +- src/core/model/AssetTypes.ts | 14 +- src/core/model/autotile.ts | 180 +++----------- src/core/model/tile.ts | 149 ++++++++++++ src/core/model/tilemap.ts | 59 +++-- src/core/model/usages.ts | 10 +- src/core/properties/PropertySchema.ts | 24 +- src/core/store/serialize.ts | 17 +- src/editor/Editor.ts | 20 +- src/editor/actions.ts | 12 +- src/editor/debugHook.ts | 30 +-- src/editor/graph/graphView.ts | 2 +- src/editor/inspector/tilemapSection.ts | 32 +-- src/editor/state.ts | 14 +- src/editor/{autotile => tile}/PictureStrip.ts | 24 +- .../AutotileCanvas.ts => tile/TileCanvas.ts} | 57 ++--- .../AutotileEditor.ts => tile/TileEditor.ts} | 60 +++-- src/editor/viewport/SceneViewport.ts | 18 +- src/editor/viewport/WorldViewport.ts | 2 +- src/viewport/items.ts | 38 ++- src/viewport/tilemap.ts | 45 ++-- src/viewport/tilemapMesh.ts | 12 +- tests/core/{autotile.test.ts => tile.test.ts} | 222 ++++++++++++------ tests/core/tilemap.test.ts | 116 +++++---- ...otile-strip.test.ts => tile-strip.test.ts} | 4 +- tools/e2e/04-assets.mjs | 4 +- tools/e2e/{39-autotile.mjs => 39-tile.mjs} | 129 ++++++---- tools/e2e/40-tilemap.mjs | 30 +-- tools/e2e/index.mjs | 4 +- tools/e2e/lib/scaffold.mjs | 4 +- 35 files changed, 786 insertions(+), 617 deletions(-) rename src/core/commands/{autotileCommands.ts => tileCommands.ts} (76%) create mode 100644 src/core/model/tile.ts rename src/editor/{autotile => tile}/PictureStrip.ts (88%) rename src/editor/{autotile/AutotileCanvas.ts => tile/TileCanvas.ts} (90%) rename src/editor/{autotile/AutotileEditor.ts => tile/TileEditor.ts} (57%) rename tests/core/{autotile.test.ts => tile.test.ts} (63%) rename tests/editor/{autotile-strip.test.ts => tile-strip.test.ts} (89%) rename tools/e2e/{39-autotile.mjs => 39-tile.mjs} (70%) diff --git a/README.md b/README.md index 409f13f0..fed321c5 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, 1009 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1013 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 70407a9a..308ad34a 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -39,8 +39,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset { return { ...base, type: 'container', objects: [] }; case 'animation': return { ...base, type: 'animation', root: '', tracks: [] }; - case 'autotile': - return { ...base, type: 'autotile', pictures: [] }; + case 'tile': + return { ...base, type: 'tile', 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: {} }] }; diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 49120529..396f98d4 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -3,7 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; import type { Asset, - AutotileAsset, + TileAsset, BehaviorAsset, StateMachineAsset, TilemapAsset, @@ -88,9 +88,9 @@ export function requireVariableOwner(project: Project, assetId: string): Behavio return asset; } -export function requireAutotile(project: Project, assetId: string): AutotileAsset { - const asset = findAssetOf(project, assetId, 'autotile'); - if (!asset) throw new CommandError(`Autotile ${assetId} not found`); +export function requireTile(project: Project, assetId: string): TileAsset { + const asset = findAssetOf(project, assetId, 'tile'); + if (!asset) throw new CommandError(`Tile ${assetId} not found`); return asset; } diff --git a/src/core/commands/autotileCommands.ts b/src/core/commands/tileCommands.ts similarity index 76% rename from src/core/commands/autotileCommands.ts rename to src/core/commands/tileCommands.ts index 7c88c365..2e268439 100644 --- a/src/core/commands/autotileCommands.ts +++ b/src/core/commands/tileCommands.ts @@ -1,16 +1,16 @@ /** - * The picture list of an autotile (#118): which blocks it is drawn from, and in which order – the + * The picture list of a tile (#118): which blocks it is drawn from, and in which order – the * order is the animation, so moving a picture is an edit of its own rather than a cosmetic sort. * The kind, the rate and the playback are ordinary properties and go through `SetAssetPropertyCommand`. */ import type { Project } from '../model/Project'; import { resolvePicture } from '../model/texture'; import { type Command, CommandError } from './Command'; -import { requireAutotile } from './require'; +import { requireTile } from './require'; /** Appends a picture; `at` puts it before the picture with that index instead. */ -export class AddAutotilePictureCommand implements Command { - readonly label = 'Add autotile picture'; +export class AddTilePictureCommand implements Command { + readonly label = 'Add tile picture'; private index = -1; constructor( @@ -20,7 +20,7 @@ export class AddAutotilePictureCommand implements Command { ) {} execute(project: Project): void { - const asset = requireAutotile(project, this.assetId); + const asset = requireTile(project, this.assetId); // A texture or one of its frames – anything else names no block, and a list of dead ids is // the one thing the tab could not explain to whoever built it. if (!resolvePicture(project, this.pictureId)) @@ -30,12 +30,12 @@ export class AddAutotilePictureCommand implements Command { } undo(project: Project): void { - requireAutotile(project, this.assetId).pictures.splice(this.index, 1); + requireTile(project, this.assetId).pictures.splice(this.index, 1); } } -export class RemoveAutotilePictureCommand implements Command { - readonly label = 'Remove autotile picture'; +export class RemoveTilePictureCommand implements Command { + readonly label = 'Remove tile picture'; private removed = ''; constructor( @@ -44,7 +44,7 @@ export class RemoveAutotilePictureCommand implements Command { ) {} execute(project: Project): void { - const asset = requireAutotile(project, this.assetId); + const asset = requireTile(project, this.assetId); const picture = asset.pictures[this.index]; if (picture === undefined) throw new CommandError(`${asset.name} has no picture ${this.index + 1}`); this.removed = picture; @@ -52,13 +52,13 @@ export class RemoveAutotilePictureCommand implements Command { } undo(project: Project): void { - requireAutotile(project, this.assetId).pictures.splice(this.index, 0, this.removed); + requireTile(project, this.assetId).pictures.splice(this.index, 0, this.removed); } } /** Moves one picture to another place in the list; the step in between is what changes the animation. */ -export class MoveAutotilePictureCommand implements Command { - readonly label = 'Reorder autotile pictures'; +export class MoveTilePictureCommand implements Command { + readonly label = 'Reorder tile pictures'; constructor( private readonly assetId: string, @@ -75,7 +75,7 @@ export class MoveAutotilePictureCommand implements Command { } private move(project: Project, from: number, to: number): void { - const asset = requireAutotile(project, this.assetId); + const asset = requireTile(project, this.assetId); const picture = asset.pictures[from]; if (picture === undefined) throw new CommandError(`${asset.name} has no picture ${from + 1}`); if (to < 0 || to >= asset.pictures.length) diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 640c4dfc..4cad325e 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -12,16 +12,16 @@ 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. */ +/** One cell as a stroke wants it: `tileId` empty erases. */ export interface CellEdit { x: number; y: number; - autotileId: string; + tileId: 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. + * a stroke over another tile back exactly – not merely empty. */ export class PaintTilesCommand implements Command { readonly label: string; @@ -38,7 +38,7 @@ export class PaintTilesCommand implements Command { /** 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); + return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.tileId); } execute(project: Project): void { @@ -47,7 +47,7 @@ export class PaintTilesCommand implements Command { 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; + if (edit.tileId) layer.cells[key] = edit.tileId; else delete layer.cells[key]; } } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index d2e77da8..7baa4baf 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -185,19 +185,20 @@ export interface SoundAsset extends AssetBase { } /** - * A tile that draws its own edges: `pictures` names one block per animation step (a texture id for - * a sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say - * which kind of block it is, how fast it runs and whether it loops or bounces. What a block is cut - * into and which quarter goes where is `model/autotile.ts`; placing one is the tilemap of #128. + * A tile a tilemap paints with: `pictures` names one block per animation step (a texture id for a + * sheet that holds nothing else, a frame id for one block cut out of a sheet), and `props` say + * which kind of block it is – a plain tile or one of the two autotile blocks that draw their own + * edges – how fast it runs and whether it loops or bounces. What a block is cut into is + * `model/tile.ts`, which quarter goes where `model/autotile.ts`; placing one is the tilemap of #128. */ -export interface AutotileAsset extends AssetBase { - type: 'autotile'; +export interface TileAsset extends AssetBase { + type: 'tile'; 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 + * One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the tile lying there, and a + * layer nobody switched off is drawn. Which of that tile's quarters a cell shows is worked out * from its neighbours while drawing, never stored – see `model/tilemap.ts`. */ export interface TilemapLayer { @@ -208,7 +209,7 @@ export interface TilemapLayer { } /** - * A grid painted with autotiles, placed in a scene as a sprite is. How wide the grid is and how + * A grid painted with tiles, 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). */ @@ -238,7 +239,7 @@ export type Asset = | ContainerAsset | TextureAsset | AnimationAsset - | AutotileAsset + | TileAsset | TilemapAsset | StateMachineAsset | ShaderAsset diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index f89a505d..44612aea 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -9,7 +9,7 @@ export const ASSET_TYPES = [ 'container', 'texture', 'animation', - 'autotile', + 'tile', 'tilemap', 'statemachine', 'shader', @@ -31,7 +31,7 @@ export const ASSET_TYPE_LABELS: Record = { container: 'Container', texture: 'Texture', animation: 'Animation', - autotile: 'Autotile', + tile: 'Tile', tilemap: 'Tilemap', statemachine: 'State machine', shader: 'Shader', @@ -44,20 +44,20 @@ export const ASSET_TYPE_LABELS: Record = { * Textures and animations are not placeable: they are pictures a sprite wears through `texture`, * 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, so it is placed no more directly than a texture is – what is - * placed is the tilemap. + * sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is + * what 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, - 'scene' | 'texture' | 'animation' | 'autotile' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables' + 'scene' | 'texture' | 'animation' | 'tile' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables' >; const NOT_PLACEABLE: ReadonlySet = new Set([ 'scene', 'texture', 'animation', - 'autotile', + 'tile', 'statemachine', 'shader', 'font', diff --git a/src/core/model/autotile.ts b/src/core/model/autotile.ts index b9118cb5..83aea7c3 100644 --- a/src/core/model/autotile.ts +++ b/src/core/model/autotile.ts @@ -1,50 +1,14 @@ -import type { AutotileAsset } from './Asset'; -import { textOf } from './Properties'; -import type { Project } from './Project'; -import { type FrameRect, resolvePicture, type TextureRef } from './texture'; +import type { QuadPos, TileKind } from './tile'; /** - * An autotile is one picture per animation step, and every picture is a *block* of tiles the - * editor cuts into quarter tiles. Which quarter goes into which corner of a drawn tile depends on - * the neighbours, which is what makes a hand-drawn edge continue across tile borders (#118). - * - * The two kinds are the two block shapes the classic RPG-Maker sheets use: - * `floor` is 2 × 3 tiles and has inner corners, `wall` is 2 × 2 and has none. Everything below - * counts in *quadrants* – quarter tiles – with (0, 0) at the block's top-left corner. + * Which quarter goes into which corner of a drawn tile, given its neighbours – what makes a + * hand-drawn edge continue across tile borders (#118). `floor` and `wall` are the two block shapes + * the classic RPG-Maker sheets use: `floor` is 2 × 3 tiles and has inner corners, `wall` is 2 × 2 + * and has none. A plain tile (`none`, #131) goes through the same code and simply takes the same + * four quarters whatever lies around it. Everything counts in quadrants, see `model/tile.ts`. */ -export const AUTOTILE_KINDS = ['floor', 'wall'] as const; -export type AutotileKind = (typeof AUTOTILE_KINDS)[number]; -export const AUTOTILE_KIND_KEY = 'kind'; -export const AUTOTILE_FPS_KEY = 'fps'; -export const AUTOTILE_PLAYBACK_KEY = 'playback'; - -/** Only the two that make sense for a tile that is always on screen; there is no end to hold. */ -export const AUTOTILE_PLAYBACKS = ['loop', 'pingpong'] as const; -export type AutotilePlayback = (typeof AUTOTILE_PLAYBACKS)[number]; - -/** A block is two tiles wide in both kinds, so it is four quadrants wide. */ -export const BLOCK_COLUMNS = 4; - -/** Quadrant rows of a block: `floor` is three tiles tall, `wall` two. */ -export const BLOCK_ROWS: Record = { floor: 6, wall: 4 }; - -export function autotileKind(asset: AutotileAsset): AutotileKind { - const value = textOf(asset.props[AUTOTILE_KIND_KEY] ?? 'floor'); - return (AUTOTILE_KINDS as readonly string[]).includes(value) ? (value as AutotileKind) : 'floor'; -} - -export function autotilePlayback(asset: AutotileAsset): AutotilePlayback { - const value = textOf(asset.props[AUTOTILE_PLAYBACK_KEY] ?? 'loop'); - return (AUTOTILE_PLAYBACKS as readonly string[]).includes(value) ? (value as AutotilePlayback) : 'loop'; -} - -export function autotileFps(asset: AutotileAsset): number { - const fps = Number(asset.props[AUTOTILE_FPS_KEY]); - return Number.isFinite(fps) && fps > 0 ? fps : 0; -} - -/** Which of the eight neighbouring cells hold the same autotile. */ +/** Which of the eight neighbouring cells hold the same tile. */ export interface Neighbours { n: boolean; e: boolean; @@ -56,12 +20,6 @@ export interface Neighbours { nw: boolean; } -/** A quarter tile inside the block, in quadrant units from its top-left corner. */ -export interface QuadPos { - x: number; - y: number; -} - /** The four quarters of a drawn tile, in reading order: top-left, top-right, bottom-left, bottom-right. */ export type TileQuadrants = [QuadPos, QuadPos, QuadPos, QuadPos]; @@ -108,8 +66,23 @@ const WALL_CORNERS: readonly CornerSpec[] = [ { fill: at(1, 1), h: at(1, 3), v: at(3, 1), corner: at(3, 3) }, ]; -function cornersOf(kind: AutotileKind): readonly CornerSpec[] { - return kind === 'wall' ? WALL_CORNERS : FLOOR_CORNERS; +/** A plain tile: every corner takes its own quarter of the one tile, so the neighbours change nothing. */ +const plainCorner = (position: QuadPos): CornerSpec => ({ + fill: position, + h: position, + v: position, + corner: position, +}); +const PLAIN_CORNERS: readonly CornerSpec[] = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)].map(plainCorner); + +const CORNERS: Record = { + none: PLAIN_CORNERS, + floor: FLOOR_CORNERS, + wall: WALL_CORNERS, +}; + +function cornersOf(kind: TileKind): readonly CornerSpec[] { + return CORNERS[kind]; } /** @@ -124,7 +97,7 @@ function quadrantFor(spec: CornerSpec, sideH: boolean, sideV: boolean, diagonal: } /** The four quarters a tile with these neighbours is drawn from. */ -export function tileQuadrants(kind: AutotileKind, around: Neighbours): TileQuadrants { +export function tileQuadrants(kind: TileKind, around: Neighbours): TileQuadrants { const [tl, tr, bl, br] = cornersOf(kind) as [CornerSpec, CornerSpec, CornerSpec, CornerSpec]; return [ quadrantFor(tl, around.w, around.n, around.nw), @@ -157,11 +130,12 @@ export interface AutotileCombination { } /** - * Every tile the kind can produce, without repeats: 47 for a floor, 16 for a wall. Built by walking - * all neighbourhoods and keeping the first that draws a tile nobody drew before – so a diagonal is - * only set where it changes something, and the list starts with the lone tile. + * Every tile the kind can produce, without repeats: 47 for a floor, 16 for a wall, 1 for a plain + * tile. Built by walking all neighbourhoods and keeping the first that draws a tile nobody drew + * before – so a diagonal is only set where it changes something, and the list starts with the + * lone tile. */ -export function autotileCombinations(kind: AutotileKind): AutotileCombination[] { +export function autotileCombinations(kind: TileKind): AutotileCombination[] { const combinations: AutotileCombination[] = []; const seen = new Set(); const keys = [...SIDES, ...DIAGONALS] as const; @@ -177,7 +151,7 @@ export function autotileCombinations(kind: AutotileKind): AutotileCombination[] return combinations; } -function combinationLabel(around: Neighbours, quadrants: TileQuadrants, kind: AutotileKind): string { +function combinationLabel(around: Neighbours, quadrants: TileQuadrants, kind: TileKind): string { const sides = SIDES.filter((side) => around[side]) .map((side) => side.toUpperCase()) .join(''); @@ -227,95 +201,3 @@ export function neighboursAt(pattern: readonly string[], row: number, column: nu nw: cell(row - 1, column - 1), }; } - -/** One picture of an autotile: the reference it was named by, and the block it covers. */ -export interface AutotilePicture { - id: string; - ref: TextureRef; - block: FrameRect; -} - -/** - * What the tab can draw and what stands in its way. `problem` is a sentence for the reader, never - * a reason to drop anything: a picture of the wrong size stays in the list and can be taken out - * again or the kind switched to the one it fits. - */ -export interface AutotileInfo { - kind: AutotileKind; - pictures: AutotilePicture[]; - /** Side of one tile in image pixels, 0 while the autotile has no usable picture. */ - tile: number; - /** Side of one quarter tile – half a tile, and what the block is measured in. */ - quad: number; - problem: string; -} - -/** - * Reads an autotile against the project: resolves every picture, derives the tile size from the - * first one and says what does not fit. The tile size is derived rather than stored – a block is - * two tiles wide by definition, so a second number could only ever disagree with the picture. - */ -export function readAutotile(project: Project, asset: AutotileAsset): AutotileInfo { - const kind = autotileKind(asset); - const rows = BLOCK_ROWS[kind]; - const pictures: AutotilePicture[] = []; - const missing: string[] = []; - for (const id of asset.pictures) { - const ref = resolvePicture(project, id); - if (!ref) { - missing.push(id); - continue; - } - pictures.push({ id, ref, block: ref.rect ?? { x: 0, y: 0, w: ref.sourceWidth, h: ref.sourceHeight } }); - } - const first = pictures[0]; - const quad = first ? first.block.w / BLOCK_COLUMNS : 0; - const fits = (rect: FrameRect) => - rect.w === quad * BLOCK_COLUMNS && rect.h === quad * rows && Number.isInteger(quad) && quad > 0; - const problem = - asset.pictures.length === 0 - ? 'No picture yet – add a texture or a frame that holds one block.' - : missing.length > 0 - ? `${missing.length} picture(s) no longer exist.` - : !first || !fits(first.block) - ? blockProblem(kind, first?.block, rows) - : pictures.some((picture) => !fits(picture.block)) - ? 'The pictures have different sizes; every step of an animation must hold the same block.' - : ''; - return { kind, pictures, tile: problem === '' ? quad * 2 : 0, quad: problem === '' ? quad : 0, problem }; -} - -function blockProblem(kind: AutotileKind, block: FrameRect | undefined, rows: number): string { - const shape = `${BLOCK_COLUMNS / 2} × ${rows / 2} tiles`; - const size = block ? `, this one is ${block.w} × ${block.h} px` : ''; - return `A ${kind} block is ${shape}, so its sides must divide by ${BLOCK_COLUMNS} and ${rows}${size}.`; -} - -/** The rectangle one quarter tile is cut from, in image pixels. */ -export function quadrantRect(picture: AutotilePicture, quad: number, position: QuadPos): FrameRect { - return { - x: picture.block.x + position.x * quad, - y: picture.block.y + position.y * quad, - w: quad, - h: quad, - }; -} - -/** That quarter as a texture reference of its own – a cut of the sheet, never nine-sliced. */ -export function quadrantRef(picture: AutotilePicture, quad: number, position: QuadPos): TextureRef { - return { ...picture.ref, rect: quadrantRect(picture, quad, position), slice: null }; -} - -/** - * Which picture is shown `time` seconds in. One picture is a still image whatever the settings - * say; ping-pong turns on the last one, so three pictures run 0 1 2 1 and not 0 1 2 2 1. - */ -export function pictureIndexAt(count: number, playback: AutotilePlayback, fps: number, time: number): number { - if (count <= 1) return 0; - if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(time) || time <= 0) return 0; - const step = Math.floor(time * fps); - if (playback === 'loop') return step % count; - const span = 2 * (count - 1); - const phase = step % span; - return phase < count ? phase : span - phase; -} diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts new file mode 100644 index 00000000..15cca8bf --- /dev/null +++ b/src/core/model/tile.ts @@ -0,0 +1,149 @@ +import type { TileAsset } from './Asset'; +import { textOf } from './Properties'; +import type { Project } from './Project'; +import { type FrameRect, resolvePicture, type TextureRef } from './texture'; + +/** + * A tile is one picture per animation step, and every picture is a *block* the editor cuts into + * quarter tiles. `none` is the plain case: the block is the tile itself and every cell shows it as + * it is (#131). `floor` and `wall` are the two autotile blocks of the classic RPG-Maker sheets, + * whose quarters are picked by the neighbours – that part is `model/autotile.ts`. + * + * Everything below counts in *quadrants* – quarter tiles – with (0, 0) at the block's top-left + * corner, so all three kinds are drawn the same way: four quarters per tile. + */ +export const TILE_KINDS = ['none', 'floor', 'wall'] as const; +export type TileKind = (typeof TILE_KINDS)[number]; + +export const TILE_KIND_KEY = 'kind'; +export const TILE_FPS_KEY = 'fps'; +export const TILE_PLAYBACK_KEY = 'playback'; + +/** Only the two that make sense for a tile that is always on screen; there is no end to hold. */ +export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; +export type TilePlayback = (typeof TILE_PLAYBACKS)[number]; + +/** Quadrant columns of a block: one tile for `none`, two for the autotile blocks. */ +export const BLOCK_COLUMNS: Record = { none: 2, floor: 4, wall: 4 }; + +/** Quadrant rows of a block: `none` is one tile tall, `floor` three and `wall` two. */ +export const BLOCK_ROWS: Record = { none: 2, floor: 6, wall: 4 }; + +export function tileKind(asset: TileAsset): TileKind { + const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none'); + return (TILE_KINDS as readonly string[]).includes(value) ? (value as TileKind) : 'none'; +} + +export function tilePlayback(asset: TileAsset): TilePlayback { + const value = textOf(asset.props[TILE_PLAYBACK_KEY] ?? 'loop'); + return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop'; +} + +export function tileFps(asset: TileAsset): number { + const fps = Number(asset.props[TILE_FPS_KEY]); + return Number.isFinite(fps) && fps > 0 ? fps : 0; +} + +/** A quarter tile inside the block, in quadrant units from its top-left corner. */ +export interface QuadPos { + x: number; + y: number; +} + +/** One picture of a tile: the reference it was named by, and the block it covers. */ +export interface TilePicture { + id: string; + ref: TextureRef; + block: FrameRect; +} + +/** + * What the tab can draw and what stands in its way. `problem` is a sentence for the reader, never + * a reason to drop anything: a picture of the wrong size stays in the list and can be taken out + * again or the kind switched to the one it fits. + */ +export interface TileInfo { + kind: TileKind; + pictures: TilePicture[]; + /** Side of one tile in image pixels, 0 while the tile has no usable picture. */ + tile: number; + /** Side of one quarter tile – half a tile, and what the block is measured in. */ + quad: number; + problem: string; +} + +/** + * Reads a tile against the project: resolves every picture, derives the tile size from the first + * one and says what does not fit. The tile size is derived rather than stored – the block's width + * is a known number of tiles, so a second number could only ever disagree with the picture. + */ +export function readTile(project: Project, asset: TileAsset): TileInfo { + const kind = tileKind(asset); + const columns = BLOCK_COLUMNS[kind]; + const rows = BLOCK_ROWS[kind]; + const pictures: TilePicture[] = []; + const missing: string[] = []; + for (const id of asset.pictures) { + const ref = resolvePicture(project, id); + if (!ref) { + missing.push(id); + continue; + } + pictures.push({ id, ref, block: ref.rect ?? { x: 0, y: 0, w: ref.sourceWidth, h: ref.sourceHeight } }); + } + const first = pictures[0]; + const quad = first ? first.block.w / columns : 0; + // A plain tile is only cut to be drawn like the others, never along a line in the picture, so + // an odd side is fine there; an autotile block is cut along its quarters and must split evenly. + const whole = kind === 'none' || Number.isInteger(quad); + const fits = (rect: FrameRect) => rect.w === quad * columns && rect.h === quad * rows && whole && quad > 0; + const problem = + asset.pictures.length === 0 + ? 'No picture yet – add a texture or a frame that holds one block.' + : missing.length > 0 + ? `${missing.length} picture(s) no longer exist.` + : !first || !fits(first.block) + ? blockProblem(kind, first?.block) + : pictures.some((picture) => !fits(picture.block)) + ? 'The pictures have different sizes; every step of an animation must hold the same block.' + : ''; + return { kind, pictures, tile: problem === '' ? quad * 2 : 0, quad: problem === '' ? quad : 0, problem }; +} + +function blockProblem(kind: TileKind, block: FrameRect | undefined): string { + const size = block ? `, this one is ${block.w} × ${block.h} px` : ''; + if (kind === 'none') return `A plain tile is one square tile${size}.`; + const columns = BLOCK_COLUMNS[kind]; + const rows = BLOCK_ROWS[kind]; + const shape = `${columns / 2} × ${rows / 2} tiles`; + return `A ${kind} block is ${shape}, so its sides must divide by ${columns} and ${rows}${size}.`; +} + +/** The rectangle one quarter tile is cut from, in image pixels. */ +export function quadrantRect(picture: TilePicture, quad: number, position: QuadPos): FrameRect { + return { + x: picture.block.x + position.x * quad, + y: picture.block.y + position.y * quad, + w: quad, + h: quad, + }; +} + +/** That quarter as a texture reference of its own – a cut of the sheet, never nine-sliced. */ +export function quadrantRef(picture: TilePicture, quad: number, position: QuadPos): TextureRef { + return { ...picture.ref, rect: quadrantRect(picture, quad, position), slice: null }; +} + +/** + * Which picture is shown `time` seconds in. One picture is a still image whatever the settings + * say; ping-pong turns on the last one, so three pictures run 0 1 2 1 and not 0 1 2 2 1. + */ +export function pictureIndexAt(count: number, playback: TilePlayback, fps: number, time: number): number { + if (count <= 1) return 0; + if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(time) || time <= 0) return 0; + const step = Math.floor(time * fps); + if (playback === 'loop') return step % count; + const span = 2 * (count - 1); + const phase = step % span; + return phase < count ? phase : span - phase; +} diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index ed00feb5..e6c4d248 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -1,20 +1,15 @@ /** - * 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 + * A tilemap is a grid of cells, and every cell names the tile lying there. Which of that tile's + * quarters a cell shows is *not* stored: it follows from the neighbours and is worked out * while drawing (#128), so painting one cell fixes the edges of the ones around it by itself. * - * Cells are sparse – a map from `"x,y"` to an autotile id – rather than one entry per grid square. + * Cells are sparse – a map from `"x,y"` to a tile id – rather than one entry per grid square. * A map is what painting, erasing and reading a neighbour all want, and changing the grid size then * moves nothing: what falls outside is simply not drawn and comes back when the grid grows again. */ -import type { AutotileAsset, TilemapLayer } from './Asset'; -import { - type AutotileKind, - autotileKind, - type Neighbours, - type TileQuadrants, - tileQuadrants, -} from './autotile'; +import type { TileAsset, TilemapLayer } from './Asset'; +import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile'; +import { type TileKind, tileKind } from './tile'; import type { PropertyBag } from './Properties'; import { findAssetOf, type Project } from './Project'; @@ -35,7 +30,7 @@ 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. */ +/** Side of one cell in world units; the tile's own pictures are scaled onto it. */ export function tilemapTileSize(props: PropertyBag): number { const size = Number(props[TILEMAP_TILE_KEY]); return Number.isFinite(size) && size > 0 ? size : 48; @@ -77,7 +72,7 @@ export function inGrid(props: PropertyBag, x: number, y: number): boolean { } /** - * Which neighbours a cell is joined to. Only the *same* autotile joins: a different one counts as + * Which neighbours a cell is joined to. Only the *same* tile joins: a different one counts as * empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets * are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge. */ @@ -86,10 +81,10 @@ export function neighboursOf( layer: TilemapLayer, x: number, y: number, - autotileId: string, + tileId: string, ): Neighbours { const same = (dx: number, dy: number): boolean => - inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === autotileId; + inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === tileId; return { n: same(0, -1), e: same(1, 0), @@ -102,48 +97,48 @@ export function neighboursOf( }; } -/** One cell as it is drawn: where it sits, which autotile it is and which quarters build it. */ +/** One cell as it is drawn: where it sits, which tile it is and which quarters build it. */ export interface TileDraw { x: number; y: number; - autotileId: string; + tileId: 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. + * Every cell of one layer that is inside the grid and names a tile that exists, in reading order + * so the result is the same list twice. Cells naming a deleted tile are skipped rather than + * dropped from the model: an undo brings the tile, and with it the drawing, back. */ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] { - const kinds = new Map(); - const kindOf = (id: string): AutotileKind | null => { + const kinds = new Map(); + const kindOf = (id: string): TileKind | 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); + const tile: TileAsset | undefined = findAssetOf(project, id, 'tile'); + if (!tile) return null; + const kind = tileKind(tile); kinds.set(id, kind); return kind; }; const tiles: TileDraw[] = []; - for (const [key, autotileId] of Object.entries(layer.cells)) { + for (const [key, tileId] of Object.entries(layer.cells)) { const at = parseCellKey(key); - if (!at || !autotileId || !inGrid(props, at.x, at.y)) continue; - const kind = kindOf(autotileId); + if (!at || !tileId || !inGrid(props, at.x, at.y)) continue; + const kind = kindOf(tileId); if (!kind) continue; tiles.push({ x: at.x, y: at.y, - autotileId, - quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, autotileId)), + tileId, + quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, tileId)), }); } return tiles.sort((a, b) => a.y - b.y || a.x - b.x); } -/** The autotiles a tilemap paints with, in the order they first appear – its palette in use. */ -export function tilemapAutotileIds(asset: { layers: TilemapLayer[] }): string[] { +/** The tiles a tilemap paints with, in the order they first appear – its palette in use. */ +export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] { const ids: string[] = []; for (const layer of asset.layers) for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id); diff --git a/src/core/model/usages.ts b/src/core/model/usages.ts index a4b01fff..6820a230 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, TilemapAsset } from './Asset'; +import type { AnimationAsset, Asset, TileAsset, TilemapAsset } from './Asset'; import { overrideSites } from './instance'; import { type ObjectHolder, objectHolders, type Project } from './Project'; import type { SceneObject } from './SceneObject'; @@ -63,15 +63,15 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri return result; } -/** The autotiles drawn from any of these pictures – intrinsic data, like an animation's keys. */ -export function autotilesUsing(project: Project, ids: readonly string[]): AutotileAsset[] { +/** The tiles drawn from any of these pictures – intrinsic data, like an animation's keys. */ +export function tilesUsing(project: Project, ids: readonly string[]): TileAsset[] { const wanted = new Set(ids); return project.assets.filter( - (a): a is AutotileAsset => a.type === 'autotile' && a.pictures.some((id) => wanted.has(id)), + (a): a is TileAsset => a.type === 'tile' && a.pictures.some((id) => wanted.has(id)), ); } -/** The tilemaps painted with any of these autotiles – intrinsic data, like an autotile's pictures. */ +/** The tilemaps painted with any of these tiles – intrinsic data, like a tile's pictures. */ export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] { const wanted = new Set(ids); return project.assets.filter( diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 95d58cc7..38fef5e0 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,7 +20,7 @@ import { type VariableKind, } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; -import { AUTOTILE_FPS_KEY, AUTOTILE_KIND_KEY, AUTOTILE_PLAYBACK_KEY } from '../model/autotile'; +import { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY } from '../model/tile'; 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'; @@ -220,23 +220,25 @@ const ASSET_SCHEMAS: Record = { ], }, ], - // The pictures are intrinsic data on the asset (see AutotileAsset); these three say what the - // block holds and how its steps run. There is no tile size: a block is two tiles wide, so the - // picture already says it (see `readAutotile`). - autotile: [ + // The pictures are intrinsic data on the asset (see TileAsset); these three say what the + // block holds and how its steps run. There is no tile size: the block's width in tiles is known + // from the kind, so the picture already says it (see `readTile`). A new tile is a plain one; the + // autotile blocks are chosen on purpose (#131). + tile: [ { - key: AUTOTILE_KIND_KEY, + key: TILE_KIND_KEY, label: 'Kind', kind: 'enum', - default: 'floor', + default: 'none', options: [ + { value: 'none', label: 'None (1 × 1)' }, { value: 'floor', label: 'Floor (2 × 3)' }, { value: 'wall', label: 'Wall (2 × 2)' }, ], }, - { key: AUTOTILE_FPS_KEY, label: 'Frames per second', kind: 'number', default: 4, min: 0.1, max: 120 }, + { key: TILE_FPS_KEY, label: 'Frames per second', kind: 'number', default: 4, min: 0.1, max: 120 }, { - key: AUTOTILE_PLAYBACK_KEY, + key: TILE_PLAYBACK_KEY, label: 'Playback', kind: 'enum', default: 'loop', @@ -247,8 +249,8 @@ 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. + // how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto + // that cell, so one tilemap can mix tiles 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 }, diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index db30ea8e..bef70118 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -79,7 +79,7 @@ import { SHADER_KEY, shaderParamDefs } from '../properties/shaders'; */ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const r = asRecord(raw); - const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord); + const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord).map(migrateAsset); // Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2. const schemas = new Map(); @@ -176,7 +176,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { } else if (type === 'sound') { const file = typeof a.file === 'string' && isSoundFile(a.file) ? a.file : ''; assets.push({ id, type, name, props, file }); - } else if (type === 'autotile') { + } else if (type === 'tile') { // A picture naming nothing is kept, like an animation's dangling root: it reads as missing // in the tab and comes back when the texture it named is restored by an undo. const pictures = (Array.isArray(a.pictures) ? a.pictures : []).filter( @@ -505,7 +505,7 @@ function normalizeTracks(raw: unknown): AnimationTrack[] { /** * 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 + * is dropped; one naming a deleted tile 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[] { @@ -537,6 +537,17 @@ function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): Pr return bag; } +/** + * A tile was called an autotile before it could be a plain one (#131). Such an asset had no kind + * but the autotile blocks, and one written without a kind meant a floor – which it has to stay now + * that a missing kind means a plain tile. + */ +function migrateAsset(a: Record): Record { + if (a.type !== 'autotile') return a; + const props = asRecord(a.props); + return { ...a, type: 'tile', props: { ...props, kind: props.kind ?? 'floor' } }; +} + function asRecord(value: unknown): Record { return value && typeof value === 'object' ? (value as Record) : {}; } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 65a69b51..2562be48 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -30,7 +30,7 @@ import { GraphEditor } from './graph/GraphEditor'; import { Inspector } from './inspector/Inspector'; import { ListEditor } from './list/ListEditor'; import { AnimationEditor } from './animation/AnimationEditor'; -import { AutotileEditor } from './autotile/AutotileEditor'; +import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; @@ -56,7 +56,7 @@ export type CentreTab = | 'game' | 'texture' | 'animation' - | 'autotile' + | 'tile' | 'container' | 'list' | 'plugins'; @@ -74,7 +74,7 @@ const TAB_ORDER = [ 'particles', 'texture', 'animation', - 'autotile', + 'tile', 'container', 'list', 'plugins', @@ -89,7 +89,7 @@ const TAB_LABELS: Record = { particles: 'Particles', texture: 'Texture', animation: 'Animation', - autotile: 'Autotile', + tile: 'Tile', container: 'Container', list: 'List', plugins: 'Plugins', @@ -104,7 +104,7 @@ const TAB_OF_ASSET: Partial> = { particles: 'particles', texture: 'texture', animation: 'animation', - autotile: 'autotile', + tile: 'tile', container: 'container', }; @@ -134,7 +134,7 @@ export class Editor implements PlayHost { readonly particlesEditor: ParticlesEditor; readonly textureEditor: TextureEditor; readonly animationEditor: AnimationEditor; - readonly autotileEditor: AutotileEditor; + readonly tileEditor: TileEditor; readonly containerEditor: ContainerEditor; readonly listEditor: ListEditor; /** Plugins tab: the switch per file in `data/plugins/`. */ @@ -189,7 +189,7 @@ export class Editor implements PlayHost { this.particlesEditor = new ParticlesEditor(state); this.textureEditor = new TextureEditor(state); this.animationEditor = new AnimationEditor(state); - this.autotileEditor = new AutotileEditor(state); + this.tileEditor = new TileEditor(state); this.containerEditor = new ContainerEditor(state); this.listEditor = new ListEditor(state); this.hierarchy = new HierarchyPanel(state); @@ -259,7 +259,7 @@ export class Editor implements PlayHost { particles: this.particlesEditor, texture: this.textureEditor, animation: this.animationEditor, - autotile: this.autotileEditor, + tile: this.tileEditor, container: this.containerEditor, list: this.listEditor, plugins: this.pluginsPane, @@ -433,7 +433,7 @@ export class Editor implements PlayHost { this.gameView.animate(); this.console.refresh(); if (this.tab === 'animation') this.animationEditor.tick(step); - if (this.tab === 'autotile') this.autotileEditor.tick(step); + if (this.tab === 'tile') this.tileEditor.tick(step); // Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session // to do it, and its states move just the same. if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); @@ -453,7 +453,7 @@ export class Editor implements PlayHost { (this.gameView.visible && this.gameView.animates) || this.tab === 'particles' || (this.tab === 'animation' && this.animationEditor.isPlaying) || - (this.tab === 'autotile' && this.autotileEditor.isPlaying) + (this.tab === 'tile' && this.tileEditor.isPlaying) ); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 7ad8151f..5ca3dd15 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -16,7 +16,7 @@ import { findAnnotation } from '../core/graph'; import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; import type { SceneObject } from '../core/model/SceneObject'; import { textureRefIds } from '../core/model/texture'; -import { animationsUsing, autotilesUsing, findPropertyUsages, findUsages } from '../core/model/usages'; +import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../core/model/usages'; import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; @@ -97,12 +97,12 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId]; const references = findPropertyUsages(state.project, ids).length; const animations = asset.type === 'texture' ? animationsUsing(state.project, ids).length : 0; - const autotiles = asset.type === 'texture' ? autotilesUsing(state.project, ids).length : 0; + const tiles = asset.type === 'texture' ? tilesUsing(state.project, ids).length : 0; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` - : references > 0 || animations > 0 || autotiles > 0 - ? wornDetail(references, animations, autotiles, asset.type) + : references > 0 || animations > 0 || tiles > 0 + ? wornDetail(references, animations, tiles, asset.type) : usages === 0 ? 'This cannot be undone with Ctrl+Z after reload.' : asset.type === 'container' @@ -114,12 +114,12 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { ]).openCentered(); } -function wornDetail(sprites: number, animations: number, autotiles: number, type: AssetType): string { +function wornDetail(sprites: number, animations: number, tiles: number, type: AssetType): string { if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`; const who = [ sprites > 0 ? `${sprites} sprite(s)` : '', animations > 0 ? `${animations} animation(s)` : '', - autotiles > 0 ? `${autotiles} autotile(s)` : '', + tiles > 0 ? `${tiles} tile(s)` : '', ].filter(Boolean); // "a, b and c" rather than "a and b and c", which three kinds of user made reachable. const list = diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 1c97ad3d..f406567d 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -517,26 +517,26 @@ export function installDebugHook(app: Application, state: EditorState, editor: E fitAt: () => centreOf(editor.animationEditor.fitButton), }, }, - autotile: { - current: () => editor.autotileEditor.currentAssetId, - info: () => editor.autotileEditor.shownInfo, + tile: { + current: () => editor.tileEditor.currentAssetId, + info: () => editor.tileEditor.shownInfo, /** What the board drew: tiles, the picture it is showing and what stands in the way. */ - board: () => editor.autotileEditor.canvas.debugInfo(), - pictures: () => editor.autotileEditor.strip.debugInfo(), + board: () => editor.tileEditor.canvas.debugInfo(), + pictures: () => editor.tileEditor.strip.debugInfo(), /** A tile of the board: its quarters and where each of them is drawn. */ - tile: (index: number) => editor.autotileEditor.canvas.tileDrawn(index), + tile: (index: number) => editor.tileEditor.canvas.tileDrawn(index), /** Which board tile a cell of the sample map became – `-1` when that cell is empty. */ - sampleTile: (row: number, column: number) => editor.autotileEditor.canvas.sampleTile(row, column), - kindPickerAt: () => centreOf(editor.autotileEditor.kindPicker), - addPictureAt: () => centreOf(editor.autotileEditor.strip.addButton), - removePictureAt: () => centreOf(editor.autotileEditor.strip.removeButton), - pictureRowAt: (index: number) => editor.autotileEditor.strip.list.rowGlobal(String(index)), - fitAt: () => centreOf(editor.autotileEditor.fitButton), + sampleTile: (row: number, column: number) => editor.tileEditor.canvas.sampleTile(row, column), + kindPickerAt: () => centreOf(editor.tileEditor.kindPicker), + addPictureAt: () => centreOf(editor.tileEditor.strip.addButton), + removePictureAt: () => centreOf(editor.tileEditor.strip.removeButton), + pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)), + fitAt: () => centreOf(editor.tileEditor.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. */ + /** The cells of one layer as the model holds them: `"x,y"` to tile id. */ cells: (assetId: string, layerId: string) => { const asset = findAssetOf(state.project, assetId, 'tilemap'); const layer = asset && findLayer(asset, layerId); @@ -557,8 +557,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E const button = currentTilemapSection()?.tools.get(tool as TilemapTool); return button ? centreOf(button) : null; }, - autotileAt: (assetId: string) => { - const button = currentTilemapSection()?.autotiles.get(assetId); + tileButtonAt: (assetId: string) => { + const button = currentTilemapSection()?.tiles.get(assetId); return button ? centreOf(button) : null; }, addLayerAt: () => { diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index 7614c031..40a28b5e 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -25,7 +25,7 @@ export interface GraphView { /** * The view after zooming by `factor` about a screen point, which keeps the graph point under it. - * `limits` are the graph's own unless a surface with other content says otherwise – the autotile + * `limits` are the graph's own unless a surface with other content says otherwise – the tile * board shows 48 px tiles, which one wants to come closer to than a node graph. */ export function zoomedAbout( diff --git a/src/editor/inspector/tilemapSection.ts b/src/editor/inspector/tilemapSection.ts index 5ac9c65f..ad4d18b3 100644 --- a/src/editor/inspector/tilemapSection.ts +++ b/src/editor/inspector/tilemapSection.ts @@ -1,9 +1,9 @@ /** - * The painting side of a placed tilemap (#128): which layer is painted on, with which autotile and + * The painting side of a placed tilemap (#128): which layer is painted on, with which tile 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 + * Nothing here belongs to the model except the layers themselves: tool, tile and layer are * editor state (`state.tilemapPaint`), so opening a project does not decide what one paints with. */ import { @@ -37,12 +37,12 @@ export function paintedLayerId(state: EditorState, asset: TilemapAsset): string /** * 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 + * that is on screen elsewhere too – the tile'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; + tiles: Map; layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[]; addLayer: Button; } @@ -58,7 +58,7 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v const paint = state.tilemapPaint; const widgets: TilemapSectionWidgets = { tools: new Map(), - autotiles: new Map(), + tiles: new Map(), layers: [], addLayer: new Button('Add layer', { onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)), @@ -78,25 +78,25 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v 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, { + addTitle(host, 'TILES'); + const tiles = state.project.assets.filter((a) => a.type === 'tile'); + if (tiles.length === 0) addNote(host, 'The project has no tile yet.'); + for (const tile of tiles) { + const button = new Button(tile.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, + tileId: tile.id, tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool, }), }); - button.toggled = paint.autotileId === autotile.id; - widgets.autotiles.set(autotile.id, button); + button.toggled = paint.tileId === tile.id; + widgets.tiles.set(tile.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'); + if (tiles.length > 0 && !tiles.some((a) => a.id === paint.tileId)) + addNote(host, 'Choose a tile to paint with.', 'small'); addTitle(host, 'LAYERS'); addNote(host, 'The last layer is drawn on top.', 'small'); @@ -148,5 +148,5 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v 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}`; + return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${layers}`; } diff --git a/src/editor/state.ts b/src/editor/state.ts index 85f5accf..37a94c40 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -141,14 +141,14 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p /** * 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 + * The other three paint: brush the chosen tile 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; + tileId: string; layerId: string; } @@ -198,11 +198,11 @@ export class EditorState { /** 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 + * What the scene view paints a tilemap with (#128): the tool, the tile 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: '' }; + tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '' }; /** The real (saved) model and its undo stack while `project` is the play-mode copy. */ private edit: { project: Project; commands: CommandStack } | null = null; @@ -542,11 +542,11 @@ export class EditorState { return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null; } - /** The autotile the selection refers to; only the asset itself, it has nothing inside to pick. */ - get selectedAutotileId(): string | null { + /** The tile the selection refers to; only the asset itself, it has nothing inside to pick. */ + get selectedTileId(): string | null { const s = this.selection; if (s?.kind !== 'asset') return null; - return findAssetOf(this.project, s.assetId, 'autotile')?.id ?? null; + return findAssetOf(this.project, s.assetId, 'tile')?.id ?? null; } /** The timeline row the selection is about; undefined when it is about something else. */ diff --git a/src/editor/autotile/PictureStrip.ts b/src/editor/tile/PictureStrip.ts similarity index 88% rename from src/editor/autotile/PictureStrip.ts rename to src/editor/tile/PictureStrip.ts index 1d4682f9..968ece6e 100644 --- a/src/editor/autotile/PictureStrip.ts +++ b/src/editor/tile/PictureStrip.ts @@ -1,10 +1,10 @@ import { Graphics } from 'pixi.js'; import { - AddAutotilePictureCommand, - MoveAutotilePictureCommand, - RemoveAutotilePictureCommand, -} from '../../core/commands/autotileCommands'; -import type { AutotileAsset } from '../../core/model/Asset'; + AddTilePictureCommand, + MoveTilePictureCommand, + RemoveTilePictureCommand, +} from '../../core/commands/tileCommands'; +import type { TileAsset } from '../../core/model/Asset'; import { pictureChoices, textureRefLabel } from '../../core/properties/textureRef'; import { Button, @@ -95,9 +95,9 @@ export class PictureStrip extends VStack { return { pictures: this.asset()?.pictures.map((id) => this.labelOf(id)) ?? [], selected: this.selected }; } - private asset(): AutotileAsset | undefined { + private asset(): TileAsset | undefined { const asset = this.state.project.assets.find((a) => a.id === this.assetId); - return asset?.type === 'autotile' ? asset : undefined; + return asset?.type === 'tile' ? asset : undefined; } private labelOf(id: string): string { @@ -130,16 +130,16 @@ export class PictureStrip extends VStack { private removeSelected(): void { const asset = this.asset(); if (!asset || this.selected < 0) return; - this.state.execute(new RemoveAutotilePictureCommand(asset.id, this.selected)); + this.state.execute(new RemoveTilePictureCommand(asset.id, this.selected)); } - /** Every texture and every frame; an autotile block is one of them, whole or cut out. */ + /** Every texture and every frame; a tile's block is one of them, whole or cut out. */ private openPicturePicker(): void { const asset = this.asset(); if (!asset) return; const items: MenuItem[] = pictureChoices(this.state.project).map((choice) => ({ label: choice.label, - onSelect: () => this.state.execute(new AddAutotilePictureCommand(asset.id, choice.id)), + onSelect: () => this.state.execute(new AddTilePictureCommand(asset.id, choice.id)), })); if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined }); new Menu(items, { searchable: true }).openBelow(this.addButton); @@ -154,7 +154,7 @@ export class PictureStrip extends VStack { { label: 'Remove', danger: true, - onSelect: () => this.state.execute(new RemoveAutotilePictureCommand(asset.id, index)), + onSelect: () => this.state.execute(new RemoveTilePictureCommand(asset.id, index)), }, ]); menu.open(x, y); @@ -167,7 +167,7 @@ export class PictureStrip extends VStack { if (!asset) return; const to = dropIndex(from, spot, asset.pictures.length); if (to === from || to < 0) return; - if (this.state.execute(new MoveAutotilePictureCommand(asset.id, from, to))) this.select(to); + if (this.state.execute(new MoveTilePictureCommand(asset.id, from, to))) this.select(to); } override redraw(): void { diff --git a/src/editor/autotile/AutotileCanvas.ts b/src/editor/tile/TileCanvas.ts similarity index 90% rename from src/editor/autotile/AutotileCanvas.ts rename to src/editor/tile/TileCanvas.ts index 3d633ebc..89494e33 100644 --- a/src/editor/autotile/AutotileCanvas.ts +++ b/src/editor/tile/TileCanvas.ts @@ -6,23 +6,25 @@ import { Sprite, Text, } from 'pixi.js'; -import type { AutotileAsset } from '../../core/model/Asset'; +import type { TileAsset } from '../../core/model/Asset'; import { autotileCombinations, - autotileFps, - autotilePlayback, - type AutotileInfo, - type AutotilePicture, neighboursAt, patternCell, - pictureIndexAt, - quadrantRef, - type QuadPos, - readAutotile, SAMPLE_PATTERN, tileQuadrants, type TileQuadrants, } from '../../core/model/autotile'; +import { + pictureIndexAt, + type QuadPos, + quadrantRef, + readTile, + tileFps, + type TileInfo, + type TilePicture, + tilePlayback, +} from '../../core/model/tile'; import { findAssetOf } from '../../core/model/Project'; import { type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; import { textureLibrary } from '../../viewport/TextureLibrary'; @@ -51,12 +53,12 @@ interface BoardTile { } /** - * The Autotile tab's picture (#118): every tile the block can produce, captioned with the + * The Tile tab's picture (#118): every tile the block can produce, captioned with the * neighbours that ask for it, and below them a sample map composed from the same quarters – the * one place that shows whether the edges really continue across tile borders. A drag pans, the * wheel zooms, nothing is edited here. */ -export class AutotileCanvas extends Widget { +export class TileCanvas extends Widget { private readonly bg = new Graphics(); private readonly content = new Container(); private readonly clip = new Graphics(); @@ -72,7 +74,7 @@ export class AutotileCanvas extends Widget { private built = ''; private shownPicture = -1; private board = { w: 0, h: 0 }; - private info: AutotileInfo | null = null; + private info: TileInfo | null = null; private needsFit = true; constructor(private readonly state: EditorState) { @@ -114,7 +116,7 @@ export class AutotileCanvas extends Widget { return this.assetId; } - /** Switching resets time and view: the next autotile is a new subject. */ + /** Switching resets time and view: the next tile is a new subject. */ show(assetId: string): void { if (this.assetId === assetId) return; this.assetId = assetId; @@ -197,13 +199,13 @@ export class AutotileCanvas extends Widget { return this.tiles.findIndex((tile) => tile.cell?.row === row && tile.cell.column === column); } - private asset(): AutotileAsset | undefined { - return this.assetId ? findAssetOf(this.state.project, this.assetId, 'autotile') : undefined; + private asset(): TileAsset | undefined { + return this.assetId ? findAssetOf(this.state.project, this.assetId, 'tile') : undefined; } private fps(): number { const asset = this.asset(); - return asset ? autotileFps(asset) : 0; + return asset ? tileFps(asset) : 0; } override layout(): void { @@ -214,7 +216,7 @@ export class AutotileCanvas extends Widget { this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport); this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); const asset = this.asset(); - this.info = asset ? readAutotile(this.state.project, asset) : null; + this.info = asset ? readTile(this.state.project, asset) : null; this.build(asset); this.paint(asset); this.syncHint(asset); @@ -228,7 +230,7 @@ export class AutotileCanvas extends Widget { } /** Rebuilds the sprites when the board's shape changed – the kind, the tile size or the asset. */ - private build(asset: AutotileAsset | undefined): void { + private build(asset: TileAsset | undefined): void { const info = this.info; const signature = asset && info && info.problem === '' ? `${asset.id}:${info.kind}:${info.tile}` : ''; if (signature === this.built) return; @@ -245,7 +247,7 @@ export class AutotileCanvas extends Widget { } /** The combination grid, then the sample map below it – all in image pixels of the tile. */ - private layoutBoard(info: AutotileInfo): void { + private layoutBoard(info: TileInfo): void { const tile = info.tile; const step = tile * (1 + GAP); const row = tile * (1 + GAP + CAPTION); @@ -302,15 +304,10 @@ export class AutotileCanvas extends Widget { } /** Puts the current picture's quarters into the sprites; a picture still loading leaves them blank. */ - private paint(asset: AutotileAsset | undefined): void { + private paint(asset: TileAsset | undefined): void { const info = this.info; if (!asset || !info || info.problem !== '' || this.tiles.length === 0) return; - const index = pictureIndexAt( - info.pictures.length, - autotilePlayback(asset), - autotileFps(asset), - this.time, - ); + const index = pictureIndexAt(info.pictures.length, tilePlayback(asset), tileFps(asset), this.time); const picture = info.pictures[index]; if (!picture) return; const half = info.tile / 2; @@ -328,14 +325,12 @@ export class AutotileCanvas extends Widget { this.shownPicture = index; } - private quadrant(picture: AutotilePicture, info: AutotileInfo, position: QuadPos) { + private quadrant(picture: TilePicture, info: TileInfo, position: QuadPos) { return textureLibrary.get(quadrantRef(picture, info.quad, position)); } - private syncHint(asset: AutotileAsset | undefined): void { - const text = !asset - ? 'No autotile selected – create one in the Assets panel.' - : (this.info?.problem ?? ''); + private syncHint(asset: TileAsset | undefined): void { + const text = !asset ? 'No tile selected – create one in the Assets panel.' : (this.info?.problem ?? ''); this.hint.text = text; this.hint.visible = text !== ''; this.hint.position.set(Theme.padding * 2, Theme.padding * 2); diff --git a/src/editor/autotile/AutotileEditor.ts b/src/editor/tile/TileEditor.ts similarity index 57% rename from src/editor/autotile/AutotileEditor.ts rename to src/editor/tile/TileEditor.ts index 03de8740..45546f72 100644 --- a/src/editor/autotile/AutotileEditor.ts +++ b/src/editor/tile/TileEditor.ts @@ -1,44 +1,38 @@ import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; -import type { Asset, AutotileAsset } from '../../core/model/Asset'; -import { - AUTOTILE_KIND_KEY, - AUTOTILE_KINDS, - autotileCombinations, - autotileFps, - autotileKind, - readAutotile, -} from '../../core/model/autotile'; +import type { Asset, TileAsset } from '../../core/model/Asset'; +import { autotileCombinations } from '../../core/model/autotile'; +import { readTile, TILE_KIND_KEY, TILE_KINDS, tileFps, tileKind } from '../../core/model/tile'; import { propDef } from '../../core/properties/PropertySchema'; import { Dropdown, formatNumber, HStack, Menu, type MenuItem, type Widget } from '../../ui'; import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; -import { AutotileCanvas } from './AutotileCanvas'; +import { TileCanvas } from './TileCanvas'; import { PictureStrip } from './PictureStrip'; -/** Room kept for the "Floor · 48 px · 3 pictures · 47 tiles" readout. */ +/** Room kept for the "Floor (2 × 3) · 48 px · 3 pictures · 47 tiles" readout. */ const INFO_WIDTH = 250; const KIND_WIDTH = 130; /** - * The Autotile tab (#118): the header picks the autotile and the kind of block, the strip on the + * The Tile tab (#118, #131): the header picks the tile and the kind of block, the strip on the * right holds the pictures one animation step each, and the board shows every tile the block can - * produce plus a sample map composed from them. The rate and the playback are properties of the + * produce plus a sample map composed from them – for a plain tile that is the one tile, repeated. The rate and the playback are properties of the * asset, edited in the inspector like an animation's. */ -export class AutotileEditor extends AssetTabEditor { +export class TileEditor extends AssetTabEditor { /** Public for the check scripts. */ readonly kindPicker: Dropdown; readonly strip: PictureStrip; constructor(state: EditorState) { - super(state, { noun: 'Autotile', infoWidth: INFO_WIDTH }); + super(state, { noun: 'Tile', infoWidth: INFO_WIDTH }); this.kindPicker = new Dropdown('—', { width: KIND_WIDTH, onClick: () => this.openKindPicker() }); this.strip = new PictureStrip(state); - this.mount(new AutotileCanvas(state), [this.kindPicker]); + this.mount(new TileCanvas(state), [this.kindPicker]); } /** The board left, the picture list right – the list keeps its width, the board takes the rest. */ - protected override body(canvas: AutotileCanvas): Widget { + protected override body(canvas: TileCanvas): Widget { const row = new HStack(); row.add(canvas, 1); row.add(this.strip); @@ -50,31 +44,31 @@ export class AutotileEditor extends AssetTabEditor 1 ? ` · ${formatNumber(autotileFps(asset), 2)} fps` : ''; - return `${kind} · ${info.tile} px · ${steps}${fps} · ${tiles} tiles`; + const fps = pictures > 1 ? ` · ${formatNumber(tileFps(asset), 2)} fps` : ''; + return `${kind} · ${info.tile} px · ${steps}${fps} · ${tiles} ${tiles === 1 ? 'tile' : 'tiles'}`; } - protected override syncExtras(asset: AutotileAsset | null): void { + protected override syncExtras(asset: TileAsset | null): void { this.strip.show(asset?.id ?? ''); this.kindPicker.setText(asset ? kindLabel(this.state, asset) : '—'); this.kindPicker.setEnabled(asset !== null); @@ -84,22 +78,22 @@ export class AutotileEditor extends AssetTabEditor ({ + const current = tileKind(asset); + const items: MenuItem[] = TILE_KINDS.map((kind) => ({ label: `${kind === current ? '● ' : ''}${optionLabel(this.state, asset, kind)}`, - onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, AUTOTILE_KIND_KEY, kind)), + onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, TILE_KIND_KEY, kind)), })); new Menu(items).openBelow(this.kindPicker); } } /** The label the schema gives the kind, so the picker and the readout cannot drift apart. */ -function optionLabel(state: EditorState, asset: AutotileAsset, kind: string): string { - const def = propDef(state.project, asset, AUTOTILE_KIND_KEY); +function optionLabel(state: EditorState, asset: TileAsset, kind: string): string { + const def = propDef(state.project, asset, TILE_KIND_KEY); const option = def?.kind === 'enum' ? def.options.find((o) => o.value === kind) : undefined; return option?.label ?? kind; } -function kindLabel(state: EditorState, asset: AutotileAsset): string { - return optionLabel(state, asset, autotileKind(asset)); +function kindLabel(state: EditorState, asset: TileAsset): string { + return optionLabel(state, asset, tileKind(asset)); } diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index cbea6090..e4d055e2 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -78,7 +78,7 @@ interface PaintStroke { assetId: string; layerId: string; tool: TilemapTool; - autotileId: string; + tileId: string; edits: CellEdit[]; previous: Map; from: Vec2; @@ -356,9 +356,9 @@ export class SceneViewport extends FreeViewport { 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.'); + const tileId = paint.tool === 'erase' ? '' : paint.tileId; + if (paint.tool !== 'erase' && !tileId) { + this.state.setNotice('Choose a tile to paint with.'); return false; } const at = this.cellUnder(target.item, e); @@ -368,7 +368,7 @@ export class SceneViewport extends FreeViewport { assetId: target.asset.id, layerId: target.layerId, tool: paint.tool, - autotileId, + tileId, edits: [], previous: new Map(), from: at, @@ -396,9 +396,9 @@ export class SceneViewport extends FreeViewport { 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; + if ((layer.cells[key] ?? '') === stroke.tileId) continue; + stroke.edits.push({ x: at.x, y: at.y, tileId: stroke.tileId }); + if (stroke.tileId) layer.cells[key] = stroke.tileId; else delete layer.cells[key]; changed = true; } @@ -421,7 +421,7 @@ export class SceneViewport extends FreeViewport { stroke.tool === 'rect' && item ? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({ ...at, - autotileId: stroke.autotileId, + tileId: stroke.tileId, })) : stroke.edits; if (edits.length > 0) diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index ec64d4fd..adc0fba4 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -334,7 +334,7 @@ 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. + * tile is on it, the same way a Time shader does. */ private syncTilemap(item: DrawItem, onScreen: boolean): void { let field = this.tilemaps.get(item.id); diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 30874962..86922bc6 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -5,13 +5,7 @@ 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 { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile'; 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'; @@ -55,14 +49,14 @@ 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; +/** One tile a tilemap paints with, as drawing needs it: the blocks and how they run. */ +export interface TilemapTile { + info: TileInfo; + playback: TilePlayback; fps: number; } -/** One layer, already worked out: which tile each cell shows and which autotile draws it. */ +/** One layer, already worked out: which quarters each cell shows and which tile draws it. */ export interface TilemapLayerDraw { id: string; tiles: TileDraw[]; @@ -70,7 +64,7 @@ export interface TilemapLayerDraw { export interface TilemapDraw { layers: TilemapLayerDraw[]; - autotiles: Map; + tiles: Map; } /** @@ -82,23 +76,23 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag): const asset = findAssetOf(project, assetId, 'tilemap'); if (!asset) return null; const layers: TilemapLayerDraw[] = []; - const autotiles = new Map(); + const used = 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), + if (used.has(tile.tileId)) continue; + const source = findAssetOf(project, tile.tileId, 'tile'); + if (!source) continue; + used.set(tile.tileId, { + info: readTile(project, source), + playback: tilePlayback(source), + fps: tileFps(source), }); } } - return { layers, autotiles }; + return { layers, tiles: used }; } /** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index 42134488..11446909 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -1,21 +1,21 @@ /** - * The drawn side of a placed tilemap (#128): one mesh per autotile and layer, so a whole map is a + * The drawn side of a placed tilemap (#128): one mesh per tile 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 { pictureIndexAt, type TilePicture } from '../core/model/tile'; import { cellOrigin, tilemapTileSize } from '../core/model/tilemap'; -import type { DrawItem, TilemapAutotile } from './items'; +import type { DrawItem, TilemapTile } from './items'; import { placeNode } from './symbols'; import type { TextureLibrary } from './TextureLibrary'; -import { type TileSheet, tileMesh, tilesByAutotile } from './tilemapMesh'; +import { groupByTile, type TileSheet, tileMesh } 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. */ + /** True after a sync that drew a tile with more than one picture. */ animated = false; /** How many meshes the last sync drew – one draw call each (test hook). */ drawCalls = 0; @@ -25,7 +25,7 @@ export class TilemapField { } /** - * `now` is the global clock, so every tilemap showing the same autotile is on the same step – + * `now` is the global clock, so every tilemap showing the same tile is on the same step – * animated water does not run out of phase between two maps. */ sync(item: DrawItem, textures: TextureLibrary, now: number): void { @@ -39,20 +39,15 @@ export class TilemapField { 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); + for (const [tileId, tiles] of groupByTile(layer.tiles)) { + const source = draw?.tiles.get(tileId); + const picture = source && pictureNow(source, 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}`; + if (!source || !picture || !texture) continue; + if (source.info.pictures.length > 1) this.animated = true; + const key = `${layer.id}|${tileId}`; alive.add(key); - const { positions, uvs, indices } = tileMesh( - tiles, - tile, - origin, - sheetOf(autotile, picture, texture), - ); + const { positions, uvs, indices } = tileMesh(tiles, tile, origin, sheetOf(source, picture, texture)); drawn.push(this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture)); } } @@ -62,7 +57,7 @@ export class TilemapField { 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. + // the list is at most one entry per tile and layer. this.container.removeChildren(); for (const mesh of drawn) this.container.addChild(mesh); this.drawCalls = drawn.length; @@ -89,22 +84,22 @@ export class TilemapField { } /** - * The block this autotile shows right now. A quarter is cut from it by uvs rather than by the + * The block this tile 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; +function pictureNow(source: TilemapTile, now: number): TilePicture | undefined { + const { pictures, problem } = source.info; if (problem) return undefined; - return pictures[pictureIndexAt(pictures.length, autotile.playback, autotile.fps, now)]; + return pictures[pictureIndexAt(pictures.length, source.playback, source.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 { +function sheetOf(source: TilemapTile, picture: TilePicture, texture: Texture): TileSheet { return { - quad: autotile.info.quad, + quad: source.info.quad, block: { x: 0, y: 0, w: texture.width, h: texture.height }, textureWidth: texture.width, textureHeight: texture.height, diff --git a/src/viewport/tilemapMesh.ts b/src/viewport/tilemapMesh.ts index 84599bfb..c0524099 100644 --- a/src/viewport/tilemapMesh.ts +++ b/src/viewport/tilemapMesh.ts @@ -1,6 +1,6 @@ /** - * 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 + * The geometry a painted layer becomes: one mesh per tile instead of one sprite per cell, so a + * map of a thousand cells costs as many draw calls as it has tiles 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. */ @@ -86,13 +86,13 @@ export function tileMesh( 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 { +/** The cells of one layer grouped by tile – one group is one mesh, because it is one texture. */ +export function groupByTile(tiles: readonly TileDraw[]): Map { const groups = new Map(); for (const drawn of tiles) { - const group = groups.get(drawn.autotileId); + const group = groups.get(drawn.tileId); if (group) group.push(drawn); - else groups.set(drawn.autotileId, [drawn]); + else groups.set(drawn.tileId, [drawn]); } return groups; } diff --git a/tests/core/autotile.test.ts b/tests/core/tile.test.ts similarity index 63% rename from tests/core/autotile.test.ts rename to tests/core/tile.test.ts index 5a622a5c..f5ae35fb 100644 --- a/tests/core/autotile.test.ts +++ b/tests/core/tile.test.ts @@ -5,50 +5,58 @@ import { SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; import { - AddAutotilePictureCommand, - MoveAutotilePictureCommand, - RemoveAutotilePictureCommand, -} from '../../src/core/commands/autotileCommands'; + AddTilePictureCommand, + MoveTilePictureCommand, + RemoveTilePictureCommand, +} from '../../src/core/commands/tileCommands'; import { AddFrameCommand } from '../../src/core/commands/frameCommands'; -import type { AutotileAsset } from '../../src/core/model/Asset'; +import type { TileAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; import { - AUTOTILE_KIND_KEY, autotileCombinations, - autotileKind, - autotilePlayback, type Neighbours, neighboursAt, - pictureIndexAt, - quadrantRect, - readAutotile, SAMPLE_PATTERN, tileQuadrants, } from '../../src/core/model/autotile'; +import { + pictureIndexAt, + quadrantRect, + readTile, + TILE_KIND_KEY, + type TileKind, + tileKind, + tilePlayback, +} from '../../src/core/model/tile'; +import { deserializeProject } from '../../src/core/store/serialize'; import { findAsset, type Project } from '../../src/core/model/Project'; -import { autotilesUsing } from '../../src/core/model/usages'; +import { tilesUsing } from '../../src/core/model/usages'; import { makeProject, roundTrip } from '../helpers'; const FILE = `${'a1b2c3d4'.repeat(8)}.png`; -/** A sheet the size of the RPG-Maker sheets the issue was written from: 48 px tiles, 8 × 4 blocks. */ -function makeAutotile(width = 768, height = 576) { +/** + * A sheet the size of the RPG-Maker sheets #118 was written from: 48 px tiles, 8 × 4 blocks. The + * tile is switched to a floor block, which is what most of these tests are about. + */ +function makeTile(width = 768, height = 576, kind: TileKind = 'floor') { const fixture = makeProject(); const texture = new CreateTextureCommand('Outside', { file: FILE, width, height }); fixture.stack.execute(texture); - const autotile = new CreateAssetCommand('autotile', 'Grass'); - fixture.stack.execute(autotile); + const tile = new CreateAssetCommand('tile', 'Grass'); + fixture.stack.execute(tile); + if (kind !== 'none') fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind)); return { ...fixture, texture: texture.asset, - autotileId: autotile.asset.id, - assetOf: () => findAsset(fixture.project, autotile.asset.id) as AutotileAsset, + tileId: tile.asset.id, + assetOf: () => findAsset(fixture.project, tile.asset.id) as TileAsset, }; } /** Cuts one block out of the sheet and returns the frame id. */ function cutBlock( - fixture: ReturnType, + fixture: ReturnType, rect: { x: number; y: number; w: number; h: number }, ): string { const command = new AddFrameCommand(fixture.texture.id, rect); @@ -142,7 +150,7 @@ function around(present: Partial): Neighbours { describe('autotile blocks', () => { it('is a library asset like a texture: nothing places it, the tilemap of #128 does', () => { - expect(isPlaceableType('autotile')).toBe(false); + expect(isPlaceableType('tile')).toBe(false); }); it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => { @@ -243,11 +251,11 @@ describe('autotile blocks', () => { }); }); -describe('autotile asset', () => { +describe('tile asset', () => { it('derives the tile size from the block and accepts a whole texture as one', () => { - const fixture = makeAutotile(96, 144); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id)); - const info = readAutotile(fixture.project, fixture.assetOf()); + const fixture = makeTile(96, 144); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id)); + const info = readTile(fixture.project, fixture.assetOf()); expect(info.problem).toBe(''); expect(info.tile).toBe(48); expect(info.quad).toBe(24); @@ -255,10 +263,10 @@ describe('autotile asset', () => { }); it('cuts a quarter tile out of the frame the picture names', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const frame = cutBlock(fixture, FLOOR_BLOCK); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame)); - const info = readAutotile(fixture.project, fixture.assetOf()); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame)); + const info = readTile(fixture.project, fixture.assetOf()); expect(info.tile).toBe(48); // The block starts at x = 96, so its third quadrant column starts 48 px further right. expect(quadrantRect(info.pictures[0]!, info.quad, { x: 2, y: 5 })).toEqual({ @@ -270,32 +278,32 @@ describe('autotile asset', () => { }); it('complains about a block that does not fit the kind, without dropping it', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const wall = cutBlock(fixture, WALL_BLOCK); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, wall)); - const asFloor = readAutotile(fixture.project, fixture.assetOf()); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, wall)); + const asFloor = readTile(fixture.project, fixture.assetOf()); expect(asFloor.problem).toContain('floor block'); expect(asFloor.tile).toBe(0); expect(fixture.assetOf().pictures).toEqual([wall]); // The same picture is a wall block, so switching the kind is all it takes. - fixture.stack.execute(new SetAssetPropertyCommand(fixture.autotileId, AUTOTILE_KIND_KEY, 'wall')); - const asWall = readAutotile(fixture.project, fixture.assetOf()); + fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall')); + const asWall = readTile(fixture.project, fixture.assetOf()); expect(asWall.problem).toBe(''); expect(asWall.tile).toBe(48); }); it('rejects an animation whose steps are not the same size, and a picture that is gone', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const big = cutBlock(fixture, FLOOR_BLOCK); const small = cutBlock(fixture, { x: 0, y: 0, w: 32, h: 48 }); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, big)); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, small)); - expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('different sizes'); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, big)); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, small)); + expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('different sizes'); // A dangling id is named as missing rather than silently skipped. fixture.assetOf().pictures = ['gone']; - expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist'); + expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist'); fixture.assetOf().pictures = []; - expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('No picture yet'); + expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('No picture yet'); }); it('steps through the pictures, loop and ping-pong, and holds still on a single one', () => { @@ -310,84 +318,160 @@ describe('autotile asset', () => { }); }); -describe('autotile pictures', () => { +describe('tile pictures', () => { it('adds, removes and reorders with undo', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const a = cutBlock(fixture, FLOOR_BLOCK); const b = cutBlock(fixture, { ...FLOOR_BLOCK, x: 192 }); const c = cutBlock(fixture, { ...FLOOR_BLOCK, x: 288 }); - for (const id of [a, b, c]) fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, id)); + for (const id of [a, b, c]) fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, id)); expect(fixture.assetOf().pictures).toEqual([a, b, c]); - fixture.stack.execute(new MoveAutotilePictureCommand(fixture.autotileId, 2, 0)); + fixture.stack.execute(new MoveTilePictureCommand(fixture.tileId, 2, 0)); expect(fixture.assetOf().pictures).toEqual([c, a, b]); fixture.stack.undo(); expect(fixture.assetOf().pictures).toEqual([a, b, c]); - fixture.stack.execute(new RemoveAutotilePictureCommand(fixture.autotileId, 1)); + fixture.stack.execute(new RemoveTilePictureCommand(fixture.tileId, 1)); expect(fixture.assetOf().pictures).toEqual([a, c]); fixture.stack.undo(); expect(fixture.assetOf().pictures).toEqual([a, b, c]); // Inserting somewhere other than the end, and taking it out again. - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, c, 1)); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, c, 1)); expect(fixture.assetOf().pictures).toEqual([a, c, b, c]); fixture.stack.undo(); expect(fixture.assetOf().pictures).toEqual([a, b, c]); }); it('takes only textures and frames, and only places that exist', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const sprite = new CreateAssetCommand('sprite', 'Box'); fixture.stack.execute(sprite); - expect(() => - new AddAutotilePictureCommand(fixture.autotileId, sprite.asset.id).execute(fixture.project), - ).toThrow(/not a texture or a frame/); - expect(() => new RemoveAutotilePictureCommand(fixture.autotileId, 0).execute(fixture.project)).toThrow( + expect(() => new AddTilePictureCommand(fixture.tileId, sprite.asset.id).execute(fixture.project)).toThrow( + /not a texture or a frame/, + ); + expect(() => new RemoveTilePictureCommand(fixture.tileId, 0).execute(fixture.project)).toThrow( /no picture 1/, ); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id)); - expect(() => new MoveAutotilePictureCommand(fixture.autotileId, 0, 3).execute(fixture.project)).toThrow( + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id)); + expect(() => new MoveTilePictureCommand(fixture.tileId, 0, 3).execute(fixture.project)).toThrow( /no place 4/, ); }); it('counts as a use of the texture, so deleting it is not silent', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const frame = cutBlock(fixture, FLOOR_BLOCK); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame)); - expect(autotilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']); - expect(autotilesUsing(fixture.project, ['somewhere else'])).toEqual([]); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame)); + expect(tilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']); + expect(tilesUsing(fixture.project, ['somewhere else'])).toEqual([]); }); }); -describe('autotile in the document', () => { +describe('tile in the document', () => { it('survives a save and keeps only what it can read', () => { - const fixture = makeAutotile(); + const fixture = makeTile(); const frame = cutBlock(fixture, FLOOR_BLOCK); - fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame)); - fixture.stack.execute(new SetAssetPropertyCommand(fixture.autotileId, AUTOTILE_KIND_KEY, 'wall')); - const asset = autotileOf(roundTrip(fixture.project)); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame)); + fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall')); + const asset = tileOf(roundTrip(fixture.project)); expect(asset.pictures).toEqual([frame]); - expect(autotileKind(asset)).toBe('wall'); + expect(tileKind(asset)).toBe('wall'); - // Junk in the list is dropped, and an unreadable kind or playback falls back to the default. + // Junk in the list is dropped, and an unreadable kind or playback falls back to the default – + // for the kind that is the plain tile. const raw = JSON.parse(JSON.stringify(fixture.project)) as { assets: Record[]; }; - const stored = raw.assets.find((a) => a.type === 'autotile')!; + const stored = raw.assets.find((a) => a.type === 'tile')!; stored.pictures = [frame, 7, '', null, 'gone']; (stored.props as Record).kind = 'diagonal'; (stored.props as Record).playback = 'once'; - const read = autotileOf(roundTrip(raw as unknown as Project)); + const read = tileOf(roundTrip(raw as unknown as Project)); expect(read.pictures).toEqual([frame, 'gone']); - expect(autotileKind(read)).toBe('floor'); - expect(autotilePlayback(read)).toBe('loop'); + expect(tileKind(read)).toBe('none'); + expect(tilePlayback(read)).toBe('loop'); + }); + + /** + * #131 renamed the asset and made a plain tile the default. A project saved before that holds + * `autotile` assets, and one of them without a stored kind meant a floor block: it has to open as + * the same floor, not turn into a plain tile. + */ + it('reads an autotile of an older project as the tile it was', () => { + const raw = { + id: 'p', + assets: [ + { id: 's', type: 'scene', name: 'Main', props: {}, objects: [] }, + { id: 'a', type: 'autotile', name: 'Old grass', props: {}, pictures: ['x'] }, + { id: 'b', type: 'autotile', name: 'Old wall', props: { kind: 'wall', fps: 2 }, pictures: [] }, + { id: 'c', type: 'tile', name: 'New', props: {}, pictures: [] }, + ], + }; + const project = deserializeProject(raw); + const byId = (id: string) => project.assets.find((a): a is TileAsset => a.id === id && a.type === 'tile'); + expect(byId('a')?.pictures).toEqual(['x']); + expect(tileKind(byId('a')!)).toBe('floor'); + expect(tileKind(byId('b')!)).toBe('wall'); + expect(byId('b')?.props.fps).toBe(2); + expect(tileKind(byId('c')!)).toBe('none'); + expect(project.assets.some((a) => (a.type as string) === 'autotile')).toBe(false); }); }); -function autotileOf(project: Project): AutotileAsset { - const asset = project.assets.find((a): a is AutotileAsset => a.type === 'autotile'); - if (!asset) throw new Error('no autotile in the project'); +describe('plain tile', () => { + it('is what a new tile is', () => { + const fixture = makeTile(96, 96, 'none'); + expect(tileKind(fixture.assetOf())).toBe('none'); + }); + + it('is one tile that ignores its neighbours', () => { + expect(autotileCombinations('none')).toHaveLength(1); + const lone = tileQuadrants('none', around({})); + const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true }; + // Every corner takes its own quarter of the picture – the picture drawn as it is. + expect(lone).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ]); + expect(tileQuadrants('none', all)).toEqual(lone); + expect(tileQuadrants('none', around({ n: true, w: true }))).toEqual(lone); + }); + + it('takes any square picture as the tile, an odd side too', () => { + const fixture = makeTile(768, 576, 'none'); + const square = cutBlock(fixture, { x: 48, y: 0, w: 48, h: 48 }); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, square)); + const info = readTile(fixture.project, fixture.assetOf()); + expect(info.problem).toBe(''); + expect(info.tile).toBe(48); + expect(quadrantRect(info.pictures[0]!, info.quad, { x: 1, y: 1 })).toEqual({ + x: 72, + y: 24, + w: 24, + h: 24, + }); + + const odd = makeTile(25, 25, 'none'); + odd.stack.execute(new AddTilePictureCommand(odd.tileId, odd.texture.id)); + expect(readTile(odd.project, odd.assetOf()).tile).toBe(25); + }); + + it('complains about a picture that is not square', () => { + const fixture = makeTile(96, 144, 'none'); + fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id)); + const info = readTile(fixture.project, fixture.assetOf()); + expect(info.problem).toContain('one square tile'); + expect(info.problem).toContain('96 × 144'); + expect(info.tile).toBe(0); + }); +}); + +function tileOf(project: Project): TileAsset { + const asset = project.assets.find((a): a is TileAsset => a.type === 'tile'); + if (!asset) throw new Error('no tile in the project'); return asset; } diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts index 7b451ac5..b10e7bbe 100644 --- a/tests/core/tilemap.test.ts +++ b/tests/core/tilemap.test.ts @@ -4,7 +4,7 @@ import { CreateTextureCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; -import { AddAutotilePictureCommand } from '../../src/core/commands/autotileCommands'; +import { AddTilePictureCommand } from '../../src/core/commands/tileCommands'; import { AddTilemapLayerCommand, MoveTilemapLayerCommand, @@ -15,7 +15,7 @@ import { } 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 { TILE_KIND_KEY, type TileKind } from '../../src/core/model/tile'; import { findAsset, type Project } from '../../src/core/model/Project'; import { cellAt, @@ -30,17 +30,17 @@ import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY, - tilemapAutotileIds, + tilemapTileIds, tilemapSize, } from '../../src/core/model/tilemap'; import { tilemapsUsing } from '../../src/core/model/usages'; -import { tileMesh, tilesByAutotile } from '../../src/viewport/tilemapMesh'; +import { groupByTile, tileMesh } 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') { +function makeTilemap(kind: TileKind = 'floor') { const fixture = makeProject(); const texture = new CreateTextureCommand('Outside', { file: FILE, @@ -48,25 +48,25 @@ function makeTilemap(kind: 'floor' | 'wall' = 'floor') { 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 tile = new CreateAssetCommand('tile', 'Grass'); + fixture.stack.execute(tile); + fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind)); + fixture.stack.execute(new AddTilePictureCommand(tile.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, + tileId: tile.asset.id, tilemapId: tilemap.asset.id, assetOf, layerId: () => assetOf().layers[0]!.id, - paint: (cells: { x: number; y: number }[], autotileId = autotile.asset.id) => + paint: (cells: { x: number; y: number }[], tileId = tile.asset.id) => fixture.stack.execute( new PaintTilesCommand( tilemap.asset.id, assetOf().layers[0]!.id, - cells.map((at) => ({ ...at, autotileId })), + cells.map((at) => ({ ...at, tileId })), ), ), }; @@ -78,9 +78,9 @@ function cellsOf(project: Project, assetId: string): Record { } describe('the tilemap asset', () => { - it('is placed, unlike the autotiles it draws with', () => { + it('is placed, unlike the tiles it draws with', () => { expect(isPlaceableType('tilemap')).toBe(true); - expect(isPlaceableType('autotile')).toBe(false); + expect(isPlaceableType('tile')).toBe(false); }); it('starts with one layer and no cells', () => { @@ -119,33 +119,33 @@ describe('painting a tilemap', () => { { 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 }); + expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId }); + f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '' }])); + expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.tileId }); f.stack.undo(); - expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId }); + expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId }); 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'); + const other = new CreateAssetCommand('tile', '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); + expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.tileId); }); 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); + expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: f.tileId }])).toBe(false); + expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: '' }])).toBe(true); + expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, tileId: f.tileId }])).toBe(true); }); it('covers the cells between two pointer events', () => { @@ -175,9 +175,9 @@ describe('painting a tilemap', () => { }); describe('what a painted cell shows', () => { - it('joins only cells holding the same autotile', () => { + it('joins only cells holding the same tile', () => { const f = makeTilemap(); - const other = new CreateAssetCommand('autotile', 'Sand'); + const other = new CreateAssetCommand('tile', 'Sand'); f.stack.execute(other); f.paint([ { x: 5, y: 5 }, @@ -186,9 +186,9 @@ describe('what a painted cell shows', () => { 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); + const around = neighboursOf(props, layer, 5, 5, f.tileId); expect(around.e).toBe(true); - // The cell above holds a *different* autotile, so this one closes against it. + // The cell above holds a *different* tile, so this one closes against it. expect(around.n).toBe(false); expect(around.w).toBe(false); }); @@ -204,7 +204,7 @@ describe('what a painted cell shows', () => { { x: 1, y: 1 }, ]); const asset = f.assetOf(); - const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.autotileId); + const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.tileId); expect(around.n).toBe(true); expect(around.w).toBe(true); expect(around.e).toBe(false); @@ -243,6 +243,26 @@ describe('what a painted cell shows', () => { ]); }); + it('draws a plain tile the same in every cell, whatever lies around it', () => { + const f = makeTilemap('none'); + f.paint([ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 1, y: 2 }, + { x: 5, y: 5 }, + ]); + const asset = f.assetOf(); + const tiles = layerTiles(f.project, asset.props, asset.layers[0]!); + expect(tiles).toHaveLength(4); + for (const tile of tiles) + expect(tile.quadrants).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 0, y: 1 }, + { x: 1, y: 1 }, + ]); + }); + 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)); @@ -251,11 +271,11 @@ describe('what a painted cell shows', () => { { x: 0, y: 0 }, { x: 5, y: 0 }, ]); - f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: 'gone' }])); + f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '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. + // The cell naming a deleted tile is kept in the model – an undo may bring it back. expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone'); }); @@ -310,13 +330,11 @@ describe('the layers of a tilemap', () => { 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 PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, tileId: f.tileId }])); 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 }); + expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.tileId }); }); }); @@ -330,37 +348,37 @@ describe('a tilemap in the document', () => { 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[0]!.cells).toEqual({ '1,2': f.tileId }); 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[0]!.cells['nonsense'] = f.tileId; + asset.layers[0]!.cells['1.5,2'] = f.tileId; + asset.layers[0]!.cells['3,3'] = f.tileId; 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 }); + expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId }); }); - it('counts as a use of the autotiles painted on it', () => { + it('counts as a use of the tiles painted on it', () => { const f = makeTilemap(); - expect(tilemapsUsing(f.project, [f.autotileId])).toHaveLength(0); + expect(tilemapsUsing(f.project, [f.tileId])).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]); + expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]); + expect(tilemapTileIds(f.assetOf())).toEqual([f.tileId]); }); }); 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', () => { + it('is one mesh per tile, not one per cell', () => { const f = makeTilemap(); - const other = new CreateAssetCommand('autotile', 'Sand'); + const other = new CreateAssetCommand('tile', 'Sand'); f.stack.execute(other); f.paint([ { x: 0, y: 0 }, @@ -369,9 +387,9 @@ describe('the mesh a layer becomes', () => { ]); f.paint([{ x: 4, y: 0 }], other.asset.id); const asset = f.assetOf(); - const groups = tilesByAutotile(layerTiles(f.project, asset.props, asset.layers[0]!)); + const groups = groupByTile(layerTiles(f.project, asset.props, asset.layers[0]!)); expect(groups.size).toBe(2); - expect(groups.get(f.autotileId)).toHaveLength(3); + expect(groups.get(f.tileId)).toHaveLength(3); }); it('builds four quads per cell, at the cell it belongs to', () => { @@ -379,7 +397,7 @@ describe('the mesh a layer becomes', () => { { x: 1, y: 0, - autotileId: 'a', + tileId: 'a', quadrants: [ { x: 0, y: 0 }, { x: 1, y: 0 }, @@ -409,7 +427,7 @@ describe('the mesh a layer becomes', () => { { x: 0, y: 0, - autotileId: 'a', + tileId: 'a', quadrants: [ { x: 0, y: 0 }, { x: 0, y: 0 }, diff --git a/tests/editor/autotile-strip.test.ts b/tests/editor/tile-strip.test.ts similarity index 89% rename from tests/editor/autotile-strip.test.ts rename to tests/editor/tile-strip.test.ts index 26a691f9..2ca59715 100644 --- a/tests/editor/autotile-strip.test.ts +++ b/tests/editor/tile-strip.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { dropIndex } from '../../src/editor/autotile/PictureStrip'; +import { dropIndex } from '../../src/editor/tile/PictureStrip'; /** - * Reordering the pictures of an autotile by dragging a row (#118). The index the move command + * Reordering the pictures of a tile by dragging a row (#118). The index the move command * takes counts in the list without the dragged row, which is where the off-by-ones live. */ describe('dropping a picture row', () => { diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index f7f38621..58fd338f 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -163,7 +163,7 @@ export async function assets(t) { { id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] }, { id: 'anim-open', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] }, { id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] }, - { id: 'at-open', type: 'autotile', name: 'Grass', props: {}, pictures: [] }, + { id: 'at-open', type: 'tile', name: 'Grass', props: {}, pictures: [] }, )`); const openable = [ ['mover', 'graph', '__gm.graph.current()'], @@ -172,7 +172,7 @@ export async function assets(t) { ['tex-open', 'texture', '__gm.texture.current()'], ['anim-open', 'animation', '__gm.animation.current()'], ['box-open', 'container', '__gm.container.current()'], - ['at-open', 'autotile', '__gm.autotile.current()'], + ['at-open', 'tile', '__gm.tile.current()'], ]; for (const [id, tab, shown] of openable) { await showTab('scene'); diff --git a/tools/e2e/39-autotile.mjs b/tools/e2e/39-tile.mjs similarity index 70% rename from tools/e2e/39-autotile.mjs rename to tools/e2e/39-tile.mjs index c6216e85..0aeb0436 100644 --- a/tools/e2e/39-autotile.mjs +++ b/tools/e2e/39-tile.mjs @@ -2,7 +2,7 @@ import { colourDistance } from '../lib/harness.mjs'; import { openTab } from './lib/scaffold.mjs'; /** - * Autotiles (#118). The sheet is drawn here and pasted in like a real picture: two floor blocks + * Tiles (#118, #131). The sheet is drawn here and pasted in like a real picture: two floor blocks * (the two steps of an animation) beside each other and a wall block below them, every quarter tile * in a colour of its own. That is what lets the checks below read a pixel off the board and say * which quarter the editor put into which corner of a tile – the rule the whole feature is, and one @@ -51,14 +51,14 @@ export function quadColour(kind, qx, qy, step = 0) { * one at a time, a running animation can step between them, and that is a flake, not a finding. */ async function tileReads(t, index, expected, kind) { - const tile = await t.json(`__gm.autotile.tile(${index})`); + const tile = await t.json(`__gm.tile.tile(${index})`); if (!tile) return { ok: false, detail: 'no such tile on the board' }; const positions = tile.quadrants.map((q) => [q.x, q.y]); if (JSON.stringify(positions) !== JSON.stringify(expected)) return { ok: false, detail: JSON.stringify({ positions, expected }) }; const points = tile.quadrants.map((q) => [Math.round(q.at.x), Math.round(q.at.y)]); const shot = await t.json( - `(() => ({ step: __gm.autotile.board().picture, read: ${JSON.stringify(points)}` + + `(() => ({ step: __gm.tile.board().picture, read: ${JSON.stringify(points)}` + `.map(([x, y]) => __gm.pixelAt(x, y)) }))()`, ); const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, shot.step)); @@ -66,13 +66,13 @@ async function tileReads(t, index, expected, kind) { return { ok: off.length === 0, step: shot.step, detail: JSON.stringify({ read: shot.read, want }) }; } -export async function autotile(t) { +export async function tile(t) { const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, settle, undo } = t; await press('Escape'); await evaluate('__gm.state.select(null)'); - // 39z. The sheet, the three blocks cut out of it and the autotile drawn from the two floor ones. + // 39z. The sheet, the blocks cut out of it and the tile drawn from the two floor ones. // Only the picture goes in the normal way (a paste, as in the nine-slice step); the frames // and the asset are written into the model, which is what the unit tests cover. await evaluate(PASTE_SHEET); @@ -82,7 +82,7 @@ export async function autotile(t) { ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', ); check( - 'the autotile sheet is imported at its own size', + 'the tile sheet is imported at its own size', sheet?.w === 192 && sheet?.h === 240, JSON.stringify(sheet), ); @@ -94,22 +94,23 @@ export async function autotile(t) { { id: 'at-floor-1', name: 'Floor 1', x: 0, y: 0, w: 96, h: 144 }, { id: 'at-floor-2', name: 'Floor 2', x: 96, y: 0, w: 96, h: 144 }, { id: 'at-wall', name: 'Wall', x: 0, y: 144, w: 96, h: 96 }, + { id: 'at-plain', name: 'Plain', x: 0, y: 0, w: 48, h: 48 }, ); __gm.state.project.assets.push({ id: 'at-grass', - type: 'autotile', + type: 'tile', name: 'Grass', props: { kind: 'floor', fps: 4, playback: 'loop' }, pictures: ['at-floor-1', 'at-floor-2'], });`); - // 39a. Opening the autotile asset opens its tab, with the board built for a floor block. + // 39a. Opening the tile asset opens its tab, with the board built for a floor block. await evaluate("__gm.state.openAsset('at-grass')"); await settle(); - let board = await json('__gm.autotile.board()'); + let board = await json('__gm.tile.board()'); check( - 'opening an autotile shows the Autotile tab with the asset in it', - (await json('__gm.tab()')) === 'autotile' && board.assetId === 'at-grass', + 'opening a tile shows the Tile tab with the asset in it', + (await json('__gm.tab()')) === 'tile' && board.assetId === 'at-grass', JSON.stringify({ tab: await json('__gm.tab()'), board }), ); check( @@ -119,12 +120,12 @@ export async function autotile(t) { ); check( 'the readout and the picture list name the two steps', - (await json('__gm.autotile.info()')).includes('2 pictures') && - JSON.stringify((await json('__gm.autotile.pictures()')).pictures) === + (await json('__gm.tile.info()')).includes('2 pictures') && + JSON.stringify((await json('__gm.tile.pictures()')).pictures) === JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']), JSON.stringify({ - info: await json('__gm.autotile.info()'), - strip: await json('__gm.autotile.pictures()'), + info: await json('__gm.tile.info()'), + strip: await json('__gm.tile.pictures()'), }), ); @@ -148,7 +149,7 @@ export async function autotile(t) { // Row 3, column 2 of the sample map is surrounded on all eight sides, so all four quarters of // that tile come from the block's inside – taken turned by half a tile, the way RPG Maker does // it, which is what makes the joint to the next tile seamless. - const surrounded = await json('__gm.autotile.sampleTile(3, 2)'); + const surrounded = await json('__gm.tile.sampleTile(3, 2)'); const inside = await tileReads( t, surrounded, @@ -161,7 +162,7 @@ export async function autotile(t) { 'floor', ); check('a surrounded tile of the sample map is drawn from its inside', inside.ok, inside.detail); - await shot('39-autotile'); + await shot('39-tile'); // 39c. The animation: two pictures at 4 fps step through on their own, and the picture decides // the colours on screen. @@ -171,7 +172,7 @@ export async function autotile(t) { await settle(); const seen = new Set(); for (let i = 0; i < 20 && seen.size < 2; i++) { - seen.add((await json('__gm.autotile.board()')).picture); + seen.add((await json('__gm.tile.board()')).picture); await sleep(120); } check('the two pictures run as an animation', seen.has(0) && seen.has(1), JSON.stringify([...seen])); @@ -189,13 +190,13 @@ export async function autotile(t) { check('and the step it stands on is the one drawn', animated.ok, `${animated.step}: ${animated.detail}`); // 39d. Fit, pan and zoom. - await t.click(...Object.values(await json('__gm.autotile.fitAt()'))); + await t.click(...Object.values(await json('__gm.tile.fitAt()'))); await settle(); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); const fitted = { zoom: board.zoom, pan: board.pan }; const centre = { x: board.rect.x + board.rect.w / 2, y: board.rect.y + board.rect.h - 40 }; await drag(centre.x, centre.y, centre.x + 60, centre.y - 40); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check( 'dragging on the board pans it', Math.round(board.pan.x - fitted.pan.x) === 60 && Math.round(board.pan.y - fitted.pan.y) === -40, @@ -203,11 +204,11 @@ export async function autotile(t) { ); await wheel(centre.x, centre.y, -300); await settle(); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check('the wheel zooms it', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom })); - await t.click(...Object.values(await json('__gm.autotile.fitAt()'))); + await t.click(...Object.values(await json('__gm.tile.fitAt()'))); await settle(); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check( 'Fit frames the board as before', Math.abs(board.zoom - fitted.zoom) < 1e-6 && Math.abs(board.pan.x - fitted.pan.x) < 1e-6, @@ -216,11 +217,11 @@ export async function autotile(t) { // 39e. The kind: the same pictures are no wall block, and the tab says so instead of drawing // something wrong. - const kindAt = await json('__gm.autotile.kindPickerAt()'); + const kindAt = await json('__gm.tile.kindPickerAt()'); await t.click(kindAt.x, kindAt.y); await clickText('Wall (2 × 2)'); await settle(); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check( 'switching the kind to Wall rejects the floor blocks with a reason', board.kind === 'wall' && board.tiles === 0 && board.problem.includes('wall block'), @@ -229,23 +230,23 @@ export async function autotile(t) { // 39f. Its own block, though, is one: the two floor pictures out, the wall picture in. for (let i = 0; i < 2; i++) { - const row = await json('__gm.autotile.pictureRowAt(0)'); + const row = await json('__gm.tile.pictureRowAt(0)'); await t.click(row.x, row.y); - const remove = await json('__gm.autotile.removePictureAt()'); + const remove = await json('__gm.tile.removePictureAt()'); await t.click(remove.x, remove.y); await settle(); } - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check( - 'an autotile without a picture says what it is missing', + 'a tile without a picture says what it is missing', board.pictures === 0 && board.problem.includes('No picture yet'), JSON.stringify(board), ); - const addAt = await json('__gm.autotile.addPictureAt()'); + const addAt = await json('__gm.tile.addPictureAt()'); await t.click(addAt.x, addAt.y); await clickText('Autotiles / Wall'); await settle(); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check( 'a wall block gives the 16 tiles a wall has', board.combinations === 16 && board.tile === 48 && board.problem === '', @@ -264,10 +265,10 @@ export async function autotile(t) { ); check('and its lone tile is drawn from its own four corners', wall.ok, wall.detail); - // 39g. Everything since the kind switch is one undo step each, and the floor autotile comes back. + // 39g. Everything since the kind switch is one undo step each, and the floor tile comes back. for (let i = 0; i < 4; i++) await undo(); await settle(); - board = await json('__gm.autotile.board()'); + board = await json('__gm.tile.board()'); check( 'undo puts the pictures and the kind back', board.kind === 'floor' && board.combinations === 47 && board.pictures === 2 && board.problem === '', @@ -275,23 +276,71 @@ export async function autotile(t) { ); // 39h. Dragging a row of the picture list reorders the animation. - const first = await json('__gm.autotile.pictureRowAt(0)'); - const second = await json('__gm.autotile.pictureRowAt(1)'); + const first = await json('__gm.tile.pictureRowAt(0)'); + const second = await json('__gm.tile.pictureRowAt(1)'); await drag(first.x, first.y, second.x, second.y + 6); await settle(); check( 'dragging a picture below the other reorders the steps', - JSON.stringify((await json('__gm.autotile.pictures()')).pictures) === + JSON.stringify((await json('__gm.tile.pictures()')).pictures) === JSON.stringify(['Autotiles / Floor 2', 'Autotiles / Floor 1']), - JSON.stringify(await json('__gm.autotile.pictures()')), + JSON.stringify(await json('__gm.tile.pictures()')), ); await undo(); await settle(); check( 'and undo puts the order back', - JSON.stringify((await json('__gm.autotile.pictures()')).pictures) === + JSON.stringify((await json('__gm.tile.pictures()')).pictures) === JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']), - JSON.stringify(await json('__gm.autotile.pictures()')), + JSON.stringify(await json('__gm.tile.pictures()')), + ); + + // 39i. A plain tile (#131): the floor blocks are no single square tile, and one square frame is – + // drawn as it is, the same four quarters wherever it lies, whatever lies around it. + const plainAt = await json('__gm.tile.kindPickerAt()'); + await t.click(plainAt.x, plainAt.y); + await clickText('None (1 × 1)'); + await settle(); + board = await json('__gm.tile.board()'); + check( + 'switching the kind to None rejects the floor blocks as not square', + board.kind === 'none' && board.tiles === 0 && board.problem.includes('one square tile'), + JSON.stringify(board), + ); + await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-plain']"); + await settle(); + board = await json('__gm.tile.board()'); + check( + 'a square frame is one plain tile of 48 px', + board.combinations === 1 && + board.tile === 48 && + board.problem === '' && + (await json('__gm.tile.info()')).endsWith(' · 48 px · 1 picture · 1 tile'), + JSON.stringify({ board, info: await json('__gm.tile.info()') }), + ); + const plainQuarters = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ]; + const plainLone = await tileReads(t, 0, plainQuarters, 'floor'); + const plainInside = await tileReads(t, await json('__gm.tile.sampleTile(3, 2)'), plainQuarters, 'floor'); + check( + 'and it is drawn as it is, alone and surrounded alike', + plainLone.ok && plainInside.ok, + `${plainLone.detail} | ${plainInside.detail}`, + ); + await mutate( + "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", + ); + await undo(); + await settle(); + board = await json('__gm.tile.board()'); + check( + 'undo makes it a floor block again', + board.kind === 'floor' && board.combinations === 47 && board.problem === '', + JSON.stringify(board), ); // Leave the tab as the rest of the suite expects to find the editor. diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 0a0a9e87..0a085a9b 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -1,10 +1,10 @@ import { colourDistance } from '../lib/harness.mjs'; import { LANES, openTab, pickAssetType } from './lib/scaffold.mjs'; -import { quadColour } from './39-autotile.mjs'; +import { quadColour } from './39-tile.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. + * Tilemaps (#128): a placed grid painted with the tiles of step 39, which is where the sheet + * and the `at-grass` tile 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 @@ -101,28 +101,28 @@ export async function tilemap(t) { 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. + // 40b. The inspector paints: a tool, the tiles of the project, the layers. const section = await json( - '(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.autotileAt("at-grass") !== null,' + + '(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.tileButtonAt("at-grass") !== null,' + ' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()', ); check( - 'a selected tilemap offers tools, autotiles and layers in the inspector', + 'a selected tilemap offers tools, tiles 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. + // Clicked through the hook rather than by its text: the tile's own row in the asset panel + // reads "Grass" as well, and a click there would only select the tile. check( - 'the autotile can be picked in the section', - await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")'), + 'the tile can be picked in the section', + await clickInInspector(t, '__gm.tilemap.tileButtonAt("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', + 'picking a tile reaches for the brush by itself', + paint.tileId === 'at-grass' && paint.tool === 'brush', JSON.stringify(paint), ); @@ -197,10 +197,10 @@ export async function tilemap(t) { ); await settle(); - // 40f. One mesh per autotile, not one per cell – the whole point of drawing it as a mesh. + // 40f. One mesh per tile, 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', + 'nine cells of one tile are one draw call', drawn.tiles === 9 && drawn.drawCalls === 1 && drawn.layers === 1, JSON.stringify(drawn), ); @@ -229,7 +229,7 @@ export async function tilemap(t) { layers.length === 2 && layers[1].cells === 0, JSON.stringify(layers), ); - await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")'); + await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'); await settle(); const above = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 0)`); await click(above.x, above.y); diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 06be6270..3c93c44d 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -41,7 +41,7 @@ import { loadScene } from './35-load-scene.mjs'; 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 { tile } from './39-tile.mjs'; import { tilemap } from './40-tilemap.mjs'; export const STEPS = [ @@ -83,6 +83,6 @@ export const STEPS = [ graphGroups, nineSlice, plugins, - autotile, + tile, tilemap, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 106c46f2..846eda14 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -110,7 +110,7 @@ export const TAB_LABELS = { shader: 'Shader', texture: 'Texture', animation: 'Animation', - autotile: 'Autotile', + tile: 'Tile', container: 'Container', list: 'List', particles: 'Particles', @@ -134,7 +134,7 @@ export async function openTab(t, 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). + // (measured 2026-09-17: step 39 ended on the Autotile tab – now Tile – 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)})`) : [];