From 0607221423b8618d1061c27525c892c44c740c2e Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 17 Sep 2026 22:28:11 +0200 Subject: [PATCH] Autotile floor tiles take their inner quadrants rotated by half a tile and their inner corners from the upper right block tile, matching RPG Maker MZ (#118) 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 --- .gitignore | 2 + eslint.config.js | 4 +- src/core/model/autotile.ts | 30 +++++---- tests/core/autotile.test.ts | 117 ++++++++++++++++++++++++++++++++---- tools/e2e/39-autotile.mjs | 35 ++++++----- 5 files changed, 149 insertions(+), 39 deletions(-) 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()')));