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)
game-maker / gate (push) Successful in 1m46s
game-maker / suite (push) Failing after 5m35s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 19:19:21 +02:00
1 parent 78899b1ace
commit 0ff3c8b742
10 files changed
+783 -43

No files matched your search

+17
View File
@@ -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);
+112
View File
@@ -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' };
},
};
+14
View File
@@ -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,
+125 -11
View File
@@ -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;
+34
View File
@@ -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 }),
};
}
+70 -31
View File
@@ -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);
}