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
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1052 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1055 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -82,6 +82,21 @@ export interface SharedState {
|
||||
states: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a graph can do to the played game's physics (#147). The object is the one a rigidbody sits
|
||||
* on, or the rigidbody itself; only a dynamic body is acted on or read, and each call answers
|
||||
* whether there was one. Vectors are world units, turning is degrees the way rotation counts.
|
||||
*/
|
||||
export interface PhysicsControl {
|
||||
velocityOf(target: EntityHandle): { x: number; y: number; angular: number } | null;
|
||||
setVelocity(target: EntityHandle, x: number, y: number): boolean;
|
||||
setAngularVelocity(target: EntityHandle, degrees: number): boolean;
|
||||
/** World units × kg per second: the velocity changes by impulse ÷ mass. */
|
||||
applyImpulse(target: EntityHandle, x: number, y: number): boolean;
|
||||
/** World units per second squared, for the rest of the run. */
|
||||
setGravity(x: number, y: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the graph does to the world goes through here, so the core stays free of runtime
|
||||
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
|
||||
@@ -121,6 +136,8 @@ export interface NodeContext {
|
||||
* axis (x first, then y) so it slides along walls.
|
||||
*/
|
||||
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
|
||||
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||||
physics?: PhysicsControl;
|
||||
log(message: string): void;
|
||||
/**
|
||||
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asEntity, asNumber } from '../../values';
|
||||
import { exec, num, obj, withDefault } from '../shared';
|
||||
|
||||
const PLAY_ONLY =
|
||||
'Acts on a dynamic rigidbody while the game is played; the editor run does not simulate, and an ' +
|
||||
'object without one is left alone (the console says so once).';
|
||||
|
||||
export const ApplyForce: NodeDef = {
|
||||
type: 'ApplyForce',
|
||||
label: 'Apply force',
|
||||
category: 'action',
|
||||
description:
|
||||
'Pushes an object with a force (world units × kg per second²) for the length of this frame, so ' +
|
||||
'from On update it pushes steadily whatever the frame rate. A heavier body speeds up less. ' +
|
||||
PLAY_ONLY,
|
||||
inputs: [exec(), obj('object'), num('x'), num('y')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) ctx.physics?.applyImpulse(target, asNumber(inputs.x) * ctx.dt, asNumber(inputs.y) * ctx.dt);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const ApplyImpulse: NodeDef = {
|
||||
type: 'ApplyImpulse',
|
||||
label: 'Apply impulse',
|
||||
category: 'action',
|
||||
description:
|
||||
'Kicks an object at once (world units × kg per second): its velocity changes by impulse ÷ mass – ' +
|
||||
'a jump, a hit, an explosion. ' +
|
||||
PLAY_ONLY,
|
||||
inputs: [exec(), obj('object'), num('x'), num('y')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) ctx.physics?.applyImpulse(target, asNumber(inputs.x), asNumber(inputs.y));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const SetVelocity: NodeDef = {
|
||||
type: 'SetVelocity',
|
||||
label: 'Set velocity',
|
||||
category: 'action',
|
||||
description:
|
||||
'Sets how fast an object moves, in world units per second, whatever it weighs. To change one ' +
|
||||
'direction only, feed the other from Get velocity. ' +
|
||||
PLAY_ONLY,
|
||||
inputs: [exec(), obj('object'), num('x'), num('y')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) ctx.physics?.setVelocity(target, asNumber(inputs.x), asNumber(inputs.y));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const SetAngularVelocity: NodeDef = {
|
||||
type: 'SetAngularVelocity',
|
||||
label: 'Set angular velocity',
|
||||
category: 'action',
|
||||
description:
|
||||
'Sets how fast an object turns, in degrees per second, counted like its rotation. A body with ' +
|
||||
'Fixed rotation does not turn. ' +
|
||||
PLAY_ONLY,
|
||||
inputs: [exec(), obj('object'), num('speed', 'degrees/s')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) ctx.physics?.setAngularVelocity(target, asNumber(inputs.speed));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const GetVelocity: NodeDef = {
|
||||
type: 'GetVelocity',
|
||||
label: 'Get velocity',
|
||||
category: 'data',
|
||||
description:
|
||||
'How fast an object moves (world units per second) and turns (degrees per second) while the game ' +
|
||||
'is played; 0 for an object without a dynamic rigidbody, and in the editor run.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [num('x'), num('y'), num('angular', 'degrees/s')],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
const v = (target && ctx.physics?.velocityOf(target)) || { x: 0, y: 0, angular: 0 };
|
||||
return { outputs: { x: v.x, y: v.y, angular: v.angular } };
|
||||
},
|
||||
};
|
||||
|
||||
export const SetGravity: NodeDef = {
|
||||
type: 'SetGravity',
|
||||
label: 'Set gravity',
|
||||
category: 'action',
|
||||
description:
|
||||
"Changes the scene's gravity (world units per second²; y points down) for the rest of the run – " +
|
||||
'the next scene starts with its own. Only dynamic rigidbodies feel it, times their Gravity scale.',
|
||||
inputs: [exec(), withDefault(num('x'), 0), withDefault(num('y'), 980)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.physics?.setGravity(asNumber(inputs.x), asNumber(inputs.y));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -5,6 +5,14 @@ import { Emit } from './action/Emit';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
import { Log } from './action/Log';
|
||||
import { Move } from './action/Move';
|
||||
import {
|
||||
ApplyForce,
|
||||
ApplyImpulse,
|
||||
GetVelocity,
|
||||
SetAngularVelocity,
|
||||
SetGravity,
|
||||
SetVelocity,
|
||||
} from './action/physicsNodes';
|
||||
import { PlaySound } from './action/PlaySound';
|
||||
import { RestartGame } from './action/RestartGame';
|
||||
import { SetPosition } from './action/SetPosition';
|
||||
@@ -204,6 +212,12 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
IsTouching,
|
||||
Move,
|
||||
SetPosition,
|
||||
GetVelocity,
|
||||
ApplyForce,
|
||||
ApplyImpulse,
|
||||
SetVelocity,
|
||||
SetAngularVelocity,
|
||||
SetGravity,
|
||||
Spawn,
|
||||
Destroy,
|
||||
Emit,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type GraphLookup,
|
||||
NO_LOOKUP,
|
||||
type NodeContext,
|
||||
type PhysicsControl,
|
||||
resolveDef,
|
||||
type RunResult,
|
||||
type Value,
|
||||
@@ -86,6 +87,26 @@ const emitTarget = fakeEntity('Sparks');
|
||||
const dancer = fakeEntity('Dancer');
|
||||
const painted = fakeHandle({ width: 100, text: '' });
|
||||
|
||||
type PhysicsDouble = PhysicsControl & { calls: unknown[][] };
|
||||
/** Records what the physics nodes ask for; "crate" is the one object with a dynamic body. */
|
||||
const physicsDouble = (): PhysicsDouble => {
|
||||
const calls: unknown[][] = [];
|
||||
const has = (target: { id: string }) => target.id === 'crate';
|
||||
return {
|
||||
calls,
|
||||
velocityOf: (target) => (has(target) ? { x: 12, y: -3, angular: 45 } : null),
|
||||
setVelocity: (target, x, y) => (calls.push(['setVelocity', target.id, x, y]), has(target)),
|
||||
setAngularVelocity: (target, degrees) => (
|
||||
calls.push(['setAngularVelocity', target.id, degrees]),
|
||||
has(target)
|
||||
),
|
||||
applyImpulse: (target, x, y) => (calls.push(['applyImpulse', target.id, x, y]), has(target)),
|
||||
setGravity: (x, y) => void calls.push(['setGravity', x, y]),
|
||||
};
|
||||
};
|
||||
const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls;
|
||||
const crate = fakeEntity('crate');
|
||||
|
||||
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||||
pointer: { ...NO_POINTER, ...overrides },
|
||||
});
|
||||
@@ -832,6 +853,82 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'no object is a no-op', inputs: { object: null, x: 1, y: 1 }, next: 'exec' },
|
||||
],
|
||||
ApplyForce: [
|
||||
{
|
||||
name: 'pushes for the length of the frame: force × dt, handed on as an impulse',
|
||||
inputs: { object: crate, x: 10, y: -4 },
|
||||
context: { physics: physicsDouble() },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['applyImpulse', 'crate', 5, -2]]),
|
||||
},
|
||||
{
|
||||
name: 'no object asks nothing and continues',
|
||||
inputs: { object: null, x: 10, y: 0 },
|
||||
context: { physics: physicsDouble() },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(physicsCalls(ctx)).toEqual([]),
|
||||
},
|
||||
{
|
||||
name: 'without physics (the editor run) it continues',
|
||||
inputs: { object: crate, x: 1, y: 1 },
|
||||
next: 'exec',
|
||||
},
|
||||
],
|
||||
ApplyImpulse: [
|
||||
{
|
||||
name: 'kicks the object with the impulse as given',
|
||||
inputs: { object: crate, x: 3, y: -4 },
|
||||
context: { physics: physicsDouble() },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['applyImpulse', 'crate', 3, -4]]),
|
||||
},
|
||||
{ name: 'without physics it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec' },
|
||||
],
|
||||
SetVelocity: [
|
||||
{
|
||||
name: 'sets the velocity as given',
|
||||
inputs: { object: crate, x: 120, y: -30 },
|
||||
context: { physics: physicsDouble() },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setVelocity', 'crate', 120, -30]]),
|
||||
},
|
||||
{ name: 'without physics it continues', inputs: { object: crate, x: 1, y: 1 }, next: 'exec' },
|
||||
],
|
||||
SetAngularVelocity: [
|
||||
{
|
||||
name: 'sets the turning speed in degrees per second',
|
||||
inputs: { object: crate, speed: 90 },
|
||||
context: { physics: physicsDouble() },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setAngularVelocity', 'crate', 90]]),
|
||||
},
|
||||
{ name: 'without physics it continues', inputs: { object: crate, speed: 90 }, next: 'exec' },
|
||||
],
|
||||
GetVelocity: [
|
||||
{
|
||||
name: 'reads velocity and turning speed',
|
||||
inputs: { object: crate },
|
||||
context: { physics: physicsDouble() },
|
||||
outputs: { x: 12, y: -3, angular: 45 },
|
||||
},
|
||||
{
|
||||
name: 'an object without a dynamic body reads 0',
|
||||
inputs: { object: fakeEntity('wall') },
|
||||
context: { physics: physicsDouble() },
|
||||
outputs: { x: 0, y: 0, angular: 0 },
|
||||
},
|
||||
{ name: 'without physics it reads 0', inputs: { object: crate }, outputs: { x: 0, y: 0, angular: 0 } },
|
||||
],
|
||||
SetGravity: [
|
||||
{
|
||||
name: 'sets the scene gravity',
|
||||
inputs: { x: 0, y: -500 },
|
||||
context: { physics: physicsDouble() },
|
||||
next: 'exec',
|
||||
after: (ctx) => expect(physicsCalls(ctx)).toEqual([['setGravity', 0, -500]]),
|
||||
},
|
||||
{ name: 'without physics it continues', inputs: { x: 0, y: 1 }, next: 'exec' },
|
||||
],
|
||||
Spawn: [
|
||||
{
|
||||
name: 'no asset chosen logs a hint and continues with no object',
|
||||
|
||||
@@ -484,3 +484,190 @@ describe('collision events from the engine', () => {
|
||||
expect(entity(box).y).toBeGreaterThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
/** A behavior on `owner` that runs one node when `event` fires, on the owner unless `untargeted`. */
|
||||
function onOwner(
|
||||
f: ReturnType<typeof physicsScene>,
|
||||
owner: string,
|
||||
event: 'OnStart' | 'OnUpdate',
|
||||
type: string,
|
||||
params: Record<string, PropertyValue> = {},
|
||||
untargeted = false,
|
||||
) {
|
||||
const behavior = makeBehavior(f, `${type} on ${owner}`);
|
||||
place(f, behavior.id, { x: 0, y: 0 }, owner);
|
||||
const edit = graphEditor(f, behavior);
|
||||
const on = edit.addNode(event);
|
||||
const node = edit.addNode(type, params);
|
||||
const self = edit.addNode('Owner');
|
||||
edit.connect(on, 'exec', node, 'exec');
|
||||
if (!untargeted) edit.connect(self, 'object', node, 'object');
|
||||
return { edit, node, self };
|
||||
}
|
||||
|
||||
/** Drawn a step behind (see "falling and resting"): after one second, 59 of the 60 steps show. */
|
||||
const SHOWN = 59 / 60;
|
||||
|
||||
describe('physics from the graph (#147)', () => {
|
||||
it('Apply impulse in On start kicks by impulse ÷ mass before the first step; Get velocity reads it', () => {
|
||||
const f = physicsScene();
|
||||
f.gravity(0, 0);
|
||||
const box = f.add(0, 0, { w: 32, body: { mass: 2 } });
|
||||
const { edit, node, self } = onOwner(f, box, 'OnStart', 'ApplyImpulse', { x: 100, y: -40 });
|
||||
const read = edit.addNode('GetVelocity');
|
||||
const log = edit.addNode('Log');
|
||||
edit.wire([
|
||||
[node, 'exec', log, 'exec'],
|
||||
[self, 'object', read, 'object'],
|
||||
[read, 'x', log, 'message'],
|
||||
]);
|
||||
const { seconds, entity, physics, world } = run(f);
|
||||
expect(world.log).toHaveLength(1);
|
||||
expect(Number(world.log[0])).toBeCloseTo(50, 4);
|
||||
expect(physics.velocityOf(entity(box))!.y).toBeCloseTo(-20, 4);
|
||||
seconds(1);
|
||||
expect(entity(box).x).toBeCloseTo(50 * SHOWN, 2);
|
||||
expect(entity(box).y).toBeCloseTo(-20 * SHOWN, 2);
|
||||
});
|
||||
|
||||
it('Apply force from On update pushes as hard at any frame rate: velocity = force × time ÷ mass', () => {
|
||||
const speedAfterOneSecond = (dt: number) => {
|
||||
const f = physicsScene();
|
||||
f.gravity(0, 0);
|
||||
const box = f.add(0, 0, { w: 32, body: { mass: 2 } });
|
||||
onOwner(f, box, 'OnUpdate', 'ApplyForce', { x: 100 });
|
||||
const { frame, collisions, world } = startWorld(f, {
|
||||
physics: true,
|
||||
collisions: true,
|
||||
behaviors: true,
|
||||
});
|
||||
for (let i = 0; i < Math.round(1 / dt); i++) frame(dt);
|
||||
return (collisions as PhysicsSystem).velocityOf(world.findByObjectId(box)!)!.x;
|
||||
};
|
||||
for (const dt of [1 / 30, 1 / 60, 1 / 144]) expect(speedAfterOneSecond(dt)).toBeCloseTo(50, 3);
|
||||
});
|
||||
|
||||
it('Set velocity moves light and heavy alike; the rigidbody itself answers for its object', () => {
|
||||
const f = physicsScene();
|
||||
f.gravity(0, 0);
|
||||
const light = f.add(0, 0, { w: 32, body: { mass: 1 } });
|
||||
const heavy = f.add(0, 100, { w: 32, body: { mass: 50 } });
|
||||
for (const id of [light, heavy]) onOwner(f, id, 'OnStart', 'SetVelocity', { x: 120, y: 0 });
|
||||
const { seconds, entity, physics, world } = run(f);
|
||||
seconds(1);
|
||||
expect(entity(light).x).toBeCloseTo(120 * SHOWN, 2);
|
||||
expect(entity(heavy).x).toBeCloseTo(120 * SHOWN, 2);
|
||||
const rigidbody = world.findByObjectId(f.rigidbodyOf(heavy))!;
|
||||
expect(physics.velocityOf(rigidbody)!.x).toBeCloseTo(120, 3);
|
||||
});
|
||||
|
||||
it('Set angular velocity turns it the way rotation counts, under a mirrored parent too; not with fixed rotation', () => {
|
||||
const f = physicsScene();
|
||||
f.gravity(0, 0);
|
||||
const spinner = f.add(0, 0, { w: 32, body: {} });
|
||||
const locked = f.add(100, 0, { w: 32, body: { fixedRotation: true } });
|
||||
const mirror = f.add(0, 200, { w: 8, props: { mirrorX: true } });
|
||||
const mirrored = f.add(50, 0, { w: 32, body: {}, parent: mirror });
|
||||
for (const id of [spinner, locked, mirrored])
|
||||
onOwner(f, id, 'OnStart', 'SetAngularVelocity', { speed: 90 });
|
||||
const { seconds, entity, physics, world } = run(f);
|
||||
seconds(1);
|
||||
expect(Number(entity(spinner).props.rotation)).toBeCloseTo(90 * SHOWN, 1);
|
||||
expect(physics.angularVelocityOf(entity(spinner))).toBeCloseTo(90, 3);
|
||||
expect(Number(entity(mirrored).props.rotation)).toBeCloseTo(90 * SHOWN, 1);
|
||||
expect(physics.angularVelocityOf(entity(mirrored))).toBeCloseTo(90, 3);
|
||||
expect(Number(entity(locked).props.rotation ?? 0)).toBe(0);
|
||||
expect(physics.angularVelocityOf(entity(locked))).toBe(0);
|
||||
// Nothing was kept back for later: let go of the rotation and it still does not turn.
|
||||
world.findByObjectId(f.rigidbodyOf(locked))!.props.fixedRotation = false;
|
||||
seconds(0.5);
|
||||
expect(Number(entity(locked).props.rotation ?? 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('Set gravity turns the fall around for the rest of the run; the scene in the project keeps its own', () => {
|
||||
const f = physicsScene();
|
||||
const box = f.add(0, 0, { w: 32, body: {} });
|
||||
onOwner(f, box, 'OnStart', 'SetGravity', { x: 0, y: -980 }, true);
|
||||
const { seconds, entity, world } = run(f);
|
||||
seconds(0.5);
|
||||
expect(entity(box).y).toBeLessThan(-100);
|
||||
expect(world.sceneProps.gravityY).toBe(-980);
|
||||
expect(findAsset(f.project, f.sceneId)!.props.gravityY ?? 980).toBe(980);
|
||||
});
|
||||
|
||||
it('moving a dynamic body directly keeps its speed and says so once; a physics node on an object without one says so once', () => {
|
||||
const f = physicsScene();
|
||||
f.gravity(0, 0);
|
||||
const box = f.add(0, 0, { w: 32, body: {} });
|
||||
const plain = f.add(200, 0, { w: 32 });
|
||||
onOwner(f, box, 'OnStart', 'SetVelocity', { x: 60, y: 0 });
|
||||
onOwner(f, plain, 'OnUpdate', 'ApplyImpulse', { x: 1 });
|
||||
const { seconds, entity, physics, world } = run(f);
|
||||
seconds(0.5, () => {
|
||||
entity(box).y += 1;
|
||||
});
|
||||
expect(physics.velocityOf(entity(box))!.x).toBeCloseTo(60, 3);
|
||||
expect(entity(plain).x).toBe(200);
|
||||
expect([...world.log].sort()).toEqual([
|
||||
'Physics: "Box" has a dynamic rigidbody and was moved directly; it keeps its velocity. Apply force, Apply impulse and Set velocity move it with the physics.',
|
||||
'Physics: "Box" has no dynamic rigidbody, so Apply force, Apply impulse and Set velocity do nothing on it.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('in the editor run the physics nodes do nothing', () => {
|
||||
const f = physicsScene();
|
||||
f.gravity(0, 0);
|
||||
const box = f.add(0, 0, { w: 32, body: {} });
|
||||
onOwner(f, box, 'OnStart', 'ApplyImpulse', { x: 100 });
|
||||
onOwner(f, box, 'OnStart', 'SetGravity', { x: 0, y: -980 }, true);
|
||||
const { world } = startWorld(f, { collisions: true, behaviors: true });
|
||||
for (let i = 0; i < 30; i++) world.tick(1 / 60);
|
||||
expect(world.findByObjectId(box)!.x).toBe(0);
|
||||
expect(world.sceneProps.gravityY).toBe(0);
|
||||
expect(world.log).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Move with "Collide with solids" pushes dynamic bodies (#147)', () => {
|
||||
/** A mover walking 3 a frame (180 per second) for a second into a crate 60 ahead, both on the ground. */
|
||||
function shove(crate: Thing, mover: Thing = { w: 32 }) {
|
||||
const f = physicsScene();
|
||||
f.add(0, 200, GROUND);
|
||||
const box = f.add(0, 164, { ...crate, w: 32 });
|
||||
const walker = f.add(-60, 164, mover);
|
||||
const { seconds, entity, physics, world } = run(f);
|
||||
seconds(0.5);
|
||||
const start = entity(box).x;
|
||||
let gap = Infinity;
|
||||
const blocked: boolean[] = [];
|
||||
seconds(1, () => {
|
||||
blocked.push(physics.moveAndSlide(world, entity(walker), 3, 0).blockedX);
|
||||
gap = Math.min(gap, entity(box).x - 16 - (entity(walker).x + mover.w / 2));
|
||||
});
|
||||
return { moved: entity(box).x - start, walked: entity(walker).x + 60, gap, blocked, log: world.log };
|
||||
}
|
||||
|
||||
it('a crate as heavy as the mover goes along; the mover never gets into it', () => {
|
||||
const { moved, walked, gap, blocked } = shove({ w: 32, body: {} });
|
||||
expect(moved).toBeGreaterThan(100);
|
||||
expect(walked).toBeGreaterThan(moved);
|
||||
expect(gap).toBeGreaterThanOrEqual(0);
|
||||
expect(blocked).toContain(true);
|
||||
});
|
||||
|
||||
it('a crate forty times heavier stays put, and a static one does not move at all', () => {
|
||||
expect(shove({ w: 32, body: { mass: 4 } }).moved).toBeLessThan(1);
|
||||
const still = shove({ w: 32, body: { bodyType: 'static' } });
|
||||
expect(still.moved).toBe(0);
|
||||
expect(still.walked).toBeCloseTo(60 - 32, 6);
|
||||
});
|
||||
|
||||
it('the mass of a kinematic mover counts: it shoves the heavy crate', () => {
|
||||
const { moved, gap } = shove(
|
||||
{ w: 32, body: { mass: 4 } },
|
||||
{ w: 32, body: { bodyType: 'kinematic', mass: 40 } },
|
||||
);
|
||||
expect(moved).toBeGreaterThan(100);
|
||||
expect(gap).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,38 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/** Kicks its owner in On start, with the scene's gravity switched off first: it glides at 100 per second. */
|
||||
const KICK = 200;
|
||||
const KICKED_MASS = 2;
|
||||
const kickGraph = {
|
||||
nodes: [
|
||||
node('pk-start', 'OnStart', 0, 0),
|
||||
node('pk-gravity', 'SetGravity', 300, 0, { x: 0, y: 0 }),
|
||||
node('pk-owner', 'Owner', 300, 160),
|
||||
node('pk-kick', 'ApplyImpulse', 600, 0, { x: KICK, y: 0 }),
|
||||
],
|
||||
edges: [
|
||||
edge('pk-e1', 'pk-start', 'exec', 'pk-gravity', 'exec'),
|
||||
edge('pk-e2', 'pk-gravity', 'exec', 'pk-kick', 'exec'),
|
||||
edge('pk-e3', 'pk-owner', 'object', 'pk-kick', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/** Walks its owner right at 120 per second with "Collide with solids" on. */
|
||||
const WALK = 120;
|
||||
const walkGraph = {
|
||||
nodes: [
|
||||
node('pw-update', 'OnUpdate', 0, 0),
|
||||
node('pw-owner', 'Owner', 0, 160),
|
||||
node('pw-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }),
|
||||
],
|
||||
edges: [
|
||||
edge('pw-e1', 'pw-update', 'exec', 'pw-move', 'exec'),
|
||||
edge('pw-e2', 'pw-update', 'dt', 'pw-move', 'dt'),
|
||||
edge('pw-e3', 'pw-owner', 'object', 'pw-move', 'object'),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
|
||||
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
|
||||
@@ -187,4 +220,97 @@ export async function physics(t) {
|
||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && !gone.has(o.id));
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-col-') && !assets.has(a.id));
|
||||
__gm.state.select(null)`);
|
||||
|
||||
// 41c. The graph drives the physics (#147): a crate kicked in On start with gravity switched off
|
||||
// by Set gravity, and a walker whose Move with "Collide with solids" runs into a free crate.
|
||||
const hintsBefore = consoleLog.filter((line) => line.includes('[physics]')).length;
|
||||
await mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
const p = __gm.state.project;
|
||||
const assets = p.assets;
|
||||
${MAKE_OBJECT}
|
||||
const behavior = (id, name, graph) => {
|
||||
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
|
||||
Object.assign(asset, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
|
||||
assets.push(asset);
|
||||
};
|
||||
behavior('phys-kick', 'Physics kick', ${JSON.stringify(kickGraph)});
|
||||
behavior('phys-walk', 'Physics walk', ${JSON.stringify(walkGraph)});
|
||||
assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
|
||||
assets.push({ id: 'phys-rb-kicked', type: 'rigidbody', name: 'phys-rb-kicked', props: { bodyType: 'dynamic', mass: ${KICKED_MASS} } });
|
||||
assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } });
|
||||
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
|
||||
const o = make(id, assetId, parentId, y, name);
|
||||
o.position.x = x;
|
||||
o.overrides = overrides;
|
||||
scene.objects.push(o);
|
||||
};
|
||||
const box = { width: 40, height: 40 };
|
||||
put('phys-kicked', 'crate', '', -200, ${Y}, 'Kicked crate', box);
|
||||
put('phys-kicked-col', 'phys-col-box', 'phys-kicked', 0, 0);
|
||||
put('phys-kicked-rb', 'phys-rb-kicked', 'phys-kicked', 0, 0);
|
||||
put('phys-kicked-kick', 'phys-kick', 'phys-kicked', 0, 0);
|
||||
put('phys-walker', 'crate', '', 0, ${Y + 100}, 'Walker', box);
|
||||
put('phys-walker-col', 'phys-col-box', 'phys-walker', 0, 0);
|
||||
put('phys-walker-walk', 'phys-walk', 'phys-walker', 0, 0);
|
||||
put('phys-pushed', 'crate', '', 60, ${Y + 100}, 'Pushed crate', box);
|
||||
put('phys-pushed-col', 'phys-col-box', 'phys-pushed', 0, 0);
|
||||
put('phys-pushed-rb', 'phys-rb-free', 'phys-pushed', 0, 0);
|
||||
__gm.state.select(null)`);
|
||||
await play();
|
||||
await evaluate(`(() => {
|
||||
const out = (window.__physicsGraph = []);
|
||||
const start = performance.now();
|
||||
const sample = () => {
|
||||
const w = __gm.world();
|
||||
if (!w) return;
|
||||
const at = (id) => w.entities.find((e) => e.objectId === id);
|
||||
const kicked = at('phys-kicked');
|
||||
out.push({
|
||||
t: (performance.now() - start) / 1000,
|
||||
kickedX: kicked?.worldX,
|
||||
kickedY: kicked?.worldY,
|
||||
walker: at('phys-walker')?.worldX,
|
||||
pushed: at('phys-pushed')?.worldX,
|
||||
});
|
||||
if (performance.now() - start < 2000) requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
})()`);
|
||||
await sleep(2300);
|
||||
const run = await json('window.__physicsGraph');
|
||||
await stop();
|
||||
|
||||
const at = (seconds) =>
|
||||
run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best));
|
||||
const [early, late] = [at(0.5), at(1.5)];
|
||||
const glide = (late.kickedX - early.kickedX) / (late.t - early.t);
|
||||
const speed = KICK / KICKED_MASS;
|
||||
check(
|
||||
`Set gravity and Apply impulse in On start: the crate glides straight at impulse ÷ mass (${speed} per second)`,
|
||||
run.length > 60 &&
|
||||
Math.abs(glide - speed) < speed * 0.05 &&
|
||||
run.every((s) => Math.abs(s.kickedY - Y) < 0.5),
|
||||
JSON.stringify({ frames: run.length, glide: Math.round(glide * 10) / 10, early, late }),
|
||||
);
|
||||
const gap = Math.min(...run.map((s) => s.pushed - 20 - (s.walker + 20)));
|
||||
const pushed = run.at(-1).pushed - 60;
|
||||
check(
|
||||
'Move with "Collide with solids" pushes the crate in its way and never gets into it',
|
||||
pushed > 100 && gap > -0.01,
|
||||
JSON.stringify({
|
||||
pushed: Math.round(pushed * 10) / 10,
|
||||
walked: Math.round(run.at(-1).walker * 10) / 10,
|
||||
gap,
|
||||
}),
|
||||
);
|
||||
const hints = consoleLog.filter((line) => line.includes('[physics]')).slice(hintsBefore);
|
||||
check('nothing in the step made the physics hint at a no-op', hints.length === 0, hints.join(' | '));
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-'));
|
||||
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-'));
|
||||
__gm.state.select(null)`);
|
||||
}
|
||||
Reference in new issue
Block a user