Move with collision reacts along the contact normal: it travels to the surface, stays flush and slides the rest of the move along it with Coulomb friction (the mean of both colliders, a tile counts as 0.5), a circle now rolls over an edge instead of catching on it, pushed dynamic bodies move off along the normal, Move gains the outputs hit, normal x and normal y, an object without its own collider moves freely, and editor run and play share the same path through Solids; contact point and normal come from our own f64 geometry (contactBetween), because Rapier's castShape lands 3e-6 px inside the wall in f32 (#150)
game-maker / gate (push) Successful in 1m48s
game-maker / suite (push) Successful in 6m23s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 21:52:11 +02:00
1 parent d931e66f92
commit 1b5e386b60
15 files changed
+680 -161

No files matched your search

+14 -3
View File
@@ -97,6 +97,17 @@ export interface PhysicsControl {
setGravity(x: number, y: number): void;
}
/**
* What a move with collision met: which of the object's own axes something stood against, whether
* it hit anything, and the world normal of the first contact (pointing away from what was hit).
*/
export interface MoveOutcome {
blockedX: boolean;
blockedY: boolean;
hit: boolean;
normal: { x: number; y: number };
}
/**
* 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.
@@ -132,10 +143,10 @@ export interface NodeContext {
/** A collider entity stands for itself; any other entity for its direct collider children. */
isTouching(a: EntityHandle, b: EntityHandle): boolean;
/**
* Moves `target` by (dx, dy) in local units, stopping solid colliders at other solid ones axis by
* axis (x first, then y) so it slides along walls.
* Moves `target` by (dx, dy) in local units; its solid colliders stop flush at other solid ones
* and the rest of the move goes on along the surface, less what friction takes.
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
physics?: PhysicsControl;
log(message: string): void;
+29 -6
View File
@@ -1,15 +1,30 @@
import type { MoveOutcome } from '../../NodeContext';
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asEntity, asNumber } from '../../values';
import { bool, exec, num, obj, withDefault } from '../shared';
const FREE = { blockedX: false, blockedY: false, hit: false, normalX: 0, normalY: 0 };
function outputsOf(outcome: MoveOutcome) {
return {
blockedX: outcome.blockedX,
blockedY: outcome.blockedY,
hit: outcome.hit,
normalX: outcome.normal.x,
normalY: outcome.normal.y,
};
}
export const Move: NodeDef = {
type: 'Move',
label: 'Move',
category: 'action',
description:
"Moves an object by (dx, dy) × scale × dt. Connect dt to an event's dt for per-second movement; " +
'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop at ' +
'other solid colliders and slide along them; blocked x/y tell which axes hit.',
'leave it at 1 for per-frame. With "Collide with solids" on, the object\'s solid colliders stop ' +
'flush at other solid colliders and the rest of the move goes on along the surface, less what the ' +
'friction of both colliders takes; blocked x/y tell which axes something stood against, hit whether ' +
'anything was met, and normal x/y which way the first surface faces.',
inputs: [
exec(),
obj('object'),
@@ -18,17 +33,25 @@ export const Move: NodeDef = {
withDefault(num('scale'), 1),
withDefault(num('dt'), 1),
],
outputs: [exec(), bool('blockedX', 'blocked x'), bool('blockedY', 'blocked y')],
outputs: [
exec(),
bool('blockedX', 'blocked x'),
bool('blockedY', 'blocked y'),
bool('hit'),
num('normalX', 'normal x'),
num('normalY', 'normal y'),
],
params: [{ key: 'collide', label: 'Collide with solids', kind: 'boolean', default: false }],
run: (ctx, inputs, params) => {
const target = asEntity(inputs.object);
if (!target) return { next: 'exec', outputs: { blockedX: false, blockedY: false } };
if (!target) return { next: 'exec', outputs: FREE };
const factor = asNumber(inputs.scale) * asNumber(inputs.dt);
const dx = asNumber(inputs.dx) * factor;
const dy = asNumber(inputs.dy) * factor;
if (asBoolean(params.collide)) return { next: 'exec', outputs: ctx.moveAndSlide(target, dx, dy) };
if (asBoolean(params.collide))
return { next: 'exec', outputs: outputsOf(ctx.moveAndSlide(target, dx, dy)) };
target.x += dx;
target.y += dy;
return { next: 'exec', outputs: { blockedX: false, blockedY: false } };
return { next: 'exec', outputs: FREE };
},
};
+80 -3
View File
@@ -18,9 +18,6 @@ export type ColliderShape =
type Box = Extract<ColliderShape, { kind: 'box' }>;
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
/** A movement axis in the plane (`z` is draw order, not geometry). */
export type PlaneAxis = 'x' | 'y';
/** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */
export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
const scale = scaleOf(m);
@@ -120,6 +117,86 @@ export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
return distanceToBox(toBoxFrame(box, circle), box.w / 2, box.h / 2) < circle.r - EPSILON;
}
/**
* How two shapes meet: `separation` is the gap between them (negative when they overlap, then its
* size is the shortest way out) and `normal` the unit direction from `obstacle` towards `mover` that
* gap is measured along – the face that was hit, or the direction from a hit corner. Where the
* shapes give no direction (centres on top of each other) it runs against `travel`.
*/
export function contactBetween(
mover: ColliderShape,
obstacle: ColliderShape,
travel: Vec2,
): { separation: number; normal: Vec2 } {
const against = (n: Vec2, offset: number): Vec2 => {
const sign = offset !== 0 ? Math.sign(offset) : dot(n, travel) > 0 ? -1 : 1;
return { x: sign * n.x + 0, y: sign * n.y + 0 };
};
if (mover.kind === 'box' && obstacle.kind === 'box') {
const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
let best = { separation: -Infinity, normal: { x: 0, y: 0 } };
for (const n of [...axesOf(mover), ...axesOf(obstacle)]) {
const offset = dot(centres, n);
const separation = Math.abs(offset) - reach(mover, n) - reach(obstacle, n);
const normal = against(n, offset);
// On a tie (a corner on a corner) the face the travel runs into more squarely wins.
if (
separation > best.separation + EPSILON ||
(separation > best.separation - EPSILON && dot(normal, travel) < dot(best.normal, travel))
)
best = { separation: Math.max(separation, best.separation), normal };
}
return best;
}
if (mover.kind === 'circle' && obstacle.kind === 'circle') {
const d = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
const length = Math.hypot(d.x, d.y);
const normal = length > 0 ? { x: d.x / length, y: d.y / length } : against(unit(travel), 0);
return { separation: length - mover.r - obstacle.r, normal };
}
const { box, circle } = boxAndCircle(mover, obstacle);
const [ux, uy] = axesOf(box);
const p = toBoxFrame(box, circle);
const hw = box.w / 2;
const hh = box.h / 2;
// Local direction from the box towards the circle, and the gap along it.
let dir: Vec2;
let separation: number;
if (Math.abs(p.x) > hw || Math.abs(p.y) > hh) {
const d = { x: p.x - clamp(p.x, hw), y: p.y - clamp(p.y, hh) };
const length = Math.hypot(d.x, d.y);
dir = { x: d.x / length, y: d.y / length };
separation = length - circle.r;
} else {
// The centre is inside: out through the nearer face.
const sign = mover.kind === 'circle' ? 1 : -1;
const local = { x: sign * dot(travel, ux), y: sign * dot(travel, uy) };
const byX = hw - Math.abs(p.x) <= hh - Math.abs(p.y);
dir = byX
? { x: p.x !== 0 ? Math.sign(p.x) : -Math.sign(local.x) || 1, y: 0 }
: { x: 0, y: p.y !== 0 ? Math.sign(p.y) : -Math.sign(local.y) || 1 };
separation = -(byX ? hw - Math.abs(p.x) : hh - Math.abs(p.y)) - circle.r;
}
const world = { x: dir.x * ux.x + dir.y * uy.x, y: dir.x * ux.y + dir.y * uy.y };
// `world` runs from the box to the circle; the normal runs from the obstacle to the mover.
const sign = mover.kind === 'circle' ? 1 : -1;
return { separation, normal: { x: sign * world.x + 0, y: sign * world.y + 0 } };
}
/** How deep two shapes overlap by the measure `shapesOverlap` uses: 0 when they only touch. */
export function overlapDepth(separation: number): number {
return separation < -EPSILON ? -separation : 0;
}
function clamp(v: number, half: number): number {
return Math.min(half, Math.max(-half, v));
}
function unit(v: Vec2): Vec2 {
const length = Math.hypot(v.x, v.y);
return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 1, y: 0 };
}
/**
* How far `mover` has to travel along `direction` until it just clears `obstacle`; 0 when they are
* apart already. This is what stops a moving object at a wall: it is pushed back against its