Physics stage 1a runs on Rapier: a new Rigidbody child object (body type, mass, gravity scale, damping, fixed rotation, CCD) makes its parent fall, bounce and come to rest in play, colliders gain friction, bounciness and density, the scene gains gravity (0, 980), the new PhysicsSystem steps at a fixed 60 Hz with interpolation in world coordinates and treats a tilemap as static ground, On collision enter gains normal and impulse, play waits for Rapier and does not start without it, and the editor run keeps the old CollisionSystem; the existing collision tests run against both systems unchanged (#146)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d02bcca266
commit
78899b1ace
34 files changed
+2291
-676
No files matched your search
@@ -1,58 +1,28 @@
|
||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||
import { applyVector, IDENTITY } 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 { parentChain } from '../../core/model/walk';
|
||||
import {
|
||||
type ColliderShape,
|
||||
colliderShape,
|
||||
exitDistance,
|
||||
isTrigger,
|
||||
type PlaneAxis,
|
||||
shapeExtent,
|
||||
shapesOverlap,
|
||||
} from '../../core/properties/collider';
|
||||
import { isHandleEnabled } from '../../core/graph';
|
||||
import { shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, isCollisionBody, partsOf, Solids } from './solids';
|
||||
import type { System } from './System';
|
||||
|
||||
/** Overlaps up to this depth (world units) count as contact, not as "already inside". */
|
||||
const SKIN = 0.05;
|
||||
/** Upper bound for the sub-steps of a single move, so a huge delta cannot stall a frame. */
|
||||
const MAX_STEPS = 256;
|
||||
|
||||
/**
|
||||
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
|
||||
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
|
||||
*/
|
||||
export interface CollisionPair {
|
||||
a: Entity;
|
||||
b: Entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts collider overlaps into enter / stay / exit per tick, detection only; colliders of one body never
|
||||
* collide. Runs before the BehaviorSystem so graphs see this frame's events.
|
||||
* collide. Runs before the BehaviorSystem so graphs see this frame's events. This is the editor
|
||||
* run's collision; a played game has the physics engine instead (`PhysicsSystem`, #146).
|
||||
*
|
||||
* A placed tilemap is a solid body of its own: its blocking cells, merged into rectangles, stop a
|
||||
* move and report a collision with the tilemap as the other side. Two tilemaps never meet – they
|
||||
* are ground, not things that walk into each other.
|
||||
*/
|
||||
export class CollisionSystem implements System {
|
||||
export class CollisionSystem implements System, CollisionSource {
|
||||
enters: CollisionPair[] = [];
|
||||
stays: CollisionPair[] = [];
|
||||
exits: CollisionPair[] = [];
|
||||
private previous = new Map<string, CollisionPair>();
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private rects = new Map<Entity, CellRect[]>();
|
||||
private readonly solids = new Solids();
|
||||
|
||||
update(world: World): void {
|
||||
this.rects = new Map();
|
||||
this.solids.reset();
|
||||
// Switched-off bodies (or ones below something switched off) take no part at all.
|
||||
const bodies = world.entities.filter((e) => isCollisionBody(e) && isHandleEnabled(e));
|
||||
const current = new Map<string, CollisionPair>();
|
||||
@@ -75,7 +45,7 @@ export class CollisionSystem implements System {
|
||||
|
||||
stop(): void {
|
||||
this.previous = new Map();
|
||||
this.rects = new Map();
|
||||
this.solids.reset();
|
||||
this.enters = [];
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
@@ -83,8 +53,8 @@ export class CollisionSystem implements System {
|
||||
|
||||
/** Live test of two bodies (world positions applied): any shape of one over any of the other. */
|
||||
private overlaps(world: World, a: Entity, b: Entity): boolean {
|
||||
const others = this.shapesOf(world, b);
|
||||
return this.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
|
||||
const others = this.solids.shapesOf(world, b);
|
||||
return this.solids.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
|
||||
}
|
||||
|
||||
/** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */
|
||||
@@ -97,178 +67,12 @@ export class CollisionSystem implements System {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
shapesOf(world: World, body: Entity): ColliderShape[] {
|
||||
if (body.type !== 'tilemap') return [shapeOf(body)];
|
||||
let rects = this.rects.get(body);
|
||||
if (!rects) {
|
||||
const asset = findAssetOf(world.project, body.assetId, 'tilemap');
|
||||
rects = asset ? solidRects(world.project, body.props, asset) : [];
|
||||
this.rects.set(body, rects);
|
||||
}
|
||||
const m = worldMatrixOf(body);
|
||||
return rects.map((rect) => cellRectShape(body.props, rect, m));
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one axis at a time, stopping each at contact so the object slides along walls. An existing
|
||||
* overlap is never resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
|
||||
* The axes are the entity's own: under a turned parent, "x" runs wherever that parent points it.
|
||||
*/
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { blockedX: boolean; blockedY: boolean } {
|
||||
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
|
||||
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
|
||||
const obstacles =
|
||||
own.length === 0
|
||||
? []
|
||||
: world.entities
|
||||
.filter(
|
||||
(c) =>
|
||||
isCollisionBody(c) &&
|
||||
!(c.type === 'collider' && isTrigger(c.props)) &&
|
||||
isHandleEnabled(c) &&
|
||||
!travelsWith(c, entity),
|
||||
)
|
||||
.flatMap((c) => this.shapesOf(world, c));
|
||||
return {
|
||||
blockedX: this.slideAxis(entity, own, obstacles, 'x', dx),
|
||||
blockedY: this.slideAxis(entity, own, obstacles, 'y', dy),
|
||||
};
|
||||
}
|
||||
|
||||
private slideAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: ColliderShape[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
): boolean {
|
||||
if (delta === 0 || own.length === 0) return false;
|
||||
const travel = axisTravel(entity, axis);
|
||||
const steps = Math.min(
|
||||
MAX_STEPS,
|
||||
Math.max(1, Math.ceil((Math.abs(delta) * travel.length) / stepLimit(own, obstacles, travel.unit))),
|
||||
);
|
||||
for (let i = 0; i < steps; i++)
|
||||
if (this.stepAxis(entity, own, obstacles, axis, delta / steps, travel)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
|
||||
private stepAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: ColliderShape[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
travel: AxisTravel,
|
||||
): boolean {
|
||||
const dir: 1 | -1 = delta > 0 ? 1 : -1;
|
||||
const forward = { x: dir * travel.unit.x, y: dir * travel.unit.y };
|
||||
// How deep each pair already sits in the other before the step. A step may never deepen
|
||||
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
|
||||
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
|
||||
// previous correction land, and taking those for "already inside" would open the wall.
|
||||
const before = own.map((a) => {
|
||||
const mover = shapeOf(a);
|
||||
return obstacles.map((b) => {
|
||||
const p = penetration(mover, b, forward);
|
||||
return p > SKIN ? p : 0;
|
||||
});
|
||||
});
|
||||
entity[axis] += delta;
|
||||
let correction = 0;
|
||||
own.forEach((a, i) => {
|
||||
const mover = shapeOf(a);
|
||||
obstacles.forEach((b, j) => {
|
||||
const t = penetration(mover, b, forward) - before[i]![j]!;
|
||||
if (t > correction) correction = t;
|
||||
});
|
||||
});
|
||||
if (correction <= 0) return false;
|
||||
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
|
||||
return true;
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
/** Where one unit along an entity's local axis goes in the world, and how far. */
|
||||
interface AxisTravel {
|
||||
unit: Vec2;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function axisTravel(entity: Entity, axis: PlaneAxis): AxisTravel {
|
||||
const parent = entity.parent ? worldMatrixOf(entity.parent) : IDENTITY;
|
||||
const v = applyVector(parent, axis === 'x' ? { x: 1, y: 0 } : { x: 0, y: 1 });
|
||||
const length = Math.hypot(v.x, v.y);
|
||||
return length > 0
|
||||
? { unit: { x: v.x / length, y: v.y / length }, length }
|
||||
: { unit: { x: 1, y: 0 }, length: 1 };
|
||||
}
|
||||
|
||||
function shapeOf(collider: Entity) {
|
||||
return colliderShape(collider.props, worldMatrixOf(collider));
|
||||
}
|
||||
|
||||
/**
|
||||
* How far `a` would have to be pushed back against its travel to just clear `b` (0 when they are
|
||||
* apart). Once a's centre has passed b's centre along the travel the shorter way out is forwards,
|
||||
* so nothing blocks – that keeps an object that ended up inside something able to leave.
|
||||
*/
|
||||
function penetration(mover: ColliderShape, obstacle: ColliderShape, forward: Vec2): number {
|
||||
if ((mover.x - obstacle.x) * forward.x + (mover.y - obstacle.y) * forward.y >= 0) return 0;
|
||||
return exitDistance(mover, obstacle, { x: -forward.x, y: -forward.y });
|
||||
}
|
||||
|
||||
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
|
||||
function travelsWith(collider: Entity, entity: Entity): boolean {
|
||||
for (const e of parentChain(collider, (c) => c.parent)) if (e === entity) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest safe step in world units: half of the thinnest collider involved, measured along the
|
||||
* travel as the colliders sit in the world (never below 1).
|
||||
*/
|
||||
function stepLimit(own: Entity[], obstacles: ColliderShape[], along: Vec2): number {
|
||||
let min = Infinity;
|
||||
for (const e of own) min = Math.min(min, shapeExtent(shapeOf(e), along));
|
||||
for (const shape of obstacles) min = Math.min(min, shapeExtent(shape, along));
|
||||
return Math.max(1, min / 2);
|
||||
}
|
||||
|
||||
/** What takes part in collisions: colliders, and placed tilemaps with their solid cells. */
|
||||
function isCollisionBody(entity: Entity): boolean {
|
||||
return entity.type === 'collider' || entity.type === 'tilemap';
|
||||
}
|
||||
|
||||
/**
|
||||
* The object a collider belongs to: its parent, or the collider itself when top-level. A tilemap
|
||||
* is its own body wherever it is placed – its cells are the thing, not a shape stuck on one.
|
||||
*/
|
||||
export function bodyOf(collider: Entity): Entity {
|
||||
if (collider.type === 'tilemap') return collider;
|
||||
return collider.parent ?? collider;
|
||||
}
|
||||
|
||||
/** What a body touches with: a tilemap its cells, anything else its colliders (see `collidersOf`). */
|
||||
function partsOf(world: World, entity: Entity): Entity[] {
|
||||
return entity.type === 'tilemap' ? [entity] : collidersOf(world, entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* A collider stands for itself; any other entity stands for its direct collider children – a
|
||||
* tilemap as well when it is the one being moved: its cells only ever take part as an obstacle.
|
||||
*/
|
||||
function collidersOf(world: World, entity: Entity): Entity[] {
|
||||
if (entity.type === 'collider') return [entity];
|
||||
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
|
||||
}
|
||||
Reference in new issue
Block a user