Files
feuerwerk/tools/e2e/08-colliders.mjs
T
adminandClaude Opus 5 4f55f85ad4
game-maker / gate (push) Successful in 2m41s
game-maker / suite (push) Failing after 8m21s
Collision layers and masks: a collider carries a layer and a mask of the layers it meets, and the pair only meets when both masks name the other's layer - the rule is one pure module (src/core/model/collisionLayers.ts) with layerBit, layersMeet and the names a project starts with, so the three places that have to agree read the same function: the editor run through CollisionSystem, the Solids sweep per pair and Rapier, where the mask becomes the collider's interaction groups; the project file gained collisionLayers: ["Default"] (default one layer, no formatVersion bump), the collider schema two new kinds - layer as a dropdown of the project's layers and layerMask as a menu of tickboxes that stays open while it is ticked (Menu.ts learned rows that do not close on a pick) - and the names are edited through "Collision layers..." in the toolbar's Project menu, not a Project tab, which is the decision I made myself because the question ran into the MCP timeout: the dialog (src/editor/CollisionLayersDialog.ts) holds up to 16 names, Add layer appends "Layer <n>" and Apply writes all of them as one undo step (SetCollisionLayersCommand); a mask read from a file goes through a check that refuses anything that is not a finite number, because Number(null) is 0 and a collidesWith: null would silently have meant "meets nothing"; 9 new e2e checks in module 8 cover the menu entry, the dialog, Add layer, Apply and its undo, both inspector rows, the tickbox menu, and the mover walking straight through the crate that 8c blocked on once their layers no longer meet, and 21 new unit tests cover the rule, the schema and the command (#152)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:31:22 +02:00

223 lines
9.4 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,
locate,
press,
typeText,
holdKey,
undo,
redo,
mutate,
select,
state,
object,
objects,
asset,
world,
entity,
stop,
} = t;
// 8a. Right-click the red crate → "Add collider": a collider asset sized like the crate
// (100×60) is placed 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 redRow = await locate('Red crate');
await click(redRow.x, redRow.y, 'right');
await clickText('Add 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');
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,
);
// 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 dialog behind Project → "Collision layers…".
await clickText('Project');
check(
'the Project menu offers the collision layers',
(await json('__gm.menuRows()')).includes('Collision layers…'),
JSON.stringify(await json('__gm.menuRows()')),
);
await clickText('Collision layers…');
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 json('__gm.menuRows()');
check(
'"Collides with" lists every layer of the project, all ticked',
JSON.stringify(ticks) === JSON.stringify(['☑ Default', '☑ Shots']),
JSON.stringify(ticks),
);
await clickText('☑ Default');
const unticked = await json('__gm.menuRows()');
check(
'picking one unticks it without closing the menu',
JSON.stringify(unticked) === JSON.stringify(['☐ Default', '☑ Shots']),
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 }`);
}