The autotile asset is renamed to tile and gains a plain None (1 × 1) kind, which is now the default; older projects load their autotiles as floor tiles (#131)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a65132bb5a
commit
732f6518ae
35 files changed
+786
-617
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, 1009 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1013 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,8 +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 'tile':
|
||||
return { ...base, type: 'tile', pictures: [] };
|
||||
// A tilemap without a layer could not be painted on, so it starts with one.
|
||||
case 'tilemap':
|
||||
return { ...base, type: 'tilemap', layers: [{ id: newId(), name: 'Layer 1', cells: {} }] };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type {
|
||||
Asset,
|
||||
AutotileAsset,
|
||||
TileAsset,
|
||||
BehaviorAsset,
|
||||
StateMachineAsset,
|
||||
TilemapAsset,
|
||||
@@ -88,9 +88,9 @@ 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`);
|
||||
export function requireTile(project: Project, assetId: string): TileAsset {
|
||||
const asset = findAssetOf(project, assetId, 'tile');
|
||||
if (!asset) throw new CommandError(`Tile ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* The picture list of an autotile (#118): which blocks it is drawn from, and in which order – the
|
||||
* The picture list of a tile (#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';
|
||||
import { requireTile } 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';
|
||||
export class AddTilePictureCommand implements Command {
|
||||
readonly label = 'Add tile picture';
|
||||
private index = -1;
|
||||
|
||||
constructor(
|
||||
@@ -20,7 +20,7 @@ export class AddAutotilePictureCommand implements Command {
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireAutotile(project, this.assetId);
|
||||
const asset = requireTile(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))
|
||||
@@ -30,12 +30,12 @@ export class AddAutotilePictureCommand implements Command {
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireAutotile(project, this.assetId).pictures.splice(this.index, 1);
|
||||
requireTile(project, this.assetId).pictures.splice(this.index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveAutotilePictureCommand implements Command {
|
||||
readonly label = 'Remove autotile picture';
|
||||
export class RemoveTilePictureCommand implements Command {
|
||||
readonly label = 'Remove tile picture';
|
||||
private removed = '';
|
||||
|
||||
constructor(
|
||||
@@ -44,7 +44,7 @@ export class RemoveAutotilePictureCommand implements Command {
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireAutotile(project, this.assetId);
|
||||
const asset = requireTile(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;
|
||||
@@ -52,13 +52,13 @@ export class RemoveAutotilePictureCommand implements Command {
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireAutotile(project, this.assetId).pictures.splice(this.index, 0, this.removed);
|
||||
requireTile(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';
|
||||
export class MoveTilePictureCommand implements Command {
|
||||
readonly label = 'Reorder tile pictures';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
@@ -75,7 +75,7 @@ export class MoveAutotilePictureCommand implements Command {
|
||||
}
|
||||
|
||||
private move(project: Project, from: number, to: number): void {
|
||||
const asset = requireAutotile(project, this.assetId);
|
||||
const asset = requireTile(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)
|
||||
@@ -12,16 +12,16 @@ import { nextNumberedName } from './naming';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireTilemap, requireTilemapLayer } from './require';
|
||||
|
||||
/** One cell as a stroke wants it: `autotileId` empty erases. */
|
||||
/** One cell as a stroke wants it: `tileId` empty erases. */
|
||||
export interface CellEdit {
|
||||
x: number;
|
||||
y: number;
|
||||
autotileId: string;
|
||||
tileId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a run of cells at once. What each one held before is remembered per cell, so an undo puts
|
||||
* a stroke over another autotile back exactly – not merely empty.
|
||||
* a stroke over another tile back exactly – not merely empty.
|
||||
*/
|
||||
export class PaintTilesCommand implements Command {
|
||||
readonly label: string;
|
||||
@@ -38,7 +38,7 @@ export class PaintTilesCommand implements Command {
|
||||
|
||||
/** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */
|
||||
static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean {
|
||||
return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.autotileId);
|
||||
return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.tileId);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
@@ -47,7 +47,7 @@ export class PaintTilesCommand implements Command {
|
||||
for (const edit of this.edits) {
|
||||
const key = cellKey(edit.x, edit.y);
|
||||
if (!this.previous.has(key)) this.previous.set(key, layer.cells[key] ?? '');
|
||||
if (edit.autotileId) layer.cells[key] = edit.autotileId;
|
||||
if (edit.tileId) layer.cells[key] = edit.tileId;
|
||||
else delete layer.cells[key];
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -185,19 +185,20 @@ export interface SoundAsset extends AssetBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A tile a tilemap paints with: `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 – a plain tile or one of the two autotile blocks that draw their own
|
||||
* edges – how fast it runs and whether it loops or bounces. What a block is cut into is
|
||||
* `model/tile.ts`, which quarter goes where `model/autotile.ts`; placing one is the tilemap of #128.
|
||||
*/
|
||||
export interface AutotileAsset extends AssetBase {
|
||||
type: 'autotile';
|
||||
export interface TileAsset extends AssetBase {
|
||||
type: 'tile';
|
||||
pictures: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the autotile lying there, and
|
||||
* a layer nobody switched off is drawn. Which tile of that autotile a cell shows is worked out
|
||||
* One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the tile lying there, and a
|
||||
* layer nobody switched off is drawn. Which of that tile's quarters a cell shows is worked out
|
||||
* from its neighbours while drawing, never stored – see `model/tilemap.ts`.
|
||||
*/
|
||||
export interface TilemapLayer {
|
||||
@@ -208,7 +209,7 @@ export interface TilemapLayer {
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid painted with autotiles, placed in a scene as a sprite is. How wide the grid is and how
|
||||
* A grid painted with tiles, placed in a scene as a sprite is. How wide the grid is and how
|
||||
* big one cell is are properties, so the inspector edits them with undo like any other row; the
|
||||
* layers are intrinsic data, as a texture's frames are (#128).
|
||||
*/
|
||||
@@ -238,7 +239,7 @@ export type Asset =
|
||||
| ContainerAsset
|
||||
| TextureAsset
|
||||
| AnimationAsset
|
||||
| AutotileAsset
|
||||
| TileAsset
|
||||
| TilemapAsset
|
||||
| StateMachineAsset
|
||||
| ShaderAsset
|
||||
|
||||
@@ -9,7 +9,7 @@ export const ASSET_TYPES = [
|
||||
'container',
|
||||
'texture',
|
||||
'animation',
|
||||
'autotile',
|
||||
'tile',
|
||||
'tilemap',
|
||||
'statemachine',
|
||||
'shader',
|
||||
@@ -31,7 +31,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
container: 'Container',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
tile: 'Tile',
|
||||
tilemap: 'Tilemap',
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
@@ -44,20 +44,20 @@ 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. An autotile
|
||||
* is a tile a tilemap draws with, so it is placed no more directly than a texture is – what is
|
||||
* placed is the tilemap.
|
||||
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. A tile is
|
||||
* what a tilemap draws with, so it is placed no more directly than a texture is – what is placed
|
||||
* is the tilemap.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
'scene' | 'texture' | 'animation' | 'autotile' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables'
|
||||
'scene' | 'texture' | 'animation' | 'tile' | 'statemachine' | 'shader' | 'font' | 'sound' | 'variables'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'scene',
|
||||
'texture',
|
||||
'animation',
|
||||
'autotile',
|
||||
'tile',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
|
||||
+31
-149
@@ -1,50 +1,14 @@
|
||||
import type { AutotileAsset } from './Asset';
|
||||
import { textOf } from './Properties';
|
||||
import type { Project } from './Project';
|
||||
import { type FrameRect, resolvePicture, type TextureRef } from './texture';
|
||||
import type { QuadPos, TileKind } from './tile';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Which quarter goes into which corner of a drawn tile, given its neighbours – what makes a
|
||||
* hand-drawn edge continue across tile borders (#118). `floor` and `wall` 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. A plain tile (`none`, #131) goes through the same code and simply takes the same
|
||||
* four quarters whatever lies around it. Everything counts in quadrants, see `model/tile.ts`.
|
||||
*/
|
||||
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. */
|
||||
/** Which of the eight neighbouring cells hold the same tile. */
|
||||
export interface Neighbours {
|
||||
n: boolean;
|
||||
e: boolean;
|
||||
@@ -56,12 +20,6 @@ export interface Neighbours {
|
||||
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];
|
||||
|
||||
@@ -108,8 +66,23 @@ const WALL_CORNERS: readonly CornerSpec[] = [
|
||||
{ fill: at(1, 1), h: at(1, 3), v: at(3, 1), corner: at(3, 3) },
|
||||
];
|
||||
|
||||
function cornersOf(kind: AutotileKind): readonly CornerSpec[] {
|
||||
return kind === 'wall' ? WALL_CORNERS : FLOOR_CORNERS;
|
||||
/** A plain tile: every corner takes its own quarter of the one tile, so the neighbours change nothing. */
|
||||
const plainCorner = (position: QuadPos): CornerSpec => ({
|
||||
fill: position,
|
||||
h: position,
|
||||
v: position,
|
||||
corner: position,
|
||||
});
|
||||
const PLAIN_CORNERS: readonly CornerSpec[] = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)].map(plainCorner);
|
||||
|
||||
const CORNERS: Record<TileKind, readonly CornerSpec[]> = {
|
||||
none: PLAIN_CORNERS,
|
||||
floor: FLOOR_CORNERS,
|
||||
wall: WALL_CORNERS,
|
||||
};
|
||||
|
||||
function cornersOf(kind: TileKind): readonly CornerSpec[] {
|
||||
return CORNERS[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +97,7 @@ function quadrantFor(spec: CornerSpec, sideH: boolean, sideV: boolean, diagonal:
|
||||
}
|
||||
|
||||
/** The four quarters a tile with these neighbours is drawn from. */
|
||||
export function tileQuadrants(kind: AutotileKind, around: Neighbours): TileQuadrants {
|
||||
export function tileQuadrants(kind: TileKind, around: Neighbours): TileQuadrants {
|
||||
const [tl, tr, bl, br] = cornersOf(kind) as [CornerSpec, CornerSpec, CornerSpec, CornerSpec];
|
||||
return [
|
||||
quadrantFor(tl, around.w, around.n, around.nw),
|
||||
@@ -157,11 +130,12 @@ export interface AutotileCombination {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Every tile the kind can produce, without repeats: 47 for a floor, 16 for a wall, 1 for a plain
|
||||
* tile. 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[] {
|
||||
export function autotileCombinations(kind: TileKind): AutotileCombination[] {
|
||||
const combinations: AutotileCombination[] = [];
|
||||
const seen = new Set<string>();
|
||||
const keys = [...SIDES, ...DIAGONALS] as const;
|
||||
@@ -177,7 +151,7 @@ export function autotileCombinations(kind: AutotileKind): AutotileCombination[]
|
||||
return combinations;
|
||||
}
|
||||
|
||||
function combinationLabel(around: Neighbours, quadrants: TileQuadrants, kind: AutotileKind): string {
|
||||
function combinationLabel(around: Neighbours, quadrants: TileQuadrants, kind: TileKind): string {
|
||||
const sides = SIDES.filter((side) => around[side])
|
||||
.map((side) => side.toUpperCase())
|
||||
.join('');
|
||||
@@ -227,95 +201,3 @@ export function neighboursAt(pattern: readonly string[], row: number, column: nu
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { TileAsset } from './Asset';
|
||||
import { textOf } from './Properties';
|
||||
import type { Project } from './Project';
|
||||
import { type FrameRect, resolvePicture, type TextureRef } from './texture';
|
||||
|
||||
/**
|
||||
* A tile is one picture per animation step, and every picture is a *block* the editor cuts into
|
||||
* quarter tiles. `none` is the plain case: the block is the tile itself and every cell shows it as
|
||||
* it is (#131). `floor` and `wall` are the two autotile blocks of the classic RPG-Maker sheets,
|
||||
* whose quarters are picked by the neighbours – that part is `model/autotile.ts`.
|
||||
*
|
||||
* Everything below counts in *quadrants* – quarter tiles – with (0, 0) at the block's top-left
|
||||
* corner, so all three kinds are drawn the same way: four quarters per tile.
|
||||
*/
|
||||
export const TILE_KINDS = ['none', 'floor', 'wall'] as const;
|
||||
export type TileKind = (typeof TILE_KINDS)[number];
|
||||
|
||||
export const TILE_KIND_KEY = 'kind';
|
||||
export const TILE_FPS_KEY = 'fps';
|
||||
export const TILE_PLAYBACK_KEY = 'playback';
|
||||
|
||||
/** Only the two that make sense for a tile that is always on screen; there is no end to hold. */
|
||||
export const TILE_PLAYBACKS = ['loop', 'pingpong'] as const;
|
||||
export type TilePlayback = (typeof TILE_PLAYBACKS)[number];
|
||||
|
||||
/** Quadrant columns of a block: one tile for `none`, two for the autotile blocks. */
|
||||
export const BLOCK_COLUMNS: Record<TileKind, number> = { none: 2, floor: 4, wall: 4 };
|
||||
|
||||
/** Quadrant rows of a block: `none` is one tile tall, `floor` three and `wall` two. */
|
||||
export const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4 };
|
||||
|
||||
export function tileKind(asset: TileAsset): TileKind {
|
||||
const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none');
|
||||
return (TILE_KINDS as readonly string[]).includes(value) ? (value as TileKind) : 'none';
|
||||
}
|
||||
|
||||
export function tilePlayback(asset: TileAsset): TilePlayback {
|
||||
const value = textOf(asset.props[TILE_PLAYBACK_KEY] ?? 'loop');
|
||||
return (TILE_PLAYBACKS as readonly string[]).includes(value) ? (value as TilePlayback) : 'loop';
|
||||
}
|
||||
|
||||
export function tileFps(asset: TileAsset): number {
|
||||
const fps = Number(asset.props[TILE_FPS_KEY]);
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : 0;
|
||||
}
|
||||
|
||||
/** A quarter tile inside the block, in quadrant units from its top-left corner. */
|
||||
export interface QuadPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** One picture of a tile: the reference it was named by, and the block it covers. */
|
||||
export interface TilePicture {
|
||||
id: string;
|
||||
ref: TextureRef;
|
||||
block: FrameRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the tab can draw and what stands in its way. `problem` is a sentence for the reader, never
|
||||
* a reason to drop anything: a picture of the wrong size stays in the list and can be taken out
|
||||
* again or the kind switched to the one it fits.
|
||||
*/
|
||||
export interface TileInfo {
|
||||
kind: TileKind;
|
||||
pictures: TilePicture[];
|
||||
/** Side of one tile in image pixels, 0 while the tile has no usable picture. */
|
||||
tile: number;
|
||||
/** Side of one quarter tile – half a tile, and what the block is measured in. */
|
||||
quad: number;
|
||||
problem: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a tile against the project: resolves every picture, derives the tile size from the first
|
||||
* one and says what does not fit. The tile size is derived rather than stored – the block's width
|
||||
* is a known number of tiles, so a second number could only ever disagree with the picture.
|
||||
*/
|
||||
export function readTile(project: Project, asset: TileAsset): TileInfo {
|
||||
const kind = tileKind(asset);
|
||||
const columns = BLOCK_COLUMNS[kind];
|
||||
const rows = BLOCK_ROWS[kind];
|
||||
const pictures: TilePicture[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const id of asset.pictures) {
|
||||
const ref = resolvePicture(project, id);
|
||||
if (!ref) {
|
||||
missing.push(id);
|
||||
continue;
|
||||
}
|
||||
pictures.push({ id, ref, block: ref.rect ?? { x: 0, y: 0, w: ref.sourceWidth, h: ref.sourceHeight } });
|
||||
}
|
||||
const first = pictures[0];
|
||||
const quad = first ? first.block.w / columns : 0;
|
||||
// A plain tile is only cut to be drawn like the others, never along a line in the picture, so
|
||||
// an odd side is fine there; an autotile block is cut along its quarters and must split evenly.
|
||||
const whole = kind === 'none' || Number.isInteger(quad);
|
||||
const fits = (rect: FrameRect) => rect.w === quad * columns && rect.h === quad * rows && whole && quad > 0;
|
||||
const problem =
|
||||
asset.pictures.length === 0
|
||||
? 'No picture yet – add a texture or a frame that holds one block.'
|
||||
: missing.length > 0
|
||||
? `${missing.length} picture(s) no longer exist.`
|
||||
: !first || !fits(first.block)
|
||||
? blockProblem(kind, first?.block)
|
||||
: pictures.some((picture) => !fits(picture.block))
|
||||
? 'The pictures have different sizes; every step of an animation must hold the same block.'
|
||||
: '';
|
||||
return { kind, pictures, tile: problem === '' ? quad * 2 : 0, quad: problem === '' ? quad : 0, problem };
|
||||
}
|
||||
|
||||
function blockProblem(kind: TileKind, block: FrameRect | undefined): string {
|
||||
const size = block ? `, this one is ${block.w} × ${block.h} px` : '';
|
||||
if (kind === 'none') return `A plain tile is one square tile${size}.`;
|
||||
const columns = BLOCK_COLUMNS[kind];
|
||||
const rows = BLOCK_ROWS[kind];
|
||||
const shape = `${columns / 2} × ${rows / 2} tiles`;
|
||||
return `A ${kind} block is ${shape}, so its sides must divide by ${columns} and ${rows}${size}.`;
|
||||
}
|
||||
|
||||
/** The rectangle one quarter tile is cut from, in image pixels. */
|
||||
export function quadrantRect(picture: TilePicture, quad: number, position: QuadPos): FrameRect {
|
||||
return {
|
||||
x: picture.block.x + position.x * quad,
|
||||
y: picture.block.y + position.y * quad,
|
||||
w: quad,
|
||||
h: quad,
|
||||
};
|
||||
}
|
||||
|
||||
/** That quarter as a texture reference of its own – a cut of the sheet, never nine-sliced. */
|
||||
export function quadrantRef(picture: TilePicture, quad: number, position: QuadPos): TextureRef {
|
||||
return { ...picture.ref, rect: quadrantRect(picture, quad, position), slice: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which picture is shown `time` seconds in. One picture is a still image whatever the settings
|
||||
* say; ping-pong turns on the last one, so three pictures run 0 1 2 1 and not 0 1 2 2 1.
|
||||
*/
|
||||
export function pictureIndexAt(count: number, playback: TilePlayback, fps: number, time: number): number {
|
||||
if (count <= 1) return 0;
|
||||
if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(time) || time <= 0) return 0;
|
||||
const step = Math.floor(time * fps);
|
||||
if (playback === 'loop') return step % count;
|
||||
const span = 2 * (count - 1);
|
||||
const phase = step % span;
|
||||
return phase < count ? phase : span - phase;
|
||||
}
|
||||
+27
-32
@@ -1,20 +1,15 @@
|
||||
/**
|
||||
* A tilemap is a grid of cells, and every cell names the autotile lying there. Which of that
|
||||
* autotile's tiles a cell shows is *not* stored: it follows from the neighbours and is worked out
|
||||
* A tilemap is a grid of cells, and every cell names the tile lying there. Which of that tile's
|
||||
* quarters a cell shows is *not* stored: it follows from the neighbours and is worked out
|
||||
* while drawing (#128), so painting one cell fixes the edges of the ones around it by itself.
|
||||
*
|
||||
* Cells are sparse – a map from `"x,y"` to an autotile id – rather than one entry per grid square.
|
||||
* Cells are sparse – a map from `"x,y"` to a tile id – rather than one entry per grid square.
|
||||
* A map is what painting, erasing and reading a neighbour all want, and changing the grid size then
|
||||
* moves nothing: what falls outside is simply not drawn and comes back when the grid grows again.
|
||||
*/
|
||||
import type { AutotileAsset, TilemapLayer } from './Asset';
|
||||
import {
|
||||
type AutotileKind,
|
||||
autotileKind,
|
||||
type Neighbours,
|
||||
type TileQuadrants,
|
||||
tileQuadrants,
|
||||
} from './autotile';
|
||||
import type { TileAsset, TilemapLayer } from './Asset';
|
||||
import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile';
|
||||
import { type TileKind, tileKind } from './tile';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
@@ -35,7 +30,7 @@ export function tilemapRows(props: PropertyBag): number {
|
||||
return wholeAbove(props[TILEMAP_ROWS_KEY], 15);
|
||||
}
|
||||
|
||||
/** Side of one cell in world units; the autotile's own pictures are scaled onto it. */
|
||||
/** Side of one cell in world units; the tile's own pictures are scaled onto it. */
|
||||
export function tilemapTileSize(props: PropertyBag): number {
|
||||
const size = Number(props[TILEMAP_TILE_KEY]);
|
||||
return Number.isFinite(size) && size > 0 ? size : 48;
|
||||
@@ -77,7 +72,7 @@ export function inGrid(props: PropertyBag, x: number, y: number): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Which neighbours a cell is joined to. Only the *same* autotile joins: a different one counts as
|
||||
* Which neighbours a cell is joined to. Only the *same* tile joins: a different one counts as
|
||||
* empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets
|
||||
* are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge.
|
||||
*/
|
||||
@@ -86,10 +81,10 @@ export function neighboursOf(
|
||||
layer: TilemapLayer,
|
||||
x: number,
|
||||
y: number,
|
||||
autotileId: string,
|
||||
tileId: string,
|
||||
): Neighbours {
|
||||
const same = (dx: number, dy: number): boolean =>
|
||||
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === autotileId;
|
||||
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === tileId;
|
||||
return {
|
||||
n: same(0, -1),
|
||||
e: same(1, 0),
|
||||
@@ -102,48 +97,48 @@ export function neighboursOf(
|
||||
};
|
||||
}
|
||||
|
||||
/** One cell as it is drawn: where it sits, which autotile it is and which quarters build it. */
|
||||
/** One cell as it is drawn: where it sits, which tile it is and which quarters build it. */
|
||||
export interface TileDraw {
|
||||
x: number;
|
||||
y: number;
|
||||
autotileId: string;
|
||||
tileId: string;
|
||||
quadrants: TileQuadrants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cell of one layer that is inside the grid and names an autotile that exists, in reading
|
||||
* order so the result is the same list twice. Cells naming a deleted autotile are skipped rather
|
||||
* than dropped from the model: an undo brings the autotile, and with it the drawing, back.
|
||||
* Every cell of one layer that is inside the grid and names a tile that exists, in reading order
|
||||
* so the result is the same list twice. Cells naming a deleted tile are skipped rather than
|
||||
* dropped from the model: an undo brings the tile, and with it the drawing, back.
|
||||
*/
|
||||
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] {
|
||||
const kinds = new Map<string, AutotileKind>();
|
||||
const kindOf = (id: string): AutotileKind | null => {
|
||||
const kinds = new Map<string, TileKind>();
|
||||
const kindOf = (id: string): TileKind | null => {
|
||||
const known = kinds.get(id);
|
||||
if (known) return known;
|
||||
const autotile: AutotileAsset | undefined = findAssetOf(project, id, 'autotile');
|
||||
if (!autotile) return null;
|
||||
const kind = autotileKind(autotile);
|
||||
const tile: TileAsset | undefined = findAssetOf(project, id, 'tile');
|
||||
if (!tile) return null;
|
||||
const kind = tileKind(tile);
|
||||
kinds.set(id, kind);
|
||||
return kind;
|
||||
};
|
||||
const tiles: TileDraw[] = [];
|
||||
for (const [key, autotileId] of Object.entries(layer.cells)) {
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||||
const at = parseCellKey(key);
|
||||
if (!at || !autotileId || !inGrid(props, at.x, at.y)) continue;
|
||||
const kind = kindOf(autotileId);
|
||||
if (!at || !tileId || !inGrid(props, at.x, at.y)) continue;
|
||||
const kind = kindOf(tileId);
|
||||
if (!kind) continue;
|
||||
tiles.push({
|
||||
x: at.x,
|
||||
y: at.y,
|
||||
autotileId,
|
||||
quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, autotileId)),
|
||||
tileId,
|
||||
quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, tileId)),
|
||||
});
|
||||
}
|
||||
return tiles.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
}
|
||||
|
||||
/** The autotiles a tilemap paints with, in the order they first appear – its palette in use. */
|
||||
export function tilemapAutotileIds(asset: { layers: TilemapLayer[] }): string[] {
|
||||
/** The tiles a tilemap paints with, in the order they first appear – its palette in use. */
|
||||
export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const layer of asset.layers)
|
||||
for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { referencesAny, trackDef } from './animation';
|
||||
import type { AnimationAsset, Asset, AutotileAsset, TilemapAsset } from './Asset';
|
||||
import type { AnimationAsset, Asset, TileAsset, TilemapAsset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
@@ -63,15 +63,15 @@ 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[] {
|
||||
/** The tiles drawn from any of these pictures – intrinsic data, like an animation's keys. */
|
||||
export function tilesUsing(project: Project, ids: readonly string[]): TileAsset[] {
|
||||
const wanted = new Set(ids);
|
||||
return project.assets.filter(
|
||||
(a): a is AutotileAsset => a.type === 'autotile' && a.pictures.some((id) => wanted.has(id)),
|
||||
(a): a is TileAsset => a.type === 'tile' && a.pictures.some((id) => wanted.has(id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The tilemaps painted with any of these autotiles – intrinsic data, like an autotile's pictures. */
|
||||
/** The tilemaps painted with any of these tiles – intrinsic data, like a tile's pictures. */
|
||||
export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] {
|
||||
const wanted = new Set(ids);
|
||||
return project.assets.filter(
|
||||
|
||||
@@ -20,7 +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 { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY } from '../model/tile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||
import { SLICE_SCALE_KEY } from '../model/texture';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
@@ -220,23 +220,25 @@ 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: [
|
||||
// The pictures are intrinsic data on the asset (see TileAsset); these three say what the
|
||||
// block holds and how its steps run. There is no tile size: the block's width in tiles is known
|
||||
// from the kind, so the picture already says it (see `readTile`). A new tile is a plain one; the
|
||||
// autotile blocks are chosen on purpose (#131).
|
||||
tile: [
|
||||
{
|
||||
key: AUTOTILE_KIND_KEY,
|
||||
key: TILE_KIND_KEY,
|
||||
label: 'Kind',
|
||||
kind: 'enum',
|
||||
default: 'floor',
|
||||
default: 'none',
|
||||
options: [
|
||||
{ value: 'none', label: 'None (1 × 1)' },
|
||||
{ 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: TILE_FPS_KEY, label: 'Frames per second', kind: 'number', default: 4, min: 0.1, max: 120 },
|
||||
{
|
||||
key: AUTOTILE_PLAYBACK_KEY,
|
||||
key: TILE_PLAYBACK_KEY,
|
||||
label: 'Playback',
|
||||
kind: 'enum',
|
||||
default: 'loop',
|
||||
@@ -247,8 +249,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
},
|
||||
],
|
||||
// The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say
|
||||
// how far the grid reaches and how big one cell is drawn. An autotile's own pictures are scaled
|
||||
// onto that cell, so one tilemap can mix autotiles drawn at different resolutions.
|
||||
// how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto
|
||||
// that cell, so one tilemap can mix tiles drawn at different resolutions.
|
||||
tilemap: [
|
||||
{ key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: 1000, step: 1 },
|
||||
{ key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: 1000, step: 1 },
|
||||
|
||||
@@ -79,7 +79,7 @@ import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||||
*/
|
||||
export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
const r = asRecord(raw);
|
||||
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
||||
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord).map(migrateAsset);
|
||||
|
||||
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
|
||||
const schemas = new Map<string, PropertySchema>();
|
||||
@@ -176,7 +176,7 @@ 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') {
|
||||
} else if (type === 'tile') {
|
||||
// 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(
|
||||
@@ -505,7 +505,7 @@ function normalizeTracks(raw: unknown): AnimationTrack[] {
|
||||
|
||||
/**
|
||||
* The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing,
|
||||
* is dropped; one naming a deleted autotile is kept, like an animation's dangling row, so an undo
|
||||
* is dropped; one naming a deleted tile is kept, like an animation's dangling row, so an undo
|
||||
* brings the drawing back. A doubled layer id is dropped as a doubled node is.
|
||||
*/
|
||||
function normalizeLayers(raw: unknown): TilemapLayer[] {
|
||||
@@ -537,6 +537,17 @@ function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): Pr
|
||||
return bag;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile was called an autotile before it could be a plain one (#131). Such an asset had no kind
|
||||
* but the autotile blocks, and one written without a kind meant a floor – which it has to stay now
|
||||
* that a missing kind means a plain tile.
|
||||
*/
|
||||
function migrateAsset(a: Record<string, unknown>): Record<string, unknown> {
|
||||
if (a.type !== 'autotile') return a;
|
||||
const props = asRecord(a.props);
|
||||
return { ...a, type: 'tile', props: { ...props, kind: props.kind ?? 'floor' } };
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
+10
-10
@@ -30,7 +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 { TileEditor } from './tile/TileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
@@ -56,7 +56,7 @@ export type CentreTab =
|
||||
| 'game'
|
||||
| 'texture'
|
||||
| 'animation'
|
||||
| 'autotile'
|
||||
| 'tile'
|
||||
| 'container'
|
||||
| 'list'
|
||||
| 'plugins';
|
||||
@@ -74,7 +74,7 @@ const TAB_ORDER = [
|
||||
'particles',
|
||||
'texture',
|
||||
'animation',
|
||||
'autotile',
|
||||
'tile',
|
||||
'container',
|
||||
'list',
|
||||
'plugins',
|
||||
@@ -89,7 +89,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
particles: 'Particles',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
plugins: 'Plugins',
|
||||
@@ -104,7 +104,7 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
animation: 'animation',
|
||||
autotile: 'autotile',
|
||||
tile: 'tile',
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
@@ -134,7 +134,7 @@ export class Editor implements PlayHost {
|
||||
readonly particlesEditor: ParticlesEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly animationEditor: AnimationEditor;
|
||||
readonly autotileEditor: AutotileEditor;
|
||||
readonly tileEditor: TileEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly listEditor: ListEditor;
|
||||
/** Plugins tab: the switch per file in `data/plugins/`. */
|
||||
@@ -189,7 +189,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.tileEditor = new TileEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
this.listEditor = new ListEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
@@ -259,7 +259,7 @@ export class Editor implements PlayHost {
|
||||
particles: this.particlesEditor,
|
||||
texture: this.textureEditor,
|
||||
animation: this.animationEditor,
|
||||
autotile: this.autotileEditor,
|
||||
tile: this.tileEditor,
|
||||
container: this.containerEditor,
|
||||
list: this.listEditor,
|
||||
plugins: this.pluginsPane,
|
||||
@@ -433,7 +433,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);
|
||||
if (this.tab === 'tile') this.tileEditor.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();
|
||||
@@ -453,7 +453,7 @@ export class Editor implements PlayHost {
|
||||
(this.gameView.visible && this.gameView.animates) ||
|
||||
this.tab === 'particles' ||
|
||||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||||
(this.tab === 'autotile' && this.autotileEditor.isPlaying)
|
||||
(this.tab === 'tile' && this.tileEditor.isPlaying)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, autotilesUsing, findPropertyUsages, findUsages } from '../core/model/usages';
|
||||
import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../core/model/usages';
|
||||
import { Dialog } from '../ui';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
@@ -97,12 +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 tiles = asset.type === 'texture' ? tilesUsing(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 || autotiles > 0
|
||||
? wornDetail(references, animations, autotiles, asset.type)
|
||||
: references > 0 || animations > 0 || tiles > 0
|
||||
? wornDetail(references, animations, tiles, asset.type)
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
@@ -114,12 +114,12 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
]).openCentered();
|
||||
}
|
||||
|
||||
function wornDetail(sprites: number, animations: number, autotiles: number, type: AssetType): string {
|
||||
function wornDetail(sprites: number, animations: number, tiles: 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)` : '',
|
||||
tiles > 0 ? `${tiles} tile(s)` : '',
|
||||
].filter(Boolean);
|
||||
// "a, b and c" rather than "a and b and c", which three kinds of user made reachable.
|
||||
const list =
|
||||
|
||||
+15
-15
@@ -517,26 +517,26 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
fitAt: () => centreOf(editor.animationEditor.fitButton),
|
||||
},
|
||||
},
|
||||
autotile: {
|
||||
current: () => editor.autotileEditor.currentAssetId,
|
||||
info: () => editor.autotileEditor.shownInfo,
|
||||
tile: {
|
||||
current: () => editor.tileEditor.currentAssetId,
|
||||
info: () => editor.tileEditor.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(),
|
||||
board: () => editor.tileEditor.canvas.debugInfo(),
|
||||
pictures: () => editor.tileEditor.strip.debugInfo(),
|
||||
/** A tile of the board: its quarters and where each of them is drawn. */
|
||||
tile: (index: number) => editor.autotileEditor.canvas.tileDrawn(index),
|
||||
tile: (index: number) => editor.tileEditor.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),
|
||||
sampleTile: (row: number, column: number) => editor.tileEditor.canvas.sampleTile(row, column),
|
||||
kindPickerAt: () => centreOf(editor.tileEditor.kindPicker),
|
||||
addPictureAt: () => centreOf(editor.tileEditor.strip.addButton),
|
||||
removePictureAt: () => centreOf(editor.tileEditor.strip.removeButton),
|
||||
pictureRowAt: (index: number) => editor.tileEditor.strip.list.rowGlobal(String(index)),
|
||||
fitAt: () => centreOf(editor.tileEditor.fitButton),
|
||||
},
|
||||
tilemap: {
|
||||
/** What the scene view painted a map with, and on which layer (#128). */
|
||||
paint: () => ({ ...state.tilemapPaint }),
|
||||
/** The cells of one layer as the model holds them: `"x,y"` to autotile id. */
|
||||
/** The cells of one layer as the model holds them: `"x,y"` to tile id. */
|
||||
cells: (assetId: string, layerId: string) => {
|
||||
const asset = findAssetOf(state.project, assetId, 'tilemap');
|
||||
const layer = asset && findLayer(asset, layerId);
|
||||
@@ -557,8 +557,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
const button = currentTilemapSection()?.tools.get(tool as TilemapTool);
|
||||
return button ? centreOf(button) : null;
|
||||
},
|
||||
autotileAt: (assetId: string) => {
|
||||
const button = currentTilemapSection()?.autotiles.get(assetId);
|
||||
tileButtonAt: (assetId: string) => {
|
||||
const button = currentTilemapSection()?.tiles.get(assetId);
|
||||
return button ? centreOf(button) : null;
|
||||
},
|
||||
addLayerAt: () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface GraphView {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `limits` are the graph's own unless a surface with other content says otherwise – the tile
|
||||
* board shows 48 px tiles, which one wants to come closer to than a node graph.
|
||||
*/
|
||||
export function zoomedAbout(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The painting side of a placed tilemap (#128): which layer is painted on, with which autotile and
|
||||
* The painting side of a placed tilemap (#128): which layer is painted on, with which tile and
|
||||
* with which tool. It hangs below the ordinary rows of the object, the way a container's content
|
||||
* section does – the grid size and the cell size are plain properties and are shown above.
|
||||
*
|
||||
* Nothing here belongs to the model except the layers themselves: tool, autotile and layer are
|
||||
* Nothing here belongs to the model except the layers themselves: tool, tile and layer are
|
||||
* editor state (`state.tilemapPaint`), so opening a project does not decide what one paints with.
|
||||
*/
|
||||
import {
|
||||
@@ -37,12 +37,12 @@ export function paintedLayerId(state: EditorState, asset: TilemapAsset): string
|
||||
|
||||
/**
|
||||
* The widgets of the section as they were last built. Every one of them carries an icon or a name
|
||||
* that is on screen elsewhere too – the autotile's row in the asset panel, the layer's name – so a
|
||||
* that is on screen elsewhere too – the tile's row in the asset panel, the layer's name – so a
|
||||
* check cannot find them by their text; this is what it clicks instead.
|
||||
*/
|
||||
export interface TilemapSectionWidgets {
|
||||
tools: Map<TilemapTool, Button>;
|
||||
autotiles: Map<string, Button>;
|
||||
tiles: Map<string, Button>;
|
||||
layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[];
|
||||
addLayer: Button;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v
|
||||
const paint = state.tilemapPaint;
|
||||
const widgets: TilemapSectionWidgets = {
|
||||
tools: new Map(),
|
||||
autotiles: new Map(),
|
||||
tiles: new Map(),
|
||||
layers: [],
|
||||
addLayer: new Button('Add layer', {
|
||||
onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)),
|
||||
@@ -78,25 +78,25 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v
|
||||
addNote(host, TOOLS.find((t) => t.tool === paint.tool)?.hint ?? '', 'small');
|
||||
addNote(host, 'Shift-drag still pans the view.', 'small');
|
||||
|
||||
addTitle(host, 'AUTOTILES');
|
||||
const autotiles = state.project.assets.filter((a) => a.type === 'autotile');
|
||||
if (autotiles.length === 0) addNote(host, 'The project has no autotile yet.');
|
||||
for (const autotile of autotiles) {
|
||||
const button = new Button(autotile.name, {
|
||||
addTitle(host, 'TILES');
|
||||
const tiles = state.project.assets.filter((a) => a.type === 'tile');
|
||||
if (tiles.length === 0) addNote(host, 'The project has no tile yet.');
|
||||
for (const tile of tiles) {
|
||||
const button = new Button(tile.name, {
|
||||
align: 'left',
|
||||
// Choosing a tile is how one starts painting, so it reaches for the brush by itself.
|
||||
onClick: () =>
|
||||
state.setTilemapPaint({
|
||||
autotileId: autotile.id,
|
||||
tileId: tile.id,
|
||||
tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool,
|
||||
}),
|
||||
});
|
||||
button.toggled = paint.autotileId === autotile.id;
|
||||
widgets.autotiles.set(autotile.id, button);
|
||||
button.toggled = paint.tileId === tile.id;
|
||||
widgets.tiles.set(tile.id, button);
|
||||
host.body.add(button);
|
||||
}
|
||||
if (autotiles.length > 0 && !autotiles.some((a) => a.id === paint.autotileId))
|
||||
addNote(host, 'Choose an autotile to paint with.', 'small');
|
||||
if (tiles.length > 0 && !tiles.some((a) => a.id === paint.tileId))
|
||||
addNote(host, 'Choose a tile to paint with.', 'small');
|
||||
|
||||
addTitle(host, 'LAYERS');
|
||||
addNote(host, 'The last layer is drawn on top.', 'small');
|
||||
@@ -148,5 +148,5 @@ export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): v
|
||||
export function tilemapSignature(state: EditorState, asset: TilemapAsset): string {
|
||||
const paint = state.tilemapPaint;
|
||||
const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(',');
|
||||
return `${paint.tool}|${paint.autotileId}|${paintedLayerId(state, asset)}|${layers}`;
|
||||
return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${layers}`;
|
||||
}
|
||||
+7
-7
@@ -141,14 +141,14 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p
|
||||
|
||||
/**
|
||||
* Select leaves the scene view as it is – the map is picked up and moved like any other object.
|
||||
* The other three paint: brush the chosen autotile cell by cell, erase clears, rect fills the box
|
||||
* The other three paint: brush the chosen tile cell by cell, erase clears, rect fills the box
|
||||
* a drag spans. Painting is a mode on purpose; a map one cannot move would be worse than a click.
|
||||
*/
|
||||
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect';
|
||||
|
||||
export interface TilemapPaint {
|
||||
tool: TilemapTool;
|
||||
autotileId: string;
|
||||
tileId: string;
|
||||
layerId: string;
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ export class EditorState {
|
||||
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
|
||||
keyClipboard: KeyClipboard | null = null;
|
||||
/**
|
||||
* What the scene view paints a tilemap with (#128): the tool, the autotile and the layer it goes
|
||||
* What the scene view paints a tilemap with (#128): the tool, the tile and the layer it goes
|
||||
* on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of
|
||||
* working rather than something the project remembers.
|
||||
*/
|
||||
tilemapPaint: TilemapPaint = { tool: 'select', autotileId: '', layerId: '' };
|
||||
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '' };
|
||||
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
|
||||
private edit: { project: Project; commands: CommandStack } | null = null;
|
||||
|
||||
@@ -542,11 +542,11 @@ 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 {
|
||||
/** The tile the selection refers to; only the asset itself, it has nothing inside to pick. */
|
||||
get selectedTileId(): string | null {
|
||||
const s = this.selection;
|
||||
if (s?.kind !== 'asset') return null;
|
||||
return findAssetOf(this.project, s.assetId, 'autotile')?.id ?? null;
|
||||
return findAssetOf(this.project, s.assetId, 'tile')?.id ?? null;
|
||||
}
|
||||
|
||||
/** The timeline row the selection is about; undefined when it is about something else. */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import {
|
||||
AddAutotilePictureCommand,
|
||||
MoveAutotilePictureCommand,
|
||||
RemoveAutotilePictureCommand,
|
||||
} from '../../core/commands/autotileCommands';
|
||||
import type { AutotileAsset } from '../../core/model/Asset';
|
||||
AddTilePictureCommand,
|
||||
MoveTilePictureCommand,
|
||||
RemoveTilePictureCommand,
|
||||
} from '../../core/commands/tileCommands';
|
||||
import type { TileAsset } from '../../core/model/Asset';
|
||||
import { pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||||
import {
|
||||
Button,
|
||||
@@ -95,9 +95,9 @@ export class PictureStrip extends VStack {
|
||||
return { pictures: this.asset()?.pictures.map((id) => this.labelOf(id)) ?? [], selected: this.selected };
|
||||
}
|
||||
|
||||
private asset(): AutotileAsset | undefined {
|
||||
private asset(): TileAsset | undefined {
|
||||
const asset = this.state.project.assets.find((a) => a.id === this.assetId);
|
||||
return asset?.type === 'autotile' ? asset : undefined;
|
||||
return asset?.type === 'tile' ? asset : undefined;
|
||||
}
|
||||
|
||||
private labelOf(id: string): string {
|
||||
@@ -130,16 +130,16 @@ export class PictureStrip extends VStack {
|
||||
private removeSelected(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset || this.selected < 0) return;
|
||||
this.state.execute(new RemoveAutotilePictureCommand(asset.id, this.selected));
|
||||
this.state.execute(new RemoveTilePictureCommand(asset.id, this.selected));
|
||||
}
|
||||
|
||||
/** Every texture and every frame; an autotile block is one of them, whole or cut out. */
|
||||
/** Every texture and every frame; a tile's 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)),
|
||||
onSelect: () => this.state.execute(new AddTilePictureCommand(asset.id, choice.id)),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.addButton);
|
||||
@@ -154,7 +154,7 @@ export class PictureStrip extends VStack {
|
||||
{
|
||||
label: 'Remove',
|
||||
danger: true,
|
||||
onSelect: () => this.state.execute(new RemoveAutotilePictureCommand(asset.id, index)),
|
||||
onSelect: () => this.state.execute(new RemoveTilePictureCommand(asset.id, index)),
|
||||
},
|
||||
]);
|
||||
menu.open(x, y);
|
||||
@@ -167,7 +167,7 @@ export class PictureStrip extends VStack {
|
||||
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);
|
||||
if (this.state.execute(new MoveTilePictureCommand(asset.id, from, to))) this.select(to);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
@@ -6,23 +6,25 @@ import {
|
||||
Sprite,
|
||||
Text,
|
||||
} from 'pixi.js';
|
||||
import type { AutotileAsset } from '../../core/model/Asset';
|
||||
import type { TileAsset } 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 {
|
||||
pictureIndexAt,
|
||||
type QuadPos,
|
||||
quadrantRef,
|
||||
readTile,
|
||||
tileFps,
|
||||
type TileInfo,
|
||||
type TilePicture,
|
||||
tilePlayback,
|
||||
} from '../../core/model/tile';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
@@ -51,12 +53,12 @@ interface BoardTile {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Autotile tab's picture (#118): every tile the block can produce, captioned with the
|
||||
* The Tile 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 {
|
||||
export class TileCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly content = new Container();
|
||||
private readonly clip = new Graphics();
|
||||
@@ -72,7 +74,7 @@ export class AutotileCanvas extends Widget {
|
||||
private built = '';
|
||||
private shownPicture = -1;
|
||||
private board = { w: 0, h: 0 };
|
||||
private info: AutotileInfo | null = null;
|
||||
private info: TileInfo | null = null;
|
||||
private needsFit = true;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
@@ -114,7 +116,7 @@ export class AutotileCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Switching resets time and view: the next autotile is a new subject. */
|
||||
/** Switching resets time and view: the next tile is a new subject. */
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
@@ -197,13 +199,13 @@ export class AutotileCanvas extends Widget {
|
||||
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 asset(): TileAsset | undefined {
|
||||
return this.assetId ? findAssetOf(this.state.project, this.assetId, 'tile') : undefined;
|
||||
}
|
||||
|
||||
private fps(): number {
|
||||
const asset = this.asset();
|
||||
return asset ? autotileFps(asset) : 0;
|
||||
return asset ? tileFps(asset) : 0;
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
@@ -214,7 +216,7 @@ export class AutotileCanvas extends Widget {
|
||||
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.info = asset ? readTile(this.state.project, asset) : null;
|
||||
this.build(asset);
|
||||
this.paint(asset);
|
||||
this.syncHint(asset);
|
||||
@@ -228,7 +230,7 @@ export class AutotileCanvas extends Widget {
|
||||
}
|
||||
|
||||
/** Rebuilds the sprites when the board's shape changed – the kind, the tile size or the asset. */
|
||||
private build(asset: AutotileAsset | undefined): void {
|
||||
private build(asset: TileAsset | undefined): void {
|
||||
const info = this.info;
|
||||
const signature = asset && info && info.problem === '' ? `${asset.id}:${info.kind}:${info.tile}` : '';
|
||||
if (signature === this.built) return;
|
||||
@@ -245,7 +247,7 @@ export class AutotileCanvas extends Widget {
|
||||
}
|
||||
|
||||
/** The combination grid, then the sample map below it – all in image pixels of the tile. */
|
||||
private layoutBoard(info: AutotileInfo): void {
|
||||
private layoutBoard(info: TileInfo): void {
|
||||
const tile = info.tile;
|
||||
const step = tile * (1 + GAP);
|
||||
const row = tile * (1 + GAP + CAPTION);
|
||||
@@ -302,15 +304,10 @@ export class AutotileCanvas extends Widget {
|
||||
}
|
||||
|
||||
/** Puts the current picture's quarters into the sprites; a picture still loading leaves them blank. */
|
||||
private paint(asset: AutotileAsset | undefined): void {
|
||||
private paint(asset: TileAsset | 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 index = pictureIndexAt(info.pictures.length, tilePlayback(asset), tileFps(asset), this.time);
|
||||
const picture = info.pictures[index];
|
||||
if (!picture) return;
|
||||
const half = info.tile / 2;
|
||||
@@ -328,14 +325,12 @@ export class AutotileCanvas extends Widget {
|
||||
this.shownPicture = index;
|
||||
}
|
||||
|
||||
private quadrant(picture: AutotilePicture, info: AutotileInfo, position: QuadPos) {
|
||||
private quadrant(picture: TilePicture, info: TileInfo, 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 ?? '');
|
||||
private syncHint(asset: TileAsset | undefined): void {
|
||||
const text = !asset ? 'No tile 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);
|
||||
@@ -1,44 +1,38 @@
|
||||
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 type { Asset, TileAsset } from '../../core/model/Asset';
|
||||
import { autotileCombinations } from '../../core/model/autotile';
|
||||
import { readTile, TILE_KIND_KEY, TILE_KINDS, tileFps, tileKind } from '../../core/model/tile';
|
||||
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 { TileCanvas } from './TileCanvas';
|
||||
import { PictureStrip } from './PictureStrip';
|
||||
|
||||
/** Room kept for the "Floor · 48 px · 3 pictures · 47 tiles" readout. */
|
||||
/** Room kept for the "Floor (2 × 3) · 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
|
||||
* The Tile tab (#118, #131): the header picks the tile 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
|
||||
* produce plus a sample map composed from them – for a plain tile that is the one tile, repeated. 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> {
|
||||
export class TileEditor extends AssetTabEditor<TileAsset, TileCanvas> {
|
||||
/** Public for the check scripts. */
|
||||
readonly kindPicker: Dropdown;
|
||||
readonly strip: PictureStrip;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Autotile', infoWidth: INFO_WIDTH });
|
||||
super(state, { noun: 'Tile', 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]);
|
||||
this.mount(new TileCanvas(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 {
|
||||
protected override body(canvas: TileCanvas): Widget {
|
||||
const row = new HStack();
|
||||
row.add(canvas, 1);
|
||||
row.add(this.strip);
|
||||
@@ -50,31 +44,31 @@ export class AutotileEditor extends AssetTabEditor<AutotileAsset, AutotileCanvas
|
||||
this.canvas.tick(dt);
|
||||
}
|
||||
|
||||
/** Whether the board moves by itself – an animated autotile has no event to wake it. */
|
||||
/** Whether the board moves by itself – an animated tile has no event to wake it. */
|
||||
get isPlaying(): boolean {
|
||||
return this.canvas.animates;
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedAutotileId ?? '';
|
||||
return this.state.selectedTileId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is AutotileAsset {
|
||||
return asset.type === 'autotile';
|
||||
protected override isMine(asset: Asset): asset is TileAsset {
|
||||
return asset.type === 'tile';
|
||||
}
|
||||
|
||||
protected override infoText(asset: AutotileAsset): string {
|
||||
const info = readAutotile(this.state.project, asset);
|
||||
protected override infoText(asset: TileAsset): string {
|
||||
const info = readTile(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`;
|
||||
const fps = pictures > 1 ? ` · ${formatNumber(tileFps(asset), 2)} fps` : '';
|
||||
return `${kind} · ${info.tile} px · ${steps}${fps} · ${tiles} ${tiles === 1 ? 'tile' : 'tiles'}`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: AutotileAsset | null): void {
|
||||
protected override syncExtras(asset: TileAsset | null): void {
|
||||
this.strip.show(asset?.id ?? '');
|
||||
this.kindPicker.setText(asset ? kindLabel(this.state, asset) : '—');
|
||||
this.kindPicker.setEnabled(asset !== null);
|
||||
@@ -84,22 +78,22 @@ export class AutotileEditor extends AssetTabEditor<AutotileAsset, AutotileCanvas
|
||||
private openKindPicker(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const current = autotileKind(asset);
|
||||
const items: MenuItem[] = AUTOTILE_KINDS.map((kind) => ({
|
||||
const current = tileKind(asset);
|
||||
const items: MenuItem[] = TILE_KINDS.map((kind) => ({
|
||||
label: `${kind === current ? '● ' : ''}${optionLabel(this.state, asset, kind)}`,
|
||||
onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, AUTOTILE_KIND_KEY, kind)),
|
||||
onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, TILE_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);
|
||||
function optionLabel(state: EditorState, asset: TileAsset, kind: string): string {
|
||||
const def = propDef(state.project, asset, TILE_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));
|
||||
function kindLabel(state: EditorState, asset: TileAsset): string {
|
||||
return optionLabel(state, asset, tileKind(asset));
|
||||
}
|
||||
@@ -78,7 +78,7 @@ interface PaintStroke {
|
||||
assetId: string;
|
||||
layerId: string;
|
||||
tool: TilemapTool;
|
||||
autotileId: string;
|
||||
tileId: string;
|
||||
edits: CellEdit[];
|
||||
previous: Map<string, string>;
|
||||
from: Vec2;
|
||||
@@ -356,9 +356,9 @@ export class SceneViewport extends FreeViewport {
|
||||
e: FederatedPointerEvent,
|
||||
): boolean {
|
||||
const paint = this.state.tilemapPaint;
|
||||
const autotileId = paint.tool === 'erase' ? '' : paint.autotileId;
|
||||
if (paint.tool !== 'erase' && !autotileId) {
|
||||
this.state.setNotice('Choose an autotile to paint with.');
|
||||
const tileId = paint.tool === 'erase' ? '' : paint.tileId;
|
||||
if (paint.tool !== 'erase' && !tileId) {
|
||||
this.state.setNotice('Choose a tile to paint with.');
|
||||
return false;
|
||||
}
|
||||
const at = this.cellUnder(target.item, e);
|
||||
@@ -368,7 +368,7 @@ export class SceneViewport extends FreeViewport {
|
||||
assetId: target.asset.id,
|
||||
layerId: target.layerId,
|
||||
tool: paint.tool,
|
||||
autotileId,
|
||||
tileId,
|
||||
edits: [],
|
||||
previous: new Map(),
|
||||
from: at,
|
||||
@@ -396,9 +396,9 @@ export class SceneViewport extends FreeViewport {
|
||||
if (!inGrid(item.props, at.x, at.y)) continue;
|
||||
const key = cellKey(at.x, at.y);
|
||||
if (!stroke.previous.has(key)) stroke.previous.set(key, layer.cells[key] ?? '');
|
||||
if ((layer.cells[key] ?? '') === stroke.autotileId) continue;
|
||||
stroke.edits.push({ x: at.x, y: at.y, autotileId: stroke.autotileId });
|
||||
if (stroke.autotileId) layer.cells[key] = stroke.autotileId;
|
||||
if ((layer.cells[key] ?? '') === stroke.tileId) continue;
|
||||
stroke.edits.push({ x: at.x, y: at.y, tileId: stroke.tileId });
|
||||
if (stroke.tileId) layer.cells[key] = stroke.tileId;
|
||||
else delete layer.cells[key];
|
||||
changed = true;
|
||||
}
|
||||
@@ -421,7 +421,7 @@ export class SceneViewport extends FreeViewport {
|
||||
stroke.tool === 'rect' && item
|
||||
? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({
|
||||
...at,
|
||||
autotileId: stroke.autotileId,
|
||||
tileId: stroke.tileId,
|
||||
}))
|
||||
: stroke.edits;
|
||||
if (edits.length > 0)
|
||||
|
||||
@@ -334,7 +334,7 @@ export abstract class WorldViewport extends Widget {
|
||||
|
||||
/**
|
||||
* A painted map, drawn from its own meshes. It keeps redrawing the view while an animated
|
||||
* autotile is on it, the same way a Time shader does.
|
||||
* tile is on it, the same way a Time shader does.
|
||||
*/
|
||||
private syncTilemap(item: DrawItem, onScreen: boolean): void {
|
||||
let field = this.tilemaps.get(item.id);
|
||||
|
||||
+16
-22
@@ -5,13 +5,7 @@ import { resolvedHolder } from '../core/model/instance';
|
||||
import type { Mat } from '../core/model/matrix';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import type { ParticlesAsset } from '../core/model/Asset';
|
||||
import {
|
||||
type AutotileInfo,
|
||||
autotileFps,
|
||||
type AutotilePlayback,
|
||||
autotilePlayback,
|
||||
readAutotile,
|
||||
} from '../core/model/autotile';
|
||||
import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile';
|
||||
import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap';
|
||||
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import { fontFileOf } from '../core/model/font';
|
||||
@@ -55,14 +49,14 @@ interface DrawList {
|
||||
background: number;
|
||||
}
|
||||
|
||||
/** One autotile a tilemap paints with, as drawing needs it: the blocks and how they run. */
|
||||
export interface TilemapAutotile {
|
||||
info: AutotileInfo;
|
||||
playback: AutotilePlayback;
|
||||
/** One tile a tilemap paints with, as drawing needs it: the blocks and how they run. */
|
||||
export interface TilemapTile {
|
||||
info: TileInfo;
|
||||
playback: TilePlayback;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
/** One layer, already worked out: which tile each cell shows and which autotile draws it. */
|
||||
/** One layer, already worked out: which quarters each cell shows and which tile draws it. */
|
||||
export interface TilemapLayerDraw {
|
||||
id: string;
|
||||
tiles: TileDraw[];
|
||||
@@ -70,7 +64,7 @@ export interface TilemapLayerDraw {
|
||||
|
||||
export interface TilemapDraw {
|
||||
layers: TilemapLayerDraw[];
|
||||
autotiles: Map<string, TilemapAutotile>;
|
||||
tiles: Map<string, TilemapTile>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,23 +76,23 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag):
|
||||
const asset = findAssetOf(project, assetId, 'tilemap');
|
||||
if (!asset) return null;
|
||||
const layers: TilemapLayerDraw[] = [];
|
||||
const autotiles = new Map<string, TilemapAutotile>();
|
||||
const used = new Map<string, TilemapTile>();
|
||||
for (const layer of asset.layers) {
|
||||
if (!isLayerVisible(layer)) continue;
|
||||
const tiles = layerTiles(project, props, layer);
|
||||
layers.push({ id: layer.id, tiles });
|
||||
for (const tile of tiles) {
|
||||
if (autotiles.has(tile.autotileId)) continue;
|
||||
const autotile = findAssetOf(project, tile.autotileId, 'autotile');
|
||||
if (!autotile) continue;
|
||||
autotiles.set(tile.autotileId, {
|
||||
info: readAutotile(project, autotile),
|
||||
playback: autotilePlayback(autotile),
|
||||
fps: autotileFps(autotile),
|
||||
if (used.has(tile.tileId)) continue;
|
||||
const source = findAssetOf(project, tile.tileId, 'tile');
|
||||
if (!source) continue;
|
||||
used.set(tile.tileId, {
|
||||
info: readTile(project, source),
|
||||
playback: tilePlayback(source),
|
||||
fps: tileFps(source),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { layers, autotiles };
|
||||
return { layers, tiles: used };
|
||||
}
|
||||
|
||||
/** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source
|
||||
|
||||
+20
-25
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* The drawn side of a placed tilemap (#128): one mesh per autotile and layer, so a whole map is a
|
||||
* The drawn side of a placed tilemap (#128): one mesh per tile and layer, so a whole map is a
|
||||
* handful of draw calls instead of one sprite per cell. The geometry itself is `tilemapMesh.ts`;
|
||||
* what happens here is Pixi – which texture a mesh wears, which animation step it shows, and
|
||||
* throwing away the meshes of a layer that is gone.
|
||||
*/
|
||||
import { Container, Mesh, MeshGeometry, type Texture } from 'pixi.js';
|
||||
import { type AutotilePicture, pictureIndexAt } from '../core/model/autotile';
|
||||
import { pictureIndexAt, type TilePicture } from '../core/model/tile';
|
||||
import { cellOrigin, tilemapTileSize } from '../core/model/tilemap';
|
||||
import type { DrawItem, TilemapAutotile } from './items';
|
||||
import type { DrawItem, TilemapTile } from './items';
|
||||
import { placeNode } from './symbols';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
import { type TileSheet, tileMesh, tilesByAutotile } from './tilemapMesh';
|
||||
import { groupByTile, type TileSheet, tileMesh } from './tilemapMesh';
|
||||
|
||||
export class TilemapField {
|
||||
readonly container = new Container();
|
||||
private readonly meshes = new Map<string, Mesh>();
|
||||
/** True after a sync that drew an autotile with more than one picture. */
|
||||
/** True after a sync that drew a tile with more than one picture. */
|
||||
animated = false;
|
||||
/** How many meshes the last sync drew – one draw call each (test hook). */
|
||||
drawCalls = 0;
|
||||
@@ -25,7 +25,7 @@ export class TilemapField {
|
||||
}
|
||||
|
||||
/**
|
||||
* `now` is the global clock, so every tilemap showing the same autotile is on the same step –
|
||||
* `now` is the global clock, so every tilemap showing the same tile is on the same step –
|
||||
* animated water does not run out of phase between two maps.
|
||||
*/
|
||||
sync(item: DrawItem, textures: TextureLibrary, now: number): void {
|
||||
@@ -39,20 +39,15 @@ export class TilemapField {
|
||||
const drawn: Mesh[] = [];
|
||||
const alive = new Set<string>();
|
||||
for (const layer of draw?.layers ?? []) {
|
||||
for (const [autotileId, tiles] of tilesByAutotile(layer.tiles)) {
|
||||
const autotile = draw?.autotiles.get(autotileId);
|
||||
const picture = autotile && pictureNow(autotile, now);
|
||||
for (const [tileId, tiles] of groupByTile(layer.tiles)) {
|
||||
const source = draw?.tiles.get(tileId);
|
||||
const picture = source && pictureNow(source, now);
|
||||
const texture = picture ? textures.get(picture.ref) : null;
|
||||
if (!autotile || !picture || !texture) continue;
|
||||
if (autotile.info.pictures.length > 1) this.animated = true;
|
||||
const key = `${layer.id}|${autotileId}`;
|
||||
if (!source || !picture || !texture) continue;
|
||||
if (source.info.pictures.length > 1) this.animated = true;
|
||||
const key = `${layer.id}|${tileId}`;
|
||||
alive.add(key);
|
||||
const { positions, uvs, indices } = tileMesh(
|
||||
tiles,
|
||||
tile,
|
||||
origin,
|
||||
sheetOf(autotile, picture, texture),
|
||||
);
|
||||
const { positions, uvs, indices } = tileMesh(tiles, tile, origin, sheetOf(source, picture, texture));
|
||||
drawn.push(this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture));
|
||||
}
|
||||
}
|
||||
@@ -62,7 +57,7 @@ export class TilemapField {
|
||||
this.meshes.delete(key);
|
||||
}
|
||||
// Re-attached in order rather than sorted lazily: the layer order is the drawing order, and
|
||||
// the list is at most one entry per autotile and layer.
|
||||
// the list is at most one entry per tile and layer.
|
||||
this.container.removeChildren();
|
||||
for (const mesh of drawn) this.container.addChild(mesh);
|
||||
this.drawCalls = drawn.length;
|
||||
@@ -89,22 +84,22 @@ export class TilemapField {
|
||||
}
|
||||
|
||||
/**
|
||||
* The block this autotile shows right now. A quarter is cut from it by uvs rather than by the
|
||||
* The block this tile shows right now. A quarter is cut from it by uvs rather than by the
|
||||
* texture library, so what is wanted here is the picture of the step and nothing finer.
|
||||
*/
|
||||
function pictureNow(autotile: TilemapAutotile, now: number): AutotilePicture | undefined {
|
||||
const { pictures, problem } = autotile.info;
|
||||
function pictureNow(source: TilemapTile, now: number): TilePicture | undefined {
|
||||
const { pictures, problem } = source.info;
|
||||
if (problem) return undefined;
|
||||
return pictures[pictureIndexAt(pictures.length, autotile.playback, autotile.fps, now)];
|
||||
return pictures[pictureIndexAt(pictures.length, source.playback, source.fps, now)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The library hands out the block as a texture of its own, so the quadrants count from its corner
|
||||
* whether the picture was a whole sheet or one frame cut from one.
|
||||
*/
|
||||
function sheetOf(autotile: TilemapAutotile, picture: AutotilePicture, texture: Texture): TileSheet {
|
||||
function sheetOf(source: TilemapTile, picture: TilePicture, texture: Texture): TileSheet {
|
||||
return {
|
||||
quad: autotile.info.quad,
|
||||
quad: source.info.quad,
|
||||
block: { x: 0, y: 0, w: texture.width, h: texture.height },
|
||||
textureWidth: texture.width,
|
||||
textureHeight: texture.height,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The geometry a painted layer becomes: one mesh per autotile instead of one sprite per cell, so a
|
||||
* map of a thousand tiles costs as many draw calls as it has autotiles on that layer (#128). Kept a
|
||||
* The geometry a painted layer becomes: one mesh per tile instead of one sprite per cell, so a
|
||||
* map of a thousand cells costs as many draw calls as it has tiles on that layer (#128). Kept a
|
||||
* plain function of numbers – no Pixi, no project – so the quarter that lands in a corner can be
|
||||
* checked without a browser.
|
||||
*/
|
||||
@@ -86,13 +86,13 @@ export function tileMesh(
|
||||
return { positions, uvs, indices };
|
||||
}
|
||||
|
||||
/** The cells of one layer grouped by autotile – one group is one mesh, because it is one texture. */
|
||||
export function tilesByAutotile(tiles: readonly TileDraw[]): Map<string, TileDraw[]> {
|
||||
/** The cells of one layer grouped by tile – one group is one mesh, because it is one texture. */
|
||||
export function groupByTile(tiles: readonly TileDraw[]): Map<string, TileDraw[]> {
|
||||
const groups = new Map<string, TileDraw[]>();
|
||||
for (const drawn of tiles) {
|
||||
const group = groups.get(drawn.autotileId);
|
||||
const group = groups.get(drawn.tileId);
|
||||
if (group) group.push(drawn);
|
||||
else groups.set(drawn.autotileId, [drawn]);
|
||||
else groups.set(drawn.tileId, [drawn]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -5,50 +5,58 @@ import {
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
AddAutotilePictureCommand,
|
||||
MoveAutotilePictureCommand,
|
||||
RemoveAutotilePictureCommand,
|
||||
} from '../../src/core/commands/autotileCommands';
|
||||
AddTilePictureCommand,
|
||||
MoveTilePictureCommand,
|
||||
RemoveTilePictureCommand,
|
||||
} from '../../src/core/commands/tileCommands';
|
||||
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
|
||||
import type { AutotileAsset } from '../../src/core/model/Asset';
|
||||
import type { TileAsset } 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 {
|
||||
pictureIndexAt,
|
||||
quadrantRect,
|
||||
readTile,
|
||||
TILE_KIND_KEY,
|
||||
type TileKind,
|
||||
tileKind,
|
||||
tilePlayback,
|
||||
} from '../../src/core/model/tile';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||||
import { autotilesUsing } from '../../src/core/model/usages';
|
||||
import { tilesUsing } 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) {
|
||||
/**
|
||||
* A sheet the size of the RPG-Maker sheets #118 was written from: 48 px tiles, 8 × 4 blocks. The
|
||||
* tile is switched to a floor block, which is what most of these tests are about.
|
||||
*/
|
||||
function makeTile(width = 768, height = 576, kind: TileKind = 'floor') {
|
||||
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);
|
||||
const tile = new CreateAssetCommand('tile', 'Grass');
|
||||
fixture.stack.execute(tile);
|
||||
if (kind !== 'none') fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
|
||||
return {
|
||||
...fixture,
|
||||
texture: texture.asset,
|
||||
autotileId: autotile.asset.id,
|
||||
assetOf: () => findAsset(fixture.project, autotile.asset.id) as AutotileAsset,
|
||||
tileId: tile.asset.id,
|
||||
assetOf: () => findAsset(fixture.project, tile.asset.id) as TileAsset,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cuts one block out of the sheet and returns the frame id. */
|
||||
function cutBlock(
|
||||
fixture: ReturnType<typeof makeAutotile>,
|
||||
fixture: ReturnType<typeof makeTile>,
|
||||
rect: { x: number; y: number; w: number; h: number },
|
||||
): string {
|
||||
const command = new AddFrameCommand(fixture.texture.id, rect);
|
||||
@@ -142,7 +150,7 @@ function around(present: Partial<Neighbours>): Neighbours {
|
||||
|
||||
describe('autotile blocks', () => {
|
||||
it('is a library asset like a texture: nothing places it, the tilemap of #128 does', () => {
|
||||
expect(isPlaceableType('autotile')).toBe(false);
|
||||
expect(isPlaceableType('tile')).toBe(false);
|
||||
});
|
||||
|
||||
it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => {
|
||||
@@ -243,11 +251,11 @@ describe('autotile blocks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('autotile asset', () => {
|
||||
describe('tile 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());
|
||||
const fixture = makeTile(96, 144);
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id));
|
||||
const info = readTile(fixture.project, fixture.assetOf());
|
||||
expect(info.problem).toBe('');
|
||||
expect(info.tile).toBe(48);
|
||||
expect(info.quad).toBe(24);
|
||||
@@ -255,10 +263,10 @@ describe('autotile asset', () => {
|
||||
});
|
||||
|
||||
it('cuts a quarter tile out of the frame the picture names', () => {
|
||||
const fixture = makeAutotile();
|
||||
const fixture = makeTile();
|
||||
const frame = cutBlock(fixture, FLOOR_BLOCK);
|
||||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
|
||||
const info = readAutotile(fixture.project, fixture.assetOf());
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame));
|
||||
const info = readTile(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({
|
||||
@@ -270,32 +278,32 @@ describe('autotile asset', () => {
|
||||
});
|
||||
|
||||
it('complains about a block that does not fit the kind, without dropping it', () => {
|
||||
const fixture = makeAutotile();
|
||||
const fixture = makeTile();
|
||||
const wall = cutBlock(fixture, WALL_BLOCK);
|
||||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, wall));
|
||||
const asFloor = readAutotile(fixture.project, fixture.assetOf());
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, wall));
|
||||
const asFloor = readTile(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());
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall'));
|
||||
const asWall = readTile(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 fixture = makeTile();
|
||||
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');
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, big));
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, small));
|
||||
expect(readTile(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');
|
||||
expect(readTile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist');
|
||||
fixture.assetOf().pictures = [];
|
||||
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('No picture yet');
|
||||
expect(readTile(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', () => {
|
||||
@@ -310,84 +318,160 @@ describe('autotile asset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('autotile pictures', () => {
|
||||
describe('tile pictures', () => {
|
||||
it('adds, removes and reorders with undo', () => {
|
||||
const fixture = makeAutotile();
|
||||
const fixture = makeTile();
|
||||
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));
|
||||
for (const id of [a, b, c]) fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, id));
|
||||
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
|
||||
|
||||
fixture.stack.execute(new MoveAutotilePictureCommand(fixture.autotileId, 2, 0));
|
||||
fixture.stack.execute(new MoveTilePictureCommand(fixture.tileId, 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));
|
||||
fixture.stack.execute(new RemoveTilePictureCommand(fixture.tileId, 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));
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, 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 fixture = makeTile();
|
||||
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(
|
||||
expect(() => new AddTilePictureCommand(fixture.tileId, sprite.asset.id).execute(fixture.project)).toThrow(
|
||||
/not a texture or a frame/,
|
||||
);
|
||||
expect(() => new RemoveTilePictureCommand(fixture.tileId, 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(
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id));
|
||||
expect(() => new MoveTilePictureCommand(fixture.tileId, 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 fixture = makeTile();
|
||||
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([]);
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame));
|
||||
expect(tilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']);
|
||||
expect(tilesUsing(fixture.project, ['somewhere else'])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autotile in the document', () => {
|
||||
describe('tile in the document', () => {
|
||||
it('survives a save and keeps only what it can read', () => {
|
||||
const fixture = makeAutotile();
|
||||
const fixture = makeTile();
|
||||
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));
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, frame));
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(fixture.tileId, TILE_KIND_KEY, 'wall'));
|
||||
const asset = tileOf(roundTrip(fixture.project));
|
||||
expect(asset.pictures).toEqual([frame]);
|
||||
expect(autotileKind(asset)).toBe('wall');
|
||||
expect(tileKind(asset)).toBe('wall');
|
||||
|
||||
// Junk in the list is dropped, and an unreadable kind or playback falls back to the default.
|
||||
// Junk in the list is dropped, and an unreadable kind or playback falls back to the default –
|
||||
// for the kind that is the plain tile.
|
||||
const raw = JSON.parse(JSON.stringify(fixture.project)) as {
|
||||
assets: Record<string, unknown>[];
|
||||
};
|
||||
const stored = raw.assets.find((a) => a.type === 'autotile')!;
|
||||
const stored = raw.assets.find((a) => a.type === 'tile')!;
|
||||
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));
|
||||
const read = tileOf(roundTrip(raw as unknown as Project));
|
||||
expect(read.pictures).toEqual([frame, 'gone']);
|
||||
expect(autotileKind(read)).toBe('floor');
|
||||
expect(autotilePlayback(read)).toBe('loop');
|
||||
expect(tileKind(read)).toBe('none');
|
||||
expect(tilePlayback(read)).toBe('loop');
|
||||
});
|
||||
|
||||
/**
|
||||
* #131 renamed the asset and made a plain tile the default. A project saved before that holds
|
||||
* `autotile` assets, and one of them without a stored kind meant a floor block: it has to open as
|
||||
* the same floor, not turn into a plain tile.
|
||||
*/
|
||||
it('reads an autotile of an older project as the tile it was', () => {
|
||||
const raw = {
|
||||
id: 'p',
|
||||
assets: [
|
||||
{ id: 's', type: 'scene', name: 'Main', props: {}, objects: [] },
|
||||
{ id: 'a', type: 'autotile', name: 'Old grass', props: {}, pictures: ['x'] },
|
||||
{ id: 'b', type: 'autotile', name: 'Old wall', props: { kind: 'wall', fps: 2 }, pictures: [] },
|
||||
{ id: 'c', type: 'tile', name: 'New', props: {}, pictures: [] },
|
||||
],
|
||||
};
|
||||
const project = deserializeProject(raw);
|
||||
const byId = (id: string) => project.assets.find((a): a is TileAsset => a.id === id && a.type === 'tile');
|
||||
expect(byId('a')?.pictures).toEqual(['x']);
|
||||
expect(tileKind(byId('a')!)).toBe('floor');
|
||||
expect(tileKind(byId('b')!)).toBe('wall');
|
||||
expect(byId('b')?.props.fps).toBe(2);
|
||||
expect(tileKind(byId('c')!)).toBe('none');
|
||||
expect(project.assets.some((a) => (a.type as string) === 'autotile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
describe('plain tile', () => {
|
||||
it('is what a new tile is', () => {
|
||||
const fixture = makeTile(96, 96, 'none');
|
||||
expect(tileKind(fixture.assetOf())).toBe('none');
|
||||
});
|
||||
|
||||
it('is one tile that ignores its neighbours', () => {
|
||||
expect(autotileCombinations('none')).toHaveLength(1);
|
||||
const lone = tileQuadrants('none', around({}));
|
||||
const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true };
|
||||
// Every corner takes its own quarter of the picture – the picture drawn as it is.
|
||||
expect(lone).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
expect(tileQuadrants('none', all)).toEqual(lone);
|
||||
expect(tileQuadrants('none', around({ n: true, w: true }))).toEqual(lone);
|
||||
});
|
||||
|
||||
it('takes any square picture as the tile, an odd side too', () => {
|
||||
const fixture = makeTile(768, 576, 'none');
|
||||
const square = cutBlock(fixture, { x: 48, y: 0, w: 48, h: 48 });
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, square));
|
||||
const info = readTile(fixture.project, fixture.assetOf());
|
||||
expect(info.problem).toBe('');
|
||||
expect(info.tile).toBe(48);
|
||||
expect(quadrantRect(info.pictures[0]!, info.quad, { x: 1, y: 1 })).toEqual({
|
||||
x: 72,
|
||||
y: 24,
|
||||
w: 24,
|
||||
h: 24,
|
||||
});
|
||||
|
||||
const odd = makeTile(25, 25, 'none');
|
||||
odd.stack.execute(new AddTilePictureCommand(odd.tileId, odd.texture.id));
|
||||
expect(readTile(odd.project, odd.assetOf()).tile).toBe(25);
|
||||
});
|
||||
|
||||
it('complains about a picture that is not square', () => {
|
||||
const fixture = makeTile(96, 144, 'none');
|
||||
fixture.stack.execute(new AddTilePictureCommand(fixture.tileId, fixture.texture.id));
|
||||
const info = readTile(fixture.project, fixture.assetOf());
|
||||
expect(info.problem).toContain('one square tile');
|
||||
expect(info.problem).toContain('96 × 144');
|
||||
expect(info.tile).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
function tileOf(project: Project): TileAsset {
|
||||
const asset = project.assets.find((a): a is TileAsset => a.type === 'tile');
|
||||
if (!asset) throw new Error('no tile in the project');
|
||||
return asset;
|
||||
}
|
||||
+67
-49
@@ -4,7 +4,7 @@ import {
|
||||
CreateTextureCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { AddAutotilePictureCommand } from '../../src/core/commands/autotileCommands';
|
||||
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
MoveTilemapLayerCommand,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||||
import { AUTOTILE_KIND_KEY } from '../../src/core/model/autotile';
|
||||
import { TILE_KIND_KEY, type TileKind } from '../../src/core/model/tile';
|
||||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||||
import {
|
||||
cellAt,
|
||||
@@ -30,17 +30,17 @@ import {
|
||||
TILEMAP_COLUMNS_KEY,
|
||||
TILEMAP_ROWS_KEY,
|
||||
TILEMAP_TILE_KEY,
|
||||
tilemapAutotileIds,
|
||||
tilemapTileIds,
|
||||
tilemapSize,
|
||||
} from '../../src/core/model/tilemap';
|
||||
import { tilemapsUsing } from '../../src/core/model/usages';
|
||||
import { tileMesh, tilesByAutotile } from '../../src/viewport/tilemapMesh';
|
||||
import { groupByTile, tileMesh } from '../../src/viewport/tilemapMesh';
|
||||
import { makeProject, roundTrip } from '../helpers';
|
||||
|
||||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||||
|
||||
/** A tilemap with a floor autotile to paint with, both sized like the RPG-Maker sheets. */
|
||||
function makeTilemap(kind: 'floor' | 'wall' = 'floor') {
|
||||
function makeTilemap(kind: TileKind = 'floor') {
|
||||
const fixture = makeProject();
|
||||
const texture = new CreateTextureCommand('Outside', {
|
||||
file: FILE,
|
||||
@@ -48,25 +48,25 @@ function makeTilemap(kind: 'floor' | 'wall' = 'floor') {
|
||||
height: kind === 'floor' ? 144 : 96,
|
||||
});
|
||||
fixture.stack.execute(texture);
|
||||
const autotile = new CreateAssetCommand('autotile', 'Grass');
|
||||
fixture.stack.execute(autotile);
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(autotile.asset.id, AUTOTILE_KIND_KEY, kind));
|
||||
fixture.stack.execute(new AddAutotilePictureCommand(autotile.asset.id, texture.asset.id));
|
||||
const tile = new CreateAssetCommand('tile', 'Grass');
|
||||
fixture.stack.execute(tile);
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_KIND_KEY, kind));
|
||||
fixture.stack.execute(new AddTilePictureCommand(tile.asset.id, texture.asset.id));
|
||||
const tilemap = new CreateAssetCommand('tilemap', 'Ground');
|
||||
fixture.stack.execute(tilemap);
|
||||
const assetOf = () => findAsset(fixture.project, tilemap.asset.id) as TilemapAsset;
|
||||
return {
|
||||
...fixture,
|
||||
autotileId: autotile.asset.id,
|
||||
tileId: tile.asset.id,
|
||||
tilemapId: tilemap.asset.id,
|
||||
assetOf,
|
||||
layerId: () => assetOf().layers[0]!.id,
|
||||
paint: (cells: { x: number; y: number }[], autotileId = autotile.asset.id) =>
|
||||
paint: (cells: { x: number; y: number }[], tileId = tile.asset.id) =>
|
||||
fixture.stack.execute(
|
||||
new PaintTilesCommand(
|
||||
tilemap.asset.id,
|
||||
assetOf().layers[0]!.id,
|
||||
cells.map((at) => ({ ...at, autotileId })),
|
||||
cells.map((at) => ({ ...at, tileId })),
|
||||
),
|
||||
),
|
||||
};
|
||||
@@ -78,9 +78,9 @@ function cellsOf(project: Project, assetId: string): Record<string, string> {
|
||||
}
|
||||
|
||||
describe('the tilemap asset', () => {
|
||||
it('is placed, unlike the autotiles it draws with', () => {
|
||||
it('is placed, unlike the tiles it draws with', () => {
|
||||
expect(isPlaceableType('tilemap')).toBe(true);
|
||||
expect(isPlaceableType('autotile')).toBe(false);
|
||||
expect(isPlaceableType('tile')).toBe(false);
|
||||
});
|
||||
|
||||
it('starts with one layer and no cells', () => {
|
||||
@@ -119,33 +119,33 @@ describe('painting a tilemap', () => {
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
]);
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId });
|
||||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: '' }]));
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.autotileId });
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
|
||||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: '' }]));
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '2,1': f.tileId });
|
||||
f.stack.undo();
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.autotileId, '2,1': f.autotileId });
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({ '1,1': f.tileId, '2,1': f.tileId });
|
||||
f.stack.undo();
|
||||
expect(cellsOf(f.project, f.tilemapId)).toEqual({});
|
||||
});
|
||||
|
||||
it('puts back what a stroke painted over, not merely empty', () => {
|
||||
const f = makeTilemap();
|
||||
const other = new CreateAssetCommand('autotile', 'Sand');
|
||||
const other = new CreateAssetCommand('tile', 'Sand');
|
||||
f.stack.execute(other);
|
||||
f.paint([{ x: 3, y: 3 }]);
|
||||
f.paint([{ x: 3, y: 3 }], other.asset.id);
|
||||
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(other.asset.id);
|
||||
f.stack.undo();
|
||||
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.autotileId);
|
||||
expect(cellAt(f.assetOf().layers[0]!, 3, 3)).toBe(f.tileId);
|
||||
});
|
||||
|
||||
it('knows when a stroke would change nothing', () => {
|
||||
const f = makeTilemap();
|
||||
f.paint([{ x: 0, y: 0 }]);
|
||||
const layer = f.assetOf().layers[0]!;
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: f.autotileId }])).toBe(false);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, autotileId: '' }])).toBe(true);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, autotileId: f.autotileId }])).toBe(true);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: f.tileId }])).toBe(false);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 0, y: 0, tileId: '' }])).toBe(true);
|
||||
expect(PaintTilesCommand.changes(layer, [{ x: 5, y: 5, tileId: f.tileId }])).toBe(true);
|
||||
});
|
||||
|
||||
it('covers the cells between two pointer events', () => {
|
||||
@@ -175,9 +175,9 @@ describe('painting a tilemap', () => {
|
||||
});
|
||||
|
||||
describe('what a painted cell shows', () => {
|
||||
it('joins only cells holding the same autotile', () => {
|
||||
it('joins only cells holding the same tile', () => {
|
||||
const f = makeTilemap();
|
||||
const other = new CreateAssetCommand('autotile', 'Sand');
|
||||
const other = new CreateAssetCommand('tile', 'Sand');
|
||||
f.stack.execute(other);
|
||||
f.paint([
|
||||
{ x: 5, y: 5 },
|
||||
@@ -186,9 +186,9 @@ describe('what a painted cell shows', () => {
|
||||
f.paint([{ x: 5, y: 4 }], other.asset.id);
|
||||
const layer = f.assetOf().layers[0]!;
|
||||
const props = f.assetOf().props;
|
||||
const around = neighboursOf(props, layer, 5, 5, f.autotileId);
|
||||
const around = neighboursOf(props, layer, 5, 5, f.tileId);
|
||||
expect(around.e).toBe(true);
|
||||
// The cell above holds a *different* autotile, so this one closes against it.
|
||||
// The cell above holds a *different* tile, so this one closes against it.
|
||||
expect(around.n).toBe(false);
|
||||
expect(around.w).toBe(false);
|
||||
});
|
||||
@@ -204,7 +204,7 @@ describe('what a painted cell shows', () => {
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
const asset = f.assetOf();
|
||||
const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.autotileId);
|
||||
const around = neighboursOf(asset.props, asset.layers[0]!, 1, 1, f.tileId);
|
||||
expect(around.n).toBe(true);
|
||||
expect(around.w).toBe(true);
|
||||
expect(around.e).toBe(false);
|
||||
@@ -243,6 +243,26 @@ describe('what a painted cell shows', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws a plain tile the same in every cell, whatever lies around it', () => {
|
||||
const f = makeTilemap('none');
|
||||
f.paint([
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 5, y: 5 },
|
||||
]);
|
||||
const asset = f.assetOf();
|
||||
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
|
||||
expect(tiles).toHaveLength(4);
|
||||
for (const tile of tiles)
|
||||
expect(tile.quadrants).toEqual([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves out cells outside the grid and cells naming nothing', () => {
|
||||
const f = makeTilemap();
|
||||
f.stack.execute(new SetAssetPropertyCommand(f.tilemapId, TILEMAP_COLUMNS_KEY, 2));
|
||||
@@ -251,11 +271,11 @@ describe('what a painted cell shows', () => {
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 5, y: 0 },
|
||||
]);
|
||||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, autotileId: 'gone' }]));
|
||||
f.stack.execute(new PaintTilesCommand(f.tilemapId, f.layerId(), [{ x: 1, y: 1, tileId: 'gone' }]));
|
||||
const asset = f.assetOf();
|
||||
const tiles = layerTiles(f.project, asset.props, asset.layers[0]!);
|
||||
expect(tiles.map((t) => `${t.x},${t.y}`)).toEqual(['0,0']);
|
||||
// The cell naming a deleted autotile is kept in the model – an undo may bring it back.
|
||||
// The cell naming a deleted tile is kept in the model – an undo may bring it back.
|
||||
expect(asset.layers[0]!.cells[cellKey(1, 1)]).toBe('gone');
|
||||
});
|
||||
|
||||
@@ -310,13 +330,11 @@ describe('the layers of a tilemap', () => {
|
||||
const f = makeTilemap();
|
||||
const add = new AddTilemapLayerCommand(f.tilemapId);
|
||||
f.stack.execute(add);
|
||||
f.stack.execute(
|
||||
new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, autotileId: f.autotileId }]),
|
||||
);
|
||||
f.stack.execute(new PaintTilesCommand(f.tilemapId, add.layerId, [{ x: 2, y: 2, tileId: f.tileId }]));
|
||||
f.stack.execute(new RemoveTilemapLayerCommand(f.tilemapId, add.layerId));
|
||||
expect(f.assetOf().layers).toHaveLength(1);
|
||||
f.stack.undo();
|
||||
expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.autotileId });
|
||||
expect(f.assetOf().layers[1]!.cells).toEqual({ '2,2': f.tileId });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -330,37 +348,37 @@ describe('a tilemap in the document', () => {
|
||||
const back = roundTrip(f.project);
|
||||
const asset = findAsset(back, f.tilemapId) as TilemapAsset;
|
||||
expect(asset.layers).toHaveLength(2);
|
||||
expect(asset.layers[0]!.cells).toEqual({ '1,2': f.autotileId });
|
||||
expect(asset.layers[0]!.cells).toEqual({ '1,2': f.tileId });
|
||||
expect(asset.layers[1]!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('drops cells that are not a pair of whole numbers, and doubled layers', () => {
|
||||
const f = makeTilemap();
|
||||
const asset = f.assetOf();
|
||||
asset.layers[0]!.cells['nonsense'] = f.autotileId;
|
||||
asset.layers[0]!.cells['1.5,2'] = f.autotileId;
|
||||
asset.layers[0]!.cells['3,3'] = f.autotileId;
|
||||
asset.layers[0]!.cells['nonsense'] = f.tileId;
|
||||
asset.layers[0]!.cells['1.5,2'] = f.tileId;
|
||||
asset.layers[0]!.cells['3,3'] = f.tileId;
|
||||
asset.layers.push({ ...asset.layers[0]!, name: 'Twin' });
|
||||
const back = findAsset(roundTrip(f.project), f.tilemapId) as TilemapAsset;
|
||||
expect(back.layers).toHaveLength(1);
|
||||
expect(back.layers[0]!.cells).toEqual({ '3,3': f.autotileId });
|
||||
expect(back.layers[0]!.cells).toEqual({ '3,3': f.tileId });
|
||||
});
|
||||
|
||||
it('counts as a use of the autotiles painted on it', () => {
|
||||
it('counts as a use of the tiles painted on it', () => {
|
||||
const f = makeTilemap();
|
||||
expect(tilemapsUsing(f.project, [f.autotileId])).toHaveLength(0);
|
||||
expect(tilemapsUsing(f.project, [f.tileId])).toHaveLength(0);
|
||||
f.paint([{ x: 0, y: 0 }]);
|
||||
expect(tilemapsUsing(f.project, [f.autotileId]).map((a) => a.id)).toEqual([f.tilemapId]);
|
||||
expect(tilemapAutotileIds(f.assetOf())).toEqual([f.autotileId]);
|
||||
expect(tilemapsUsing(f.project, [f.tileId]).map((a) => a.id)).toEqual([f.tilemapId]);
|
||||
expect(tilemapTileIds(f.assetOf())).toEqual([f.tileId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the mesh a layer becomes', () => {
|
||||
const sheet = { quad: 24, block: { x: 0, y: 0, w: 96, h: 144 }, textureWidth: 96, textureHeight: 144 };
|
||||
|
||||
it('is one mesh per autotile, not one per cell', () => {
|
||||
it('is one mesh per tile, not one per cell', () => {
|
||||
const f = makeTilemap();
|
||||
const other = new CreateAssetCommand('autotile', 'Sand');
|
||||
const other = new CreateAssetCommand('tile', 'Sand');
|
||||
f.stack.execute(other);
|
||||
f.paint([
|
||||
{ x: 0, y: 0 },
|
||||
@@ -369,9 +387,9 @@ describe('the mesh a layer becomes', () => {
|
||||
]);
|
||||
f.paint([{ x: 4, y: 0 }], other.asset.id);
|
||||
const asset = f.assetOf();
|
||||
const groups = tilesByAutotile(layerTiles(f.project, asset.props, asset.layers[0]!));
|
||||
const groups = groupByTile(layerTiles(f.project, asset.props, asset.layers[0]!));
|
||||
expect(groups.size).toBe(2);
|
||||
expect(groups.get(f.autotileId)).toHaveLength(3);
|
||||
expect(groups.get(f.tileId)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('builds four quads per cell, at the cell it belongs to', () => {
|
||||
@@ -379,7 +397,7 @@ describe('the mesh a layer becomes', () => {
|
||||
{
|
||||
x: 1,
|
||||
y: 0,
|
||||
autotileId: 'a',
|
||||
tileId: 'a',
|
||||
quadrants: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
@@ -409,7 +427,7 @@ describe('the mesh a layer becomes', () => {
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
autotileId: 'a',
|
||||
tileId: 'a',
|
||||
quadrants: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dropIndex } from '../../src/editor/autotile/PictureStrip';
|
||||
import { dropIndex } from '../../src/editor/tile/PictureStrip';
|
||||
|
||||
/**
|
||||
* Reordering the pictures of an autotile by dragging a row (#118). The index the move command
|
||||
* Reordering the pictures of a tile 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', () => {
|
||||
@@ -163,7 +163,7 @@ export async function assets(t) {
|
||||
{ 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: [] },
|
||||
{ id: 'at-open', type: 'tile', name: 'Grass', props: {}, pictures: [] },
|
||||
)`);
|
||||
const openable = [
|
||||
['mover', 'graph', '__gm.graph.current()'],
|
||||
@@ -172,7 +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()'],
|
||||
['at-open', 'tile', '__gm.tile.current()'],
|
||||
];
|
||||
for (const [id, tab, shown] of openable) {
|
||||
await showTab('scene');
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
* Tiles (#118, #131). 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
|
||||
@@ -51,14 +51,14 @@ export function quadColour(kind, qx, qy, step = 0) {
|
||||
* one at a time, a running animation can step between them, and that is a flake, not a finding.
|
||||
*/
|
||||
async function tileReads(t, index, expected, kind) {
|
||||
const tile = await t.json(`__gm.autotile.tile(${index})`);
|
||||
const tile = await t.json(`__gm.tile.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 points = tile.quadrants.map((q) => [Math.round(q.at.x), Math.round(q.at.y)]);
|
||||
const shot = await t.json(
|
||||
`(() => ({ step: __gm.autotile.board().picture, read: ${JSON.stringify(points)}` +
|
||||
`(() => ({ step: __gm.tile.board().picture, read: ${JSON.stringify(points)}` +
|
||||
`.map(([x, y]) => __gm.pixelAt(x, y)) }))()`,
|
||||
);
|
||||
const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, shot.step));
|
||||
@@ -66,13 +66,13 @@ async function tileReads(t, index, expected, kind) {
|
||||
return { ok: off.length === 0, step: shot.step, detail: JSON.stringify({ read: shot.read, want }) };
|
||||
}
|
||||
|
||||
export async function autotile(t) {
|
||||
export async function tile(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.
|
||||
// 39z. The sheet, the blocks cut out of it and the tile 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);
|
||||
@@ -82,7 +82,7 @@ export async function autotile(t) {
|
||||
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
|
||||
);
|
||||
check(
|
||||
'the autotile sheet is imported at its own size',
|
||||
'the tile sheet is imported at its own size',
|
||||
sheet?.w === 192 && sheet?.h === 240,
|
||||
JSON.stringify(sheet),
|
||||
);
|
||||
@@ -94,22 +94,23 @@ export async function autotile(t) {
|
||||
{ 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 },
|
||||
{ id: 'at-plain', name: 'Plain', x: 0, y: 0, w: 48, h: 48 },
|
||||
);
|
||||
__gm.state.project.assets.push({
|
||||
id: 'at-grass',
|
||||
type: 'autotile',
|
||||
type: 'tile',
|
||||
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.
|
||||
// 39a. Opening the tile 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()');
|
||||
let board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'opening an autotile shows the Autotile tab with the asset in it',
|
||||
(await json('__gm.tab()')) === 'autotile' && board.assetId === 'at-grass',
|
||||
'opening a tile shows the Tile tab with the asset in it',
|
||||
(await json('__gm.tab()')) === 'tile' && board.assetId === 'at-grass',
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), board }),
|
||||
);
|
||||
check(
|
||||
@@ -119,12 +120,12 @@ export async function autotile(t) {
|
||||
);
|
||||
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) ===
|
||||
(await json('__gm.tile.info()')).includes('2 pictures') &&
|
||||
JSON.stringify((await json('__gm.tile.pictures()')).pictures) ===
|
||||
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
|
||||
JSON.stringify({
|
||||
info: await json('__gm.autotile.info()'),
|
||||
strip: await json('__gm.autotile.pictures()'),
|
||||
info: await json('__gm.tile.info()'),
|
||||
strip: await json('__gm.tile.pictures()'),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -148,7 +149,7 @@ export async function autotile(t) {
|
||||
// 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 – taken turned by half a tile, the way RPG Maker does
|
||||
// it, which is what makes the joint to the next tile seamless.
|
||||
const surrounded = await json('__gm.autotile.sampleTile(3, 2)');
|
||||
const surrounded = await json('__gm.tile.sampleTile(3, 2)');
|
||||
const inside = await tileReads(
|
||||
t,
|
||||
surrounded,
|
||||
@@ -161,7 +162,7 @@ export async function autotile(t) {
|
||||
'floor',
|
||||
);
|
||||
check('a surrounded tile of the sample map is drawn from its inside', inside.ok, inside.detail);
|
||||
await shot('39-autotile');
|
||||
await shot('39-tile');
|
||||
|
||||
// 39c. The animation: two pictures at 4 fps step through on their own, and the picture decides
|
||||
// the colours on screen.
|
||||
@@ -171,7 +172,7 @@ export async function autotile(t) {
|
||||
await settle();
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 20 && seen.size < 2; i++) {
|
||||
seen.add((await json('__gm.autotile.board()')).picture);
|
||||
seen.add((await json('__gm.tile.board()')).picture);
|
||||
await sleep(120);
|
||||
}
|
||||
check('the two pictures run as an animation', seen.has(0) && seen.has(1), JSON.stringify([...seen]));
|
||||
@@ -189,13 +190,13 @@ export async function autotile(t) {
|
||||
check('and the step it stands on is the one drawn', animated.ok, `${animated.step}: ${animated.detail}`);
|
||||
|
||||
// 39d. Fit, pan and zoom.
|
||||
await t.click(...Object.values(await json('__gm.autotile.fitAt()')));
|
||||
await t.click(...Object.values(await json('__gm.tile.fitAt()')));
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.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()');
|
||||
board = await json('__gm.tile.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,
|
||||
@@ -203,11 +204,11 @@ export async function autotile(t) {
|
||||
);
|
||||
await wheel(centre.x, centre.y, -300);
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.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 t.click(...Object.values(await json('__gm.tile.fitAt()')));
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.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,
|
||||
@@ -216,11 +217,11 @@ export async function autotile(t) {
|
||||
|
||||
// 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()');
|
||||
const kindAt = await json('__gm.tile.kindPickerAt()');
|
||||
await t.click(kindAt.x, kindAt.y);
|
||||
await clickText('Wall (2 × 2)');
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.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'),
|
||||
@@ -229,23 +230,23 @@ export async function autotile(t) {
|
||||
|
||||
// 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)');
|
||||
const row = await json('__gm.tile.pictureRowAt(0)');
|
||||
await t.click(row.x, row.y);
|
||||
const remove = await json('__gm.autotile.removePictureAt()');
|
||||
const remove = await json('__gm.tile.removePictureAt()');
|
||||
await t.click(remove.x, remove.y);
|
||||
await settle();
|
||||
}
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'an autotile without a picture says what it is missing',
|
||||
'a tile 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()');
|
||||
const addAt = await json('__gm.tile.addPictureAt()');
|
||||
await t.click(addAt.x, addAt.y);
|
||||
await clickText('Autotiles / Wall');
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'a wall block gives the 16 tiles a wall has',
|
||||
board.combinations === 16 && board.tile === 48 && board.problem === '',
|
||||
@@ -264,10 +265,10 @@ export async function autotile(t) {
|
||||
);
|
||||
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.
|
||||
// 39g. Everything since the kind switch is one undo step each, and the floor tile comes back.
|
||||
for (let i = 0; i < 4; i++) await undo();
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'undo puts the pictures and the kind back',
|
||||
board.kind === 'floor' && board.combinations === 47 && board.pictures === 2 && board.problem === '',
|
||||
@@ -275,23 +276,71 @@ export async function autotile(t) {
|
||||
);
|
||||
|
||||
// 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)');
|
||||
const first = await json('__gm.tile.pictureRowAt(0)');
|
||||
const second = await json('__gm.tile.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((await json('__gm.tile.pictures()')).pictures) ===
|
||||
JSON.stringify(['Autotiles / Floor 2', 'Autotiles / Floor 1']),
|
||||
JSON.stringify(await json('__gm.autotile.pictures()')),
|
||||
JSON.stringify(await json('__gm.tile.pictures()')),
|
||||
);
|
||||
await undo();
|
||||
await settle();
|
||||
check(
|
||||
'and undo puts the order back',
|
||||
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
|
||||
JSON.stringify((await json('__gm.tile.pictures()')).pictures) ===
|
||||
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
|
||||
JSON.stringify(await json('__gm.autotile.pictures()')),
|
||||
JSON.stringify(await json('__gm.tile.pictures()')),
|
||||
);
|
||||
|
||||
// 39i. A plain tile (#131): the floor blocks are no single square tile, and one square frame is –
|
||||
// drawn as it is, the same four quarters wherever it lies, whatever lies around it.
|
||||
const plainAt = await json('__gm.tile.kindPickerAt()');
|
||||
await t.click(plainAt.x, plainAt.y);
|
||||
await clickText('None (1 × 1)');
|
||||
await settle();
|
||||
board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'switching the kind to None rejects the floor blocks as not square',
|
||||
board.kind === 'none' && board.tiles === 0 && board.problem.includes('one square tile'),
|
||||
JSON.stringify(board),
|
||||
);
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-plain']");
|
||||
await settle();
|
||||
board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'a square frame is one plain tile of 48 px',
|
||||
board.combinations === 1 &&
|
||||
board.tile === 48 &&
|
||||
board.problem === '' &&
|
||||
(await json('__gm.tile.info()')).endsWith(' · 48 px · 1 picture · 1 tile'),
|
||||
JSON.stringify({ board, info: await json('__gm.tile.info()') }),
|
||||
);
|
||||
const plainQuarters = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
];
|
||||
const plainLone = await tileReads(t, 0, plainQuarters, 'floor');
|
||||
const plainInside = await tileReads(t, await json('__gm.tile.sampleTile(3, 2)'), plainQuarters, 'floor');
|
||||
check(
|
||||
'and it is drawn as it is, alone and surrounded alike',
|
||||
plainLone.ok && plainInside.ok,
|
||||
`${plainLone.detail} | ${plainInside.detail}`,
|
||||
);
|
||||
await mutate(
|
||||
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
|
||||
);
|
||||
await undo();
|
||||
await settle();
|
||||
board = await json('__gm.tile.board()');
|
||||
check(
|
||||
'undo makes it a floor block again',
|
||||
board.kind === 'floor' && board.combinations === 47 && board.problem === '',
|
||||
JSON.stringify(board),
|
||||
);
|
||||
|
||||
// Leave the tab as the rest of the suite expects to find the editor.
|
||||
+15
-15
@@ -1,10 +1,10 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
import { LANES, openTab, pickAssetType } from './lib/scaffold.mjs';
|
||||
import { quadColour } from './39-autotile.mjs';
|
||||
import { quadColour } from './39-tile.mjs';
|
||||
|
||||
/**
|
||||
* Tilemaps (#128): a placed grid painted with the autotiles of step 39, which is where the sheet
|
||||
* and the `at-grass` autotile come from – this step needs it and is run after it.
|
||||
* Tilemaps (#128): a placed grid painted with the tiles of step 39, which is where the sheet
|
||||
* and the `at-grass` tile come from – this step needs it and is run after it.
|
||||
*
|
||||
* The point of the checks below is that a painted cell really shows the quarters its neighbours
|
||||
* call for: they read pixels off the scene view, because everything between the model and the
|
||||
@@ -101,28 +101,28 @@ export async function tilemap(t) {
|
||||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||||
await settle();
|
||||
|
||||
// 40b. The inspector paints: a tool, the autotiles of the project, the layers.
|
||||
// 40b. The inspector paints: a tool, the tiles of the project, the layers.
|
||||
const section = await json(
|
||||
'(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.autotileAt("at-grass") !== null,' +
|
||||
'(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.tileButtonAt("at-grass") !== null,' +
|
||||
' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()',
|
||||
);
|
||||
check(
|
||||
'a selected tilemap offers tools, autotiles and layers in the inspector',
|
||||
'a selected tilemap offers tools, tiles and layers in the inspector',
|
||||
section.tools && section.grass && Boolean(section.layers),
|
||||
JSON.stringify(section),
|
||||
);
|
||||
// Clicked through the hook rather than by its text: the autotile's own row in the asset panel
|
||||
// reads "Grass" as well, and a click there would only select the autotile.
|
||||
// Clicked through the hook rather than by its text: the tile's own row in the asset panel
|
||||
// reads "Grass" as well, and a click there would only select the tile.
|
||||
check(
|
||||
'the autotile can be picked in the section',
|
||||
await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")'),
|
||||
'the tile can be picked in the section',
|
||||
await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'),
|
||||
JSON.stringify(await json('__gm.inspectorRect()')),
|
||||
);
|
||||
await settle();
|
||||
const paint = await json('__gm.tilemap.paint()');
|
||||
check(
|
||||
'picking an autotile reaches for the brush by itself',
|
||||
paint.autotileId === 'at-grass' && paint.tool === 'brush',
|
||||
'picking a tile reaches for the brush by itself',
|
||||
paint.tileId === 'at-grass' && paint.tool === 'brush',
|
||||
JSON.stringify(paint),
|
||||
);
|
||||
|
||||
@@ -197,10 +197,10 @@ export async function tilemap(t) {
|
||||
);
|
||||
await settle();
|
||||
|
||||
// 40f. One mesh per autotile, not one per cell – the whole point of drawing it as a mesh.
|
||||
// 40f. One mesh per tile, not one per cell – the whole point of drawing it as a mesh.
|
||||
const drawn = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`);
|
||||
check(
|
||||
'nine cells of one autotile are one draw call',
|
||||
'nine cells of one tile are one draw call',
|
||||
drawn.tiles === 9 && drawn.drawCalls === 1 && drawn.layers === 1,
|
||||
JSON.stringify(drawn),
|
||||
);
|
||||
@@ -229,7 +229,7 @@ export async function tilemap(t) {
|
||||
layers.length === 2 && layers[1].cells === 0,
|
||||
JSON.stringify(layers),
|
||||
);
|
||||
await clickInInspector(t, '__gm.tilemap.autotileAt("at-grass")');
|
||||
await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")');
|
||||
await settle();
|
||||
const above = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 0)`);
|
||||
await click(above.x, above.y);
|
||||
|
||||
+2
-2
@@ -41,7 +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';
|
||||
import { tile } from './39-tile.mjs';
|
||||
import { tilemap } from './40-tilemap.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
@@ -83,6 +83,6 @@ export const STEPS = [
|
||||
graphGroups,
|
||||
nineSlice,
|
||||
plugins,
|
||||
autotile,
|
||||
tile,
|
||||
tilemap,
|
||||
];
|
||||
@@ -110,7 +110,7 @@ export const TAB_LABELS = {
|
||||
shader: 'Shader',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
tile: 'Tile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
particles: 'Particles',
|
||||
@@ -134,7 +134,7 @@ export async function openTab(t, tab) {
|
||||
}
|
||||
// The label alone is not the tab: the toolbar labels its scene selector "Scene" too, and being
|
||||
// drawn first that is what `clickText` had been hitting – so the tab stayed where it was
|
||||
// (measured 2026-09-17: step 39 ended on the Autotile tab although it asks for the Scene tab).
|
||||
// (measured 2026-09-17: step 39 ended on the Autotile tab – now Tile – although it asks for the Scene tab).
|
||||
// The hit is therefore picked by the box the tab row reports for that tab.
|
||||
const entry = row.shown.find((e) => e.tab === tab);
|
||||
const hits = entry ? await t.json(`__gm.locateAll(${JSON.stringify(label)})`) : [];
|
||||
|
||||
Reference in new issue
Block a user