498 lines
23 KiB
TypeScript
498 lines
23 KiB
TypeScript
import { beforeAll, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddColliderCommand, AddRigidbodyCommand } 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 { AddTilemapLayerCommand, PaintTilesCommand } from '../../src/core/commands/tilemapCommands';
|
||
import type { TextureAsset, TilemapAsset, TileAsset } from '../../src/core/model/Asset';
|
||
import type { PropertyValue, Vec2 } from '../../src/core/model/Properties';
|
||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||
import { TILE_COLLISION_SCALE_KEY, TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile';
|
||
import { insideOutline } from '../../src/editor/polygonEdit';
|
||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap';
|
||
import { solidCells, solidPolygons, solidRects, tileShape } from '../../src/core/model/tilemapCollision';
|
||
import { contactBetween, shapesOverlap } from '../../src/core/properties/collider';
|
||
import type { Entity } from '../../src/runtime/Entity';
|
||
import { loadPhysics } from '../../src/runtime/physics/engine';
|
||
import { shapeOf, Solids } from '../../src/runtime/systems/solids';
|
||
import {
|
||
COLLISION_SYSTEMS,
|
||
makeSpriteScene,
|
||
place,
|
||
roundTrip,
|
||
type SceneFixture,
|
||
startWorld,
|
||
} from '../helpers';
|
||
|
||
beforeAll(async () => {
|
||
await loadPhysics();
|
||
});
|
||
|
||
const P = (...xy: number[]) =>
|
||
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
|
||
|
||
/** A scene of objects with one collider each; `add` sets the collider's properties and returns the ids. */
|
||
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, collider: command.objectId };
|
||
};
|
||
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 colliderOf = (e: Entity) => started.world.childrenOf(e).find((c) => c.type === 'collider')!;
|
||
const move = (e: Entity, dx: number, dy: number) =>
|
||
started.collisions.moveAndSlide(started.world, e, dx, dy);
|
||
const seconds = (s: number) => {
|
||
for (let i = 0; i < Math.round(s * 60); i++) started.frame(1 / 60);
|
||
};
|
||
return { ...started, entity, colliderOf, move, seconds };
|
||
}
|
||
|
||
/** A ramp at 30°, falling to the right: its top face runs through the origin. */
|
||
const TAN30 = Math.tan(Math.PI / 6);
|
||
const RAMP = P(-500, -500 * TAN30, 500, 500 * TAN30, 500, 400, -500, 400);
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system: Move against the new shapes (#151)', ({ physics }) => {
|
||
it('a circle pressed onto a polygon ramp slides as far as friction lets it: g·sin − μ·g·cos', () => {
|
||
const sin = Math.sin(Math.PI / 6);
|
||
const cos = Math.cos(Math.PI / 6);
|
||
for (const friction of [0, 0.5, 0.6]) {
|
||
const s = scene();
|
||
const ramp = s.add({ x: 0, y: 0 }, { shape: 'polygon', points: RAMP, friction });
|
||
const ball = s.add({ x: 0, y: -100 }, { shape: 'circle', radius: 10, friction });
|
||
const r = run(s, physics);
|
||
const mover = r.entity(ball.id);
|
||
for (let i = 0; i < 30; i++) r.move(mover, 0, 5);
|
||
const from = { x: mover.x, y: mover.y };
|
||
const results = Array.from({ length: 10 }, () => r.move(mover, 0, 5));
|
||
const along = 10 * Math.max(0, 5 * sin - friction * 5 * cos);
|
||
expect(mover.x - from.x, `friction ${friction}`).toBeCloseTo(along * cos, 6);
|
||
expect(mover.y - from.y, `friction ${friction}`).toBeCloseTo(along * sin, 6);
|
||
expect(results[0]!.normal.x).toBeCloseTo(sin, 9);
|
||
expect(results[0]!.normal.y).toBeCloseTo(-cos, 9);
|
||
const surface = shapeOf(r.colliderOf(r.entity(ramp.id)));
|
||
const gap = contactBetween(shapeOf(r.colliderOf(mover)), surface, { x: 0, y: 1 }).separation;
|
||
expect(Math.abs(gap)).toBeLessThan(1e-6);
|
||
}
|
||
});
|
||
|
||
it('a capsule runs into a box wall, stops flush and slides down it', () => {
|
||
const s = scene();
|
||
s.add({ x: 100, y: 0 }, { width: 20, height: 400 }); // face at x = 90
|
||
const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60, friction: 0 });
|
||
const r = run(s, physics);
|
||
const mover = r.entity(hero.id);
|
||
const hit = r.move(mover, 100, 0);
|
||
expect(hit).toMatchObject({ hit: true, blockedX: true, blockedY: false });
|
||
expect(hit.normal.x).toBeCloseTo(-1, 12);
|
||
expect(hit.normal.y).toBeCloseTo(0, 12);
|
||
expect(mover.x).toBeCloseTo(80, 9);
|
||
r.move(mover, 3, 4); // the wall's friction is the default 0.5, the capsule's 0: the pair has 0.25
|
||
expect(mover.x).toBeCloseTo(80, 9);
|
||
expect(mover.y).toBeCloseTo(4 - 0.25 * 3, 9);
|
||
});
|
||
|
||
it('a box dropped into a concave U lands on its inner floor, clear of the walls', () => {
|
||
const s = scene();
|
||
s.add(
|
||
{ x: 0, y: 0 },
|
||
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
|
||
);
|
||
const crate = s.add({ x: 0, y: -120 }, { width: 40, height: 40 });
|
||
const r = run(s, physics);
|
||
const mover = r.entity(crate.id);
|
||
for (let i = 0; i < 40; i++) r.move(mover, 0, 5);
|
||
expect(mover.y).toBeCloseTo(10, 9); // bottom face on the inner floor at 30
|
||
expect(mover.x).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the physics engine with the new shapes (#151)', () => {
|
||
it('a capsule falls onto a box floor and comes to rest standing on its round end', () => {
|
||
const s = scene();
|
||
s.add({ x: 0, y: 200 }, { width: 400, height: 40 }); // top at 180
|
||
const hero = s.add({ x: 0, y: 0 }, { shape: 'capsule', width: 20, height: 60 }, { fixedRotation: true });
|
||
const r = run(s, true);
|
||
r.seconds(3);
|
||
expect(r.entity(hero.id).y).toBeCloseTo(180 - 30, 0);
|
||
});
|
||
|
||
it('a crate falls into a concave U and rests on its inner floor – not on the hull over the notch', () => {
|
||
const s = scene();
|
||
s.add(
|
||
{ x: 0, y: 200 },
|
||
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
|
||
);
|
||
const crate = s.add({ x: 0, y: 0 }, { width: 40, height: 40 }, { fixedRotation: true });
|
||
const r = run(s, true);
|
||
r.seconds(3);
|
||
expect(r.entity(crate.id).y).toBeCloseTo(200 + 30 - 20, 0);
|
||
expect(Math.abs(r.entity(crate.id).x)).toBeLessThan(0.5);
|
||
});
|
||
|
||
it('a ball rolls down a polygon ramp; a polygon body falls and rests with its own mass', () => {
|
||
const s = scene();
|
||
const ramp = s.add({ x: 0, y: 300 }, { shape: 'polygon', points: RAMP });
|
||
const ball = s.add({ x: -100, y: 0 }, { shape: 'circle', radius: 10 }, {});
|
||
const triangle = s.add({ x: 300, y: -200 }, { shape: 'polygon', points: P(0, -20, 20, 20, -20, 20) }, {});
|
||
const r = run(s, true);
|
||
const start = r.entity(ball.id).x;
|
||
r.seconds(1);
|
||
expect(r.entity(ball.id).x).toBeGreaterThan(start + 50); // downhill is to the right
|
||
r.seconds(4);
|
||
// The triangle came to rest on the ramp, and did not sink into it.
|
||
const surface = shapeOf(r.colliderOf(r.entity(ramp.id)));
|
||
const resting = r.entity(triangle.id);
|
||
expect(resting.y).toBeGreaterThan(-100); // it fell
|
||
expect(
|
||
contactBetween(shapeOf(r.colliderOf(resting)), surface, { x: 0, y: 1 }).separation,
|
||
).toBeGreaterThan(-1);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A texture with one 32 × 32 frame, a plain tile showing it, a solid switch on that tile and a
|
||
* 12 × 4 map of 32 px cells at the origin – cell (x, y) spans x·32 − 192 … and y·32 − 64 ….
|
||
*/
|
||
function outlinedMap() {
|
||
const f = scene();
|
||
const texture = new CreateAssetCommand('texture', 'Sheet');
|
||
const tile = new CreateAssetCommand('tile', 'Slope');
|
||
const plain = new CreateAssetCommand('tile', 'Wall');
|
||
const lake = new CreateAssetCommand('tile', 'Lake');
|
||
const map = new CreateAssetCommand('tilemap', 'Ground');
|
||
for (const command of [texture, tile, plain, lake, map]) f.stack.execute(command);
|
||
const sheet = findAsset(f.project, texture.asset.id) as TextureAsset;
|
||
sheet.file = `${'a'.repeat(64)}.png`;
|
||
sheet.width = 64;
|
||
sheet.height = 128;
|
||
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));
|
||
// A floor block of 32 px tiles below it: 2 × 3 tiles, quarters of 16 px (#141).
|
||
const block = new AddFrameCommand(texture.asset.id, { x: 0, y: 32, w: 64, h: 96 });
|
||
f.stack.execute(block);
|
||
f.stack.execute(new AddTilePictureCommand(lake.asset.id, block.frameId));
|
||
f.stack.execute(new SetAssetPropertyCommand(lake.asset.id, TILE_KIND_KEY, 'floor'));
|
||
for (const id of [tile.asset.id, plain.asset.id, lake.asset.id])
|
||
f.stack.execute(new SetAssetPropertyCommand(id, TILE_SOLID_KEY, true));
|
||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 12));
|
||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 4));
|
||
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_TILE_KEY, 32));
|
||
const mapObject = place(f, map.asset.id, { x: 0, y: 0 });
|
||
const mapAsset = () => findAsset(f.project, map.asset.id) as TilemapAsset;
|
||
const paint = (cells: [number, number][], tileId = tile.asset.id, layer = 0) =>
|
||
f.stack.execute(
|
||
new PaintTilesCommand(
|
||
map.asset.id,
|
||
mapAsset().layers[layer]!.id,
|
||
cells.map(([x, y]) => ({ x, y, tileId })),
|
||
),
|
||
);
|
||
const outline = (...outlines: Vec2[][]) =>
|
||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, outlines));
|
||
const blockOutline = (...outlines: Vec2[][]) =>
|
||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, block.frameId, outlines));
|
||
const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset;
|
||
return {
|
||
...f,
|
||
mapId: map.asset.id,
|
||
mapObject,
|
||
mapAsset,
|
||
paint,
|
||
outline,
|
||
blockOutline,
|
||
tileAsset,
|
||
plainId: plain.asset.id,
|
||
lakeId: lake.asset.id,
|
||
textureId: texture.asset.id,
|
||
frameId: frame.frameId,
|
||
};
|
||
}
|
||
|
||
/** The lower right half of a cell: a slope rising to the right, as a frame outline in pixels. */
|
||
const WEDGE = P(0, 32, 32, 0, 32, 32);
|
||
|
||
describe('collision outlines of tiles (#141)', () => {
|
||
it('a frame keeps its outline through a round trip; broken corners and short outlines fall away', () => {
|
||
const m = outlinedMap();
|
||
m.outline(WEDGE);
|
||
const frameOf = (project: Project) =>
|
||
(findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!;
|
||
expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]);
|
||
frameOf(m.project).collision = [
|
||
[
|
||
{ x: 1, y: 1 },
|
||
{ x: Number.NaN, y: 0 },
|
||
{ x: 3, y: 3 },
|
||
],
|
||
];
|
||
expect(frameOf(roundTrip(m.project))).not.toHaveProperty('collision');
|
||
m.stack.undo();
|
||
expect(frameOf(m.project)).not.toHaveProperty('collision');
|
||
m.stack.redo();
|
||
expect(frameOf(m.project).collision).toEqual([WEDGE]);
|
||
});
|
||
|
||
it('a frame holds several outlines; one saved before that, as a plain corner list, loads as one', () => {
|
||
const m = outlinedMap();
|
||
const SQUARE_AT_0 = P(0, 0, 8, 0, 8, 8, 0, 8);
|
||
m.outline(WEDGE, SQUARE_AT_0);
|
||
const frameOf = (project: Project) =>
|
||
(findAsset(project, m.textureId) as TextureAsset).frames.find((f) => f.id === m.frameId)!;
|
||
expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE, SQUARE_AT_0]);
|
||
// Of two outlines, the one with too few corners falls away on load and the other stays.
|
||
frameOf(m.project).collision = [P(1, 1, 2, 2), WEDGE];
|
||
expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]);
|
||
// An outline emptied by the editor is dropped, and the frame without any keeps saying so.
|
||
m.outline([], []);
|
||
expect(frameOf(m.project)).not.toHaveProperty('collision');
|
||
(frameOf(m.project) as { collision: unknown }).collision = WEDGE;
|
||
expect(frameOf(roundTrip(m.project)).collision).toEqual([WEDGE]);
|
||
});
|
||
|
||
it('a plain tile blocks with its first picture’s outlines; an outline that is none, or a block of the wrong size, as a box', () => {
|
||
const m = outlinedMap();
|
||
expect(tileShape(m.project, m.tileAsset())).toBeNull();
|
||
m.outline(WEDGE);
|
||
expect(tileShape(m.project, m.tileAsset())).toEqual({
|
||
kind: 'plain',
|
||
outlines: [{ outline: P(0, 1, 1, 0, 1, 1), pieces: [P(0, 1, 1, 0, 1, 1)] }],
|
||
});
|
||
const BOW_TIE = P(0, 0, 32, 32, 32, 0, 0, 32);
|
||
m.outline(BOW_TIE);
|
||
expect(tileShape(m.project, m.tileAsset())).toBeNull();
|
||
m.outline(BOW_TIE, WEDGE); // the bow tie collides as none, the wedge still blocks
|
||
expect(tileShape(m.project, m.tileAsset())).toMatchObject({
|
||
kind: 'plain',
|
||
outlines: [{ outline: P(0, 1, 1, 0, 1, 1) }],
|
||
});
|
||
m.outline(WEDGE);
|
||
// A floor needs a block of 2 × 3 tiles; the 32 px frame is none, so it blocks as a box.
|
||
m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor'));
|
||
expect(tileShape(m.project, m.tileAsset())).toBeNull();
|
||
});
|
||
|
||
it('a plain tile with two outlines blocks with both in every cell', () => {
|
||
const m = outlinedMap();
|
||
m.outline(P(0, 0, 16, 0, 16, 16, 0, 16), P(16, 16, 32, 16, 32, 32, 16, 32));
|
||
m.paint([[2, 3]]);
|
||
const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 };
|
||
expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([
|
||
P(2, 3, 2.5, 3, 2.5, 3.5, 2, 3.5),
|
||
P(2.5, 3.5, 3, 3.5, 3, 4, 2.5, 4),
|
||
]);
|
||
});
|
||
|
||
it('outlined cells are polygons, not rectangles; a plain solid tile on the same cell covers it whole', () => {
|
||
const m = outlinedMap();
|
||
m.outline(WEDGE);
|
||
m.paint([
|
||
[2, 3],
|
||
[3, 3],
|
||
]);
|
||
m.paint([[5, 3]], m.plainId);
|
||
const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 };
|
||
expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 5, y: 3, scale: 1 }]);
|
||
expect(solidPolygons(m.project, props, m.mapAsset()).map((p) => p.outline)).toEqual([
|
||
P(2, 4, 3, 3, 3, 4),
|
||
P(3, 4, 4, 3, 4, 4),
|
||
]);
|
||
m.stack.execute(new AddTilemapLayerCommand(m.mapId));
|
||
m.paint([[2, 3]], m.plainId, 1);
|
||
expect(solidPolygons(m.project, props, m.mapAsset())).toHaveLength(1);
|
||
expect(solidCells(m.project, props, m.mapAsset())).toHaveLength(2);
|
||
// The collision scale draws the outline in about the cell's centre (#140).
|
||
m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_COLLISION_SCALE_KEY, 0.5));
|
||
expect(solidPolygons(m.project, props, m.mapAsset())[0]!.outline).toEqual(
|
||
P(3.25, 3.75, 3.75, 3.25, 3.75, 3.75),
|
||
);
|
||
});
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
|
||
it('a crate on a floor of outlined cells slides over every seam', () => {
|
||
const m = outlinedMap();
|
||
m.outline(P(0, 0, 32, 0, 32, 32, 0, 32)); // every cell outlined as a whole square: twelve polygons
|
||
m.paint(Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number])); // top face at y = 32
|
||
const crate = m.add({ x: -180, y: 0 }, { width: 30, height: 30 });
|
||
const r = run(m, physics);
|
||
const mover = r.entity(crate.id);
|
||
const steps: { dx: number; y: number; blockedX: boolean }[] = [];
|
||
for (let i = 0; i < 100; i++) {
|
||
const x = mover.x;
|
||
const { blockedX } = r.move(mover, 3, 1.5);
|
||
steps.push({ dx: mover.x - x, y: mover.y, blockedX });
|
||
}
|
||
for (const step of steps.slice(20)) {
|
||
expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9);
|
||
expect(step.y).toBeCloseTo(32 - 15, 9);
|
||
expect(step.blockedX).toBe(false);
|
||
}
|
||
expect(mover.x).toBeGreaterThan(-180 + 5 * 32);
|
||
});
|
||
|
||
it('a crate walking along the floor into the foot of a wedge tile climbs it instead of stopping there', () => {
|
||
const m = outlinedMap();
|
||
m.outline(WEDGE);
|
||
m.paint([[6, 2]]); // x 0 … 32, its foot on the floor at (0, 32), its top at (32, 0)
|
||
m.paint(
|
||
Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]),
|
||
m.plainId,
|
||
);
|
||
const crate = m.add({ x: -100, y: 22 }, { width: 20, height: 20, friction: 0 });
|
||
const r = run(m, physics);
|
||
const mover = r.entity(crate.id);
|
||
let highest = mover.y;
|
||
for (let i = 0; i < 300; i++) {
|
||
r.move(mover, 2, 0.6);
|
||
highest = Math.min(highest, mover.y);
|
||
}
|
||
// At the foot the crate's corner meets the wedge's corner: the slope, not a wall, is the contact.
|
||
// Its bottom reached the wedge's top at y = 0; the rest of that frame's slide ran on up-slope.
|
||
expect(highest).toBeLessThanOrEqual(-10 + 1e-9);
|
||
expect(highest).toBeGreaterThan(-10 - 0.5);
|
||
expect(mover.x).toBeGreaterThan(45); // and it went on past the wedge's right side at x = 32
|
||
});
|
||
|
||
it('a ball rolls up and over a wedge tile instead of stopping at it', () => {
|
||
const m = outlinedMap();
|
||
m.outline(WEDGE);
|
||
m.paint([[6, 2]]); // x 0 … 32, rising from the floor at its left to its top at its right
|
||
m.paint(
|
||
Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]),
|
||
m.plainId,
|
||
); // floor top at 32
|
||
const ball = m.add({ x: -100, y: 22 }, { shape: 'circle', radius: 10, friction: 0 });
|
||
const r = run(m, physics);
|
||
const mover = r.entity(ball.id);
|
||
let highest = mover.y;
|
||
for (let i = 0; i < 200; i++) {
|
||
r.move(mover, 3, 1);
|
||
highest = Math.min(highest, mover.y);
|
||
}
|
||
expect(highest).toBeLessThan(0); // it climbed to the wedge's top at y = 0
|
||
expect(mover.x).toBeGreaterThan(60); // and went on past its right side at x = 32
|
||
const map = r.world.entities.find((e) => e.type === 'tilemap')!;
|
||
const own = shapeOf(r.colliderOf(mover));
|
||
expect(new Solids().shapesOf(r.world, map).some((shape) => shapesOverlap(own, shape))).toBe(false);
|
||
});
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The floor block's bordered 2 × 2 tile (frame y 32 … 96) outlined 8 px in from its edges – half a
|
||
* quarter. A cell of it blocks from a quarter of a cell in wherever it has no neighbour.
|
||
*/
|
||
const POND = P(8, 40, 56, 40, 56, 88, 8, 88);
|
||
|
||
describe('collision outlines of autotiles (#141)', () => {
|
||
const props = { [TILEMAP_COLUMNS_KEY]: 12, [TILEMAP_ROWS_KEY]: 4 };
|
||
/** Whether a point of the map, in cells, lies inside anything the map blocks with. */
|
||
const blocksAt = (m: ReturnType<typeof outlinedMap>, p: Vec2) =>
|
||
solidRects(m.project, props, m.mapAsset()).some(
|
||
(r) => p.x > r.x && p.x < r.x + r.w && p.y > r.y && p.y < r.y + r.h,
|
||
) ||
|
||
solidPolygons(m.project, props, m.mapAsset()).some((polygon) =>
|
||
polygon.pieces.some((piece) => insideOutline(piece, p)),
|
||
);
|
||
const cells = (x0: number, y0: number, x1: number, y1: number, skip = '') =>
|
||
Array.from(
|
||
{ length: (x1 - x0 + 1) * (y1 - y0 + 1) },
|
||
(_, i) => [x0 + (i % (x1 - x0 + 1)), y0 + Math.floor(i / (x1 - x0 + 1))] as [number, number],
|
||
).filter(([x, y]) => `${x},${y}` !== skip);
|
||
|
||
it('a lake blocks with the block’s outline pieced together like its picture: in by a quarter cell all round', () => {
|
||
const m = outlinedMap();
|
||
m.blockOutline(POND);
|
||
m.paint(cells(2, 0, 4, 2), m.lakeId);
|
||
const wrong: Vec2[] = [];
|
||
for (let x = 1.037; x < 6; x += 0.1)
|
||
for (let y = 0.037; y < 4; y += 0.1) {
|
||
const expected = x > 2.25 && x < 4.75 && y > 0.25 && y < 2.75;
|
||
if (blocksAt(m, { x, y }) !== expected) wrong.push({ x, y });
|
||
}
|
||
expect(wrong).toEqual([]);
|
||
// The middle cell is drawn from inside quarters only, which block whole: a box, not polygons.
|
||
expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 3, y: 1, scale: 1 }]);
|
||
expect(solidPolygons(m.project, props, m.mapAsset()).some((p) => p.x === 3 && p.y === 1)).toBe(false);
|
||
});
|
||
|
||
it('a notch tile with nothing drawn in it blocks whole; once anything is drawn there, only that', () => {
|
||
const m = outlinedMap();
|
||
m.blockOutline(POND);
|
||
m.paint(cells(2, 0, 4, 2, '3,1'), m.lakeId); // a ring round an empty cell: four inner corners
|
||
// Cell (2, 0)'s bottom right is a notch – the block's top right tile, which holds no outline.
|
||
expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(true);
|
||
expect(blocksAt(m, { x: 3.5, y: 1.5 })).toBe(false);
|
||
// A small triangle in the notch tile's top-left quarter: the inner corner of cell (4, 2).
|
||
m.blockOutline(POND, P(32, 0, 40, 0, 32, 8));
|
||
expect(blocksAt(m, { x: 2.9, y: 0.9 })).toBe(false);
|
||
expect(blocksAt(m, { x: 4.05, y: 2.05 })).toBe(true);
|
||
expect(blocksAt(m, { x: 4.2, y: 2.2 })).toBe(false);
|
||
});
|
||
|
||
it('an autotile block without an outline still blocks as boxes, with its collision scale', () => {
|
||
const m = outlinedMap();
|
||
m.stack.execute(new SetAssetPropertyCommand(m.lakeId, TILE_COLLISION_SCALE_KEY, 0.5));
|
||
m.paint([[2, 3]], m.lakeId);
|
||
expect(solidCells(m.project, props, m.mapAsset())).toEqual([{ x: 2, y: 3, scale: 0.5 }]);
|
||
expect(solidPolygons(m.project, props, m.mapAsset())).toEqual([]);
|
||
});
|
||
|
||
describe.each(COLLISION_SYSTEMS)('$system', ({ physics }) => {
|
||
it('a crate slides along the shore of a row of lake cells without catching on a quarter seam', () => {
|
||
const m = outlinedMap();
|
||
m.blockOutline(POND);
|
||
m.paint(cells(0, 3, 11, 3), m.lakeId); // shore at y = 3.25 cells, 40 in the world
|
||
const crate = m.add({ x: -170, y: 0 }, { width: 30, height: 30 });
|
||
const r = run(m, physics);
|
||
const mover = r.entity(crate.id);
|
||
const steps: { dx: number; y: number; blockedX: boolean }[] = [];
|
||
for (let i = 0; i < 100; i++) {
|
||
const x = mover.x;
|
||
const { blockedX } = r.move(mover, 3, 1.5);
|
||
steps.push({ dx: mover.x - x, y: mover.y, blockedX });
|
||
}
|
||
for (const step of steps.slice(20)) {
|
||
expect(step.dx).toBeCloseTo(3 - 0.5 * 1.5, 9);
|
||
expect(step.y).toBeCloseTo(40 - 15, 9);
|
||
expect(step.blockedX).toBe(false);
|
||
}
|
||
expect(mover.x).toBeGreaterThan(-170 + 5 * 32);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('outlined tiles in the engine (#141)', () => {
|
||
it('a crate falls onto a wedge tile and slides down it to the floor', () => {
|
||
const m = outlinedMap();
|
||
m.outline(P(0, 0, 32, 32, 0, 32)); // falling to the right
|
||
m.paint([[5, 2]]); // x −32 … 0: from (−32, 0) down to (0, 32)
|
||
m.paint(
|
||
Array.from({ length: 12 }, (_, x) => [x, 3] as [number, number]),
|
||
m.plainId,
|
||
);
|
||
const crate = m.add({ x: -26, y: -30 }, { width: 10, height: 10, friction: 0 }, {});
|
||
const r = run(m, true);
|
||
r.seconds(3);
|
||
expect(r.entity(crate.id).x).toBeGreaterThan(5); // off the wedge's foot at x = 0
|
||
expect(r.entity(crate.id).y).toBeCloseTo(32 - 5, 0); // on the floor
|
||
});
|
||
});
|