167 lines
7.2 KiB
TypeScript
167 lines
7.2 KiB
TypeScript
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<TileKind, number> = { 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<TileKind, number> = { 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;
|
||
}
|