426 lines
18 KiB
JavaScript
426 lines
18 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { LANES, openTab, pickAssetType } from './lib/scaffold.mjs';
|
||
import { quadColour } from './39-tile.mjs';
|
||
|
||
/**
|
||
* Tilemaps (#128): a placed grid painted with the tiles of step 39, which is where the sheet
|
||
* and the `at-grass` tile come from – this step needs it and is run after it.
|
||
*
|
||
* The point of the checks below is that a painted cell really shows the quarters its neighbours
|
||
* call for: they read pixels off the scene view, because everything between the model and the
|
||
* screen (the uvs, the mesh, the transform of the placed object) is exactly what a unit test
|
||
* cannot see through.
|
||
*/
|
||
|
||
const LANE = LANES.tilemap;
|
||
const MAP_OBJECT = 'tm-object';
|
||
const COLUMNS = 6;
|
||
const ROWS = 4;
|
||
|
||
/**
|
||
* A widget of the painting section, scrolled into the inspector first. The section sits below the
|
||
* rows of the object, so on this window it starts out below the panel – and a click on a point
|
||
* outside the panel lands on whatever is drawn there instead.
|
||
*/
|
||
async function inInspector(t, hook) {
|
||
const anchor = await t.json("__gm.inspectorField('Columns')");
|
||
for (let i = 0; i < 15; i++) {
|
||
const rect = await t.json('__gm.inspectorRect()');
|
||
const point = await t.json(hook);
|
||
if (point && point.y > rect.y + 8 && point.y < rect.y + rect.h - 8) return point;
|
||
await t.wheel(anchor.x, anchor.y, 200);
|
||
await t.settle();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Clicks such a widget; returns false when it could not be brought into view. */
|
||
async function clickInInspector(t, hook) {
|
||
const point = await inInspector(t, hook);
|
||
if (point) await t.click(point.x, point.y);
|
||
return Boolean(point);
|
||
}
|
||
|
||
/** The four quarters of the cell, read in one call: a running animation must not step between them. */
|
||
async function cellQuarters(t, x, y) {
|
||
const centre = await t.json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`);
|
||
if (!centre) return null;
|
||
// A quarter of a 48 px cell is 24 px, so these four points sit in the middle of each quarter.
|
||
const points = [
|
||
[centre.x - 12, centre.y - 12],
|
||
[centre.x + 12, centre.y - 12],
|
||
[centre.x - 12, centre.y + 12],
|
||
[centre.x + 12, centre.y + 12],
|
||
].map(([px, py]) => [Math.round(px), Math.round(py)]);
|
||
return t.json(`${JSON.stringify(points)}.map(([x, y]) => __gm.pixelAt(x, y))`);
|
||
}
|
||
|
||
/** Whether a cell is drawn from these quadrants of the block, by the colours they were drawn in. */
|
||
async function cellShows(t, x, y, quadrants) {
|
||
const read = await cellQuarters(t, x, y);
|
||
if (!read) return { ok: false, detail: 'the cell is not on screen' };
|
||
const want = quadrants.map(([qx, qy]) => quadColour('floor', qx, qy));
|
||
const off = read.filter((colour, i) => colourDistance(colour, want[i]) > 12);
|
||
return { ok: off.length === 0, detail: JSON.stringify({ read, want }) };
|
||
}
|
||
|
||
export async function tilemap(t) {
|
||
const { check, json, evaluate, mutate, click, clickText, drag, press, shot, settle, undo, select } = t;
|
||
const { saved, api, navigate, projectId } = t;
|
||
|
||
await press('Escape');
|
||
await evaluate('__gm.state.select(null)');
|
||
await openTab(t, 'scene');
|
||
|
||
// 40a. The asset is made the way any asset is made: through the + menu of the asset panel.
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Tilemap');
|
||
await settle();
|
||
const made = await json(
|
||
"(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'tilemap').at(-1);" +
|
||
' return a ? { id: a.id, name: a.name, layers: a.layers.length } : null; })()',
|
||
);
|
||
check(
|
||
'the + menu makes a tilemap, and it starts with one layer',
|
||
made?.name === 'Tilemap' && made.layers === 1,
|
||
JSON.stringify(made),
|
||
);
|
||
if (!made) return;
|
||
|
||
// A small grid in a lane of its own: the origin is crowded by the objects of earlier steps, and
|
||
// a 20 × 15 map at 48 px would cover half of them.
|
||
await mutate(`
|
||
const project = __gm.state.project;
|
||
const map = project.assets.find((a) => a.id === ${JSON.stringify(made.id)});
|
||
map.props.columns = ${COLUMNS};
|
||
map.props.rows = ${ROWS};
|
||
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(MAP_OBJECT)}, assetId: ${JSON.stringify(made.id)},
|
||
name: 'Ground', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
|
||
overrides: {}, parentId: '', instanceEdits: {} });
|
||
__gm.lookAt(0, ${LANE});`);
|
||
await settle();
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||
await settle();
|
||
|
||
// 40b. The inspector paints: a tool, the tiles of the project, the layers.
|
||
const section = await json(
|
||
'(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.tileButtonAt("at-grass") !== null,' +
|
||
' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()',
|
||
);
|
||
check(
|
||
'a selected tilemap offers tools, tiles and layers in the inspector',
|
||
section.tools && section.grass && Boolean(section.layers),
|
||
JSON.stringify(section),
|
||
);
|
||
// The tiles are a grid of pictures (#139), and each shows its block's lone tile: the four convex
|
||
// corners, read off the palette in one call like a cell of the map.
|
||
await inInspector(t, '__gm.tilemap.tileButtonAt("at-grass")');
|
||
const swatch = await json(`(() => {
|
||
const s = __gm.tilemap.tileSwatch("at-grass");
|
||
if (!s) return null;
|
||
const q = s.picture.w / 4;
|
||
const points = [[q, q], [3 * q, q], [q, 3 * q], [3 * q, 3 * q]]
|
||
.map(([x, y]) => [Math.round(s.picture.x + x), Math.round(s.picture.y + y)]);
|
||
return { columns: s.columns, read: points.map(([x, y]) => __gm.pixelAt(x, y)) };
|
||
})()`);
|
||
const lone = [
|
||
[0, 2],
|
||
[3, 2],
|
||
[0, 5],
|
||
[3, 5],
|
||
].map(([qx, qy]) => quadColour('floor', qx, qy));
|
||
check(
|
||
'the palette shows a tile as its picture, the lone tile of the block',
|
||
swatch !== null && swatch.read.every((colour, i) => colourDistance(colour, lone[i]) <= 12),
|
||
JSON.stringify({ swatch, want: lone }),
|
||
);
|
||
check(
|
||
'and puts several tiles side by side in the inspector',
|
||
swatch !== null && swatch.columns >= 3,
|
||
JSON.stringify(swatch),
|
||
);
|
||
// Clicked through the hook rather than by its text: the tile's own row in the asset panel
|
||
// reads "Grass" as well, and a click there would only select the tile.
|
||
check(
|
||
'the tile can be picked in the section',
|
||
await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")'),
|
||
JSON.stringify(await json('__gm.inspectorRect()')),
|
||
);
|
||
await settle();
|
||
const paint = await json('__gm.tilemap.paint()');
|
||
check(
|
||
'picking a tile reaches for the brush by itself',
|
||
paint.tileId === 'at-grass' && paint.tool === 'brush',
|
||
JSON.stringify(paint),
|
||
);
|
||
|
||
// 40c. Painting: a drag across the map paints every cell it passes, in one undo step.
|
||
const scene = await json(
|
||
`(() => ({ tab: __gm.tab(), rect: __gm.objectRect(${JSON.stringify(MAP_OBJECT)}),` +
|
||
' selection: __gm.state.selection, paint: __gm.tilemap.paint() }))()',
|
||
);
|
||
check(
|
||
'the map is on screen in the scene tab, selected, with a brush in hand',
|
||
scene.tab === 'scene' && Boolean(scene.rect) && scene.paint.tool === 'brush',
|
||
JSON.stringify(scene),
|
||
);
|
||
const from = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`);
|
||
const to = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 3, 1)`);
|
||
await drag(from.x, from.y, to.x, to.y);
|
||
await settle();
|
||
const layerId = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[0].id;
|
||
const painted = () => json(`__gm.tilemap.cells(${JSON.stringify(made.id)}, ${JSON.stringify(layerId)})`);
|
||
let cells = await painted();
|
||
check(
|
||
'dragging the brush paints every cell along the way',
|
||
Object.keys(cells).sort().join(' ') === '1,1 2,1 3,1',
|
||
JSON.stringify(cells),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
check(
|
||
'and the whole stroke is one undo step',
|
||
Object.keys(await painted()).length === 0,
|
||
JSON.stringify(await painted()),
|
||
);
|
||
|
||
// 40d. The rectangle tool fills the box a drag spans.
|
||
await clickInInspector(t, '__gm.tilemap.toolAt("rect")');
|
||
await settle();
|
||
const corner = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
const far = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`);
|
||
await drag(corner.x, corner.y, far.x, far.y);
|
||
await settle();
|
||
cells = await painted();
|
||
check(
|
||
'the rectangle tool fills the box between the two corners',
|
||
Object.keys(cells).length === 9 && cells['1,1'] === 'at-grass' && cells['2,2'] === 'at-grass',
|
||
JSON.stringify(Object.keys(cells).sort()),
|
||
);
|
||
|
||
// 40e. The pixels. While the animation runs the block under the pointer changes between the two
|
||
// steps, so it is cut down to one for the reading and put back afterwards (as step 39 does).
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
|
||
await settle();
|
||
// The rest of 40e: the middle of a 3 × 3 block is surrounded, so all four of its quarters come
|
||
// from the inside of the block – taken turned by half a tile, as RPG Maker does it. The
|
||
// corner cell has neighbours east and south only, so it shows the convex corner up left.
|
||
const middle = await cellShows(t, 1, 1, [
|
||
[2, 4],
|
||
[1, 4],
|
||
[2, 3],
|
||
[1, 3],
|
||
]);
|
||
check('a cell surrounded by its own kind is drawn from the inside of the block', middle.ok, middle.detail);
|
||
const corner00 = await cellShows(t, 0, 0, [
|
||
[0, 2],
|
||
[1, 2],
|
||
[0, 3],
|
||
[1, 3],
|
||
]);
|
||
check('and the top-left cell of the block shows the convex corner there', corner00.ok, corner00.detail);
|
||
await shot('40-tilemap');
|
||
await mutate(
|
||
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
|
||
);
|
||
await settle();
|
||
|
||
// 40f. One mesh per tile, not one per cell – the whole point of drawing it as a mesh.
|
||
const drawn = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`);
|
||
check(
|
||
'nine cells of one tile are one draw call',
|
||
drawn.tiles === 9 && drawn.drawCalls === 1 && drawn.layers === 1,
|
||
JSON.stringify(drawn),
|
||
);
|
||
|
||
// 40g. Erasing takes cells out again.
|
||
await clickInInspector(t, '__gm.tilemap.toolAt("erase")');
|
||
await settle();
|
||
const one = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 2)`);
|
||
await click(one.x, one.y);
|
||
await settle();
|
||
cells = await painted();
|
||
check(
|
||
'the eraser clears the cell it is clicked on and leaves the rest',
|
||
Object.keys(cells).length === 8 && cells['2,2'] === undefined,
|
||
JSON.stringify(Object.keys(cells).sort()),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
|
||
// 40h. A second layer draws above the first, and hiding it leaves its cells alone.
|
||
await clickInInspector(t, '__gm.tilemap.addLayerAt()');
|
||
await settle();
|
||
let layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
check(
|
||
'Add layer appends a second layer, on top of the first',
|
||
layers.length === 2 && layers[1].cells === 0,
|
||
JSON.stringify(layers),
|
||
);
|
||
await clickInInspector(t, '__gm.tilemap.tileButtonAt("at-grass")');
|
||
await settle();
|
||
const above = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 4, 0)`);
|
||
await click(above.x, above.y);
|
||
await settle();
|
||
layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
check(
|
||
'painting goes onto the chosen layer, not the first one',
|
||
layers[0].cells === 9 && layers[1].cells === 1,
|
||
JSON.stringify(layers),
|
||
);
|
||
// Hiding the upper layer (the list is drawn top-down, newest first) leaves its cell in the model
|
||
// and takes it out of the drawing.
|
||
const row = await inInspector(t, '__gm.tilemap.layerRowAt(0)');
|
||
if (row) await click(row.visible.x, row.visible.y);
|
||
await settle();
|
||
const hidden = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
const drawnNow = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`);
|
||
check(
|
||
'switching a layer off keeps its cells and takes it out of the drawing',
|
||
hidden[1].hidden === true && hidden[1].cells === 1 && drawnNow.layers === 1 && drawnNow.tiles === 9,
|
||
JSON.stringify({ hidden, drawnNow }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
|
||
// 40i. Back to Select: the map is moved again instead of painted on.
|
||
await clickInInspector(t, '__gm.tilemap.toolAt("select")');
|
||
await settle();
|
||
const before = (await t.object(MAP_OBJECT)).position;
|
||
const grab = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`);
|
||
await drag(grab.x, grab.y, grab.x + 40, grab.y);
|
||
await settle();
|
||
const after = (await t.object(MAP_OBJECT)).position;
|
||
cells = await painted();
|
||
check(
|
||
'with Select the map is dragged like any other object and nothing is painted',
|
||
Math.round(after.x - before.x) === 40 && Object.keys(cells).length === 9,
|
||
JSON.stringify({ before, after, cells: Object.keys(cells).length }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
|
||
// 40j. The running game draws the same map: it is scene content, not editor decoration. The map
|
||
// is moved in front of the camera for this, because an object off screen is culled there.
|
||
await mutate(`
|
||
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)});
|
||
o.position = { x: 0, y: 0, z: 0 };`);
|
||
await settle();
|
||
await t.play();
|
||
await settle();
|
||
const game = await json(`__gm.tilemap.gameInfo(${JSON.stringify(MAP_OBJECT)})`);
|
||
check(
|
||
'the map is drawn in the running game as well',
|
||
game && game.tiles === 10 && game.layers === 2,
|
||
JSON.stringify(game),
|
||
);
|
||
await t.stop();
|
||
await settle();
|
||
await mutate(`
|
||
const o = __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)});
|
||
o.position = { x: 0, y: ${LANE}, z: 0 };`);
|
||
|
||
// 40k. The file holds the cells compactly (#134): per layer the tiles once and a string of runs
|
||
// per row, no key per cell. A reload reads them back into exactly the same cells.
|
||
const inMemory = await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells)`,
|
||
);
|
||
await saved();
|
||
const storedMap = (await api(`projects/${projectId}`)).project.assets.find((a) => a.id === made.id);
|
||
const storedLayers = storedMap?.layers ?? [];
|
||
check(
|
||
'the saved map stores each layer as its tiles and a run string per row, not a key per cell',
|
||
storedLayers.length === 2 &&
|
||
storedLayers.every((l) => l.cells === undefined && Array.isArray(l.rows)) &&
|
||
storedLayers[0].tiles.join() === 'at-grass' &&
|
||
storedLayers[0].rows.every((r) => typeof r === 'string'),
|
||
JSON.stringify(storedLayers.map(({ tiles, origin, rows }) => ({ tiles, origin, rows }))),
|
||
);
|
||
await navigate();
|
||
const reloaded = await json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => l.cells)`,
|
||
);
|
||
const sorted = (layers) => JSON.stringify(layers.map((c) => Object.entries(c).sort()));
|
||
check(
|
||
'a reloaded project has exactly the cells it was saved with',
|
||
sorted(reloaded) === sorted(inMemory) && Object.keys(inMemory[0]).length === 9,
|
||
JSON.stringify({ reloaded, inMemory }),
|
||
);
|
||
|
||
// 40l. Collision (#132). The switch sits on the tile, and the map outlines where it now blocks.
|
||
// The map holds a 3 × 3 block and, on the second layer, one cell at (4, 0).
|
||
await openTab(t, 'scene');
|
||
await select({ kind: 'asset', assetId: 'at-grass' });
|
||
const solidRow = await json("__gm.inspectorField('Solid')");
|
||
if (solidRow) await click(solidRow.x, solidRow.y);
|
||
await settle();
|
||
check(
|
||
'a tile has a Solid switch in the inspector, and clicking it makes the tile solid',
|
||
Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true,
|
||
JSON.stringify({ solidRow, props: (await t.asset('at-grass'))?.props }),
|
||
);
|
||
await mutate(`__gm.lookAt(0, ${LANE})`);
|
||
await settle();
|
||
const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`);
|
||
check(
|
||
'the scene view outlines the blocking cells, merged into rectangles',
|
||
outlined?.visible === true &&
|
||
JSON.stringify(outlined.rects) ===
|
||
JSON.stringify([
|
||
{ x: 0, y: 0, w: 3, h: 3 },
|
||
{ x: 4, y: 0, w: 1, h: 1 },
|
||
]),
|
||
JSON.stringify(outlined),
|
||
);
|
||
await shot('40-tilemap-solid');
|
||
|
||
// In the game a crate of its own walks into the block: the seeded Crate sprite with a 100 × 60
|
||
// collider and the Mover (ArrowRight moves its owner, colliding with solids) below it. Its own
|
||
// rather than the seeded red crate, which an earlier step of a full run may have removed. It
|
||
// starts at −200 with 30 units of free travel: the map sits so that the block's face is at −120.
|
||
const mapBefore = (await t.object(MAP_OBJECT)).position;
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const object = (id, assetId, parentId, position, overrides) => ({ id, assetId, name: '', position,
|
||
editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} });
|
||
scene.objects.push(
|
||
object('tm-crate', 'crate', '', { x: -200, y: ${LANE}, z: 0 }, {}),
|
||
object('tm-crate-collider', 'collider', 'tm-crate', { x: 0, y: 0, z: 0 },
|
||
{ shape: 'box', width: 100, height: 60, isTrigger: false }),
|
||
object('tm-crate-mover', 'mover', 'tm-crate', { x: 0, y: 0, z: 0 }, {}));
|
||
scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = { x: 24, y: ${LANE + 24}, z: 0 };
|
||
__gm.state.select(null)`);
|
||
await settle();
|
||
const playAgainstMap = async () => {
|
||
await t.play();
|
||
await t.holdKey('ArrowRight', 700);
|
||
const crate = await t.entity('tm-crate');
|
||
await t.stop();
|
||
await settle();
|
||
return crate;
|
||
};
|
||
const stopped = await playAgainstMap();
|
||
check(
|
||
'Move with "Collide with solids" stops the crate flush against the solid cells',
|
||
stopped && Math.abs(stopped.x - -170) < 0.01,
|
||
`world x=${stopped?.x}`,
|
||
);
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').props.solid = false");
|
||
const passed = await playAgainstMap();
|
||
check(
|
||
'a tile that is not solid lets the crate walk over it',
|
||
passed && passed.x > -170 + 20,
|
||
`world x=${passed?.x}`,
|
||
);
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('tm-crate'));
|
||
scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = ${JSON.stringify(mapBefore)};`);
|
||
await settle();
|
||
|
||
// Leave the editor as the rest of the suite expects to find it.
|
||
await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })");
|
||
await evaluate('__gm.state.select(null)');
|
||
await mutate('__gm.lookAt(0, 0)');
|
||
}
|