Physics stage 1b: six new nodes act on dynamic bodies in play (Apply force over the frame, independent of the frame rate, Apply impulse, Set velocity, Set angular velocity, Get velocity, Set gravity for the rest of the run), a node on an object without a dynamic body and moving a dynamic body directly each log a hint once per object, and Move with collision now pushes dynamic bodies with a mass-dependent push while the mover stays flush; Move keeps its own move-and-slide instead of Rapier's character controller, which measurably snags on seams between colliders (#147)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
78899b1ace
commit
0ff3c8b742
10 files changed
+783
-43
No files matched your search
@@ -33,6 +33,22 @@ export interface CollisionPair {
|
||||
impulse?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export interface Blocked {
|
||||
by: Entity;
|
||||
along: Vec2;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/** A shape in the mover's way and the collider or tilemap it belongs to. */
|
||||
interface Obstacle {
|
||||
shape: ColliderShape;
|
||||
body: Entity;
|
||||
}
|
||||
|
||||
/** What the behaviors read of collisions, whichever system works them out. */
|
||||
export interface CollisionSource {
|
||||
readonly enters: CollisionPair[];
|
||||
@@ -88,61 +104,65 @@ 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.
|
||||
*/
|
||||
moveAndSlide(
|
||||
world: World,
|
||||
entity: Entity,
|
||||
dx: number,
|
||||
dy: number,
|
||||
onBlocked?: (blocked: Blocked) => void,
|
||||
): { blockedX: boolean; blockedY: boolean } {
|
||||
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props) && isHandleEnabled(c));
|
||||
const own = blockingCollidersOf(world, entity);
|
||||
// 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: slideAxis(entity, own, obstacles, 'x', dx),
|
||||
blockedY: slideAxis(entity, own, obstacles, 'y', dy),
|
||||
.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;
|
||||
};
|
||||
return { blockedX: slide('x', dx), blockedY: slide('y', dy) };
|
||||
}
|
||||
}
|
||||
|
||||
function slideAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: ColliderShape[],
|
||||
obstacles: Obstacle[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
): boolean {
|
||||
if (delta === 0 || own.length === 0) return false;
|
||||
): 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((Math.abs(delta) * travel.length) / stepLimit(own, obstacles, travel.unit))),
|
||||
Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, travel.unit))),
|
||||
);
|
||||
for (let i = 0; i < steps; i++)
|
||||
if (stepAxis(entity, own, obstacles, axis, delta / steps, travel)) return true;
|
||||
return false;
|
||||
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 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
|
||||
/** One indivisible move along `axis`; returns the obstacle that stopped it, if one did. */
|
||||
function stepAxis(
|
||||
entity: Entity,
|
||||
own: Entity[],
|
||||
obstacles: ColliderShape[],
|
||||
obstacles: Obstacle[],
|
||||
axis: PlaneAxis,
|
||||
delta: number,
|
||||
travel: AxisTravel,
|
||||
): boolean {
|
||||
): 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
|
||||
@@ -151,23 +171,27 @@ function stepAxis(
|
||||
// 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 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((b, j) => {
|
||||
const t = penetration(mover, b, forward) - before[i]![j]!;
|
||||
if (t > correction) correction = t;
|
||||
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 false;
|
||||
if (correction <= 0) return null;
|
||||
entity[axis] -= dir * Math.min(correction / travel.length, Math.abs(delta));
|
||||
return true;
|
||||
return by;
|
||||
}
|
||||
|
||||
/** Where one unit along an entity's local axis goes in the world, and how far. */
|
||||
@@ -199,6 +223,21 @@ function penetration(mover: ColliderShape, obstacle: ColliderShape, forward: Vec
|
||||
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));
|
||||
}
|
||||
|
||||
/** Whether `body` (a collider or a tilemap) stands in the way of `mover`. */
|
||||
export function blocksMover(body: Entity, mover: Entity): boolean {
|
||||
return (
|
||||
isCollisionBody(body) &&
|
||||
!(body.type === 'collider' && isTrigger(body.props)) &&
|
||||
isHandleEnabled(body) &&
|
||||
!travelsWith(body, mover)
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -209,10 +248,10 @@ function travelsWith(collider: Entity, entity: Entity): boolean {
|
||||
* 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 {
|
||||
function stepLimit(own: Entity[], obstacles: Obstacle[], 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));
|
||||
for (const { shape } of obstacles) min = Math.min(min, shapeExtent(shape, along));
|
||||
return Math.max(1, min / 2);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user