Add a terrain autotile kind for 6x4 sheets like TD Plains
game-maker / gate (push) Successful in 3m7s
game-maker / suite (push) Successful in 9m54s

Sheets such as "TD - Tileset - Plains" draw an autotile as a 6 x 4
block: a 3 x 3 nine-slice, a vertical and a horizontal strip and a
single tile on the left, the inner corners on the right. The floor
kind only knew the RPG Maker 2 x 3 layout, so these sheets could not
be used as autotiles at all.

- New TileKind "terrain" with 16 px tiles in a 6 x 4 block. All 47
  neighbour combinations are resolved per quarter, so the renderer,
  the collision and the palette stay untouched.
- Whole tiles are used where the sheet draws them: the 16 side cases,
  the four single notches and the two diagonal pairs. Hand-drawn
  strips and single blocks therefore look as drawn.
- Only the missing cases are composed, for example an edge with a
  notch or two notches side by side. They take the side tile and put
  the notch quarters in.
- A notch also takes the quarter above or below from its notch tile
  when fill would stand there. Tall cliff notches keep their wall.
  On ground and stones fill meets fill, so nothing changes there.
- The label rule for floor and terrain now runs through one notch
  function. Floor still passes the MZ table test.

Not touched: the tile board only refits on open and on Fit. Switching
an open tile to a kind with another tile size leaves it small until
Fit is pressed. That was the case before.

Tests:
- ./check.sh: 156 files, 2290 tests, CHECK EXIT 0 (before 2283).
- e2e 39,40: ALL PASSED, 90 checks (before 86). 39j checks 47 tiles,
  pixels of single, notch, pair and composed tiles, and undo to floor.
- Counter-checks: without whole notch and pair tiles one unit test
  and one e2e check fail. Without the taken quarter one of each
  fails. Originals restored by cmp and rebuilt.
- Check sum: 1363 measured by CI plus 4 is 1367, computed only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-24 06:14:51 +02:00
1 parent 32cb5b4ba5
commit c1a702bc39
6 files changed
+297 -28

No files matched your search

+1 -1
View File
@@ -43,7 +43,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: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1363 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1367 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+95 -18
View File
@@ -4,8 +4,9 @@ import type { QuadPos, TileKind } from './tile';
* Which quarter goes into which corner of a drawn tile, given its neighbours – what makes a * 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 * 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 * 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 * and has none. `terrain` is the 6 × 4 block of the common top-down sheets. A plain tile (`none`,
* four quarters whatever lies around it. Everything counts in quadrants, see `model/tile.ts`. * #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`.
*/ */
/** Which of the eight neighbouring cells hold the same tile. */ /** Which of the eight neighbouring cells hold the same tile. */
@@ -75,16 +76,12 @@ const plainCorner = (position: QuadPos): CornerSpec => ({
}); });
const PLAIN_CORNERS: readonly CornerSpec[] = [at(0, 0), at(1, 0), at(0, 1), at(1, 1)].map(plainCorner); 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[]> = { const CORNERS: Record<Exclude<TileKind, 'terrain'>, readonly CornerSpec[]> = {
none: PLAIN_CORNERS, none: PLAIN_CORNERS,
floor: FLOOR_CORNERS, floor: FLOOR_CORNERS,
wall: WALL_CORNERS, wall: WALL_CORNERS,
}; };
function cornersOf(kind: TileKind): readonly CornerSpec[] {
return CORNERS[kind];
}
/** /**
* `sideH` is the neighbour left or right of the tile, `sideV` the one above or below, `diagonal` * `sideH` is the neighbour left or right of the tile, `sideV` the one above or below, `diagonal`
* the one between them. Without notches a diagonal says nothing, so a wall ignores it. * the one between them. Without notches a diagonal says nothing, so a wall ignores it.
@@ -96,9 +93,94 @@ function quadrantFor(spec: CornerSpec, sideH: boolean, sideV: boolean, diagonal:
return spec.corner; return spec.corner;
} }
/** The four quarters of the whole tile at tile column `tx`, row `ty` of the block. */
const wholeTile = (tx: number, ty: number): TileQuadrants => [
at(2 * tx, 2 * ty),
at(2 * tx + 1, 2 * ty),
at(2 * tx, 2 * ty + 1),
at(2 * tx + 1, 2 * ty + 1),
];
/**
* The terrain block, 6 × 4 tiles: a bordered 3 × 3 in columns 1–3, a vertical strip in column 0, a
* horizontal strip and the lone tile in row 3 – together one tile for each of the 16 ways the four
* sides can join. East and west pick the column, north and south the row, and where neither of a
* pair is there it is the strip's column or row.
*/
function terrainSides(around: Neighbours): TileQuadrants {
const column = around.e ? (around.w ? 2 : 1) : around.w ? 3 : 0;
const row = around.s ? (around.n ? 1 : 0) : around.n ? 2 : 3;
return wholeTile(column, row);
}
/**
* Columns 4–5 hold the notches: the 2 × 2 on top is a hole seen from its four sides, so each of its
* tiles has one notch, in reading order of the corners, and row 2 the two diagonal pairs.
*/
const TERRAIN_NOTCH_TILES: readonly TileQuadrants[] = [
wholeTile(5, 1),
wholeTile(4, 1),
wholeTile(5, 0),
wholeTile(4, 0),
];
const TERRAIN_PAIRS = { tlbr: wholeTile(4, 2), trbl: wholeTile(5, 2) };
/** The corner above or below, which shares the notch tile's half column. */
const VERTICAL_PARTNER = [2, 3, 0, 1];
/**
* A terrain tile is taken whole wherever the sheet draws it – so a strip or a lone rock looks the
* way it was drawn – and composed only for what the sheet leaves out, an edge with a notch or
* notches next to each other: the tile of its sides, with the notch quarters put in. A notch brings
* the quarter above or below it along when that quarter would be plain inside, because a cliff's
* notch reaches over the crossing into it; on flat ground that quarter is inside too and changes
* nothing.
*/
function terrainQuadrants(around: Neighbours): TileQuadrants {
const sides = terrainSides(around);
const notched = notchedCorners(around);
const count = notched.filter(Boolean).length;
if (count === 0) return sides;
const all = around.n && around.e && around.s && around.w;
if (all && count === 1) return TERRAIN_NOTCH_TILES[notched.indexOf(true)]!;
if (count === 2 && notched[0] && notched[3]) return TERRAIN_PAIRS.tlbr;
if (count === 2 && notched[1] && notched[2]) return TERRAIN_PAIRS.trbl;
const quadrants = [...sides] as TileQuadrants;
const inside = insideCorners(around);
notched.forEach((notch, corner) => {
if (!notch) return;
const source = TERRAIN_NOTCH_TILES[corner]!;
quadrants[corner] = source[corner]!;
const partner = VERTICAL_PARTNER[corner]!;
if (inside[partner]) quadrants[partner] = source[partner]!;
});
return quadrants;
}
/** The corners, in reading order, whose two sides are there – the ones that can be inside or notched. */
function joinedCorners(around: Neighbours): boolean[] {
return [around.n && around.w, around.n && around.e, around.s && around.w, around.s && around.e];
}
function cornerDiagonals(around: Neighbours): boolean[] {
return [around.nw, around.ne, around.sw, around.se];
}
/** The corners whose sides are there but whose diagonal is not. */
function notchedCorners(around: Neighbours): boolean[] {
const diagonals = cornerDiagonals(around);
return joinedCorners(around).map((joined, corner) => joined && !diagonals[corner]);
}
function insideCorners(around: Neighbours): boolean[] {
const diagonals = cornerDiagonals(around);
return joinedCorners(around).map((joined, corner) => joined && diagonals[corner]!);
}
/** The four quarters a tile with these neighbours is drawn from. */ /** The four quarters a tile with these neighbours is drawn from. */
export function tileQuadrants(kind: TileKind, around: Neighbours): TileQuadrants { export function tileQuadrants(kind: TileKind, around: Neighbours): TileQuadrants {
const [tl, tr, bl, br] = cornersOf(kind) as [CornerSpec, CornerSpec, CornerSpec, CornerSpec]; if (kind === 'terrain') return terrainQuadrants(around);
const [tl, tr, bl, br] = CORNERS[kind] as [CornerSpec, CornerSpec, CornerSpec, CornerSpec];
return [ return [
quadrantFor(tl, around.w, around.n, around.nw), quadrantFor(tl, around.w, around.n, around.nw),
quadrantFor(tr, around.e, around.n, around.ne), quadrantFor(tr, around.e, around.n, around.ne),
@@ -138,7 +220,7 @@ export interface AutotileCombination {
} }
/** /**
* Every tile the kind can produce, without repeats: 47 for a floor, 16 for a wall, 1 for a plain * Every tile the kind can produce, without repeats: 47 for a floor or terrain, 16 for a wall, 1 for a plain
* tile. Built by walking all neighbourhoods and keeping the first that draws a tile nobody drew * 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 * before – so a diagonal is only set where it changes something, and the list starts with the
* lone tile. * lone tile.
@@ -154,22 +236,17 @@ export function autotileCombinations(kind: TileKind): AutotileCombination[] {
const signature = quadrants.map((q) => `${q.x},${q.y}`).join(' '); const signature = quadrants.map((q) => `${q.x},${q.y}`).join(' ');
if (seen.has(signature)) continue; if (seen.has(signature)) continue;
seen.add(signature); seen.add(signature);
combinations.push({ neighbours, quadrants, label: combinationLabel(neighbours, quadrants, kind) }); combinations.push({ neighbours, quadrants, label: combinationLabel(neighbours, kind) });
} }
return combinations; return combinations;
} }
function combinationLabel(around: Neighbours, quadrants: TileQuadrants, kind: TileKind): string { function combinationLabel(around: Neighbours, kind: TileKind): string {
const sides = SIDES.filter((side) => around[side]) const sides = SIDES.filter((side) => around[side])
.map((side) => side.toUpperCase()) .map((side) => side.toUpperCase())
.join(''); .join('');
const corners = cornersOf(kind); const corners = kind === 'floor' || kind === 'terrain' ? notchedCorners(around) : [];
const notched = DIAGONALS.filter((_, index) => { const notched = DIAGONALS.filter((_, index) => corners[NOTCH_ORDER[index]!]);
const corner = NOTCH_ORDER[index]!;
const inner = corners[corner]?.inner;
const drawn = quadrants[corner]!;
return inner !== undefined && drawn.x === inner.x && drawn.y === inner.y;
});
return `${sides || '·'}${notched.length > 0 ? `-${notched.join(',')}` : ''}`; return `${sides || '·'}${notched.length > 0 ? `-${notched.join(',')}` : ''}`;
} }
+8 -7
View File
@@ -7,12 +7,13 @@ 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 * 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 * 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, * 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`. * `terrain` the 6 × 4 block of the common top-down sheets; their 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 * 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. * corner, so all kinds are drawn the same way: four quarters per tile.
*/ */
export const TILE_KINDS = ['none', 'floor', 'wall'] as const; export const TILE_KINDS = ['none', 'floor', 'wall', 'terrain'] as const;
export type TileKind = (typeof TILE_KINDS)[number]; export type TileKind = (typeof TILE_KINDS)[number];
export const TILE_KIND_KEY = 'kind'; export const TILE_KIND_KEY = 'kind';
@@ -36,11 +37,11 @@ export const TILE_COLLISION_SCALE_MAX = 2;
const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; const TILE_PLAYBACKS = ['loop', 'pingpong'] as const;
export type TilePlayback = (typeof TILE_PLAYBACKS)[number]; export type TilePlayback = (typeof TILE_PLAYBACKS)[number];
/** Quadrant columns of a block: one tile for `none`, two for the autotile blocks. */ /** Quadrant columns of a block: one tile for `none`, two for the RPG-Maker blocks, six for `terrain`. */
const BLOCK_COLUMNS: Record<TileKind, number> = { none: 2, floor: 4, wall: 4 }; const BLOCK_COLUMNS: Record<TileKind, number> = { none: 2, floor: 4, wall: 4, terrain: 12 };
/** Quadrant rows of a block: `none` is one tile tall, `floor` three and `wall` two. */ /** Quadrant rows of a block: `none` is one tile tall, `floor` three, `wall` two and `terrain` four. */
const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4 }; const BLOCK_ROWS: Record<TileKind, number> = { none: 2, floor: 6, wall: 4, terrain: 8 };
export function tileKind(asset: TileAsset): TileKind { export function tileKind(asset: TileAsset): TileKind {
const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none'); const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none');
+1
View File
@@ -368,6 +368,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ value: 'none', label: 'None (1 × 1)' }, { value: 'none', label: 'None (1 × 1)' },
{ value: 'floor', label: 'Floor (2 × 3)' }, { value: 'floor', label: 'Floor (2 × 3)' },
{ value: 'wall', label: 'Wall (2 × 2)' }, { value: 'wall', label: 'Wall (2 × 2)' },
{ value: 'terrain', label: 'Terrain (6 × 4)' },
], ],
}, },
{ key: TILE_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 },
+108
View File
@@ -274,6 +274,114 @@ describe('autotile blocks', () => {
}); });
}); });
/** 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', () => { describe('tile asset', () => {
it('derives the tile size from the block and accepts a whole texture as one', () => { it('derives the tile size from the block and accepts a whole texture as one', () => {
const fixture = makeTile(96, 144); const fixture = makeTile(96, 144);
+84 -2
View File
@@ -16,8 +16,12 @@ const QUAD = 24;
/** /**
* The sheet as a paste: 8 × 10 quarter tiles. Floor blocks at x = 0 and x = 96 (told apart by their * The sheet as a paste: 8 × 10 quarter tiles. Floor blocks at x = 0 and x = 96 (told apart by their
* blue), the wall block below the first, the rest transparent. * blue), the wall block below the first, and in the corner left over a terrain block of 16 px tiles,
* 12 × 8 quarters of 8 px.
*/ */
const TERRAIN_QUAD = 8;
const TERRAIN_AT = { x: QUAD * 4, y: QUAD * 6 };
const PASTE_SHEET = `(async () => { const PASTE_SHEET = `(async () => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = ${QUAD * 8}; canvas.width = ${QUAD * 8};
@@ -32,6 +36,11 @@ const PASTE_SHEET = `(async () => {
g.fillStyle = 'rgb(' + r + ',' + gr + ',' + b + ')'; g.fillStyle = 'rgb(' + r + ',' + gr + ',' + b + ')';
g.fillRect(qx * ${QUAD}, qy * ${QUAD}, ${QUAD}, ${QUAD}); g.fillRect(qx * ${QUAD}, qy * ${QUAD}, ${QUAD}, ${QUAD});
} }
for (let qy = 0; qy < 8; qy++)
for (let qx = 0; qx < 12; qx++) {
g.fillStyle = 'rgb(' + (20 + qx * 20) + ',' + (40 + qy * 25) + ',240)';
g.fillRect(${TERRAIN_AT.x} + qx * ${TERRAIN_QUAD}, ${TERRAIN_AT.y} + qy * ${TERRAIN_QUAD}, ${TERRAIN_QUAD}, ${TERRAIN_QUAD});
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
const transfer = new DataTransfer(); const transfer = new DataTransfer();
transfer.items.add(new File([blob], 'autotiles.png', { type: 'image/png' })); transfer.items.add(new File([blob], 'autotiles.png', { type: 'image/png' }));
@@ -41,7 +50,11 @@ const PASTE_SHEET = `(async () => {
/** The colour a quarter of a block was drawn in – the same formula as the paste above. */ /** The colour a quarter of a block was drawn in – the same formula as the paste above. */
export function quadColour(kind, qx, qy, step = 0) { export function quadColour(kind, qx, qy, step = 0) {
const [r, g, b] = const [r, g, b] =
kind === 'wall' ? [30 + qy * 60, 40 + qx * 40, 20] : [40 + qx * 60, 30 + qy * 40, step ? 200 : 90]; kind === 'terrain'
? [20 + qx * 20, 40 + qy * 25, 240]
: kind === 'wall'
? [30 + qy * 60, 40 + qx * 40, 20]
: [40 + qx * 60, 30 + qy * 40, step ? 200 : 90];
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
} }
@@ -95,6 +108,7 @@ export async function tile(t) {
{ id: 'at-floor-2', name: 'Floor 2', x: 96, 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-wall', name: 'Wall', x: 0, y: 144, w: 96, h: 96 },
{ id: 'at-plain', name: 'Plain', x: 0, y: 0, w: 48, h: 48 }, { id: 'at-plain', name: 'Plain', x: 0, y: 0, w: 48, h: 48 },
{ id: 'at-terrain', name: 'Terrain', x: ${TERRAIN_AT.x}, y: ${TERRAIN_AT.y}, w: 96, h: 64 },
); );
__gm.state.project.assets.push({ __gm.state.project.assets.push({
id: 'at-grass', id: 'at-grass',
@@ -343,6 +357,74 @@ export async function tile(t) {
JSON.stringify(board), JSON.stringify(board),
); );
// 39j. A terrain block: whole tiles where the sheet draws them, composed where it does not.
const terrainAt = await json('__gm.tile.kindPickerAt()');
await t.click(terrainAt.x, terrainAt.y);
await clickText('Terrain (6 × 4)');
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-terrain']");
await settle();
board = await json('__gm.tile.board()');
check(
'a 6 × 4 terrain block gives 47 tiles at 16 px',
board.kind === 'terrain' && board.combinations === 47 && board.tile === 16 && board.problem === '',
JSON.stringify(board),
);
const wholeTile = (tx, ty) => [
[2 * tx, 2 * ty],
[2 * tx + 1, 2 * ty],
[2 * tx, 2 * ty + 1],
[2 * tx + 1, 2 * ty + 1],
];
const terrainLone = await tileReads(t, 0, wholeTile(0, 3), 'terrain');
const terrainInside = await tileReads(
t,
await json('__gm.tile.sampleTile(3, 2)'),
wholeTile(2, 1),
'terrain',
);
// Row 5, column 3 of the sample map has the hole in its north-east and a gap in its south-west.
const terrainPair = await tileReads(
t,
await json('__gm.tile.sampleTile(5, 3)'),
wholeTile(5, 2),
'terrain',
);
check(
'the lone, the surrounded and the two-notch tile are the whole tiles the sheet draws',
terrainLone.ok && terrainInside.ok && terrainPair.ok,
`${terrainLone.detail} | ${terrainInside.detail} | ${terrainPair.detail}`,
);
// An edge with a notch is not in the sheet: the left edge tile with the notch put in, and the
// quarter below the notch brought along from the same notch tile.
const composed = [
[2, 2],
[9, 2],
[2, 3],
[9, 3],
];
let composedIndex = -1;
for (let i = 0; i < board.combinations && composedIndex < 0; i++) {
const quadrants = (await json(`__gm.tile.tile(${i})`))?.quadrants ?? [];
if (JSON.stringify(quadrants.map((q) => [q.x, q.y])) === JSON.stringify(composed)) composedIndex = i;
}
const edgeNotch =
composedIndex < 0
? { ok: false, detail: 'no such tile' }
: await tileReads(t, composedIndex, composed, 'terrain');
check('an edge with a notch is composed from the edge and the notch tile', edgeNotch.ok, edgeNotch.detail);
await shot('39-terrain');
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(
'and 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. // Leave the tab as the rest of the suite expects to find the editor.
await openTab(t, 'scene'); await openTab(t, 'scene');
await evaluate('__gm.state.select(null)'); await evaluate('__gm.state.select(null)');