A texture was fetched only when it was drawn for the first time. Until the file arrived, the tilemap skipped the cell for that frame. An animated tile whose next step sat in another picture therefore blinked away once on its first cycle, and sprite animations did the same. - FileLibrary gets preload(files). It requests every file that is not held, loading or known to have failed. Each library reports what it holds through holds(). - src/core/model/projectFiles.ts lists the files a project names per library: uploaded textures, fonts and pixel art cels. - App.ts runs the preload on every project event. That covers opening a project, new assets and edits from a collaborator. - A new test hook __gm.heldPictures() reports which textures of the project are in memory. Not done on purpose: - The tilemap keeps no fallback frame while a step is still loading. - Sounds still load on first play. Not checked: the flicker itself was not reproduced in the browser, the e2e check measures "held in memory". Modules 42, 44 and 47 read textures from the project and were not run locally. Tests: - ./check.sh: 174 files, 2518 tests, CHECK EXIT 0 - e2e 39 + 40: ALL PASSED, 96 checks - 3 new unit tests for preload and the project file list - New e2e check 40k-ii: an unplaced picture is held after a reload - Counter-probe: without the preload 40k-ii fails with "Autotiles: held false, unworn: held false" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1294 lines
57 KiB
JavaScript
1294 lines
57 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-iii. Sort by Y (#228) draws the painted layer row by row: its one cell (4,0) becomes the
|
||
// slot of row 0 in place of the whole layer's. Undone again for the blocks below.
|
||
const sortBox = await inPanel(t, '__gm.tilemap.sortByYAt()');
|
||
if (sortBox) await click(sortBox.x, sortBox.y);
|
||
await settle();
|
||
const sortedLayer = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1];
|
||
const orderSorted = await json('__gm.drawOrder()');
|
||
const boxSorted = await json('__gm.tilemap.sortByYAt()');
|
||
await undo();
|
||
await settle();
|
||
const orderUnsorted = await json('__gm.drawOrder()');
|
||
check(
|
||
'Sort by Y draws the painted layer as one slot per row, and undo makes it one piece again',
|
||
sortBox?.checked === false &&
|
||
sortedLayer.sortByY === true &&
|
||
boxSorted?.checked === true &&
|
||
orderSorted.includes(`${upperSlot}#0`) &&
|
||
!orderSorted.includes(upperSlot) &&
|
||
orderUnsorted.includes(upperSlot) &&
|
||
!orderUnsorted.includes(`${upperSlot}#0`),
|
||
JSON.stringify({ sortBox, sortedLayer, boxSorted, orderSorted, orderUnsorted }),
|
||
);
|
||
|
||
// 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)`,
|
||
);
|
||
// For 40k-ii: a picture of its own that nothing wears, so only loading ahead can bring it in.
|
||
await evaluate(`(async () => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = canvas.height = 8;
|
||
const g = canvas.getContext('2d');
|
||
g.fillStyle = 'rgb(201, 17, 99)';
|
||
g.fillRect(0, 0, 8, 8);
|
||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||
const transfer = new DataTransfer();
|
||
transfer.items.add(new File([blob], 'unworn.png', { type: 'image/png' }));
|
||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||
})()`);
|
||
await t.sleep(2500);
|
||
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 }),
|
||
);
|
||
|
||
// 40k-ii. Every picture the project names is loaded right after opening, not on its first draw:
|
||
// a tile stepping to a picture fetched only then blinked out once. "unworn" was pasted
|
||
// above and is placed nowhere.
|
||
let pictures = await json('__gm.heldPictures()');
|
||
for (let i = 0; i < 20 && !pictures.every((p) => p.held); i++) {
|
||
await t.sleep(150);
|
||
pictures = await json('__gm.heldPictures()');
|
||
}
|
||
check(
|
||
'every picture of the project is in memory after opening, drawn anywhere or not',
|
||
pictures.some((p) => p.name === 'unworn') && pictures.every((p) => p.held),
|
||
JSON.stringify(pictures),
|
||
);
|
||
|
||
// 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)');
|
||
}
|