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
@@ -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. */
|
||||
|
||||
@@ -2,7 +2,14 @@ import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||
import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '../../core/model/matrix';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { type CellRect, cellRectShape, solidRects } from '../../core/model/tilemapCollision';
|
||||
import {
|
||||
type CellPolygon,
|
||||
cellPolygonShape,
|
||||
type CellRect,
|
||||
cellRectShape,
|
||||
solidPolygons,
|
||||
solidRects,
|
||||
} from '../../core/model/tilemapCollision';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import {
|
||||
type ColliderShape,
|
||||
@@ -97,35 +104,45 @@ export interface CollisionSource {
|
||||
*/
|
||||
export class Solids {
|
||||
/**
|
||||
* The blocking rectangles of each tilemap, in cells. Worked out once per tick – painting, or a
|
||||
* behavior changing the map, is picked up by the next one – and turned into world shapes only
|
||||
* when asked, since the map may move in between.
|
||||
* The blocking rectangles and outlined cells of each tilemap, in cells. Worked out once per tick –
|
||||
* painting, or a behavior changing the map, is picked up by the next one – and turned into world
|
||||
* shapes only when asked, since the map may move in between.
|
||||
*/
|
||||
private rects = new Map<Entity, CellRect[]>();
|
||||
private blocks = new Map<Entity, { rects: CellRect[]; polygons: CellPolygon[] }>();
|
||||
|
||||
/** Forgets the rectangles, so the next tick works them out afresh. */
|
||||
/** Forgets the cells, so the next tick works them out afresh. */
|
||||
reset(): void {
|
||||
this.rects = new Map();
|
||||
this.blocks = new Map();
|
||||
}
|
||||
|
||||
rectsOf(world: World, tilemap: Entity): CellRect[] {
|
||||
let rects = this.rects.get(tilemap);
|
||||
if (!rects) {
|
||||
private blocksOf(world: World, tilemap: Entity): { rects: CellRect[]; polygons: CellPolygon[] } {
|
||||
let blocks = this.blocks.get(tilemap);
|
||||
if (!blocks) {
|
||||
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
|
||||
rects = asset ? solidRects(world.project, tilemap.props, asset) : [];
|
||||
this.rects.set(tilemap, rects);
|
||||
blocks = asset
|
||||
? {
|
||||
rects: solidRects(world.project, tilemap.props, asset),
|
||||
polygons: solidPolygons(world.project, tilemap.props, asset),
|
||||
}
|
||||
: { rects: [], polygons: [] };
|
||||
this.blocks.set(tilemap, blocks);
|
||||
}
|
||||
return rects;
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shapes a body occupies in the world: one for a collider, one box per merged rectangle
|
||||
* for a tilemap. A tilemap placed before the first tick is worked out on the spot.
|
||||
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
|
||||
* rectangle and one polygon per outlined cell (#141). A tilemap placed before the first tick is
|
||||
* worked out on the spot.
|
||||
*/
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
if (body.type !== 'tilemap') return [shapeOf(body)];
|
||||
const m = worldMatrixOf(body);
|
||||
return this.rectsOf(world, body).map((rect) => cellRectShape(body.props, rect, m));
|
||||
const { rects, polygons } = this.blocksOf(world, body);
|
||||
return [
|
||||
...rects.map((rect) => cellRectShape(body.props, rect, m)),
|
||||
...polygons.map((polygon) => cellPolygonShape(body.props, polygon, m)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user