Files
feuerwerk/tools/e2e/08-colliders.mjs
T
rootandClaude Opus 5 15ffcd5739 Left-align menu entries, add a Dropdown field, scrollable and searchable menus
Menu entries are left-aligned and live in a ScrollContainer capped at 320 px, so a
long list (the parent picker holds every object of the scene) scrolls instead of
running off the screen. `Menu(items, { searchable: true })` puts a filter box above
the scroll area; Enter takes the first match. Filtering forces a relayout because
two queries with the same hit count leave the height unchanged, which would keep
the stale row positions.

Fields that open a menu are a new `Dropdown` widget: the value sits flush left and
the chevron is drawn flush right instead of being written into the label. A menu
opened below a field is never narrower than that field.

New debug hooks `overlayTexts()` / `overlayRect()` make the popup contents
assertable; e2e step 11 covers the cap, the scrolling and the search box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:31:14 +02:00

134 lines
5.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.
/**
* Colliders: "Add collider" from the context menu, collision events reaching the owner's
* behavior, and Move with "Collide with solids" – blocking, triggers, no tunnelling.
*/
export async function colliders(t) {
const {
check,
sleep,
evaluate,
json,
shot,
click,
clickText,
locate,
press,
holdKey,
undo,
mutate,
select,
state,
object,
objects,
asset,
world,
entity,
stop,
} = t;
// 15. 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');
await sleep(100);
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,
);
// 15a. 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.state.activeScene.objects.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();
// 15b. 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 sleep(300);
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 sleep(200);
await shot('22-trigger-collider');
}