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;
|
||||
|
||||
Reference in new issue
Block a user