A frame's collision outline can be generated from its pixels (#161)
game-maker / gate (push) Successful in 2m13s
game-maker / suite (push) Successful in 7m42s

Drawing an outline by hand for every frame of a sheet is slow, and the
outline mostly follows the opaque part of the image anyway. The texture
tab now traces it: one click, a corner budget, and every Frame
collision collider and tile using that frame blocks with the result.

- "Generate collision..." sits in the texture tab header and is off
  while the texture has no frames.
- The dialog has Max points (default 8, 3 to 64), Solid from ... %
  alpha (default 50) and All frames of the texture. A live line says
  what Generate will write, and Generate stays off when nothing is
  solid enough.
- traceCollision walks each island of solid pixels along the pixel
  edges, then drops the least important corners until at most N are
  left and no stair step remains. A slope becomes one straight edge.
- An outline never crosses itself. Where the image would force that,
  the island's convex hull is taken instead.
- Crumbs under 1 % of the frame area are dropped unless they are the
  only island. Holes are not reproduced.
- Existing outlines are replaced. One frame or all frames is a single
  undo step.
- TextureLibrary hands out the sheet's RGBA data, and a pure function
  cuts a frame's region from it.

Findings:
- The wedge in the e2e fixture gets 4 corners, not 3. Its tip is one
  pixel wide in the image, and that real edge stays while corners are
  still free.

Tests:
- ./check.sh: 131 files, 1941 tests, CHECK EXIT 0 (was 130 / 1931).
- 10 new unit tests in tests/core/trace-collision.test.ts, including
  150 random shapes that must all be valid and within the budget.
- GM_STEPS=42, freshly seeded: ALL PASSED, 20 checks (was 16).
- Three counter-probes bite: no hull fallback, no stair threshold,
  dialog ignoring the alpha threshold.
- Check sum 1202 in README is computed (1198 + 4), CI measures it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 08:00:30 +02:00
1 parent f4ec23d3d3
commit 2d79230b55
10 files changed
+705 -5

No files matched your search

+112 -1
View File
@@ -6,7 +6,8 @@ import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
* (#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).
* does a collider that wears the frame's outline (#193), 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. */
@@ -22,6 +23,26 @@ const PASTE_PICTURE = `(async () => {
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: [
@@ -421,4 +442,94 @@ export async function shapes(t) {
JSON.stringify({ frames: frameClimb.length, peak, top: F - 42, last: frameClimb.at(-1) }),
);
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();
}