Files
feuerwerk/tools/e2e/08-colliders.mjs
T
adminandClaude Opus 5.5 abf3b1f618
game-maker / gate (push) Successful in 3m8s
game-maker / suite (push) Successful in 9m52s
Pick an existing collider or rigidbody from the object menu (#222)
"Add collider" and "Add rigidbody" could only create a fresh asset.
Reusing one meant creating a copy by hand, and a new box collider
never matched the sprite it was added to.

- "Add collider…" and "Add rigidbody…" open a second menu at the same
  spot: a search field, "Create new …", a separator, then every asset
  of that kind sorted by name. Enter takes the first match.
- An existing asset is placed as a shared child, not copied. An asset
  the object already carries is greyed out.
- The rule for when the entries are available lives in one place in
  the collider commands. An object without a resolvable asset is
  greyed out again, as with the old canAddCollider.
- "Fit to sprite" in the menu of a placed collider sizes it to its
  sprite and centres it (position 0,0, z kept) as one undo step.
- Fit writes overrides of that placement, not the asset, because a
  collider can now sit on several sprites. A value equal to the asset
  value clears the override instead.
- Fit removes the collider's own scale, swaps width and height near
  90 degrees and gives a circle the radius that fits (min/2). It is
  greyed out for polygon and frame outlines and off a sprite.
- Menu has no submenus, so a second menu opens instead, similar to
  "Add Component" in Unity. That costs one click more than before.

Tests:
- ./check.sh: 152 files, 2234 tests, CHECK EXIT 0 (before 151/2228).
- 6 new unit tests in tests/core/collider-parts.test.ts.
- 3 new e2e checks in 08a: submenu with search and greying, an
  existing collider via search and Enter, Fit on oc2.
- 09, 40 and 41 follow the new labels.
- e2e 1-16,39-41 on the final build: ALL PASSED, 596 checks.
- Counter-checks: no greying, existing asset ignored and fit without
  centring each fail at their own check. Breaking the override rule
  fails 2 unit tests.
- CI confirmed 1345 checks at 2cf7202 (task 265). New total 1348,
  calculated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 18:29:34 +02:00

288 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { inView } from './lib/scaffold.mjs';
/**
* Colliders: "Add collider" from the context menu, collision events reaching the owner's
* behavior, Move with "Collide with solids" – blocking, triggers, no tunnelling – and the
* collision layers that decide which colliders meet at all (#152).
*/
export async function colliders(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
doubleClick,
clickText,
clickToolbar,
locate,
press,
typeText,
holdKey,
undo,
redo,
mutate,
select,
state,
object,
objects,
asset,
world,
entity,
stop,
} = t;
// 8a. Right-click the red crate → "Add collider…" opens a second, searchable menu (#222): a new
// collider or one the project has, the shared "Crate collider" the crate wears greyed out.
// "Create new collider" places a collider asset sized like the crate (100×60) as its child at
// the centre, selected and drawn in the overlay. The seed already gives both crates a
// "Crate collider" child (oc1/oc2), so the added one is undone again to keep exactly one
// collider per body for the checks below.
const objectsBefore = (await objects()).length;
const assetCount = () => json('__gm.state.project.assets.length');
const colliderMenuOf = async (id) => {
await evaluate('__gm.hierarchyExpandAll()');
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
await click(row.x, row.y, 'right');
await clickText('Add collider…');
};
await colliderMenuOf('o1');
const pickRows = await json('__gm.menuRows()');
const pickEnabled = await json('__gm.menuEnabled()');
await typeText('crate');
const searched = ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
await press('Escape');
check(
'Add collider… offers a new or an existing collider, searchable; one the crate wears is greyed out',
JSON.stringify(pickRows) === JSON.stringify(['Create new collider', null, 'Crate collider']) &&
JSON.stringify(pickEnabled) === JSON.stringify([true, null, false]) &&
JSON.stringify(searched) === JSON.stringify(['Crate collider']),
JSON.stringify({ pickRows, pickEnabled, searched }),
);
await colliderMenuOf('o1');
await clickText('Create new collider');
const s = await state();
const colObj = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
const colAsset = colObj ? await asset(colObj.assetId) : null;
check(
'Add collider creates a collider child sized like the sprite',
colObj?.parentId === 'o1' &&
colAsset?.type === 'collider' &&
colAsset.props.width === 100 &&
colAsset.props.height === 60,
JSON.stringify({ colObj, colAsset }),
);
check(
'collider is drawn in the overlay layer',
colObj && (await evaluate(`__gm.layerOf('${colObj.id}')`)) === 'overlay',
);
check('collider inspector shows the shape menu', Boolean(await evaluate(`__gm.locate('Box')`)));
await shot('21-collider');
// The blue crate takes that new collider as it is: its name typed and Enter place it below the
// crate, no second asset made.
const assetsWithNew = await assetCount();
await colliderMenuOf('o2');
if (colAsset) await typeText(colAsset.name);
await press('Enter');
const sShared = await state();
const shared = sShared.selection?.kind === 'object' ? await object(sShared.selection.objectId) : null;
const assetsAfterShare = await assetCount();
check(
'picking an existing collider places it below the blue crate without a new asset',
shared?.parentId === 'o2' && shared.assetId === colAsset?.id && assetsAfterShare === assetsWithNew,
JSON.stringify({ shared, assetsWithNew, assetsAfterShare }),
);
if (shared) await undo();
await undo();
const colliderAssetGone = colAsset ? !(await asset(colAsset.id)) : false;
check(
'undo removes the added collider object and its asset',
(await objects()).length === objectsBefore && colliderAssetGone,
);
// 8a-iii. "Fit to sprite" on a collider (#222): the blue crate's collider, pushed aside and
// narrowed, covers its 140-wide crate again and sits centred – which is how the seed has it.
await mutate(`
const oc2 = __gm.state.activeScene.objects.find(o => o.id === 'oc2');
oc2.position = { x: 30, y: 12, z: 0 };
oc2.overrides = { width: 90 };`);
const oc2Row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow('oc2')`);
await click(oc2Row.x, oc2Row.y, 'right');
const colliderRows = ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
if (colliderRows.includes('Fit to sprite')) await clickText('Fit to sprite');
else await press('Escape');
const fitted = await object('oc2');
check(
'Fit to sprite sizes the collider like its sprite and centres it on the sprite',
JSON.stringify(fitted?.overrides) === JSON.stringify({ width: 140 }) &&
fitted.position.x === 0 &&
fitted.position.y === 0,
JSON.stringify({ colliderRows, fitted }),
);
// 8b. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
// the crates overlapping: the log names the blue crate exactly once (enter, not stay),
// and colliders are not rendered in play mode.
await mutate(`
const mover = __gm.state.project.assets.find(a => a.id === 'mover');
mover.graph.nodes.push({ id: 'n-hit', type: 'OnCollisionEnter', x: 0, y: 600, params: {} }, { id: 'n-hitlog', type: 'Log', x: 340, y: 600, params: {} });
mover.graph.edges.push(
{ id: 'e-hit1', from: { node: 'n-hit', port: 'exec' }, to: { node: 'n-hitlog', port: 'exec' } },
{ id: 'e-hit2', from: { node: 'n-hit', port: 'other' }, to: { node: 'n-hitlog', port: 'message' } });
__gm.state.activeScene.objects.find(o => o.id === 'o2').position = { x: -100, y: 0, z: 0 }; // 50 from the red crate → 100-wide boxes overlap
__gm.state.select(null)`);
await press('Enter', { ctrl: true });
await sleep(500);
const collisionWorld = await world();
check(
'On collision enter fired once with the other body',
collisionWorld.log.filter((l) => l === 'Blue crate').length === 1,
JSON.stringify(collisionWorld.log),
);
const spriteCount = await evaluate(
`__gm.sceneObjects().filter(o => __gm.state.project.assets.find(a => a.id === o.assetId)?.type === 'sprite').length`,
);
const info = await json('__gm.gameInfo()');
check(
'colliders are not rendered in the game view',
info.visibleItems === spriteCount,
`${JSON.stringify(info)} sprites=${spriteCount}`,
);
await stop();
// 8c. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
// Making the blue collider a trigger lets it pass through instead (drawn amber). A frame
// step far larger than the collider must not jump across it either (sub-steps).
const playAgainstBlue = async (trigger, speed = 0) => {
await mutate(`
const scene = __gm.state.activeScene;
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'};
${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''}
__gm.state.select(null)`);
await press('Enter', { ctrl: true });
await holdKey('ArrowRight', 700);
const red = await entity('o1');
await stop();
return red;
};
const blocked = await playAgainstBlue(false);
check(
'Move with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider',
blocked && Math.abs(blocked.x - -120) < 0.01,
`world x=${blocked?.x}`,
);
const passed = await playAgainstBlue(true);
check(
'a trigger collider does not block (the crate passes through)',
passed && passed.x > -120 + 20,
`world x=${passed?.x}`,
);
const fast = await playAgainstBlue(false, 40000);
check(
'a very fast mover cannot tunnel through a solid collider',
fast && Math.abs(fast.x - -120) < 0.5,
`world x=${fast?.x}`,
);
await mutate(`__gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200`); // later steps expect the seeded speed
await select({ kind: 'object', objectId: 'oc2' });
await shot('22-trigger-collider');
// 8d. Collision layers (#152). The project starts with the one layer every collider is on; a
// second one is added and named in the Project settings dialog, which is where the layers
// moved with the top bar's rework (#183) – menu button → "Project settings…".
await clickToolbar('menu');
check(
'the top bar menu offers the project settings',
(await json('__gm.menuRows()')).includes('Project settings…'),
JSON.stringify(await json('__gm.menuRows()')),
);
await clickText('Project settings…');
const opened = await json('__gm.collisionLayers()');
check(
'the dialog opens on the one layer a project starts with',
opened.project.length === 1 && opened.dialog?.length === 1 && opened.dialog[0] === 'Default',
JSON.stringify(opened),
);
await clickText('Add layer');
const added = await json('__gm.collisionLayers()');
check(
'Add layer appends a second one, named after its number until it is typed over',
added.dialog?.length === 2 && added.dialog[1] === 'Layer 1' && added.project.length === 1,
JSON.stringify(added),
);
const layerField = await locate('Layer 1');
await doubleClick(layerField.x, layerField.y);
await typeText('Shots');
await press('Enter');
await shot('22b-collision-layers');
await clickText('Apply');
const applied = await json('__gm.collisionLayers()');
check(
'Apply writes the names into the project and closes the dialog',
applied.dialog === null && JSON.stringify(applied.project) === JSON.stringify(['Default', 'Shots']),
JSON.stringify(applied),
);
await undo();
check(
'the layers are an ordinary undo step',
JSON.stringify((await json('__gm.collisionLayers()')).project) === JSON.stringify(['Default']),
JSON.stringify(await json('__gm.collisionLayers()')),
);
await redo();
// The collider inspector carries both rows, and "Collides with" ticks every layer by default.
// They are the last rows of a long schema, so the inspector is scrolled to them first.
// Clicked rather than selected through the hook: after the play runs above, a selection set
// from outside leaves the inspector empty – only the click rebuilds it.
const crateRow = await inView(t, '__gm.leftColumn().objects', "__gm.hierarchyRow('oc2')");
await click(crateRow.x, crateRow.y);
const layerRow = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Layer')");
check('the collider inspector shows the Layer row', Boolean(layerRow), JSON.stringify(layerRow));
const collidesWith = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Collides with')");
await click(collidesWith.x, collidesWith.y);
await shot('22c-collides-with');
const ticks = await t.menuTicked();
const both = (a, b) => [
{ label: 'Default', ticked: a },
{ label: 'Shots', ticked: b },
];
check(
'"Collides with" lists every layer of the project, all ticked',
JSON.stringify(ticks) === JSON.stringify(both(true, true)),
JSON.stringify(ticks),
);
// The last "Default" on screen: the Layer row above says it too, the menu is drawn on top.
const defaultEntry = await t.locate('Default', true);
await click(defaultEntry.x, defaultEntry.y);
const unticked = await t.menuTicked();
check(
'picking one unticks it without closing the menu',
JSON.stringify(unticked) === JSON.stringify(both(false, true)),
JSON.stringify(unticked),
);
await press('Escape');
// Putting the blue crate's collider on "Shots" – which the red crate's does not meet – is what
// 8c blocked on: the very same run now walks straight through it.
await mutate(`
const scene = __gm.state.activeScene;
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
scene.objects.find(o => o.id === 'oc2').overrides = { width: 140, layer: 1, collidesWith: 2 };
__gm.state.select(null)`);
await press('Enter', { ctrl: true });
await holdKey('ArrowRight', 700);
const throughLayer = await entity('o1');
await stop();
check(
'a collider on a layer the mover does not meet stops nothing',
throughLayer && throughLayer.x > -120 + 20,
`world x=${throughLayer?.x}`,
);
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'oc2').overrides = { width: 140 }`);
}