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
@@ -21,6 +21,8 @@ import {
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
import {
|
||||
type Blocked,
|
||||
blockingCollidersOf,
|
||||
bodyOf,
|
||||
type CollisionPair,
|
||||
type CollisionSource,
|
||||
@@ -97,6 +99,10 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
private current = new Map<string, CollisionPair>();
|
||||
private readonly solids = new Solids();
|
||||
private backlog = 0;
|
||||
/** The length of the frame being played – the time a move of the graph's is spread over. */
|
||||
private frameDt = PHYSICS_STEP;
|
||||
private readonly hinted = new Set<string>();
|
||||
private world: World | null = null;
|
||||
|
||||
constructor(private readonly rapier: Rapier) {}
|
||||
|
||||
@@ -105,12 +111,14 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
engine.timestep = PHYSICS_STEP;
|
||||
this.engine = engine;
|
||||
this.queue = new this.rapier.EventQueue(true);
|
||||
this.world = world;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.engine?.free();
|
||||
this.queue?.free();
|
||||
this.engine = null;
|
||||
this.world = null;
|
||||
this.queue = null;
|
||||
this.bodies.clear();
|
||||
this.parts.clear();
|
||||
@@ -120,12 +128,15 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
this.stays = [];
|
||||
this.exits = [];
|
||||
this.backlog = 0;
|
||||
this.frameDt = PHYSICS_STEP;
|
||||
this.hinted.clear();
|
||||
}
|
||||
|
||||
update(world: World, dt: number): void {
|
||||
const engine = this.engine;
|
||||
if (!engine) return;
|
||||
this.solids.reset();
|
||||
this.frameDt = dt > 0 ? dt : PHYSICS_STEP;
|
||||
this.enters = [];
|
||||
this.exits = [];
|
||||
this.sync(world);
|
||||
@@ -154,29 +165,125 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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 } {
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy);
|
||||
return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked));
|
||||
}
|
||||
|
||||
/** A dynamic body's velocity in world units per second; null for an object the engine does not move. */
|
||||
velocityOf(owner: Entity): Vec2 | null {
|
||||
const body = this.bodies.get(owner);
|
||||
if (!body || body.kind !== 'rigid' || !body.body.isDynamic()) return null;
|
||||
const v = body.body.linvel();
|
||||
velocityOf(target: Entity): Vec2 | null {
|
||||
const body = this.dynamicBodyOf(target)?.body;
|
||||
if (!body) return null;
|
||||
const v = body.linvel();
|
||||
return { x: toPixels(v.x), y: toPixels(v.y) };
|
||||
}
|
||||
|
||||
/** Sets a dynamic body's velocity (world units per second); false when the object has none. */
|
||||
setVelocity(owner: Entity, velocity: Vec2): boolean {
|
||||
setVelocity(target: Entity, velocity: Vec2): boolean {
|
||||
const body = this.dynamicBodyOf(target)?.body;
|
||||
body?.setLinvel({ x: toMetres(velocity.x), y: toMetres(velocity.y) }, true);
|
||||
return Boolean(body);
|
||||
}
|
||||
|
||||
/** Degrees per second, counted the way the object's rotation is; null as for `velocityOf`. */
|
||||
angularVelocityOf(target: Entity): number | null {
|
||||
const body = this.dynamicBodyOf(target);
|
||||
if (!body) return null;
|
||||
return body.turns ? (this.turnSign(target) * body.body.angvel() * 180) / Math.PI : 0;
|
||||
}
|
||||
|
||||
setAngularVelocity(target: Entity, degrees: number): boolean {
|
||||
const body = this.dynamicBodyOf(target);
|
||||
// With fixed rotation the engine would keep the speed and never turn: nothing to set.
|
||||
if (body?.turns) body.body.setAngvel((this.turnSign(target) * degrees * Math.PI) / 180, true);
|
||||
return Boolean(body);
|
||||
}
|
||||
|
||||
/** A kick (world units × kg per second): the velocity changes by impulse ÷ mass, at once. */
|
||||
applyImpulse(target: Entity, impulse: Vec2): boolean {
|
||||
const body = this.dynamicBodyOf(target)?.body;
|
||||
body?.applyImpulse({ x: toMetres(impulse.x), y: toMetres(impulse.y) }, true);
|
||||
return Boolean(body);
|
||||
}
|
||||
|
||||
/** The scene's gravity for the rest of this run, in world units per second squared. */
|
||||
setGravity(world: World, gravity: Vec2): void {
|
||||
world.sceneProps.gravityX = gravity.x;
|
||||
world.sceneProps.gravityY = gravity.y;
|
||||
if (this.engine) this.engine.gravity = this.gravity(world);
|
||||
}
|
||||
|
||||
/**
|
||||
* Says once per run and object what the physics did not do, in the log and the console – the
|
||||
* one place a quiet no-op gets noticed.
|
||||
*/
|
||||
hint(world: World, target: Entity, text: string): void {
|
||||
const key = `${target.id} ${text}`;
|
||||
if (this.hinted.has(key)) return;
|
||||
this.hinted.add(key);
|
||||
const line = `Physics: "${target.name}" ${text}`;
|
||||
world.log.push(line);
|
||||
console.warn(`[physics] ${line}`);
|
||||
}
|
||||
|
||||
/** The engine body of the object a graph names – the object with the rigidbody, or the rigidbody. */
|
||||
private dynamicBodyOf(target: Entity): Body | null {
|
||||
const owner = target.type === 'rigidbody' ? target.parent : target;
|
||||
if (!owner) return null;
|
||||
// On start, and an object spawned this frame, come before the engine has its body: it gets it now.
|
||||
const world = this.world;
|
||||
if (world && !this.bodies.has(owner))
|
||||
if (world.childrenOf(owner).some((e) => e.type === 'rigidbody' && isHandleEnabled(e))) this.sync(world);
|
||||
const body = this.bodies.get(owner);
|
||||
if (!body || body.kind !== 'rigid' || !body.body.isDynamic()) return false;
|
||||
body.body.setLinvel({ x: toMetres(velocity.x), y: toMetres(velocity.y) }, true);
|
||||
return true;
|
||||
return body?.kind === 'rigid' && body.body.isDynamic() && body.body.isEnabled() ? body : null;
|
||||
}
|
||||
|
||||
/** Under a mirrored parent the engine turns against the rotation property. */
|
||||
private turnSign(target: Entity): number {
|
||||
const owner = target.type === 'rigidbody' ? target.parent : target;
|
||||
return owner?.parent && determinant(worldMatrixOf(owner.parent)) < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A mover that runs into a dynamic body pushes it along its travel, 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.
|
||||
*/
|
||||
private push(world: World, mover: Entity, { by, along, distance }: Blocked): void {
|
||||
const part = [...this.parts.values()].find((p) => p.entity === by);
|
||||
const body = part?.owner.body;
|
||||
if (!body || part.owner.kind !== 'rigid' || !body.isDynamic() || !body.isEnabled()) return;
|
||||
const v = body.linvel();
|
||||
const closing = toMetres(distance / this.frameDt) - (v.x * along.x + v.y * along.y);
|
||||
const m = this.massOf(world, mover);
|
||||
const M = body.mass();
|
||||
if (closing <= 0 || m <= 0 || M <= 0) return;
|
||||
const impulse = ((m * M) / (m + M)) * closing;
|
||||
body.applyImpulse({ x: impulse * along.x, y: impulse * along.y }, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a moved object weighs against what it pushes: a dynamic body what the engine says, else
|
||||
* the mass of its rigidbody, else what its colliders weigh by their density.
|
||||
*/
|
||||
private massOf(world: World, entity: Entity): number {
|
||||
const own = this.bodies.get(entity);
|
||||
if (own?.kind === 'rigid' && own.body.isDynamic()) return own.body.mass();
|
||||
const rigidbody = world.childrenOf(entity).find((e) => e.type === 'rigidbody' && isHandleEnabled(e));
|
||||
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,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
private gravity(world: World): Vec2 {
|
||||
@@ -197,7 +304,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
body = this.create(want, key);
|
||||
this.bodies.set(key, body);
|
||||
}
|
||||
this.place(body, want, key);
|
||||
this.place(body, want, key, world);
|
||||
this.build(body, want, key, world);
|
||||
}
|
||||
}
|
||||
@@ -278,7 +385,7 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
* Puts the body where its object is. A dynamic body is only moved when something else moved the
|
||||
* object since we last wrote it – that is a teleport, and the body keeps its velocity (#146, 3).
|
||||
*/
|
||||
private place(body: Body, want: Wanted, key: Entity): void {
|
||||
private place(body: Body, want: Wanted, key: Entity, world: World): void {
|
||||
const settings = want.settings;
|
||||
const settingsKey = settings ? JSON.stringify(settings) + hasTransformProps(key.type) : '';
|
||||
if (settings && settingsKey !== body.settingsKey) {
|
||||
@@ -296,6 +403,13 @@ export class PhysicsSystem implements System, CollisionSource {
|
||||
body.body.setNextKinematicRotation(pose.angle);
|
||||
return;
|
||||
}
|
||||
if (body.body.isDynamic())
|
||||
this.hint(
|
||||
world,
|
||||
key,
|
||||
'has a dynamic rigidbody and was moved directly; it keeps its velocity. ' +
|
||||
'Apply force, Apply impulse and Set velocity move it with the physics.',
|
||||
);
|
||||
body.body.setTranslation(t, true);
|
||||
body.body.setRotation(pose.angle, true);
|
||||
body.previous = pose;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
GraphInterpreter,
|
||||
isHandleEnabled,
|
||||
type NodeContext,
|
||||
type PhysicsControl,
|
||||
type SharedState,
|
||||
startStates,
|
||||
type TraceListener,
|
||||
@@ -61,6 +62,9 @@ interface StateChange {
|
||||
source: Instance | null;
|
||||
}
|
||||
|
||||
const NO_DYNAMIC_BODY =
|
||||
'has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.';
|
||||
|
||||
/** How often state events may cause the next move before the run is called endless. */
|
||||
const MAX_STATE_ROUNDS = 100;
|
||||
|
||||
@@ -565,6 +569,10 @@ export class BehaviorSystem implements System {
|
||||
found.y += dy;
|
||||
return { blockedX: false, blockedY: false };
|
||||
},
|
||||
physics:
|
||||
this.collisions instanceof PhysicsSystem
|
||||
? physicsControl(world, this.collisions, resolve)
|
||||
: undefined,
|
||||
log: (message) => {
|
||||
world.log.push(message);
|
||||
// The `Log` node's whole purpose: the author wants this in the browser console.
|
||||
@@ -599,3 +607,29 @@ export class BehaviorSystem implements System {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
|
||||
function physicsControl(
|
||||
world: World,
|
||||
physics: PhysicsSystem,
|
||||
resolve: (handle: EntityHandle | null) => Entity | undefined,
|
||||
): PhysicsControl {
|
||||
const act = (handle: EntityHandle, action: (entity: Entity) => boolean): boolean => {
|
||||
const found = resolve(handle);
|
||||
if (!found) return false;
|
||||
if (action(found)) return true;
|
||||
physics.hint(world, found, NO_DYNAMIC_BODY);
|
||||
return false;
|
||||
};
|
||||
return {
|
||||
velocityOf: (handle) => {
|
||||
const found = resolve(handle);
|
||||
const v = found ? physics.velocityOf(found) : null;
|
||||
return found && v ? { ...v, angular: physics.angularVelocityOf(found) ?? 0 } : null;
|
||||
},
|
||||
setVelocity: (handle, x, y) => act(handle, (e) => physics.setVelocity(e, { x, y })),
|
||||
setAngularVelocity: (handle, degrees) => act(handle, (e) => physics.setAngularVelocity(e, degrees)),
|
||||
applyImpulse: (handle, x, y) => act(handle, (e) => physics.applyImpulse(e, { x, y })),
|
||||
setGravity: (x, y) => physics.setGravity(world, { x, y }),
|
||||
};
|
||||
}
|
||||
@@ -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