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

+1 -1
View File
@@ -34,7 +34,7 @@ export abstract class FreeViewport extends WorldViewport {
fit(): void {
const { items } = this.collectFrame();
const boxes = items.map((item) => {
const e = footprintExtent(item.type, item.props, item.m);
const e = footprintExtent(item.type, item.props, item.m, item.frame);
return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top };
});
const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!;
+1 -1
View File
@@ -1094,7 +1094,7 @@ export class SceneViewport extends FreeViewport {
if (!item) continue;
// Two screen pixels outside the footprint, whichever way it is turned.
const corners = this.screenCorners(
footprintCorners(item.type, item.props, item.m, 2 / view.scale),
footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame),
view,
);
// A switched-off object draws nothing, so its outline is the only way to see where it
+3 -3
View File
@@ -191,7 +191,7 @@ export abstract class WorldViewport extends Widget {
const view = this.currentView();
const item = this.lastItems.find((i) => i.id === objectId);
if (!item || !view) return null;
const extent = footprintExtent(item.type, item.props, item.m);
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2);
const origin = this.getGlobalPosition();
return {
@@ -229,8 +229,8 @@ export abstract class WorldViewport extends Widget {
const seen = new Set<string>();
for (const item of items) {
seen.add(item.id);
const size = footprintOf(item.type, item.props);
const extent = footprintExtent(item.type, item.props, item.m);
const size = footprintOf(item.type, item.props, item.frame);
const extent = footprintExtent(item.type, item.props, item.m, item.frame);
// A switched-off object is not drawn at all, in the scene view as in the game; it is
// selected and switched back on from the hierarchy panel. A source is never culled by its
// own little square: its particles may be anywhere on screen while it is not.