A composition is a timeline of scene objects for cuts and cutscenes. This first step brings the model and the pure evaluation behind it. The tab and placing it in a scene follow in the next steps. - CompositionAsset holds objects, tracks, items and clips. Each item owns exactly one object with overrides and container edits. - The composition is a third object holder next to scene and container. Delete cascade, undo, usages and references work with it. - Duplicating gives the copy new object ids and moves the item ids along with them. - compositionAt() tells which items live at a frame. The top track is drawn in front. - A clip acts on the item of its own track that it overlaps. After its end it holds the last value until the item ends. The animation's play mode is ignored, a clip is one pass. - Clip targets are found by template path, else by name path, like the runtime's animation system. - The root sprite of an item plays its video driven at offset plus time. Children follow unless they are already driven by hand. - fittedLength() gives the video or clip length minus the offset, or 3 s without media. - fps (60) and loop live in their own properties file. Keeping them in PropertySchema would close an import cycle. - The type is listed under Visuals with its own icon. It is not placeable yet. Tests: the gate passed with 300 files and 4625 tests. That is 25 new tests in tests/core/composition.test.ts and 2 icon checks that run for every icon. Four counter-checks (track order, overlap rule, child videos, ids on duplicate) each turned exactly their test red. e2e 04 passed with 67 checks and now counts 33 asset types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
30 KiB
JavaScript
684 lines
30 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { pickAssetType } from './lib/scaffold.mjs';
|
||
|
||
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
|
||
const ASSET_TYPE_COUNT = 33;
|
||
/** The categories of the asset panel and the `+` menu, in their order (`ASSET_CATEGORIES`). */
|
||
const CATEGORIES = ['Basics', 'Visuals', 'Logics', 'Physics', 'Audio'];
|
||
|
||
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
||
export async function assets(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
shot,
|
||
click,
|
||
doubleClick,
|
||
clickText,
|
||
mouse,
|
||
mutate,
|
||
press,
|
||
redo,
|
||
undo,
|
||
select,
|
||
objects,
|
||
asset,
|
||
showTab,
|
||
has,
|
||
} = t;
|
||
|
||
// 4a. Context menu on the Crate asset. Placing left the menu when it became a drag. The groups
|
||
// start folded (#176) – the hook unfolds Crate's.
|
||
await json("__gm.assetRow('crate')");
|
||
await clickText('Crate', 'right');
|
||
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
|
||
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
|
||
await shot('11-context-menu');
|
||
await clickText('Duplicate');
|
||
const copy = await json(`(() => {
|
||
const assets = __gm.state.project.assets;
|
||
const c = assets[assets.findIndex((a) => a.id === 'crate') + 1];
|
||
const s = __gm.state.selection;
|
||
return c ? { name: c.name, type: c.type, open: s?.kind === 'asset' && s.assetId === c.id } : null;
|
||
})()`);
|
||
check(
|
||
'Duplicate puts "Crate 2" right behind the Crate and opens it',
|
||
copy?.name === 'Crate 2' && copy.type === 'sprite' && copy.open,
|
||
JSON.stringify(copy),
|
||
);
|
||
await undo();
|
||
check(
|
||
'one undo takes the copy back out',
|
||
!(await json("__gm.state.project.assets.some((a) => a.name === 'Crate 2')")),
|
||
);
|
||
await showTab('scene');
|
||
|
||
await addMenuOnRightClick(t);
|
||
await emptyGroupsOnSwitch(t);
|
||
|
||
// 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
|
||
// with `drag()`, because the preview only exists while the button is still down.
|
||
const from = await json("__gm.assetRow('crate')");
|
||
const centre = await json('__gm.viewportCenter()');
|
||
const to = { x: centre.x + 70, y: centre.y - 50 };
|
||
await mouse('mouseMoved', from.x, from.y);
|
||
await mouse('mousePressed', from.x, from.y);
|
||
for (let i = 1; i <= 8; i++) {
|
||
const x = from.x + ((to.x - from.x) * i) / 8;
|
||
const y = from.y + ((to.y - from.y) * i) / 8;
|
||
await mouse('mouseMoved', x, y, 'left', { buttons: 1 });
|
||
await sleep(16);
|
||
}
|
||
const preview = await json('__gm.dropPreview()');
|
||
check(
|
||
'dragging an asset over the view previews where it would land',
|
||
Boolean(preview) && preview.assetId === 'crate' && preview.rejection === '',
|
||
JSON.stringify(preview),
|
||
);
|
||
await shot('11b-drop-preview');
|
||
await mouse('mouseReleased', to.x, to.y);
|
||
await sleep(200);
|
||
|
||
const count = (await objects()).length;
|
||
check('dropping the asset adds an object', count === 8, `objects=${count}`);
|
||
const placed = await json(
|
||
'(() => { const id = __gm.state.selectedObjectIds[0]; return __gm.sceneObjects().find(o => o.id === id) ?? null; })()',
|
||
);
|
||
// The preview is a promise: whatever it drew has to be where the object then sits, or the
|
||
// outline was decoration rather than a prediction.
|
||
check(
|
||
'the object lands where the preview promised, and is selected',
|
||
Boolean(placed) &&
|
||
placed.assetId === 'crate' &&
|
||
placed.position.x === preview.x &&
|
||
placed.position.y === preview.y,
|
||
JSON.stringify({ placed: placed && placed.position, preview }),
|
||
);
|
||
check('the preview is gone once the asset is placed', (await json('__gm.dropPreview()')) === null);
|
||
await undo();
|
||
const afterUndo = (await objects()).length;
|
||
check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`);
|
||
await redo();
|
||
check('redo puts it back', (await objects()).length === 8);
|
||
|
||
// 4c. Create a new asset through the + menu. The menu lists the categories of the asset panel, in
|
||
// the panel's order; each opens the types filed under it, sorted by label, beside the menu.
|
||
await clickText('+');
|
||
const rows = await json('__gm.menuRows()');
|
||
const opens = await json('__gm.menuSubmenus()');
|
||
const menuBox = await json('__gm.overlayRect()');
|
||
const title = await json('__gm.menuTitle()');
|
||
check(
|
||
'the + menu lists the categories of the asset panel, each opening a submenu, under "Create asset"',
|
||
String(rows) === String(CATEGORIES) && opens.every(Boolean) && title === 'Create asset',
|
||
JSON.stringify({ rows, opens, title }),
|
||
);
|
||
const offered = {};
|
||
for (const category of CATEGORIES) {
|
||
await clickText(category);
|
||
offered[category] = await json('__gm.menuRows()');
|
||
}
|
||
const flat = Object.values(offered).flat();
|
||
check(
|
||
'every asset type is offered once, each category sorted by label',
|
||
flat.length === ASSET_TYPE_COUNT &&
|
||
new Set(flat).size === flat.length &&
|
||
Object.values(offered).every((list) => String(list) === String([...list].sort())),
|
||
JSON.stringify(offered),
|
||
);
|
||
check(
|
||
'a type sits under the category the asset panel files it under',
|
||
offered.Basics.includes('Scene') &&
|
||
offered.Visuals.includes('Sprite') &&
|
||
offered.Logics.includes('Behavior') &&
|
||
offered.Physics.includes('Collider') &&
|
||
offered.Audio.includes('Song'),
|
||
JSON.stringify(offered),
|
||
);
|
||
// The submenu opens right of the menu, its first entry level with the row that opened it. The
|
||
// pointer resting on another category swaps it after a short wait.
|
||
await clickText('Visuals');
|
||
const visualsRow = await json("__gm.locate('Visuals')");
|
||
const subBox = await json('__gm.overlayRect()');
|
||
const firstEntry = await json(`__gm.locate(${JSON.stringify(offered.Visuals[0])}, true)`);
|
||
await shot('tmp-plus-menu');
|
||
const screenH = await json('__gm.app.screen.height');
|
||
check(
|
||
'the submenu stands right of the menu, its first entry level with its category, all on screen',
|
||
subBox.x >= menuBox.x + menuBox.w - 4 &&
|
||
Math.abs(firstEntry.y - visualsRow.y) <= 2 &&
|
||
subBox.y + subBox.h <= screenH - Math.round(screenH * 0.1) + 1,
|
||
JSON.stringify({ menuBox, subBox, visualsRow, firstEntry, screenH }),
|
||
);
|
||
const audioRow = await json("__gm.locate('Audio')");
|
||
await mouse('mouseMoved', audioRow.x, audioRow.y);
|
||
await sleep(400);
|
||
const hovered = await json('__gm.menuRows()');
|
||
check(
|
||
'resting the pointer on another category swaps the submenu',
|
||
String(hovered) === String(offered.Audio),
|
||
JSON.stringify(hovered),
|
||
);
|
||
await clickText('Visuals');
|
||
await clickText('Sprite');
|
||
const names = await json('__gm.state.project.assets.map(a => a.name)');
|
||
const leftOpen = await json('__gm.menuRows()');
|
||
check(
|
||
'+ menu creates a sprite asset, and picking it closes menu and submenu',
|
||
names.includes('Sprite') && leftOpen === null,
|
||
JSON.stringify({ names, leftOpen }),
|
||
);
|
||
await shot('12-new-asset');
|
||
|
||
// 4d. Double-click on a scene row: the one thing activating an asset still does since placing
|
||
// became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry
|
||
// shares its label with the Scene tab, so a click by text could land on either.
|
||
await mutate(
|
||
`__gm.state.project.assets.push({ id: 'scene2', type: 'scene', name: 'Level 2', props: { backgroundColor: 0x22252c }, objects: [] })`,
|
||
);
|
||
const secondScene = await json("__gm.assetRow('scene2')");
|
||
await evaluate('__gm.lookAt(5000, 5000)');
|
||
const pannedAway = await json(`__gm.objectRect('o1')`);
|
||
await showTab('graph');
|
||
await doubleClick(secondScene.x, secondScene.y);
|
||
check(
|
||
'double-clicking a scene asset makes it the active scene',
|
||
(await json('__gm.state.project.activeSceneId')) === 'scene2',
|
||
await json('__gm.state.project.activeSceneId'),
|
||
);
|
||
check('and opens the Scene tab', (await json('__gm.tab()')) === 'scene', await json('__gm.tab()'));
|
||
const firstScene = await json("__gm.assetRow('scene1')");
|
||
await doubleClick(firstScene.x, firstScene.y);
|
||
check(
|
||
'double-clicking the first one switches back',
|
||
(await json('__gm.state.project.activeSceneId')) === 'scene1',
|
||
await json('__gm.state.project.activeSceneId'),
|
||
);
|
||
// An opened scene comes up framed, as if Fit had been clicked (#295) – not where the view stood.
|
||
const opened = await json(`__gm.objectRect('o1')`);
|
||
const fitAt = await json(`__gm.sceneHeaderAt('fit')`);
|
||
await click(fitAt.x, fitAt.y);
|
||
const fitted = await json(`__gm.objectRect('o1')`);
|
||
const sameRect = (a, b) =>
|
||
Boolean(a && b) && ['x', 'y', 'w', 'h'].every((k) => Math.abs(a[k] - b[k]) < 0.01);
|
||
check(
|
||
'a scene that is opened comes up framed like after a click on Fit',
|
||
sameRect(opened, fitted) && !sameRect(opened, pannedAway),
|
||
JSON.stringify({ pannedAway, opened, fitted }),
|
||
);
|
||
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
|
||
|
||
// 4d′. Opening an asset: a click on its row only selects, a double-click opens the tab it
|
||
// lives in – every type that has one. The fixture lacks five of them; they go in through
|
||
// the model, empty.
|
||
await mutate(`__gm.state.project.assets.push(
|
||
{ id: 'sm-open', type: 'statemachine', name: 'Machine', props: {}, graph: { nodes: [], edges: [] } },
|
||
{ id: 'tex-open', type: 'texture', name: 'Sheet', props: {}, file: '', width: 0, height: 0, frames: [] },
|
||
{ id: 'anim-open', type: 'animation', name: 'Walk', props: {}, root: '', tracks: [] },
|
||
{ id: 'box-open', type: 'container', name: 'Box', props: {}, objects: [] },
|
||
{ id: 'at-open', type: 'tile', name: 'Grass', props: {}, pictures: [] },
|
||
)`);
|
||
const openable = [
|
||
['mover', 'graph', '__gm.graph.current()'],
|
||
['sm-open', 'statemachine', '__gm.stateMachine.current()'],
|
||
['adjust-colors', 'shader', '__gm.shader.current()'],
|
||
['tex-open', 'texture', '__gm.texture.current()'],
|
||
['anim-open', 'animation', '__gm.animation.current()'],
|
||
['box-open', 'container', '__gm.container.current()'],
|
||
['at-open', 'tile', '__gm.tile.current()'],
|
||
];
|
||
for (const [id, tab, shown] of openable) {
|
||
await showTab('scene');
|
||
const row = await json(`__gm.assetRow(${JSON.stringify(id)})`);
|
||
await click(row.x, row.y);
|
||
await sleep(500); // past the double-click window, so the next press starts a fresh pair
|
||
const clicked = {
|
||
tab: await json('__gm.tab()'),
|
||
selected: (await json('__gm.state.selection'))?.assetId,
|
||
};
|
||
check(
|
||
`a click on the ${tab} asset selects it and leaves the Scene tab alone`,
|
||
clicked.tab === 'scene' && clicked.selected === id,
|
||
JSON.stringify(clicked),
|
||
);
|
||
await doubleClick(row.x, row.y);
|
||
const opened = { tab: await json('__gm.tab()'), shown: await json(shown) };
|
||
check(
|
||
`a double-click opens the ${tab} tab on it`,
|
||
opened.tab === tab && opened.shown === id,
|
||
JSON.stringify(opened),
|
||
);
|
||
}
|
||
// Enter goes the same way (`onActivate`), but only once the list holds keyboard focus, which a
|
||
// click does not give it – so it is not driven here.
|
||
await showTab('scene');
|
||
await clickText('+');
|
||
await pickAssetType(t, 'Behavior');
|
||
const created = await json(
|
||
"(__gm.state.project.assets.find((a) => a.type === 'behavior' && !['mover', 'spawner'].includes(a.id)) ?? {}).id ?? ''",
|
||
);
|
||
const fresh = { created, tab: await json('__gm.tab()'), shown: await json('__gm.graph.current()') };
|
||
check(
|
||
'creating an asset through + opens its tab on the new one',
|
||
Boolean(created) && fresh.tab === 'graph' && fresh.shown === created,
|
||
JSON.stringify(fresh),
|
||
);
|
||
await undo();
|
||
await mutate(
|
||
`__gm.state.project.assets = __gm.state.project.assets.filter((a) => !['sm-open', 'tex-open', 'anim-open', 'box-open', 'at-open'].includes(a.id))`,
|
||
);
|
||
await showTab('scene');
|
||
|
||
// 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
|
||
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
|
||
// reset is the first on screen and colour's the last.
|
||
await select({ kind: 'asset', assetId: 'crate' });
|
||
const heightReset = await evaluate(`__gm.locateIcon('reset')`);
|
||
const colorReset = await evaluate(`__gm.locateIcon('reset', true)`);
|
||
check('asset inspector shows a reset per deviating property', Boolean(heightReset));
|
||
// The reset is a picture since #214, no longer a character from the system font: its circle
|
||
// passes 4.8 units right of its centre, drawn in the muted text colour.
|
||
const onResetRing = await json(`__gm.pixelAt(${heightReset.x + 4}, ${heightReset.y + 1})`);
|
||
const muted = await json("__gm.themeColor('textMuted')");
|
||
check(
|
||
'the reset button draws its picture',
|
||
colourDistance(onResetRing, muted) < 40,
|
||
JSON.stringify({ onResetRing, muted }),
|
||
);
|
||
check(
|
||
'the two deviations have two separate resets',
|
||
Boolean(colorReset) && colorReset.y > heightReset.y,
|
||
JSON.stringify({ heightReset, colorReset }),
|
||
);
|
||
await click(heightReset.x, heightReset.y);
|
||
check(
|
||
'reset puts the asset property back to its default',
|
||
(await asset('crate')).props.height === 100,
|
||
`height=${(await asset('crate')).props.height}`,
|
||
);
|
||
const remaining = await evaluate(`__gm.locateIcon('reset')`);
|
||
check(
|
||
'the reset of the property now at its default is gone, the other one stays',
|
||
Boolean(remaining) &&
|
||
remaining.y === colorReset.y &&
|
||
remaining.y === (await evaluate(`__gm.locateIcon('reset', true)`)).y,
|
||
JSON.stringify({ remaining, colorReset }),
|
||
);
|
||
await shot('15-asset-reset');
|
||
await undo();
|
||
check(
|
||
'undo restores the asset value',
|
||
(await asset('crate')).props.height === 60,
|
||
`height=${(await asset('crate')).props.height}`,
|
||
);
|
||
check(
|
||
'the height reset is back after undo',
|
||
(await evaluate(`__gm.locateIcon('reset')`))?.y === heightReset.y,
|
||
JSON.stringify(await evaluate(`__gm.locateIcon('reset')`)),
|
||
);
|
||
|
||
// 4f. The same gold in the asset list: a row is marked while the asset carries anything the
|
||
// inspector marks. The Crate deviates twice, so it takes two resets to turn it plain.
|
||
const GOLD = '#f2c84b';
|
||
const PLAIN = '#e6e8ee';
|
||
const assetColor = (id) => json(`__gm.assetRowColor('${id}')`);
|
||
check('a deviating asset is marked gold in the list', (await assetColor('crate')) === GOLD);
|
||
check('an asset at its defaults is not', (await assetColor('cam')) === PLAIN, await assetColor('cam'));
|
||
// The Mover's `speed` is 200 against a declared variable default of 0. The inspector marks no
|
||
// variable, so neither may the list – the two must say the same thing.
|
||
check(
|
||
'a behavior is not marked for a variable value, which the inspector does not mark either',
|
||
(await assetColor('mover')) === PLAIN,
|
||
await assetColor('mover'),
|
||
);
|
||
await click(heightReset.x, heightReset.y);
|
||
check(
|
||
'the mark stays while the second deviation is left',
|
||
(await assetColor('crate')) === GOLD,
|
||
await assetColor('crate'),
|
||
);
|
||
const colorOnly = await evaluate(`__gm.locateIcon('reset')`);
|
||
await click(colorOnly.x, colorOnly.y);
|
||
check(
|
||
'resetting the last deviation clears the mark, without a reload',
|
||
(await assetColor('crate')) === PLAIN,
|
||
`${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`,
|
||
);
|
||
await shot('15b-asset-marks');
|
||
await undo();
|
||
await undo();
|
||
check(
|
||
'undo brings the mark back',
|
||
(await assetColor('crate')) === GOLD,
|
||
`${await assetColor('crate')} props=${JSON.stringify((await asset('crate')).props)}`,
|
||
);
|
||
|
||
// 4g. The list is grouped by category and in it by type (#176): a heading per category, under it
|
||
// a heading per type, groups and the assets in them alphabetical, each type heading with an
|
||
// icon of its own. A freshly opened project shows every category open and every group folded
|
||
// – reloaded here, since the modules before have unfolded groups by selecting.
|
||
await evaluate('__gm.state.loadProject(__gm.state.project, __gm.state.revision)');
|
||
await sleep(100);
|
||
const groups = await json('__gm.assetGroups()');
|
||
const categories = await json('__gm.assetCategories()');
|
||
const nameOf = Object.fromEntries(
|
||
(await json('__gm.state.project.assets.map((a) => [a.id, a.name])')).map(([id, name]) => [id, name]),
|
||
);
|
||
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
|
||
const sorted = (list) => list.every((v, i) => i === 0 || collator.compare(list[i - 1], v) <= 0);
|
||
check(
|
||
'the assets panel groups by category and type, groups and assets sorted by name',
|
||
groups.length >= 4 &&
|
||
categories.length >= 2 &&
|
||
sorted(categories.map((c) => CATEGORIES.indexOf(c.category)).map(String)) &&
|
||
categories.every((c) => sorted(groups.filter((g) => g.category === c.category).map((g) => g.label))) &&
|
||
groups.every((g) => sorted(g.ids.map((id) => nameOf[id]))) &&
|
||
groups.flatMap((g) => g.ids).length === Object.keys(nameOf).length,
|
||
JSON.stringify({
|
||
categories,
|
||
groups: groups.map((g) => [g.category, g.label, g.ids.map((id) => nameOf[id])]),
|
||
}),
|
||
);
|
||
check(
|
||
'a loaded project shows every category open and every group folded',
|
||
categories.every((c) => c.expanded) &&
|
||
groups.every((g) => !g.expanded) &&
|
||
(await json("__gm.assetRowShown('crate')")) === null,
|
||
JSON.stringify({ categories, groups: groups.map((g) => [g.label, g.expanded]) }),
|
||
);
|
||
// Read in the drawn picture: every icon inks its 16 units in the muted colour, and no two alike.
|
||
// Every pixel against the row's own background (`+`), since a 1.5 unit outline on half pixels is
|
||
// smoothed and never quite the colour; the exact colour (`#`) still shows where the ink is solid.
|
||
const MUTED = '#8a90a0';
|
||
const icons = await json(`(() => Object.fromEntries(__gm.assetGroups().map((g) => {
|
||
const c = __gm.assetGroupIcon(g.type);
|
||
const grid = [];
|
||
for (let dy = -7; dy <= 7; dy++) for (let dx = -7; dx <= 7; dx++) grid.push({ x: c.x + dx, y: c.y + dy });
|
||
const [background, ...ink] = __gm.pixels([{ x: c.x, y: c.y - 9 }, ...grid]);
|
||
const mask = ink.map((p) => (p === '${MUTED}' ? '#' : p === background ? '.' : '+')).join('');
|
||
return [g.type, mask];
|
||
})))()`);
|
||
const masks = Object.values(icons);
|
||
check(
|
||
'every heading draws its icon in the muted colour, and no two icons look alike',
|
||
masks.every((m) => m.replaceAll('.', '').length >= 20 && m.includes('#')) &&
|
||
new Set(masks.map((m) => m.replaceAll('#', '+'))).size === masks.length,
|
||
JSON.stringify(icons),
|
||
);
|
||
|
||
// A press on the heading unfolds the group, a second folds it again: rows come and go, the
|
||
// heading and its count stay, and the selection is left where it was.
|
||
await select({ kind: 'asset', assetId: 'cam' });
|
||
const sprites = await json("__gm.assetRow('group:sprite')");
|
||
const spriteGroup = async () => ({
|
||
expanded: (await json('__gm.assetGroups()')).find((g) => g.type === 'sprite').expanded,
|
||
crate: await json("__gm.assetRowShown('crate')"),
|
||
heading: await json("__gm.assetRow('group:sprite')"),
|
||
selected: (await json('__gm.state.selection'))?.assetId,
|
||
});
|
||
await click(sprites.x, sprites.y);
|
||
const unfolded = await spriteGroup();
|
||
check(
|
||
'a click on a folded heading unfolds its group below it and selects nothing',
|
||
unfolded.expanded === true &&
|
||
Boolean(unfolded.crate) &&
|
||
Boolean(unfolded.heading) &&
|
||
unfolded.heading.y < unfolded.crate.y &&
|
||
unfolded.selected === 'cam' &&
|
||
(await has('Sprite')),
|
||
JSON.stringify(unfolded),
|
||
);
|
||
await shot('15c-asset-groups');
|
||
await click(sprites.x, sprites.y);
|
||
const folded = await spriteGroup();
|
||
check(
|
||
'a second click folds it again',
|
||
folded.expanded === false &&
|
||
folded.crate === null &&
|
||
Boolean(folded.heading) &&
|
||
folded.selected === 'cam',
|
||
JSON.stringify(folded),
|
||
);
|
||
// Selecting a folded asset from elsewhere – a picker, a graph – unfolds its group.
|
||
await select({ kind: 'asset', assetId: 'crate' });
|
||
check(
|
||
'selecting an asset in a folded group unfolds it',
|
||
(await json('__gm.assetGroups()')).find((g) => g.type === 'sprite').expanded === true &&
|
||
Boolean(await json("__gm.assetRowShown('crate')")),
|
||
JSON.stringify(await json('__gm.assetGroups()')),
|
||
);
|
||
// Folding the group of the selected asset lets go of the selection – it would unfold it again.
|
||
const heading = await json("__gm.assetRow('group:sprite')");
|
||
await click(heading.x, heading.y);
|
||
const released = await spriteGroup();
|
||
check(
|
||
'folding the group of the selected asset folds it and clears the selection',
|
||
released.expanded === false && released.crate === null && released.selected === undefined,
|
||
JSON.stringify(released),
|
||
);
|
||
// A category folds the same way: its type headings go, and selecting an asset in it opens it again.
|
||
const visual = await json("__gm.assetRowShown('category:Visuals')");
|
||
await click(visual.x, visual.y);
|
||
const visualFolded = {
|
||
category: (await json('__gm.assetCategories()')).find((c) => c.category === 'Visuals'),
|
||
sprites: await json("__gm.assetRowShown('group:sprite')"),
|
||
};
|
||
await select({ kind: 'asset', assetId: 'crate' });
|
||
const visualAgain = {
|
||
category: (await json('__gm.assetCategories()')).find((c) => c.category === 'Visuals'),
|
||
crate: await json("__gm.assetRowShown('crate')"),
|
||
};
|
||
check(
|
||
'a click on a category heading folds its groups away, selecting an asset in it unfolds it',
|
||
visualFolded.category?.expanded === false &&
|
||
visualFolded.sprites === null &&
|
||
visualAgain.category?.expanded === true &&
|
||
Boolean(visualAgain.crate),
|
||
JSON.stringify({ visualFolded, visualAgain }),
|
||
);
|
||
await select(null);
|
||
const spritesAgain = await json("__gm.assetRow('group:sprite')");
|
||
await click(spritesAgain.x, spritesAgain.y);
|
||
|
||
// 4h. The same icons elsewhere: in front of every type in the `+` menu, and at the right edge of
|
||
// a scene object's row, where its type used to be spelled out.
|
||
await clickText('+');
|
||
const menuIcons = {};
|
||
for (const category of CATEGORIES) {
|
||
await clickText(category);
|
||
menuIcons[category] = await json('__gm.menuIcons()');
|
||
}
|
||
await shot('15d-add-menu');
|
||
await press('Escape');
|
||
const iconRows = Object.values(menuIcons).flat();
|
||
check(
|
||
'every type in the + menu has its icon in front',
|
||
iconRows.length === ASSET_TYPE_COUNT && iconRows.every((icon) => icon === true),
|
||
JSON.stringify(menuIcons),
|
||
);
|
||
await showTab('scene');
|
||
const first = (await json('__gm.hierarchyOrder()'))[0];
|
||
const typeOfFirst = await json(`(() => {
|
||
const scene = __gm.state.activeScene;
|
||
const o = scene.objects.find((x) => x.id === ${JSON.stringify(first)});
|
||
return __gm.state.project.assets.find((a) => a.id === o.assetId).type;
|
||
})()`);
|
||
const rowAt = await json(`__gm.hierarchyRow(${JSON.stringify(first)})`);
|
||
const iconAt = await json(`__gm.hierarchyIcon(${JSON.stringify(first)})`);
|
||
const ink = await json(`(() => {
|
||
const c = __gm.hierarchyIcon(${JSON.stringify(first)});
|
||
const grid = [];
|
||
for (let dy = -7; dy <= 7; dy++) for (let dx = -7; dx <= 7; dx++) grid.push({ x: c.x + dx, y: c.y + dy });
|
||
const [background, ...ink] = __gm.pixels([{ x: c.x - 12, y: c.y }, ...grid]);
|
||
return ink.filter((p) => p !== background).length;
|
||
})()`);
|
||
const typeWords = (await json(`__gm.locateAll(${JSON.stringify(typeOfFirst)})`)).filter(
|
||
(p) => Math.abs(p.y - rowAt.y) < 8,
|
||
);
|
||
check(
|
||
'a scene object shows its type as an icon at the right edge, no longer as a word',
|
||
Boolean(iconAt) && iconAt.x > rowAt.x && ink >= 20 && typeWords.length === 0,
|
||
JSON.stringify({ first, typeOfFirst, rowAt, iconAt, ink, typeWords }),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 4a-ii. Where no menu of its own opens, a right-click brings up the + menu (#244): on the empty
|
||
* space below the rows all of it, on a category heading the types of that category, on a group
|
||
* heading only its type (#361). The heading stays folded as it was.
|
||
*/
|
||
async function addMenuOnRightClick(t) {
|
||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||
const addRows = (rows) => String(rows) === String(CATEGORIES);
|
||
const heading = await json("__gm.assetRowShown('group:scene')");
|
||
const sceneFolded = async () =>
|
||
!(await json('__gm.assetGroups()')).find((g) => g.type === 'scene').expanded;
|
||
const foldedBefore = await sceneFolded();
|
||
await click(heading.x, heading.y, 'right');
|
||
const onHeading = await json('__gm.menuRows()');
|
||
const headingTitle = await json('__gm.menuTitle()');
|
||
check(
|
||
'a right-click on a group heading offers only its type, under "Create asset", and leaves the group as it was',
|
||
String(onHeading) === 'Scene' &&
|
||
headingTitle === 'Create asset' &&
|
||
(await sceneFolded()) === foldedBefore,
|
||
JSON.stringify({ onHeading, headingTitle, foldedBefore }),
|
||
);
|
||
await press('Escape');
|
||
const basics = await json("__gm.assetRowShown('category:Basics')");
|
||
await click(basics.x, basics.y, 'right');
|
||
const onCategory = {
|
||
rows: await json('__gm.menuRows()'),
|
||
submenus: await json('__gm.menuSubmenus()'),
|
||
};
|
||
onCategory.filedUnder = await json(
|
||
`${JSON.stringify(onCategory.rows)}.map((label) => __gm.addMenuCategory(label))`,
|
||
);
|
||
check(
|
||
'a right-click on a category heading offers the types of that category, sorted, without submenus',
|
||
onCategory.rows.length >= 4 &&
|
||
onCategory.rows.includes('Scene') &&
|
||
onCategory.filedUnder.every((c) => c === 'Basics') &&
|
||
String(onCategory.rows) === String([...onCategory.rows].sort()) &&
|
||
onCategory.submenus.every((open) => !open) &&
|
||
(await json('__gm.assetCategories()')).find((c) => c.category === 'Basics').expanded,
|
||
JSON.stringify(onCategory),
|
||
);
|
||
await press('Escape');
|
||
const groups = await json('__gm.assetGroups()');
|
||
const lastGroup = groups.at(-1);
|
||
const lastShown = await json(
|
||
`__gm.assetRowShown(${JSON.stringify(lastGroup.expanded ? lastGroup.ids.at(-1) : `group:${lastGroup.type}`)})`,
|
||
);
|
||
const panel = await json('__gm.assetsRect()');
|
||
const empty = { x: panel.x + panel.w / 2, y: panel.y + panel.h - 12 };
|
||
await click(empty.x, empty.y, 'right');
|
||
const onEmpty = await json('__gm.menuRows()');
|
||
check(
|
||
'a right-click on the empty space below the rows opens the + menu',
|
||
empty.y > lastShown.y + 20 && addRows(onEmpty),
|
||
JSON.stringify({ empty, lastShown, onEmpty }),
|
||
);
|
||
await shot('11c-add-menu-on-right-click');
|
||
const before = (await json('__gm.state.project.assets')).length;
|
||
// The headings of the panel read like the menu's categories and entries and are drawn first: the
|
||
// menu still wins, for the category as for the type.
|
||
await clickText('Logics');
|
||
const behaviorWords = (await json("__gm.locateAll('Behavior')")).length;
|
||
await clickText('Behavior');
|
||
const fromMenu = await json('__gm.state.project.assets.at(-1)');
|
||
check(
|
||
'an entry of that menu creates the asset like the + button does, even under a heading of the same word',
|
||
(await json('__gm.state.project.assets')).length === before + 1 &&
|
||
fromMenu.type === 'behavior' &&
|
||
behaviorWords >= 2,
|
||
JSON.stringify({ fromMenu: fromMenu && { type: fromMenu.type, name: fromMenu.name }, behaviorWords }),
|
||
);
|
||
await undo();
|
||
await showTab('scene');
|
||
}
|
||
|
||
/**
|
||
* 4a-iii. A switch in "General settings…" lists every category and type, the empty ones faint
|
||
* (#361); off again, only what holds assets is left. A right-click on an empty type's heading
|
||
* creates the first asset of it.
|
||
*/
|
||
async function emptyGroupsOnSwitch(t) {
|
||
const { check, json, shot, click, clickText, clickToolbar, sleep, undo, showTab } = t;
|
||
const flip = async () => {
|
||
await clickToolbar('menu');
|
||
await clickText('General settings…');
|
||
const box = await json('__gm.generalSettingsEmptyGroupsAt()');
|
||
await click(box.x, box.y);
|
||
await clickText('Apply');
|
||
await sleep(100);
|
||
};
|
||
const shape = (groups) => JSON.stringify(groups.map((g) => [g.type, g.ids]));
|
||
const before = await json('__gm.assetGroups()');
|
||
await flip();
|
||
const all = await json('__gm.assetGroups()');
|
||
const categories = await json('__gm.assetCategories()');
|
||
// One that an entry creates at once – a texture, a font or a sound opens a picker instead.
|
||
const empty = all.find(
|
||
(g) => g.ids.length === 0 && !['texture', 'font', 'sound', 'pixelart'].includes(g.type),
|
||
);
|
||
const full = all.find((g) => g.ids.length > 0);
|
||
const emptyCategory = CATEGORIES.find((c) =>
|
||
all.filter((g) => g.category === c).every((g) => g.ids.length === 0),
|
||
);
|
||
const seen = {
|
||
stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`),
|
||
emptyAlpha: await json(`__gm.assetRowAlpha('group:${empty?.type}')`),
|
||
fullAlpha: await json(`__gm.assetRowAlpha('group:${full?.type}')`),
|
||
categoryAlpha: emptyCategory ? await json(`__gm.assetRowAlpha('category:${emptyCategory}')`) : null,
|
||
filledCategoryAlpha: await json(`__gm.assetRowAlpha('category:${full?.category}')`),
|
||
};
|
||
check(
|
||
'the switch lists every category and type, the empty ones faint, the rest as before',
|
||
all.length === ASSET_TYPE_COUNT &&
|
||
String(categories.map((c) => c.category)) === String(CATEGORIES) &&
|
||
before.every(
|
||
(g) => JSON.stringify(all.find((a) => a.type === g.type)?.ids) === JSON.stringify(g.ids),
|
||
) &&
|
||
seen.stored?.showEmptyAssetGroups === true &&
|
||
Boolean(empty) &&
|
||
seen.emptyAlpha === 0.5 &&
|
||
seen.fullAlpha === 1 &&
|
||
seen.filledCategoryAlpha === 1 &&
|
||
(!emptyCategory || seen.categoryAlpha === 0.5),
|
||
JSON.stringify({ ...seen, empty: empty?.type, emptyCategory, all: all.length }),
|
||
);
|
||
await shot('11d-empty-asset-groups');
|
||
|
||
// Scrolled into view first: with every type listed the panel is longer than it is high.
|
||
const heading = await json(`__gm.assetRow('group:${empty.type}')`);
|
||
await click(heading.x, heading.y, 'right');
|
||
const offered = await json('__gm.menuRows()');
|
||
await clickText(offered[0]);
|
||
const made = await json('__gm.state.project.assets.at(-1)');
|
||
const after = {
|
||
offered,
|
||
made: made && { type: made.type, name: made.name },
|
||
alpha: await json(`__gm.assetRowAlpha('group:${empty.type}')`),
|
||
};
|
||
check(
|
||
'a right-click on an empty type offers just that type, and its first asset makes the heading solid',
|
||
offered.length === 1 && made?.type === empty.type && after.alpha === 1,
|
||
JSON.stringify(after),
|
||
);
|
||
await undo();
|
||
await showTab('scene');
|
||
|
||
await flip();
|
||
const off = await json('__gm.assetGroups()');
|
||
check(
|
||
'switched off again, only the types with assets are listed',
|
||
shape(off) === shape(before) &&
|
||
(await json(`JSON.parse(localStorage.getItem('game-maker.general-settings'))`)).showEmptyAssetGroups ===
|
||
false,
|
||
JSON.stringify({ before: shape(before), off: shape(off) }),
|
||
);
|
||
}
|