Autotiles are a new asset type with a tab that shows every tile of the floor (2x3) or wall (2x2) block and an animated sample pattern (#118)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c17b2b5426
commit
1a9f07c4ac
24 files changed
+1797
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<AssetType, string> = {
|
||||
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<AssetType, string> = {
|
||||
* 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<AssetType> = new Set([
|
||||
'scene',
|
||||
'texture',
|
||||
'animation',
|
||||
'autotile',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
|
||||
@@ -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<AutotileKind, number> = { 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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user