The pointer drawn into an editor recording lived as paths in code, so it could not be looked at or edited by hand. The shapes are now SVG files, and the editor can show the same pointer on screen, so what is recorded is what was seen. - src/core/recording/cursors/ holds one file per shape: arrow, hand, open-hand, fist, move, crosshair, text and resize. Resize is turned for the vertical and diagonal directions. - data-hot="x y" on the <svg> is the hotspot in viewBox units. Width and height in the file are ignored, and anything before <svg>, such as <?xml ...> from a drawing program, is cut off. - The open hand had six fingers. It has a thumb and four now, the middle one longest. - CURSOR_SIZE = 0.8 scales every pointer the same on screen and in a recording, so the files keep their units. - General settings has "Custom mouse pointer", on by default. It hangs the files into Pixi's cursorStyles as CSS cursors with the keyword as fallback, so the system still draws the pointer and the canvas does not. Off gives Pixi's own values back at once. - A recording draws the pointer from the same files at its own size, so it stays sharp in 4K. It still reads the shape from the canvas when that holds a url(...) cursor. Tests: - Unit recording and view-tween: 3 new, green. They read viewBox and data-hot in an edited form, the boxes of turned shapes, the default size and old settings without the switch. Red without data-hot and without cutting before <svg>. - e2e 46i-ii: the canvas cursor is a url(...) ending in default and the image loads. Off gives inherit and stores false, on brings the file back. Red without useCursorFiles. - e2e 67e: the recording is still black at the mouse position. - The README names 2046 e2e checks. - Gate on melektaus-atx: 304 files, 4702 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
24 KiB
JavaScript
545 lines
24 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…',
|
||
'Release…',
|
||
'Project settings…',
|
||
'Master routing',
|
||
'Clear saved data',
|
||
null,
|
||
'Start recording…',
|
||
null,
|
||
'General settings…',
|
||
'Plugins',
|
||
];
|
||
check(
|
||
'the menu button opens new project, the project picker, the settings, the master routing, clearing saves, recording the editor, the general settings 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'),
|
||
);
|
||
|
||
// 46h. The icon buttons say what they do (#209). The pointer has to rest for the sign to show; it
|
||
// names the step redo would take and the keys, and sits under its button, inside the window.
|
||
const tooltip = () => json('__gm.tooltip()');
|
||
const redoAt = await json('__gm.toolbarAt("redo")');
|
||
const redoLabel = await json('__gm.state.commands.redoLabel');
|
||
await t.mouse('mouseMoved', redoAt.x - 1, redoAt.y);
|
||
await t.mouse('mouseMoved', redoAt.x, redoAt.y);
|
||
const atOnce = await tooltip();
|
||
await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the redo button' });
|
||
const redoTip = await tooltip();
|
||
const windowSize = await json('({ w: window.innerWidth, h: window.innerHeight })');
|
||
await shot('46h-tooltip');
|
||
check(
|
||
'resting on the redo button shows "Redo <step> (Ctrl+Y)" under it, after a wait and not at once',
|
||
atOnce === null &&
|
||
typeof redoLabel === 'string' &&
|
||
redoLabel.length > 0 &&
|
||
redoTip.text === `Redo ${redoLabel} (Ctrl+Y)` &&
|
||
redoTip.box.y >= redoTip.anchor.y + redoTip.anchor.h &&
|
||
redoTip.box.x === Math.round(redoTip.anchor.x) &&
|
||
redoTip.box.x + redoTip.box.w <= windowSize.w &&
|
||
(await has(redoTip.text)) === false,
|
||
JSON.stringify({ atOnce, redoLabel, redoTip }),
|
||
);
|
||
// Off the button it goes. The wheel takes it away as well, and the button stays quiet until the
|
||
// pointer has left it; a click does the same, here with the menu opening under it.
|
||
await t.mouse('mouseMoved', redoAt.x + 90, redoAt.y);
|
||
await sleep(100);
|
||
const afterLeaving = await tooltip();
|
||
const menuAt = await json('__gm.toolbarAt("menu")');
|
||
const restOnMenu = async () => {
|
||
await t.mouse('mouseMoved', menuAt.x - 1, menuAt.y);
|
||
await t.mouse('mouseMoved', menuAt.x, menuAt.y);
|
||
await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the menu button' });
|
||
return tooltip();
|
||
};
|
||
const menuTip = await restOnMenu();
|
||
await t.wheel(menuAt.x, menuAt.y, 40);
|
||
const afterWheel = await tooltip();
|
||
await t.mouse('mouseMoved', menuAt.x + 1, menuAt.y);
|
||
await sleep(800);
|
||
const afterWaiting = await tooltip();
|
||
await t.mouse('mouseMoved', menuAt.x + 200, menuAt.y);
|
||
const again = await restOnMenu();
|
||
await clickToolbar('menu');
|
||
const afterClick = await tooltip();
|
||
const menuOpen = (await json('__gm.menuRows()')) !== null;
|
||
await press('Escape');
|
||
check(
|
||
'the tooltip goes when the pointer leaves, and the wheel or a click takes it away until the pointer comes back',
|
||
afterLeaving === null &&
|
||
menuTip.text === 'Menu' &&
|
||
afterWheel === null &&
|
||
afterWaiting === null &&
|
||
again.text === 'Menu' &&
|
||
afterClick === null &&
|
||
menuOpen === true,
|
||
JSON.stringify({ afterLeaving, menuTip, afterWheel, afterWaiting, again, afterClick, menuOpen }),
|
||
);
|
||
// At the right border the sign turns left: flush with the right edge of its button. The button
|
||
// nearest that border is the reset of an inspector row, which an override brings up.
|
||
const edgeObject = await json(`(() => {
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const o = scene.objects.find((o) => p.assets.find((a) => a.id === o.assetId)?.type === 'sprite');
|
||
return o ? { scene: scene.id, id: o.id, overrides: o.overrides } : null;
|
||
})()`);
|
||
const setOverrides = (overrides) =>
|
||
t.mutate(
|
||
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(edgeObject.id)}).overrides = ${JSON.stringify(overrides)}`,
|
||
);
|
||
await setOverrides({ ...edgeObject.overrides, width: 77 });
|
||
await t.select({ kind: 'object', holderId: edgeObject.scene, objectId: edgeObject.id });
|
||
const resetAt = await t.findIcon('reset', false);
|
||
await t.mouse('mouseMoved', resetAt.x - 1, resetAt.y);
|
||
await t.mouse('mouseMoved', resetAt.x, resetAt.y);
|
||
await waitUntil('__gm.tooltip() !== null', { label: 'the tooltip of the reset button' });
|
||
const edgeTip = await tooltip();
|
||
await shot('46h-tooltip-edge');
|
||
check(
|
||
'a tooltip that would leave the window on the right turns left, and names the one thing its button does',
|
||
edgeTip.text === 'Reset to asset' &&
|
||
edgeTip.anchor.x + edgeTip.box.w > windowSize.w &&
|
||
edgeTip.box.x + edgeTip.box.w === Math.round(edgeTip.anchor.x + edgeTip.anchor.w) &&
|
||
edgeTip.box.x + edgeTip.box.w <= windowSize.w,
|
||
JSON.stringify({ edgeTip, windowSize }),
|
||
);
|
||
await t.mouse('mouseMoved', windowSize.w / 2, windowSize.h / 2);
|
||
await setOverrides(edgeObject.overrides);
|
||
await t.select(null);
|
||
|
||
// 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',
|
||
'composition',
|
||
'container',
|
||
'joint',
|
||
'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();
|
||
|
||
// 46i. "General settings…" (#309): the zoom transition in ms, kept in this browser. The harness
|
||
// sets 0 on load. At 1000 ms one wheel step in the scene view is sampled on its way – the zoom
|
||
// between the two steps, the world point under the pointer still under it – and it lands on
|
||
// the very view a jump of 0 ms reaches.
|
||
await clickToolbar('menu');
|
||
await clickText('General settings…');
|
||
const general = await json('__gm.generalSettingsDialog()');
|
||
const msField = await json('__gm.generalSettingsZoomAt()');
|
||
await t.click(msField.x, msField.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('1000');
|
||
await press('Enter');
|
||
await clickText('Apply');
|
||
await sleep(100);
|
||
const kept = {
|
||
dialog: await json('__gm.generalSettingsDialog()'),
|
||
ms: await json('__gm.zoomTransition()'),
|
||
stored: await json(`JSON.parse(localStorage.getItem('game-maker.general-settings') ?? 'null')`),
|
||
};
|
||
check(
|
||
'"General settings…" opens on the kept transition, and Apply keeps the one typed in the browser',
|
||
general?.zoomTransitionMs === 0 &&
|
||
kept.dialog === null &&
|
||
kept.ms === 1000 &&
|
||
kept.stored?.zoomTransitionMs === 1000,
|
||
JSON.stringify({ general, kept }),
|
||
);
|
||
|
||
// 46i-ii. The custom mouse pointer, on by default: the canvas shows the cursor file an editor
|
||
// recording draws as its CSS cursor (Chrome took the value, the picture loads, the keyword
|
||
// stays at the end). Switched off in the dialog the system's keyword is back, on again the file.
|
||
// `json` would stringify the promise itself: this one hands back its JSON once it is settled.
|
||
const pictureLoads = async () =>
|
||
JSON.parse(
|
||
await t.evaluate(`(async () => {
|
||
const url = /^url\\("([^"]*)"\\)/.exec(__gm.canvasCursor())?.[1];
|
||
if (!url) return 'null';
|
||
const image = new Image();
|
||
image.src = url;
|
||
try { await image.decode(); return JSON.stringify([image.naturalWidth, image.naturalHeight]); }
|
||
catch { return '"broken"'; }
|
||
})()`),
|
||
);
|
||
const switchCursor = async () => {
|
||
await clickToolbar('menu');
|
||
await clickText('General settings…');
|
||
const box = await json('__gm.generalSettingsCustomCursorAt()');
|
||
await t.click(box.x, box.y);
|
||
await clickText('Apply');
|
||
await sleep(100);
|
||
};
|
||
const cursorOn = { css: await json('__gm.canvasCursor()'), size: await pictureLoads() };
|
||
await switchCursor();
|
||
const cursorOff = { css: await json('__gm.canvasCursor()') };
|
||
const storedOff = await json(
|
||
`JSON.parse(localStorage.getItem('game-maker.general-settings')).customCursor`,
|
||
);
|
||
await switchCursor();
|
||
const cursorBack = await json('__gm.canvasCursor()');
|
||
check(
|
||
'the custom mouse pointer is on by default as the CSS cursor, General settings switch it off and on',
|
||
general?.customCursor === true &&
|
||
/^url\("data:image\/svg\+xml,.*"\) \d+ \d+, default$/.test(cursorOn.css) &&
|
||
Array.isArray(cursorOn.size) &&
|
||
cursorOn.size[0] > 0 &&
|
||
!cursorOff.css.startsWith('url(') &&
|
||
storedOff === false &&
|
||
cursorBack === cursorOn.css,
|
||
JSON.stringify({ cursorOn: { ...cursorOn, css: cursorOn.css.slice(0, 40) }, cursorOff, storedOff }),
|
||
);
|
||
|
||
await t.showTab('scene');
|
||
const rect = await json('__gm.viewportRect()');
|
||
const at = { x: Math.round(rect.x + rect.w * 0.7), y: Math.round(rect.y + rect.h * 0.4) };
|
||
const viewNow = async () => ({
|
||
...(await json('__gm.sceneView()')),
|
||
world: await json(`__gm.sceneWorldAt(${at.x}, ${at.y})`),
|
||
});
|
||
await t.evaluate('__gm.zoomTransition(0)');
|
||
const start = await viewNow();
|
||
await t.wheel(at.x, at.y, -100);
|
||
const jumped = await viewNow();
|
||
await t.wheel(at.x, at.y, 100);
|
||
const back = await viewNow();
|
||
await t.evaluate('__gm.zoomTransition(1000)');
|
||
await t.mouse('mouseMoved', at.x, at.y);
|
||
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
|
||
await t.settle(2);
|
||
const midway = await viewNow();
|
||
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended' });
|
||
const landed = await viewNow();
|
||
const samePoint = (a, b) => Math.abs(a.x - b.x) < 1e-6 && Math.abs(a.y - b.y) < 1e-6;
|
||
check(
|
||
'a frame after the wheel the scene view is on its way: zoom between the steps, the point under the pointer kept',
|
||
back.zoom === start.zoom &&
|
||
jumped.zoom > start.zoom &&
|
||
midway.moving === true &&
|
||
midway.zoom > start.zoom &&
|
||
midway.zoom < jumped.zoom &&
|
||
samePoint(midway.world, start.world),
|
||
JSON.stringify({ start, jumped, back, midway }),
|
||
);
|
||
check(
|
||
'the transition lands on the view a jump reaches: the same step and the same centre',
|
||
landed.moving === false &&
|
||
landed.zoom === jumped.zoom &&
|
||
samePoint(landed, jumped) &&
|
||
samePoint(landed.world, start.world),
|
||
JSON.stringify({ jumped, landed }),
|
||
);
|
||
// A pan during the transition takes it along instead of jumping to its end.
|
||
const by = { x: 40, y: 30 };
|
||
const moved = { x: at.x + by.x, y: at.y + by.y };
|
||
await t.mouse('mouseMoved', at.x, at.y);
|
||
await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
|
||
await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' });
|
||
const panned = await json('__gm.sceneView()');
|
||
await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' });
|
||
const settled = await json('__gm.sceneView()');
|
||
const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`);
|
||
check(
|
||
'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan',
|
||
panned.moving === true &&
|
||
panned.zoom > landed.zoom &&
|
||
panned.zoom < settled.zoom &&
|
||
settled.zoom > landed.zoom &&
|
||
samePoint(heldAfter, start.world),
|
||
JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }),
|
||
);
|
||
// Handed back as found: no transition for the steps after this one, and the view it had.
|
||
await t.evaluate('__gm.zoomTransition(0)');
|
||
await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' });
|
||
await t.wheel(at.x, at.y, 100);
|
||
await t.wheel(at.x, at.y, 100);
|
||
}
|