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
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
// 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`.
|
||||
|
||||
@@ -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 : '';
|
||||
|
||||
+11
-1
@@ -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<CentreTab, string> = {
|
||||
particles: 'Particles',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
plugins: 'Plugins',
|
||||
@@ -100,6 +104,7 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -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.`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<AutotileAsset, AutotileCanvas> {
|
||||
/** 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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof makeAutotile>,
|
||||
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>): 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<string, unknown>[];
|
||||
};
|
||||
const stored = raw.assets.find((a) => a.type === 'autotile')!;
|
||||
stored.pictures = [frame, 7, '', null, 'gone'];
|
||||
(stored.props as Record<string, unknown>).kind = 'diagonal';
|
||||
(stored.props as Record<string, unknown>).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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -109,6 +109,7 @@ export const TAB_LABELS = {
|
||||
shader: 'Shader',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
particles: 'Particles',
|
||||
|
||||
Reference in new issue
Block a user