import { edge, node } from '../lib/builders.mjs'; import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs'; /** * Capsules and polygons (#151), and the collision outline of a frame that a tile blocks with * (#141). What only a running editor can show: the corners of a polygon collider are edited on * its outline in the scene, the engine lets a capsule slide down a polygon ramp, the Texture tab * draws a frame's outline with clicks, and a painted map blocks with that outline in the game – as * does a collider that wears the frame's outline (#193) – which "Add collider…" picks by itself on a * sprite wearing such a frame (#218) –, and "Generate collision…" reads that outline off the picture * (#161). */ /** A 32 × 32 picture, pasted like a file: the texture the outline is drawn on. */ const PASTE_PICTURE = `(async () => { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const g = canvas.getContext('2d'); g.fillStyle = '#3c9a4a'; g.fillRect(0, 0, 32, 32); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'wedge.png', { type: 'image/png' })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; /** * A 64 × 32 sheet, pasted like a file, painted pixel by pixel so no edge is smoothed: on the left a * wedge (pixels with x + y ≥ 32) and a band of alpha 0.3 above it, on the right a 16 × 16 square. */ const PASTE_SHEET = `(async () => { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 32; const g = canvas.getContext('2d'); g.fillStyle = '#c04040'; for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) if (x + y >= 32) g.fillRect(x, y, 1, 1); g.fillRect(40, 8, 16, 16); g.fillStyle = 'rgba(64, 64, 192, 0.3)'; g.fillRect(0, 0, 20, 4); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'generate.png', { type: 'image/png' })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; /** Walks its owner right and a little down, colliding with solids: 2 and 0.6 a frame at 60 fps. */ const climbGraph = { nodes: [ node('sc-update', 'OnUpdate', 0, 0), node('sc-owner', 'Owner', 0, 160), node('sc-move', 'Move', 300, 0, { dx: 1, dy: 0.3, scale: 120, collide: true }), ], edges: [ edge('sc-e1', 'sc-update', 'exec', 'sc-move', 'exec'), edge('sc-e2', 'sc-update', 'dt', 'sc-move', 'dt'), edge('sc-e3', 'sc-owner', 'object', 'sc-move', 'object'), ], }; const TRIANGLE = [ { x: 0, y: -80 }, { x: 80, y: 80 }, { x: -80, y: 80 }, ]; const TAN30 = Math.tan(Math.PI / 6); /** A ramp falling right at 30°; its top face runs through the object's origin. */ const RAMP = [ { x: -200, y: -200 * TAN30 }, { x: 200, y: 200 * TAN30 }, { x: 200, y: 150 }, { x: -200, y: 150 }, ]; const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); export async function shapes(t) { const { check, json, evaluate, mutate, select, click, doubleClick, drag, clickText, undo } = t; const { object, settle, sleep, play, stop, shot, showTab, press } = t; const Y = LANES.shapes; const put = ` const put = (id, assetId, parentId, x, y, name, overrides = {}) => { const o = make(id, assetId, parentId, y, name); o.position.x = x; o.overrides = overrides; scene.objects.push(o); };`; // 42a. A polygon collider on a crate, selected: a handle on each corner. A corner is dragged, a // double-click on an edge puts a corner there, Delete or the corner's menu takes the picked // one away (#288) – one undo step each. await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; ${MAKE_OBJECT} ${put} assets.push({ id: 'sh-col-poly', type: 'collider', name: 'sh-col-poly', props: { shape: 'polygon', points: ${JSON.stringify(TRIANGLE)}, isTrigger: false } }); put('sh-poly', 'crate', '', 0, ${Y}, 'Polygon crate', { width: 160, height: 160 }); put('sh-poly-col', 'sh-col-poly', 'sh-poly', 0, 0, 'Polygon collider'); __gm.state.select(null)`); await evaluate(`__gm.lookAt(0, ${Y})`); await select({ kind: 'object', holderId: 'scene1', objectId: 'sh-poly-col', objectIds: ['sh-poly-col'] }); await settle(); const corners = async () => (await json('__gm.polygonCorners()'))?.corners ?? []; const points = async () => (await object('sh-poly-col'))?.overrides?.points; const start = await corners(); check( 'a selected polygon collider shows a handle on each of its corners', start.length === 3 && start[0].y < start[1].y && start[1].x > start[2].x, JSON.stringify(start), ); const zoom = distance(start[0], start[1]) / distance(TRIANGLE[0], TRIANGLE[1]); await drag(start[1].x, start[1].y, start[1].x + 40, start[1].y); const dragged = await points(); check( "dragging a corner moves it in the collider's own units, and one undo puts it back", dragged?.length === 3 && Math.abs(dragged[1].x - (80 + 40 / zoom)) <= 1 && dragged[1].y === 80 && (await undo(), (await points()) === undefined), JSON.stringify({ dragged, zoom }), ); const pickedCorner = async () => (await json('__gm.polygonCorners()'))?.picked ?? null; const [a, b] = await corners(); const onEdge = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; await click(onEdge.x, onEdge.y); const clicked = await points(); // Past the double-click window: the click above must not count as the first of the pair. await sleep(500); await doubleClick(onEdge.x, onEdge.y); const inserted = await points(); check( 'a click on an edge adds nothing, a double-click puts a corner there and picks it', clicked === undefined && inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1 && (await pickedCorner()) === 1, JSON.stringify({ clicked, inserted, picked: await pickedCorner() }), ); await shot('42-polygon-corners'); const fourth = (await corners())[1]; await sleep(500); await doubleClick(fourth.x, fourth.y); const kept = await points(); await press('Delete'); const deleted = await points(); check( 'a double-click on a corner keeps it; Delete takes the picked corner away and leaves the collider', kept?.length === 4 && JSON.stringify(deleted) === JSON.stringify(TRIANGLE) && Boolean(await object('sh-poly-col')) && (await pickedCorner()) === -1 && (await undo(), (await points())?.length === 4), JSON.stringify({ kept, deleted, picked: await pickedCorner() }), ); const again = (await corners())[1]; await click(again.x, again.y, 'right'); const cornerMenu = await json('__gm.menuRows()'); await clickText('Delete point'); const byMenu = await points(); const last = (await corners())[1]; await click(last.x, last.y, 'right'); const lastThree = await json('__gm.menuEnabled()'); await press('Escape'); check( "a right-click on a corner offers Delete point, which takes it away – greyed out at the polygon's last three", JSON.stringify(cornerMenu) === JSON.stringify(['Delete point']) && JSON.stringify(byMenu) === JSON.stringify(TRIANGLE) && JSON.stringify(lastThree) === JSON.stringify([false]) && Boolean(await object('sh-poly-col')), JSON.stringify({ cornerMenu, byMenu, lastThree }), ); const clearShapes = () => mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => !o.id.startsWith('sh-')); p.assets = p.assets.filter((a) => !a.id.startsWith('sh-')); __gm.state.select(null)`); await clearShapes(); // Every step clears its objects, so each has the lane to itself. const RAMP_Y = Y + 100; // 42b. The engine with the new shapes: a capsule with a rigidbody (no turning) falls onto a // polygon ramp and slides down it, on the surface and never into it. await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; ${MAKE_OBJECT} ${put} assets.push({ id: 'sh-col-ramp', type: 'collider', name: 'sh-col-ramp', props: { shape: 'polygon', points: ${JSON.stringify(RAMP)}, isTrigger: false } }); assets.push({ id: 'sh-col-capsule', type: 'collider', name: 'sh-col-capsule', props: { shape: 'capsule', width: 20, height: 40, friction: 0, isTrigger: false } }); assets.push({ id: 'sh-rb', type: 'rigidbody', name: 'sh-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } }); put('sh-ramp', 'crate', '', 0, ${RAMP_Y}, 'Ramp', { width: 20, height: 20 }); put('sh-ramp-col', 'sh-col-ramp', 'sh-ramp', 0, 0); put('sh-capsule', 'crate', '', -150, ${RAMP_Y - 200}, 'Capsule', { width: 20, height: 40 }); put('sh-capsule-col', 'sh-col-capsule', 'sh-capsule', 0, 0); put('sh-capsule-rb', 'sh-rb', 'sh-capsule', 0, 0); __gm.state.select(null)`); await evaluate(`__gm.lookAt(0, ${RAMP_Y - 100})`); await settle(); await shot('42-capsule-ramp'); await play(); await evaluate(`(() => { const out = (window.__shapeSamples = []); const begin = performance.now(); const sample = () => { const w = __gm.world(); if (!w) return; const e = w.entities.find((x) => x.objectId === 'sh-capsule'); out.push({ x: e?.worldX, y: e?.worldY }); if (performance.now() - begin < 2500) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(2800); const samples = await json('window.__shapeSamples'); await stop(); // The lower round end's centre is 10 above the capsule's centre; its distance to the ramp's face // (through (0, RAMP_Y), normal (sin 30°, −cos 30°)) is what keeps it on the surface. const n = { x: 0.5, y: -Math.cos(Math.PI / 6) }; const gap = (s) => (s.x - 0) * n.x + (s.y + 10 - RAMP_Y) * n.y - 10; // Only while it is over the ramp, which ends at x = 200: past it the capsule falls free. const landed = samples.filter((s) => s.x < 190 && gap(s) < 1); check( 'a capsule falls onto a polygon ramp and slides down it on the surface', samples.length > 60 && landed.length > 30 && Math.max(...landed.map((s) => s.x)) > -150 + 60 && // The landing sinks it in a little (measured 0.9 at ~6 a frame); the engine pushes it back out. landed.every((s) => gap(s) > -2) && landed.slice(30).every((s) => Math.abs(gap(s)) < 0.1), JSON.stringify({ frames: samples.length, landed: landed.length, furthest: Math.max(...landed.map((s) => s.x)), deepest: Math.min(...landed.map(gap)), settled: Math.max(...landed.slice(30).map((s) => Math.abs(gap(s)))), }), ); await clearShapes(); // 42c. The Texture tab draws a frame's collision outline (#141): with "Collision" on, three // clicks in the selected frame are three corners; a corner drags; undo takes the drag back. await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate(PASTE_PICTURE); await sleep(2500); const texture = await json( "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" + ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', ); check( 'the picture is imported at its own size', texture?.w === 32 && texture?.h === 32, JSON.stringify(texture), ); await mutate(` const texture = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}); texture.frames.push({ id: 'sh-frame', name: 'Wedge', x: 0, y: 0, w: 32, h: 32 });`); await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' }); await showTab('texture'); await settle(); await clickText('Collision'); const outline = () => json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)})` + `.frames.find((f) => f.id === 'sh-frame').collision ?? null`, ); for (const [x, y] of [ [0, 32], [32, 0], [32, 32], ]) { const at = await json(`__gm.texture.pointAt(${x}, ${y})`); await click(at.x, at.y); } const drawn = await outline(); check( 'with Collision on, clicks in the selected frame draw its outline corner by corner', (await json('__gm.texture.collisionMode()')) === true && JSON.stringify(drawn) === JSON.stringify([ [ { x: 0, y: 32 }, { x: 32, y: 0 }, { x: 32, y: 32 }, ], ]), JSON.stringify(drawn), ); const from = await json('__gm.texture.pointAt(32, 0)'); const to = await json('__gm.texture.pointAt(16, 0)'); await drag(from.x, from.y, to.x, to.y); const moved = await outline(); await shot('42-texture-outline'); await undo(); check( 'a corner of the outline drags, and one undo puts it back', moved?.[0]?.[1]?.x === 16 && moved[0][1].y === 0 && (await outline())?.[0]?.[1]?.x === 32, JSON.stringify({ moved, after: await outline() }), ); // A frame holds several outlines: Ctrl+click starts a second one. The corner put down last is the // picked one; a click on an edge drops the pick, and Delete then takes the active outline away // whole – not the frame, which Delete removes outside the mode. for (const [i, [x, y]] of [ [2, 2], [10, 2], [2, 10], ].entries()) { const at = await json(`__gm.texture.pointAt(${x}, ${y})`); await click(at.x, at.y, 'left', i === 0 ? { ctrl: true } : {}); } const two = await outline(); const pickedLast = await json('__gm.texture.pickedCorner()'); await shot('42-texture-two-outlines'); const secondEdge = await json('__gm.texture.pointAt(6, 2)'); await click(secondEdge.x, secondEdge.y); const pickedNone = await json('__gm.texture.pickedCorner()'); await press('Delete'); const left = await outline(); check( 'Ctrl+click starts a second outline in the frame, and Delete removes only that one', JSON.stringify(pickedLast) === JSON.stringify({ outline: 1, index: 2 }) && pickedNone === null && two?.length === 2 && JSON.stringify(two[1]) === JSON.stringify([ { x: 2, y: 2 }, { x: 10, y: 2 }, { x: 2, y: 10 }, ]) && left?.length === 1 && left[0].length === 3 && (await json( `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(texture?.id)}).frames.some((f) => f.id === 'sh-frame')`, )) === true, JSON.stringify({ two, left, pickedLast, pickedNone }), ); // #288 on the outline that is left: a double-click on an edge puts a corner there and picks it, // Delete takes the picked corner (not the outline), and a corner's menu does the same. const slope = await json('__gm.texture.pointAt(16, 16)'); await sleep(500); await doubleClick(slope.x, slope.y); const four = await outline(); const pickedNew = await json('__gm.texture.pickedCorner()'); await press('Delete'); const three = await outline(); const apex = await json('__gm.texture.pointAt(32, 0)'); await click(apex.x, apex.y, 'right'); const outlineMenu = await json('__gm.menuRows()'); await clickText('Delete point'); const pair = await outline(); // All three steps back: the checks after this one count on the undo stack as it was. await undo(); await undo(); await undo(); check( 'a double-click on an edge adds a picked corner, Delete and the menu of a corner take one away', four?.[0]?.length === 4 && four[0][1].x === 16 && four[0][1].y === 16 && JSON.stringify(pickedNew) === JSON.stringify({ outline: 0, index: 1 }) && three?.length === 1 && three[0].length === 3 && JSON.stringify(outlineMenu) === JSON.stringify(['Delete point']) && pair?.[0]?.length === 2 && (await outline())?.[0]?.length === 3, JSON.stringify({ four, pickedNew, three, outlineMenu, pair }), ); // 42c-ii. A sheet full of outlines stays smooth (#220): dragging a corner redraws only the // edited frame, a pan moves the drawn outlines instead of drawing them anew, and a // change to an unselected frame still reaches the picture. const drawInfo = () => json('__gm.texture.drawInfo()'); const beforeDrag = await drawInfo(); const corner = await json('__gm.texture.pointAt(32, 0)'); const aside = await json('__gm.texture.pointAt(24, 0)'); await drag(corner.x, corner.y, aside.x, aside.y, 8); const afterDrag = await drawInfo(); await undo(); check( 'dragging a corner does not redraw the frames around it', afterDrag.sheetBuilds === beforeDrag.sheetBuilds, JSON.stringify({ before: beforeDrag.sheetBuilds, after: afterDrag.sheetBuilds }), ); await select({ kind: 'asset', assetId: texture?.id }); await settle(); const unselected = await drawInfo(); await undo(); await settle(); const restored = await drawInfo(); await t.redo(); await settle(); check( 'undo on an unselected frame redraws its outlines', unselected.sheetOutlines === 1 && restored.sheetOutlines === 2 && (await drawInfo()).sheetOutlines === 1, JSON.stringify({ unselected: unselected.sheetOutlines, restored: restored.sheetOutlines }), ); // Every frame's outlines show, but only the selected frame's are tinted, so the tint does not lie // over the whole sheet. check( "only the selected frame's collision outlines are tinted", beforeDrag.filled.overlay === 1 && beforeDrag.filled.sheet === 0 && unselected.sheetOutlines === 1 && unselected.filled.sheet === 0 && unselected.filled.overlay === 0, JSON.stringify({ selected: beforeDrag.filled, unselected: unselected.filled }), ); const area = await json('__gm.texture.rect()'); const beforePan = await drawInfo(); const [px, py] = [Math.round(area.x + area.w / 2), Math.round(area.y + area.h / 2)]; await drag(px, py, px + 40, py + 25, { steps: 8, button: 'middle' }); const afterPan = await drawInfo(); const origin = await json('__gm.texture.pointAt(0, 0)'); const label = afterPan.labels.find((l) => l.text === 'Wedge'); check( 'a pan moves the drawn outlines and names along without drawing them anew', afterPan.sheetBuilds === beforePan.sheetBuilds && Math.abs(afterPan.origin.x - beforePan.origin.x - 40) < 0.01 && Math.abs(afterPan.origin.y - beforePan.origin.y - 25) < 0.01 && Math.abs(afterPan.origin.x - origin.x) < 0.01 && Math.abs(afterPan.origin.y - origin.y) < 0.01 && Boolean(label) && Math.abs(label.x - origin.x - 5) < 0.01 && Math.abs(label.y - origin.y - 5) < 0.01, JSON.stringify({ beforePan, afterPan, origin }), ); await select({ kind: 'frame', assetId: texture?.id, frameId: 'sh-frame' }); await settle(); await clickText('Collision'); await showTab('scene'); await settle(); // 42d. A plain tile of that frame blocks with the outline: a wedge on a floor of plain solid // tiles. The scene view outlines it, and a walker whose Move collides climbs over it. const MAP_Y = Y; // cell (x, y) spans x·32 − 192 … and y·32 − 64 + MAP_Y …; the floor's top is MAP_Y + 32 const floor = Object.fromEntries(Array.from({ length: 12 }, (_, x) => [`${x},3`, 'sh-wall'])); await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; ${MAKE_OBJECT} ${put} const behavior = structuredClone(assets.find((a) => a.id === 'mover')); Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} }); assets.push(behavior); assets.push({ id: 'sh-wedge', type: 'tile', name: 'Wedge', props: { kind: 'none', solid: true }, pictures: ['sh-frame'] }); assets.push({ id: 'sh-wall', type: 'tile', name: 'Floor', props: { kind: 'none', solid: true }, pictures: [] }); assets.push({ id: 'sh-map', type: 'tilemap', name: 'Wedge map', props: { columns: 12, rows: 4, tileSize: 32 }, layers: [{ id: 'sh-layer', name: 'Layer 1', cells: { ...${JSON.stringify(floor)}, '6,2': 'sh-wedge' } }] }); assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker', props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } }); put('sh-map-obj', 'sh-map', '', 0, ${MAP_Y}, 'Wedge map'); put('sh-walker', 'crate', '', -100, ${MAP_Y + 22}, 'Walker', { width: 20, height: 20 }); put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0); put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb'); __gm.state.select(null)`); await evaluate(`__gm.lookAt(0, ${MAP_Y})`); await settle(); const solids = await json(`__gm.tilemap.solids('sh-map-obj')`); check( 'the scene view outlines the wedge cell with its outline, the floor as one rectangle', solids?.visible === true && JSON.stringify(solids.rects) === JSON.stringify([{ x: 0, y: 3, w: 12, h: 1 }]) && JSON.stringify(solids.outlines) === JSON.stringify([ [ { x: 6, y: 3 }, { x: 7, y: 2 }, { x: 7, y: 3 }, ], ]), JSON.stringify(solids), ); // Drawn along the outer edges only (#173): the wedge's foot on the floor is no stroke. const cornersOf = (line) => (line?.points ?? []).map((p) => `${p.x},${p.y}`).sort(); check( 'the scene view draws the wedge and the floor as one closed outline, without the edge they share', solids?.boundary?.length === 1 && solids.boundary[0].closed === true && JSON.stringify(cornersOf(solids.boundary[0])) === JSON.stringify(['0,3', '0,4', '12,3', '12,4', '6,3', '7,2', '7,3'].sort()), JSON.stringify(solids?.boundary), ); await shot('42-wedge-map'); await play(); await evaluate(`(() => { const out = (window.__climb = []); const begin = performance.now(); const sample = () => { const w = __gm.world(); if (!w) return; const e = w.entities.find((x) => x.objectId === 'sh-walker'); out.push({ x: e?.worldX, y: e?.worldY }); if (performance.now() - begin < 4500) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(4800); const climb = await json('window.__climb'); await stop(); const highest = Math.min(...climb.map((s) => s.y)); check( "a walker whose Move collides climbs the wedge tile's slope and goes on past it", climb.length > 100 && highest < MAP_Y - 10 + 1 && highest > MAP_Y - 10 - 3 && climb.at(-1).x > 45, JSON.stringify({ frames: climb.length, highest, top: MAP_Y - 10, last: climb.at(-1) }), ); await clearShapes(); // 42e. A collider wears the same frame's outline (#193), linked: shape "Frame collision", the frame // picked like a texture. It is the frame's size in the scene, and a walker climbs its slope – // a box of that size would have stopped it at its side. const F = Y; // the floor's top face; the wedge stands on it, its slope rising to x = 16 await mutate(` const scene = __gm.state.activeScene; const assets = __gm.state.project.assets; const texture = assets.find((a) => a.id === ${JSON.stringify(texture?.id)}); texture.frames.find((f) => f.id === 'sh-frame').collision = [[{ x: 0, y: 32 }, { x: 32, y: 0 }, { x: 32, y: 32 }]]; ${MAKE_OBJECT} ${put} const behavior = structuredClone(assets.find((a) => a.id === 'mover')); Object.assign(behavior, { id: 'sh-climb', name: 'Climb', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(climbGraph)} }); assets.push(behavior); assets.push({ id: 'sh-col-wedge', type: 'collider', name: 'sh-col-wedge', props: { shape: 'frame', frame: 'sh-frame', isTrigger: false } }); assets.push({ id: 'sh-col-floor', type: 'collider', name: 'sh-col-floor', props: { shape: 'box', width: 400, height: 32, isTrigger: false } }); assets.push({ id: 'sh-col-walker', type: 'collider', name: 'sh-col-walker', props: { shape: 'box', width: 20, height: 20, friction: 0, isTrigger: false } }); put('sh-floor', 'crate', '', 0, ${F + 16}, 'Floor', { width: 20, height: 20 }); put('sh-floor-col', 'sh-col-floor', 'sh-floor', 0, 0); put('sh-wedge', 'crate', '', 0, ${F - 16}, 'Wedge', { width: 20, height: 20 }); put('sh-wedge-col', 'sh-col-wedge', 'sh-wedge', 0, 0); put('sh-walker', 'crate', '', -100, ${F - 10}, 'Walker', { width: 20, height: 20 }); put('sh-walker-col', 'sh-col-walker', 'sh-walker', 0, 0); put('sh-walker-climb', 'sh-climb', 'sh-walker', 0, 0, 'Climb'); __gm.state.select(null)`); await evaluate(`__gm.lookAt(0, ${F})`); await settle(); const wedgeRect = await json(`__gm.objectRect('sh-wedge-col')`); const floorRect = await json(`__gm.objectRect('sh-floor-col')`); check( 'a collider wearing a frame outline is the size of the frame in the scene, not its own width', wedgeRect && floorRect && Math.abs(wedgeRect.w - wedgeRect.h) < 1 && Math.abs(floorRect.w / wedgeRect.w - 400 / 32) < 0.2, JSON.stringify({ wedgeRect, floorRect }), ); await shot('42-frame-collider'); await play(); await evaluate(`(() => { const out = (window.__frameClimb = []); const begin = performance.now(); const sample = () => { const w = __gm.world(); if (!w) return; const e = w.entities.find((x) => x.objectId === 'sh-walker'); out.push({ x: e?.worldX, y: e?.worldY }); if (performance.now() - begin < 3000) requestAnimationFrame(sample); }; requestAnimationFrame(sample); })()`); await sleep(3300); const frameClimb = await json('window.__frameClimb'); await stop(); // Over the wedge's top corner (16, F − 32) the walker's centre stands 10 higher. const peak = Math.min(...frameClimb.map((s) => s.y)); check( "a walker whose Move collides climbs the slope drawn in the collider's frame and goes on past it", frameClimb.length > 100 && peak < F - 42 + 1 && peak > F - 42 - 3 && frameClimb.at(-1).x > 45, JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }), ); // 42e-ii. "Add collider…" on a sprite wearing that frame (#218): the new collider wears the frame's // outline at once, scaled to the sprite (64 wide, the frame 32); a sprite without a picture // gets the box. Undo takes each collider and its asset away again. await mutate(` const scene = __gm.state.activeScene; ${MAKE_OBJECT} ${put} put('sh-worn', 'crate', '', 150, ${F - 120}, 'Worn 42', { texture: 'sh-frame', width: 64, height: 64 }); put('sh-plain', 'crate', '', -150, ${F - 120}, 'Plain 42', { width: 64, height: 64 }); __gm.state.select(null)`); await evaluate(`__gm.lookAt(0, ${F - 60})`); const addedTo = 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 clickText('Create new collider'); await settle(); const s = await t.state(); const placed = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null; const props = placed ? (await t.asset(placed.assetId))?.props : null; const rect = placed ? await json(`__gm.objectRect(${JSON.stringify(placed.id)})`) : null; return { parentId: placed?.parentId, shape: props?.shape, frame: props?.frame, scale: props?.scale, rect, }; }; const assetCount = () => json('__gm.state.project.assets.length'); const assetsBefore = await assetCount(); const worn = await addedTo('sh-worn'); const wornRect = await json(`__gm.objectRect('sh-worn')`); await shot('42-add-frame-collider'); await undo(); const plain = await addedTo('sh-plain'); await undo(); check( 'Add collider on a sprite wearing a frame with an outline takes "Frame collision" of that frame, sprite-sized', worn.parentId === 'sh-worn' && worn.shape === 'frame' && worn.frame === 'sh-frame' && worn.scale === 2 && Math.abs(worn.rect?.w - wornRect?.w) < 1 && plain.parentId === 'sh-plain' && plain.shape === 'box' && (await assetCount()) === assetsBefore, JSON.stringify({ worn, wornRect, plain }), ); await clearShapes(); // 42f. "Generate collision…" reads the outlines off the picture (#161): a wedge and a faint band // (alpha 30 %) in one frame, a square in the other. From 50 % the band is not solid, from // 20 % it is an outline of its own; "All frames" writes both frames in one undo step. await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate(PASTE_SHEET); await sleep(2500); const sheet = await json( "(() => { const a = __gm.state.project.assets.filter((a) => a.type === 'texture').at(-1);" + ' return a ? { id: a.id, w: a.width, h: a.height } : null; })()', ); await mutate(` const sheet = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)}); sheet.frames.push({ id: 'gc-left', name: 'Left', x: 0, y: 0, w: 32, h: 32 }, { id: 'gc-right', name: 'Right', x: 32, y: 0, w: 32, h: 32 });`); await select({ kind: 'frame', assetId: sheet?.id, frameId: 'gc-left' }); await showTab('texture'); await settle(); await clickText('Generate collision…'); await settle(); const dialog = () => json('__gm.texture.generateDialog()'); const opened = await dialog(); check( 'Generate collision… opens on the selected frame and names what it would write from 50 % alpha', sheet?.w === 64 && opened?.enabled === true && opened.all === false && /^Frame "Left": 1 outline, [34] corners$/.test(opened.summary), JSON.stringify({ sheet, opened }), ); const retype = async (at, text) => { await click(at.x, at.y); await press('a', { ctrl: true }); await t.typeText(text); await press('Enter'); await settle(); }; await retype(opened.at.threshold, '20'); const faint = await dialog(); await shot('42-generate-dialog'); await retype(opened.at.threshold, '50'); check( 'from 20 % alpha the faint band is solid too and becomes an outline of its own', /^Frame "Left": 2 outlines, \d+ corners$/.test(faint?.summary ?? '') && /^Frame "Left": 1 outline/.test((await dialog())?.summary ?? ''), JSON.stringify(faint), ); await click(opened.at.all.x, opened.at.all.y); await settle(); const both = await dialog(); await clickText('Generate'); await settle(); const outlines = () => json( `Object.fromEntries(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet?.id)})` + `.frames.map((f) => [f.id, f.collision ?? null]))`, ); const written = await outlines(); const cornerSet = (ring) => (ring ?? []).map((p) => `${p.x},${p.y}`).sort(); const ringArea = (ring) => Math.abs( ring.reduce( (sum, p, i) => sum + p.x * ring[(i + 1) % ring.length].y - ring[(i + 1) % ring.length].x * p.y, 0, ), ) / 2; check( 'with All frames, Generate outlines the wedge and the square of both frames', both?.all === true && /^2 frames: 2 outlines/.test(both.summary) && written['gc-left']?.length === 1 && written['gc-left'][0].length <= 4 && Math.abs(ringArea(written['gc-left'][0]) - 512) < 26 && JSON.stringify(cornerSet(written['gc-right']?.[0])) === JSON.stringify(['24,24', '24,8', '8,24', '8,8']), JSON.stringify({ both, written }), ); await clickText('Collision'); await settle(); await shot('42-generated-outline'); await clickText('Collision'); await undo(); const undone = await outlines(); check( 'one undo takes the outlines of both frames back', undone['gc-left'] === null && undone['gc-right'] === null, JSON.stringify(undone), ); await showTab('scene'); await settle(); }