diff --git a/README.md b/README.md index d3f85d58..1c5aeb5b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1447 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1452 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 163596c6..2d6730c8 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -829,11 +829,15 @@ export async function tilemap(t) { const OBJECT_MENU = [ 'Select asset', 'Open asset', + 'Copy', + 'Paste', 'Add collider…', 'Add rigidbody…', 'Create container', 'Unpack container', 'Unparent (move to top level)', + 'Group…', + 'Remove from group', 'Remove from scene', ]; const onHidden = await cellPoint(1, 1); @@ -869,15 +873,16 @@ export async function tilemap(t) { JSON.stringify({ topRows, topPicked }), ); - // Empty space opens no menu and leaves the selection alone. + // Empty space opens only the menu to paste into the scene (#240) and leaves the selection alone. const emptyPoint = await cellPoint(COLUMNS - 1, ROWS - 1); await click(emptyPoint.x, emptyPoint.y, 'right'); await settle(); const emptyMenu = await json('__gm.menuRows()'); const emptyPicked = await selected(); check( - 'a right-click on an empty cell opens no menu and keeps the selection', - emptyMenu === null && JSON.stringify(emptyPicked) === JSON.stringify([MAP_OBJECT]), + 'a right-click on an empty cell opens only Paste and keeps the selection', + JSON.stringify(emptyMenu) === JSON.stringify(['Paste']) && + JSON.stringify(emptyPicked) === JSON.stringify([MAP_OBJECT]), JSON.stringify({ emptyMenu, emptyPicked }), ); await press('Escape'); diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index ae89741b..b3f957a3 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -181,11 +181,13 @@ export async function topbar(t) { })()`); const ids = { sprite: await firstOf('sprite'), - collider: await firstOf('collider'), + 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; @@ -197,6 +199,9 @@ export async function topbar(t) { 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 clickToolbar('visibility'); @@ -295,8 +300,8 @@ export async function topbar(t) { await press('Escape'); await t.mutate(` const scene = __gm.state.activeScene; - scene.objects.splice(scene.objects.findIndex((o) => o.id === 'vis-map-obj'), 1); + 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']) assets.splice(assets.findIndex((a) => a.id === id), 1)`); + for (const id of ['vis-map', 'vis-wall', 'vis-col']) assets.splice(assets.findIndex((a) => a.id === id), 1)`); await t.saved(); } diff --git a/tools/e2e/51-clipboard.mjs b/tools/e2e/51-clipboard.mjs index aa99dd1e..9af24caf 100644 --- a/tools/e2e/51-clipboard.mjs +++ b/tools/e2e/51-clipboard.mjs @@ -18,10 +18,28 @@ export async function clipboard(t) { await press('Escape'); await showTab('scene'); await evaluate('__gm.state.clipboard = null'); + // Two crates of its own, each with a collider, where the seed has its red and blue crate. In the + // full run those two are long packed into a container by module 09, so they are no fixture here. + const BLUE = 'clip-blue'; + const RED = 'clip-red'; + await t.mutate(` + const scene = __gm.state.activeScene; + const put = (id, assetId, parentId, x, y, name) => { + const o = structuredClone(scene.objects[0]); + Object.assign(o, { id, assetId, parentId, name, position: { x, y, z: 0 }, overrides: {}, instanceEdits: {} }); + delete o.groupId; + if ('enabled' in o) o.enabled = true; + scene.objects.push(o); + }; + put(${JSON.stringify(BLUE)}, 'crate', '', 120, -80, 'Blue crate'); + put(${JSON.stringify(BLUE)} + '-col', 'collider', ${JSON.stringify(BLUE)}, 0, 0, 'Collider'); + put(${JSON.stringify(RED)}, 'crate', '', -150, 0, 'Red crate'); + put(${JSON.stringify(RED)} + '-col', 'collider', ${JSON.stringify(RED)}, 0, 0, 'Collider'); + __gm.state.select(null)`); // 51a. Ctrl+C on the blue crate, Ctrl+V with the pointer over empty space: the crate and its // collider come back as new objects, the crate standing right under the pointer. - await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o2'] }); + await select({ kind: 'object', holderId: 'scene1', objectId: BLUE, objectIds: [BLUE] }); await press('c', { ctrl: true }); const spot = await json('__gm.emptySpot()'); const before = await stored(); @@ -51,16 +69,16 @@ export async function clipboard(t) { // 51b. With the pointer over the hierarchy the copy has nowhere to be aimed: it lands one grid // step down and right of the original, beside it rather than inside it. - await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o2'] }); - await moveTo(await json('__gm.hierarchyRow("o2")')); + await select({ kind: 'object', holderId: 'scene1', objectId: BLUE, objectIds: [BLUE] }); + await moveTo(await json(`__gm.hierarchyRow(${JSON.stringify(BLUE)})`)); await press('v', { ctrl: true }); const besideId = (await selected()).objectId; const beside = (await stored()).find((o) => o.id === besideId); - const original = (await stored()).find((o) => o.id === 'o2'); + const original = (await stored()).find((o) => o.id === BLUE); check( 'without the pointer on the view the copy lands a grid step beside the original', beside !== undefined && - beside.id !== 'o2' && + beside.id !== BLUE && beside.parentId === original.parentId && beside.position.x === original.position.x + 100 && beside.position.y === original.position.y + 100, @@ -70,7 +88,7 @@ export async function clipboard(t) { // 51c. The object menu: right-click the red crate, "Paste" puts the copy inside what was hit – // the crate or the collider drawn over it, whichever the pointer picks. - const red = await json('__gm.objectScreen("o1")'); + const red = await json(`__gm.objectScreen(${JSON.stringify(RED)})`); await click(red.x, red.y, 'right'); const target = (await selected()).objectId; const rows = (await json('__gm.menuRows()')) ?? []; @@ -81,7 +99,7 @@ export async function clipboard(t) { 'the object menu offers Copy and Paste, and Paste puts the copy into the object clicked', rows.includes('Copy') && rows.includes('Paste') && - ['o1', 'oc1'].includes(target) && + [RED, `${RED}-col`].includes(target) && inside?.assetId === 'crate' && inside.parentId === target, JSON.stringify({ target, rows, inside }), @@ -128,4 +146,8 @@ export async function clipboard(t) { check('the graph is back as it was', (await nodes()).length === count); await evaluate('__gm.state.clipboard = null'); await showTab('scene'); + await t.mutate(` + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('clip-'));`); + await t.saved(); } diff --git a/tools/e2e/52-hierarchy-groups.mjs b/tools/e2e/52-hierarchy-groups.mjs index b276ba60..9218c78e 100644 --- a/tools/e2e/52-hierarchy-groups.mjs +++ b/tools/e2e/52-hierarchy-groups.mjs @@ -2,13 +2,37 @@ import { same } from '../lib/harness.mjs'; /** * Groups in the hierarchy (#241), made, opened, filled, renamed and dissolved with the real menus, - * clicks, keys and a real drag. The fixture's top level is o1, o2, o3, o5; the order rules and - * drops are unit-tested, this checks that the panel and the dialog do what those rules say. - * Everything is taken back by undo, so the fixture leaves as it came. + * clicks, keys and a real drag. The order rules and drops are unit-tested, this checks that the + * panel and the dialog do what those rules say. + * It works in a scene of its own, whose top level is g1, g2 (with the collider gc2), g3 and g5: the + * checks compare whole lists of rows, and in the full run the fixture's scene holds whatever the + * modules before left there. The scene is removed at the end. */ export async function hierarchyGroups(t) { const { check, json, evaluate, click, clickText, drag, press, typeText, select, showTab, undo, shot } = t; + const SCENE = 'groups-52'; + const home = await json('__gm.state.project.activeSceneId'); + await t.mutate(` + const p = __gm.state.project; + const base = __gm.state.activeScene.objects[0]; + const make = (id, assetId, parentId, x, name) => { + const o = structuredClone(base); + Object.assign(o, { id, assetId, parentId, name, position: { x, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); + delete o.groupId; + if ('enabled' in o) o.enabled = true; + return o; + }; + p.assets.push({ id: ${JSON.stringify(SCENE)}, type: 'scene', name: 'Groups 52', props: { backgroundColor: 0x22252c }, + objects: [ + make('g1', 'crate', '', -200, 'Crate 1'), + make('g2', 'crate', '', 0, 'Crate 2'), + make('gc2', 'collider', 'g2', 0, 'Collider'), + make('g3', 'crate', '', 200, 'Crate 3'), + make('g5', 'mover', '', 300, 'Mover'), + ] }); + p.activeSceneId = ${JSON.stringify(SCENE)}`); + const rowIds = () => json('__gm.hierarchyOrder()'); const scene = () => json('__gm.state.activeScene'); const selected = async () => (await json('__gm.state.selection'))?.objectIds ?? []; @@ -23,8 +47,8 @@ export async function hierarchyGroups(t) { await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); // 52a. A child cannot go into a group: the entry is there, greyed out. - await select({ kind: 'object', holderId: 'scene1', objectId: 'oc2', objectIds: ['oc2'] }); - const child = await rowAt('oc2'); + await select({ kind: 'object', holderId: SCENE, objectId: 'gc2', objectIds: ['gc2'] }); + const child = await rowAt('gc2'); await click(child.x, child.y, 'right'); const childEntry = await menuRow('Group…'); check('Group… is greyed out for a child', childEntry.enabled === false, JSON.stringify(childEntry)); @@ -32,8 +56,8 @@ export async function hierarchyGroups(t) { await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); // 52b. Two top-level objects, the row menu's "Group 2 objects…", a name typed into the dialog. - await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o3'] }); - const first = await rowAt('o1'); + await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g3'] }); + const first = await rowAt('g1'); await click(first.x, first.y, 'right'); const entry = await menuRow('Group 2 objects…'); await clickText('Group 2 objects…'); @@ -53,13 +77,13 @@ export async function hierarchyGroups(t) { made.objects .filter((o) => o.groupId === group.id) .map((o) => o.id) - .join() === 'o1,o3', + .join() === 'g1,g3', JSON.stringify({ entry, dialogOpened, groups: made.groups }), ); - // o3 is listed with o1 now; with both members selected the group stays folded. + // g3 is listed with g1 now; with both members selected the group stays folded. check( - 'the group row stands where o1 stood, folded', - same(await rowIds(), [row, 'o2', 'o5']), + 'the group row stands where g1 stood, folded', + same(await rowIds(), [row, 'g2', 'g5']), JSON.stringify(await rowIds()), ); @@ -69,26 +93,26 @@ export async function hierarchyGroups(t) { await click(groupRow.x, groupRow.y); check( 'clicking the group row selects all its members and leaves it folded', - same(await selected(), ['o1', 'o3']) && same(await rowIds(), [row, 'o2', 'o5']), + same(await selected(), ['g1', 'g3']) && same(await rowIds(), [row, 'g2', 'g5']), JSON.stringify({ selected: await selected(), rows: await rowIds() }), ); const chevron = await json(`__gm.hierarchyChevron(${JSON.stringify(row)})`); await click(chevron.x, chevron.y); check( 'its chevron lists the members below it', - same(await rowIds(), [row, 'o1', 'o3', 'o2', 'o5']), + same(await rowIds(), [row, 'g1', 'g3', 'g2', 'g5']), JSON.stringify(await rowIds()), ); await shot('52-group-open'); - // 52d. Dragging o5 onto the group row puts it in behind the last member; undo takes it out. - const from = await rowAt('o5'); + // 52d. Dragging g5 onto the group row puts it in behind the last member; undo takes it out. + const from = await rowAt('g5'); const onto = await rowAt(row); await drag(from.x, from.y, onto.x, onto.y); check( 'dragging an object onto the group row adds it to the group', - same(await rowIds(), [row, 'o1', 'o3', 'o5', 'o2']) && - (await scene()).objects.find((o) => o.id === 'o5')?.groupId === group?.id, + same(await rowIds(), [row, 'g1', 'g3', 'g5', 'g2']) && + (await scene()).objects.find((o) => o.id === 'g5')?.groupId === group?.id, JSON.stringify(await rowIds()), ); await undo(); @@ -114,7 +138,7 @@ export async function hierarchyGroups(t) { 'Ungroup dissolves the group and keeps its members', ungrouped.groups === undefined && !ungrouped.objects.some((o) => o.groupId) && - same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + same(await rowIds(), ['g1', 'g2', 'g3', 'g5']), JSON.stringify({ groups: ungrouped.groups, rows: await rowIds() }), ); @@ -132,48 +156,48 @@ export async function hierarchyGroups(t) { // 52g. A plain click on a row that is already part of a multi-selection keeps only that row – // the other member as well as the primary one. - await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2', 'o3'] }); - const other = await rowAt('o2'); + await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2', 'g3'] }); + const other = await rowAt('g2'); await click(other.x, other.y); const narrowedToOther = await selected(); - await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2', 'o3'] }); - const primary = await rowAt('o1'); + await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2', 'g3'] }); + const primary = await rowAt('g1'); await click(primary.x, primary.y); const narrowedToPrimary = await selected(); check( 'clicking a selected row narrows the selection to that row', - same(narrowedToOther, ['o2']) && same(narrowedToPrimary, ['o1']), + same(narrowedToOther, ['g2']) && same(narrowedToPrimary, ['g1']), JSON.stringify({ narrowedToOther, narrowedToPrimary }), ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); // 52h. Shift-click takes every row from the last plain click to the clicked one; a second // Shift-click redraws the range from the same anchor, and it runs upwards just as well. - // It starts on o2: 52g's last click hit o1, and a second one there would count as a double click. + // It starts on g2: 52g's last click hit g1, and a second one there would count as a double click. const clickRow = async (id, modifiers) => { const at = await rowAt(id); await click(at.x, at.y, 'left', modifiers); }; - await clickRow('o2'); - await clickRow('o5', { shift: true }); + await clickRow('g2'); + await clickRow('g5', { shift: true }); const down = await selected(); const primaryAfter = (await json('__gm.state.selection'))?.objectId; - await clickRow('o1', { shift: true }); + await clickRow('g1', { shift: true }); const up = await selected(); check( 'Shift-click selects the rows in between', - same([...down].sort(), ['o2', 'o3', 'o5']) && - primaryAfter === 'o5' && - same([...up].sort(), ['o1', 'o2']) && - same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + same([...down].sort(), ['g2', 'g3', 'g5']) && + primaryAfter === 'g5' && + same([...up].sort(), ['g1', 'g2']) && + same(await rowIds(), ['g1', 'g2', 'g3', 'g5']), JSON.stringify({ down, primaryAfter, up, rows: await rowIds() }), ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); - // 52i. A group "Pile" of o5 alone; its folder stands in the icon column of the objects, the - // count left of it. Then o1 and o2 join it through the dialog's group dropdown. - await select({ kind: 'object', holderId: 'scene1', objectId: 'o5', objectIds: ['o5'] }); - const lone = await rowAt('o5'); + // 52i. A group "Pile" of g5 alone; its folder stands in the icon column of the objects, the + // count left of it. Then g1 and g2 join it through the dialog's group dropdown. + await select({ kind: 'object', holderId: SCENE, objectId: 'g5', objectIds: ['g5'] }); + const lone = await rowAt('g5'); await click(lone.x, lone.y, 'right'); await clickText('Group…'); await typeText('Pile'); @@ -182,7 +206,7 @@ export async function hierarchyGroups(t) { const pileRow = `group:${pile?.id}`; const folder = await json(`__gm.hierarchyIcon(${JSON.stringify(pileRow)})`); const count = await json(`__gm.hierarchyHint(${JSON.stringify(pileRow)})`); - const objectIcon = await json('__gm.hierarchyIcon("o1")'); + const objectIcon = await json('__gm.hierarchyIcon("g1")'); check( "a group's folder stands in the objects' icon column, its count left of it", folder !== null && @@ -192,8 +216,8 @@ export async function hierarchyGroups(t) { count.x < folder.x - 8, JSON.stringify({ folder, objectIcon, count }), ); - await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2'] }); - const joiner = await rowAt('o1'); + await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2'] }); + const joiner = await rowAt('g1'); await click(joiner.x, joiner.y, 'right'); await clickText('Group 2 objects…'); await clickText('New group'); @@ -210,15 +234,15 @@ export async function hierarchyGroups(t) { joined.groups?.length === 1 && same( joined.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id), - ['o1', 'o2', 'o5'], + ['g1', 'g2', 'g5'], ), JSON.stringify({ choices, groups: joined.groups, objects: joined.objects.map((o) => [o.id, o.groupId]) }), ); await undo(); // 52j. Dragging one row of a multi-selection takes the others along – here onto the group row. - await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2'] }); - const grab = await rowAt('o2'); + await select({ kind: 'object', holderId: SCENE, objectId: 'g1', objectIds: ['g1', 'g2'] }); + const grab = await rowAt('g2'); const into = await rowAt(pileRow); await drag(grab.x, grab.y, into.x, into.y); const dragged = await scene(); @@ -227,18 +251,23 @@ export async function hierarchyGroups(t) { const undone = await scene(); check( 'dragging a selected row moves the whole selection, in one undo step', - same(inPile, ['o5', 'o1', 'o2']) && + same(inPile, ['g5', 'g1', 'g2']) && same( undone.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id), - ['o5'], + ['g5'], ), JSON.stringify({ inPile, undone: undone.objects.map((o) => [o.id, o.groupId]) }), ); await undo(); check( - 'undo takes the group of o5 back too', + 'undo takes the group of g5 back too', (await scene()).groups === undefined, JSON.stringify((await scene()).groups), ); await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); + await t.mutate(` + const p = __gm.state.project; + p.activeSceneId = ${JSON.stringify(home)}; + p.assets = p.assets.filter((a) => a.id !== ${JSON.stringify(SCENE)})`); + await t.saved(); }