A video becomes an asset like a sound: uploaded once, converted to one format by a worker of its own, cut into clips in its tab, and worn by a sprite through its Texture row. Only Chrome is a target, without alpha. - Server: POST ?r=videos queues the upload under the hash of its bytes, so the MP4's name is known at once. The worker (VideoWorker over VideoFormat) writes H.264/AAC, yuv420p, constant frame rate, a keyframe every 15 frames, at most 1920x1080, with a poster and the details beside it. A failed job says why and is tried again on the next upload. - An upload may be up to 2 GB. It is streamed from the request to disk and hashed there, so php-fpm holds none of it in memory. The other routes keep their 256 MB. - The worker runs in the container game-maker-video on stein2, a compose project of its own (tools/video/), and in CI beside the PHP server. The CI image has ffmpeg for it. - Model: asset type video with size, length, frame rate and sound, and clips like a sound's. The clip helpers and commands serve both. - A sprite's Texture row offers videos and clips after the pictures. resolvePicture names a playing video by a key, and the texture library hands it to a VideoLibrary with one player per key. - The editor shows a placed video as a quiet preview. Play starts it over at its clip's start and lets it be heard through the master bus, a pause holds it, Esc makes it quiet again. - Video tab: the picture, a ruler, the clip strip and a track on the Sound tab's time axis. A drag spans a clip, a handle moves an edge, Play plays the picked clip. - The picker shows a video on its way with its progress and adopts an upload from it once it is ready. Dropping a video file does the same. Decided on my own: - Two sprites wearing the same clip share one player and show the same picture. Control per instance belongs to the follow-up with nodes. - A video plays in real time, the game's time scale does not slow it. - The container may use 3 of the 4 cores of stein2. Findings: - The first real upload (279 MB) got 413: the API's own ceiling of 256 MB for every request, not nginx or the Brandmauer. - Chrome has no picture of a video that never played, and a Pixi VideoSource drawn before the first frame stays empty for good. The texture is made in the first frame callback (openVideo). - A player nobody asks for in 2 s is let go. The inspector's PREVIEW asked only on an edit, so with the sprite not on screen its texture was destroyed under it and the batcher threw on every frame. The preview now redraws every frame while it shows a video, like a Time shader. A let-go texture keeps an empty source instead of none. - VideoSource.load() reloads the element and cuts off an earlier play(). Playing starts after loadeddata. Tests: - PHP videos.test.php: 68 checks, with the real ffmpeg - 300 MB upload through Brandmauer and nginx: 201 in 21 s, php-fpm at 13.7 MB RSS at most - Unit videos.test.ts: 20 tests - e2e 66 (stein2, atx was off): ALL PASSED, 18 checks; e2e 04 with the new asset count green - Gate (atx): 294 files, 4520 tests. serialize.test.ts used video as its example of an unknown type and now uses a made-up one. - Counter-checks: a keyframe gap of 30 fails the PHP check, a key that keeps the video's end fails 2 unit tests, a clip without its end fails "plays only its stretch" in e2e 66 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 = 32;
|
||
/** 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) }),
|
||
);
|
||
}
|