217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
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,
|
||
);
|
||
});
|
||
});
|