Files
adminandClaude Opus 5.5 af6ac12ab5
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 9m20s
Show sharp tile preview labels and paths with tunnels and crossings
The mask labels under the tile preview sat inside the zoomed board.
Pixi rendered them at 10 px and stretched them with the zoom, and
each label was shrunk to the width of its tile on its own. They came
out blurry and in different sizes. The example map also only showed
a ring, so narrow paths and crossings were never previewed.

- The labels live in their own layer in screen space and are
  re-rendered at the fitting font size on every zoom.
- All labels share one size, at most the 10 px of the theme. Below
  6 px they are hidden.
- A label has two lines: the sides on top, the corners dimmed below.
- The example map keeps the ring cell for cell and adds a network of
  1 tile wide paths below it. It has straight 1xN and Nx1 pieces,
  curves, two T junctions, a crossing and a dead end.
- The map stands next to the grid in a wide panel and below it in a
  tall one. Fit picks the layout with the larger tiles, and only Fit
  changes it, so zooming does not make the board jump.
- The new hook __gm.tile.captions() reports size, scale and lines.

Found on the way: the taller map made Fit zoom out further, and 3
pixel checks on the 16 px terrain tiles failed. The side by side
layout fixed that.

Tests:
- ./check.sh: 160 files, 2364 tests, CHECK EXIT 0 (was 2363).
- e2e 39: ALL PASSED, 29 checks (was 28).
- New unit test: the example map has straights, a curve, a crossing
  with four notched corners, a T junction and a dead end.
- Counter check: with the labels back in the zoomed board the new
  caption check fails (scales [0.648, 1]).
- The e2e sum in the README is now 1381. It is calculated only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:18:41 +02:00

618 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import {
CreateAssetCommand,
CreateTextureCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import {
AddTilePictureCommand,
MoveTilePictureCommand,
RemoveTilePictureCommand,
} from '../../src/core/commands/tileCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { TileAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import {
autotileCombinations,
loneTileQuadrants,
type Neighbours,
neighboursAt,
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 { 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 #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 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,
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 makeTile>,
rect: { x: number; y: number; w: number; h: number },
): string {
const command = new AddFrameCommand(fixture.texture.id, rect);
fixture.stack.execute(command);
return command.frameId;
}
/**
* `Tilemap.FLOOR_AUTOTILE_TABLE` and `Tilemap.WALL_AUTOTILE_TABLE` of RPG Maker MZ, generated from
* its `js/rmmz_core.js` and not typed by hand. Each row is one tile as four quarters – top-left,
* top-right, bottom-left, bottom-right – in quadrant units from the block's top-left corner.
*/
// prettier-ignore
const MZ_FLOOR: readonly (readonly (readonly number[])[])[] = [
[[2, 4], [1, 4], [2, 3], [1, 3]],
[[2, 0], [1, 4], [2, 3], [1, 3]],
[[2, 4], [3, 0], [2, 3], [1, 3]],
[[2, 0], [3, 0], [2, 3], [1, 3]],
[[2, 4], [1, 4], [2, 3], [3, 1]],
[[2, 0], [1, 4], [2, 3], [3, 1]],
[[2, 4], [3, 0], [2, 3], [3, 1]],
[[2, 0], [3, 0], [2, 3], [3, 1]],
[[2, 4], [1, 4], [2, 1], [1, 3]],
[[2, 0], [1, 4], [2, 1], [1, 3]],
[[2, 4], [3, 0], [2, 1], [1, 3]],
[[2, 0], [3, 0], [2, 1], [1, 3]],
[[2, 4], [1, 4], [2, 1], [3, 1]],
[[2, 0], [1, 4], [2, 1], [3, 1]],
[[2, 4], [3, 0], [2, 1], [3, 1]],
[[2, 0], [3, 0], [2, 1], [3, 1]],
[[0, 4], [1, 4], [0, 3], [1, 3]],
[[0, 4], [3, 0], [0, 3], [1, 3]],
[[0, 4], [1, 4], [0, 3], [3, 1]],
[[0, 4], [3, 0], [0, 3], [3, 1]],
[[2, 2], [1, 2], [2, 3], [1, 3]],
[[2, 2], [1, 2], [2, 3], [3, 1]],
[[2, 2], [1, 2], [2, 1], [1, 3]],
[[2, 2], [1, 2], [2, 1], [3, 1]],
[[2, 4], [3, 4], [2, 3], [3, 3]],
[[2, 4], [3, 4], [2, 1], [3, 3]],
[[2, 0], [3, 4], [2, 3], [3, 3]],
[[2, 0], [3, 4], [2, 1], [3, 3]],
[[2, 4], [1, 4], [2, 5], [1, 5]],
[[2, 0], [1, 4], [2, 5], [1, 5]],
[[2, 4], [3, 0], [2, 5], [1, 5]],
[[2, 0], [3, 0], [2, 5], [1, 5]],
[[0, 4], [3, 4], [0, 3], [3, 3]],
[[2, 2], [1, 2], [2, 5], [1, 5]],
[[0, 2], [1, 2], [0, 3], [1, 3]],
[[0, 2], [1, 2], [0, 3], [3, 1]],
[[2, 2], [3, 2], [2, 3], [3, 3]],
[[2, 2], [3, 2], [2, 1], [3, 3]],
[[2, 4], [3, 4], [2, 5], [3, 5]],
[[2, 0], [3, 4], [2, 5], [3, 5]],
[[0, 4], [1, 4], [0, 5], [1, 5]],
[[0, 4], [3, 0], [0, 5], [1, 5]],
[[0, 2], [3, 2], [0, 3], [3, 3]],
[[0, 2], [1, 2], [0, 5], [1, 5]],
[[0, 4], [3, 4], [0, 5], [3, 5]],
[[2, 2], [3, 2], [2, 5], [3, 5]],
[[0, 2], [3, 2], [0, 5], [3, 5]],
[[0, 0], [1, 0], [0, 1], [1, 1]],
];
// prettier-ignore
const MZ_WALL: readonly (readonly (readonly number[])[])[] = [
[[2, 2], [1, 2], [2, 1], [1, 1]],
[[0, 2], [1, 2], [0, 1], [1, 1]],
[[2, 0], [1, 0], [2, 1], [1, 1]],
[[0, 0], [1, 0], [0, 1], [1, 1]],
[[2, 2], [3, 2], [2, 1], [3, 1]],
[[0, 2], [3, 2], [0, 1], [3, 1]],
[[2, 0], [3, 0], [2, 1], [3, 1]],
[[0, 0], [3, 0], [0, 1], [3, 1]],
[[2, 2], [1, 2], [2, 3], [1, 3]],
[[0, 2], [1, 2], [0, 3], [1, 3]],
[[2, 0], [1, 0], [2, 3], [1, 3]],
[[0, 0], [1, 0], [0, 3], [1, 3]],
[[2, 2], [3, 2], [2, 3], [3, 3]],
[[0, 2], [3, 2], [0, 3], [3, 3]],
[[2, 0], [3, 0], [2, 3], [3, 3]],
[[0, 0], [3, 0], [0, 3], [3, 3]],
];
const FLOOR_BLOCK = { x: 96, y: 0, w: 96, h: 144 };
const WALL_BLOCK = { x: 0, y: 0, w: 96, h: 96 };
function around(present: Partial<Neighbours>): Neighbours {
return { n: false, e: false, s: false, w: false, ne: false, se: false, sw: false, nw: false, ...present };
}
describe('autotile blocks', () => {
it('is a library asset like a texture: nothing places it, the tilemap of #128 does', () => {
expect(isPlaceableType('tile')).toBe(false);
});
it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => {
const floor = autotileCombinations('floor');
const wall = autotileCombinations('wall');
expect(floor).toHaveLength(47);
expect(wall).toHaveLength(16);
const signature = (quadrants: { x: number; y: number }[]) =>
quadrants.map((q) => `${q.x},${q.y}`).join(' ');
expect(new Set(floor.map((c) => signature(c.quadrants))).size).toBe(47);
expect(new Set(wall.map((c) => signature(c.quadrants))).size).toBe(16);
});
/**
* The whole point of #118: the tiles must come out of the block the way RPG Maker MZ takes them,
* or a seam runs through every joint. `MZ_FLOOR` and `MZ_WALL` below are its tables verbatim, so
* this test fails the moment the model drifts away from them.
*/
it('produces exactly the quarters of the RPG-Maker tables', () => {
const signature = (quadrants: readonly (readonly number[])[]) =>
quadrants.map((q) => `${q[0]},${q[1]}`).join(' ');
const mine = (kind: 'floor' | 'wall') =>
new Set(autotileCombinations(kind).map((c) => signature(c.quadrants.map((q) => [q.x, q.y]))));
// MZ's floor table has one row more than there are neighbourhoods: its last one is the block's
// top-left tile, which is the same picture as the lone tile of row 46.
expect(mine('floor')).toEqual(new Set(MZ_FLOOR.slice(0, 47).map(signature)));
expect(mine('wall')).toEqual(new Set(MZ_WALL.map(signature)));
});
it('takes a lone tile from the four convex corners and a surrounded one from the inside', () => {
// Row 46 of MZ's table.
expect(tileQuadrants('floor', around({}))).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
// Row 0, and the reason a joint is seamless: the inside is taken turned by half a tile, so the
// top-left corner of the tile is the quarter at the bottom right of the block's inside.
const all = { n: true, e: true, s: true, w: true, ne: true, se: true, sw: true, nw: true };
expect(tileQuadrants('floor', all)).toEqual([
{ x: 2, y: 4 },
{ x: 1, y: 4 },
{ x: 2, y: 3 },
{ x: 1, y: 3 },
]);
});
it('draws a straight edge towards the side that is missing', () => {
// Only the north neighbour: the top half is inside, the bottom half is the lower border.
const north = tileQuadrants('floor', around({ n: true }));
expect(north[0]).toEqual({ x: 0, y: 4 });
expect(north[1]).toEqual({ x: 3, y: 4 });
expect(north[2]).toEqual({ x: 0, y: 5 });
expect(north[3]).toEqual({ x: 3, y: 5 });
});
it('puts a notch where both sides are there but the diagonal between them is not', () => {
const withDiagonal = tileQuadrants('floor', around({ n: true, w: true, nw: true }));
const without = tileQuadrants('floor', around({ n: true, w: true }));
// Only the corner the diagonal belongs to changes, and it changes into the notch – which sits
// in the block's top *right* tile, not the top left one.
expect(withDiagonal[0]).toEqual({ x: 2, y: 4 });
expect(without[0]).toEqual({ x: 2, y: 0 });
expect(without.slice(1)).toEqual(withDiagonal.slice(1));
});
it('ignores the diagonals on a wall, which has no notch to draw', () => {
const bare = tileQuadrants('wall', around({ n: true, w: true }));
const diagonal = tileQuadrants('wall', around({ n: true, w: true, nw: true }));
expect(bare).toEqual(diagonal);
expect(bare[0]).toEqual({ x: 2, y: 2 });
// Every quarter a wall can use lies in its own 2 × 2 block: four quadrant rows, no more.
for (const combination of autotileCombinations('wall'))
for (const quadrant of combination.quadrants) expect(quadrant.y).toBeLessThan(4);
});
it('labels a tile by the sides it joins and the corners it notches', () => {
const floor = autotileCombinations('floor');
expect(floor[0]?.label).toBe('·');
const surrounded = floor.find(
(c) => c.neighbours.n && c.neighbours.e && c.neighbours.s && c.neighbours.w,
);
expect(surrounded?.label).toBe('NESW-ne,se,sw,nw');
});
it('reads the sample map so that the hole in it asks for notches', () => {
// Row 2, column 4 sits right above the hole: joined to the north, open to the south.
const above = neighboursAt(SAMPLE_PATTERN, 2, 4);
expect(above.n).toBe(true);
expect(above.s).toBe(false);
expect(above.se).toBe(false);
// Row 5, column 3 is the cell below the hole's bottom-left corner: joined north and east,
// with the hole itself sitting in the north-east – exactly the case the notch is drawn for.
const corner = neighboursAt(SAMPLE_PATTERN, 5, 3);
expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]);
expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 3, y: 0 });
});
it('puts paths one tile wide into the sample map: straights, bends, junctions, a crossing', () => {
const sides = (row: number, column: number) => {
const around = neighboursAt(SAMPLE_PATTERN, row, column);
return (['n', 'e', 's', 'w'] as const)
.filter((side) => around[side])
.join('')
.toUpperCase();
};
expect(sides(7, 5)).toBe('NS'); // the tunnel leaving the ring
expect(sides(9, 2)).toBe('NS');
expect(sides(8, 3)).toBe('EW');
expect(sides(8, 2)).toBe('ES'); // a bend
expect(sides(8, 5)).toBe('NESW'); // the crossing
expect(sides(10, 5)).toBe('NEW'); // a T-junction
expect(sides(10, 0)).toBe('E'); // a dead end
// A crossing of one-wide paths has none of its diagonals: all four corners are notched.
const crossing = neighboursAt(SAMPLE_PATTERN, 8, 5);
expect([crossing.ne, crossing.se, crossing.sw, crossing.nw]).toEqual([false, false, false, false]);
expect(SAMPLE_PATTERN.every((line) => line.length === SAMPLE_PATTERN[0]!.length)).toBe(true);
});
it('shows each kind in the palette as its lone tile, the four convex corners (#139)', () => {
expect(loneTileQuadrants('floor')).toEqual([
{ x: 0, y: 2 },
{ x: 3, y: 2 },
{ x: 0, y: 5 },
{ x: 3, y: 5 },
]);
expect(loneTileQuadrants('wall')).toEqual([
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 0, y: 3 },
{ x: 3, y: 3 },
]);
// A plain tile is its picture as it is, each corner its own quarter.
expect(loneTileQuadrants('none')).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
]);
});
});
/** The four quarters of the whole tile at tile column `tx`, row `ty`, written out as [x, y]. */
const whole = (tx: number, ty: number) => [
[2 * tx, 2 * ty],
[2 * tx + 1, 2 * ty],
[2 * tx, 2 * ty + 1],
[2 * tx + 1, 2 * ty + 1],
];
const pairs = (quadrants: { x: number; y: number }[]) => quadrants.map((q) => [q.x, q.y]);
const ALL_SIDES = { n: true, e: true, s: true, w: true };
const ALL_AROUND = { ...ALL_SIDES, ne: true, se: true, sw: true, nw: true };
describe('terrain block', () => {
it('produces 47 different tiles, all inside the 6 × 4 block and none from its two empty tiles', () => {
const terrain = autotileCombinations('terrain');
expect(terrain).toHaveLength(47);
for (const { quadrants } of terrain)
for (const q of quadrants) {
expect(q.x).toBeLessThan(12);
expect(q.y).toBeLessThan(8);
expect(q.x >= 8 && q.y >= 6).toBe(false);
}
});
it('takes each of the 16 ways the sides join as a whole tile of the 4 × 4 on the left', () => {
const drawn = new Set<string>();
for (let mask = 0; mask < 16; mask++) {
const sides = { n: !!(mask & 1), e: !!(mask & 2), s: !!(mask & 4), w: !!(mask & 8) };
const quadrants = pairs(
tileQuadrants('terrain', around({ ...sides, ne: true, se: true, sw: true, nw: true })),
);
const [x, y] = quadrants[0]!;
expect(quadrants).toEqual(whole(x! / 2, y! / 2));
expect(x).toBeLessThan(8);
drawn.add(`${x},${y}`);
}
expect(drawn.size).toBe(16);
expect(pairs(tileQuadrants('terrain', around({})))).toEqual(whole(0, 3));
expect(pairs(tileQuadrants('terrain', around({ n: true, s: true })))).toEqual(whole(0, 1));
expect(pairs(tileQuadrants('terrain', around({ e: true, w: true })))).toEqual(whole(2, 3));
expect(pairs(tileQuadrants('terrain', around(ALL_AROUND)))).toEqual(whole(2, 1));
expect(pairs(loneTileQuadrants('terrain'))).toEqual(whole(0, 3));
});
it('takes a single notch and the two diagonal pairs whole from the columns on the right', () => {
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, se: false })))).toEqual(whole(4, 0));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, sw: false })))).toEqual(whole(5, 0));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, ne: false })))).toEqual(whole(4, 1));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_AROUND, nw: false })))).toEqual(whole(5, 1));
expect(pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, ne: true, sw: true })))).toEqual(
whole(4, 2),
);
expect(pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, nw: true, se: true })))).toEqual(
whole(5, 2),
);
});
it('puts the notch into an edge tile and brings the quarter over or under it along', () => {
// Joined north, east and south, open to the west and the north-east: the left edge tile, with
// the top-right notch and, below it, the quarter of the same notch tile.
const edge = pairs(tileQuadrants('terrain', around({ n: true, e: true, s: true, se: true })));
expect(edge).toEqual([
[2, 2],
[9, 2],
[2, 3],
[9, 3],
]);
// Two notches side by side, the south-east and the south-west: each brings its top quarter.
const both = pairs(tileQuadrants('terrain', around({ ...ALL_SIDES, ne: true, nw: true })));
expect(both).toEqual([
[10, 0],
[9, 0],
[10, 1],
[9, 1],
]);
});
it('leaves an edge above a notch alone – only a plain inside quarter is brought along', () => {
// Open to the north: the top edge tile keeps its top half, only the south-east notch goes in.
const topEdge = pairs(tileQuadrants('terrain', around({ e: true, s: true, w: true, sw: true })));
expect(topEdge).toEqual([
[4, 0],
[5, 0],
[4, 1],
[9, 1],
]);
});
it('labels the notched corners like a floor does', () => {
const labels = autotileCombinations('terrain').map((c) => c.label);
expect(labels[0]).toBe('·');
expect(labels).toContain('NESW-ne,se,sw,nw');
expect(labels).toContain('NES-ne');
});
it('reads a 6 × 4 block as a terrain tile and says so when the block is the wrong shape', () => {
const fixture = makeTile(96, 192, 'terrain');
fixture.stack.execute(
new AddTilePictureCommand(fixture.tileId, cutBlock(fixture, { x: 0, y: 0, w: 96, h: 64 })),
);
const info = readTile(fixture.project, fixture.assetOf());
expect(info.problem).toBe('');
expect(info.tile).toBe(16);
const wrong = makeTile(96, 192, 'terrain');
wrong.stack.execute(new AddTilePictureCommand(wrong.tileId, wrong.texture.id));
expect(readTile(wrong.project, wrong.assetOf()).problem).toContain('terrain block is 6 × 4 tiles');
});
});
describe('tile asset', () => {
it('derives the tile size from the block and accepts a whole texture as one', () => {
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);
expect(info.pictures).toHaveLength(1);
});
it('cuts a quarter tile out of the frame the picture names', () => {
const fixture = makeTile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
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({
x: 96 + 48,
y: 120,
w: 24,
h: 24,
});
});
it('complains about a block that does not fit the kind, without dropping it', () => {
const fixture = makeTile();
const wall = cutBlock(fixture, WALL_BLOCK);
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.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 = makeTile();
const big = cutBlock(fixture, FLOOR_BLOCK);
const small = cutBlock(fixture, { x: 0, y: 0, w: 32, h: 48 });
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(readTile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist');
fixture.assetOf().pictures = [];
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', () => {
// Three pictures at 4 fps: a quarter second each.
const loop = [0, 0.26, 0.51, 0.76, 1.01].map((t) => pictureIndexAt(3, 'loop', 4, t));
expect(loop).toEqual([0, 1, 2, 0, 1]);
const pingpong = [0, 0.26, 0.51, 0.76, 1.01, 1.26].map((t) => pictureIndexAt(3, 'pingpong', 4, t));
// Turns on the last picture, so the middle one is never shown twice in a row.
expect(pingpong).toEqual([0, 1, 2, 1, 0, 1]);
expect(pictureIndexAt(1, 'loop', 4, 99)).toBe(0);
expect(pictureIndexAt(3, 'loop', 0, 99)).toBe(0);
});
});
describe('tile pictures', () => {
it('adds, removes and reorders with undo', () => {
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 AddTilePictureCommand(fixture.tileId, id));
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
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 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 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 = makeTile();
const sprite = new CreateAssetCommand('sprite', 'Box');
fixture.stack.execute(sprite);
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 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 = makeTile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
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('tile in the document', () => {
it('survives a save and keeps only what it can read', () => {
const fixture = makeTile();
const frame = cutBlock(fixture, FLOOR_BLOCK);
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(tileKind(asset)).toBe('wall');
// 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 === '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 = tileOf(roundTrip(raw as unknown as Project));
expect(read.pictures).toEqual([frame, 'gone']);
expect(tileKind(read)).toBe('none');
expect(tilePlayback(read)).toBe('loop');
});
// No migration any more (#137): `autotile`, the name before #131, is an unknown type like any other.
it('drops an asset of the old type autotile', () => {
const raw = {
id: 'p',
assets: [
{ id: 's', type: 'scene', name: 'Main', props: {}, objects: [] },
{ id: 'a', type: 'autotile', name: 'Old grass', props: { kind: 'floor' }, pictures: ['x'] },
{ id: 'c', type: 'tile', name: 'New', props: {}, pictures: [] },
],
};
expect(deserializeProject(raw).assets.map((a) => a.id)).toEqual(['s', 'c']);
});
});
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;
}