diff --git a/README.md b/README.md index c04aebe2..49d4dcb2 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/autotile.ts b/src/core/model/autotile.ts index 61433e71..bd87df10 100644 --- a/src/core/model/autotile.ts +++ b/src/core/model/autotile.ts @@ -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 * 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 - * and has none. A plain tile (`none`, #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`. + * and has none. `terrain` is the 6 × 4 block of the common top-down sheets. A plain tile (`none`, + * #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. */ @@ -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 CORNERS: Record = { +const CORNERS: Record, readonly CornerSpec[]> = { none: PLAIN_CORNERS, floor: FLOOR_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` * 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; } +/** 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. */ 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 [ quadrantFor(tl, around.w, around.n, around.nw), 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 * before – so a diagonal is only set where it changes something, and the list starts with the * lone tile. @@ -154,22 +236,17 @@ export function autotileCombinations(kind: TileKind): AutotileCombination[] { const signature = quadrants.map((q) => `${q.x},${q.y}`).join(' '); if (seen.has(signature)) continue; seen.add(signature); - combinations.push({ neighbours, quadrants, label: combinationLabel(neighbours, quadrants, kind) }); + combinations.push({ neighbours, quadrants, label: combinationLabel(neighbours, kind) }); } 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]) .map((side) => side.toUpperCase()) .join(''); - const corners = cornersOf(kind); - const notched = DIAGONALS.filter((_, 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; - }); + const corners = kind === 'floor' || kind === 'terrain' ? notchedCorners(around) : []; + const notched = DIAGONALS.filter((_, index) => corners[NOTCH_ORDER[index]!]); return `${sides || '·'}${notched.length > 0 ? `-${notched.join(',')}` : ''}`; } diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index cf7a96ae..48841b7c 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -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 * 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, - * 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 - * 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 const TILE_KIND_KEY = 'kind'; @@ -36,11 +37,11 @@ export const TILE_COLLISION_SCALE_MAX = 2; const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; export type TilePlayback = (typeof TILE_PLAYBACKS)[number]; -/** Quadrant columns of a block: one tile for `none`, two for the autotile blocks. */ -const BLOCK_COLUMNS: Record = { none: 2, floor: 4, wall: 4 }; +/** Quadrant columns of a block: one tile for `none`, two for the RPG-Maker blocks, six for `terrain`. */ +const BLOCK_COLUMNS: Record = { none: 2, floor: 4, wall: 4, terrain: 12 }; -/** Quadrant rows of a block: `none` is one tile tall, `floor` three and `wall` two. */ -const BLOCK_ROWS: Record = { none: 2, floor: 6, wall: 4 }; +/** Quadrant rows of a block: `none` is one tile tall, `floor` three, `wall` two and `terrain` four. */ +const BLOCK_ROWS: Record = { none: 2, floor: 6, wall: 4, terrain: 8 }; export function tileKind(asset: TileAsset): TileKind { const value = textOf(asset.props[TILE_KIND_KEY] ?? 'none'); diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index d34bca20..f0957368 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -368,6 +368,7 @@ const ASSET_SCHEMAS: Record = { { value: 'none', label: 'None (1 × 1)' }, { value: 'floor', label: 'Floor (2 × 3)' }, { 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 }, diff --git a/tests/core/tile.test.ts b/tests/core/tile.test.ts index 83c27c80..eab1ad80 100644 --- a/tests/core/tile.test.ts +++ b/tests/core/tile.test.ts @@ -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(); + 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); diff --git a/tools/e2e/39-tile.mjs b/tools/e2e/39-tile.mjs index 0aeb0436..18db4ac0 100644 --- a/tools/e2e/39-tile.mjs +++ b/tools/e2e/39-tile.mjs @@ -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 - * 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 canvas = document.createElement('canvas'); canvas.width = ${QUAD * 8}; @@ -32,6 +36,11 @@ const PASTE_SHEET = `(async () => { g.fillStyle = 'rgb(' + r + ',' + gr + ',' + b + ')'; 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 transfer = new DataTransfer(); 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. */ export function quadColour(kind, qx, qy, step = 0) { 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('')}`; } @@ -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-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-terrain', name: 'Terrain', x: ${TERRAIN_AT.x}, y: ${TERRAIN_AT.y}, w: 96, h: 64 }, ); __gm.state.project.assets.push({ id: 'at-grass', @@ -343,6 +357,74 @@ export async function tile(t) { 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. await openTab(t, 'scene'); await evaluate('__gm.state.select(null)');