f4cc681 fills tilemap solids only for the selected map and layer, and
texture collision only for the selected frame. Neither the scene view
wiring nor the texture canvas had a test for that.
- __gm.tilemap.solids() now reports the cells that were filled, and
__gm.texture.drawInfo() counts the filled outlines.
- e2e 40l-iii: a selected tile fills nothing. The map selected with
the upper layer fills only cell (4,0), with the lower layer only the
3x3 block. Two selected objects fill nothing again.
- e2e 42: a selected frame fills its one outline. Deselected it fills
none, while the outline stays visible.
- README check total goes from 1375 to 1377. CI confirmed 1375, the
new total is counted, not yet measured.
Tests:
- ./check.sh: 159 files, 2337 tests, CHECK EXIT 0
- e2e 39-43: ALL PASSED, 147 checks (before 145)
- Counter checks: a scene view that fills every map fails only the
tilemap check. A texture canvas that fills every frame fails only
the texture check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1244 lines
55 KiB
JavaScript
1244 lines
55 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { colourDistance } from '../lib/harness.mjs';
|
||
import { LANES, MAKE_OBJECT, 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 tilemap panel (#142), scrolled into view first. On a short window the layers sit
|
||
* below the fold – and a click on a point outside the panel lands on whatever is drawn there instead.
|
||
*/
|
||
async function inPanel(t, hook) {
|
||
for (let i = 0; i < 15; i++) {
|
||
const rect = (await t.json('__gm.tilemap.panel()')).panel;
|
||
if (!rect) return null;
|
||
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(rect.x + rect.w / 2, rect.y + rect.h / 2, point && point.y < rect.y ? -200 : 200);
|
||
await t.settle();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Clicks such a widget; returns false when it could not be brought into view. */
|
||
async function clickInPanel(t, hook) {
|
||
const point = await inPanel(t, hook);
|
||
if (point) await t.click(point.x, point.y);
|
||
return Boolean(point);
|
||
}
|
||
|
||
/**
|
||
* The − or + button of an edge row in the inspector's GRID EDGES block (#136), scrolled into view
|
||
* first. Both glyphs are on screen many times over, so the button is the one on the label's line.
|
||
*/
|
||
async function edgeButton(t, label, glyph) {
|
||
for (let i = 0; i < 15; i++) {
|
||
const rect = await t.json('__gm.inspectorRect()');
|
||
const within = (p) => p.x > rect.x && p.x < rect.x + rect.w;
|
||
const row = (await t.json(`__gm.locateAll(${JSON.stringify(label)})`)).find(within);
|
||
if (row && row.y > rect.y + 8 && row.y < rect.y + rect.h - 8) {
|
||
const buttons = (await t.json(`__gm.locateAll(${JSON.stringify(glyph)})`)).filter(
|
||
(p) => within(p) && Math.abs(p.y - row.y) < 6 && p.x > row.x,
|
||
);
|
||
return buttons[0] ?? null;
|
||
}
|
||
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, row && row.y < rect.y ? -200 : 200);
|
||
await t.settle();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Every 0.2 s: puts a still tile into cell 2,1 of its owner, a placed tilemap, logs what `Tile at`
|
||
* reads there, then turns the cell's middle into world units and back and logs the cell it lands in.
|
||
* A timer rather than On start, so the cell changes after the views have drawn the map once.
|
||
*/
|
||
const tileGraph = {
|
||
nodes: [
|
||
node('tl-start', 'OnTimer', 0, 0, { interval: 0.2 }),
|
||
node('tl-owner', 'Owner', 0, 200),
|
||
node('tl-set', 'SetTile', 300, 0, { cellX: 2, cellY: 1, tile: 'tl-still' }),
|
||
node('tl-at', 'TileAt', 300, 350, { cellX: 2, cellY: 1 }),
|
||
node('tl-log-tile', 'Log', 650, 0),
|
||
node('tl-middle', 'CellToWorld', 300, 550, { cellX: 2, cellY: 1 }),
|
||
node('tl-cell', 'WorldToCell', 650, 550),
|
||
node('tl-log-x', 'Log', 950, 0),
|
||
node('tl-log-y', 'Log', 1250, 0),
|
||
],
|
||
edges: [
|
||
edge('tl-e1', 'tl-start', 'exec', 'tl-set', 'exec'),
|
||
edge('tl-e2', 'tl-owner', 'object', 'tl-set', 'tilemap'),
|
||
edge('tl-e3', 'tl-owner', 'object', 'tl-at', 'tilemap'),
|
||
edge('tl-e4', 'tl-owner', 'object', 'tl-middle', 'tilemap'),
|
||
edge('tl-e5', 'tl-owner', 'object', 'tl-cell', 'tilemap'),
|
||
edge('tl-e6', 'tl-set', 'exec', 'tl-log-tile', 'exec'),
|
||
edge('tl-e7', 'tl-at', 'tile', 'tl-log-tile', 'message'),
|
||
edge('tl-e8', 'tl-middle', 'x', 'tl-cell', 'x'),
|
||
edge('tl-e9', 'tl-middle', 'y', 'tl-cell', 'y'),
|
||
edge('tl-e10', 'tl-log-tile', 'exec', 'tl-log-x', 'exec'),
|
||
edge('tl-e11', 'tl-cell', 'cellX', 'tl-log-x', 'message'),
|
||
edge('tl-e12', 'tl-log-x', 'exec', 'tl-log-y', 'exec'),
|
||
edge('tl-e13', 'tl-cell', 'cellY', 'tl-log-y', 'message'),
|
||
],
|
||
};
|
||
|
||
/** Whether a box lies inside another, give or take a pixel of rounding. */
|
||
function inside(inner, outer) {
|
||
return (
|
||
inner.x >= outer.x - 1 &&
|
||
inner.y >= outer.y - 1 &&
|
||
inner.x + inner.w <= outer.x + outer.w + 1 &&
|
||
inner.y + inner.h <= outer.y + outer.h + 1
|
||
);
|
||
}
|
||
|
||
/** 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 }) };
|
||
}
|
||
|
||
/** `over` laid at `alpha` on `under`, both `#rrggbb` as `pixelAt` reads them. */
|
||
function blend(over, under, alpha) {
|
||
const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
|
||
return `#${[1, 3, 5]
|
||
.map((i) => Math.round(channel(over, i) * alpha + channel(under, i) * (1 - alpha)))
|
||
.map((c) => c.toString(16).padStart(2, '0'))
|
||
.join('')}`;
|
||
}
|
||
|
||
export async function tilemap(t) {
|
||
const {
|
||
check,
|
||
json,
|
||
evaluate,
|
||
mutate,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
press,
|
||
typeText,
|
||
shot,
|
||
settle,
|
||
undo,
|
||
redo,
|
||
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();
|
||
const unselected = await json('__gm.tilemap.panel()');
|
||
const cellUnselected = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||
await settle();
|
||
|
||
// 40b. Painting has a panel of its own beside the scene view (#142): tools, tiles and layers.
|
||
const panel = await json('__gm.tilemap.panel()');
|
||
const inspectorRect = await json('__gm.inspectorRect()');
|
||
check(
|
||
'selecting a placed tilemap opens the tilemap panel between the scene view and the inspector',
|
||
!unselected.shown &&
|
||
panel.shown &&
|
||
panel.title === 'Tilemap · Tilemap' &&
|
||
Math.abs(panel.panel.w - inspectorRect.w) <= 1 &&
|
||
panel.viewport.x + panel.viewport.w <= panel.panel.x &&
|
||
panel.panel.x + panel.panel.w <= inspectorRect.x &&
|
||
Math.abs(panel.panel.y - panel.viewport.y) <= 1 &&
|
||
Math.abs(panel.panel.h - panel.viewport.h) <= 1 &&
|
||
panel.viewport.w < unselected.viewport.w,
|
||
JSON.stringify({ unselected, panel, inspectorRect }),
|
||
);
|
||
const section = await json(
|
||
'(() => ({ tools: __gm.tilemap.toolAt("brush") !== null, grass: __gm.tilemap.tileButtonAt("at-grass") !== null,' +
|
||
' layers: (__gm.tilemap.layerRowAt(0) || {}).id }))()',
|
||
);
|
||
// The view keeps the world where it is on screen, rather than its centre, which shifted the map
|
||
// by half the panel's width.
|
||
const cellWithPanel = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
check(
|
||
'the map stays put on screen when the panel opens',
|
||
Math.abs(cellWithPanel.x - cellUnselected.x) <= 1 && Math.abs(cellWithPanel.y - cellUnselected.y) <= 1,
|
||
JSON.stringify({ cellUnselected, cellWithPanel }),
|
||
);
|
||
// Staying put leaves the right of the map behind the panel; the steps below paint across it.
|
||
await evaluate(`__gm.lookAt(0, ${LANE})`);
|
||
await settle();
|
||
// Found by its title rather than a hook: whatever is left of the section in the inspector would
|
||
// have no hook pointing at it any more.
|
||
const paintingTitles = await json('__gm.locateAll("PAINTING")');
|
||
check(
|
||
'the panel offers tools, tiles and layers, and the inspector no longer does',
|
||
section.tools &&
|
||
section.grass &&
|
||
Boolean(section.layers) &&
|
||
paintingTitles.length === 1 &&
|
||
inside({ ...paintingTitles[0], w: 0, h: 0 }, panel.panel) &&
|
||
Boolean(await json("__gm.inspectorField('Columns')")),
|
||
JSON.stringify({ section, paintingTitles }),
|
||
);
|
||
await shot('40-tilemap-panel');
|
||
// 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 inPanel(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 panel',
|
||
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 panel',
|
||
await clickInPanel(t, '__gm.tilemap.tileButtonAt("at-grass")'),
|
||
JSON.stringify(await json('__gm.tilemap.panel()')),
|
||
);
|
||
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 clickInPanel(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()),
|
||
);
|
||
// The same box again changes no cell, so it leaves no step behind (#212): the one undo takes
|
||
// back the first rectangle, and redo brings it back for the blocks below.
|
||
await drag(corner.x, corner.y, far.x, far.y);
|
||
await settle();
|
||
await undo();
|
||
await settle();
|
||
const afterUndo = Object.keys(await painted()).length;
|
||
await redo();
|
||
await settle();
|
||
check(
|
||
'a rectangle over cells that already hold its tile is no undo step',
|
||
afterUndo === 0 && Object.keys(await painted()).length === 9,
|
||
JSON.stringify({ afterUndo, redone: Object.keys(await painted()).length }),
|
||
);
|
||
|
||
// 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 clickInPanel(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();
|
||
|
||
// 40n. Fill and pick (#133). The 3 × 3 block sits in the corner of the 6 × 4 grid, so the empty
|
||
// cells around it are one region of 15: a click fills exactly those, in one undo step.
|
||
await clickInPanel(t, '__gm.tilemap.toolAt("fill")');
|
||
await settle();
|
||
const empty = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 5, 3)`);
|
||
await click(empty.x, empty.y);
|
||
await settle();
|
||
cells = await painted();
|
||
check(
|
||
'the fill tool fills the connected empty cells up to the edge of the grid',
|
||
Object.keys(cells).length === COLUMNS * ROWS && Object.values(cells).every((id) => id === 'at-grass'),
|
||
JSON.stringify(Object.keys(cells).sort()),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
check(
|
||
'and the whole fill is one undo step',
|
||
Object.keys(await painted()).length === 9,
|
||
JSON.stringify(Object.keys(await painted()).sort()),
|
||
);
|
||
// With no tile chosen, whatever tile the paint state holds afterwards came from the map.
|
||
await evaluate("__gm.state.setTilemapPaint({ tileId: '' })");
|
||
await clickInPanel(t, '__gm.tilemap.toolAt("pick")');
|
||
await settle();
|
||
const inBlock = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`);
|
||
await click(inBlock.x, inBlock.y);
|
||
await settle();
|
||
const picked = await json('__gm.tilemap.paint()');
|
||
check(
|
||
'the pick tool takes the tile of the cell and hands it back to the tool before it',
|
||
picked.tileId === 'at-grass' && picked.tool === 'fill' && Object.keys(await painted()).length === 9,
|
||
JSON.stringify(picked),
|
||
);
|
||
// Held Alt: Pick for as long as it is down, the brush again once it is up.
|
||
await clickInPanel(t, '__gm.tilemap.toolAt("brush")');
|
||
await evaluate("__gm.state.setTilemapPaint({ tileId: '' })");
|
||
await settle();
|
||
await t.keyDown('Alt');
|
||
await settle();
|
||
const holding = await json('__gm.tilemap.paint()');
|
||
await click(inBlock.x, inBlock.y, 'left', { alt: true });
|
||
const whileHeld = await json('__gm.tilemap.paint()');
|
||
await t.keyUp('Alt');
|
||
await settle();
|
||
const released = await json('__gm.tilemap.paint()');
|
||
check(
|
||
'holding Alt picks, and letting go of it brings the brush back',
|
||
holding.tool === 'pick' &&
|
||
whileHeld.tool === 'pick' &&
|
||
whileHeld.tileId === 'at-grass' &&
|
||
released.tool === 'brush' &&
|
||
released.tileId === 'at-grass' &&
|
||
Object.keys(await painted()).length === 9,
|
||
JSON.stringify({ holding, whileHeld, released }),
|
||
);
|
||
|
||
// 40h. A second layer draws above the first, and hiding it leaves its cells alone.
|
||
await clickInPanel(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 clickInPanel(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 inPanel(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();
|
||
|
||
// 40l-ii. The rows are dragged into another order: the upper layer dropped below the lower one is
|
||
// drawn first. Undone again, so the blocks below find the layers as they were.
|
||
const upperRow = await inPanel(t, '__gm.tilemap.layerRowAt(0)');
|
||
const lowerRow = await inPanel(t, '__gm.tilemap.layerRowAt(1)');
|
||
const orderBefore = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id);
|
||
if (upperRow && lowerRow) await drag(upperRow.name.x, upperRow.y, lowerRow.name.x, lowerRow.y + 20);
|
||
await settle();
|
||
const orderAfter = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id);
|
||
const topRowNow = await json('(__gm.tilemap.layerRowAt(0) || {}).id');
|
||
check(
|
||
'dragging the upper layer row below the lower one puts that layer first in the drawing order',
|
||
orderBefore.length === 2 &&
|
||
orderAfter.join() === [orderBefore[1], orderBefore[0]].join() &&
|
||
topRowNow === orderBefore[0],
|
||
JSON.stringify({ orderBefore, orderAfter, topRowNow }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
// With no layer chosen the topmost is painted on; the hook points at that row's cross.
|
||
const chosenLayer = (await json('__gm.tilemap.paint()')).layerId;
|
||
const paintedLayer = orderBefore.includes(chosenLayer) ? chosenLayer : orderBefore.at(-1);
|
||
const removeAt = await inPanel(t, '__gm.tilemap.removeLayerAt()');
|
||
if (removeAt?.enabled) await click(removeAt.x, removeAt.y);
|
||
await settle();
|
||
const leftOver = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id);
|
||
const lastOne = await json('__gm.tilemap.removeLayerAt()');
|
||
check(
|
||
'the cross takes out the layer painted on, and the last layer has none',
|
||
leftOver.length === 1 && !leftOver.includes(paintedLayer) && lastOne?.enabled === false,
|
||
JSON.stringify({ paintedLayer, leftOver, lastOne }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
|
||
// 40m. A double-click on the painted layer's name edits it in its row, and Show singles it out in
|
||
// the scene view (#138). It sits here, not at the end, because here both layers exist: the
|
||
// upper one is painted on and holds the cell (4,0), the lower one the 3 × 3 block.
|
||
const nameSpot = await inPanel(t, '__gm.tilemap.layerNameAt()');
|
||
if (nameSpot) await t.doubleClick(nameSpot.x, nameSpot.y);
|
||
await settle();
|
||
const nameEditing = (await json('__gm.tilemap.layerNameAt()'))?.editing;
|
||
await press('a', { ctrl: true });
|
||
await typeText('Roofs');
|
||
await press('Enter');
|
||
await settle();
|
||
layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
const renamedRow = await json('(__gm.tilemap.layerRowAt(0) || {}).id');
|
||
const nameNow = await json('__gm.tilemap.layerNameAt()');
|
||
check(
|
||
'a double-click on a layer name edits it in the row, and the list shows the new name',
|
||
nameEditing?.value === 'Layer 2' &&
|
||
layers[1].name === 'Roofs' &&
|
||
layers[0].name === 'Layer 1' &&
|
||
renamedRow === layers[1].id &&
|
||
nameNow?.value === 'Roofs' &&
|
||
nameNow.editing === null,
|
||
JSON.stringify({ nameEditing, layers, renamedRow, nameNow }),
|
||
);
|
||
|
||
// 40p. The Z field in the row gives the painted layer a z of its own, added to the map's (#174):
|
||
// at −5 the upper layer is sorted in below the map itself and below the lower layer.
|
||
const zField = await inPanel(t, '__gm.tilemap.layerZAt()');
|
||
if (zField) {
|
||
await click(zField.x, zField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('-5');
|
||
await press('Enter');
|
||
}
|
||
await settle();
|
||
await shot('40-tilemap-layer-z');
|
||
const withZ = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
const slot = (order, id) => order.indexOf(id);
|
||
const orderZ = await json('__gm.drawOrder()');
|
||
const upperSlot = `${MAP_OBJECT}#${withZ[1].id}`;
|
||
const lowerSlot = `${MAP_OBJECT}#${withZ[0].id}`;
|
||
await undo();
|
||
await settle();
|
||
const orderBack = await json('__gm.drawOrder()');
|
||
const zBack = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1].z;
|
||
check(
|
||
'the Z field sorts the painted layer below the map and the other layer, and undo puts it back',
|
||
withZ[1].z === -5 &&
|
||
withZ[0].z === 0 &&
|
||
slot(orderZ, upperSlot) >= 0 &&
|
||
slot(orderZ, upperSlot) < slot(orderZ, MAP_OBJECT) &&
|
||
slot(orderZ, upperSlot) < slot(orderZ, lowerSlot) &&
|
||
zBack === 0 &&
|
||
slot(orderBack, MAP_OBJECT) < slot(orderBack, lowerSlot) &&
|
||
slot(orderBack, lowerSlot) < slot(orderBack, upperSlot),
|
||
JSON.stringify({ withZ, orderZ, orderBack, zBack }),
|
||
);
|
||
|
||
// 40p-ii. The duplicate mark copies the painted layer right above it, cells and all, and paints
|
||
// on the copy. Undone again: the blocks below count on two layers.
|
||
const copyMark = await inPanel(t, '__gm.tilemap.duplicateLayerAt()');
|
||
if (copyMark) await click(copyMark.x, copyMark.y);
|
||
await settle();
|
||
const withCopy = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
const paintedCopy = (await json('__gm.tilemap.paint()')).layerId;
|
||
check(
|
||
'the duplicate mark on a layer row puts a copy with the same cells above it and paints on it',
|
||
copyMark !== null &&
|
||
withCopy.length === 3 &&
|
||
withCopy[2].name === 'Roofs copy' &&
|
||
withCopy[2].cells === withCopy[1].cells &&
|
||
paintedCopy === withCopy[2].id,
|
||
JSON.stringify({ copyMark, withCopy, paintedCopy }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
|
||
// Pixels again, so the animation is cut down to one step for the reading, as in 40e.
|
||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
|
||
await settle();
|
||
const quarter = async (x, y) => (await cellQuarters(t, x, y))?.[0] ?? null;
|
||
const lowerWhole = await quarter(1, 1);
|
||
const emptyCell = await quarter(5, 3);
|
||
const upperWhole = await quarter(4, 0);
|
||
/** Chooses an entry of the Show dropdown: the topmost hit, which is the open menu. */
|
||
const show = async (label) => {
|
||
const button = await inPanel(t, '__gm.tilemap.focusAt()');
|
||
if (button) await click(button.x, button.y);
|
||
await settle();
|
||
const entry = await t.locate(label, true);
|
||
await click(entry.x, entry.y);
|
||
await settle();
|
||
};
|
||
|
||
await show('Others faded');
|
||
const faded = await quarter(1, 1);
|
||
// The lower layer is laid over the background at 30 %, so its colour lands in between.
|
||
const mix = lowerWhole && emptyCell && blend(lowerWhole, emptyCell, 0.3);
|
||
const focusLabel = await json('(__gm.tilemap.focusAt() || {}).label');
|
||
check(
|
||
'Others faded draws the other layers at 30 % and the painted one whole',
|
||
Boolean(mix) &&
|
||
colourDistance(faded, mix) <= 16 &&
|
||
colourDistance(await quarter(4, 0), upperWhole) <= 12 &&
|
||
focusLabel === 'Others faded',
|
||
JSON.stringify({ faded, mix, lowerWhole, emptyCell, focusLabel }),
|
||
);
|
||
|
||
await show('Active layer only');
|
||
const solo = await quarter(1, 1);
|
||
const soloInfo = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`);
|
||
check(
|
||
'Active layer only leaves the other layers out and keeps the painted one',
|
||
colourDistance(solo, emptyCell) <= 12 &&
|
||
colourDistance(await quarter(4, 0), upperWhole) <= 12 &&
|
||
soloInfo.drawCalls === 1,
|
||
JSON.stringify({ solo, emptyCell, soloInfo }),
|
||
);
|
||
|
||
// Only the selected map is singled out; deselected it is drawn whole, and the model never changed.
|
||
// The brush is taken up first, so the deselection has a tool to put down (#142).
|
||
await evaluate("__gm.state.setTilemapPaint({ tool: 'brush' })");
|
||
const paintBefore = await json('__gm.tilemap.paint()');
|
||
const cellSelected = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
await evaluate('__gm.state.select(null)');
|
||
await settle();
|
||
const deselected = await quarter(1, 1);
|
||
layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||
check(
|
||
'a deselected map is drawn whole again, and Show hid nothing in the model',
|
||
colourDistance(deselected, lowerWhole) <= 12 && layers.every((l) => !l.hidden),
|
||
JSON.stringify({ deselected, lowerWhole, layers }),
|
||
);
|
||
const gone = await json('__gm.tilemap.panel()');
|
||
const cellGone = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
const paintAfter = await json('__gm.tilemap.paint()');
|
||
check(
|
||
'deselecting closes the panel, gives the scene view its room back and puts the brush down',
|
||
!gone.shown &&
|
||
gone.viewport.w > panel.viewport.w &&
|
||
Math.abs(cellGone.x - cellSelected.x) <= 1 &&
|
||
Math.abs(cellGone.y - cellSelected.y) <= 1 &&
|
||
paintAfter.tool === 'select' &&
|
||
paintAfter.tileId === paintBefore.tileId &&
|
||
paintAfter.layerId === paintBefore.layerId,
|
||
JSON.stringify({ gone, cellSelected, cellGone, paintBefore, paintAfter }),
|
||
);
|
||
|
||
// Two objects selected are no single map to paint, even when both are tilemaps; and the panel
|
||
// belongs to the scene tab, so another tab takes it along while the map stays selected.
|
||
const second = await json(
|
||
`__gm.state.activeScene.objects.find((o) => o.id !== ${JSON.stringify(MAP_OBJECT)} && !o.parentId).id`,
|
||
);
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT, second], objectId: MAP_OBJECT });
|
||
await settle();
|
||
const withTwo = (await json('__gm.tilemap.panel()')).shown;
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||
await settle();
|
||
await openTab(t, 'graph');
|
||
await settle();
|
||
const onOtherTab = (await json('__gm.tilemap.panel()')).shown;
|
||
await openTab(t, 'scene');
|
||
await settle();
|
||
const back = await json('__gm.tilemap.panel()');
|
||
check(
|
||
'the panel stays away with two objects selected and on another tab, and comes back with the scene',
|
||
withTwo === false && onOtherTab === false && back.shown && Math.abs(back.panel.w - panel.panel.w) <= 1,
|
||
JSON.stringify({ second, withTwo, onOtherTab, back }),
|
||
);
|
||
const cellBack = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
await drag(back.splitter.x, back.splitter.y, back.splitter.x - 60, back.splitter.y);
|
||
await settle();
|
||
const wider = await json('__gm.tilemap.panel()');
|
||
const cellWider = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 0, 0)`);
|
||
check(
|
||
'the splitter widens the panel at the expense of the scene view, and the map stays put',
|
||
Math.abs(wider.panel.w - back.panel.w - 60) <= 1 &&
|
||
Math.abs(back.viewport.w - wider.viewport.w - 60) <= 1 &&
|
||
Math.abs(cellWider.x - cellBack.x) <= 1,
|
||
JSON.stringify({ back, wider, cellBack, cellWider }),
|
||
);
|
||
await drag(wider.splitter.x, wider.splitter.y, wider.splitter.x + 60, wider.splitter.y);
|
||
await settle();
|
||
await show('All layers');
|
||
await mutate(
|
||
"__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1', 'at-floor-2']",
|
||
);
|
||
await undo(); // the rename
|
||
await settle();
|
||
|
||
// 40i. Back to Select: the map is moved again instead of painted on.
|
||
await clickInPanel(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();
|
||
|
||
// 40q. A click picks what is drawn under the pointer (#203). A crate of its own sits on the 3 × 3
|
||
// block at z −1, below the map's 0; with the block's layer sorted in at −5 the crate is drawn
|
||
// in front, so it is what a click there takes, although the map's grid spans the spot.
|
||
const CRATE = 'tm-pick-crate';
|
||
const blockLayer = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.find((l) => l.cells['1,1'])`;
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
${MAKE_OBJECT}
|
||
const crate = make(${JSON.stringify(CRATE)}, 'crate', '', ${LANE - 24}, 'Pick crate');
|
||
crate.position = { x: -72, y: ${LANE - 24}, z: -1 };
|
||
scene.objects.push(crate);
|
||
${blockLayer}.z = -5;`);
|
||
await evaluate('__gm.state.select(null)');
|
||
await settle();
|
||
const cellPoint = (x, y) => json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, ${x}, ${y})`);
|
||
const stackAt = (p) => json(`__gm.sceneStack(${p.x}, ${p.y})`);
|
||
const selected = () => json('__gm.state.selectedObjectIds');
|
||
const onBlock = await cellPoint(1, 1);
|
||
const stackBelow = await stackAt(onBlock);
|
||
await click(onBlock.x, onBlock.y);
|
||
await settle();
|
||
const pickedBelow = await selected();
|
||
check(
|
||
'a sprite drawn over a map layer sorted in below it is what a click there picks',
|
||
JSON.stringify(stackBelow) === JSON.stringify([CRATE, MAP_OBJECT]) &&
|
||
JSON.stringify(pickedBelow) === JSON.stringify([CRATE]),
|
||
JSON.stringify({ stackBelow, pickedBelow }),
|
||
);
|
||
|
||
// An empty cell is empty space: the click there takes nothing and clears the selection.
|
||
const onEmpty = await cellPoint(COLUMNS - 1, ROWS - 1);
|
||
const stackEmpty = await stackAt(onEmpty);
|
||
await click(onEmpty.x, onEmpty.y);
|
||
await settle();
|
||
const pickedEmpty = await selected();
|
||
check(
|
||
'a click on an empty cell of the map selects nothing',
|
||
stackEmpty.length === 0 && pickedEmpty.length === 0,
|
||
JSON.stringify({ stackEmpty, pickedEmpty }),
|
||
);
|
||
|
||
// Clicking what is selected passes on to the next one below, and from the bottom round to the top.
|
||
const cycle = [];
|
||
for (let i = 0; i < 3; i++) {
|
||
const p = await cellPoint(1, 1);
|
||
await click(p.x, p.y);
|
||
await settle();
|
||
cycle.push((await selected()).join(','));
|
||
}
|
||
check(
|
||
'clicking the selected object again selects the one below it, then the top one again',
|
||
JSON.stringify(cycle) === JSON.stringify([CRATE, MAP_OBJECT, CRATE]),
|
||
JSON.stringify(cycle),
|
||
);
|
||
|
||
// With the layer sorted in above the crate the map is in front – and a drag on the selected crate
|
||
// still moves the crate, not the map on top of it.
|
||
await mutate(`${blockLayer}.z = 5;`);
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [CRATE], objectId: CRATE });
|
||
await settle();
|
||
const onTop = await cellPoint(1, 1);
|
||
const stackAbove = await stackAt(onTop);
|
||
const crateBefore = (await t.object(CRATE)).position;
|
||
const mapUnder = (await t.object(MAP_OBJECT)).position;
|
||
await drag(onTop.x, onTop.y, onTop.x + 40, onTop.y);
|
||
await settle();
|
||
const crateAfter = (await t.object(CRATE)).position;
|
||
const mapUnderAfter = (await t.object(MAP_OBJECT)).position;
|
||
const pickedDrag = await selected();
|
||
check(
|
||
'a drag on a selected sprite hidden under the map moves the sprite and leaves the map',
|
||
JSON.stringify(stackAbove) === JSON.stringify([MAP_OBJECT, CRATE]) &&
|
||
Math.round(crateAfter.x - crateBefore.x) === 40 &&
|
||
JSON.stringify(mapUnderAfter) === JSON.stringify(mapUnder) &&
|
||
JSON.stringify(pickedDrag) === JSON.stringify([CRATE]),
|
||
JSON.stringify({ stackAbove, crateBefore, crateAfter, mapUnder, mapUnderAfter, pickedDrag }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
|
||
// 40r. A right-click in the scene view opens the hierarchy's menu (#204), by the rule of a press:
|
||
// the selected crate hidden under the map keeps the selection and gets the menu.
|
||
const menuRows = async () => ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
|
||
const OBJECT_MENU = [
|
||
'Select asset',
|
||
'Open asset',
|
||
'Add collider…',
|
||
'Add rigidbody…',
|
||
'Create container',
|
||
'Unpack container',
|
||
'Unparent (move to top level)',
|
||
'Remove from scene',
|
||
];
|
||
const onHidden = await cellPoint(1, 1);
|
||
await click(onHidden.x, onHidden.y, 'right');
|
||
await settle();
|
||
const hiddenRows = await menuRows();
|
||
const hiddenPicked = await selected();
|
||
await clickText('Select asset');
|
||
await settle();
|
||
const afterPick = await json('__gm.state.selection');
|
||
check(
|
||
'a right-click on a selected sprite under the map opens the object menu for the sprite',
|
||
JSON.stringify(hiddenRows) === JSON.stringify(OBJECT_MENU) &&
|
||
JSON.stringify(hiddenPicked) === JSON.stringify([CRATE]) &&
|
||
afterPick?.kind === 'asset' &&
|
||
afterPick.assetId === 'crate',
|
||
JSON.stringify({ hiddenRows, hiddenPicked, afterPick }),
|
||
);
|
||
|
||
// Nothing selected under the pointer: the topmost is selected first, the menu is for it.
|
||
await evaluate('__gm.state.select(null)');
|
||
await settle();
|
||
await click(onHidden.x, onHidden.y, 'right');
|
||
await settle();
|
||
const topRows = await menuRows();
|
||
const topPicked = await selected();
|
||
await press('Escape');
|
||
await settle();
|
||
check(
|
||
'a right-click with nothing selected there selects the topmost object and opens its menu',
|
||
JSON.stringify(topRows) === JSON.stringify(OBJECT_MENU) &&
|
||
JSON.stringify(topPicked) === JSON.stringify([MAP_OBJECT]),
|
||
JSON.stringify({ topRows, topPicked }),
|
||
);
|
||
|
||
// Empty space opens no menu and leaves the selection alone.
|
||
const emptyPoint = await cellPoint(COLUMNS - 1, ROWS - 1);
|
||
await click(emptyPoint.x, emptyPoint.y, 'right');
|
||
await settle();
|
||
const emptyMenu = await json('__gm.menuRows()');
|
||
const emptyPicked = await selected();
|
||
check(
|
||
'a right-click on an empty cell opens no menu and keeps the selection',
|
||
emptyMenu === null && JSON.stringify(emptyPicked) === JSON.stringify([MAP_OBJECT]),
|
||
JSON.stringify({ emptyMenu, emptyPicked }),
|
||
);
|
||
await press('Escape');
|
||
await evaluate('__gm.state.select(null)');
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== ${JSON.stringify(CRATE)});
|
||
delete ${blockLayer}.z;`);
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||
await settle();
|
||
|
||
// 40o. The grid grows at the left edge (#136): the cells of both layers move one column in, the
|
||
// origin moves half a cell to the left, and on screen the painted cells stay where they were.
|
||
const allCells = () =>
|
||
json(
|
||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => Object.keys(l.cells).sort().join(' '))`,
|
||
);
|
||
const cellsBefore = await allCells();
|
||
const objectBefore = await t.object(MAP_OBJECT);
|
||
const screenBefore = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`);
|
||
const addLeft = await edgeButton(t, 'Left', '+');
|
||
if (addLeft) await click(addLeft.x, addLeft.y);
|
||
await settle();
|
||
await shot('40-grid-edges');
|
||
const cellsGrown = await allCells();
|
||
const objectGrown = await t.object(MAP_OBJECT);
|
||
const screenGrown = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`);
|
||
const drawnGrown = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`);
|
||
const movedIn = (keys) =>
|
||
keys
|
||
.split(' ')
|
||
.filter(Boolean)
|
||
.map((key) => key.split(',').map(Number))
|
||
.map(([x, y]) => `${x + 1},${y}`)
|
||
.sort()
|
||
.join(' ');
|
||
check(
|
||
'the + of the left edge moves every layer one column in and the map stays where it is on screen',
|
||
addLeft !== null &&
|
||
cellsGrown.length === 2 &&
|
||
cellsGrown.every((keys, i) => keys === movedIn(cellsBefore[i])) &&
|
||
objectGrown.overrides.columns === COLUMNS + 1 &&
|
||
Math.abs(objectGrown.position.x - (objectBefore.position.x - 24)) < 1e-6 &&
|
||
Math.abs(screenGrown.x - screenBefore.x) < 0.5 &&
|
||
Math.abs(screenGrown.y - screenBefore.y) < 0.5 &&
|
||
drawnGrown.tiles === 10,
|
||
JSON.stringify({ addLeft, cellsBefore, cellsGrown, objectGrown, screenBefore, screenGrown, drawnGrown }),
|
||
);
|
||
await undo();
|
||
await settle();
|
||
const objectUndone = await t.object(MAP_OBJECT);
|
||
check(
|
||
'and one undo takes back the cells, the column and the position',
|
||
JSON.stringify(await allCells()) === JSON.stringify(cellsBefore) &&
|
||
objectUndone.overrides.columns === undefined &&
|
||
JSON.stringify(objectUndone.position) === JSON.stringify(objectBefore.position),
|
||
JSON.stringify({ cells: await allCells(), objectUndone }),
|
||
);
|
||
|
||
// 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')");
|
||
const surfaceRows = () =>
|
||
json("[__gm.inspectorField('Friction'), __gm.inspectorField('Bounciness')].map(Boolean)");
|
||
const surfaceBefore = await surfaceRows();
|
||
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 }),
|
||
);
|
||
// Its surface (#193) is a solid tile's business: the rows come with the switch.
|
||
const surfaceAfter = await surfaceRows();
|
||
check(
|
||
'Friction and Bounciness show on a tile only once it is solid',
|
||
JSON.stringify(surfaceBefore) === '[false,false]' && JSON.stringify(surfaceAfter) === '[true,true]',
|
||
JSON.stringify({ surfaceBefore, surfaceAfter }),
|
||
);
|
||
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');
|
||
|
||
// 40l-iii. The outline shows on every map, the tint only on the map being painted, and there only
|
||
// where the layer picked in the panel blocks: the block on the lower, (4, 0) on the upper.
|
||
const tinted = async () =>
|
||
(await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`))?.filled ?? null;
|
||
const tintedByTile = await tinted();
|
||
await select({ kind: 'object', holderId: 'scene1', objectIds: [MAP_OBJECT], objectId: MAP_OBJECT });
|
||
await settle();
|
||
const layerIds = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`)).map((l) => l.id);
|
||
const tintOf = async (row) => {
|
||
const spot = await inPanel(t, `__gm.tilemap.layerRowAt(${row})`);
|
||
if (spot) await click(spot.name.x, spot.y);
|
||
await settle();
|
||
// No layer chosen means the topmost is painted on, as the panel has it.
|
||
const chosen = (await json('__gm.tilemap.paint()')).layerId;
|
||
return { layer: layerIds.includes(chosen) ? chosen : layerIds.at(-1), filled: await tinted() };
|
||
};
|
||
const tintRows = [await tintOf(0), await tintOf(1)];
|
||
const expectedTint = {
|
||
[layerIds[0]]: [{ x: 0, y: 0, w: 3, h: 3 }],
|
||
[layerIds[1]]: [{ x: 4, y: 0, w: 1, h: 1 }],
|
||
};
|
||
const besideMap = await json(
|
||
`__gm.state.activeScene.objects.find((o) => o.id !== ${JSON.stringify(MAP_OBJECT)} && !o.parentId).id`,
|
||
);
|
||
await select({
|
||
kind: 'object',
|
||
holderId: 'scene1',
|
||
objectIds: [MAP_OBJECT, besideMap],
|
||
objectId: MAP_OBJECT,
|
||
});
|
||
await settle();
|
||
const tintedWithTwo = await tinted();
|
||
await select({ kind: 'asset', assetId: 'at-grass' });
|
||
await settle();
|
||
check(
|
||
'only the selected map is tinted, and only where the layer painted on blocks',
|
||
tintedByTile === null &&
|
||
tintedWithTwo === null &&
|
||
layerIds.length === 2 &&
|
||
new Set(tintRows.map((r) => r.layer)).size === 2 &&
|
||
tintRows.every(
|
||
(r) =>
|
||
JSON.stringify(r.filled?.rects) === JSON.stringify(expectedTint[r.layer]) &&
|
||
r.filled.outlines === 0 &&
|
||
r.filled.oneWay.length === 0,
|
||
),
|
||
JSON.stringify({ tintedByTile, tintRows, tintedWithTwo }),
|
||
);
|
||
|
||
// 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}`,
|
||
);
|
||
|
||
// Collision scale (#140), typed into the tile's field: 0.5 draws the boxes in by a quarter cell on
|
||
// their open sides only – the 3 × 3 block stays one rectangle – and lets the crate that much closer.
|
||
await select({ kind: 'asset', assetId: 'at-grass' });
|
||
await settle();
|
||
const scaleRow = await json("__gm.inspectorField('Collision scale')");
|
||
if (scaleRow) {
|
||
await click(scaleRow.x, scaleRow.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('0.5');
|
||
await press('Enter');
|
||
}
|
||
await settle();
|
||
const drawnIn = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`);
|
||
check(
|
||
'a tile has a Collision scale field; at 0.5 the outline is drawn in on the open sides, the block still one rectangle',
|
||
(await t.asset('at-grass'))?.props.collisionScale === 0.5 &&
|
||
JSON.stringify(drawnIn?.rects) ===
|
||
JSON.stringify([
|
||
{ x: 0.25, y: 0.25, w: 2.5, h: 2.5 },
|
||
{ x: 4.25, y: 0.25, w: 0.5, h: 0.5 },
|
||
]),
|
||
JSON.stringify({ scaleRow, props: (await t.asset('at-grass'))?.props, drawnIn }),
|
||
);
|
||
await evaluate('__gm.state.select(null)');
|
||
await settle();
|
||
const closer = await playAgainstMap();
|
||
check(
|
||
'with the smaller box the crate comes a quarter cell closer before it stops',
|
||
closer && Math.abs(closer.x - -158) < 0.01,
|
||
`world x=${closer?.x}`,
|
||
);
|
||
await mutate("delete __gm.state.project.assets.find((a) => a.id === 'at-grass').props.collisionScale");
|
||
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();
|
||
|
||
// 40s. A behavior reads and sets cells while the game runs (#135). One map asset is placed twice;
|
||
// the behavior sits on the left placement and, on start, puts a tile into cell 2,1, reads it
|
||
// back and finds the cell again from its middle in world units. Only that placement shows
|
||
// it, and after Stop the asset holds what was painted and nothing more.
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const assets = __gm.state.project.assets;
|
||
// A tile of one picture: an animated one redraws the view every frame and would hide whether a
|
||
// changed cell asks for a redraw by itself.
|
||
const still = structuredClone(assets.find((a) => a.id === 'at-grass'));
|
||
Object.assign(still, { id: 'tl-still', name: 'Still', pictures: ['at-floor-1'] });
|
||
assets.push(still);
|
||
const map = structuredClone(assets.find((a) => a.id === ${JSON.stringify(made.id)}));
|
||
Object.assign(map, { id: 'tl-map', name: 'Live map', props: { ...map.props, columns: 4, rows: 2, tileSize: 48 },
|
||
layers: [{ id: 'tl-bridge', name: 'Bridge', cells: { '0,0': 'tl-still' } }] });
|
||
assets.push(map);
|
||
const logic = structuredClone(assets.find((a) => a.id === 'mover'));
|
||
Object.assign(logic, { id: 'tl-behavior', name: 'Tile logic', props: { enabled: true, runInEditor: false },
|
||
variables: [], graph: ${JSON.stringify(tileGraph)} });
|
||
assets.push(logic);
|
||
const object = (id, assetId, parentId, x, name) => ({ id, assetId, name, position: { x, y: 0, z: 0 },
|
||
editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} });
|
||
scene.objects.push(object('tl-a', 'tl-map', '', -150, 'Live left'), object('tl-b', 'tl-map', '', 150, 'Live right'),
|
||
object('tl-logic', 'tl-behavior', 'tl-a', 0, 'Tiler'));
|
||
__gm.state.select(null)`);
|
||
await select({ kind: 'node', assetId: 'tl-behavior', nodeId: 'tl-set' });
|
||
await settle();
|
||
const layerField = await json("__gm.inspectorField('layer', true)");
|
||
if (layerField) await click(layerField.x, layerField.y);
|
||
const layerChoices = await json('__gm.overlayTexts()');
|
||
if (layerChoices.includes('Bridge')) {
|
||
const choice = await t.locate('Bridge', true);
|
||
await click(choice.x, choice.y);
|
||
}
|
||
await settle();
|
||
const setParams = await json(
|
||
"__gm.state.project.assets.find((a) => a.id === 'tl-behavior').graph.nodes.find((n) => n.id === 'tl-set').params",
|
||
);
|
||
check(
|
||
'the layer of Set tile is picked by name from the layers of the project’s tilemaps',
|
||
Boolean(layerField) &&
|
||
layerChoices.includes('First layer') &&
|
||
layerChoices.includes('Bridge') &&
|
||
setParams.layer === 'Bridge',
|
||
JSON.stringify({ layerField, layerChoices, setParams }),
|
||
);
|
||
|
||
await evaluate('__gm.state.select(null)');
|
||
await openTab(t, 'scene');
|
||
const tileLogFrom = t.consoleLog.length;
|
||
await t.play();
|
||
await t.sleep(600);
|
||
const leftInGame = await json("__gm.tilemap.gameInfo('tl-a')");
|
||
const rightInGame = await json("__gm.tilemap.gameInfo('tl-b')");
|
||
await t.stop();
|
||
await settle();
|
||
const tilerSaid = t.consoleLog
|
||
.slice(tileLogFrom)
|
||
.filter((line) => line.startsWith('[behavior Tiler]'))
|
||
.map((line) => line.split('] ').pop());
|
||
check(
|
||
'Set tile puts the tile down, Tile at reads it back, and World to cell finds the cell from its middle',
|
||
tilerSaid.length >= 3 &&
|
||
tilerSaid.length % 3 === 0 &&
|
||
tilerSaid.every((line, i) => line === ['tl-still', '2', '1'][i % 3]),
|
||
JSON.stringify(tilerSaid),
|
||
);
|
||
check(
|
||
'the running game draws the new tile on that placement only',
|
||
leftInGame?.tiles === 2 && rightInGame?.tiles === 1,
|
||
JSON.stringify({ leftInGame, rightInGame }),
|
||
);
|
||
const liveCells = () => json("__gm.state.project.assets.find((a) => a.id === 'tl-map').layers[0].cells");
|
||
const afterPlay = await liveCells();
|
||
check(
|
||
'after Stop the map asset holds what was painted, not what the game set',
|
||
JSON.stringify(afterPlay) === JSON.stringify({ '0,0': 'tl-still' }),
|
||
JSON.stringify(afterPlay),
|
||
);
|
||
|
||
// The editor run changes the same cell, and the scene view shows it while the run lasts.
|
||
await mutate(
|
||
"__gm.state.activeScene.objects.find((o) => o.id === 'tl-logic').overrides.runInEditor = true",
|
||
);
|
||
await mutate('__gm.lookAt(0, 0)');
|
||
await t.sleep(500);
|
||
const leftRunning = await json("__gm.tilemap.info('tl-a')");
|
||
const rightRunning = await json("__gm.tilemap.info('tl-b')");
|
||
await shot('40-tile-set-in-run');
|
||
await mutate(
|
||
"__gm.state.activeScene.objects.find((o) => o.id === 'tl-logic').overrides.runInEditor = false",
|
||
);
|
||
await settle();
|
||
const leftStopped = await json("__gm.tilemap.info('tl-a')");
|
||
const afterRun = await liveCells();
|
||
check(
|
||
'the editor run shows the set tile in the scene view, and ending it shows the painted map again',
|
||
(await json('__gm.editorRun()')).running === false &&
|
||
leftRunning?.tiles === 2 &&
|
||
rightRunning?.tiles === 1 &&
|
||
leftStopped?.tiles === 1 &&
|
||
JSON.stringify(afterRun) === JSON.stringify({ '0,0': 'tl-still' }),
|
||
JSON.stringify({ leftRunning, rightRunning, leftStopped, afterRun }),
|
||
);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('tl-'));
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('tl-'));
|
||
__gm.state.select(null)`);
|
||
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)');
|
||
}
|