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

+316
View File
@@ -0,0 +1,316 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model/Asset';
import { translation } from '../../src/core/model/matrix';
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import { TILE_BOUNCINESS_KEY, TILE_FRICTION_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
import { solidRects, tileMaterial, tileMaterials } from '../../src/core/model/tilemapCollision';
import {
colliderFrame,
colliderShape,
colliderSize,
outlinesOf,
shapeContains,
} from '../../src/core/properties/collider';
import { isRowShown, schemaFor } from '../../src/core/properties/PropertySchema';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { COLLISION_SYSTEMS, makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
const P = (...xy: number[]) =>
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
/**
* A scene with a texture of one 40 × 40 frame, and `add`, which places an object with a collider
* (and maybe a rigidbody) and sets the collider's properties.
*/
function frameScene() {
const f = makeSpriteScene('Thing');
const texture = new CreateAssetCommand('texture', 'Sheet');
f.stack.execute(texture);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 64;
sheet.height = 64;
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 40, h: 40 });
f.stack.execute(frame);
const outline = (...outlines: Vec2[][]) =>
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines));
const add = (at: Vec2, collider: Record<string, PropertyValue>, body?: Record<string, PropertyValue>) => {
const id = place(f, f.spriteId, at);
const command = new AddColliderCommand(f.sceneId, id);
f.stack.execute(command);
for (const [key, value] of Object.entries(collider))
f.stack.execute(new SetOverrideCommand(f.sceneId, command.objectId, key, value));
if (body) f.stack.execute(new AddRigidbodyCommand(f.sceneId, id));
return id;
};
return { ...f, textureId: texture.asset.id, frameId: frame.frameId, outline, add };
}
/** The right half of the 40 × 40 frame: in the collider's units x runs 0 … 20, y −20 … 20. */
const RIGHT_HALF = P(20, 0, 40, 0, 40, 40, 20, 40);
/** A triangle in the top left corner and a square in the bottom right: two rings. */
const TRIANGLE = P(0, 0, 16, 0, 0, 16);
const SQUARE = P(24, 24, 36, 24, 36, 36, 24, 36);
describe('a collider wearing a frame outline (#193)', () => {
it('is a shape of its own with a frame row, the frame picked like a texture', () => {
const schema = schemaFor('collider');
const shape = schema.find((d) => d.key === 'shape')!;
expect(shape.kind === 'enum' && shape.options.map((o) => o.value)).toContain('frame');
expect(schema.find((d) => d.key === 'frame')).toMatchObject({ kind: 'texture', default: '' });
});
it('takes every ring of the frame, centred on its origin, a pixel to a unit, and the frame size', () => {
const s = frameScene();
s.outline(TRIANGLE, SQUARE);
const props = { shape: 'frame', frame: s.frameId };
const frame = colliderFrame(s.project, props);
expect(frame).toMatchObject({ w: 40, h: 40 });
expect(colliderSize(props, frame)).toEqual({ w: 40, h: 40 });
const shape = colliderShape(props, translation(100, 50), frame);
if (shape.kind !== 'polygon') throw new Error(shape.kind);
expect(outlinesOf(shape)).toEqual([P(80, 30, 96, 30, 80, 46), P(104, 54, 116, 54, 116, 66, 104, 66)]);
// Inside a ring collides, the gap between them does not.
expect(shapeContains(shape, { x: 83, y: 33 })).toBe(true);
expect(shapeContains(shape, { x: 110, y: 60 })).toBe(true);
expect(shapeContains(shape, { x: 100, y: 50 })).toBe(false);
});
it('stays linked: a redrawn outline is what the collider wears next, and undo brings the old one back', () => {
const s = frameScene();
s.outline(TRIANGLE);
const props = { shape: 'frame', frame: s.frameId };
const ringsNow = () => colliderFrame(s.project, props)!.rings;
expect(ringsNow()).toHaveLength(1);
s.outline(TRIANGLE, SQUARE);
expect(ringsNow()).toHaveLength(2);
s.stack.undo();
expect(ringsNow()).toEqual([P(-20, -20, -4, -20, -20, -4)]);
});
it('without a frame, with a whole texture or with nothing drawn it is a box that collides with nothing', () => {
const s = frameScene();
const shapeOf = (frame: string) => {
const props = { shape: 'frame', frame, width: 30, height: 10 };
return colliderShape(props, translation(0, 0), colliderFrame(s.project, props));
};
// No frame, or an id that is no frame: the collider's own width × height.
for (const id of ['', s.textureId, 'gone'])
expect(shapeOf(id), id).toMatchObject({
kind: 'polygon',
outline: P(-15, -5, 15, -5, 15, 5, -15, 5),
pieces: [],
});
// A frame nobody drew an outline on: the frame's box.
expect(shapeOf(s.frameId)).toMatchObject({ outline: P(-20, -20, 20, -20, 20, 20, -20, 20), pieces: [] });
});
it('keeps shape and frame through a round trip', () => {
const s = frameScene();
s.outline(RIGHT_HALF);
s.add({ x: 0, y: 0 }, { shape: 'frame', frame: s.frameId });
const collider = roundTrip(s.project).assets.find((a) => a.type === 'collider')!;
const object = roundTrip(s.project)
.assets.flatMap((a) => (a.type === 'scene' ? a.objects : []))
.find((o) => o.assetId === collider.id)!;
expect(object.overrides).toMatchObject({ shape: 'frame', frame: s.frameId });
});
});
describe.each(COLLISION_SYSTEMS)(
'$system: a frame outline blocks where it is drawn (#193)',
({ physics }) => {
it('Move stops at the outline, not at the frame box, and a click hits only the outline', () => {
const s = frameScene();
s.outline(RIGHT_HALF);
const wall = s.add({ x: 100, y: 0 }, { shape: 'frame', frame: s.frameId });
const hero = s.add({ x: 0, y: 0 }, { width: 20, height: 20 });
const { world, collisions } = startWorld(s, { physics, collisions: true });
const mover = world.findByObjectId(hero)!;
expect(collisions.moveAndSlide(world, mover, 200, 0)).toMatchObject({ blockedX: true, hit: true });
// The outline's left face is at 100 (the frame's would be at 80): the hero's right side stops there.
expect(mover.x).toBeCloseTo(90, 6);
const target = world.findByObjectId(wall)!;
expect(world.hitTest(target, 110, 0)).toBe(true);
expect(world.hitTest(target, 90, 0)).toBe(false);
});
},
);
describe('physics: a body lands on a frame outline (#193)', () => {
it('a dropped box comes to rest on the drawn top, not on the frame box', () => {
const s = frameScene();
// The lower half of the frame: its top face lies at the collider's origin.
s.outline(P(0, 20, 40, 20, 40, 40, 0, 40));
s.add({ x: 0, y: 200 }, { shape: 'frame', frame: s.frameId });
const box = s.add({ x: 0, y: 100 }, { width: 10, height: 10 }, {});
const started = startWorld(s, { physics: true, collisions: true });
for (let i = 0; i < 120; i++) started.frame(1 / 60);
// Resting on y = 200, half its 10 up; the frame box would hold it at 175.
expect(started.world.findByObjectId(box)!.y).toBeCloseTo(195, 0);
});
});
/**
* A scene with a 10 × 10 map of 48 px cells at the origin – cell (x, y) spans x·48 − 240 … – a
* solid Wall and a solid Ice tile, and a 40 × 40 hero with a collider at the origin.
*/
function tileScene() {
const f = makeSpriteScene('Hero');
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40));
f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40));
const wall = new CreateAssetCommand('tile', 'Wall');
const ice = new CreateAssetCommand('tile', 'Ice');
const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [wall, ice, map]) f.stack.execute(command);
for (const tile of [wall, ice])
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetAssetPropertyCommand(ice.asset.id, TILE_FRICTION_KEY, 0));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
place(f, map.asset.id, { x: 0, y: 0 });
const hero = place(f, f.spriteId, { x: 0, y: 0 });
f.stack.execute(new AddColliderCommand(f.sceneId, hero));
const mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset;
const paint = (at: [number, number][], tileId: string) =>
f.stack.execute(
new PaintTilesCommand(
map.asset.id,
mapAsset().layers[0]!.id,
at.map(([x, y]) => ({ x, y, tileId })),
),
);
const tile = (id: string) => findAsset(f.project, id) as TileAsset;
return { ...f, wallId: wall.asset.id, iceId: ice.asset.id, mapAsset, paint, hero, tile };
}
const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => [6, y] as [number, number]);
describe('the surface of a solid tile (#193)', () => {
it('has the rows and defaults of a collider, no density, shown only while the tile is solid', () => {
const tile = schemaFor('tile');
expect(tile.find((d) => d.key === TILE_FRICTION_KEY)).toMatchObject({
kind: 'number',
default: 0.5,
min: 0,
});
expect(tile.find((d) => d.key === TILE_BOUNCINESS_KEY)).toMatchObject({ default: 0, min: 0, max: 1 });
expect(tile.some((d) => d.key === 'density')).toBe(false);
const row = tile.find((d) => d.key === TILE_FRICTION_KEY)!;
expect(isRowShown(row, { [TILE_SOLID_KEY]: false })).toBe(false);
expect(isRowShown(row, { [TILE_SOLID_KEY]: true })).toBe(true);
});
it('is read like a collider material and survives a round trip', () => {
const s = tileScene();
expect(tileMaterial(s.tile(s.wallId))).toEqual({ friction: 0.5, bounciness: 0 });
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_BOUNCINESS_KEY, 0.8));
const ice = findAsset(roundTrip(s.project), s.iceId) as TileAsset;
expect(tileMaterial(ice)).toEqual({ friction: 0, bounciness: 0.8 });
// Out of range or unreadable, held like a collider's.
s.tile(s.iceId).props[TILE_BOUNCINESS_KEY] = 7;
s.tile(s.iceId).props[TILE_FRICTION_KEY] = 'x';
expect(tileMaterial(s.tile(s.iceId))).toEqual({ friction: 0.5, bounciness: 1 });
});
it('ice and wall never merge into one rectangle; each surface is its own group', () => {
const s = tileScene();
s.paint(
[
[0, 0],
[1, 0],
],
s.wallId,
);
s.paint(
[
[2, 0],
[3, 0],
],
s.iceId,
);
const asset = s.mapAsset();
// Drawn, the row is one rectangle; for collision, one per surface.
expect(solidRects(s.project, asset.props, asset)).toEqual([{ x: 0, y: 0, w: 4, h: 1 }]);
const materials = tileMaterials(s.project, asset);
expect(materials).toEqual([
{ friction: 0, bounciness: 0 },
{ friction: 0.5, bounciness: 0 },
]);
expect(solidRects(s.project, asset.props, asset, false, materials[0])).toEqual([
{ x: 2, y: 0, w: 2, h: 1 },
]);
expect(solidRects(s.project, asset.props, asset, false, materials[1])).toEqual([
{ x: 0, y: 0, w: 2, h: 1 },
]);
});
});
describe.each(COLLISION_SYSTEMS)('$system: Move slides by the tile surface (#193)', ({ physics }) => {
/** The hero pressed against column six while walking down it, 60 times: how far down it got. */
function slideDown(tileId: 'wall' | 'ice'): number {
const s = tileScene();
s.paint(COLUMN_SIX, tileId === 'wall' ? s.wallId : s.iceId);
const { world, collisions } = startWorld(s, { physics, collisions: true });
const hero = world.findByObjectId(s.hero)!;
hero.y = -200;
collisions.moveAndSlide(world, hero, 97.3, 0);
for (let i = 0; i < 60; i++) collisions.moveAndSlide(world, hero, 3.1, 6.7);
expect(hero.x).toBeCloseTo(28, 6);
return hero.y + 200;
}
it('the pair averages the hero’s 0.5 with the tile’s own friction', () => {
expect(slideDown('wall')).toBeCloseTo(60 * (6.7 - 0.5 * 3.1), 6);
expect(slideDown('ice')).toBeCloseTo(60 * (6.7 - 0.25 * 3.1), 6);
});
});
describe('physics: a ball bounces by the tile it lands on (#193)', () => {
/** How high a ball dropped onto the bottom row climbs again after its first bounce. */
function rebound(bounciness: number): number {
const s = tileScene();
s.stack.execute(new SetAssetPropertyCommand(s.iceId, TILE_BOUNCINESS_KEY, bounciness));
s.paint(
Array.from({ length: 10 }, (_, x) => [x, 9] as [number, number]),
s.iceId,
); // top face at y = 192
// Clear of the hero, who stands on the origin with a collider of its own.
const ball = place(s, s.spriteId, { x: 120, y: 0 });
const collider = new AddColliderCommand(s.sceneId, ball);
s.stack.execute(collider);
for (const [key, value] of Object.entries({ shape: 'circle', radius: 16 }))
s.stack.execute(new SetOverrideCommand(s.sceneId, collider.objectId, key, value));
s.stack.execute(new AddRigidbodyCommand(s.sceneId, ball));
const started = startWorld(s, { physics: true, collisions: true });
const y = () => started.world.findByObjectId(ball)!.y;
let landed = false;
let highest = Infinity;
for (let i = 0; i < 240; i++) {
started.frame(1 / 60);
if (y() > 192 - 16 - 1) landed = true;
else if (landed) highest = Math.min(highest, y());
}
return Number.isFinite(highest) ? 192 - 16 - highest : 0;
}
it('a dead tile keeps it down, 0.5 sends it back a quarter of the drop, 1 nearly all of it', () => {
const drop = 192 - 16;
expect(rebound(0)).toBeLessThan(1);
const half = rebound(0.5) / drop;
expect(half).toBeGreaterThan(0.2);
expect(half).toBeLessThan(0.3);
expect(rebound(1) / drop).toBeGreaterThan(0.9);
});
});