diff --git a/README.md b/README.md index dfeca9ee..01287670 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, 969 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 989 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 7c0784c4..2bbb04f6 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -39,6 +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 'statemachine': return { ...base, type: 'statemachine', graph: createEmptyGraph() }; case 'shader': diff --git a/src/core/commands/autotileCommands.ts b/src/core/commands/autotileCommands.ts new file mode 100644 index 00000000..7c88c365 --- /dev/null +++ b/src/core/commands/autotileCommands.ts @@ -0,0 +1,91 @@ +/** + * The picture list of an autotile (#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'; + +/** Appends a picture; `at` puts it before the picture with that index instead. */ +export class AddAutotilePictureCommand implements Command { + readonly label = 'Add autotile picture'; + private index = -1; + + constructor( + private readonly assetId: string, + private readonly pictureId: string, + private readonly at?: number, + ) {} + + execute(project: Project): void { + const asset = requireAutotile(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)) + throw new CommandError(`${this.pictureId} is not a texture or a frame`); + this.index = this.at === undefined ? asset.pictures.length : clampIndex(this.at, asset.pictures.length); + asset.pictures.splice(this.index, 0, this.pictureId); + } + + undo(project: Project): void { + requireAutotile(project, this.assetId).pictures.splice(this.index, 1); + } +} + +export class RemoveAutotilePictureCommand implements Command { + readonly label = 'Remove autotile picture'; + private removed = ''; + + constructor( + private readonly assetId: string, + private readonly index: number, + ) {} + + execute(project: Project): void { + const asset = requireAutotile(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; + asset.pictures.splice(this.index, 1); + } + + undo(project: Project): void { + requireAutotile(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'; + + constructor( + private readonly assetId: string, + private readonly from: number, + private readonly to: number, + ) {} + + execute(project: Project): void { + this.move(project, this.from, this.to); + } + + undo(project: Project): void { + this.move(project, this.to, this.from); + } + + private move(project: Project, from: number, to: number): void { + const asset = requireAutotile(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) + throw new CommandError(`${asset.name} has no place ${to + 1} to move it to`); + asset.pictures.splice(from, 1); + asset.pictures.splice(to, 0, picture); + } +} + +function clampIndex(index: number, length: number): number { + if (!Number.isFinite(index)) return length; + return Math.max(0, Math.min(length, Math.floor(index))); +} diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 15a5ce51..eab19d08 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -1,7 +1,7 @@ /** Lookups that throw a `CommandError` when the target does not exist. */ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import { type NodeDef, nodeDef } from '../graph/NodeRegistry'; -import type { Asset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset'; +import type { Asset, AutotileAsset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset'; import { findObject, type ObjectTree } from '../model/hierarchy'; import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance'; import { @@ -79,6 +79,12 @@ 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`); + return asset; +} + export function requireStateMachine(project: Project, assetId: string): StateMachineAsset { const asset = findAssetOf(project, assetId, 'statemachine'); if (!asset) throw new CommandError(`State machine ${assetId} not found`); diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index a9af342d..b1122490 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -184,6 +184,17 @@ export interface SoundAsset extends AssetBase { file: string; } +/** + * 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. + */ +export interface AutotileAsset extends AssetBase { + type: 'autotile'; + pictures: string[]; +} + /** * The graph is the motion of one particle: every output of its `Output` node is a function of the * particle's age and its own random number (see `core/particles/compile.ts`). A placed object is @@ -205,6 +216,7 @@ export type Asset = | ContainerAsset | TextureAsset | AnimationAsset + | AutotileAsset | StateMachineAsset | ShaderAsset | FontAsset diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 3cc5efe8..9a4f2b3d 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -9,6 +9,7 @@ export const ASSET_TYPES = [ 'container', 'texture', 'animation', + 'autotile', 'statemachine', 'shader', 'font', @@ -29,6 +30,7 @@ export const ASSET_TYPE_LABELS: Record = { container: 'Container', texture: 'Texture', animation: 'Animation', + autotile: 'Autotile', statemachine: 'State machine', shader: 'Shader', font: 'Font', @@ -40,17 +42,19 @@ 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. + * sound is nowhere on screen at all – a graph plays it, so there is nothing to place. An autotile + * is a tile a tilemap draws with (#128), so it is placed no more directly than a texture is. */ export type PlaceableAssetType = Exclude< AssetType, - 'scene' | 'texture' | 'animation' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables' + 'scene' | 'texture' | 'animation' | 'autotile' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables' >; const NOT_PLACEABLE: ReadonlySet = new Set([ 'scene', 'texture', 'animation', + 'autotile', 'statemachine', 'shader', 'font', diff --git a/src/core/model/autotile.ts b/src/core/model/autotile.ts new file mode 100644 index 00000000..9530f3dc --- /dev/null +++ b/src/core/model/autotile.ts @@ -0,0 +1,313 @@ +import type { AutotileAsset } from './Asset'; +import { textOf } from './Properties'; +import type { Project } from './Project'; +import { type FrameRect, resolvePicture, type TextureRef } from './texture'; + +/** + * 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. + */ +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. */ +export interface Neighbours { + n: boolean; + e: boolean; + s: boolean; + w: boolean; + ne: boolean; + se: boolean; + sw: boolean; + 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]; + +/** + * Where one corner of a tile takes its quarter from. `fill` is the inside, `h`/`v` the straight + * edges towards the horizontal and the vertical neighbour, `corner` the convex corner where both + * are missing, and `inner` the concave notch where both neighbours are there but the diagonal + * between them is not. A `wall` has no notch, which is why its block is one tile row shorter. + */ +interface CornerSpec { + fill: QuadPos; + h: QuadPos; + v: QuadPos; + corner: QuadPos; + inner?: QuadPos; +} + +const at = (x: number, y: number): QuadPos => ({ x, y }); + +/** + * The floor block: the top tile row holds the four notches, the 2 × 2 below it the bordered tile + * whose middle four quadrants are the inside. Verified against the `Outside_A1`–`A4` sheets. + */ +const FLOOR_CORNERS: readonly CornerSpec[] = [ + { fill: at(1, 3), h: at(1, 2), v: at(0, 3), corner: at(0, 2), inner: at(0, 0) }, + { fill: at(2, 3), h: at(2, 2), v: at(3, 3), corner: at(3, 2), inner: at(1, 0) }, + { fill: at(1, 4), h: at(1, 5), v: at(0, 4), corner: at(0, 5), inner: at(0, 1) }, + { fill: at(2, 4), h: at(2, 5), v: at(3, 4), corner: at(3, 5), inner: at(1, 1) }, +]; + +/** The wall block: the same bordered 2 × 2 tile and nothing else. */ +const WALL_CORNERS: readonly CornerSpec[] = [ + { fill: at(1, 1), h: at(1, 0), v: at(0, 1), corner: at(0, 0) }, + { fill: at(2, 1), h: at(2, 0), v: at(3, 1), corner: at(3, 0) }, + { fill: at(1, 2), h: at(1, 3), v: at(0, 2), corner: at(0, 3) }, + { fill: at(2, 2), h: at(2, 3), v: at(3, 2), corner: at(3, 3) }, +]; + +function cornersOf(kind: AutotileKind): readonly CornerSpec[] { + return kind === 'wall' ? WALL_CORNERS : FLOOR_CORNERS; +} + +/** + * `sideH` is the neighbour left or right of the tile, `sideV` the one above or below, `diagonal` + * the one between them. Without notches a diagonal says nothing, so a wall ignores it. + */ +function quadrantFor(spec: CornerSpec, sideH: boolean, sideV: boolean, diagonal: boolean): QuadPos { + if (sideH && sideV) return !spec.inner || diagonal ? spec.fill : spec.inner; + if (sideH) return spec.h; + if (sideV) return spec.v; + return spec.corner; +} + +/** The four quarters a tile with these neighbours is drawn from. */ +export function tileQuadrants(kind: AutotileKind, around: Neighbours): TileQuadrants { + const [tl, tr, bl, br] = cornersOf(kind) as [CornerSpec, CornerSpec, CornerSpec, CornerSpec]; + return [ + quadrantFor(tl, around.w, around.n, around.nw), + quadrantFor(tr, around.e, around.n, around.ne), + quadrantFor(bl, around.w, around.s, around.sw), + quadrantFor(br, around.e, around.s, around.se), + ]; +} + +const NO_NEIGHBOURS: Neighbours = { + n: false, + e: false, + s: false, + w: false, + ne: false, + se: false, + sw: false, + nw: false, +}; + +const SIDES = ['n', 'e', 's', 'w'] as const; +const DIAGONALS = ['ne', 'se', 'sw', 'nw'] as const; + +/** A tile as it comes out of the block: the neighbours that produce it and the quarters it uses. */ +export interface AutotileCombination { + neighbours: Neighbours; + quadrants: TileQuadrants; + /** The joined sides, with a notched corner spelled out: `NESW-nw`, `·` for a lone tile. */ + label: string; +} + +/** + * 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. + */ +export function autotileCombinations(kind: AutotileKind): AutotileCombination[] { + const combinations: AutotileCombination[] = []; + const seen = new Set(); + const keys = [...SIDES, ...DIAGONALS] as const; + for (let mask = 0; mask < 1 << keys.length; mask++) { + const neighbours = { ...NO_NEIGHBOURS }; + keys.forEach((key, bit) => (neighbours[key] = (mask & (1 << bit)) !== 0)); + const quadrants = tileQuadrants(kind, neighbours); + const signature = quadrants.map((q) => `${q.x},${q.y}`).join(' '); + if (seen.has(signature)) continue; + seen.add(signature); + combinations.push({ neighbours, quadrants, label: combinationLabel(neighbours, quadrants, kind) }); + } + return combinations; +} + +function combinationLabel(around: Neighbours, quadrants: TileQuadrants, kind: AutotileKind): string { + const sides = SIDES.filter((side) => around[side]) + .map((side) => side.toUpperCase()) + .join(''); + const corners = cornersOf(kind); + const notched = DIAGONALS.filter((_, index) => { + const corner = NOTCH_ORDER[index]!; + const inner = corners[corner]?.inner; + const drawn = quadrants[corner]!; + return inner !== undefined && drawn.x === inner.x && drawn.y === inner.y; + }); + return `${sides || '·'}${notched.length > 0 ? `-${notched.join(',')}` : ''}`; +} + +/** Which corner of the tile each diagonal belongs to, in the order of `DIAGONALS`. */ +const NOTCH_ORDER = [1, 3, 2, 0]; + +/** + * The little map the tab composes as a sample: a ring with a hole and a step, which is every + * interesting case at once – outer corners, straight edges and, in the hole, the notches. + */ +export const SAMPLE_PATTERN: readonly string[] = [ + '..........', + '..#####...', + '.#######..', + '.###..###.', + '.###..###.', + '..######..', + '...####...', + '..........', +]; + +export function patternCell(pattern: readonly string[], row: number, column: number): boolean { + return pattern[row]?.[column] === '#'; +} + +/** What lies around a cell of a pattern; anything outside it counts as empty. */ +export function neighboursAt(pattern: readonly string[], row: number, column: number): Neighbours { + const cell = (r: number, c: number) => patternCell(pattern, r, c); + return { + n: cell(row - 1, column), + e: cell(row, column + 1), + s: cell(row + 1, column), + w: cell(row, column - 1), + ne: cell(row - 1, column + 1), + se: cell(row + 1, column + 1), + sw: cell(row + 1, column - 1), + 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/usages.ts b/src/core/model/usages.ts index 7680a0be..cd7c9714 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 } from './Asset'; +import type { AnimationAsset, Asset, AutotileAsset } from './Asset'; import { overrideSites } from './instance'; import { type ObjectHolder, objectHolders, type Project } from './Project'; import type { SceneObject } from './SceneObject'; @@ -63,6 +63,14 @@ 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[] { + const wanted = new Set(ids); + return project.assets.filter( + (a): a is AutotileAsset => a.type === 'autotile' && a.pictures.some((id) => wanted.has(id)), + ); +} + /** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */ export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] { const wanted = new Set(ids); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 095cedc1..e4d7623b 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -20,6 +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 { SLICE_SCALE_KEY } from '../model/texture'; import type { SceneObject } from '../model/SceneObject'; import { flatListOf, listItemOf } from './listItems'; @@ -217,6 +218,32 @@ 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: [ + { + key: AUTOTILE_KIND_KEY, + label: 'Kind', + kind: 'enum', + default: 'floor', + options: [ + { 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: AUTOTILE_PLAYBACK_KEY, + label: 'Playback', + kind: 'enum', + default: 'loop', + options: [ + { value: 'loop', label: 'Loop' }, + { value: 'pingpong', label: 'Ping-pong' }, + ], + }, + ], // Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`. behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }], // Every row is declared on the asset itself – see `schemaForBag`. diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 5c27922a..ddf43615 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -168,6 +168,13 @@ 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') { + // 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( + (id): id is string => typeof id === 'string' && id !== '', + ); + assets.push({ id, type, name, props, pictures }); } else if (type === 'animation') { // A root or row aiming at something gone is kept: it reads as missing and keeps its keys. const root = typeof a.root === 'string' ? a.root : ''; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 81d1cee5..582a7e1d 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -30,6 +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 { AssetPanel } from './panels/AssetPanel'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { Toolbar } from './panels/Toolbar'; @@ -55,6 +56,7 @@ export type CentreTab = | 'game' | 'texture' | 'animation' + | 'autotile' | 'container' | 'list' | 'plugins'; @@ -72,6 +74,7 @@ const TAB_ORDER = [ 'particles', 'texture', 'animation', + 'autotile', 'container', 'list', 'plugins', @@ -86,6 +89,7 @@ const TAB_LABELS: Record = { particles: 'Particles', texture: 'Texture', animation: 'Animation', + autotile: 'Autotile', container: 'Container', list: 'List', plugins: 'Plugins', @@ -100,6 +104,7 @@ const TAB_OF_ASSET: Partial> = { particles: 'particles', texture: 'texture', animation: 'animation', + autotile: 'autotile', container: 'container', }; @@ -127,6 +132,7 @@ export class Editor implements PlayHost { readonly particlesEditor: ParticlesEditor; readonly textureEditor: TextureEditor; readonly animationEditor: AnimationEditor; + readonly autotileEditor: AutotileEditor; readonly containerEditor: ContainerEditor; readonly listEditor: ListEditor; /** Plugins tab: the switch per file in `data/plugins/`. */ @@ -177,6 +183,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.containerEditor = new ContainerEditor(state); this.listEditor = new ListEditor(state); this.hierarchy = new HierarchyPanel(state); @@ -235,6 +242,7 @@ export class Editor implements PlayHost { particles: this.particlesEditor, texture: this.textureEditor, animation: this.animationEditor, + autotile: this.autotileEditor, container: this.containerEditor, list: this.listEditor, plugins: this.pluginsPane, @@ -408,6 +416,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); // 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(); @@ -426,7 +435,8 @@ export class Editor implements PlayHost { (this.viewport.visible && this.viewport.animates) || (this.gameView.visible && this.gameView.animates) || this.tab === 'particles' || - (this.tab === 'animation' && this.animationEditor.isPlaying) + (this.tab === 'animation' && this.animationEditor.isPlaying) || + (this.tab === 'autotile' && this.autotileEditor.isPlaying) ); } diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 6f7228c2..7ad8151f 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, findPropertyUsages, findUsages } from '../core/model/usages'; +import { animationsUsing, autotilesUsing, findPropertyUsages, findUsages } from '../core/model/usages'; import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; @@ -97,11 +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 detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` - : references > 0 || animations > 0 - ? wornDetail(references, animations, asset.type) + : references > 0 || animations > 0 || autotiles > 0 + ? wornDetail(references, animations, autotiles, asset.type) : usages === 0 ? 'This cannot be undone with Ctrl+Z after reload.' : asset.type === 'container' @@ -113,11 +114,15 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void { ]).openCentered(); } -function wornDetail(sprites: number, animations: number, type: AssetType): string { +function wornDetail(sprites: number, animations: number, autotiles: 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)` : '', ].filter(Boolean); - return `${who.join(' and ')} reference it and show the plain colour in its place.`; + // "a, b and c" rather than "a and b and c", which three kinds of user made reachable. + const list = + who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and '); + return `${list} reference it and show the plain colour in its place.`; } diff --git a/src/editor/autotile/AutotileCanvas.ts b/src/editor/autotile/AutotileCanvas.ts new file mode 100644 index 00000000..3d633ebc --- /dev/null +++ b/src/editor/autotile/AutotileCanvas.ts @@ -0,0 +1,348 @@ +import { + Container, + type FederatedPointerEvent, + type FederatedWheelEvent, + Graphics, + Sprite, + Text, +} from 'pixi.js'; +import type { AutotileAsset } 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 { findAssetOf } from '../../core/model/Project'; +import { type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui'; +import { textureLibrary } from '../../viewport/TextureLibrary'; +import { type Box, frameBoxes } from '../framing'; +import { type GraphView, zoomedAbout } from '../graph/graphView'; +import type { EditorState } from '../state'; + +/** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */ +const GRID_COLUMNS = 8; +/** Gaps and caption room, in tile widths – so the board keeps its shape at any tile size. */ +const GAP = 0.34; +const CAPTION = 0.3; +const SECTION_GAP = 1.2; +const FIT_MARGIN = 16; +/** Wider than the graph's: a 48 px tile is small, and its quarters are what one wants to look at. */ +const ZOOM_LIMITS = { min: 0.1, max: 6 }; + +/** One tile of the board: where it sits and which quarters it is drawn from. */ +interface BoardTile { + x: number; + y: number; + quadrants: TileQuadrants; + sprites: [Sprite, Sprite, Sprite, Sprite]; + /** Set on the tiles of the sample map: which cell of it this is. */ + cell?: { row: number; column: number }; +} + +/** + * The Autotile 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 { + private readonly bg = new Graphics(); + private readonly content = new Container(); + private readonly clip = new Graphics(); + private readonly hint = new Text({ text: '', style: textStyle('muted') }); + private readonly tiles: BoardTile[] = []; + private readonly labels: Text[] = []; + private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 }; + private panning: { start: { x: number; y: number }; pan: { x: number; y: number } } | null = null; + private assetId = ''; + /** Seconds the preview has been running – the only thing that moves without an edit. */ + private time = 0; + /** What the sprites were built for, so an unchanged board is not rebuilt every frame. */ + private built = ''; + private shownPicture = -1; + private board = { w: 0, h: 0 }; + private info: AutotileInfo | null = null; + private needsFit = true; + + constructor(private readonly state: EditorState) { + super(); + this.content.eventMode = 'none'; + this.hint.eventMode = 'none'; + this.addChild(this.bg, this.content, this.hint, this.clip); + this.content.mask = this.clip; + + this.on('pointerdown', (e: FederatedPointerEvent) => { + this.panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...this.view.pan } }; + }); + this.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.panning) return; + const { start, pan } = this.panning; + this.view.pan = { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y }; + this.applyView(); + }); + this.on('pointerup', () => (this.panning = null)); + this.on('pointerupoutside', () => (this.panning = null)); + this.on('wheel', (e: FederatedWheelEvent) => { + this.view = zoomedAbout(this.view, this.toLocal(e.global), zoomFactor(e.deltaY), ZOOM_LIMITS); + this.needsFit = false; + this.applyView(); + e.preventDefault(); + }); + + state.on('project', () => this.redraw()); + state.on('load', () => this.show('')); + // A picture arrives asynchronously; the board is empty until it does. + textureLibrary.onChange(() => this.redraw()); + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + get currentAssetId(): string { + return this.assetId; + } + + /** Switching resets time and view: the next autotile is a new subject. */ + show(assetId: string): void { + if (this.assetId === assetId) return; + this.assetId = assetId; + this.time = 0; + this.needsFit = true; + this.redraw(); + } + + fit(): void { + this.needsFit = true; + this.redraw(); + } + + /** Advances the animation; only called while the tab is visible (see `Editor.tick`). */ + tick(dt: number): void { + if (!this.animates) return; + this.time += dt; + this.redraw(); + } + + /** Whether anything moves by itself – one picture is a still image, however fast it is set to run. */ + get animates(): boolean { + const info = this.info; + return Boolean(info && info.problem === '' && info.pictures.length > 1 && this.fps() > 0); + } + + /** What the board shows – the check scripts read this. */ + debugInfo(): { + assetId: string; + kind: string; + tiles: number; + combinations: number; + picture: number; + pictures: number; + tile: number; + problem: string; + zoom: number; + pan: { x: number; y: number }; + rect: { x: number; y: number; w: number; h: number }; + } { + const info = this.info; + const origin = this.getGlobalPosition(); + return { + assetId: this.assetId, + kind: info?.kind ?? '', + tiles: this.tiles.length, + combinations: info ? autotileCombinations(info.kind).length : 0, + picture: this.shownPicture, + pictures: info?.pictures.length ?? 0, + tile: info?.tile ?? 0, + problem: info?.problem ?? '', + zoom: this.view.zoom, + pan: { ...this.view.pan }, + rect: { x: origin.x, y: origin.y, w: this.w, h: this.h }, + }; + } + + /** + * One tile of the board as drawn: which quarters it uses and where the middle of each of them + * sits on screen. A check reads a pixel there – the only way to see that the right quarter of + * the block ended up in the right corner of the tile. + */ + tileDrawn(index: number): { + quadrants: { x: number; y: number; at: { x: number; y: number } }[]; + } | null { + const tile = this.tiles[index]; + if (!tile) return null; + const half = (this.tiles[0] ? (this.info?.tile ?? 0) / 2 : 0) * this.view.zoom; + return { + quadrants: tile.quadrants.map((position, corner) => { + const sprite = tile.sprites[corner]!; + const at = sprite.getGlobalPosition(); + return { x: position.x, y: position.y, at: { x: at.x + half / 2, y: at.y + half / 2 } }; + }), + }; + } + + /** The board tile a cell of the sample map became, so a check can name one without counting. */ + sampleTile(row: number, column: number): number { + 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 fps(): number { + const asset = this.asset(); + return asset ? autotileFps(asset) : 0; + } + + override layout(): void { + if (this.needsFit) this.redraw(); + } + + override redraw(): void { + 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.build(asset); + this.paint(asset); + this.syncHint(asset); + if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) { + const box: Box = { id: 'board', x: 0, y: 0, w: this.board.w, h: this.board.h }; + const framing = frameBoxes([box], { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS); + this.view = { pan: { x: framing.panX, y: framing.panY }, zoom: framing.zoom }; + this.needsFit = false; + } + this.applyView(); + } + + /** Rebuilds the sprites when the board's shape changed – the kind, the tile size or the asset. */ + private build(asset: AutotileAsset | undefined): void { + const info = this.info; + const signature = asset && info && info.problem === '' ? `${asset.id}:${info.kind}:${info.tile}` : ''; + if (signature === this.built) return; + this.built = signature; + this.shownPicture = -1; + for (const tile of this.tiles.splice(0)) for (const sprite of tile.sprites) sprite.destroy(); + for (const label of this.labels.splice(0)) label.destroy(); + this.content.removeChildren(); + if (!info || signature === '') { + this.board = { w: 0, h: 0 }; + return; + } + this.layoutBoard(info); + } + + /** The combination grid, then the sample map below it – all in image pixels of the tile. */ + private layoutBoard(info: AutotileInfo): void { + const tile = info.tile; + const step = tile * (1 + GAP); + const row = tile * (1 + GAP + CAPTION); + const combinations = autotileCombinations(info.kind); + combinations.forEach((combination, index) => { + const column = index % GRID_COLUMNS; + const line = Math.floor(index / GRID_COLUMNS); + const x = column * step; + const y = line * row; + this.addTile(x, y, combination.quadrants); + this.addLabel(combination.label, x, y + tile + tile * 0.06, tile); + }); + const gridHeight = Math.ceil(combinations.length / GRID_COLUMNS) * row; + const sampleTop = gridHeight + tile * SECTION_GAP; + for (let line = 0; line < SAMPLE_PATTERN.length; line++) + for (let column = 0; column < (SAMPLE_PATTERN[line]?.length ?? 0); column++) { + if (!patternCell(SAMPLE_PATTERN, line, column)) continue; + const around = neighboursAt(SAMPLE_PATTERN, line, column); + this.addTile(column * tile, sampleTop + line * tile, tileQuadrants(info.kind, around), { + row: line, + column, + }); + } + this.board = { + w: Math.max(GRID_COLUMNS * step - tile * GAP, (SAMPLE_PATTERN[0]?.length ?? 0) * tile), + h: sampleTop + SAMPLE_PATTERN.length * tile, + }; + } + + private addTile( + x: number, + y: number, + quadrants: TileQuadrants, + cell?: { row: number; column: number }, + ): void { + const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as [ + Sprite, + Sprite, + Sprite, + Sprite, + ]; + this.content.addChild(...sprites); + this.tiles.push(cell ? { x, y, quadrants, sprites, cell } : { x, y, quadrants, sprites }); + } + + private addLabel(text: string, x: number, y: number, tile: number): void { + const label = new Text({ text, style: textStyle('viewportLabel') }); + // Scaled with the tile so a caption never overruns the tile it belongs to. + const scale = Math.min(1, tile / Math.max(label.width, 1)); + label.scale.set(scale); + label.position.set(x, y); + this.content.addChild(label); + this.labels.push(label); + } + + /** Puts the current picture's quarters into the sprites; a picture still loading leaves them blank. */ + private paint(asset: AutotileAsset | 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 picture = info.pictures[index]; + if (!picture) return; + const half = info.tile / 2; + for (const tile of this.tiles) { + tile.quadrants.forEach((position, corner) => { + const sprite = tile.sprites[corner]!; + const texture = this.quadrant(picture, info, position); + sprite.visible = texture !== null; + if (texture) sprite.texture = texture; + sprite.position.set(tile.x + (corner % 2) * half, tile.y + Math.floor(corner / 2) * half); + sprite.width = half; + sprite.height = half; + }); + } + this.shownPicture = index; + } + + private quadrant(picture: AutotilePicture, info: AutotileInfo, 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 ?? ''); + this.hint.text = text; + this.hint.visible = text !== ''; + this.hint.position.set(Theme.padding * 2, Theme.padding * 2); + } + + private applyView(): void { + this.content.position.set(Math.round(this.view.pan.x), Math.round(this.view.pan.y)); + this.content.scale.set(this.view.zoom); + } +} diff --git a/src/editor/autotile/AutotileEditor.ts b/src/editor/autotile/AutotileEditor.ts new file mode 100644 index 00000000..03de8740 --- /dev/null +++ b/src/editor/autotile/AutotileEditor.ts @@ -0,0 +1,105 @@ +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 { 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 { PictureStrip } from './PictureStrip'; + +/** Room kept for the "Floor · 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 + * 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 + * asset, edited in the inspector like an animation's. + */ +export class AutotileEditor extends AssetTabEditor { + /** Public for the check scripts. */ + readonly kindPicker: Dropdown; + readonly strip: PictureStrip; + + constructor(state: EditorState) { + super(state, { noun: 'Autotile', 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]); + } + + /** The board left, the picture list right – the list keeps its width, the board takes the rest. */ + protected override body(canvas: AutotileCanvas): Widget { + const row = new HStack(); + row.add(canvas, 1); + row.add(this.strip); + return row; + } + + /** Only called while the tab is visible. */ + tick(dt: number): void { + this.canvas.tick(dt); + } + + /** Whether the board moves by itself – an animated autotile has no event to wake it. */ + get isPlaying(): boolean { + return this.canvas.animates; + } + + protected override selectedId(): string { + return this.state.selectedAutotileId ?? ''; + } + + protected override isMine(asset: Asset): asset is AutotileAsset { + return asset.type === 'autotile'; + } + + protected override infoText(asset: AutotileAsset): string { + const info = readAutotile(this.state.project, asset); + const kind = kindLabel(this.state, asset); + const pictures = asset.pictures.length; + const steps = `${pictures} ${pictures === 1 ? 'picture' : 'pictures'}`; + if (info.problem !== '') return `${kind} · ${steps} · not usable yet`; + const tiles = autotileCombinations(info.kind).length; + const fps = pictures > 1 ? ` · ${formatNumber(autotileFps(asset), 2)} fps` : ''; + return `${kind} · ${info.tile} px · ${steps}${fps} · ${tiles} tiles`; + } + + protected override syncExtras(asset: AutotileAsset | null): void { + this.strip.show(asset?.id ?? ''); + this.kindPicker.setText(asset ? kindLabel(this.state, asset) : '—'); + this.kindPicker.setEnabled(asset !== null); + this.fitButton.setEnabled(asset !== null); + } + + private openKindPicker(): void { + const asset = this.asset(); + if (!asset) return; + const current = autotileKind(asset); + const items: MenuItem[] = AUTOTILE_KINDS.map((kind) => ({ + label: `${kind === current ? '● ' : ''}${optionLabel(this.state, asset, kind)}`, + onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, AUTOTILE_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); + 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)); +} diff --git a/src/editor/autotile/PictureStrip.ts b/src/editor/autotile/PictureStrip.ts new file mode 100644 index 00000000..1d4682f9 --- /dev/null +++ b/src/editor/autotile/PictureStrip.ts @@ -0,0 +1,177 @@ +import { Graphics } from 'pixi.js'; +import { + AddAutotilePictureCommand, + MoveAutotilePictureCommand, + RemoveAutotilePictureCommand, +} from '../../core/commands/autotileCommands'; +import type { AutotileAsset } from '../../core/model/Asset'; +import { pictureChoices, textureRefLabel } from '../../core/properties/textureRef'; +import { + Button, + type DropSpot, + HStack, + Label, + ListView, + Menu, + type MenuItem, + ScrollContainer, + Theme, + VStack, +} from '../../ui'; +import type { EditorState } from '../state'; + +const WIDTH = 210; + +/** + * Where a dragged row lands, counted in the list it has *left* – which is what the move command + * takes. A drop on the middle of a row takes that row's place: the list is flat, so there is no + * "inside" for it to mean, and without this a drag downwards onto the next row would cancel itself + * out. The gaps above and below a row are insert points, and one below the dragged row shifts up + * by one once the row is out. `-1` is "nothing to do". + */ +export function dropIndex(from: number, spot: DropSpot, length: number): number { + const last = length - 1; + if (from < 0 || from > last) return -1; + if (spot.at === 'end') return last; + const target = Number(spot.targetId); + if (!Number.isInteger(target) || target < 0 || target > last) return -1; + if (spot.at === 'onto') return target; + const insert = target + (spot.at === 'after' ? 1 : 0); + return Math.min(last, insert > from ? insert - 1 : insert); +} + +/** + * The picture list beside the board (#118): one row per animation step, in the order they run. + * Rows are addressed by their place rather than by the picture they name – the same block may be + * used twice in one animation, and then two rows carry the same id. + */ +export class PictureStrip extends VStack { + /** Public for the check scripts. */ + readonly addButton: Button; + readonly removeButton: Button; + readonly list = new ListView(); + private readonly title = new Label('Pictures', { style: 'muted' }); + private readonly scroll = new ScrollContainer(); + private readonly border = new Graphics(); + private assetId = ''; + private selected = -1; + + constructor(private readonly state: EditorState) { + super({ gap: Theme.gap, padding: Theme.padding }); + this.minWidth = WIDTH; + this.addButton = new Button('+ Picture', { onClick: () => this.openPicturePicker() }); + this.removeButton = new Button('Remove', { onClick: () => this.removeSelected() }); + const header = new HStack({ gap: Theme.gap, align: 'center' }); + header.add(this.title); + this.add(header); + this.list.emptyText = 'No pictures yet'; + this.list.onSelect = (id) => this.select(id === null ? -1 : Number(id)); + this.list.onDrop = (draggedId, spot) => this.drop(Number(draggedId), spot); + this.list.onContextMenu = (id, x, y) => this.openRowMenu(Number(id), x, y); + this.scroll.setContent(this.list); + this.add(this.scroll, 1); + const buttons = new HStack({ gap: Theme.gap }); + buttons.add(this.addButton, 1); + buttons.add(this.removeButton, 1); + this.add(buttons); + this.addChildAt(this.border, 0); + + state.on('project', () => this.refresh()); + state.on('load', () => this.show('')); + } + + show(assetId: string): void { + if (this.assetId === assetId) { + this.refresh(); + return; + } + this.assetId = assetId; + this.selected = -1; + this.refresh(); + } + + /** The rows as shown and which one is picked – the check scripts read this. */ + debugInfo(): { pictures: string[]; selected: number } { + return { pictures: this.asset()?.pictures.map((id) => this.labelOf(id)) ?? [], selected: this.selected }; + } + + private asset(): AutotileAsset | undefined { + const asset = this.state.project.assets.find((a) => a.id === this.assetId); + return asset?.type === 'autotile' ? asset : undefined; + } + + private labelOf(id: string): string { + return textureRefLabel(this.state.project, id); + } + + private refresh(): void { + const asset = this.asset(); + const pictures = asset?.pictures ?? []; + if (this.selected >= pictures.length) this.selected = pictures.length - 1; + this.list.setItems( + pictures.map((id, index) => ({ + id: String(index), + label: this.labelOf(id), + hint: String(index + 1), + })), + ); + this.list.select(this.selected >= 0 ? String(this.selected) : null, true); + this.scroll.relayoutContent(); + this.addButton.setEnabled(asset !== undefined); + this.removeButton.setEnabled(this.selected >= 0); + this.title.setText(pictures.length === 1 ? '1 picture' : `${pictures.length} pictures`); + } + + private select(index: number): void { + this.selected = index; + this.removeButton.setEnabled(index >= 0); + } + + private removeSelected(): void { + const asset = this.asset(); + if (!asset || this.selected < 0) return; + this.state.execute(new RemoveAutotilePictureCommand(asset.id, this.selected)); + } + + /** Every texture and every frame; an autotile 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)), + })); + if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined }); + new Menu(items, { searchable: true }).openBelow(this.addButton); + } + + private openRowMenu(index: number, x: number, y: number): Menu | undefined { + const asset = this.asset(); + if (!asset) return undefined; + this.select(index); + this.list.select(String(index), true); + const menu = new Menu([ + { + label: 'Remove', + danger: true, + onSelect: () => this.state.execute(new RemoveAutotilePictureCommand(asset.id, index)), + }, + ]); + menu.open(x, y); + return menu; + } + + /** Dragging a row to another place is what reorders the animation. */ + private drop(from: number, spot: DropSpot): void { + const asset = this.asset(); + 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); + } + + override redraw(): void { + super.redraw(); + this.border.clear().rect(0, 0, 1, this.h).fill(Theme.colors.border); + } +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 799776f6..0e5e71f5 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -507,6 +507,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E fitAt: () => centreOf(editor.animationEditor.fitButton), }, }, + autotile: { + current: () => editor.autotileEditor.currentAssetId, + info: () => editor.autotileEditor.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(), + /** A tile of the board: its quarters and where each of them is drawn. */ + tile: (index: number) => editor.autotileEditor.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), + }, container: { current: () => containerEditor.currentAssetId, pickerWidth: () => containerEditor.picker.w, diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts index bf4f962e..7614c031 100644 --- a/src/editor/graph/graphView.ts +++ b/src/editor/graph/graphView.ts @@ -23,9 +23,18 @@ export interface GraphView { zoom: number; } -/** The view after zooming by `factor` about a screen point, which keeps the graph point under it. */ -export function zoomedAbout(view: GraphView, at: Point, factor: number): GraphView { - const zoom = clamp(view.zoom * factor, ZOOM_LIMITS.min, ZOOM_LIMITS.max); +/** + * 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 + * board shows 48 px tiles, which one wants to come closer to than a node graph. + */ +export function zoomedAbout( + view: GraphView, + at: Point, + factor: number, + limits: { min: number; max: number } = ZOOM_LIMITS, +): GraphView { + const zoom = clamp(view.zoom * factor, limits.min, limits.max); const graphX = (at.x - view.pan.x) / view.zoom; const graphY = (at.y - view.pan.y) / view.zoom; return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom }; diff --git a/src/editor/state.ts b/src/editor/state.ts index 69e66ce0..a564d7d5 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -523,6 +523,13 @@ 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 { + const s = this.selection; + if (s?.kind !== 'asset') return null; + return findAssetOf(this.project, s.assetId, 'autotile')?.id ?? null; + } + /** The timeline row the selection is about; undefined when it is about something else. */ get selectedTrackId(): string | undefined { return this.selection?.kind === 'track' ? this.selection.trackId : undefined; diff --git a/tests/core/autotile.test.ts b/tests/core/autotile.test.ts new file mode 100644 index 00000000..f0462a49 --- /dev/null +++ b/tests/core/autotile.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + CreateTextureCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { + AddAutotilePictureCommand, + MoveAutotilePictureCommand, + RemoveAutotilePictureCommand, +} from '../../src/core/commands/autotileCommands'; +import { AddFrameCommand } from '../../src/core/commands/frameCommands'; +import type { AutotileAsset } 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 { findAsset, type Project } from '../../src/core/model/Project'; +import { autotilesUsing } 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) { + 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); + return { + ...fixture, + texture: texture.asset, + autotileId: autotile.asset.id, + assetOf: () => findAsset(fixture.project, autotile.asset.id) as AutotileAsset, + }; +} + +/** Cuts one block out of the sheet and returns the frame id. */ +function cutBlock( + fixture: ReturnType, + rect: { x: number; y: number; w: number; h: number }, +): string { + const command = new AddFrameCommand(fixture.texture.id, rect); + fixture.stack.execute(command); + return command.frameId; +} + +const FLOOR_BLOCK = { x: 96, y: 0, w: 96, h: 144 }; +const WALL_BLOCK = { x: 0, y: 0, w: 96, h: 96 }; + +function around(present: Partial): Neighbours { + return { n: false, e: false, s: false, w: false, ne: false, se: false, sw: false, nw: false, ...present }; +} + +describe('autotile blocks', () => { + it('is a library asset like a texture: nothing places it, the tilemap of #128 does', () => { + expect(isPlaceableType('autotile')).toBe(false); + }); + + it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => { + const floor = autotileCombinations('floor'); + const wall = autotileCombinations('wall'); + expect(floor).toHaveLength(47); + expect(wall).toHaveLength(16); + const signature = (quadrants: { x: number; y: number }[]) => + quadrants.map((q) => `${q.x},${q.y}`).join(' '); + expect(new Set(floor.map((c) => signature(c.quadrants))).size).toBe(47); + expect(new Set(wall.map((c) => signature(c.quadrants))).size).toBe(16); + }); + + /** + * The four corners of the block's bordered tile, its inside and its notches – the layout read off + * the `Outside_A1`–`A4` sheets. A wrong one of these is the seam #118 is about. + */ + it('takes a lone tile from the four convex corners and a surrounded one from the inside', () => { + expect(tileQuadrants('floor', around({}))).toEqual([ + { x: 0, y: 2 }, + { x: 3, y: 2 }, + { x: 0, y: 5 }, + { x: 3, y: 5 }, + ]); + const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true }; + expect(tileQuadrants('floor', all)).toEqual([ + { x: 1, y: 3 }, + { x: 2, y: 3 }, + { x: 1, y: 4 }, + { x: 2, y: 4 }, + ]); + }); + + it('draws a straight edge towards the side that is missing', () => { + // Only the north neighbour: the top half is inside, the bottom half is the lower border. + const north = tileQuadrants('floor', around({ n: true })); + expect(north[0]).toEqual({ x: 0, y: 3 }); + expect(north[1]).toEqual({ x: 3, y: 3 }); + expect(north[2]).toEqual({ x: 0, y: 5 }); + expect(north[3]).toEqual({ x: 3, y: 5 }); + }); + + it('puts a notch where both sides are there but the diagonal between them is not', () => { + const withDiagonal = tileQuadrants('floor', around({ n: true, w: true, nw: true })); + const without = tileQuadrants('floor', around({ n: true, w: true })); + // Only the corner the diagonal belongs to changes, and it changes into the notch. + expect(withDiagonal[0]).toEqual({ x: 1, y: 3 }); + expect(without[0]).toEqual({ x: 0, y: 0 }); + expect(without.slice(1)).toEqual(withDiagonal.slice(1)); + }); + + it('ignores the diagonals on a wall, which has no notch to draw', () => { + const bare = tileQuadrants('wall', around({ n: true, w: true })); + const diagonal = tileQuadrants('wall', around({ n: true, w: true, nw: true })); + expect(bare).toEqual(diagonal); + expect(bare[0]).toEqual({ x: 1, y: 1 }); + // Every quarter a wall can use lies in its own 2 × 2 block: four quadrant rows, no more. + for (const combination of autotileCombinations('wall')) + for (const quadrant of combination.quadrants) expect(quadrant.y).toBeLessThan(4); + }); + + it('labels a tile by the sides it joins and the corners it notches', () => { + const floor = autotileCombinations('floor'); + expect(floor[0]?.label).toBe('·'); + const surrounded = floor.find( + (c) => c.neighbours.n && c.neighbours.e && c.neighbours.s && c.neighbours.w, + ); + expect(surrounded?.label).toBe('NESW-ne,se,sw,nw'); + }); + + it('reads the sample map so that the hole in it asks for notches', () => { + // Row 2, column 4 sits right above the hole: joined to the north, open to the south. + const above = neighboursAt(SAMPLE_PATTERN, 2, 4); + expect(above.n).toBe(true); + expect(above.s).toBe(false); + expect(above.se).toBe(false); + // Row 5, column 3 is the cell below the hole's bottom-left corner: joined north and east, + // with the hole itself sitting in the north-east – exactly the case the notch is drawn for. + const corner = neighboursAt(SAMPLE_PATTERN, 5, 3); + expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]); + expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 1, y: 0 }); + }); +}); + +describe('autotile 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()); + expect(info.problem).toBe(''); + expect(info.tile).toBe(48); + expect(info.quad).toBe(24); + expect(info.pictures).toHaveLength(1); + }); + + it('cuts a quarter tile out of the frame the picture names', () => { + const fixture = makeAutotile(); + const frame = cutBlock(fixture, FLOOR_BLOCK); + fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame)); + const info = readAutotile(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({ + x: 96 + 48, + y: 120, + w: 24, + h: 24, + }); + }); + + it('complains about a block that does not fit the kind, without dropping it', () => { + const fixture = makeAutotile(); + const wall = cutBlock(fixture, WALL_BLOCK); + fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, wall)); + const asFloor = readAutotile(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()); + 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 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'); + // 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'); + fixture.assetOf().pictures = []; + expect(readAutotile(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', () => { + // Three pictures at 4 fps: a quarter second each. + const loop = [0, 0.26, 0.51, 0.76, 1.01].map((t) => pictureIndexAt(3, 'loop', 4, t)); + expect(loop).toEqual([0, 1, 2, 0, 1]); + const pingpong = [0, 0.26, 0.51, 0.76, 1.01, 1.26].map((t) => pictureIndexAt(3, 'pingpong', 4, t)); + // Turns on the last picture, so the middle one is never shown twice in a row. + expect(pingpong).toEqual([0, 1, 2, 1, 0, 1]); + expect(pictureIndexAt(1, 'loop', 4, 99)).toBe(0); + expect(pictureIndexAt(3, 'loop', 0, 99)).toBe(0); + }); +}); + +describe('autotile pictures', () => { + it('adds, removes and reorders with undo', () => { + const fixture = makeAutotile(); + 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)); + expect(fixture.assetOf().pictures).toEqual([a, b, c]); + + fixture.stack.execute(new MoveAutotilePictureCommand(fixture.autotileId, 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)); + 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)); + 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 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( + /no picture 1/, + ); + fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id)); + expect(() => new MoveAutotilePictureCommand(fixture.autotileId, 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 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([]); + }); +}); + +describe('autotile in the document', () => { + it('survives a save and keeps only what it can read', () => { + const fixture = makeAutotile(); + 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)); + expect(asset.pictures).toEqual([frame]); + expect(autotileKind(asset)).toBe('wall'); + + // Junk in the list is dropped, and an unreadable kind or playback falls back to the default. + const raw = JSON.parse(JSON.stringify(fixture.project)) as { + assets: Record[]; + }; + const stored = raw.assets.find((a) => a.type === 'autotile')!; + 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)); + expect(read.pictures).toEqual([frame, 'gone']); + expect(autotileKind(read)).toBe('floor'); + expect(autotilePlayback(read)).toBe('loop'); + }); +}); + +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'); + return asset; +} diff --git a/tests/editor/autotile-strip.test.ts b/tests/editor/autotile-strip.test.ts new file mode 100644 index 00000000..26a691f9 --- /dev/null +++ b/tests/editor/autotile-strip.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { dropIndex } from '../../src/editor/autotile/PictureStrip'; + +/** + * Reordering the pictures of an autotile 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', () => { + it('takes the place of the row it is dropped on', () => { + expect(dropIndex(0, { at: 'onto', targetId: '1' }, 3)).toBe(1); + expect(dropIndex(2, { at: 'onto', targetId: '0' }, 3)).toBe(0); + // Dropping a row on itself is nothing to do – which is also what the list reports as no spot. + expect(dropIndex(1, { at: 'onto', targetId: '1' }, 3)).toBe(1); + }); + + it('counts a gap in the list the row has already left', () => { + // Downwards: the gap below row 1 is index 1 once row 0 is out of the way. + expect(dropIndex(0, { at: 'after', targetId: '1' }, 3)).toBe(1); + expect(dropIndex(0, { at: 'before', targetId: '2' }, 3)).toBe(1); + // Upwards nothing shifts: the gap above row 0 stays index 0. + expect(dropIndex(2, { at: 'before', targetId: '0' }, 3)).toBe(0); + expect(dropIndex(2, { at: 'after', targetId: '0' }, 3)).toBe(1); + expect(dropIndex(1, { at: 'end' }, 3)).toBe(2); + }); + + it('refuses a row or a target that is not in the list', () => { + expect(dropIndex(-1, { at: 'end' }, 3)).toBe(-1); + expect(dropIndex(3, { at: 'end' }, 3)).toBe(-1); + expect(dropIndex(0, { at: 'onto', targetId: '9' }, 3)).toBe(-1); + expect(dropIndex(0, { at: 'before', targetId: 'nonsense' }, 3)).toBe(-1); + }); +}); diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index 0f0b8fcb..dd21cdfa 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -74,7 +74,7 @@ export async function assets(t) { // 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups – // what can be placed in a scene, a line, then what only lives in the library – and it is - // tall enough to show all fifteen types without scrolling. + // tall enough to show all sixteen types without scrolling. await clickText('+'); const rows = await json('__gm.menuRows()'); const cut = rows.indexOf(null); @@ -87,7 +87,7 @@ export async function assets(t) { ); check( 'every asset type is offered once, each group sorted by label', - placeable.length + library.length === 15 && + placeable.length + library.length === 16 && String(placeable) === String([...placeable].sort()) && String(library) === String([...library].sort()), JSON.stringify({ placeable, library }), @@ -156,13 +156,14 @@ export async function assets(t) { await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`); // 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it - // lives in – every type that has one. The fixture lacks four of them; they go in through + // lives in – every type that has one. The fixture lacks five of them; they go in through // the model, empty. await mutate(`__gm.state.project.assets.push( { id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } }, { 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: [] }, )`); const openable = [ ['mover', 'graph', '__gm.graph.current()'], @@ -171,6 +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()'], ]; for (const [id, tab, shown] of openable) { await showTab('scene'); @@ -211,7 +213,7 @@ export async function assets(t) { ); await undo(); await mutate( - `__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open'].includes(a.id))`, + `__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open', 'at-open'].includes(a.id))`, ); await showTab('scene'); diff --git a/tools/e2e/39-autotile.mjs b/tools/e2e/39-autotile.mjs new file mode 100644 index 00000000..dcf81754 --- /dev/null +++ b/tools/e2e/39-autotile.mjs @@ -0,0 +1,295 @@ +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 + * (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 + * no unit test can see through the renderer. + * + * Drawn here rather than seeded into the fixture: a texture asset in the fixture is a texture the + * texture steps would find first, and they are written for a project that starts without one. + */ + +const QUAD = 24; + +/** + * The sheet as a paste: 8 × 10 quarter tiles. Floor blocks at x = 0 and x = 96 (told apart by their + * blue), the wall block below the first, the rest transparent. + */ +const PASTE_SHEET = `(async () => { + const canvas = document.createElement('canvas'); + canvas.width = ${QUAD * 8}; + canvas.height = ${QUAD * 10}; + const g = canvas.getContext('2d'); + for (let qy = 0; qy < 10; qy++) + for (let qx = 0; qx < 8; qx++) { + if (qy >= 6 && qx >= 4) continue; + const [r, gr, b] = qy < 6 + ? [40 + (qx % 4) * 60, 30 + qy * 40, qx < 4 ? 90 : 200] + : [30 + (qy - 6) * 60, 40 + qx * 40, 20]; + g.fillStyle = 'rgb(' + r + ',' + gr + ',' + b + ')'; + g.fillRect(qx * ${QUAD}, qy * ${QUAD}, ${QUAD}, ${QUAD}); + } + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const transfer = new DataTransfer(); + transfer.items.add(new File([blob], 'autotiles.png', { type: 'image/png' })); + window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); +})()`; + +/** The colour a quarter of a block was drawn in – the same formula as the paste above. */ +function quadColour(kind, qx, qy, step = 0) { + const [r, g, b] = + kind === 'wall' ? [30 + qy * 60, 40 + qx * 40, 20] : [40 + qx * 60, 30 + qy * 40, step ? 200 : 90]; + return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; +} + +/** Whether the tile's quarters were taken from these block positions and drawn in these colours. */ +async function tileReads(t, index, expected, kind, step = 0) { + const tile = await t.json(`__gm.autotile.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 read = []; + for (const quadrant of tile.quadrants) + read.push(await t.json(`__gm.pixelAt(${Math.round(quadrant.at.x)}, ${Math.round(quadrant.at.y)})`)); + const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, step)); + const off = read.filter((colour, i) => colourDistance(colour, want[i]) > 12); + return { ok: off.length === 0, detail: JSON.stringify({ read, want }) }; +} + +export async function autotile(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. + // 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); + await sleep(2500); + const sheet = await json( + "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" + + ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', + ); + check( + 'the autotile sheet is imported at its own size', + sheet?.w === 192 && sheet?.h === 240, + JSON.stringify(sheet), + ); + await mutate(` + const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet.id)}); + sheet.name = 'Autotiles'; + sheet.props.filter = 'nearest'; + sheet.frames.push( + { 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 }, + ); + __gm.state.project.assets.push({ + id: 'at-grass', + type: 'autotile', + 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. + await evaluate("__gm.state.openAsset('at-grass')"); + await settle(); + let board = await json('__gm.autotile.board()'); + check( + 'opening an autotile shows the Autotile tab with the asset in it', + (await json('__gm.tab()')) === 'autotile' && board.assetId === 'at-grass', + JSON.stringify({ tab: await json('__gm.tab()'), board }), + ); + check( + 'a floor block gives 47 tiles at 48 px and nothing to complain about', + board.kind === 'floor' && board.combinations === 47 && board.tile === 48 && board.problem === '', + JSON.stringify(board), + ); + 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) === + JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']), + JSON.stringify({ + info: await json('__gm.autotile.info()'), + strip: await json('__gm.autotile.pictures()'), + }), + ); + + // 39b. The pixels. While the animation runs the picture under the pointer changes, so it is cut + // down to one step for the reading and put back afterwards. + await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); + await settle(); + const lone = await tileReads( + t, + 0, + [ + [0, 2], + [3, 2], + [0, 5], + [3, 5], + ], + 'floor', + ); + check('a lone tile is drawn from the four convex corners of the block', lone.ok, lone.detail); + + // 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. + const surrounded = await json('__gm.autotile.sampleTile(3, 2)'); + const inside = await tileReads( + t, + surrounded, + [ + [1, 3], + [2, 3], + [1, 4], + [2, 4], + ], + 'floor', + ); + check('a surrounded tile of the sample map is drawn from its inside', inside.ok, inside.detail); + await shot('39-autotile'); + + // 39c. The animation: two pictures at 4 fps step through on their own, and the picture decides + // the colours on screen. + await mutate( + "__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']", + ); + await settle(); + const seen = new Set(); + for (let i = 0; i < 20 && seen.size < 2; i++) { + seen.add((await json('__gm.autotile.board()')).picture); + await sleep(120); + } + check('the two pictures run as an animation', seen.has(0) && seen.has(1), JSON.stringify([...seen])); + const step = (await json('__gm.autotile.board()')).picture; + const animated = await tileReads( + t, + 0, + [ + [0, 2], + [3, 2], + [0, 5], + [3, 5], + ], + 'floor', + step, + ); + check('and the step it stands on is the one drawn', animated.ok, `${step}: ${animated.detail}`); + + // 39d. Fit, pan and zoom. + await t.click(...Object.values(await json('__gm.autotile.fitAt()'))); + await settle(); + board = await json('__gm.autotile.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()'); + 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, + JSON.stringify({ fitted, now: board.pan }), + ); + await wheel(centre.x, centre.y, -300); + await settle(); + board = await json('__gm.autotile.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 settle(); + board = await json('__gm.autotile.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, + JSON.stringify({ fitted, now: { zoom: board.zoom, pan: board.pan } }), + ); + + // 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()'); + await t.click(kindAt.x, kindAt.y); + await clickText('Wall (2 × 2)'); + await settle(); + board = await json('__gm.autotile.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'), + JSON.stringify(board), + ); + + // 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)'); + await t.click(row.x, row.y); + const remove = await json('__gm.autotile.removePictureAt()'); + await t.click(remove.x, remove.y); + await settle(); + } + board = await json('__gm.autotile.board()'); + check( + 'an autotile 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()'); + await t.click(addAt.x, addAt.y); + await clickText('Autotiles / Wall'); + await settle(); + board = await json('__gm.autotile.board()'); + check( + 'a wall block gives the 16 tiles a wall has', + board.combinations === 16 && board.tile === 48 && board.problem === '', + JSON.stringify(board), + ); + const wall = await tileReads( + t, + 0, + [ + [0, 0], + [3, 0], + [0, 3], + [3, 3], + ], + 'wall', + ); + 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. + for (let i = 0; i < 4; i++) await undo(); + await settle(); + board = await json('__gm.autotile.board()'); + check( + 'undo puts the pictures and the kind back', + board.kind === 'floor' && board.combinations === 47 && board.pictures === 2 && board.problem === '', + JSON.stringify(board), + ); + + // 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)'); + 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(['Autotiles / Floor 2', 'Autotiles / Floor 1']), + JSON.stringify(await json('__gm.autotile.pictures()')), + ); + await undo(); + await settle(); + check( + 'and undo puts the order back', + JSON.stringify((await json('__gm.autotile.pictures()')).pictures) === + JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']), + JSON.stringify(await json('__gm.autotile.pictures()')), + ); + + // Leave the tab as the rest of the suite expects to find the editor. + await openTab(t, 'scene'); + await evaluate('__gm.state.select(null)'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index a01d4f36..c67663c9 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -41,6 +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'; export const STEPS = [ hierarchy, @@ -81,4 +82,5 @@ export const STEPS = [ graphGroups, nineSlice, plugins, + autotile, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 22db6448..33c75244 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -109,6 +109,7 @@ export const TAB_LABELS = { shader: 'Shader', texture: 'Texture', animation: 'Animation', + autotile: 'Autotile', container: 'Container', list: 'List', particles: 'Particles',