One-way platforms, raycast and collision stay with contact data: a collider can be one-way with a side it blocks from (turning with its object, drawn thick in the scene view) and a tile can be one-way from above, one-way cells never merge with solid tiles, a contact blocks only when its normal is within 45 degrees of the blocking side, the new node Ignore one-way lets an object drop through for a while, and it all holds for Move and for dynamic bodies in Rapier, where the decision is made before each step because the hook may not touch the engine (#155); the new Raycast node answers hit, object, collider, point, normal and distance in our own f64 geometry, the same in editor run and play, and the editor run draws each ray for 0.25 s (#153); On collision stay gains an interval with its own clock per pair, and stay and enter gain the contact point next to normal and impulse (#149); fixes that every Rapier collider of a tilemap had the shape of its last cell
game-maker / gate (push) Successful in 1m55s
game-maker / suite (push) Successful in 6m50s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 11:36:11 +02:00
1 parent 947a6b1977
commit 6a31f5e0ad
33 files changed
+2064 -125

No files matched your search

+37 -1
View File
@@ -1,7 +1,7 @@
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import type { PropertyBag } from '../core/model/Properties';
import type { PropertyBag, Vec2 } from '../core/model/Properties';
import { isPlaceableType } from '../core/model/AssetTypes';
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
import { treeOrder } from '../core/model/hierarchy';
@@ -14,6 +14,17 @@ import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity';
import type { System } from './systems/System';
/** How long (seconds of run time) a cast ray stays drawn, and how many at most. */
export const RAY_SHOWN = 0.25;
const MAX_RAYS = 64;
export interface CastRay {
from: Vec2;
to: Vec2;
hit: boolean;
time: number;
}
export interface WorldOptions {
/**
* Whether a placed container's content may be written into the scene (see `syncFromModel`).
@@ -63,6 +74,10 @@ export class World {
};
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
/** The rays cast lately (#153), for the editor run to draw: where from, where to, whether they met anything. */
readonly rays: CastRay[] = [];
/** Until when (run time) an object falls through one-way colliders and tiles – `Ignore one-way` (#155). */
private readonly ignoringOneWay = new Map<Entity, number>();
private readonly systems: System[] = [];
private elapsed = 0;
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
@@ -117,6 +132,27 @@ export class World {
}
}
/** Remembers a ray for a moment, forgetting those cast longer ago than `RAY_SHOWN`. */
noteRay(from: Vec2, to: Vec2, hit: boolean): void {
const since = this.time - RAY_SHOWN;
const kept = this.rays.filter((ray) => ray.time >= since);
this.rays.splice(0, this.rays.length, ...kept.slice(-(MAX_RAYS - 1)), { from, to, hit, time: this.time });
}
/** Lets the object, and anything below it, fall through one-way colliders for `seconds` from now. */
ignoreOneWay(entity: Entity, seconds: number): void {
this.ignoringOneWay.set(entity, this.time + Math.max(0, seconds));
}
/** Whether one-way colliders let the entity through right now, asked of it or any object above it. */
ignoresOneWay(entity: Entity): boolean {
for (let e: Entity | null = entity; e; e = e.parent) {
const until = this.ignoringOneWay.get(e);
if (until !== undefined && until > this.time) return true;
}
return false;
}
/** Sets a scene property for the rest of the run; the scene in the model keeps its own. */
setSceneProp(key: string, value: PropertyBag[string]): void {
this.sceneProps[key] = value;
+192 -22
View File
@@ -2,6 +2,7 @@ import type {
Collider,
ColliderDesc,
EventQueue,
PhysicsHooks,
RigidBody,
World as RapierWorld,
} from '@dimforge/rapier2d-compat';
@@ -10,7 +11,16 @@ import { apply, determinant, IDENTITY, invert, type Mat } from '../../core/model
import type { Vec2 } from '../../core/model/Properties';
import { signedArea } from '../../core/model/polygon';
import { parentChain } from '../../core/model/walk';
import { type ColliderShape, isTrigger, shapeArea, shapesOverlap } from '../../core/properties/collider';
import {
type ColliderShape,
contactBetween,
isTrigger,
landsOn,
movedShape,
piecesOf,
shapeArea,
shapesOverlap,
} from '../../core/properties/collider';
import { hasTransformProps } from '../../core/properties/PropertySchema';
import {
type BodyType,
@@ -27,9 +37,13 @@ import {
bodyOf,
type CollisionPair,
type CollisionSource,
type Contact,
type MoveResult,
partsOf,
type RayFilter,
type RayResult,
shapeOf,
type SolidShape,
Solids,
} from '../systems/solids';
import type { System } from '../systems/System';
@@ -41,6 +55,11 @@ export const PHYSICS_STEP = 1 / 60;
const MAX_STEPS_PER_TICK = 8;
/** A pose that moved less than this (world units, radians) was not moved by anyone. */
const POSE_EPSILON = 1e-6;
/**
* How deep (world units) a body may sit in a one-way collider and still count as on top of it: the
* engine lets a landing sink in a little before it pushes it out (1.9 px measured at 60 Hz, #155).
*/
const ONE_WAY_SLOP = 2;
interface Pose {
x: number;
@@ -56,9 +75,10 @@ interface Pose {
interface Body {
kind: 'rigid' | 'free' | 'tilemap';
body: RigidBody;
/** The engine's colliders – several for a polygon, one per convex piece – and whose each is. */
/** The engine's colliders – several for a polygon, one per convex piece – whose each is, and its shape. */
colliders: Collider[];
sources: Entity[];
solids: SolidShape[];
/** What the colliders were built from; they are built again when it changes (a scaled object). */
shapeKey: string;
settingsKey: string;
@@ -73,10 +93,14 @@ interface Body {
current: Pose;
}
/** An engine collider: the entity it stands for and its world shape at the last sync. */
/**
* An engine collider: the entity it stands for, its world shape at the last sync and, for a one-way
* one, the side it blocks from (#155).
*/
interface Part {
entity: Entity;
shape: ColliderShape;
facing: Vec2 | null;
owner: Body;
}
@@ -101,6 +125,14 @@ export class PhysicsSystem implements System, CollisionSource {
/** Collider handle pairs the engine reports in contact or intersecting, keyed `h1|h2`. */
private readonly candidates = new Map<string, [number, number]>();
private current = new Map<string, CollisionPair>();
/** The engine colliders each current pair was found with, `a`'s first – where its contact is read. */
private pairColliders = new Map<string, [number, number]>();
/**
* Pairs of a dynamic body and a one-way collider near each other, keyed `h1|h2`: whether the body
* passes through (#155). Decided before each step, since the engine's hook must not touch the engine.
*/
private passes = new Map<string, boolean>();
private hooks: PhysicsHooks | null = null;
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. */
@@ -116,6 +148,12 @@ export class PhysicsSystem implements System, CollisionSource {
this.engine = engine;
this.queue = new this.rapier.EventQueue(true);
this.world = world;
const { SolverFlags } = this.rapier;
this.hooks = {
filterContactPair: (h1, h2) =>
this.passes.get(handlePairKey(h1, h2)) ? SolverFlags.EMPTY : SolverFlags.COMPUTE_IMPULSE,
filterIntersectionPair: () => true,
};
}
stop(): void {
@@ -128,6 +166,9 @@ export class PhysicsSystem implements System, CollisionSource {
this.parts.clear();
this.candidates.clear();
this.current = new Map();
this.pairColliders = new Map();
this.passes = new Map();
this.hooks = null;
this.enters = [];
this.stays = [];
this.exits = [];
@@ -150,7 +191,8 @@ export class PhysicsSystem implements System, CollisionSource {
const entered = new Set<string>();
while (this.backlog >= PHYSICS_STEP - 1e-9 && steps < MAX_STEPS_PER_TICK) {
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.previous = body.current;
engine.step(this.queue!);
this.decideOneWay(world);
engine.step(this.queue!, this.hooks ?? undefined);
for (const body of this.bodies.values()) if (body.body.isDynamic()) body.current = poseOf(body.body);
this.collect(world, entered);
this.backlog -= PHYSICS_STEP;
@@ -177,6 +219,17 @@ export class PhysicsSystem implements System, CollisionSource {
return this.solids.moveAndSlide(world, entity, dx, dy, (blocked) => this.push(world, entity, blocked));
}
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
return this.solids.raycast(world, from, direction, length, filter);
}
/** How a current pair touches as of the last step: read from the engine's contact. */
contactOf(pair: CollisionPair): Contact | null {
const handles = this.pairColliders.get(`${pair.a.id}|${pair.b.id}`);
if (!handles || !this.parts.has(handles[0]) || !this.parts.has(handles[1])) return null;
return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1]));
}
/** A dynamic body's velocity in world units per second; null for an object the engine does not move. */
velocityOf(target: Entity): Vec2 | null {
const body = this.dynamicBodyOf(target)?.body;
@@ -355,6 +408,7 @@ export class PhysicsSystem implements System, CollisionSource {
body: this.engine!.createRigidBody(desc),
colliders: [],
sources: [],
solids: [],
shapeKey: '',
settingsKey: '',
turns: false,
@@ -437,12 +491,13 @@ export class PhysicsSystem implements System, CollisionSource {
private build(body: Body, want: Wanted, key: Entity, world: World): void {
const shapes = this.shapesWanted(want, key, world);
const origin = body.placed;
const local = shapes.map(({ entity, shape }) => ({ entity, shape, pieces: piecesInBody(origin, shape) }));
const local = shapes.map((solid) => ({ ...solid, pieces: piecesInBody(origin, solid.shape) }));
const shapeKey =
JSON.stringify(
local.map(({ entity, pieces }) => [
local.map(({ entity, pieces, facing }) => [
entity.id,
pieces.map(pieceKey),
facing && [round(facing.x), round(facing.y)],
entity.type === 'collider' ? [isTrigger(entity.props), colliderMaterial(entity.props)] : 0,
]),
) + JSON.stringify(want.settings?.mass ?? 0);
@@ -454,27 +509,31 @@ export class PhysicsSystem implements System, CollisionSource {
}
body.colliders = [];
body.sources = [];
body.solids = [];
const mass = want.settings?.mass ?? 0;
const area = local.reduce(
(sum, { entity, shape }) => sum + (isSolid(entity) ? shapeArea(shape) : 0),
0,
);
for (const { entity, shape, pieces } of local)
for (const { entity, shape, facing, pieces } of local)
for (const piece of pieces) {
const share = mass > 0 && area > 0 ? (mass * pieceArea(piece)) / area : 0;
const desc = this.colliderDesc(entity, piece, share);
const desc = this.colliderDesc(entity, piece, share, facing !== null);
if (!desc) continue;
const collider = this.engine!.createCollider(desc, body.body);
body.colliders.push(collider);
body.sources.push(entity);
this.parts.set(collider.handle, { entity, shape, owner: body });
body.solids.push({ shape, facing });
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
}
}
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them.
const shapeOfEntity = new Map(local.map(({ entity, shape }) => [entity, shape]));
// Shapes are refreshed every tick even when the colliders stay: the overlap test reads them. A
// tilemap's many shapes all stand for the one map, so its colliders keep the shape they were built from.
const solidOfEntity = new Map(local.map((solid) => [solid.entity, solid]));
body.colliders.forEach((collider, i) => {
const entity = body.sources[i]!;
this.parts.set(collider.handle, { entity, shape: shapeOfEntity.get(entity)!, owner: body });
const { shape, facing } = body.kind === 'tilemap' ? body.solids[i]! : solidOfEntity.get(entity)!;
this.parts.set(collider.handle, { entity, shape, facing, owner: body });
const on = isHandleEnabled(entity);
if (collider.isEnabled() === on) return;
collider.setEnabled(on);
@@ -491,14 +550,16 @@ export class PhysicsSystem implements System, CollisionSource {
}
}
private shapesWanted(want: Wanted, key: Entity, world: World): { entity: Entity; shape: ColliderShape }[] {
private shapesWanted(want: Wanted, key: Entity, world: World): (SolidShape & { entity: Entity })[] {
if (want.kind === 'tilemap')
return this.solids.shapesOf(world, key).map((shape) => ({ entity: key, shape }));
return want.colliders.map((entity) => ({ entity, shape: shapeOf(entity) }));
return this.solids.solidShapesOf(world, key).map((solid) => ({ ...solid, entity: key }));
return want.colliders.flatMap((entity) =>
this.solids.solidShapesOf(world, entity).map((solid) => ({ ...solid, entity })),
);
}
/** The engine collider of one piece; null for a piece of no size, which the engine would refuse. */
private colliderDesc(entity: Entity, piece: Piece, mass: number): ColliderDesc | null {
private colliderDesc(entity: Entity, piece: Piece, mass: number, oneWay: boolean): ColliderDesc | null {
const R = this.rapier;
if (pieceArea(piece) <= 0) return null;
let desc: ColliderDesc | null;
@@ -531,9 +592,80 @@ export class PhysicsSystem implements System, CollisionSource {
else desc.setDensity(material.density);
desc.setActiveEvents(R.ActiveEvents.COLLISION_EVENTS);
desc.setActiveCollisionTypes(R.ActiveCollisionTypes.ALL);
if (oneWay && isSolid(entity)) desc.setActiveHooks(R.ActiveHooks.FILTER_CONTACT_PAIRS);
return desc;
}
/**
* Whether each dynamic body near a one-way collider passes through it in the coming step (#155).
* It lands when it is on the blocking side (or sunk in no deeper than a landing sinks), the contact
* faces that side within 45° and it is not moving away from it; a body that landed is held as long
* as the two overlap, however deep a fast fall took it in one step. Otherwise it passes – from
* below, from the side, from inside – and so does a body told to ignore one-way colliders. One that
* is to pass is woken: a resting body sleeps, and a new answer alone would not move it.
*/
private decideOneWay(world: World): void {
const platforms: [number, Part][] = [];
const movers: [number, Part][] = [];
for (const [handle, part] of this.parts) {
if (!isHandleEnabled(part.entity) || !isSolid(part.entity)) continue;
if (part.facing) platforms.push([handle, part]);
else if (part.owner.kind === 'rigid' && part.owner.body.isDynamic()) movers.push([handle, part]);
}
const decided = new Map<string, boolean>();
for (const [hp, platform] of platforms) {
const surface = this.shapeNow(platform);
const facing = this.facingNow(platform);
const vp = platform.owner.body.linvel();
for (const [hm, mover] of movers) {
if (mover.owner === platform.owner) continue;
const shape = this.shapeNow(mover);
const vm = mover.owner.body.linvel();
const relative = { x: toPixels(vm.x - vp.x), y: toPixels(vm.y - vp.y) };
const margin = Math.hypot(relative.x, relative.y) * PHYSICS_STEP * 2 + 16;
if (Math.hypot(shape.x - surface.x, shape.y - surface.y) > reachOf(shape) + reachOf(surface) + margin)
continue;
const key = handlePairKey(hp, hm);
const contact = contactBetween(shape, surface, relative);
const lands =
contact.separation >= -ONE_WAY_SLOP &&
landsOn(contact.normal, facing) &&
relative.x * facing.x + relative.y * facing.y <= 1e-3;
const held = this.passes.get(key) === false && contact.separation < 0;
const passes = world.ignoresOneWay(mover.entity) || (!lands && !held);
if (passes && mover.owner.body.isSleeping()) mover.owner.body.wakeUp();
decided.set(key, passes);
}
}
this.passes = decided;
}
/** A part's shape where the engine has its body now: a dynamic body has moved on since the sync. */
private shapeNow(part: Part): ColliderShape {
const body = part.owner;
if (!body.body.isDynamic()) return part.shape;
const from = body.placed;
const to = poseOf(body.body);
const turn = to.angle - from.angle;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
const move = (p: Vec2): Vec2 => {
const dx = p.x - from.x;
const dy = p.y - from.y;
return { x: to.x + dx * cos - dy * sin, y: to.y + dx * sin + dy * cos };
};
return movedShape(part.shape, move, turn);
}
private facingNow(part: Part): Vec2 {
const facing = part.facing!;
if (!part.owner.body.isDynamic()) return facing;
const turn = part.owner.body.rotation() - part.owner.placed.angle;
const cos = Math.cos(turn);
const sin = Math.sin(turn);
return { x: facing.x * cos - facing.y * sin, y: facing.x * sin + facing.y * cos };
}
/**
* The pairs in contact after a step, compared with those before it. The engine proposes the
* pairs; where no dynamic body takes part, the overlap test decides as it always did.
@@ -571,32 +703,58 @@ export class PhysicsSystem implements System, CollisionSource {
now.set(pairKey, { a: p.entity, b: q.entity });
first.set(pairKey, [this.engine!.getCollider(c1), this.engine!.getCollider(c2)]);
}
this.pairColliders = new Map();
for (const [key, pair] of now) {
const [c1, c2] = first.get(key)!;
this.pairColliders.set(key, [c1.handle, c2.handle]);
if (this.current.has(key)) {
now.set(key, this.current.get(key)!);
continue;
}
const [c1, c2] = first.get(key)!;
this.enters.push(Object.assign(pair, this.contactOf(c1, c2)));
this.enters.push(Object.assign(pair, this.measure(c1, c2)));
entered.add(key);
}
for (const [key, pair] of this.current) if (!now.has(key)) this.exits.push(pair);
this.current = now;
}
/** How the pair met: the normal from the second collider towards the first, and the impulse. */
private contactOf(c1: Collider, c2: Collider): { normal: Vec2; impulse: number } {
/**
* How the pair touches: the normal from the second collider towards the first, the impulse of the
* last step and the middle of the contact points (world units; zero without a contact).
*/
private measure(c1: Collider, c2: Collider): Contact {
let normal = { x: 0, y: 0 };
let impulse = 0;
let sum = { x: 0, y: 0 };
let points = 0;
this.engine!.contactPair(c1, c2, (manifold, flipped) => {
if (manifold.numContacts() === 0) return;
const n = manifold.normal();
// The manifold's normal runs from its first collider to its second; ours runs back to `a`.
const sign = flipped ? 1 : -1;
if (normal.x === 0 && normal.y === 0) normal = { x: sign * n.x, y: sign * n.y };
for (let i = 0; i < manifold.numContacts(); i++) impulse += manifold.contactImpulse(i);
// Each of its points has a side on either collider, in that collider's space: the contact
// point is the middle of the two, which is also where two bodies that sank in meet.
const [first, second] = flipped ? [c2, c1] : [c1, c2];
const toWorld = (collider: Collider, p: { x: number; y: number }) => {
const at = collider.translation();
const cos = Math.cos(collider.rotation());
const sin = Math.sin(collider.rotation());
return { x: at.x + p.x * cos - p.y * sin, y: at.y + p.x * sin + p.y * cos };
};
for (let i = 0; i < manifold.numContacts(); i++) {
impulse += manifold.contactImpulse(i);
const p1 = manifold.localContactPoint1(i);
const p2 = manifold.localContactPoint2(i);
if (!p1 || !p2) continue;
const a = toWorld(first, p1);
const b = toWorld(second, p2);
sum = { x: sum.x + (a.x + b.x) / 2, y: sum.y + (a.y + b.y) / 2 };
points++;
}
});
return { normal, impulse: toPixels(impulse) };
const point = points > 0 ? { x: toPixels(sum.x / points), y: toPixels(sum.y / points) } : { x: 0, y: 0 };
return { normal, impulse: toPixels(impulse), point };
}
/**
@@ -628,6 +786,18 @@ interface Wanted {
colliders: Entity[];
}
function handlePairKey(h1: number, h2: number): string {
return h1 < h2 ? `${h1}|${h2}` : `${h2}|${h1}`;
}
/** How far the shape reaches from its centre. */
function reachOf(shape: ColliderShape): number {
let reach = 0;
for (const { points, r } of piecesOf(shape))
for (const p of points) reach = Math.max(reach, Math.hypot(p.x - shape.x, p.y - shape.y) + r);
return reach;
}
function poseOf(body: RigidBody): Pose {
const t = body.translation();
return { x: toPixels(t.x), y: toPixels(t.y), angle: body.rotation() };
+84 -24
View File
@@ -18,7 +18,7 @@ import {
} from '../../core/graph';
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { graphLookup } from '../../core/model/graphLookup';
import { type PropertyBag, textOf } from '../../core/model/Properties';
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties';
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
import { soundFileOf } from '../../core/model/sound';
import { parentChain } from '../../core/model/walk';
@@ -30,7 +30,7 @@ import type { World } from '../World';
import type { Rapier } from '../physics/engine';
import { PhysicsSystem } from '../physics/PhysicsSystem';
import { CollisionSystem } from './CollisionSystem';
import { bodyOf, type CollisionPair, type CollisionSource, NOT_BLOCKED } from './solids';
import { bodyOf, type CollisionPair, type CollisionSource, type Contact, NOT_BLOCKED } from './solids';
import { AnimationSystem } from './AnimationSystem';
import type { Input } from './Input';
import type { System } from './System';
@@ -68,6 +68,13 @@ const NO_DYNAMIC_BODY =
/** How often state events may cause the next move before the run is called endless. */
const MAX_STATE_ROUNDS = 100;
/** A collision pair seen from one behavior's body: its own collider and the one across. */
interface Side {
pair: CollisionPair;
own: Entity;
other: Entity;
}
interface Instance {
entity: Entity;
interpreter: GraphInterpreter;
@@ -387,7 +394,7 @@ export class BehaviorSystem implements System {
i.fire('OnTimer', ctx, {}, (n) => n.id === node.id);
}
}
this.fireCollisions(instance, i, ctx);
this.fireCollisions(instance, i, ctx, dt);
this.fireAnimationsFinished(instance, i, ctx);
// With an owner the click has to land on it; a behavior without one has no shape to
// be hit, so it listens to the whole game view (anywhere inside the camera frame).
@@ -433,31 +440,65 @@ export class BehaviorSystem implements System {
/**
* Collision events for this behavior: every pair in which one collider belongs to the
* behavior's body (its owner, or itself when it has none) fires once, with `other` being
* the body on the far side.
* behavior's body (its owner, or itself when it has none) fires, with `other` being the body on
* the far side – enter and exit once, stay every frame or, with an interval, once each time that
* long has passed since the pair met (#149). Each stay node keeps a clock per pair in its node
* state, and a pair that is gone takes its clock with it.
*/
private fireCollisions(instance: Instance, interpreter: GraphInterpreter, ctx: NodeContext): void {
if (!this.collisions) return;
private fireCollisions(
instance: Instance,
interpreter: GraphInterpreter,
ctx: NodeContext,
dt: number,
): void {
const collisions = this.collisions;
if (!collisions) return;
const body = instance.entity.parent ?? instance.entity;
const fire = (type: string, pairs: CollisionPair[], contact = false) => {
for (const { a, b, normal, impulse } of pairs) {
const own = bodyOf(a) === body ? a : bodyOf(b) === body ? b : null;
if (!own) continue;
const other = own === a ? b : a;
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
if (contact) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === a ? 1 : -1;
outputs.normalX = sign * (normal?.x ?? 0) || 0;
outputs.normalY = sign * (normal?.y ?? 0) || 0;
outputs.impulse = impulse ?? 0;
}
interpreter.fire(type, ctx, outputs);
const mine = (pairs: CollisionPair[]) =>
pairs.flatMap((pair) => {
const own = bodyOf(pair.a) === body ? pair.a : bodyOf(pair.b) === body ? pair.b : null;
return own ? [{ pair, own, other: own === pair.a ? pair.b : pair.a }] : [];
});
const outputsOf = ({ own, other, pair }: Side, contact?: Partial<Contact> | null) => {
const outputs: Record<string, Value> = { other: bodyOf(other), otherCollider: other, collider: own };
if (contact !== undefined) {
// The pair's normal points towards `a`; seen from `b` it points the other way.
const sign = own === pair.a ? 1 : -1;
outputs.normalX = sign * (contact?.normal?.x ?? 0) || 0;
outputs.normalY = sign * (contact?.normal?.y ?? 0) || 0;
outputs.impulse = contact?.impulse ?? 0;
outputs.pointX = contact?.point?.x ?? 0;
outputs.pointY = contact?.point?.y ?? 0;
}
return outputs;
};
fire('OnCollisionEnter', this.collisions.enters, true);
fire('OnCollisionStay', this.collisions.stays);
fire('OnCollisionExit', this.collisions.exits);
for (const side of mine(collisions.enters))
interpreter.fire('OnCollisionEnter', ctx, outputsOf(side, side.pair));
const stays = mine(collisions.stays);
const contacts = new Map<CollisionPair, Contact | null>();
const contactOf = (pair: CollisionPair) => {
if (!contacts.has(pair)) contacts.set(pair, collisions.contactOf(pair));
return contacts.get(pair)!;
};
for (const node of interpreter.eventNodes('OnCollisionStay')) {
const interval = Math.max(0, Number(node.params.interval ?? 0)) || 0;
const clocks = interpreter.nodeState.get(node.id) ?? {};
const running: Record<string, number> = {};
for (const side of stays) {
let fires = 1;
if (interval > 0) {
const key = `${side.pair.a.id}|${side.pair.b.id}`;
let elapsed = (clocks[key] ?? 0) + dt;
for (fires = 0; elapsed >= interval - 1e-9; fires++) elapsed -= interval;
running[key] = elapsed;
}
for (let i = 0; i < fires; i++)
interpreter.fire('OnCollisionStay', ctx, outputsOf(side, contactOf(side.pair)), (n) => n === node);
}
if (interval > 0) interpreter.nodeState.set(node.id, running);
else interpreter.nodeState.delete(node.id);
}
for (const side of mine(collisions.exits)) interpreter.fire('OnCollisionExit', ctx, outputsOf(side));
}
/**
@@ -569,6 +610,18 @@ export class BehaviorSystem implements System {
found.y += dy;
return NOT_BLOCKED;
},
raycast: (from, direction, length, { ignore, triggers }) => {
const hit = this.collisions?.raycast(world, from, direction, length, {
ignore: resolve(ignore) ?? null,
triggers,
});
world.noteRay(from, hit?.point ?? pointAlong(from, direction, length), Boolean(hit));
return hit ? { ...hit, object: bodyOf(hit.collider) } : null;
},
ignoreOneWay: (target, seconds) => {
const found = resolve(target);
if (found) world.ignoreOneWay(found, seconds);
},
physics:
this.collisions instanceof PhysicsSystem
? physicsControl(world, this.collisions, resolve)
@@ -608,6 +661,13 @@ export class BehaviorSystem implements System {
}
}
/** Where a ray of that length ends; where it has no direction, where it starts. */
function pointAlong(from: Vec2, direction: Vec2, length: number): Vec2 {
const d = Math.hypot(direction.x, direction.y);
if (!(d > 0) || !(length > 0)) return from;
return { x: from.x + (direction.x / d) * length, y: from.y + (direction.y / d) * length };
}
/** The physics nodes' way into the played game's engine, with the handles of the graph resolved. */
function physicsControl(
world: World,
+13
View File
@@ -2,13 +2,17 @@ import { isHandleEnabled } from '../../core/graph';
import { shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { Vec2 } from '../../core/model/Properties';
import {
bodyOf,
type Contact,
type CollisionPair,
type CollisionSource,
isCollisionBody,
type MoveResult,
partsOf,
type RayFilter,
type RayResult,
Solids,
} from './solids';
import type { System } from './System';
@@ -78,4 +82,13 @@ export class CollisionSystem implements System, CollisionSource {
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult {
return this.solids.moveAndSlide(world, entity, dx, dy);
}
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
return this.solids.raycast(world, from, direction, length, filter);
}
/** Nothing is simulated here, so a contact has no normal or impulse to tell. */
contactOf(): Contact | null {
return null;
}
}
+129 -23
View File
@@ -17,9 +17,14 @@ import {
contactBetween,
exitDistance,
isTrigger,
landsOn,
movedShape,
oneWayFacing,
overlapDepth,
shapeExtent,
shapesOverlap,
} from '../../core/properties/collider';
import { rayCast } from '../../core/properties/ray';
import { colliderMaterial } from '../../core/properties/rigidbody';
import type { Entity } from '../Entity';
import type { World } from '../World';
@@ -36,14 +41,15 @@ const SQUARE = 1e-6;
/**
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
* collider or a placed tilemap, which takes part as a whole with its solid cells (#132).
* `normal` points from `b` towards `a` and `impulse` is how hard they met; both are only known
* where the physics engine saw the contact begin (#146), and zero elsewhere.
* `normal` points from `b` towards `a`, `impulse` is how hard they met and `point` where; they are
* only known where the physics engine saw the contact begin (#146), and zero elsewhere.
*/
export interface CollisionPair {
a: Entity;
b: Entity;
normal?: Vec2;
impulse?: number;
point?: Vec2;
}
/**
@@ -75,14 +81,36 @@ export const NOT_BLOCKED: MoveResult = Object.freeze({
normal: Object.freeze({ x: 0, y: 0 }),
});
/** A shape in the mover's way and the collider or tilemap it belongs to. */
interface Obstacle {
/**
* A shape of a collider or tilemap, and for a one-way one the world direction it blocks from (#155):
* only a contact whose normal lies within 45° of it stops anything.
*/
export interface SolidShape {
shape: ColliderShape;
facing: Vec2 | null;
}
/** A shape in the mover's way and the collider or tilemap it belongs to. */
interface Obstacle extends SolidShape {
body: Entity;
}
/** What a ray met (#153): the collider or tilemap, where, the face's normal and how far along the ray. */
export interface RayResult {
collider: Entity;
point: Vec2;
normal: Vec2;
distance: number;
}
/** Which colliders a ray looks at: all but those of `ignore` and below it, triggers only when asked. */
export interface RayFilter {
ignore: Entity | null;
triggers: boolean;
}
/** Where a sweep stopped: what it met, with which own collider, the normal, and the move left over. */
interface Contact {
interface SweepContact {
by: Entity;
own: Entity;
normal: Vec2;
@@ -96,6 +124,20 @@ export interface CollisionSource {
readonly exits: CollisionPair[];
touching(world: World, a: Entity, b: Entity): boolean;
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): MoveResult;
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null;
/**
* How a pair that stays in contact touches right now (#149): the normal from `b` towards `a`, the
* impulse of the last physics step and the middle of the contact points; null where nothing
* simulates the contact (the editor run).
*/
contactOf(pair: CollisionPair): Contact | null;
}
/** A contact as the physics engine knows it (see `CollisionSource.contactOf`). */
export interface Contact {
normal: Vec2;
impulse: number;
point: Vec2;
}
/**
@@ -108,23 +150,27 @@ export class Solids {
* painting, or a behavior changing the map, is picked up by the next one – and turned into world
* shapes only when asked, since the map may move in between.
*/
private blocks = new Map<Entity, { rects: CellRect[]; polygons: CellPolygon[] }>();
private blocks = new Map<Entity, TilemapBlocks>();
/** Forgets the cells, so the next tick works them out afresh. */
reset(): void {
this.blocks = new Map();
}
private blocksOf(world: World, tilemap: Entity): { rects: CellRect[]; polygons: CellPolygon[] } {
private blocksOf(world: World, tilemap: Entity): TilemapBlocks {
let blocks = this.blocks.get(tilemap);
if (!blocks) {
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
const { project } = world;
const { props } = tilemap;
blocks = asset
? {
rects: solidRects(world.project, tilemap.props, asset),
polygons: solidPolygons(world.project, tilemap.props, asset),
rects: solidRects(project, props, asset),
polygons: solidPolygons(project, props, asset),
oneWayRects: solidRects(project, props, asset, true),
oneWayPolygons: solidPolygons(project, props, asset, true),
}
: { rects: [], polygons: [] };
: { rects: [], polygons: [], oneWayRects: [], oneWayPolygons: [] };
this.blocks.set(tilemap, blocks);
}
return blocks;
@@ -132,17 +178,48 @@ export class Solids {
/**
* The shapes a body occupies in the world: one for a collider; for a tilemap one box per merged
* rectangle and one polygon per outlined cell (#141). A tilemap placed before the first tick is
* worked out on the spot.
* rectangle and one polygon per outlined cell (#141), the one-way cells blocking from the map's
* top (#155). A tilemap placed before the first tick is worked out on the spot.
*/
shapesOf(world: World, body: Entity): ColliderShape[] {
if (body.type !== 'tilemap') return [shapeOf(body)];
solidShapesOf(world: World, body: Entity): SolidShape[] {
const m = worldMatrixOf(body);
const { rects, polygons } = this.blocksOf(world, body);
return [
...rects.map((rect) => cellRectShape(body.props, rect, m)),
...polygons.map((polygon) => cellPolygonShape(body.props, polygon, m)),
if (body.type !== 'tilemap')
return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }];
const { rects, polygons, oneWayRects, oneWayPolygons } = this.blocksOf(world, body);
const up = oneWayFacing({ oneWay: true }, m);
const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: Vec2 | null) => [
...cells.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })),
...outlines.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })),
];
return [...shapes(rects, polygons, null), ...shapes(oneWayRects, oneWayPolygons, up)];
}
shapesOf(world: World, body: Entity): ColliderShape[] {
return this.solidShapesOf(world, body).map(({ shape }) => shape);
}
/**
* The first solid the ray from `from` along `direction` meets within `length` (world units): any
* blocking collider or tilemap cell, triggers when asked, a one-way one only on the side it blocks
* from. What the ray starts inside is not met (see `rayCast`).
*/
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
let best: RayResult | null = null;
for (const body of world.entities) {
if (!isCollisionBody(body) || !isHandleEnabled(body)) continue;
if (body.type === 'collider' && isTrigger(body.props) && !filter.triggers) continue;
if (filter.ignore && travelsWith(body, filter.ignore)) continue;
for (const { shape, facing } of this.solidShapesOf(world, body)) {
const hit = rayCast(shape, from, d, best?.distance ?? length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
if (best && hit.distance >= best.distance) continue;
const point = { x: from.x + d.x * hit.distance, y: from.y + d.y * hit.distance };
best = { collider: body, point, normal: hit.normal, distance: hit.distance };
}
}
return best;
}
/**
@@ -170,13 +247,19 @@ export class Solids {
return NOT_BLOCKED;
}
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
const obstacles = world.entities
const obstacles: Obstacle[] = world.entities
.filter((c) => blocksMover(c, entity))
.flatMap((body) => this.shapesOf(world, body).map((shape) => ({ shape, body })));
const contacts: Contact[] = [];
.flatMap((body) => this.solidShapesOf(world, body).map((solid) => ({ ...solid, body })));
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
// whole move: it is on its way through, from below or from the side.
const ignoring = world.ignoresOneWay(entity);
const passing = new Set(
obstacles.filter((o) => o.facing && (ignoring || own.some((a) => shapesOverlap(shapeOf(a), o.shape)))),
);
const contacts: SweepContact[] = [];
let rest = wanted;
for (let pass = 0; pass < MAX_PASSES && (rest.x !== 0 || rest.y !== 0); pass++) {
const contact = sweep(entity, frame, own, obstacles, rest);
const contact = sweep(entity, frame, own, obstacles, passing, rest);
if (!contact) break;
contacts.push(contact);
rest = slideAlong(contact.left, contact.normal, frictionBetween(contact.own, contact.by));
@@ -213,8 +296,16 @@ function parentFrame(entity: Entity): Mat {
/**
* Moves the entity along `move` (world) in steps no solid is thinner than, until a step runs into
* one: then it is set back flush against it and the contact is reported with what is left of the move.
* A one-way solid met anywhere but on its blocking side joins `passing` instead.
*/
function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[], move: Vec2): Contact | null {
function sweep(
entity: Entity,
frame: Mat,
own: Entity[],
obstacles: Obstacle[],
passing: Set<Obstacle>,
move: Vec2,
): SweepContact | null {
const distance = Math.hypot(move.x, move.y);
const forward = { x: move.x / distance, y: move.y / distance };
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(distance / stepLimit(own, obstacles, forward))));
@@ -238,6 +329,7 @@ function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[],
for (const [k, a] of own.entries()) {
const mover = shapeOf(a);
for (const [j, obstacle] of obstacles.entries()) {
if (passing.has(obstacle)) continue;
const was = before[k]![j]!;
if (overlapDepth(contactBetween(mover, obstacle.shape, forward).separation) <= was + DEEPER) continue;
// Coming from outside, the step goes back exactly to where the two met; from inside, all
@@ -246,6 +338,13 @@ function sweep(entity: Entity, frame: Mat, own: Entity[], obstacles: Obstacle[],
was === 0
? Math.min(step, exitDistance(mover, obstacle.shape, { x: -forward.x, y: -forward.y }))
: step;
if (obstacle.facing) {
const met = movedShape(mover, (p) => ({ x: p.x - forward.x * back, y: p.y - forward.y * back }), 0);
if (!landsOn(contactBetween(met, obstacle.shape, forward).normal, obstacle.facing)) {
passing.add(obstacle);
continue;
}
}
if (back > correction || !found) {
correction = back;
found = { own: a, obstacle };
@@ -299,6 +398,13 @@ function unitOf(v: Vec2): Vec2 {
return length > 0 ? { x: v.x / length, y: v.y / length } : v;
}
interface TilemapBlocks {
rects: CellRect[];
polygons: CellPolygon[];
oneWayRects: CellRect[];
oneWayPolygons: CellPolygon[];
}
export function shapeOf(collider: Entity): ColliderShape {
return colliderShape(collider.props, worldMatrixOf(collider));
}