Files
feuerwerk/tests/core/one-way-angle.test.ts
T
adminandClaude Opus 5 0fdde6cb1e
game-maker / gate (push) Successful in 2m7s
game-maker / suite (push) Successful in 7m22s
The Raycast node filters by collision layers: the node gained a "Layers" setting, a tickbox per collision layer of the project - the same field kind the collider uses for "Collides with" - and the ray only hits what sits on a ticked layer, tilemaps counting as colliders; the default is every layer, and a graph without the param answers exactly as before, which is its own unit test; the check is one-sided on purpose, unlike the symmetric rule of #152: a ray is not a collider, so it never reads the collidesWith of the thing it hits - a collider that meets nothing is still seen by a ray - because who a ray may look at is the ray's business, and a unit test holds that case; the filter sits in Solids.raycast in front of the intersection test, which is the one path the editor run and Play both take, so every unit test runs through describe.each against both systems; NodeView did not know the field kinds layer/layers, so the node would have read "Layers: 65533" - it now shows the name of the layer, or "All layers"; 5 new e2e checks in the new block 43d (a shield on its own layer between the caster and the floor stops the ray, the "Layers" row on the node, the tickbox menu with the project's layers, the ray reaching 280 instead of 145 units once the shield's layer is unticked, and the mask read as a name) and 6 new unit tests, three against each system; two counter-checks hold: disarming the filter line drops 6 unit tests and the unticking check in module 43, and switching the formatting off makes the node check fail with the raw bits; two findings from measuring came out of it and stand in the wiki and the notes - __gm.rays() only measures while the Scene tab is in front, so a node selection blinds it and the block measures through a Log node on distance instead, and a running behavior instance keeps the graph it started with, so the editor run casts with the old mask until the instance is built anew (#158)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:15:35 +02:00

217 lines
9.9 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.
expect(tile.filter((d) => !isRowShown(d, {})).map((d) => d.key)).toEqual([TILE_ONE_WAY_ANGLE_KEY]);
});
});
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);
}
});
});