diff --git a/.gitignore b/.gitignore index c7d9b775..a2cbe9d4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,7 @@ /dist/ /node_modules/ /logs/ +/rpgmz/ /wiki/ /index.html +/pasted-*.png diff --git a/eslint.config.js b/eslint.config.js index c29d58db..840639c6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,7 +8,9 @@ import tseslint from 'typescript-eslint'; * so a rule here always means "this is a mistake", never "this looks different". */ export default tseslint.config( - { ignores: ['dist/**', 'data/**', 'node_modules/**', 'index.html'] }, + // `rpgmz/` is gitignored: a copy of the RPG Maker MZ runtime kept next to the project to read the + // autotile tables from (#118). It is nobody's source, so it is not linted either. + { ignores: ['dist/**', 'data/**', 'node_modules/**', 'rpgmz/**', 'wiki/**', 'index.html'] }, // Type-aware linting for everything the tsconfig covers (src, tests, vite.config.ts). { diff --git a/src/core/model/autotile.ts b/src/core/model/autotile.ts index 9530f3dc..b9118cb5 100644 --- a/src/core/model/autotile.ts +++ b/src/core/model/autotile.ts @@ -82,22 +82,30 @@ interface CornerSpec { const at = (x: number, y: number): QuadPos => ({ x, y }); /** - * The floor block: the top tile row holds the four notches, the 2 × 2 below it the bordered tile - * whose middle four quadrants are the inside. Verified against the `Outside_A1`–`A4` sheets. + * The floor block: the top *right* tile holds the four notches, the 2 × 2 below the two rows the + * bordered tile. Its border quadrants sit where one would expect them – left border on the left, + * top border on top – but the four inside quadrants are taken **turned by half a tile**: the + * top-left corner of a drawn tile takes the inside quadrant at the bottom right, and so on. That + * is what makes two neighbouring tiles continue each other, because the quarters they meet with + * are the ones that touch in the sheet. Taken from the `FLOOR_AUTOTILE_TABLE` of RPG Maker MZ + * (`Tilemap._addAutotile`) and checked against it: the 47 tiles below are exactly its rows 0–46. + * + * Its row 47, the block's top-left tile, stays unused – it is pixel for pixel the same as the lone + * tile built from the four convex corners, which is row 46. */ const FLOOR_CORNERS: readonly CornerSpec[] = [ - { fill: at(1, 3), h: at(1, 2), v: at(0, 3), corner: at(0, 2), inner: at(0, 0) }, - { fill: at(2, 3), h: at(2, 2), v: at(3, 3), corner: at(3, 2), inner: at(1, 0) }, - { fill: at(1, 4), h: at(1, 5), v: at(0, 4), corner: at(0, 5), inner: at(0, 1) }, - { fill: at(2, 4), h: at(2, 5), v: at(3, 4), corner: at(3, 5), inner: at(1, 1) }, + { fill: at(2, 4), h: at(2, 2), v: at(0, 4), corner: at(0, 2), inner: at(2, 0) }, + { fill: at(1, 4), h: at(1, 2), v: at(3, 4), corner: at(3, 2), inner: at(3, 0) }, + { fill: at(2, 3), h: at(2, 5), v: at(0, 3), corner: at(0, 5), inner: at(2, 1) }, + { fill: at(1, 3), h: at(1, 5), v: at(3, 3), corner: at(3, 5), inner: at(3, 1) }, ]; -/** The wall block: the same bordered 2 × 2 tile and nothing else. */ +/** The wall block: the same bordered 2 × 2 tile and nothing else – MZ's `WALL_AUTOTILE_TABLE`. */ const WALL_CORNERS: readonly CornerSpec[] = [ - { fill: at(1, 1), h: at(1, 0), v: at(0, 1), corner: at(0, 0) }, - { fill: at(2, 1), h: at(2, 0), v: at(3, 1), corner: at(3, 0) }, - { fill: at(1, 2), h: at(1, 3), v: at(0, 2), corner: at(0, 3) }, - { fill: at(2, 2), h: at(2, 3), v: at(3, 2), corner: at(3, 3) }, + { fill: at(2, 2), h: at(2, 0), v: at(0, 2), corner: at(0, 0) }, + { fill: at(1, 2), h: at(1, 0), v: at(3, 2), corner: at(3, 0) }, + { fill: at(2, 1), h: at(2, 3), v: at(0, 1), corner: at(0, 3) }, + { fill: at(1, 1), h: at(1, 3), v: at(3, 1), corner: at(3, 3) }, ]; function cornersOf(kind: AutotileKind): readonly CornerSpec[] { diff --git a/tests/core/autotile.test.ts b/tests/core/autotile.test.ts index f0462a49..5a622a5c 100644 --- a/tests/core/autotile.test.ts +++ b/tests/core/autotile.test.ts @@ -56,6 +56,83 @@ function cutBlock( 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 }; @@ -80,30 +157,45 @@ describe('autotile blocks', () => { }); /** - * The four corners of the block's bordered tile, its inside and its notches – the layout read off - * the `Outside_A1`–`A4` sheets. A wrong one of these is the seam #118 is about. + * 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: 1, y: 3 }, - { x: 2, y: 3 }, - { x: 1, y: 4 }, { 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: 3 }); - expect(north[1]).toEqual({ x: 3, y: 3 }); + 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 }); }); @@ -111,9 +203,10 @@ describe('autotile blocks', () => { it('puts a notch where both sides are there but the diagonal between them is not', () => { const withDiagonal = tileQuadrants('floor', around({ n: true, w: true, nw: true })); const without = tileQuadrants('floor', around({ n: true, w: true })); - // Only the corner the diagonal belongs to changes, and it changes into the notch. - expect(withDiagonal[0]).toEqual({ x: 1, y: 3 }); - expect(without[0]).toEqual({ x: 0, y: 0 }); + // 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)); }); @@ -121,7 +214,7 @@ describe('autotile blocks', () => { const bare = tileQuadrants('wall', around({ n: true, w: true })); const diagonal = tileQuadrants('wall', around({ n: true, w: true, nw: true })); expect(bare).toEqual(diagonal); - expect(bare[0]).toEqual({ x: 1, y: 1 }); + 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); @@ -146,7 +239,7 @@ describe('autotile blocks', () => { // with the hole itself sitting in the north-east – exactly the case the notch is drawn for. const corner = neighboursAt(SAMPLE_PATTERN, 5, 3); expect([corner.n, corner.e, corner.ne]).toEqual([true, true, false]); - expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 1, y: 0 }); + expect(tileQuadrants('floor', corner)[1]).toEqual({ x: 3, y: 0 }); }); }); diff --git a/tools/e2e/39-autotile.mjs b/tools/e2e/39-autotile.mjs index dcf81754..5d12f85d 100644 --- a/tools/e2e/39-autotile.mjs +++ b/tools/e2e/39-autotile.mjs @@ -45,19 +45,25 @@ function quadColour(kind, qx, qy, step = 0) { return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; } -/** Whether the tile's quarters were taken from these block positions and drawn in these colours. */ -async function tileReads(t, index, expected, kind, step = 0) { +/** + * Whether the tile's quarters were taken from these block positions and drawn in these colours. + * The step the animation stands on and the four pixels are read in **one** call into the page: read + * 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})`); if (!tile) return { ok: false, detail: 'no such tile on the board' }; const positions = tile.quadrants.map((q) => [q.x, q.y]); if (JSON.stringify(positions) !== JSON.stringify(expected)) return { ok: false, detail: JSON.stringify({ positions, expected }) }; - const read = []; - for (const quadrant of tile.quadrants) - read.push(await t.json(`__gm.pixelAt(${Math.round(quadrant.at.x)}, ${Math.round(quadrant.at.y)})`)); - const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, step)); - const off = read.filter((colour, i) => colourDistance(colour, want[i]) > 12); - return { ok: off.length === 0, detail: JSON.stringify({ read, want }) }; + 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)}` + + `.map(([x, y]) => __gm.pixelAt(x, y)) }))()`, + ); + const want = expected.map(([qx, qy]) => quadColour(kind, qx, qy, shot.step)); + const off = shot.read.filter((colour, i) => colourDistance(colour, want[i]) > 12); + return { ok: off.length === 0, step: shot.step, detail: JSON.stringify({ read: shot.read, want }) }; } export async function autotile(t) { @@ -140,16 +146,17 @@ export async function autotile(t) { check('a lone tile is drawn from the four convex corners of the block', lone.ok, lone.detail); // Row 3, column 2 of the sample map is surrounded on all eight sides, so all four quarters of - // that tile come from the block's inside. + // 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 inside = await tileReads( t, surrounded, [ - [1, 3], - [2, 3], - [1, 4], [2, 4], + [1, 4], + [2, 3], + [1, 3], ], 'floor', ); @@ -168,7 +175,6 @@ export async function autotile(t) { await sleep(120); } check('the two pictures run as an animation', seen.has(0) && seen.has(1), JSON.stringify([...seen])); - const step = (await json('__gm.autotile.board()')).picture; const animated = await tileReads( t, 0, @@ -179,9 +185,8 @@ export async function autotile(t) { [3, 5], ], 'floor', - step, ); - check('and the step it stands on is the one drawn', animated.ok, `${step}: ${animated.detail}`); + 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()')));