Files
feuerwerk/tools/e2e/08-colliders.mjs
T
adminandClaude Opus 5 60d0146bde The topbar is icons only: the leading Project dropdown, the Scene picker and the labelled Play/Pause buttons are gone and in their place stand five icon buttons - menu, undo, redo on the left, play and pause in the middle - while Notice, the save state, fps and the Account menu keep the right side; the Play button swaps its icon for a stop square while the project runs and Pause swaps for a second play sign while it is held (Button.setIcon()), and iconSpace() drops the gap between icon and label when a button has no label, which is what kept a 24 px icon button 3 px off its own centre; the menu carries New project, Open project..., a separator and Project settings..., where the picker (src/editor/OpenProjectDialog.ts) preselects the open project and marks it "open", opens on a double click and puts Delete on the left of Cancel and Open - it closes itself before it asks whether to delete, because two modals on top of each other would have left the question standing over a list it no longer speaks about - and the settings dialog (src/editor/ProjectSettingsDialog.ts) holds the name, the "Play starts" switch (Start scene / Active scene, a preference of this browser and so no command) and the collision layers from #152, which retires CollisionLayersDialog.ts and the "Collision layers..." entry; what Apply actually writes is the pure function settingsChanges(), so renaming and the layers are each their own undo step and each drop out when nothing differs; the Scene dropdown falls away without replacement, the active scene is switched by double clicking it in the Assets panel, which is the way that was there anyway; new module 46 runs last with 9 checks (no label left in the bar, the run buttons in edit mode, the four menu entries, the picker with the open project preselected, Cancel leaving the project standing, the settings dialog on the state of the project, renaming with undo and redo through the icon buttons) and it neither opens nor deletes a project, because that would pull the project out from under every earlier module; modules 8, 18 and 35 and the harness follow the new bar, 6 unit tests cover settingsChanges and 3 the icons (#183)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:34:14 +02:00

225 lines
9.5 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": 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 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 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 }`);
}