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 }`); }