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'; /** Whether a cell of this tile blocks movement in the running game (#132). */ export const TILE_SOLID_KEY = 'solid'; /** How big the blocking box of a cell is against the cell, 1 = the whole cell (#140). */ export const TILE_COLLISION_SCALE_KEY = 'collisionScale'; export const TILE_COLLISION_SCALE_MIN = 0.1; export const TILE_COLLISION_SCALE_MAX = 2; /** 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 isTileSolid(asset: TileAsset): boolean { return asset.props[TILE_SOLID_KEY] === true; } /** The collision scale, held to the range the field offers; anything unreadable is the whole cell. */ export function tileCollisionScale(asset: TileAsset): number { const scale = Number(asset.props[TILE_COLLISION_SCALE_KEY] ?? 1); if (!Number.isFinite(scale)) return 1; return Math.min(TILE_COLLISION_SCALE_MAX, Math.max(TILE_COLLISION_SCALE_MIN, scale)); } 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; }