Colliders take a frame's outline and tiles have their own surface (#193)
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m33s

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>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 07:29:26 +02:00
1 parent 3ff7e91dbf
commit f4ec23d3d3
20 files changed
+729 -83

No files matched your search

+10
View File
@@ -715,6 +715,9 @@ export async function tilemap(t) {
await openTab(t, 'scene');
await select({ kind: 'asset', assetId: 'at-grass' });
const solidRow = await json("__gm.inspectorField('Solid')");
const surfaceRows = () =>
json("[__gm.inspectorField('Friction'), __gm.inspectorField('Bounciness')].map(Boolean)");
const surfaceBefore = await surfaceRows();
if (solidRow) await click(solidRow.x, solidRow.y);
await settle();
check(
@@ -722,6 +725,13 @@ export async function tilemap(t) {
Boolean(solidRow) && (await t.asset('at-grass'))?.props.solid === true,
JSON.stringify({ solidRow, props: (await t.asset('at-grass'))?.props }),
);
// Its surface (#193) is a solid tile's business: the rows come with the switch.
const surfaceAfter = await surfaceRows();
check(
'Friction and Bounciness show on a tile only once it is solid',
JSON.stringify(surfaceBefore) === '[false,false]' && JSON.stringify(surfaceAfter) === '[true,true]',
JSON.stringify({ surfaceBefore, surfaceAfter }),
);
await mutate(`__gm.lookAt(0, ${LANE})`);
await settle();
const outlined = await json(`__gm.tilemap.solids(${JSON.stringify(MAP_OBJECT)})`);
+68 -1
View File
@@ -5,7 +5,8 @@ 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.
* 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. */
@@ -354,4 +355,70 @@ export async function shapes(t) {
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();
}