Files
feuerwerk/tools/e2e/42-shapes.mjs
T
adminandClaude Opus 5.5 d39bf83ea9
game-maker / gate (push) Successful in 2m49s
game-maker / suite (push) Successful in 13m30s
Pick the frame's outline when adding a collider to a sprite (#218)
"Add collider" on a sprite always made a box of the sprite's size,
even when the sprite's picture is a frame that has a collision outline.
The outline then had to be picked by hand as "Frame collision".

- newColliderProps in colliderCommands.ts is the rule as a pure
  function. A sprite whose picture is a frame with an outline gets a
  collider with shape "frame" and that frame. Anything else keeps the
  box of the sprite's size.
- The frame is the one the sprite shows when the collider is added,
  also when only this placement overrides it.
- Decided here, the issue says nothing about size: a frame collider is
  as big as its frame, so a sprite drawn bigger or smaller with the
  same proportions gets the collider's scale set to match.
- Decided here as well: a sprite stretched out of the frame's
  proportions keeps the box, because an outline cannot be stretched
  in one direction.

Tests:
- Gate: 198 files, 2914 tests (2911 + 3), exit 0
- e2e 8,42: 46 checks passed (42: +1 for 42e-ii)
- Three new unit tests in tests/core/collider-parts.test.ts: the
  frame with undo, the scale with an overridden frame, and the cases
  that keep the box
- 42e-ii goes through the context menu: "Frame collision" with scale
  2 on the sprite wearing the wedge frame, a box on a sprite without
  a picture
- Five unit counter-probes fail, 1 to 2 tests each. They ran on
  stein2, not in the container.
- The browser counter-probe fails 42e-ii with shape "box"
- The e2e sum of 1617 is calculated (1616 + 1). Neither the full
  suite nor the smokes ran for this state.
- Vector art has no test of its own, it takes the same branch as a
  whole texture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:03:05 +02:00

649 lines
28 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 { 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
// press on an edge puts a corner there, a double-click takes it away – 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 [a, b] = await corners();
await click((a.x + b.x) / 2, (a.y + b.y) / 2);
const inserted = await points();
check(
'a press on an edge puts a corner there',
inserted?.length === 4 && Math.abs(inserted[1].x - 40) <= 1 && Math.abs(inserted[1].y) <= 1,
JSON.stringify(inserted),
);
await shot('42-polygon-corners');
const fourth = (await corners())[1];
await doubleClick(fourth.x, fourth.y);
check(
'a double-click on a corner takes it away again',
JSON.stringify(await points()) === JSON.stringify(TRIANGLE),
JSON.stringify(await points()),
);
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, and Delete 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();
await shot('42-texture-two-outlines');
await press('Delete');
const left = await outline();
check(
'Ctrl+click starts a second outline in the frame, and Delete removes only that one',
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 }),
);
// 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();
}