The scene view gets crowded once a scene holds many behaviors, colliders and tilemaps. A new eye button in the topbar opens a list of all placeable asset kinds, and each row has toggles for what the scene view draws for that kind. - The popup lists all 9 placeable kinds from Behavior to Tilemap, with the columns Symbol, Name and Shape. It stays open while switching. - Sprite, Text and Tilemap draw no symbol, so their symbol toggle is greyed out. Their name toggle shows a new centred name tag, which is off by default. - Shape exists for Collider and Tilemap. A hidden collider outline is neither drawn nor clickable. A hidden tilemap shape removes the collision outlines and the paint tint, the tiles stay visible. - A hidden symbol takes no slot in the ring and draws no dashed link to its parent. A name that stays on moves to the centre of the object. - The setting lives in the project as `sceneVisibility`, not in localStorage. Older projects load with the defaults, which match the view as it was. - Switching has no undo step, like the game scale. It only changes the view. A change made during Play survives Stop. - The rules sit in `src/core/model/sceneVisibility.ts`, the popup in `src/editor/panels/SceneVisibilityPopup.ts`. Four new SVG icons. Not covered by a check: symbol off with name on for a kind with a symbol, Text and Tilemap with names, and the paint tint with shape off. Tests: - ./check.sh: 173 files, 2515 tests, CHECK EXIT 0 - e2e 46: ALL PASSED, 17 checks. 46g clicks the popup, lays a small tilemap over the crate and removes it again. - e2e 8 alone on a fresh seed: ALL PASSED, 23 checks. A red check in the partial run 2,8,46 came from the module selection. - 8 new unit tests in tests/editor/scene-visibility.test.ts. - 4 counter-probes, each fails its test: load ignores the saved value, Play skips the saved model, scene view ignores collider shape, scene view ignores tilemap shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
13 KiB
JavaScript
303 lines
13 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 beside redo: 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: await firstOf('collider'),
|
||
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.
|
||
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);
|
||
__gm.state.select(null)`);
|
||
const undoBefore = await json('__gm.state.commands.canUndo');
|
||
await clickToolbar('visibility');
|
||
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 and tilemap',
|
||
JSON.stringify(types) ===
|
||
JSON.stringify([
|
||
'behavior',
|
||
'camera',
|
||
'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 &&
|
||
popup.filter((e) => e.part === 'shape').length === 2,
|
||
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.splice(scene.objects.findIndex((o) => o.id === 'vis-map-obj'), 1);
|
||
const assets = __gm.state.project.assets;
|
||
for (const id of ['vis-map', 'vis-wall']) assets.splice(assets.findIndex((a) => a.id === id), 1)`);
|
||
await t.saved();
|
||
}
|