Files
feuerwerk/src/core/model/tile.ts
T

167 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}