Files
feuerwerk/tests/core/one-way-angle.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

221 lines
10 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 { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { ALL_LAYERS } from '../../src/core/model/collisionLayers';
import { IDENTITY } from '../../src/core/model/matrix';
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import { TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY } from '../../src/core/model/tilemap';
import { oneWayAngles, solidRects } from '../../src/core/model/tilemapCollision';
import { landsOn, oneWayAngle, oneWayCone, oneWayFacing } 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, type SceneFixture, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
function scene() {
const f = makeSpriteScene('Thing');
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) {
const rigidbody = new AddRigidbodyCommand(f.sceneId, id);
f.stack.execute(rigidbody);
for (const [key, value] of Object.entries(body))
f.stack.execute(new SetOverrideCommand(f.sceneId, rigidbody.objectId, key, value));
}
return id;
};
return { ...f, add };
}
function run(f: SceneFixture, physics: boolean) {
const started = startWorld(f, { physics, collisions: true });
const entity = (id: string) => started.world.findByObjectId(id)!;
const move = (id: string, dx: number, dy: number) =>
started.collisions.moveAndSlide(started.world, entity(id), dx, dy);
const seconds = (s: number) => {
for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60);
};
return { ...started, entity, move, seconds };
}
/**
* A one-way wedge blocking from above whose slope leans 25° from level: the contact on the slope
* has a normal 25° from straight up, so it lands within any angle of 25° or more. At x = 0 the slope
* is at y = 50 − 100·tan 25° ≈ 3.37. Gentler than friction 0.5 lets slide (tan 25° ≈ 0.47).
*/
const SLOPE = (25 * Math.PI) / 180;
const WEDGE_POINTS = [
{ x: -100, y: 50 },
{ x: 100, y: 50 - 200 * Math.tan(SLOPE) },
{ x: 100, y: 50 },
];
const wedge = (angle?: number) => ({
shape: 'polygon',
points: WEDGE_POINTS,
oneWay: true,
...(angle === undefined ? {} : { oneWayAngle: angle }),
});
const SLOPE_AT_0 = 50 - 100 * Math.tan(SLOPE);
describe('the one-way angle (#159)', () => {
it('is 45° when not set, so projects from before it keep landing exactly as they did', () => {
const facing = oneWayFacing({ oneWay: true }, IDENTITY)!;
expect(facing.cone).toBe(Math.cos(Math.PI / 4) - 1e-9);
expect(schemaFor('collider').find((d) => d.key === 'oneWayAngle')?.default).toBe(45);
expect(schemaFor('tile').find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)?.default).toBe(45);
});
it('is held to 0 … 90°, and anything unreadable is the default', () => {
expect([-10, 0, 30, 90, 120].map(oneWayAngle)).toEqual([0, 0, 30, 90, 90]);
expect([undefined, 'wide', true, Number.NaN].map((v) => oneWayAngle(v as PropertyValue))).toEqual([
45, 45, 45, 45,
]);
});
it('lands a normal exactly on the edge of the cone, and not one a hair beyond it', () => {
const up = { x: 0, y: -1 };
const at = (deg: number) => ({ x: Math.sin((deg * Math.PI) / 180), y: -Math.cos((deg * Math.PI) / 180) });
for (const angle of [0, 20, 45, 90]) {
const side = { ...up, cone: oneWayCone(angle) };
expect(landsOn(at(angle), side)).toBe(true);
expect(landsOn(at(angle + 0.01), side)).toBe(false);
}
});
it('the inspector shows the angle and "Blocks from" only while One-way is on', () => {
const collider = schemaFor('collider');
const shown = (values: Record<string, PropertyValue>) =>
collider.filter((d) => isRowShown(d, values)).map((d) => d.key);
expect(shown({ oneWay: false })).not.toContain('oneWayAngle');
expect(shown({ oneWay: false })).not.toContain('oneWayDirection');
expect(shown({ oneWay: true })).toEqual(expect.arrayContaining(['oneWayAngle', 'oneWayDirection']));
const tile = schemaFor('tile');
const angleRow = tile.find((d) => d.key === TILE_ONE_WAY_ANGLE_KEY)!;
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: false })).toBe(false);
expect(isRowShown(angleRow, { [TILE_ONE_WAY_KEY]: true })).toBe(true);
// Rows without a switch are always there; the surface waits for Solid (#193).
expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([
TILE_ONE_WAY_ANGLE_KEY,
'friction',
'bounciness',
]);
});
});
describe.each(COLLISION_SYSTEMS)('$system: Move and the one-way angle (#159)', ({ physics }) => {
it('a box falling onto a 25° slope lands at the default 45° and at 25°, and falls through at 20°', () => {
for (const [angle, lands] of [
[undefined, true],
[25, true],
[20, false],
] as const) {
const s = scene();
s.add({ x: 0, y: 0 }, wedge(angle));
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 });
const r = run(s, physics);
let hit = false;
for (let i = 0; i < 60; i++) hit = r.move(box, 0, 5).hit || hit;
expect({ angle, hit }).toEqual({ angle, hit: lands });
if (lands) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0 - 10 + 1e-6);
else expect(r.entity(box).y).toBeCloseTo(200, 9);
}
});
it('at 90° the sides of a platform block too, while at 45° a box walks through them', () => {
for (const [angle, blocks] of [
[45, false],
[90, true],
] as const) {
const s = scene();
s.add({ x: 0, y: 104 }, { width: 200, height: 8, oneWay: true, oneWayAngle: angle });
const walker = s.add({ x: -200, y: 104 }, { width: 20, height: 20 });
const r = run(s, physics);
for (let i = 0; i < 20; i++) r.move(walker, 10, 0);
expect(r.entity(walker).x).toBeCloseTo(blocks ? -110 : 0, 9);
}
});
it('a ray straight down meets the slope within the angle and passes it beyond', () => {
for (const [angle, meets] of [
[45, true],
[20, false],
] as const) {
const s = scene();
s.add({ x: 0, y: 0 }, wedge(angle));
const r = run(s, physics);
const hit = r.collisions.raycast(r.world, { x: 0, y: -100 }, { x: 0, y: 1 }, 500, {
ignore: null,
triggers: false,
layers: ALL_LAYERS,
});
if (meets) expect(hit?.point.y).toBeCloseTo(SLOPE_AT_0, 9);
else expect(hit).toBeNull();
}
});
it('one-way tiles of two angles stay apart, and each blocks by its own', () => {
const s = scene();
const [steep, wide] = [new CreateAssetCommand('tile', 'Steep'), new CreateAssetCommand('tile', 'Wide')];
const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [steep, wide, map]) s.stack.execute(command);
for (const tile of [steep, wide]) {
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
s.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_ONE_WAY_KEY, true));
}
s.stack.execute(new SetAssetPropertyCommand(wide.asset.id, TILE_ONE_WAY_ANGLE_KEY, 90));
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10));
s.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10));
place(s, map.asset.id, { x: 0, y: 0 });
const asset = () => findAsset(s.project, map.asset.id) as TilemapAsset;
// Cells of 48, centred: cell (x, y) spans x·48 − 240. Row 5 (y 0 … 48): the default tile at
// x = 0 … 2, the 90° one at x = 5 … 7.
const cells = [
...[0, 1, 2].map((x) => ({ x, y: 5, tileId: steep.asset.id })),
...[5, 6, 7].map((x) => ({ x, y: 5, tileId: wide.asset.id })),
...[3, 4].map((x) => ({ x, y: 7, tileId: wide.asset.id })),
];
s.stack.execute(new PaintTilesCommand(map.asset.id, asset().layers[0]!.id, cells));
expect(oneWayAngles(s.project, asset())).toEqual([45, 90]);
expect(solidRects(s.project, {}, asset(), 45)).toEqual([{ x: 0, y: 5, w: 3, h: 1 }]);
expect(solidRects(s.project, {}, asset(), 90)).toEqual([
{ x: 5, y: 5, w: 3, h: 1 },
{ x: 3, y: 7, w: 2, h: 1 },
]);
// Walking in along row 5 from the left: through the 45° tiles, stopped by the side of the 90° ones.
const walker = s.add({ x: -300, y: 24 }, { width: 20, height: 20 });
const r = run(s, physics);
for (let i = 0; i < 40; i++) r.move(walker, 10, 0);
expect(r.entity(walker).x).toBeCloseTo(-10, 9);
});
});
describe('the physics engine and the one-way angle (#159)', () => {
it('a dynamic box rests on the 25° slope at 45°, and falls through onto the ground at 20°', () => {
for (const [angle, rests] of [
[45, true],
[20, false],
] as const) {
const s = scene();
s.add({ x: 0, y: 0 }, wedge(angle));
s.add({ x: 0, y: 304 }, { width: 400, height: 8 });
const box = s.add({ x: 0, y: -100 }, { width: 20, height: 20 }, { fixedRotation: true });
const r = run(s, true);
r.seconds(2);
if (rests) expect(r.entity(box).y).toBeLessThan(SLOPE_AT_0);
else expect(r.entity(box).y).toBeCloseTo(290, 1);
}
});
});