Autotiles are a new asset type with a tab that shows every tile of the floor (2x3) or wall (2x2) block and an animated sample pattern (#118)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c17b2b5426
commit
1a9f07c4ac
24 files changed
+1797
-18
No files matched your search
@@ -74,7 +74,7 @@ export async function assets(t) {
|
||||
|
||||
// 4c. Create a new asset through the + menu. Since #43 the menu is two alphabetical groups –
|
||||
// what can be placed in a scene, a line, then what only lives in the library – and it is
|
||||
// tall enough to show all fifteen types without scrolling.
|
||||
// tall enough to show all sixteen types without scrolling.
|
||||
await clickText('+');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
const cut = rows.indexOf(null);
|
||||
@@ -87,7 +87,7 @@ export async function assets(t) {
|
||||
);
|
||||
check(
|
||||
'every asset type is offered once, each group sorted by label',
|
||||
placeable.length + library.length === 15 &&
|
||||
placeable.length + library.length === 16 &&
|
||||
String(placeable) === String([...placeable].sort()) &&
|
||||
String(library) === String([...library].sort()),
|
||||
JSON.stringify({ placeable, library }),
|
||||
@@ -156,13 +156,14 @@ export async function assets(t) {
|
||||
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
|
||||
|
||||
// 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it
|
||||
// lives in – every type that has one. The fixture lacks four of them; they go in through
|
||||
// lives in – every type that has one. The fixture lacks five of them; they go in through
|
||||
// the model, empty.
|
||||
await mutate(`__gm.state.project.assets.push(
|
||||
{ id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } },
|
||||
{ id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] },
|
||||
{ id: 'anim-open', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] },
|
||||
{ id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] },
|
||||
{ id: 'at-open', type: 'autotile', name: 'Grass', props: {}, pictures: [] },
|
||||
)`);
|
||||
const openable = [
|
||||
['mover', 'graph', '__gm.graph.current()'],
|
||||
@@ -171,6 +172,7 @@ export async function assets(t) {
|
||||
['tex-open', 'texture', '__gm.texture.current()'],
|
||||
['anim-open', 'animation', '__gm.animation.current()'],
|
||||
['box-open', 'container', '__gm.container.current()'],
|
||||
['at-open', 'autotile', '__gm.autotile.current()'],
|
||||
];
|
||||
for (const [id, tab, shown] of openable) {
|
||||
await showTab('scene');
|
||||
@@ -211,7 +213,7 @@ export async function assets(t) {
|
||||
);
|
||||
await undo();
|
||||
await mutate(
|
||||
`__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open'].includes(a.id))`,
|
||||
`__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open', 'at-open'].includes(a.id))`,
|
||||
);
|
||||
await showTab('scene');
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
import { openTab } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Autotiles (#118). The sheet is drawn here and pasted in like a real picture: two floor blocks
|
||||
* (the two steps of an animation) beside each other and a wall block below them, every quarter tile
|
||||
* in a colour of its own. That is what lets the checks below read a pixel off the board and say
|
||||
* which quarter the editor put into which corner of a tile – the rule the whole feature is, and one
|
||||
* no unit test can see through the renderer.
|
||||
*
|
||||
* Drawn here rather than seeded into the fixture: a texture asset in the fixture is a texture the
|
||||
* texture steps would find first, and they are written for a project that starts without one.
|
||||
*/
|
||||
|
||||
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.
|
||||
*/
|
||||
const PASTE_SHEET = `(async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = ${QUAD * 8};
|
||||
canvas.height = ${QUAD * 10};
|
||||
const g = canvas.getContext('2d');
|
||||
for (let qy = 0; qy < 10; qy++)
|
||||
for (let qx = 0; qx < 8; qx++) {
|
||||
if (qy >= 6 && qx >= 4) continue;
|
||||
const [r, gr, b] = qy < 6
|
||||
? [40 + (qx % 4) * 60, 30 + qy * 40, qx < 4 ? 90 : 200]
|
||||
: [30 + (qy - 6) * 60, 40 + qx * 40, 20];
|
||||
g.fillStyle = 'rgb(' + r + ',' + gr + ',' + b + ')';
|
||||
g.fillRect(qx * ${QUAD}, qy * ${QUAD}, ${QUAD}, ${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' }));
|
||||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||||
})()`;
|
||||
|
||||
/** The colour a quarter of a block was drawn in – the same formula as the paste above. */
|
||||
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];
|
||||
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) {
|
||||
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 }) };
|
||||
}
|
||||
|
||||
export async function autotile(t) {
|
||||
const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, settle, undo } = t;
|
||||
|
||||
await press('Escape');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
|
||||
// 39z. The sheet, the three blocks cut out of it and the autotile drawn from the two floor ones.
|
||||
// Only the picture goes in the normal way (a paste, as in the nine-slice step); the frames
|
||||
// and the asset are written into the model, which is what the unit tests cover.
|
||||
await evaluate(PASTE_SHEET);
|
||||
await sleep(2500);
|
||||
const sheet = await json(
|
||||
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" +
|
||||
' return a ? { id: a.id, w: a.width, h: a.height } : null; })()',
|
||||
);
|
||||
check(
|
||||
'the autotile sheet is imported at its own size',
|
||||
sheet?.w === 192 && sheet?.h === 240,
|
||||
JSON.stringify(sheet),
|
||||
);
|
||||
await mutate(`
|
||||
const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet.id)});
|
||||
sheet.name = 'Autotiles';
|
||||
sheet.props.filter = 'nearest';
|
||||
sheet.frames.push(
|
||||
{ id: 'at-floor-1', name: 'Floor 1', x: 0, 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 },
|
||||
);
|
||||
__gm.state.project.assets.push({
|
||||
id: 'at-grass',
|
||||
type: 'autotile',
|
||||
name: 'Grass',
|
||||
props: { kind: 'floor', fps: 4, playback: 'loop' },
|
||||
pictures: ['at-floor-1', 'at-floor-2'],
|
||||
});`);
|
||||
|
||||
// 39a. Opening the autotile asset opens its tab, with the board built for a floor block.
|
||||
await evaluate("__gm.state.openAsset('at-grass')");
|
||||
await settle();
|
||||
let board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'opening an autotile shows the Autotile tab with the asset in it',
|
||||
(await json('__gm.tab()')) === 'autotile' && board.assetId === 'at-grass',
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), board }),
|
||||
);
|
||||
check(
|
||||
'a floor block gives 47 tiles at 48 px and nothing to complain about',
|
||||
board.kind === 'floor' && board.combinations === 47 && board.tile === 48 && board.problem === '',
|
||||
JSON.stringify(board),
|
||||
);
|
||||
check(
|
||||
'the readout and the picture list name the two steps',
|
||||
(await json('__gm.autotile.info()')).includes('2 pictures') &&
|
||||
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
|
||||
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
|
||||
JSON.stringify({
|
||||
info: await json('__gm.autotile.info()'),
|
||||
strip: await json('__gm.autotile.pictures()'),
|
||||
}),
|
||||
);
|
||||
|
||||
// 39b. The pixels. While the animation runs the picture under the pointer changes, so it is cut
|
||||
// down to one step for the reading and put back afterwards.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
|
||||
await settle();
|
||||
const lone = await tileReads(
|
||||
t,
|
||||
0,
|
||||
[
|
||||
[0, 2],
|
||||
[3, 2],
|
||||
[0, 5],
|
||||
[3, 5],
|
||||
],
|
||||
'floor',
|
||||
);
|
||||
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.
|
||||
const surrounded = await json('__gm.autotile.sampleTile(3, 2)');
|
||||
const inside = await tileReads(
|
||||
t,
|
||||
surrounded,
|
||||
[
|
||||
[1, 3],
|
||||
[2, 3],
|
||||
[1, 4],
|
||||
[2, 4],
|
||||
],
|
||||
'floor',
|
||||
);
|
||||
check('a surrounded tile of the sample map is drawn from its inside', inside.ok, inside.detail);
|
||||
await shot('39-autotile');
|
||||
|
||||
// 39c. The animation: two pictures at 4 fps step through on their own, and the picture decides
|
||||
// the colours on screen.
|
||||
await mutate(
|
||||
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
|
||||
);
|
||||
await settle();
|
||||
const seen = new Set();
|
||||
for (let i = 0; i < 20 && seen.size < 2; i++) {
|
||||
seen.add((await json('__gm.autotile.board()')).picture);
|
||||
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,
|
||||
[
|
||||
[0, 2],
|
||||
[3, 2],
|
||||
[0, 5],
|
||||
[3, 5],
|
||||
],
|
||||
'floor',
|
||||
step,
|
||||
);
|
||||
check('and the step it stands on is the one drawn', animated.ok, `${step}: ${animated.detail}`);
|
||||
|
||||
// 39d. Fit, pan and zoom.
|
||||
await t.click(...Object.values(await json('__gm.autotile.fitAt()')));
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
const fitted = { zoom: board.zoom, pan: board.pan };
|
||||
const centre = { x: board.rect.x + board.rect.w / 2, y: board.rect.y + board.rect.h - 40 };
|
||||
await drag(centre.x, centre.y, centre.x + 60, centre.y - 40);
|
||||
board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'dragging on the board pans it',
|
||||
Math.round(board.pan.x - fitted.pan.x) === 60 && Math.round(board.pan.y - fitted.pan.y) === -40,
|
||||
JSON.stringify({ fitted, now: board.pan }),
|
||||
);
|
||||
await wheel(centre.x, centre.y, -300);
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
check('the wheel zooms it', board.zoom > fitted.zoom, JSON.stringify({ fitted, now: board.zoom }));
|
||||
await t.click(...Object.values(await json('__gm.autotile.fitAt()')));
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'Fit frames the board as before',
|
||||
Math.abs(board.zoom - fitted.zoom) < 1e-6 && Math.abs(board.pan.x - fitted.pan.x) < 1e-6,
|
||||
JSON.stringify({ fitted, now: { zoom: board.zoom, pan: board.pan } }),
|
||||
);
|
||||
|
||||
// 39e. The kind: the same pictures are no wall block, and the tab says so instead of drawing
|
||||
// something wrong.
|
||||
const kindAt = await json('__gm.autotile.kindPickerAt()');
|
||||
await t.click(kindAt.x, kindAt.y);
|
||||
await clickText('Wall (2 × 2)');
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'switching the kind to Wall rejects the floor blocks with a reason',
|
||||
board.kind === 'wall' && board.tiles === 0 && board.problem.includes('wall block'),
|
||||
JSON.stringify(board),
|
||||
);
|
||||
|
||||
// 39f. Its own block, though, is one: the two floor pictures out, the wall picture in.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const row = await json('__gm.autotile.pictureRowAt(0)');
|
||||
await t.click(row.x, row.y);
|
||||
const remove = await json('__gm.autotile.removePictureAt()');
|
||||
await t.click(remove.x, remove.y);
|
||||
await settle();
|
||||
}
|
||||
board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'an autotile without a picture says what it is missing',
|
||||
board.pictures === 0 && board.problem.includes('No picture yet'),
|
||||
JSON.stringify(board),
|
||||
);
|
||||
const addAt = await json('__gm.autotile.addPictureAt()');
|
||||
await t.click(addAt.x, addAt.y);
|
||||
await clickText('Autotiles / Wall');
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'a wall block gives the 16 tiles a wall has',
|
||||
board.combinations === 16 && board.tile === 48 && board.problem === '',
|
||||
JSON.stringify(board),
|
||||
);
|
||||
const wall = await tileReads(
|
||||
t,
|
||||
0,
|
||||
[
|
||||
[0, 0],
|
||||
[3, 0],
|
||||
[0, 3],
|
||||
[3, 3],
|
||||
],
|
||||
'wall',
|
||||
);
|
||||
check('and its lone tile is drawn from its own four corners', wall.ok, wall.detail);
|
||||
|
||||
// 39g. Everything since the kind switch is one undo step each, and the floor autotile comes back.
|
||||
for (let i = 0; i < 4; i++) await undo();
|
||||
await settle();
|
||||
board = await json('__gm.autotile.board()');
|
||||
check(
|
||||
'undo puts the pictures and the kind back',
|
||||
board.kind === 'floor' && board.combinations === 47 && board.pictures === 2 && board.problem === '',
|
||||
JSON.stringify(board),
|
||||
);
|
||||
|
||||
// 39h. Dragging a row of the picture list reorders the animation.
|
||||
const first = await json('__gm.autotile.pictureRowAt(0)');
|
||||
const second = await json('__gm.autotile.pictureRowAt(1)');
|
||||
await drag(first.x, first.y, second.x, second.y + 6);
|
||||
await settle();
|
||||
check(
|
||||
'dragging a picture below the other reorders the steps',
|
||||
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
|
||||
JSON.stringify(['Autotiles / Floor 2', 'Autotiles / Floor 1']),
|
||||
JSON.stringify(await json('__gm.autotile.pictures()')),
|
||||
);
|
||||
await undo();
|
||||
await settle();
|
||||
check(
|
||||
'and undo puts the order back',
|
||||
JSON.stringify((await json('__gm.autotile.pictures()')).pictures) ===
|
||||
JSON.stringify(['Autotiles / Floor 1', 'Autotiles / Floor 2']),
|
||||
JSON.stringify(await json('__gm.autotile.pictures()')),
|
||||
);
|
||||
|
||||
// Leave the tab as the rest of the suite expects to find the editor.
|
||||
await openTab(t, 'scene');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import { loadScene } from './35-load-scene.mjs';
|
||||
import { graphGroups } from './36-graph-groups.mjs';
|
||||
import { nineSlice } from './37-nine-slice.mjs';
|
||||
import { plugins } from './38-plugins.mjs';
|
||||
import { autotile } from './39-autotile.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -81,4 +82,5 @@ export const STEPS = [
|
||||
graphGroups,
|
||||
nineSlice,
|
||||
plugins,
|
||||
autotile,
|
||||
];
|
||||
@@ -109,6 +109,7 @@ export const TAB_LABELS = {
|
||||
shader: 'Shader',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
particles: 'Particles',
|
||||
|
||||
Reference in new issue
Block a user