Move with collision reacts along the contact normal: it travels to the surface, stays flush and slides the rest of the move along it with Coulomb friction (the mean of both colliders, a tile counts as 0.5), a circle now rolls over an edge instead of catching on it, pushed dynamic bodies move off along the normal, Move gains the outputs hit, normal x and normal y, an object without its own collider moves freely, and editor run and play share the same path through Solids; contact point and normal come from our own f64 geometry (contactBetween), because Rapier's castShape lands 3e-6 px inside the wall in f32 (#150)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d931e66f92
commit
1b5e386b60
15 files changed
+680
-161
No files matched your search
@@ -26,6 +26,7 @@ import {
|
||||
bodyOf,
|
||||
type CollisionPair,
|
||||
type CollisionSource,
|
||||
type MoveResult,
|
||||
partsOf,
|
||||
shapeOf,
|
||||
Solids,
|
||||
@@ -169,12 +170,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
* `Move` with "Collide with solids": the object stops at what is in the way and slides along it,
|
||||
* as in the editor run, and a dynamic body that stops it gets a push (#147).
|
||||
*/
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { blockedX: boolean; blockedY: boolean } {
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult {
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked));
|
||||
}
|
||||
|
||||
@@ -253,7 +249,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* A mover that runs into a dynamic body pushes it along its travel, as one body meeting another
|
||||
* A mover that runs into a dynamic body pushes it into the contact, as one body meeting another
|
||||
* without bounce: the wanted speed is the move spread over the frame, and a light mover barely
|
||||
* shifts a heavy crate. Both keep moving – the mover as the graph says, the crate as the engine does.
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { World } from '../World';
|
||||
import type { Rapier } from '../physics/engine';
|
||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||
import { CollisionSystem } from './CollisionSystem';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource } from './solids';
|
||||
import { bodyOf, type CollisionPair, type CollisionSource, NOT_BLOCKED } from './solids';
|
||||
import { AnimationSystem } from './AnimationSystem';
|
||||
import type { Input } from './Input';
|
||||
import type { System } from './System';
|
||||
@@ -563,11 +563,11 @@ export class BehaviorSystem implements System {
|
||||
},
|
||||
moveAndSlide: (target, dx, dy) => {
|
||||
const found = resolve(target);
|
||||
if (!found) return { blockedX: false, blockedY: false };
|
||||
if (!found) return NOT_BLOCKED;
|
||||
if (this.collisions) return this.collisions.moveAndSlide(world, found, dx, dy);
|
||||
found.x += dx;
|
||||
found.y += dy;
|
||||
return { blockedX: false, blockedY: false };
|
||||
return NOT_BLOCKED;
|
||||
},
|
||||
physics:
|
||||
this.collisions instanceof PhysicsSystem
|
||||
|
||||
@@ -2,7 +2,15 @@ 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 {
|
||||
bodyOf,
|
||||
type CollisionPair,
|
||||
type CollisionSource,
|
||||
isCollisionBody,
|
||||
type MoveResult,
|
||||
partsOf,
|
||||
Solids,
|
||||
} from './solids';
|
||||
import type { System } from './System';
|
||||
|
||||
/**
|
||||
@@ -67,12 +75,7 @@ export class CollisionSystem implements System, CollisionSource {
|
||||
return false;
|
||||
}
|
||||
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { blockedX: boolean; blockedY: boolean } {
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult {
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy);
|
||||
}
|
||||
}
|
||||
+168
-105
@@ -1,5 +1,5 @@
|
||||
import { isHandleEnabled, worldMatrixOf } from '../../core/graph';
|
||||
import { applyVector, IDENTITY } from '../../core/model/matrix';
|
||||
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';
|
||||
@@ -7,18 +7,24 @@ import { parentChain } from '../../core/model/walk';
|
||||
import {
|
||||
type ColliderShape,
|
||||
colliderShape,
|
||||
contactBetween,
|
||||
exitDistance,
|
||||
isTrigger,
|
||||
type PlaneAxis,
|
||||
overlapDepth,
|
||||
shapeExtent,
|
||||
} from '../../core/properties/collider';
|
||||
import { colliderMaterial } from '../../core/properties/rigidbody';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
|
||||
/** 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. */
|
||||
/** Upper bound for the sub-steps of a single sweep, so a huge delta cannot stall a frame. */
|
||||
const MAX_STEPS = 256;
|
||||
/** A move is swept this many times at most: into a contact, along it, into the next one, and on. */
|
||||
const MAX_PASSES = 4;
|
||||
/** Deepening an overlap by no more than this is rounding, not moving in. */
|
||||
const DEEPER = 1e-9;
|
||||
/** A normal this close to square with an axis does not stand against a move along it. */
|
||||
const SQUARE = 1e-6;
|
||||
|
||||
/**
|
||||
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
|
||||
@@ -34,8 +40,8 @@ export interface CollisionPair {
|
||||
}
|
||||
|
||||
/**
|
||||
* What stopped a move along one axis: the collider or tilemap in the way, the world direction the
|
||||
* move was going, and how far it wanted to go there (world units).
|
||||
* What a move ran into: the collider or tilemap in the way, the world direction into it (against
|
||||
* the contact normal), and how far the whole move wanted to go that way (world units).
|
||||
*/
|
||||
export interface Blocked {
|
||||
by: Entity;
|
||||
@@ -43,24 +49,46 @@ export interface Blocked {
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `Move` with "Collide with solids" reports: which of the object's own axes something stood
|
||||
* against, whether it hit anything, and the normal of the first contact (world, pointing away from
|
||||
* what was hit; zero without a hit).
|
||||
*/
|
||||
export interface MoveResult {
|
||||
blockedX: boolean;
|
||||
blockedY: boolean;
|
||||
hit: boolean;
|
||||
normal: Vec2;
|
||||
}
|
||||
|
||||
export const NOT_BLOCKED: MoveResult = Object.freeze({
|
||||
blockedX: false,
|
||||
blockedY: false,
|
||||
hit: false,
|
||||
normal: Object.freeze({ x: 0, y: 0 }),
|
||||
});
|
||||
|
||||
/** A shape in the mover's way and the collider or tilemap it belongs to. */
|
||||
interface Obstacle {
|
||||
shape: ColliderShape;
|
||||
body: Entity;
|
||||
}
|
||||
|
||||
/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
|
||||
interface Contact {
|
||||
by: Entity;
|
||||
own: Entity;
|
||||
normal: Vec2;
|
||||
left: Vec2;
|
||||
}
|
||||
|
||||
/** What the behaviors read of collisions, whichever system works them out. */
|
||||
export interface CollisionSource {
|
||||
readonly enters: CollisionPair[];
|
||||
readonly stays: CollisionPair[];
|
||||
readonly exits: CollisionPair[];
|
||||
touching(world: World, a: Entity, b: Entity): boolean;
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): { blockedX: boolean; blockedY: boolean };
|
||||
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,10 +129,13 @@ export class Solids {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `onBlocked` hears what stopped each axis.
|
||||
* Moves the object towards (dx, dy) and, where a solid is in the way, stops flush at it and goes
|
||||
* on along its surface: what is left of the move loses the part into the surface, and friction
|
||||
* takes off the part along it in proportion (Coulomb) – so a circle rolls off an edge, and a
|
||||
* pressed object slides the less the rougher the pair (#150). An existing overlap is never
|
||||
* resolved (no jolt) but never deepened, so an object can leave a wall but not burrow in.
|
||||
* (dx, dy) are in the entity's own axes: under a turned parent, "x" runs wherever that parent
|
||||
* points it. `onBlocked` hears each solid the move ran into, once.
|
||||
*/
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
@@ -112,117 +143,149 @@ export class Solids {
|
||||
dx: number,
|
||||
dy: number,
|
||||
onBlocked?: (blocked: Blocked) => void,
|
||||
): { blockedX: boolean; blockedY: boolean } {
|
||||
): MoveResult {
|
||||
const own = blockingCollidersOf(world, entity);
|
||||
const frame = parentFrame(entity);
|
||||
const wanted = applyVector(frame, { x: dx, y: dy });
|
||||
if (own.length === 0) {
|
||||
entity.x += dx;
|
||||
entity.y += dy;
|
||||
return NOT_BLOCKED;
|
||||
}
|
||||
// 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) => blocksMover(c, entity))
|
||||
.flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body })));
|
||||
const slide = (axis: PlaneAxis, delta: number) => {
|
||||
const blocked = slideAxis(entity, own, obstacles, axis, delta);
|
||||
if (blocked) onBlocked?.(blocked);
|
||||
return blocked !== null;
|
||||
const obstacles = world.entities
|
||||
.filter((c) => blocksMover(c, entity))
|
||||
.flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body })));
|
||||
const contacts: Contact[] = [];
|
||||
let rest = wanted;
|
||||
for (let pass = 0; pass < MAX_PASSES && (rest.x !== 0 || rest.y !== 0); pass++) {
|
||||
const contact = sweep(entity, frame, own, obstacles, rest);
|
||||
if (!contact) break;
|
||||
contacts.push(contact);
|
||||
rest = slideAlong(contact.left, contact.normal, frictionBetween(contact.own, contact.by));
|
||||
}
|
||||
if (contacts.length === 0) return NOT_BLOCKED;
|
||||
const pushed = new Set<Entity>();
|
||||
for (const { by, normal } of contacts) {
|
||||
if (pushed.has(by)) continue;
|
||||
pushed.add(by);
|
||||
const along = { x: -normal.x, y: -normal.y };
|
||||
onBlocked?.({ by, along, distance: Math.max(0, dot(wanted, along)) });
|
||||
}
|
||||
const against = (axis: Vec2, delta: number) =>
|
||||
delta !== 0 && contacts.some(({ normal }) => Math.sign(delta) * dot(normal, unitOf(axis)) < -SQUARE);
|
||||
const first = contacts[0]!.normal;
|
||||
return {
|
||||
blockedX: against({ x: frame.a, y: frame.b }, dx),
|
||||
blockedY: against({ x: frame.c, y: frame.d }, dy),
|
||||
hit: true,
|
||||
normal: { x: first.x || 0, y: first.y || 0 },
|
||||
};
|
||||
return { blockedX: slide('x', dx), blockedY: slide('y', dy) };
|
||||
}
|
||||
}
|
||||
|
||||
function slideAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: Obstacle[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
): Blocked | null {
|
||||
if (delta === 0 || own.length === 0) return null;
|
||||
const travel = axisTravel(entity, axis);
|
||||
const distance = Math.abs(delta) * travel.length;
|
||||
const steps = Math.min(
|
||||
MAX_STEPS,
|
||||
Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, travel.unit))),
|
||||
);
|
||||
/**
|
||||
* The linear map from the entity's own axes to the world: its parent's. A parent scaled to nothing
|
||||
* leaves no direction to move in, so there the world's axes stand in.
|
||||
*/
|
||||
function parentFrame(entity: Entity): Mat {
|
||||
const parent = entity.parent ? linearOf(worldMatrixOf(entity.parent)) : IDENTITY;
|
||||
return Math.abs(determinant(parent)) < 1e-12 ? IDENTITY : parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the entity along `move` (world) in steps no solid is thinner than, until a step runs into
|
||||
* one: then it is set back flush against it and the contact is reported with what is left of the move.
|
||||
*/
|
||||
function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[], move: Vec2): Contact | null {
|
||||
const distance = Math.hypot(move.x, move.y);
|
||||
const forward = { x: move.x / distance, y: move.y / distance };
|
||||
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, forward))));
|
||||
const step = distance / steps;
|
||||
const toLocal = invert(frame);
|
||||
const shift = (length: number) => {
|
||||
const local = applyVector(toLocal, { x: forward.x * length, y: forward.y * length });
|
||||
entity.x += local.x;
|
||||
entity.y += local.y;
|
||||
};
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const by = stepAxis(entity, own, obstacles, axis, delta / steps, travel);
|
||||
if (by) {
|
||||
const dir = Math.sign(delta);
|
||||
return { by, along: { x: dir * travel.unit.x, y: dir * travel.unit.y }, distance };
|
||||
// How deep each pair already overlaps. A step may never deepen that, but resolving it is not
|
||||
// our job (no jolt, and leaving a wall is always possible).
|
||||
const before = own.map((a) => {
|
||||
const mover = shapeOf(a);
|
||||
return obstacles.map(({ shape }) => overlapDepth(contactBetween(mover, shape, forward).separation));
|
||||
});
|
||||
shift(step);
|
||||
let correction = 0;
|
||||
let found: { own: Entity; obstacle: Obstacle } | null = null;
|
||||
for (const [k, a] of own.entries()) {
|
||||
const mover = shapeOf(a);
|
||||
for (const [j, obstacle] of obstacles.entries()) {
|
||||
const was = before[k]![j]!;
|
||||
if (overlapDepth(contactBetween(mover, obstacle.shape, forward).separation) <= was + DEEPER) continue;
|
||||
// Coming from outside, the step goes back exactly to where the two met; from inside, all
|
||||
// of it is taken back.
|
||||
const back =
|
||||
was === 0
|
||||
? Math.min(step, exitDistance(mover, obstacle.shape, { x: -forward.x, y: -forward.y }))
|
||||
: step;
|
||||
if (back > correction || !found) {
|
||||
correction = back;
|
||||
found = { own: a, obstacle };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) continue;
|
||||
const { own: collider, obstacle } = found;
|
||||
shift(-correction);
|
||||
const left = distance - i * step - (step - correction);
|
||||
return {
|
||||
by: obstacle.body,
|
||||
own: collider,
|
||||
normal: contactBetween(shapeOf(collider), obstacle.shape, forward).normal,
|
||||
left: { x: forward.x * left, y: forward.y * left },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One indivisible move along `axis`; returns the obstacle that stopped it, if one did. */
|
||||
function stepAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: Obstacle[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
travel: AxisTravel,
|
||||
): Entity | null {
|
||||
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(({ shape }) => {
|
||||
const p = penetration(mover, shape, forward);
|
||||
return p > SKIN ? p : 0;
|
||||
});
|
||||
});
|
||||
entity[axis] += delta;
|
||||
let correction = 0;
|
||||
let by: Entity | null = null;
|
||||
own.forEach((a, i) => {
|
||||
const mover = shapeOf(a);
|
||||
obstacles.forEach(({ shape, body }, j) => {
|
||||
const t = penetration(mover, shape, forward) - before[i]![j]!;
|
||||
if (t > correction) {
|
||||
correction = t;
|
||||
by = body;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (correction <= 0) return null;
|
||||
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
|
||||
return by;
|
||||
/**
|
||||
* What of a move goes on after a contact: nothing into the surface, and along it what friction
|
||||
* leaves – shortened by `friction` times the part that pressed into the surface, down to nothing.
|
||||
*/
|
||||
function slideAlong(move: Vec2, normal: Vec2, friction: number): Vec2 {
|
||||
const into = dot(move, normal);
|
||||
if (into >= 0) return move;
|
||||
const along = { x: move.x - into * normal.x, y: move.y - into * normal.y };
|
||||
const length = Math.hypot(along.x, along.y);
|
||||
const kept = length - friction * -into;
|
||||
if (kept <= 0 || length === 0) return { x: 0, y: 0 };
|
||||
return { x: (along.x * kept) / length, y: (along.y * kept) / length };
|
||||
}
|
||||
|
||||
/** Where one unit along an entity's local axis goes in the world, and how far. */
|
||||
interface AxisTravel {
|
||||
unit: Vec2;
|
||||
length: number;
|
||||
/**
|
||||
* The friction of a pair, as the physics engine combines it for its bodies (the average), so an
|
||||
* object moved by the graph slides as far as one the engine moves. A tile has no material of its
|
||||
* own and has what a new collider starts with.
|
||||
*/
|
||||
function frictionBetween(own: Entity, obstacle: Entity): number {
|
||||
const of = (e: Entity) => colliderMaterial(e.type === 'collider' ? e.props : {}).friction;
|
||||
return (of(own) + of(obstacle)) / 2;
|
||||
}
|
||||
|
||||
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 });
|
||||
function dot(a: Vec2, b: Vec2): number {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
function unitOf(v: Vec2): Vec2 {
|
||||
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 };
|
||||
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
|
||||
}
|
||||
|
||||
export function shapeOf(collider: Entity): ColliderShape {
|
||||
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 });
|
||||
}
|
||||
|
||||
/** What of an entity stops at walls when it moves: its solid colliders that are switched on. */
|
||||
export function blockingCollidersOf(world: World, entity: Entity): Entity[] {
|
||||
return collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
|
||||
|
||||
Reference in new issue
Block a user