A frame's collision outline can be generated from its pixels (#161)
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:
1 parent
f4ec23d3d3
commit
2d79230b55
10 files changed
+705
-5
No files matched your search
+112
-1
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user