An inventory grid could not scroll, because nothing could cut a child off at an edge. A game also had no way to hear the mouse wheel. Both are needed for a scrolling list, a dialog text or a window with more content than room. - A new object type Clip area has width and height and turns, scales and mirrors with its parent. The size gizmo drags width and height. - Everything below a clip area, on every level, is drawn only inside its rectangle, in the Scene view as in the game. Nested areas cut to their intersection. The rule works on a convex polygon, so a turned area cuts right as well. - On click, Is hovered and On mouse wheel do not hit a cut-off part. The Scene view does not pick it either. - In the Scene view the area is a dashed frame that is grabbed only at its edge, so a click inside lands on the content. The eye under Shape hides the frame. In the game the area is invisible. - While an object below an area or one of its parents is selected, the Scene view shows the cut-off part at 30 % opacity and it can be grabbed there. The rule lives in core/properties/clip.ts. - The Properties node knows the type Clip area. - The new event node On mouse wheel fires by the rule of On click. Its delta counts in notches, positive is down, and a frame's turns arrive as one value. It also gives x and y. Limits: - Only sprites and texts show the faded rest. Tilemaps and particles are cut and can be grabbed when open, but are not shown faded. - Masks by texture shape are out, as noted in the issue. Tests: - ./check.sh: 2773 tests, CHECK EXIT 0. - Full e2e run (local): ALL PASSED, 1549 checks, measured. - tests/core/clip-areas.test.ts: 12 tests for the cut rule, scene and game, save and load, hits, the wheel with and without owner, the notch conversion and the open rule. - e2e 58, 5 checks with real mouse and wheel: the scene cuts (74 of 74 red inside, 0 outside), a click on the cut part picks nothing, the selected rest is faded and grabbable, the game cuts, one notch logs 1. - e2e 04 and 46 count the object types and the eye rows. They now include Clip area. - Counter-checks: 6 in unit tests and 3 in the browser, all fail as expected. The originals were restored and checked with cmp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
312 lines
14 KiB
JavaScript
312 lines
14 KiB
JavaScript
/**
|
||
* The top bar after its rework (#183): five icon buttons and no captions at all. What it used to
|
||
* spell out moved into the one menu – the project list into "Open project…", the project name and
|
||
* the start-scene switch into "Project settings…" next to the collision layers.
|
||
*
|
||
* Runs last, and on purpose: the project picker is opened here and left again with Cancel. Opening
|
||
* or deleting a project for real would pull the project out from under every step before this one.
|
||
*/
|
||
export async function topbar(t) {
|
||
const { check, json, has, shot, clickText, clickToolbar, typeText, press, sleep, waitUntil } = t;
|
||
|
||
await press('Escape');
|
||
|
||
// 46a. Nothing in the bar is written any more. The five words it used to carry are asked for as
|
||
// drawn text: the buttons are still there, only their captions are gone.
|
||
const captions = {
|
||
project: await has('Project'),
|
||
undo: await has('Undo'),
|
||
redo: await has('Redo'),
|
||
play: await has('Play'),
|
||
pause: await has('Pause'),
|
||
};
|
||
check(
|
||
'the top bar carries no captions any more – menu, undo, redo, play and pause are icons',
|
||
Object.values(captions).every((shown) => shown === false),
|
||
JSON.stringify(captions),
|
||
);
|
||
const buttons = await json('__gm.runButtons()');
|
||
check(
|
||
'in edit mode the run buttons offer play and nothing to pause',
|
||
buttons.play === 'play' &&
|
||
buttons.pause === 'pause' &&
|
||
buttons.playEnabled === true &&
|
||
buttons.pauseEnabled === false,
|
||
JSON.stringify(buttons),
|
||
);
|
||
// The pair is centred on the bar itself. The row cannot do that on its own: the spacers around
|
||
// the two buttons share the free space with the flexible notice and so get a third of it each,
|
||
// which used to leave the pair well left of the middle.
|
||
const play = await json('__gm.toolbarAt("play")');
|
||
const pause = await json('__gm.toolbarAt("pause")');
|
||
const barCentre = await json('window.innerWidth / 2');
|
||
check(
|
||
'play and pause sit on the middle of the bar, not on the middle of what is left over',
|
||
Math.abs((play.x + pause.x) / 2 - barCentre) <= 2,
|
||
JSON.stringify({ middle: (play.x + pause.x) / 2, barCentre }),
|
||
);
|
||
await shot('46-topbar');
|
||
|
||
// 46b. The one menu, with the separator between what opens a project and what changes the one
|
||
// that is open. Clearing the saved data is checked in 32.
|
||
await clickToolbar('menu');
|
||
const rows = await json('__gm.menuRows()');
|
||
const expected = [
|
||
'New project',
|
||
'Open project…',
|
||
null,
|
||
'Snapshots…',
|
||
'Project settings…',
|
||
'Clear saved data',
|
||
null,
|
||
'Plugins',
|
||
];
|
||
check(
|
||
'the menu button opens new project, the project picker, the settings, clearing saves and the plugins',
|
||
JSON.stringify(rows) === JSON.stringify(expected),
|
||
JSON.stringify(rows),
|
||
);
|
||
|
||
// 46c. The picker lists what the account owns, with the open project picked, and Cancel leaves
|
||
// that project exactly where it was.
|
||
await clickText('Open project…');
|
||
// The picker fetches the list before it can open, so it is waited for rather than read at once.
|
||
await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' });
|
||
const picker = await json('__gm.projectPicker()');
|
||
const projectId = await json('__gm.state.project.id');
|
||
const projectName = await json('__gm.state.project.name');
|
||
check(
|
||
'"Open project…" lists the projects with the open one picked',
|
||
picker !== null && picker.picked === projectId && picker.names.includes(projectName),
|
||
JSON.stringify({ picker, projectId, projectName }),
|
||
);
|
||
await clickText('Cancel');
|
||
check(
|
||
'Cancel closes the picker and leaves the project loaded',
|
||
(await json('__gm.projectPicker()')) === null && (await json('__gm.state.project.id')) === projectId,
|
||
JSON.stringify({ picker: await json('__gm.projectPicker()'), id: await json('__gm.state.project.id') }),
|
||
);
|
||
|
||
// 46d. The settings dialog holds the name, the start-scene switch and the collision layers of
|
||
// #152 – the three things the bar itself used to carry.
|
||
await clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
const settings = await json('__gm.projectSettings()');
|
||
const layers = await json('__gm.collisionLayers()');
|
||
check(
|
||
'"Project settings…" opens on the project as it stands',
|
||
settings?.name === projectName &&
|
||
(settings.playFrom === 'start' || settings.playFrom === 'active') &&
|
||
JSON.stringify(layers.dialog) === JSON.stringify(layers.project),
|
||
JSON.stringify({ settings, layers }),
|
||
);
|
||
|
||
// 46e. Renaming through it, and the icon buttons undoing and redoing it. The name is read off the
|
||
// project rather than written down here: CI seeds the same project under a different name.
|
||
const nameField = await t.locate(projectName);
|
||
await t.doubleClick(nameField.x, nameField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('Renamed by 46');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(200);
|
||
check(
|
||
'Apply renames the project',
|
||
(await json('__gm.state.project.name')) === 'Renamed by 46',
|
||
await json('__gm.state.project.name'),
|
||
);
|
||
await clickToolbar('undo');
|
||
await sleep(200);
|
||
const undone = await json('__gm.state.project.name');
|
||
await clickToolbar('redo');
|
||
await sleep(200);
|
||
const redone = await json('__gm.state.project.name');
|
||
check(
|
||
'the undo and redo buttons of the bar walk the rename back and forth',
|
||
undone === projectName && redone === 'Renamed by 46',
|
||
JSON.stringify({ undone, redone, was: projectName }),
|
||
);
|
||
// Back to the name the rest of the world knows it by.
|
||
await clickToolbar('undo');
|
||
await sleep(200);
|
||
check(
|
||
'the project is handed back under its own name',
|
||
(await json('__gm.state.project.name')) === projectName,
|
||
await json('__gm.state.project.name'),
|
||
);
|
||
|
||
// 46f. The symbol scale in the same dialog: 1 by default, typed down to 0.5, and every icon of the
|
||
// scene drawn at half its side (18 → 9 world units), undone in one step.
|
||
const iconSize = () =>
|
||
json(`(() => {
|
||
const p = __gm.state.project;
|
||
const scene = p.assets.find((a) => a.id === p.activeSceneId);
|
||
const id = (scene?.objects ?? []).map((o) => o.id).find((id) => __gm.sceneIcon(id)?.visible);
|
||
return id ? __gm.sceneIcon(id).size : null;
|
||
})()`);
|
||
await clickToolbar('menu');
|
||
await clickText('Project settings…');
|
||
const opened = await json('__gm.projectSettings()');
|
||
const field = await json('__gm.projectSettingsSymbolScaleAt()');
|
||
await t.click(field.x, field.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('0.5');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(200);
|
||
const scaled = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() };
|
||
await clickToolbar('undo');
|
||
await sleep(200);
|
||
const restored = { project: await json('__gm.state.project.symbolScale'), icon: await iconSize() };
|
||
check(
|
||
'the symbol scale starts at 1, Apply halves every scene icon, and undo gives the size back',
|
||
opened?.symbolScale === 1 &&
|
||
scaled.project === 0.5 &&
|
||
Math.abs(scaled.icon - 9) < 1e-9 &&
|
||
restored.project === 1 &&
|
||
Math.abs(restored.icon - 18) < 1e-9,
|
||
JSON.stringify({ opened, scaled, restored }),
|
||
);
|
||
|
||
// 46g. The eye in the scene tab's header: every placeable type with a switch for its symbol and its name, and a
|
||
// outline of a collider and of a tilemap's solid cells besides. Sprite, Text and Tilemap have no symbol, so that switch is
|
||
// greyed out. Real clicks flip the scene view, and the switches are saved but no undo step.
|
||
await json('(__gm.showTab("scene"), true)');
|
||
const firstOf = (type) =>
|
||
json(`(() => {
|
||
const p = __gm.state.project;
|
||
const scene = p.assets.find((a) => a.id === p.activeSceneId);
|
||
const o = (scene?.objects ?? []).find((o) => p.assets.find((a) => a.id === o.assetId)?.type === ${JSON.stringify(type)} && o.enabled !== false);
|
||
return o ? o.id : null;
|
||
})()`);
|
||
const ids = {
|
||
sprite: await firstOf('sprite'),
|
||
collider: 'vis-col-obj',
|
||
behavior: await firstOf('behavior'),
|
||
tilemap: 'vis-map-obj',
|
||
};
|
||
// A map of its own with one solid row, laid over the sprite so it is on screen: the seed has none.
|
||
// A collider of its own there too: in the full run the first one in the scene is a leftover of an
|
||
// earlier module, off screen, and its outline reads as hidden before any switch is touched.
|
||
await t.mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const assets = __gm.state.project.assets;
|
||
const at = scene.objects.find((o) => o.id === ${JSON.stringify(ids.sprite)}).position;
|
||
assets.push({ id: 'vis-wall', type: 'tile', name: 'Vis wall', props: { kind: 'none', solid: true }, pictures: [] });
|
||
assets.push({ id: 'vis-map', type: 'tilemap', name: 'Vis map', props: { columns: 3, rows: 1, tileSize: 32 },
|
||
layers: [{ id: 'vis-layer', name: 'Layer 1', cells: { '0,0': 'vis-wall', '1,0': 'vis-wall', '2,0': 'vis-wall' } }] });
|
||
const o = structuredClone(scene.objects[0]);
|
||
Object.assign(o, { id: 'vis-map-obj', assetId: 'vis-map', name: 'Vis map', position: { ...at },
|
||
parentId: '', overrides: {}, instanceEdits: {} });
|
||
scene.objects.push(o);
|
||
assets.push({ id: 'vis-col', type: 'collider', name: 'Vis collider',
|
||
props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
|
||
scene.objects.push(Object.assign(structuredClone(o), { id: 'vis-col-obj', assetId: 'vis-col', name: 'Vis collider' }));
|
||
__gm.state.select(null)`);
|
||
const undoBefore = await json('__gm.state.commands.canUndo');
|
||
await t.showTab('scene');
|
||
const eye = await json(`__gm.sceneHeaderAt('visibility')`);
|
||
await t.click(eye.x, eye.y);
|
||
const popup = await json('__gm.visibilityPopup()');
|
||
const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part);
|
||
const types = [...new Set((popup ?? []).map((e) => e.type))];
|
||
check(
|
||
'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider, tilemap and clip area',
|
||
JSON.stringify(types) ===
|
||
JSON.stringify([
|
||
'behavior',
|
||
'camera',
|
||
'clip',
|
||
'collider',
|
||
'container',
|
||
'particles',
|
||
'rigidbody',
|
||
'sprite',
|
||
'text',
|
||
'tilemap',
|
||
]) &&
|
||
entry(popup, 'sprite', 'symbol')?.enabled === false &&
|
||
entry(popup, 'tilemap', 'label')?.enabled === true &&
|
||
entry(popup, 'sprite', 'label')?.on === false &&
|
||
entry(popup, 'collider', 'shape')?.on === true &&
|
||
entry(popup, 'tilemap', 'shape')?.on === true &&
|
||
entry(popup, 'clip', 'shape')?.on === true &&
|
||
popup.filter((e) => e.part === 'shape').length === 3,
|
||
JSON.stringify(popup),
|
||
);
|
||
const flip = async (type, part) => {
|
||
const at = entry(await json('__gm.visibilityPopup()'), type, part);
|
||
await t.click(at.x, at.y);
|
||
await sleep(150);
|
||
};
|
||
const labelIds = async () => (await json('__gm.sceneLabels()')).map((l) => l.id);
|
||
const mapLooks = async () => ({
|
||
solids: (await json(`__gm.tilemap.solids(${JSON.stringify(ids.tilemap)})`))?.visible ?? null,
|
||
drawn: (await json(`__gm.tilemap.info(${JSON.stringify(ids.tilemap)})`)) !== null,
|
||
});
|
||
const before = {
|
||
shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`),
|
||
labels: await labelIds(),
|
||
icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible,
|
||
map: await mapLooks(),
|
||
};
|
||
await flip('collider', 'shape');
|
||
await flip('tilemap', 'shape');
|
||
await flip('behavior', 'label');
|
||
await flip('behavior', 'symbol');
|
||
await flip('sprite', 'label');
|
||
const after = {
|
||
shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`),
|
||
labels: await labelIds(),
|
||
icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible,
|
||
popup: await json('__gm.visibilityPopup()'),
|
||
saved: await json('__gm.state.project.sceneVisibility'),
|
||
map: await mapLooks(),
|
||
};
|
||
await shot('46g-visibility');
|
||
check(
|
||
"a tilemap's Shape hides the outline of its solid cells and leaves the map itself drawn",
|
||
before.map.solids === true &&
|
||
before.map.drawn === true &&
|
||
after.map.solids === false &&
|
||
after.map.drawn === true &&
|
||
after.saved.tilemap.shape === false,
|
||
JSON.stringify({ before: before.map, after: after.map }),
|
||
);
|
||
check(
|
||
'switching hides the collider outline and the behavior icon and name, and puts the sprite name on',
|
||
before.shape?.visible === true &&
|
||
after.shape?.visible === false &&
|
||
after.shape?.clickable === false &&
|
||
before.icon === true &&
|
||
after.icon === false &&
|
||
before.labels.includes(ids.behavior) &&
|
||
!after.labels.includes(ids.behavior) &&
|
||
!before.labels.includes(ids.sprite) &&
|
||
after.labels.includes(ids.sprite) &&
|
||
entry(after.popup, 'collider', 'shape')?.on === false,
|
||
JSON.stringify({ ids, before, after: { ...after, popup: undefined } }),
|
||
);
|
||
check(
|
||
'the switches go into the project and not onto the undo stack',
|
||
after.saved.collider.shape === false &&
|
||
after.saved.behavior.label === false &&
|
||
after.saved.sprite.label === true &&
|
||
(await json('__gm.state.commands.canUndo')) === undoBefore,
|
||
JSON.stringify({ saved: after.saved, undoBefore }),
|
||
);
|
||
// Handed back as found, for whatever runs after this.
|
||
await flip('collider', 'shape');
|
||
await flip('tilemap', 'shape');
|
||
await flip('behavior', 'label');
|
||
await flip('behavior', 'symbol');
|
||
await flip('sprite', 'label');
|
||
await press('Escape');
|
||
await t.mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => o.id !== 'vis-map-obj' && o.id !== 'vis-col-obj');
|
||
const assets = __gm.state.project.assets;
|
||
for (const id of ['vis-map', 'vis-wall', 'vis-col']) assets.splice(assets.findIndex((a) => a.id === id), 1)`);
|
||
await t.saved();
|
||
}
|