Notes could only be played one at a time, by a key or a behavior. A pattern is a stretch of notes for one instrument, drawn in a piano roll and played in time through the scheduler of #332. Songs (#334) will lay patterns out on tracks. - New asset type pattern under the heading Patterns. Instruments are listed under Instruments now, since a heading is one per type. - Notes are a start and a length in ticks, a note number and a velocity, kept to their rules on load and in every command. The instrument, the length in bars, the grid, the meter and the tempo are rows in the inspector. The loop is the asset's own data, the whole pattern while none is set. - Grids from 1/1 to 1/32 and the triplets 1/4T to 1/32T, all whole ticks. The meter only moves the bar lines, the notes stay put. - The tab shows the keys down the left, a ruler with bars, beats, loop and start along the top, the notes between them and a velocity lane below. Keys the instrument plays nothing for are grey, the names of a drum kit stand on its keys. - A click draws a note on the grid line before the pointer, as long as the last one drawn or lengthened and as hard as the last velocity set. The body moves a note, the right end lengthens it, Alt leaves the grid. A drag on empty grid picks with a band, Shift adds, a right-click deletes. - Keys: Space plays, Delete, Ctrl+C/V/D/A, the arrows by a semitone or a grid step and with Shift by an octave or a bar. A paste lands where playing starts. Every gesture is one undo step. - A click on the ruler sets where playing starts, a drag the loop or one of its ends, its menu loops a bar or the whole pattern. - The wheel scrolls, Shift across, on the ruler or with Alt it zooms. Ctrl+wheel stays with the browser, Pixi listens passively. - Play runs the loop through the scheduler. An edit goes to it on the next window without a restart, a new loop or tempo counts from what is planned. Another pattern shown or a project opened stops it. - The time axis and the ruler are their own pieces in editor/music for the song tab. The rules of the roll are pure functions in rollView and rollEdit. Findings: - Music.play could start after a stop that came while it was still loading its files. A run counter makes the late start give up. Music.edit now loads what a new note needs as well. - A click on an end of the loop would have set the loop again. A press there only moves the end once it is dragged. Tests: - pattern.test.ts (new, 9) and piano-roll.test.ts (new, 16): model, grids, meter, held notes, sequence, commands, loop, round trip, drum names, time lines, rows, hits, bands, lane, edit rules. - e2e 64 (new) on melektaus-atx: ALL PASSED, 20 checks. e2e 4 with 29 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 247 test files, 3839 tests. - Counter-checks: the move without its clamp, the sequence without the end of the pattern, the duplicate offset without whole grid steps each fail 1 test. Without passing edits to the scheduler the e2e check for a note drawn while playing fails, the rest passes. - Probe on stein2: the tab with a drum kit, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
531 lines
24 KiB
JavaScript
531 lines
24 KiB
JavaScript
import { colourDistance } from '../lib/harness.mjs';
|
||
import { inView } from './lib/scaffold.mjs';
|
||
|
||
/** One per entry of `ASSET_TYPES` – a new asset type counts here too. */
|
||
const ASSET_TYPE_COUNT = 29;
|
||
|
||
/** 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);
|
||
|
||
// 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 is two alphabetical groups (#43) – what
|
||
// can be placed in a scene, a line, then what only lives in the library. Its types do not
|
||
// all fit under the button on a canvas 800 high: there it scrolls.
|
||
await clickText('+');
|
||
const rows = await json('__gm.menuRows()');
|
||
const cut = rows.indexOf(null);
|
||
const placeable = rows.slice(0, cut);
|
||
const library = rows.slice(cut + 1);
|
||
check(
|
||
'the + menu is split by exactly one line',
|
||
rows.filter((r) => r === null).length === 1 && cut > 0,
|
||
JSON.stringify(rows),
|
||
);
|
||
check(
|
||
'every asset type is offered once, each group sorted by label',
|
||
placeable.length + library.length === ASSET_TYPE_COUNT &&
|
||
String(placeable) === String([...placeable].sort()) &&
|
||
String(library) === String([...library].sort()),
|
||
JSON.stringify({ placeable, library }),
|
||
);
|
||
check(
|
||
'placeable types come first, the library ones after the line',
|
||
placeable.includes('Sprite') &&
|
||
!placeable.includes('Scene') &&
|
||
library.includes('Scene') &&
|
||
library.includes('Texture'),
|
||
JSON.stringify({ placeable, library }),
|
||
);
|
||
// The height cap is the canvas, not a constant: fifteen entries used to run under the mask edge,
|
||
// where `locate` still finds them but a click lands on nothing (that is what took step 30 down).
|
||
// What does not fit is scrolled to, never drawn past the box.
|
||
await shot('tmp-plus-menu');
|
||
const menuBox = await json('__gm.overlayRect()');
|
||
const lastRow = await inView(
|
||
t,
|
||
'__gm.overlayRect()',
|
||
`__gm.locate(${JSON.stringify(library.at(-1))}, true)`,
|
||
);
|
||
const screenH = await json('__gm.app.screen.height');
|
||
check(
|
||
'the whole menu stands on screen, last entry reached by scrolling at most',
|
||
Boolean(lastRow) &&
|
||
lastRow.y < menuBox.y + menuBox.h &&
|
||
menuBox.y + menuBox.h <= screenH &&
|
||
menuBox.y >= 0,
|
||
JSON.stringify({ menuBox, lastRow, screenH }),
|
||
);
|
||
// A tenth of the canvas stays free below every popup. The margin used to live only in the height
|
||
// cap, never in the placement, so a popup that had to be shifted up ended flush on the bottom
|
||
// edge with the whole gap collected above it. Above it is a budget, not a distance: this menu
|
||
// hangs off a button near the top and stays there.
|
||
const above = menuBox.y;
|
||
const below = screenH - (menuBox.y + menuBox.h);
|
||
const margin = Math.round(screenH * 0.1);
|
||
check(
|
||
'the menu keeps a tenth of the canvas free below, and stays under its button',
|
||
below >= margin - 1 && above >= 0 && above < margin,
|
||
JSON.stringify({ above, below, margin, screenH }),
|
||
);
|
||
await clickText('Sprite');
|
||
const names = await json('__gm.state.project.assets.map(a => a.name)');
|
||
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
|
||
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('+');
|
||
const entry = await evaluate(`__gm.locate('Behavior', true)`);
|
||
await click(entry.x, entry.y);
|
||
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 type (#176): a heading per type, groups and the assets in them
|
||
// alphabetical, each heading with an icon of its own. A freshly opened project shows 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 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 type, groups and assets sorted by name',
|
||
groups.length >= 4 &&
|
||
sorted(groups.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(groups.map((g) => [g.label, g.ids.map((id) => nameOf[id])])),
|
||
);
|
||
check(
|
||
'a loaded project shows every group folded',
|
||
groups.every((g) => !g.expanded) && (await json("__gm.assetRowShown('crate')")) === null,
|
||
JSON.stringify(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('Sprites')),
|
||
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),
|
||
);
|
||
|
||
// 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 menuRows = await json('__gm.menuRows()');
|
||
const menuIcons = await json('__gm.menuIcons()');
|
||
await shot('15d-add-menu');
|
||
await press('Escape');
|
||
check(
|
||
'every type in the + menu has its icon in front',
|
||
menuRows.filter(Boolean).length >= 15 &&
|
||
menuRows.every((row, i) => (row === null) === (menuIcons[i] === null) && menuIcons[i] !== false),
|
||
JSON.stringify({ menuRows, 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 – on a group heading or on the empty space below the rows –
|
||
* a right-click brings up the + menu (#244). The heading stays folded as it was.
|
||
*/
|
||
async function addMenuOnRightClick(t) {
|
||
const { check, json, shot, click, clickText, press, undo, showTab } = t;
|
||
const addRows = (rows) =>
|
||
Array.isArray(rows) && rows.filter(Boolean).length === ASSET_TYPE_COUNT && rows.includes('Sprite');
|
||
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()');
|
||
check(
|
||
'a right-click on a group heading opens the + menu and leaves the group as it was',
|
||
addRows(onHeading) && (await sceneFolded()) === foldedBefore,
|
||
JSON.stringify({ onHeading, foldedBefore }),
|
||
);
|
||
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;
|
||
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',
|
||
(await json('__gm.state.project.assets')).length === before + 1 && fromMenu.type === 'behavior',
|
||
JSON.stringify(fromMenu && { type: fromMenu.type, name: fromMenu.name }),
|
||
);
|
||
await undo();
|
||
await showTab('scene');
|
||
}
|