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

+112 -42
View File
@@ -8,8 +8,9 @@ import type {
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model/matrix';
import type { Vec2 } from '../../core/model/Properties';
import { signedArea } from '../../core/model/polygon';
import { parentChain } from '../../core/model/walk';
import { type ColliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
import { type ColliderShape, isTrigger, shapeArea, shapesOverlap } from '../../core/properties/collider';
import { hasTransformProps } from '../../core/properties/PropertySchema';
import {
type BodyType,
@@ -55,7 +56,9 @@ interface Pose {
interface Body {
kind: 'rigid' | 'free' | 'tilemap';
body: RigidBody;
/** The engine's colliders – several for a polygon, one per convex piece – and whose each is. */
colliders: Collider[];
sources: Entity[];
/** What the colliders were built from; they are built again when it changes (a scaled object). */
shapeKey: string;
settingsKey: string;
@@ -277,7 +280,7 @@ export class PhysicsSystem implements System, CollisionSource {
const mass = rigidbody ? rigidbodySettings(rigidbody.props).mass : 0;
if (mass > 0) return mass;
return blockingCollidersOf(world, entity).reduce(
(sum, c) => sum + colliderMaterial(c.props).density * areaOf(shapeOf(c)) * toMetres(1) ** 2,
(sum, c) => sum + colliderMaterial(c.props).density * shapeArea(shapeOf(c)) * toMetres(1) ** 2,
0,
);
}
@@ -351,6 +354,7 @@ export class PhysicsSystem implements System, CollisionSource {
kind: want.kind,
body: this.engine!.createRigidBody(desc),
colliders: [],
sources: [],
shapeKey: '',
settingsKey: '',
turns: false,
@@ -372,7 +376,7 @@ export class PhysicsSystem implements System, CollisionSource {
if (want.kind === 'tilemap') return { x: 0, y: 0, angle: 0 };
if (want.kind === 'free') {
const shape = shapeOf(key);
return { x: shape.x, y: shape.y, angle: shape.kind === 'box' ? shape.angle : 0 };
return { x: shape.x, y: shape.y, angle: shape.kind === 'circle' ? 0 : shape.angle };
}
return matrixPose(worldMatrixOf(key));
}
@@ -433,15 +437,12 @@ export class PhysicsSystem implements System, CollisionSource {
private build(body: Body, want: Wanted, key: Entity, world: World): void {
const shapes = this.shapesWanted(want, key, world);
const origin = body.placed;
const local = shapes.map(({ entity, shape }) => ({ entity, shape, at: relativeTo(origin, shape) }));
const local = shapes.map(({ entity, shape }) => ({ entity, shape, pieces: piecesInBody(origin, shape) }));
const shapeKey =
JSON.stringify(
local.map(({ entity, shape, at }) => [
local.map(({ entity, pieces }) => [
entity.id,
shape.kind === 'box' ? [round(shape.w), round(shape.h)] : round(shape.r),
round(at.x),
round(at.y),
round(at.angle),
pieces.map(pieceKey),
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
]),
) + JSON.stringify(want.settings?.mass ?? 0);
@@ -452,26 +453,28 @@ export class PhysicsSystem implements System, CollisionSource {
this.engine!.removeCollider(collider, true);
}
body.colliders = [];
body.sources = [];
const mass = want.settings?.mass ?? 0;
const area = local.reduce((sum, { entity, shape }) => sum + (isSolid(entity) ? areaOf(shape) : 0), 0);
for (const { entity, shape, at } of local) {
if (areaOf(shape) <= 0) continue;
const desc = this.colliderDesc(
entity,
shape,
at,
mass > 0 && area > 0 ? (mass * areaOf(shape)) / area : 0,
);
const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider);
this.parts.set(collider.handle, { entity, shape, owner: body });
}
const area = local.reduce(
(sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0),
0,
);
for (const { entity, shape, pieces } of local)
for (const piece of pieces) {
const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0;
const desc = this.colliderDesc(entity, piece, share);
if (!desc) continue;
const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider);
body.sources.push(entity);
this.parts.set(collider.handle, { entity, shape, owner: body });
}
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them.
const built = local.filter(({ shape }) => areaOf(shape) > 0);
const shapeOfEntity = new Map(local.map(({ entity, shape }) => [entity, shape]));
body.colliders.forEach((collider, i) => {
const { entity, shape } = built[i]!;
this.parts.set(collider.handle, { entity, shape, owner: body });
const entity = body.sources[i]!;
this.parts.set(collider.handle, { entity, shape: shapeOfEntity.get(entity)!, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
@@ -480,7 +483,7 @@ export class PhysicsSystem implements System, CollisionSource {
});
if (body.kind !== 'rigid') return;
// Without a collider the engine would weigh it nothing, and a thing of no weight never falls.
const weighed = built.some(({ entity }) => isSolid(entity) && isHandleEnabled(entity));
const weighed = body.sources.some((entity) => isSolid(entity) && isHandleEnabled(entity));
const own = weighed ? 0 : want.settings?.mass || 1;
if (own !== body.ownMass) {
body.ownMass = own;
@@ -494,13 +497,30 @@ export class PhysicsSystem implements System, CollisionSource {
return want.colliders.map((entity) => ({ entity, shape: shapeOf(entity) }));
}
private colliderDesc(entity: Entity, shape: ColliderShape, at: Pose, mass: number): ColliderDesc {
/** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */
private colliderDesc(entity: Entity, piece: Piece, mass: number): ColliderDesc | null {
const R = this.rapier;
const desc =
shape.kind === 'circle'
? R.ColliderDesc.ball(toMetres(shape.r))
: R.ColliderDesc.cuboid(toMetres(shape.w / 2), toMetres(shape.h / 2));
desc.setTranslation(toMetres(at.x), toMetres(at.y)).setRotation(at.angle);
if (pieceArea(piece) <= 0) return null;
let desc: ColliderDesc | null;
switch (piece.kind) {
case 'ball':
desc = R.ColliderDesc.ball(toMetres(piece.r));
break;
case 'cuboid':
desc = R.ColliderDesc.cuboid(toMetres(piece.hw), toMetres(piece.hh));
break;
case 'capsule':
desc = R.ColliderDesc.capsule(toMetres(piece.half), toMetres(piece.r));
break;
case 'convex':
desc = R.ColliderDesc.convexHull(
new Float32Array(piece.points.flatMap((p) => [toMetres(p.x), toMetres(p.y)])),
);
break;
}
if (!desc) return null;
if (piece.kind !== 'convex')
desc.setTranslation(toMetres(piece.x), toMetres(piece.y)).setRotation(piece.angle);
// A tile has no material of its own; it gets what a new collider starts with.
const material = colliderMaterial(entity.type === 'collider' ? entity.props : {});
desc.setFriction(material.friction).setRestitution(material.bounciness);
@@ -639,21 +659,71 @@ function blend(a: Pose, b: Pose, t: number): Pose {
};
}
/** A world shape as seen from a body at `origin`: offset and turn in the body's own frame. */
function relativeTo(origin: Pose, shape: ColliderShape): Pose {
const dx = shape.x - origin.x;
const dy = shape.y - origin.y;
/**
* One engine collider in the body's own frame (world units): a ball, a box or a capsule placed and
* turned there, or a convex piece given by its corners.
*/
type Piece =
| { kind: 'ball'; x: number; y: number; angle: number; r: number }
| { kind: 'cuboid'; x: number; y: number; angle: number; hw: number; hh: number }
| { kind: 'capsule'; x: number; y: number; angle: number; half: number; r: number }
| { kind: 'convex'; points: Vec2[] };
/** A world shape as seen from a body at `origin`, as the engine colliders that make it up. */
function piecesInBody(origin: Pose, shape: ColliderShape): Piece[] {
const cos = Math.cos(origin.angle);
const sin = Math.sin(origin.angle);
return {
x: dx * cos + dy * sin,
y: -dx * sin + dy * cos,
angle: (shape.kind === 'box' ? shape.angle : 0) - origin.angle,
const toBody = (p: Vec2): Vec2 => {
const dx = p.x - origin.x;
const dy = p.y - origin.y;
return { x: dx * cos + dy * sin, y: -dx * sin + dy * cos };
};
switch (shape.kind) {
case 'circle':
return [{ kind: 'ball', ...toBody(shape), angle: 0, r: shape.r }];
case 'box':
return [
{
kind: 'cuboid',
...toBody(shape),
angle: shape.angle - origin.angle,
hw: shape.w / 2,
hh: shape.h / 2,
},
];
case 'capsule': {
const a = toBody(shape.a);
const b = toBody(shape.b);
const half = Math.hypot(b.x - a.x, b.y - a.y) / 2;
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
if (half === 0) return [{ kind: 'ball', ...mid, angle: 0, r: shape.r }];
// The engine's capsule lies along its own y axis.
const angle = Math.atan2(b.y - a.y, b.x - a.x) - Math.PI / 2;
return [{ kind: 'capsule', ...mid, angle, half, r: shape.r }];
}
case 'polygon':
return shape.pieces.map((piece) => ({ kind: 'convex', points: piece.map(toBody) }));
}
}
function areaOf(shape: ColliderShape): number {
return shape.kind === 'circle' ? Math.PI * shape.r * shape.r : shape.w * shape.h;
function pieceArea(piece: Piece): number {
switch (piece.kind) {
case 'ball':
return Math.PI * piece.r * piece.r;
case 'cuboid':
return 4 * piece.hw * piece.hh;
case 'capsule':
return Math.PI * piece.r * piece.r + 4 * piece.half * piece.r;
case 'convex':
return Math.abs(signedArea(piece.points));
}
}
/** What a piece was built from, rounded, so a rebuild happens when – and only when – it changed. */
function pieceKey(piece: Piece): unknown {
if (piece.kind === 'convex') return piece.points.map((p) => [round(p.x), round(p.y)]);
const { kind, ...values } = piece;
return [kind, ...Object.values(values).map(round)];
}
/** A tilemap's cells always block; a collider unless it is a trigger. */