Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
game-maker / gate (push) Successful in 1m52s
game-maker / suite (push) Successful in 6m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 22:56:25 +02:00
1 parent 1b5e386b60
commit 32057a96fc
25 files changed
+2341 -122

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
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.
*/
/** 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 } = 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?.[1]?.x === 16 && moved[1].y === 0 && (await outline())?.[1]?.x === 32,
JSON.stringify({ moved, after: await outline() }),
);
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),
);
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();
}
+2
View File
@@ -44,6 +44,7 @@ import { plugins } from './38-plugins.mjs';
import { tile } from './39-tile.mjs';
import { tilemap } from './40-tilemap.mjs';
import { physics } from './41-physics.mjs';
import { shapes } from './42-shapes.mjs';
export const STEPS = [
hierarchy,
@@ -87,4 +88,5 @@ export const STEPS = [
tile,
tilemap,
physics,
shapes,
];
+1
View File
@@ -22,6 +22,7 @@ export const LANES = {
sound: -2700,
tilemap: -3100,
physics: -3500,
shapes: -3900,
};
/**