The Container tab reuses SceneViewport against any object holder, so a container template is edited exactly like a scene. Right-click no longer changes the selection, which previously made nesting impossible: opening the menu on a container loaded it, and "Place in container" then targeted itself. Template edits (move, rename, override) now reach already-placed instances per slot, through a shared templateSync used by both the apply path and the plain object commands. Copies the user has touched keep their own value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
15 KiB
JavaScript
329 lines
15 KiB
JavaScript
import { same } from '../lib/harness.mjs';
|
||
|
||
/**
|
||
* Multi-selection and containers: Ctrl-click, Create container, dragging the group, rubber
|
||
* band, group move/delete, placing and spawning the container asset, and apply/revert of
|
||
* instance values against the template and the asset.
|
||
*/
|
||
export async function containers(t) {
|
||
const {
|
||
check,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
shot,
|
||
click,
|
||
clickText,
|
||
drag,
|
||
locate,
|
||
press,
|
||
undo,
|
||
mutate,
|
||
select,
|
||
state,
|
||
object,
|
||
objects,
|
||
asset,
|
||
world,
|
||
play,
|
||
stop,
|
||
} = t;
|
||
const center = await json('__gm.viewportCenter()');
|
||
const has = (text) => evaluate(`Boolean(__gm.locate(${JSON.stringify(text)}))`);
|
||
|
||
// 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
|
||
// both are selected, the inspector shows the multi view.
|
||
await mutate(`
|
||
const objs = __gm.state.activeScene.objects;
|
||
const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
|
||
o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = '';
|
||
o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
|
||
objs.find(o => o.id === 'oc2').overrides = { width: 140 };
|
||
__gm.state.select(null)`);
|
||
await clickText('Red crate');
|
||
const blueRow = await locate('Blue crate');
|
||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||
let s = await state();
|
||
check(
|
||
'Ctrl-click in the hierarchy adds to the selection',
|
||
s.selection?.kind === 'object' &&
|
||
same(s.selection.objectIds, ['o1', 'o2']) &&
|
||
s.selection.objectId === 'o2',
|
||
JSON.stringify(s.selection),
|
||
);
|
||
check('inspector shows the multi-selection', await has('2 OBJECTS SELECTED'));
|
||
await shot('23-multi-selected');
|
||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||
s = await state();
|
||
check(
|
||
'Ctrl-click again removes the object from the selection',
|
||
s.selection?.kind === 'object' && same(s.selection.objectIds, ['o1']),
|
||
JSON.stringify(s.selection),
|
||
);
|
||
await click(blueRow.x, blueRow.y, 'left', { ctrl: true });
|
||
|
||
// 16a. Create container: both crates (with their colliders and the Mover) become the content
|
||
// of a new container asset; the scene gets one container object at the selection's
|
||
// centre (-15,-40), the crates hang below it with recomputed local positions, world
|
||
// positions are unchanged, and the container is selected.
|
||
const assetsBefore = (await json('__gm.state.project.assets')).length;
|
||
const objectsBefore = (await objects()).length;
|
||
await clickText('Create container');
|
||
await sleep(100);
|
||
s = await state();
|
||
const containerObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||
const containerAsset = containerObj ? await asset(containerObj.assetId) : null;
|
||
check(
|
||
'Create container adds one container asset and one placed container, selected',
|
||
containerAsset?.type === 'container' &&
|
||
s.selection.objectIds.length === 1 &&
|
||
(await json('__gm.state.project.assets')).length === assetsBefore + 1 &&
|
||
(await objects()).length === objectsBefore + 1,
|
||
JSON.stringify({ containerObj, type: containerAsset?.type }),
|
||
);
|
||
check(
|
||
'the container sits at the centre of the selection',
|
||
containerObj && containerObj.parentId === '' && same(containerObj.position, { x: -15, y: -40, z: 0 }),
|
||
JSON.stringify(containerObj?.position),
|
||
);
|
||
let o1c = await object('o1');
|
||
const o2c = await object('o2');
|
||
check(
|
||
'the crates are re-parented below the container with local positions relative to its centre',
|
||
o1c.parentId === containerObj?.id &&
|
||
o1c.position.x === -135 &&
|
||
o1c.position.y === 40 &&
|
||
o2c.parentId === containerObj?.id &&
|
||
o2c.position.x === 135 &&
|
||
o2c.position.y === -40,
|
||
JSON.stringify({ o1: o1c.position, o2: o2c.position }),
|
||
);
|
||
check(
|
||
'the template holds the whole subtree (2 crates, 2 colliders, the Mover)',
|
||
containerAsset?.objects.length === 5 &&
|
||
containerAsset.objects.filter((o) => o.parentId === '').length === 2 &&
|
||
o1c.templateId &&
|
||
containerAsset.objects.some((o) => o.id === o1c.templateId),
|
||
JSON.stringify(containerAsset?.objects.map((o) => [o.assetId, o.parentId === '' ? 'root' : 'child'])),
|
||
);
|
||
const orderC = await json('(__gm.hierarchyExpandAll(), __gm.hierarchyOrder())');
|
||
const cAt = orderC.indexOf(containerObj?.id);
|
||
check(
|
||
'hierarchy lists the container with its content indented below',
|
||
cAt >= 0 && same(orderC.slice(cAt), [containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']),
|
||
JSON.stringify(orderC),
|
||
);
|
||
check(
|
||
'container symbol is drawn in the overlay layer',
|
||
containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay',
|
||
);
|
||
await shot('24-container-created');
|
||
|
||
// 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
|
||
const cScreen = await json(`__gm.objectScreen('${containerObj.id}')`);
|
||
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
|
||
const containerMoved = await object(containerObj.id);
|
||
o1c = await object('o1');
|
||
check(
|
||
'dragging the container moves the group (children keep their local position)',
|
||
containerMoved.position.x === 85 && o1c.position.x === -135,
|
||
JSON.stringify({ container: containerMoved.position, o1: o1c.position }),
|
||
);
|
||
await undo();
|
||
check('undo moves the container back', (await object(containerObj.id)).position.x === -15);
|
||
|
||
// 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
|
||
// the objects whose position lies inside – the crate and its collider, not the Mover
|
||
// (at y = 110), not the camera (at the origin).
|
||
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
|
||
s = await state();
|
||
check(
|
||
'rubber-band selection picks the objects inside it',
|
||
s.selection?.kind === 'object' && same([...s.selection.objectIds].sort(), ['o1', 'oc1']),
|
||
JSON.stringify(s.selection),
|
||
);
|
||
await shot('25-marquee');
|
||
|
||
// 16d. Dragging one of several selected objects moves all of them as one undo step.
|
||
const redScreen = await json(`__gm.objectScreen('o1')`);
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
|
||
o1c = await object('o1');
|
||
const o2d = await object('o2');
|
||
check(
|
||
'dragging a multi-selection moves every selected object',
|
||
o1c.position.x === -85 && o2d.position.x === 185,
|
||
JSON.stringify({ o1: o1c.position, o2: o2d.position }),
|
||
);
|
||
await undo();
|
||
o1c = await object('o1');
|
||
check('one undo restores both', o1c.position.x === -135 && (await object('o2')).position.x === 135);
|
||
|
||
// 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
|
||
const objectsBeforeDelete = (await objects()).length;
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
|
||
await press('Delete');
|
||
await sleep(150);
|
||
check(
|
||
'Delete removes the whole multi-selection with its subtrees',
|
||
(await objects()).length === objectsBeforeDelete - 5,
|
||
);
|
||
await undo();
|
||
check('undo restores all of them', (await objects()).length === objectsBeforeDelete);
|
||
|
||
// 16f. Placing the container asset again from the asset panel creates a second instance:
|
||
// the root plus 5 content objects with fresh ids, the crate copy at the same offset.
|
||
await clickText(containerAsset.name, 'right');
|
||
await clickText('Place in scene');
|
||
await sleep(100);
|
||
const afterPlace = await objects();
|
||
const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
|
||
const secondCrate =
|
||
secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
|
||
check(
|
||
'placing the container asset instantiates its content below a new container object',
|
||
afterPlace.length === objectsBeforeDelete + 6 &&
|
||
secondCrate &&
|
||
secondCrate.position.x === -135 &&
|
||
secondCrate.position.y === 40,
|
||
JSON.stringify({ count: afterPlace.length, secondCrate }),
|
||
);
|
||
await shot('26-container-placed');
|
||
|
||
// 16f-ii. Spawning the container at runtime instantiates its content the same way: point the
|
||
// Spawner at the container asset, press Space while playing – a whole instance appears
|
||
// in the runtime copy and in the world, and Stop throws it away with the rest.
|
||
const spawnParam = (assetId) =>
|
||
mutate(
|
||
`__gm.state.project.assets.find(a => a.id === 'spawner').graph.nodes.find(n => n.id === 's-spawn').params.asset = ${JSON.stringify(assetId)}`,
|
||
);
|
||
await spawnParam(containerAsset.id);
|
||
const beforeSpawn = await objects();
|
||
await play();
|
||
await press('Space');
|
||
await sleep(250);
|
||
const fresh = (await objects()).filter((o) => !beforeSpawn.some((b) => b.id === o.id));
|
||
const spawnedRoot = fresh.find((o) => o.parentId === '');
|
||
check(
|
||
'Spawn of a container asset creates the container object plus its content',
|
||
fresh.length === 6 &&
|
||
spawnedRoot?.assetId === containerAsset.id &&
|
||
fresh.filter((o) => o.templateId).length === 5,
|
||
JSON.stringify(fresh.map((o) => ({ id: o.id, assetId: o.assetId, templateId: o.templateId }))),
|
||
);
|
||
const spawnWorld = await world();
|
||
check(
|
||
'the spawned instance lives in the world and its name reached the Log node',
|
||
spawnWorld.entities.length === beforeSpawn.length + 6 && spawnWorld.log.includes(containerAsset.name),
|
||
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log.slice(-1) }),
|
||
);
|
||
await stop();
|
||
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
|
||
await spawnParam('crate');
|
||
|
||
// 16g. Apply / revert on a container instance. The red crate (o1) is part of the first
|
||
// container; the second instance has an unmodified copy of it. Moving o1 on x shows
|
||
// ↑ and ↺ on the X row; ↑ writes x into the template and the copy follows; undo takes
|
||
// both back; ↺ puts o1 back to the template value.
|
||
const templateA = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === o1c.templateId);
|
||
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1' });
|
||
await sleep(200);
|
||
check('inspector names the container the object is part of', await has(containerAsset.name));
|
||
check('no apply/revert while the instance matches its template', !(await has('↑')) && !(await has('↺')));
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').position.x = -100`);
|
||
const applyButton = await evaluate(`__gm.locate('↑')`);
|
||
check('a moved instance shows apply and revert on its X row', Boolean(applyButton) && (await has('↺')));
|
||
await shot('27-apply-buttons');
|
||
await click(applyButton.x, applyButton.y);
|
||
await sleep(100);
|
||
check(
|
||
'apply writes x into the template and the unmodified copy follows',
|
||
(await templateA()).position.x === -100 && (await object(secondCrate.id)).position.x === -100,
|
||
JSON.stringify({ template: (await templateA()).position, copy: (await object(secondCrate.id)).position }),
|
||
);
|
||
check('apply hides the buttons again', !(await has('↑')));
|
||
await undo();
|
||
check(
|
||
'undo restores template and copy',
|
||
(await templateA()).position.x === -135 && (await object(secondCrate.id)).position.x === -135,
|
||
);
|
||
const revertButton = await evaluate(`__gm.locate('↺')`);
|
||
await click(revertButton.x, revertButton.y);
|
||
await sleep(100);
|
||
check('revert puts the instance back to the template position', (await object('o1')).position.x === -135);
|
||
|
||
// 16h. Overrides on an instance offer a menu: "Apply to container" or "Apply to asset".
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 77`);
|
||
const applyMenu = await evaluate(`__gm.locate('↑ ▾')`);
|
||
check('an overridden property on an instance shows the apply menu button', Boolean(applyMenu));
|
||
await click(applyMenu.x, applyMenu.y);
|
||
check(
|
||
'the menu offers container and asset',
|
||
(await has('Apply to container')) && (await has('Apply to asset')),
|
||
);
|
||
await shot('28-apply-menu');
|
||
await clickText('Apply to asset');
|
||
await sleep(100);
|
||
const crateAfterApply = (await asset('crate')).props;
|
||
check(
|
||
'Apply to asset writes the value into the asset and drops the override',
|
||
crateAfterApply.width === 77 && (await object('o1')).overrides.width === undefined,
|
||
JSON.stringify(crateAfterApply),
|
||
);
|
||
await undo();
|
||
check(
|
||
'undo restores asset and override',
|
||
(await asset('crate')).props.width === 100 && (await object('o1')).overrides.width === 77,
|
||
);
|
||
await click(applyMenu.x, applyMenu.y);
|
||
await clickText('Apply to container');
|
||
await sleep(100);
|
||
check(
|
||
'Apply to container writes the override into the template and the copy',
|
||
(await templateA()).overrides.width === 77 && (await object(secondCrate.id)).overrides.width === 77,
|
||
);
|
||
|
||
// 16i. o1 now matches its template (both 77) but still overrides the asset (100). Only bases
|
||
// the value really differs from are offered, so "Reset to container" is left out and the
|
||
// menu collapses to a direct ↺. Deviating from both brings the menu back.
|
||
await sleep(200);
|
||
check(
|
||
'a reset that would change nothing is left out – a direct button instead of a menu',
|
||
(await has('↺')) && !(await has('↺ ▾')) && !(await has('↑ ▾')),
|
||
);
|
||
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o1').overrides.width = 55`);
|
||
const resetMenu = await evaluate(`__gm.locate('↺ ▾')`);
|
||
check('deviating from template and asset brings the reset menu back', Boolean(resetMenu));
|
||
await click(resetMenu.x, resetMenu.y);
|
||
check(
|
||
'the reset menu offers container and asset',
|
||
(await has('Reset to container')) && (await has('Reset to asset')),
|
||
);
|
||
await shot('28b-reset-menu');
|
||
await clickText('Reset to container');
|
||
await sleep(100);
|
||
check(
|
||
'Reset to container puts the override back on the template value',
|
||
(await object('o1')).overrides.width === 77,
|
||
);
|
||
const singleReset = await evaluate(`__gm.locate('↺')`);
|
||
check(
|
||
'with only the asset left to differ from, the menu is a single button again',
|
||
Boolean(singleReset) && !(await has('↺ ▾')),
|
||
);
|
||
await click(singleReset.x, singleReset.y);
|
||
await sleep(100);
|
||
check(
|
||
'↺ drops the override so the object follows the asset again',
|
||
(await object('o1')).overrides.width === undefined && (await templateA()).overrides.width === 77,
|
||
);
|
||
// Dropping the override left the object on the asset value (100) while the template still
|
||
// says 77 – that is a real deviation, so exactly one entry remains: "Reset to container".
|
||
check(
|
||
'the remaining deviation from the template keeps a single button, not a menu',
|
||
(await has('↺')) && !(await has('↺ ▾')),
|
||
);
|
||
await undo();
|
||
check('undo restores the override', (await object('o1')).overrides.width === 77);
|
||
}
|