diff --git a/README.md b/README.md index 5e75632c..e43a9b9c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index b09b6710..6ae8b506 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -82,6 +82,21 @@ export interface SharedState { states: Record; } +/** + * 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); diff --git a/src/core/graph/nodes/action/physicsNodes.ts b/src/core/graph/nodes/action/physicsNodes.ts new file mode 100644 index 00000000..7cf706ce --- /dev/null +++ b/src/core/graph/nodes/action/physicsNodes.ts @@ -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' }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 8914df20..973587cb 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -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, diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 15f744ad..2852b166 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -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(); 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(); + 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; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 0c63f742..a38bedce 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -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 }), + }; +} diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index cea1e1d1..dd0045b9 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -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); } diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e28360e7..62323a00 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -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): Partial => ({ pointer: { ...NO_POINTER, ...overrides }, }); @@ -832,6 +853,82 @@ const CASES: Record = { }, { 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', diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index 693dcfb4..8b857db7 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -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, + owner: string, + event: 'OnStart' | 'OnUpdate', + type: string, + params: Record = {}, + 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); + }); +}); diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index 4a2190ab..47780690 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -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)`); }