Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b5e386b60
commit
32057a96fc
25 files changed
+2341
-122
No files matched your search
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { transformMatrix, translation } from '../../src/core/model/matrix';
|
||||
import {
|
||||
cleanOutline,
|
||||
convexPieces,
|
||||
isConvex,
|
||||
pointsOf,
|
||||
polygonProblem,
|
||||
signedArea,
|
||||
} from '../../src/core/model/polygon';
|
||||
import type { Vec2 } from '../../src/core/model/Properties';
|
||||
import {
|
||||
type ColliderShape,
|
||||
colliderShape,
|
||||
colliderSize,
|
||||
contactBetween,
|
||||
exitDistance,
|
||||
shapeArea,
|
||||
shapeContains,
|
||||
shapesOverlap,
|
||||
} from '../../src/core/properties/collider';
|
||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../../src/editor/polygonEdit';
|
||||
|
||||
const P = (...xy: number[]) =>
|
||||
Array.from({ length: xy.length / 2 }, (_, i) => ({ x: xy[2 * i]!, y: xy[2 * i + 1]! }));
|
||||
const area = (points: Vec2[]) => Math.abs(signedArea(points));
|
||||
|
||||
/** An L: 3 wide at the bottom, 1 wide going up – concave at (1, 1). Area 3 + 2 = 5. */
|
||||
const L = P(0, 0, 1, 0, 1, 2, 3, 2, 3, 3, 0, 3);
|
||||
|
||||
let seed = 11;
|
||||
const random = () => (seed = (seed * 48271) % 2147483647) / 2147483647;
|
||||
|
||||
/** A star-shaped outline: corners round a centre at random radii, so it is simple and mostly concave. */
|
||||
function randomStar(corners: number): Vec2[] {
|
||||
return Array.from({ length: corners }, (_, i) => {
|
||||
const angle = (i / corners) * 2 * Math.PI;
|
||||
const r = 20 + random() * 80;
|
||||
return { x: Math.cos(angle) * r, y: Math.sin(angle) * r };
|
||||
});
|
||||
}
|
||||
|
||||
describe('polygon outlines (#151)', () => {
|
||||
it('reads {x, y} items and leaves out what is no point', () => {
|
||||
expect(
|
||||
pointsOf([
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 'a', y: 3 },
|
||||
5,
|
||||
{ x: 4 },
|
||||
{ x: '6', y: 7 },
|
||||
{ x: '', y: 1 },
|
||||
{ x: true, y: 1 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 6, y: 7 },
|
||||
]);
|
||||
expect(pointsOf(undefined)).toEqual([]);
|
||||
expect(pointsOf('nope')).toEqual([]);
|
||||
});
|
||||
|
||||
it('says what is wrong: too few corners, no area, an outline that crosses itself', () => {
|
||||
expect(polygonProblem(P(0, 0, 10, 0))).toMatch(/three corners/);
|
||||
expect(polygonProblem(P(0, 0, 10, 0, 20, 0))).toMatch(/three corners/); // straight: cleaned to two
|
||||
expect(polygonProblem(P(0, 0, 10, 0, 10, 10, 10, 10, 0, 10))).toBe(''); // a repeated corner is fine
|
||||
expect(polygonProblem(P(0, 0, 10, 10, 10, 0, 0, 10))).toMatch(/no area/); // a bow tie: halves cancel
|
||||
// A pentagram: every other corner of a pentagon, so each edge crosses two others.
|
||||
const pentagram = [0, 2, 4, 1, 3].map((i) => ({
|
||||
x: Math.cos((i * 2 * Math.PI) / 5) * 50,
|
||||
y: Math.sin((i * 2 * Math.PI) / 5) * 50,
|
||||
}));
|
||||
expect(polygonProblem(pentagram)).toMatch(/crosses itself/);
|
||||
// Touching itself at a corner counts as crossing: the two halves would share only a point.
|
||||
expect(polygonProblem(P(0, 0, 10, 0, 5, 5, 10, 10, 0, 10, 5, 5))).toMatch(/crosses itself/);
|
||||
expect(polygonProblem(L)).toBe('');
|
||||
expect(cleanOutline(P(0, 0, 5, 0, 10, 0, 10, 10))).toEqual(P(0, 0, 10, 0, 10, 10));
|
||||
});
|
||||
|
||||
it('a convex outline is one piece, in either winding; one with a problem has none', () => {
|
||||
const square = P(0, 0, 10, 0, 10, 10, 0, 10);
|
||||
expect(convexPieces(square)).toEqual([square]);
|
||||
expect(convexPieces([...square].reverse())).toHaveLength(1);
|
||||
expect(convexPieces(P(0, 0, 10, 10, 10, 0, 0, 10))).toEqual([]);
|
||||
});
|
||||
|
||||
it('an L is cut into two convex pieces that cover it exactly', () => {
|
||||
for (const outline of [L, [...L].reverse()]) {
|
||||
const pieces = convexPieces(outline);
|
||||
expect(pieces).toHaveLength(2);
|
||||
expect(pieces.every(isConvex)).toBe(true);
|
||||
expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(5, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it('random star outlines: convex pieces, the whole area, only the outline’s own corners, few pieces', () => {
|
||||
for (let round = 0; round < 200; round++) {
|
||||
const outline = randomStar(5 + Math.floor(random() * 12));
|
||||
const pieces = convexPieces(outline);
|
||||
expect(pieces.length).toBeGreaterThan(0);
|
||||
expect(pieces.every(isConvex)).toBe(true);
|
||||
expect(pieces.reduce((sum, p) => sum + area(p), 0)).toBeCloseTo(area(outline), 6);
|
||||
const corners = new Set(outline.map((p) => `${p.x},${p.y}`));
|
||||
expect(pieces.flat().every((p) => corners.has(`${p.x},${p.y}`))).toBe(true);
|
||||
// Triangles alone would be n − 2; merging must have joined some whenever there was room.
|
||||
expect(pieces.length).toBeLessThanOrEqual(outline.length - 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const box = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape => ({
|
||||
kind: 'box',
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
angle: (degrees * Math.PI) / 180,
|
||||
});
|
||||
const circle = (x: number, y: number, r: number): ColliderShape => ({ kind: 'circle', x, y, r });
|
||||
/** The same box as a polygon collider, so the new path can be held against the old exact one. */
|
||||
const boxAsPolygon = (x: number, y: number, w: number, h: number, degrees = 0): ColliderShape =>
|
||||
colliderShape(
|
||||
{ shape: 'polygon', points: P(-w / 2, -h / 2, w / 2, -h / 2, w / 2, h / 2, -w / 2, h / 2) },
|
||||
transformMatrix(x, y, degrees, 1, false, false),
|
||||
);
|
||||
|
||||
describe('collider geometry of capsules and polygons (#151)', () => {
|
||||
it('a capsule fills width × height, rounded along the longer side, and turns with the object', () => {
|
||||
expect(colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(5, 0))).toMatchObject({
|
||||
kind: 'capsule',
|
||||
a: { x: 5, y: -20 },
|
||||
b: { x: 5, y: 20 },
|
||||
r: 10,
|
||||
});
|
||||
expect(colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0))).toMatchObject({
|
||||
a: { x: -20, y: 0 },
|
||||
b: { x: 20, y: 0 },
|
||||
r: 10,
|
||||
});
|
||||
const turned = colliderShape(
|
||||
{ shape: 'capsule', width: 20, height: 60 },
|
||||
transformMatrix(0, 0, 90, 2, false, false),
|
||||
);
|
||||
expect(turned.kind === 'capsule' && turned.a.x).toBeCloseTo(40, 9);
|
||||
expect(turned.kind === 'capsule' && turned.r).toBe(20);
|
||||
// As wide as tall: no segment left, a circle.
|
||||
const round = colliderShape({ shape: 'capsule', width: 30, height: 30 }, translation(0, 0));
|
||||
expect(round).toMatchObject({ a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, r: 15 });
|
||||
expect(shapeArea(round)).toBeCloseTo(Math.PI * 225, 9);
|
||||
});
|
||||
|
||||
it('a polygon keeps its corners in its own units: moved, sized, turned and mirrored with the object', () => {
|
||||
const props = { shape: 'polygon', points: P(0, -10, 10, 10, -10, 10) };
|
||||
expect(colliderShape(props, translation(100, 0))).toMatchObject({
|
||||
outline: P(100, -10, 110, 10, 90, 10),
|
||||
pieces: [P(100, -10, 110, 10, 90, 10)],
|
||||
});
|
||||
const mirrored = colliderShape(
|
||||
{ shape: 'polygon', points: P(0, 0, 10, 0, 0, 10) },
|
||||
transformMatrix(0, 0, 0, 2, true, false),
|
||||
);
|
||||
expect(mirrored.kind === 'polygon' && mirrored.outline).toEqual(P(0, 0, -20, 0, 0, 20));
|
||||
expect(colliderSize(props)).toEqual({ w: 20, h: 20 });
|
||||
// Without points of its own it is the default triangle.
|
||||
expect(colliderShape({ shape: 'polygon' }, translation(0, 0))).toMatchObject({
|
||||
outline: P(0, -50, 50, 50, -50, 50),
|
||||
});
|
||||
// One that crosses itself is drawn but has no pieces, so it meets nothing.
|
||||
const bowTie = colliderShape(
|
||||
{ shape: 'polygon', points: P(0, 0, 10, 10, 10, 0, 0, 10) },
|
||||
translation(0, 0),
|
||||
);
|
||||
expect(bowTie).toMatchObject({ pieces: [] });
|
||||
expect(shapesOverlap(bowTie, box(5, 5, 100, 100))).toBe(false);
|
||||
expect(contactBetween(bowTie, box(5, 5, 100, 100), { x: 1, y: 0 }).separation).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('a square polygon meets boxes and circles exactly as the box it stands for', () => {
|
||||
for (let round = 0; round < 400; round++) {
|
||||
const [x, y, deg] = [random() * 160 - 80, random() * 160 - 80, random() * 360];
|
||||
const [w, h] = [20 + random() * 60, 20 + random() * 60];
|
||||
const other = random() < 0.5 ? box(0, 0, 50, 70, random() * 90) : circle(0, 0, 10 + random() * 40);
|
||||
const classic = box(x, y, w, h, deg);
|
||||
const polygon = boxAsPolygon(x, y, w, h, deg);
|
||||
const travel = { x: random() - 0.5, y: random() - 0.5 };
|
||||
expect(shapesOverlap(polygon, other)).toBe(shapesOverlap(classic, other));
|
||||
const want = contactBetween(classic, other, travel);
|
||||
const got = contactBetween(polygon, other, travel);
|
||||
// Apart, box against box measures along its best axis rather than the real distance.
|
||||
if (other.kind === 'circle' || want.separation <= 0) {
|
||||
expect(got.separation).toBeCloseTo(want.separation, 9);
|
||||
expect(got.normal.x).toBeCloseTo(want.normal.x, 9);
|
||||
expect(got.normal.y).toBeCloseTo(want.normal.y, 9);
|
||||
}
|
||||
expect(exitDistance(polygon, other, travel)).toBeCloseTo(exitDistance(classic, other, travel), 7);
|
||||
}
|
||||
});
|
||||
|
||||
it('a capsule standing 10 above a floor, and sunk 5 into it: the gap, the normal and the way out', () => {
|
||||
const floor = box(0, 100, 400, 40); // top face at 80
|
||||
const above = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 40)); // bottom at 70
|
||||
expect(contactBetween(above, floor, { x: 0, y: 1 })).toEqual({ separation: 10, normal: { x: 0, y: -1 } });
|
||||
const sunk = colliderShape({ shape: 'capsule', width: 20, height: 60 }, translation(0, 55));
|
||||
expect(shapesOverlap(sunk, floor)).toBe(true);
|
||||
expect(contactBetween(sunk, floor, { x: 0, y: 1 }).separation).toBeCloseTo(-5, 12);
|
||||
expect(exitDistance(sunk, floor, { x: 0, y: -1 })).toBeCloseTo(5, 9);
|
||||
// On its side against a wall, the round end is what touches.
|
||||
const side = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0));
|
||||
const wall = box(40, 0, 20, 200); // left face at 30
|
||||
expect(shapesOverlap(side, wall)).toBe(false);
|
||||
expect(shapesOverlap(side, box(39, 0, 20, 200))).toBe(true);
|
||||
expect(shapeContains(side, { x: 29, y: 0 })).toBe(true);
|
||||
expect(shapeContains(side, { x: 29, y: 9 })).toBe(false); // outside the rounding
|
||||
});
|
||||
|
||||
it('a circle in line with a capsule, beyond its end: apart by the real distance, not by the side axis', () => {
|
||||
const capsule = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(0, 0)); // ends at ±30
|
||||
expect(contactBetween(circle(45, 0, 10), capsule, { x: -1, y: 0 })).toEqual({
|
||||
separation: 5,
|
||||
normal: { x: 1, y: 0 },
|
||||
});
|
||||
expect(shapesOverlap(circle(45, 0, 10), capsule)).toBe(false);
|
||||
expect(shapesOverlap(circle(35, 0, 10), capsule)).toBe(true);
|
||||
// Two capsules end to end on one line.
|
||||
const next = colliderShape({ shape: 'capsule', width: 60, height: 20 }, translation(65, 0));
|
||||
expect(contactBetween(next, capsule, { x: -1, y: 0 }).separation).toBeCloseTo(5, 12);
|
||||
});
|
||||
|
||||
it('a concave polygon: a box in the notch of a U touches nothing, one on its rim does', () => {
|
||||
// A U 120 wide, 100 tall, walls 20 thick: the notch is 80 wide and 80 deep.
|
||||
const u = colliderShape(
|
||||
{ shape: 'polygon', points: P(-60, -50, -40, -50, -40, 30, 40, 30, 40, -50, 60, -50, 60, 50, -60, 50) },
|
||||
translation(0, 0),
|
||||
);
|
||||
expect(u.kind === 'polygon' && u.pieces.length).toBeGreaterThan(1);
|
||||
expect(shapesOverlap(box(0, -10, 78, 78), u)).toBe(false); // inside the notch, 1 clear of every face
|
||||
expect(shapesOverlap(box(0, -10, 82, 60), u)).toBe(true); // wider than the notch
|
||||
// A box resting on the inner floor (top face at 30): touching, not overlapping; its way out is up.
|
||||
const resting = box(0, 10, 40, 40);
|
||||
expect(shapesOverlap(resting, u)).toBe(false);
|
||||
expect(contactBetween(resting, u, { x: 0, y: 1 }).normal).toEqual({ x: 0, y: -1 });
|
||||
expect(exitDistance(box(0, 13, 40, 40), u, { x: 0, y: -1 })).toBeCloseTo(3, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editing an outline by its corners', () => {
|
||||
const square = P(0, 0, 100, 0, 100, 100, 0, 100);
|
||||
|
||||
it('a corner wins over the edges through it; an edge gives the point on it', () => {
|
||||
expect(gripAt(square, { x: 3, y: 2 }, 5)).toEqual({ kind: 'corner', index: 0 });
|
||||
expect(gripAt(square, { x: 50, y: 3 }, 5)).toEqual({ kind: 'edge', index: 0, at: { x: 50, y: 0 } });
|
||||
expect(gripAt(square, { x: 3, y: 50 }, 5)).toEqual({ kind: 'edge', index: 3, at: { x: 0, y: 50 } });
|
||||
expect(gripAt(square, { x: 50, y: 50 }, 5)).toBeNull();
|
||||
// Two corners are no outline yet: only the corners can be taken.
|
||||
expect(gripAt(P(0, 0, 100, 0), { x: 50, y: 0 }, 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('puts, moves and takes away corners; the last three stay unless told otherwise', () => {
|
||||
expect(withCornerAfter(square, 3, { x: 0, y: 50 })).toEqual([...square, { x: 0, y: 50 }]);
|
||||
expect(withCornerAfter(square, 0, { x: 50, y: 0 })[1]).toEqual({ x: 50, y: 0 });
|
||||
expect(withCornerMoved(square, 2, { x: 90, y: 80 })[2]).toEqual({ x: 90, y: 80 });
|
||||
expect(withoutCorner(square, 1)).toEqual(P(0, 0, 100, 100, 0, 100));
|
||||
expect(withoutCorner(P(0, 0, 1, 0, 0, 1), 0)).toBeNull();
|
||||
expect(withoutCorner(P(0, 0, 1, 0), 0, 0)).toEqual(P(1, 0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
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 { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../../src/core/model/tilemap';
|
||||
import { solidCells, solidPolygons, tileOutline } 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 map = new CreateAssetCommand('tilemap', 'Ground');
|
||||
for (const command of [texture, tile, plain, 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 = 64;
|
||||
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));
|
||||
for (const id of [tile.asset.id, plain.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 = (points: Vec2[]) =>
|
||||
f.stack.execute(new SetFrameCollisionCommand(texture.asset.id, frame.frameId, points));
|
||||
const tileAsset = () => findAsset(f.project, tile.asset.id) as TileAsset;
|
||||
return {
|
||||
...f,
|
||||
mapId: map.asset.id,
|
||||
mapObject,
|
||||
mapAsset,
|
||||
paint,
|
||||
outline,
|
||||
tileAsset,
|
||||
plainId: plain.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 },
|
||||
] as Vec2[];
|
||||
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 plain tile blocks with its first picture’s outline; an autotile, or an outline that is none, as a box', () => {
|
||||
const m = outlinedMap();
|
||||
expect(tileOutline(m.project, m.tileAsset())).toBeNull();
|
||||
m.outline(WEDGE);
|
||||
expect(tileOutline(m.project, m.tileAsset())).toEqual({
|
||||
outline: P(0, 1, 1, 0, 1, 1),
|
||||
pieces: [P(0, 1, 1, 0, 1, 1)],
|
||||
});
|
||||
m.outline(P(0, 0, 32, 32, 32, 0, 0, 32)); // a bow tie
|
||||
expect(tileOutline(m.project, m.tileAsset())).toBeNull();
|
||||
m.outline(WEDGE);
|
||||
m.stack.execute(new SetAssetPropertyCommand(m.tileAsset().id, TILE_KIND_KEY, 'floor'));
|
||||
expect(tileOutline(m.project, m.tileAsset())).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user