Files
feuerwerk/tools/e2e/42-shapes.mjs
T
adminandClaude Opus 5 f4ec23d3d3
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m33s
Colliders take a frame's outline and tiles have their own surface (#193)
Issue #193 asked where friction, bounciness and density of a tile
live. Tiles had a fixed surface (friction 0.5, bounciness 0) and a
collider could not use the outline drawn on a texture frame (#141).
Both gaps are closed here.

- A collider has a fifth shape "Frame collision" with a Frame row.
  The frame is picked like a sprite's texture and stored as the
  value 'frame'.
- The shape is linked, not copied. Redrawing the outline in the
  texture tab changes every collider using that frame, in the editor
  and on the next play.
- The frame centre sits on the collider origin, one image pixel is
  one unit. The shape turns, scales and mirrors like any other.
- Every outline ring blocks. Each ring is split into convex parts the
  same way a polygon is.
- Without a frame, with a whole texture or with a frame that has no
  outline, the collider is drawn red as a box and blocks nothing.
- Move, Rapier, raycast, detection, hit test, drawing and footprint
  all resolve the frame. At runtime an entity looks the frame up
  live in the current project.
- A tile has Friction and Bounciness next to Solid and One-way. The
  rows only show when Solid is on. Defaults and mixing rules match
  the collider. Density is left out because a tilemap never moves.
- Tilemap cells are grouped by surface as well as by one-way angle.
  Ice and stone never merge into one rectangle, and every cell brings
  its surface to Move and Rapier.

Findings:

- A tile with a collision scale below 1 is no longer flush with a
  neighbour of another surface. It is inset there like at an open
  side, the same as next to one-way tiles today.
- A bounce test first measured 0.84. The ball had landed on the hero
  at the origin, not on the tile. On the tile it measures 0.993.

Tests:

- ./check.sh: 130 files, 1931 tests, exit 0 (was 129 / 1917).
- 14 new unit tests in tests/core/surface.test.ts. The Move tests run
  against the editor run and the physics system.
- 3 new e2e checks: 40l (surface rows follow Solid), 42e (collider
  has frame size, a walker climbs the slope in the frame).
- GM_STEPS=1-8,39-43 freshly seeded: ALL PASSED, 376 checks.
- Three counter-probes bite: Move ignores the tile surface (2 fail),
  Rapier ignores it (1 fails), frame shape falls back to a box
  (5 fail).
- Check sum 1198 is computed (1195 + 3), the full run belongs to CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:29:26 +02:00

425 lines
19 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 { 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).
*/
/** 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 }));
})()`;
/** 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 }),
);
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) }),
);
await clearShapes();
}