"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>
288 lines
13 KiB
JavaScript
288 lines
13 KiB
JavaScript
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 }`);
|
||
}
|