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)
game-maker / gate (push) Successful in 1m52s
game-maker / suite (push) Successful in 6m41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 22:56:25 +02:00
1 parent 1b5e386b60
commit 32057a96fc
25 files changed
+2341 -122

No files matched your search

+266
View File
@@ -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));
});
});
+365
View File
@@ -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
});
});