- Graph: nodes.ts split into nodes/<category>/<Type>.ts (62 files) with shared.ts factories and index.ts registry; NodeContext/values/NodeRegistry separated, writeTyped() replaces duplicated typed writes - Comments: one style (JSDoc on non-obvious exports, // for decisions and traps), wrong/stale comments fixed or removed, ids.ts now real 10 base-36 chars - Core: chainOf, shared worldPosition/editorPosition core, commands/require.ts, commands/naming.ts, util/list.ts, setBagValue/toPosition/propDef; dead code removed (isBehaviorAsset, SCENE_OBJECT_SCHEMA, CommandStack.subscribe, Input.axis, setMode, titleText, World.worldPosition, unused ctor params) - Editor: Inspector split into inspector/ sections, App.ts into Editor.ts + debugHook.ts + shortcuts.ts, Viewport into items.ts + symbols.ts, shared "Add collider" action, unified type labels/swatches - UI toolkit: Widget.trackHover/onPointerRelease, drawControlBox, math helpers, Popup.openBelow, Menu/Dialog/ColorPicker/ListRow in own files, named Theme sizes - Tools: seed-demo/smoke-demo and demo-check project removed, tools/lib (harness, projects, builders), e2e.mjs as runner over tools/e2e/*.mjs sections - Tests: tests/helpers.ts replaces copied fixtures and NodeContext doubles Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
78 lines
5.2 KiB
JavaScript
78 lines
5.2 KiB
JavaScript
/**
|
||
* 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');
|
||
}
|