Files
feuerwerk/tests/core/broadphase.test.ts
T
adminandClaude Opus 5 eac50e4194
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m21s
Moving against a tilemap whose cells block with an outline no longer costs more than a whole frame: an outlined cell is its own polygon, split into convex parts, so a map that is a handful of merged rectangles when built from plain solid tiles is hundreds of solids under one entity - and Move brought every one of them into world coordinates and tested it exactly, including the ones on the far side of the map, which measured 148 ms per Move call on a 20x20 map with 16-corner outlines (34 ms at 8 corners, 0.4 ms with plain solid tiles), matching the report that standing still holds 60 fps while anything that moves stutters; the fix is a broad phase in two stages that decides nothing and only leaves out what cannot be reached - a move never ends further from its start than it is long, because sliding always shortens the rest, so every collider of the mover gets a rectangle grown by the move length and an obstacle whose rectangle does not meet it is never tested, a ray taking the rectangle around itself, and that rectangle is carried back into the map's own units with invert(m) and turned into cell coordinates (cellsWithin), a rotated map through its own axes, so a far cell is not brought into world form in the first place - solidShapesOf was O(cells) per call, which was the second cost block and the reason the 60x60 case still grew; CollisionSystem.overlaps takes the same range, so the editor run asks a map only for the cells within reach of the other side each frame, two maps narrowing nothing, and a Move obstacle now falls away at build time when its layer does not meet the mover's (#152) instead of inside the loop; the measurements after: 148 -> 0.26 ms, 34 -> 0.27 ms, 60x60 20 -> 3.5 ms, plain tiles unchanged; this is no regression of the recent commits - the same benchmark in worktrees against 947a6b1 (#141), 6a31f5e, 4f55f85 and ae3aa7d gives the same 85-148 ms, the cost has been there since outlined cells arrived with #141; new pure functions shapeBounds/grownBounds/boundsMeet in collider.ts and cellsWithin/cellsMeet in tilemapCollision.ts, 11 new unit tests in tests/core/broadphase.test.ts (the rectangles of all four shapes including a rotated box measuring its diagonal reach, touching counts as meeting, cellsWithin straight and turned by 90 degrees, a map handing out 400 cells without a range and under 10 with one, an 800 unit move from far away still stopping at the first cell, a rotated map blocking where its one painted cell was turned to, a ray across the whole map, and the editor run seeing a collider on a far cell), and four counter-checks hold: dropping the cell filter fails the counting test, padding without the move length fails 4 move tests, ignoring the rotation in cellsWithin fails 3, and a wrong range in the detection fails the touching test; one blunt test on the way - the first rotated map was painted on every cell, where even a wrongly computed cell range blocks and the counter-check stayed green, so exactly one cell is painted now and the test measures where it turned to; no e2e check and the check sum stays 1157, because a timing check in the browser would flutter under CI load while modules 40 and 42 cover the correctness anyway, the speed evidence being the node benchmark (#184)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:25:53 +02:00

217 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 } from '../../src/core/commands/colliderCommands';
import { AddFrameCommand, SetFrameCollisionCommand } from '../../src/core/commands/frameCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
import { PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
import type { TextureAsset, TilemapAsset } from '../../src/core/model/Asset';
import { IDENTITY, transformMatrix } from '../../src/core/model/matrix';
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
import { findAsset } from '../../src/core/model/Project';
import { TILE_SOLID_KEY } from '../../src/core/model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap';
import { cellsMeet, cellsWithin } from '../../src/core/model/tilemapCollision';
import { boundsMeet, grownBounds, shapeBounds } from '../../src/core/properties/collider';
import type { Entity } from '../../src/runtime/Entity';
import { loadPhysics } from '../../src/runtime/physics/engine';
import { Solids } from '../../src/runtime/systems/solids';
import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers';
beforeAll(async () => {
await loadPhysics();
});
/** A jagged ring inside a 32 px cell, like the lake outlines of #184. */
function jagged(corners: number, r: number): Vec2[] {
return Array.from({ length: corners }, (_, i) => {
const a = (i / corners) * Math.PI * 2;
const rad = r * (i % 2 === 0 ? 1 : 0.7);
return { x: 16 + Math.cos(a) * rad, y: 16 + Math.sin(a) * rad };
});
}
function scene() {
const f = makeSpriteScene('Thing');
const add = (at: Vec2, collider: 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));
return { id, collider: command.objectId };
};
return { ...f, add };
}
/**
* A map of `cells` × `cells` cells of 32 px, every one painted with a tile whose picture carries an
* eight-cornered outline – the shape of #184. The map sits at the origin, so it spans
* −16·`cells` … 16·`cells` on both axes.
*/
function outlinedMap(cells: number, angle = 0, only?: Vec2) {
const f = scene();
const texture = new CreateAssetCommand('texture', 'Sheet');
const tile = new CreateAssetCommand('tile', 'Lake');
const map = new CreateAssetCommand('tilemap', 'Ground');
for (const command of [texture, tile, map]) f.stack.execute(command);
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
sheet.file = `${'a'.repeat(64)}.png`;
sheet.width = 32;
sheet.height = 32;
const frame = new AddFrameCommand(texture.asset.id, { x: 0, y: 0, w: 32, h: 32 });
f.stack.execute(frame);
f.stack.execute(new AddTilePictureCommand(tile.asset.id, frame.frameId));
f.stack.execute(new SetAssetPropertyCommand(tile.asset.id, TILE_SOLID_KEY, true));
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, [jagged(8, 15)]));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, cells));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, cells));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32));
const mapId = place(f, map.asset.id, { x: 0, y: 0 });
if (angle !== 0) f.stack.execute(new SetOverrideCommand(f.sceneId, mapId, 'rotation', angle));
const asset = findAsset(f.project, map.asset.id) as TilemapAsset;
const painted: { x: number; y: number; tileId: string }[] = only
? [{ x: only.x, y: only.y, tileId: tile.asset.id }]
: [];
if (!only)
for (let x = 0; x < cells; x++)
for (let y = 0; y < cells; y++) painted.push({ x, y, tileId: tile.asset.id });
f.stack.execute(new PaintTilesCommand(map.asset.id, asset.layers[0]!.id, painted));
return { ...f, mapId };
}
describe('the broad phase of a move and a ray (#184)', () => {
it('a rectangle around a shape holds every corner of it, turned as it is', () => {
const square = shapeBounds({ kind: 'box', x: 0, y: 0, w: 10, h: 10, angle: Math.PI / 4 });
expect(square.right).toBeCloseTo(Math.hypot(5, 5), 9);
expect(square.left).toBeCloseTo(-Math.hypot(5, 5), 9);
expect(shapeBounds({ kind: 'circle', x: 3, y: -2, r: 4 })).toEqual({
left: -1,
top: -6,
right: 7,
bottom: 2,
});
const capsule = shapeBounds({
kind: 'capsule',
x: 0,
y: 0,
angle: 0,
a: { x: 0, y: -5 },
b: { x: 0, y: 5 },
r: 2,
});
expect(capsule).toEqual({ left: -2, top: -7, right: 2, bottom: 7 });
const polygon = shapeBounds({
kind: 'polygon',
x: 0,
y: 0,
angle: 0,
outline: [
{ x: -3, y: 0 },
{ x: 4, y: 1 },
{ x: 0, y: 9 },
],
pieces: [],
});
expect(polygon).toEqual({ left: -3, top: 0, right: 4, bottom: 9 });
});
it('two rectangles that share an edge still count as meeting, so nothing near is dropped', () => {
const a = { left: 0, top: 0, right: 10, bottom: 10 };
expect(boundsMeet(a, { left: 10, top: 10, right: 20, bottom: 20 })).toBe(true);
expect(boundsMeet(a, { left: 10.001, top: 0, right: 20, bottom: 10 })).toBe(false);
expect(boundsMeet(a, grownBounds({ left: 12, top: 0, right: 20, bottom: 10 }, 2))).toBe(true);
});
it('the cells of a map within a rectangle are read in cells, a turned map through its own axes', () => {
// A 4 × 4 map of 32 px cells at the origin spans −64 … 64; cell (0,0) is its top left.
const props = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 4, [TILEMAP_TILE_KEY]: 32 };
const straight = cellsWithin(props, IDENTITY, { left: -64, top: -64, right: -32, bottom: -32 });
expect(straight).toEqual({ left: 0, top: 0, right: 1, bottom: 1 });
expect(cellsMeet(straight, 0, 0)).toBe(true);
expect(cellsMeet(straight, 2, 2)).toBe(false);
// Turned by 90°, the world rectangle of the top left corner covers the map's top right one.
const turned = cellsWithin(props, transformMatrix(0, 0, 90, 1, false, false), {
left: -64,
top: -64,
right: -32,
bottom: -32,
});
expect(turned.left).toBeCloseTo(0, 9);
expect(turned.right).toBeCloseTo(1, 9);
expect(turned.top).toBeCloseTo(3, 9);
expect(turned.bottom).toBeCloseTo(4, 9);
});
it('a map hands out only the cells of the rectangle it is asked about, and all of them without one', () => {
const f = outlinedMap(20);
const { world } = startWorld(f);
const map = world.findByObjectId(f.mapId)!;
const solids = new Solids();
expect(solids.solidShapesOf(world, map)).toHaveLength(400);
// One cell of the map, grown by a hair: its own shape and those of the cells it touches.
const near = solids.solidShapesOf(world, map, { left: -320, top: -320, right: -300, bottom: -300 });
expect(near.length).toBeGreaterThan(0);
expect(near.length).toBeLessThan(10);
expect(solids.solidShapesOf(world, map, { left: 1e6, top: 1e6, right: 2e6, bottom: 2e6 })).toEqual([]);
});
});
describe.each(COLLISION_SYSTEMS)('$system: a move sees everything it can reach (#184)', ({ physics }) => {
const mover = (f: ReturnType<typeof outlinedMap>, at: Vec2) =>
f.add(at, { shape: 'box', width: 16, height: 16 });
it('a long move into a map far away still stops at its first cell, however far it starts', () => {
const f = outlinedMap(20);
const m = mover(f, { x: -1000, y: 0 });
const started = startWorld(f, { physics, collisions: true });
const e: Entity = started.world.findByObjectId(m.id)!;
// The map spans −320 … 320, so the move of 800 units has to be stopped well before its end.
const result = started.collisions.moveAndSlide(started.world, e, 800, 0);
expect(result.blockedX).toBe(true);
expect(e.x).toBeGreaterThan(-400);
expect(e.x).toBeLessThan(-300);
});
it('a turned map blocks where its one painted cell has turned to, not where it lies in the asset', () => {
// Turned by 90°, a point of the map lands at (−y, x): the cell at the left middle of the asset
// (own units −304, −16) sits at the top middle of the world, so a move down runs into it.
const f = outlinedMap(20, 90, { x: 0, y: 9 });
const m = mover(f, { x: 16, y: -1000 });
const started = startWorld(f, { physics, collisions: true });
const e: Entity = started.world.findByObjectId(m.id)!;
const result = started.collisions.moveAndSlide(started.world, e, 0, 800);
expect(result.blockedY).toBe(true);
expect(e.y).toBeLessThan(-300);
expect(e.y).toBeGreaterThan(-340);
});
it('a ray across the whole map still meets its first cell', () => {
const f = outlinedMap(20);
const started = startWorld(f, { physics, collisions: true });
const hit = started.collisions.raycast(started.world, { x: -1000, y: -16 }, { x: 1, y: 0 }, 2000, {
ignore: null,
triggers: false,
layers: 0xffff,
});
expect(hit).not.toBeNull();
expect(hit!.distance).toBeGreaterThan(600);
expect(hit!.distance).toBeLessThan(700);
});
});
describe('the editor run over a big map (#184)', () => {
it('a collider on a far cell of a big map still counts as touching it', () => {
// The only painted cell is the bottom right one: its middle sits at (304, 304).
const f = outlinedMap(20, 0, { x: 19, y: 19 });
const on = f.add({ x: 304, y: 304 }, { shape: 'box', width: 16, height: 16 });
const off = f.add({ x: 304, y: 100 }, { shape: 'box', width: 16, height: 16 });
const started = startWorld(f, { collisions: true });
const map = started.world.findByObjectId(f.mapId)!;
expect(started.collisions.touching(started.world, started.world.findByObjectId(on.id)!, map)).toBe(true);
expect(started.collisions.touching(started.world, started.world.findByObjectId(off.id)!, map)).toBe(
false,
);
});
});