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

+21
View File
@@ -108,6 +108,15 @@ export interface MoveOutcome {
normal: { x: number; y: number };
}
/** What a ray met (#153): the object and the collider (or tilemap), where, the face and how far. */
export interface RayOutcome {
object: EntityHandle;
collider: EntityHandle;
point: { x: number; y: number };
normal: { x: number; y: number };
distance: 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.
@@ -147,6 +156,18 @@ export interface NodeContext {
* and the rest of the move goes on along the surface, less what friction takes.
*/
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
/**
* The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units;
* null for none. `ignore` and whatever is parented below it are not met.
*/
raycast(
from: { x: number; y: number },
direction: { x: number; y: number },
length: number,
options: { ignore: EntityHandle | null; triggers: boolean },
): RayOutcome | null;
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
ignoreOneWay(target: EntityHandle, seconds: number): void;
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
physics?: PhysicsControl;
log(message: string): void;
@@ -0,0 +1,21 @@
import type { NodeDef } from '../../NodeRegistry';
import { asEntity, asNumber } from '../../values';
import { exec, num, obj, withDefault } from '../shared';
export const IgnoreOneWay: NodeDef = {
type: 'IgnoreOneWay',
label: 'Ignore one-way',
category: 'action',
description:
'Lets an object fall through one-way colliders and one-way tiles for the given seconds – drop down ' +
'from a platform. Once they are over, it still passes whatever it is inside of, and lands again on ' +
'the next one it comes down on. Works for Move with "Collide with solids" and for dynamic rigidbodies.',
inputs: [exec(), obj('object'), withDefault(num('seconds'), 0.25)],
outputs: [exec()],
params: [],
run: (ctx, inputs) => {
const target = asEntity(inputs.object);
if (target) ctx.ignoreOneWay(target, asNumber(inputs.seconds));
return { next: 'exec' };
},
};
+74
View File
@@ -0,0 +1,74 @@
import type { NodeDef } from '../../NodeRegistry';
import { asBoolean, asNumber } from '../../values';
import { bool, exec, num, obj, withDefault } from '../shared';
const MISSED = {
hit: false,
object: null,
collider: null,
pointX: 0,
pointY: 0,
normalX: 0,
normalY: 0,
distance: 0,
};
export const Raycast: NodeDef = {
type: 'Raycast',
label: 'Raycast',
category: 'action',
description:
'Casts a ray from (from x, from y) along (dir x, dir y) for up to length world units and says what ' +
'it meets first: the object, its collider (or the tilemap), where, the normal of the face (pointing ' +
'out of it) and how far. Solid colliders and tilemap cells are met, triggers only with "Hit ' +
'triggers", a one-way one only from the side it blocks from. What the ray starts inside is not met, ' +
'and with "Ignore owner" neither is the owner or anything parented below it.',
inputs: [
exec(),
num('fromX', 'from x'),
num('fromY', 'from y'),
withDefault(num('dirX', 'dir x'), 1),
num('dirY', 'dir y'),
withDefault(num('length'), 1000),
],
outputs: [
exec(),
bool('hit'),
obj('object'),
obj('collider'),
num('pointX', 'point x'),
num('pointY', 'point y'),
num('normalX', 'normal x'),
num('normalY', 'normal y'),
num('distance'),
],
params: [
{ key: 'ignoreOwner', label: 'Ignore owner', kind: 'boolean', default: true },
{ key: 'triggers', label: 'Hit triggers', kind: 'boolean', default: false },
],
run: (ctx, inputs, params) => {
const hit = ctx.raycast(
{ x: asNumber(inputs.fromX), y: asNumber(inputs.fromY) },
{ x: asNumber(inputs.dirX), y: asNumber(inputs.dirY) },
asNumber(inputs.length),
{
ignore: asBoolean(params.ignoreOwner ?? true) ? (ctx.owner ?? ctx.self) : null,
triggers: asBoolean(params.triggers ?? false),
},
);
if (!hit) return { next: 'exec', outputs: MISSED };
return {
next: 'exec',
outputs: {
hit: true,
object: hit.object,
collider: hit.collider,
pointX: hit.point.x,
pointY: hit.point.y,
normalX: hit.normal.x,
normalY: hit.normal.y,
distance: hit.distance,
},
};
},
};
@@ -7,8 +7,8 @@ export const OnCollisionEnter: NodeDef = {
category: 'event',
description:
'Runs once when a collider of the owner starts overlapping another collider. While playing, ' +
'normal x/y point from the other collider towards the own one and impulse says how hard they met ' +
'(both 0 for a trigger).',
'normal x/y point from the other collider towards the own one, impulse says how hard they met and ' +
'point x/y where (all 0 for a trigger).',
inputs: [],
outputs: [...collisionOutputs(), ...contactOutputs()],
params: [],
+16 -4
View File
@@ -1,12 +1,24 @@
import type { NodeDef } from '../../NodeRegistry';
import { collisionOutputs } from '../shared';
import { collisionOutputs, contactOutputs } from '../shared';
export const OnCollisionStay: NodeDef = {
type: 'OnCollisionStay',
label: 'On collision stay',
category: 'event',
description: 'Runs every frame while a collider of the owner overlaps another collider.',
description:
'Runs while a collider of the owner overlaps another collider: every frame, or with an interval once ' +
'each time that long has passed since they met. While playing, normal x/y, impulse and point x/y tell ' +
'how they touch right now (all 0 for a trigger and in the editor run).',
inputs: [],
outputs: collisionOutputs(),
params: [],
outputs: [...collisionOutputs(), ...contactOutputs()],
params: [
{
key: 'interval',
label: 'Interval (s, 0 = every frame)',
kind: 'number',
default: 0,
min: 0,
step: 0.1,
},
],
};
+4
View File
@@ -4,6 +4,7 @@ import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animation
import { Emit } from './action/Emit';
import { LoadScene } from './action/LoadScene';
import { Log } from './action/Log';
import { IgnoreOneWay } from './action/IgnoreOneWay';
import { Move } from './action/Move';
import {
ApplyForce,
@@ -14,6 +15,7 @@ import {
SetVelocity,
} from './action/physicsNodes';
import { PlaySound } from './action/PlaySound';
import { Raycast } from './action/Raycast';
import { RestartGame } from './action/RestartGame';
import { SetPosition } from './action/SetPosition';
import { Spawn } from './action/Spawn';
@@ -218,6 +220,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
SetVelocity,
SetAngularVelocity,
SetGravity,
Raycast,
IgnoreOneWay,
Spawn,
Destroy,
Emit,
+5 -2
View File
@@ -32,13 +32,16 @@ export const collisionOutputs = (): PortDef[] => [
];
/**
* What only the start of a contact knows: which way the other side pushes (a unit vector pointing
* from it towards the own collider) and how hard they met. The physics engine works both out (#146).
* How a contact touches: which way the other side pushes (a unit vector pointing from it towards the
* own collider), how hard, and the middle of the contact points in the world. The physics engine
* works them out (#146, #149).
*/
export const contactOutputs = (): PortDef[] => [
num('normalX', 'normal x'),
num('normalY', 'normal y'),
num('impulse'),
num('pointX', 'point x'),
num('pointY', 'point y'),
];
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };