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>
536 lines
23 KiB
JavaScript
536 lines
23 KiB
JavaScript
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), 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 }),
|
||
);
|
||
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();
|
||
|
||
// 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();
|
||
}
|