The corner tables are now derived from rmmz_core.js (Tilemap._addAutotile, FLOOR_AUTOTILE_TABLE, WALL_AUTOTILE_TABLE): the fill quadrants are taken rotated by half a tile so that neighbouring tiles meet with the quarters that sit next to each other on the sheet, and the inner corners come from the upper right tile of the block, not the upper left one. A test pins the model against both MZ tables, generated from rmmz_core.js rather than typed out. The animation check in e2e module 39 read its four pixels one by one and could straddle a frame change; step and pixels are now read in a single call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
17 KiB
TypeScript
394 lines
17 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CreateAssetCommand,
|
||
CreateTextureCommand,
|
||
SetAssetPropertyCommand,
|
||
} from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddAutotilePictureCommand,
|
||
MoveAutotilePictureCommand,
|
||
RemoveAutotilePictureCommand,
|
||
} from '../../src/core/commands/autotileCommands';
|
||
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
|
||
import type { AutotileAsset } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import {
|
||
AUTOTILE_KIND_KEY,
|
||
autotileCombinations,
|
||
autotileKind,
|
||
autotilePlayback,
|
||
type Neighbours,
|
||
neighboursAt,
|
||
pictureIndexAt,
|
||
quadrantRect,
|
||
readAutotile,
|
||
SAMPLE_PATTERN,
|
||
tileQuadrants,
|
||
} from '../../src/core/model/autotile';
|
||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||
import { autotilesUsing } from '../../src/core/model/usages';
|
||
import { makeProject, roundTrip } from '../helpers';
|
||
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
|
||
/** A sheet the size of the RPG-Maker sheets the issue was written from: 48 px tiles, 8 × 4 blocks. */
|
||
function makeAutotile(width = 768, height = 576) {
|
||
const fixture = makeProject();
|
||
const texture = new CreateTextureCommand('Outside', { file: FILE, width, height });
|
||
fixture.stack.execute(texture);
|
||
const autotile = new CreateAssetCommand('autotile', 'Grass');
|
||
fixture.stack.execute(autotile);
|
||
return {
|
||
...fixture,
|
||
texture: texture.asset,
|
||
autotileId: autotile.asset.id,
|
||
assetOf: () => findAsset(fixture.project, autotile.asset.id) as AutotileAsset,
|
||
};
|
||
}
|
||
|
||
/** Cuts one block out of the sheet and returns the frame id. */
|
||
function cutBlock(
|
||
fixture: ReturnType<typeof makeAutotile>,
|
||
rect: { x: number; y: number; w: number; h: number },
|
||
): string {
|
||
const command = new AddFrameCommand(fixture.texture.id, rect);
|
||
fixture.stack.execute(command);
|
||
return command.frameId;
|
||
}
|
||
|
||
/**
|
||
* `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('autotile')).toBe(false);
|
||
});
|
||
|
||
it('produces 47 tiles from a floor block and 16 from a wall, each of them different', () => {
|
||
const floor = autotileCombinations('floor');
|
||
const wall = autotileCombinations('wall');
|
||
expect(floor).toHaveLength(47);
|
||
expect(wall).toHaveLength(16);
|
||
const signature = (quadrants: { x: number; y: number }[]) =>
|
||
quadrants.map((q) => `${q.x},${q.y}`).join(' ');
|
||
expect(new Set(floor.map((c) => signature(c.quadrants))).size).toBe(47);
|
||
expect(new Set(wall.map((c) => signature(c.quadrants))).size).toBe(16);
|
||
});
|
||
|
||
/**
|
||
* The 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 });
|
||
});
|
||
});
|
||
|
||
describe('autotile asset', () => {
|
||
it('derives the tile size from the block and accepts a whole texture as one', () => {
|
||
const fixture = makeAutotile(96, 144);
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id));
|
||
const info = readAutotile(fixture.project, fixture.assetOf());
|
||
expect(info.problem).toBe('');
|
||
expect(info.tile).toBe(48);
|
||
expect(info.quad).toBe(24);
|
||
expect(info.pictures).toHaveLength(1);
|
||
});
|
||
|
||
it('cuts a quarter tile out of the frame the picture names', () => {
|
||
const fixture = makeAutotile();
|
||
const frame = cutBlock(fixture, FLOOR_BLOCK);
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
|
||
const info = readAutotile(fixture.project, fixture.assetOf());
|
||
expect(info.tile).toBe(48);
|
||
// The block starts at x = 96, so its third quadrant column starts 48 px further right.
|
||
expect(quadrantRect(info.pictures[0]!, info.quad, { x: 2, y: 5 })).toEqual({
|
||
x: 96 + 48,
|
||
y: 120,
|
||
w: 24,
|
||
h: 24,
|
||
});
|
||
});
|
||
|
||
it('complains about a block that does not fit the kind, without dropping it', () => {
|
||
const fixture = makeAutotile();
|
||
const wall = cutBlock(fixture, WALL_BLOCK);
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, wall));
|
||
const asFloor = readAutotile(fixture.project, fixture.assetOf());
|
||
expect(asFloor.problem).toContain('floor block');
|
||
expect(asFloor.tile).toBe(0);
|
||
expect(fixture.assetOf().pictures).toEqual([wall]);
|
||
// The same picture is a wall block, so switching the kind is all it takes.
|
||
fixture.stack.execute(new SetAssetPropertyCommand(fixture.autotileId, AUTOTILE_KIND_KEY, 'wall'));
|
||
const asWall = readAutotile(fixture.project, fixture.assetOf());
|
||
expect(asWall.problem).toBe('');
|
||
expect(asWall.tile).toBe(48);
|
||
});
|
||
|
||
it('rejects an animation whose steps are not the same size, and a picture that is gone', () => {
|
||
const fixture = makeAutotile();
|
||
const big = cutBlock(fixture, FLOOR_BLOCK);
|
||
const small = cutBlock(fixture, { x: 0, y: 0, w: 32, h: 48 });
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, big));
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, small));
|
||
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('different sizes');
|
||
// A dangling id is named as missing rather than silently skipped.
|
||
fixture.assetOf().pictures = ['gone'];
|
||
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('no longer exist');
|
||
fixture.assetOf().pictures = [];
|
||
expect(readAutotile(fixture.project, fixture.assetOf()).problem).toContain('No picture yet');
|
||
});
|
||
|
||
it('steps through the pictures, loop and ping-pong, and holds still on a single one', () => {
|
||
// Three pictures at 4 fps: a quarter second each.
|
||
const loop = [0, 0.26, 0.51, 0.76, 1.01].map((t) => pictureIndexAt(3, 'loop', 4, t));
|
||
expect(loop).toEqual([0, 1, 2, 0, 1]);
|
||
const pingpong = [0, 0.26, 0.51, 0.76, 1.01, 1.26].map((t) => pictureIndexAt(3, 'pingpong', 4, t));
|
||
// Turns on the last picture, so the middle one is never shown twice in a row.
|
||
expect(pingpong).toEqual([0, 1, 2, 1, 0, 1]);
|
||
expect(pictureIndexAt(1, 'loop', 4, 99)).toBe(0);
|
||
expect(pictureIndexAt(3, 'loop', 0, 99)).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('autotile pictures', () => {
|
||
it('adds, removes and reorders with undo', () => {
|
||
const fixture = makeAutotile();
|
||
const a = cutBlock(fixture, FLOOR_BLOCK);
|
||
const b = cutBlock(fixture, { ...FLOOR_BLOCK, x: 192 });
|
||
const c = cutBlock(fixture, { ...FLOOR_BLOCK, x: 288 });
|
||
for (const id of [a, b, c]) fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, id));
|
||
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
|
||
|
||
fixture.stack.execute(new MoveAutotilePictureCommand(fixture.autotileId, 2, 0));
|
||
expect(fixture.assetOf().pictures).toEqual([c, a, b]);
|
||
fixture.stack.undo();
|
||
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
|
||
|
||
fixture.stack.execute(new RemoveAutotilePictureCommand(fixture.autotileId, 1));
|
||
expect(fixture.assetOf().pictures).toEqual([a, c]);
|
||
fixture.stack.undo();
|
||
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
|
||
|
||
// Inserting somewhere other than the end, and taking it out again.
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, c, 1));
|
||
expect(fixture.assetOf().pictures).toEqual([a, c, b, c]);
|
||
fixture.stack.undo();
|
||
expect(fixture.assetOf().pictures).toEqual([a, b, c]);
|
||
});
|
||
|
||
it('takes only textures and frames, and only places that exist', () => {
|
||
const fixture = makeAutotile();
|
||
const sprite = new CreateAssetCommand('sprite', 'Box');
|
||
fixture.stack.execute(sprite);
|
||
expect(() =>
|
||
new AddAutotilePictureCommand(fixture.autotileId, sprite.asset.id).execute(fixture.project),
|
||
).toThrow(/not a texture or a frame/);
|
||
expect(() => new RemoveAutotilePictureCommand(fixture.autotileId, 0).execute(fixture.project)).toThrow(
|
||
/no picture 1/,
|
||
);
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, fixture.texture.id));
|
||
expect(() => new MoveAutotilePictureCommand(fixture.autotileId, 0, 3).execute(fixture.project)).toThrow(
|
||
/no place 4/,
|
||
);
|
||
});
|
||
|
||
it('counts as a use of the texture, so deleting it is not silent', () => {
|
||
const fixture = makeAutotile();
|
||
const frame = cutBlock(fixture, FLOOR_BLOCK);
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
|
||
expect(autotilesUsing(fixture.project, [frame]).map((a) => a.name)).toEqual(['Grass']);
|
||
expect(autotilesUsing(fixture.project, ['somewhere else'])).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('autotile in the document', () => {
|
||
it('survives a save and keeps only what it can read', () => {
|
||
const fixture = makeAutotile();
|
||
const frame = cutBlock(fixture, FLOOR_BLOCK);
|
||
fixture.stack.execute(new AddAutotilePictureCommand(fixture.autotileId, frame));
|
||
fixture.stack.execute(new SetAssetPropertyCommand(fixture.autotileId, AUTOTILE_KIND_KEY, 'wall'));
|
||
const asset = autotileOf(roundTrip(fixture.project));
|
||
expect(asset.pictures).toEqual([frame]);
|
||
expect(autotileKind(asset)).toBe('wall');
|
||
|
||
// Junk in the list is dropped, and an unreadable kind or playback falls back to the default.
|
||
const raw = JSON.parse(JSON.stringify(fixture.project)) as {
|
||
assets: Record<string, unknown>[];
|
||
};
|
||
const stored = raw.assets.find((a) => a.type === 'autotile')!;
|
||
stored.pictures = [frame, 7, '', null, 'gone'];
|
||
(stored.props as Record<string, unknown>).kind = 'diagonal';
|
||
(stored.props as Record<string, unknown>).playback = 'once';
|
||
const read = autotileOf(roundTrip(raw as unknown as Project));
|
||
expect(read.pictures).toEqual([frame, 'gone']);
|
||
expect(autotileKind(read)).toBe('floor');
|
||
expect(autotilePlayback(read)).toBe('loop');
|
||
});
|
||
});
|
||
|
||
function autotileOf(project: Project): AutotileAsset {
|
||
const asset = project.assets.find((a): a is AutotileAsset => a.type === 'autotile');
|
||
if (!asset) throw new Error('no autotile in the project');
|
||
return asset;
|
||
}
|