Files
feuerwerk/tests/core/surface.test.ts
T
adminandClaude Opus 5 f4ec23d3d3
game-maker / gate (push) Successful in 2m11s
game-maker / suite (push) Successful in 7m33s
Colliders take a frame's outline and tiles have their own surface (#193)
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>
2026-09-21 07:29:26 +02:00

317 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});