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 { 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, body?: Record) => { 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) => 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, }); 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); } }); });