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

+1 -19
View File
@@ -1,5 +1,5 @@
import { edge, node } from '../lib/builders.mjs';
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
import { inView, 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;
@@ -61,24 +61,6 @@ const rollGraph = {
],
};
/**
* 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
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
* whatever is drawn there instead.
*/
async function inView(t, panel, point) {
for (let i = 0; i < 15; i++) {
const rect = await t.json(panel);
const at = await t.json(point);
if (!rect) return null;
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
await t.settle();
}
return null;
}
/**
* The physics engine (Rapier) loads in the editor, in the background after the first project is
* open, from a chunk of its own – the editor bundle does not carry it. A played scene simulates its
+264
View File
@@ -0,0 +1,264 @@
import { edge, node } from '../lib/builders.mjs';
import { inView, LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/**
* What a game asks the collision about, and platforms one jumps through: one-way colliders (#155),
* the Raycast node (#153) and On collision stay with an interval and contact data (#149). What only a
* running editor shows: the switch in the inspector, the engine and Move landing on a platform and
* passing it from below, the ray drawn in the Scene tab by the editor run, and a stay that fires on
* its own clock.
*/
const FALL = 300;
/** Falls with Move and "Collide with solids"; 1.5 s after it starts it drops through one-way platforms. */
const dropGraph = {
nodes: [
node('qd-update', 'OnUpdate', 0, 0),
node('qd-owner', 'Owner', 0, 160),
node('qd-move', 'Move', 300, 0, { dy: 1, scale: FALL, collide: true }),
node('qd-timer', 'OnTimer', 0, 320, { interval: 1.5 }),
node('qd-drop', 'IgnoreOneWay', 300, 320, { seconds: 0.3 }),
],
edges: [
edge('qd-e1', 'qd-update', 'exec', 'qd-move', 'exec'),
edge('qd-e2', 'qd-update', 'dt', 'qd-move', 'dt'),
edge('qd-e3', 'qd-owner', 'object', 'qd-move', 'object'),
edge('qd-e4', 'qd-timer', 'exec', 'qd-drop', 'exec'),
edge('qd-e5', 'qd-owner', 'object', 'qd-drop', 'object'),
],
};
/** Shoots its owner – a dynamic body below the platform – straight up in On start. */
const jumpGraph = {
nodes: [
node('qj-start', 'OnStart', 0, 0),
node('qj-owner', 'Owner', 0, 160),
node('qj-shoot', 'SetVelocity', 300, 0, { x: 0, y: -700 }),
],
edges: [
edge('qj-e1', 'qj-start', 'exec', 'qj-shoot', 'exec'),
edge('qj-e2', 'qj-owner', 'object', 'qj-shoot', 'object'),
],
};
/** Casts a ray straight down from its owner every frame. */
const eyeGraph = {
nodes: [
node('qe-update', 'OnUpdate', 0, 0),
node('qe-owner', 'Owner', 0, 160),
node('qe-where', 'GetPosition', 250, 160),
node('qe-ray', 'Raycast', 500, 0, { dirX: 0, dirY: 1, length: 500 }),
],
edges: [
edge('qe-e1', 'qe-update', 'exec', 'qe-ray', 'exec'),
edge('qe-e2', 'qe-owner', 'object', 'qe-where', 'object'),
edge('qe-e3', 'qe-where', 'worldX', 'qe-ray', 'fromX'),
edge('qe-e4', 'qe-where', 'worldY', 'qe-ray', 'fromY'),
],
};
/** Logs how its owner touches the ground (normal y, then impulse) each half second it stays on it. */
const STAY = 0.5;
const feelGraph = {
nodes: [
node('qf-stay', 'OnCollisionStay', 0, 0, { interval: STAY }),
node('qf-log-n', 'Log', 300, 0),
node('qf-log-i', 'Log', 550, 0),
],
edges: [
edge('qf-e1', 'qf-stay', 'exec', 'qf-log-n', 'exec'),
edge('qf-e2', 'qf-stay', 'normalY', 'qf-log-n', 'message'),
edge('qf-e3', 'qf-log-n', 'exec', 'qf-log-i', 'exec'),
edge('qf-e4', 'qf-stay', 'impulse', 'qf-log-i', 'message'),
],
};
export async function queries(t) {
const {
check,
json,
evaluate,
mutate,
select,
click,
object,
settle,
sleep,
play,
stop,
shot,
consoleLog,
} = t;
const Y = LANES.queries;
const GROUND_TOP = Y + 280;
const PLATFORM_TOP = Y + 95;
const setup = (body) =>
mutate(`
const scene = __gm.state.activeScene;
const assets = __gm.state.project.assets;
${MAKE_OBJECT}
const collider = (id, props) =>
assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } });
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);
};
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);
};
${body}
__gm.state.select(null)`);
const clear = () =>
mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('q-'));
p.assets = p.assets.filter((a) => !a.id.startsWith('q-'));
__gm.state.select(null)`);
const sample = async (seconds, ids) => {
await evaluate(`(() => {
const out = (window.__querySamples = []);
const start = performance.now();
const sample = () => {
const w = __gm.world();
if (!w) return;
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
out.push({ t: (performance.now() - start) / 1000, ...Object.fromEntries(${JSON.stringify(ids)}.map((id) => [id, y(id)])) });
if (performance.now() - start < ${seconds * 1000}) requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
})()`);
await sleep(seconds * 1000 + 300);
return json('window.__querySamples');
};
// 43a. A platform one jumps through (#155), switched to one-way in the inspector. A crate falls
// onto it with Move and drops through 1.5 s later; a dynamic crate shot up from below
// passes it and lands on it.
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-platform', { width: 300, height: 10 });
collider('q-col-box', {});
assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
behavior('q-drop', 'Query drop', ${JSON.stringify(dropGraph)});
behavior('q-jump', 'Query jump', ${JSON.stringify(jumpGraph)});
const box = { width: 40, height: 40 };
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-platform', 'crate', '', 0, ${Y + 100}, 'Query platform', { width: 300, height: 10 });
put('q-platform-col', 'q-col-platform', 'q-platform', 0, 0, 'Platform collider');
put('q-dropper', 'crate', '', -100, ${Y}, 'Dropper', box);
put('q-dropper-col', 'q-col-box', 'q-dropper', 0, 0);
put('q-dropper-go', 'q-drop', 'q-dropper', 0, 0);
put('q-jumper', 'crate', '', 100, ${Y + 200}, 'Jumper', box);
put('q-jumper-col', 'q-col-box', 'q-jumper', 0, 0);
put('q-jumper-rb', 'q-rb', 'q-jumper', 0, 0);
put('q-jumper-go', 'q-jump', 'q-jumper', 0, 0);`);
await select({
kind: 'object',
holderId: 'scene1',
objectId: 'q-platform-col',
objectIds: ['q-platform-col'],
});
const toggle = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('One-way')");
if (toggle) await click(toggle.x, toggle.y);
await settle();
const blocksFrom = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Blocks from')");
check(
"a collider's One-way switch in the inspector turns it one-way, blocking from above",
Boolean(toggle) &&
Boolean(blocksFrom) &&
(await object('q-platform-col'))?.overrides?.oneWay === true &&
(await t.has('Above')),
JSON.stringify({ toggle, blocksFrom, overrides: (await object('q-platform-col'))?.overrides }),
);
await select(null);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await settle();
await shot('43-one-way');
await play();
const run = await sample(2.8, ['q-dropper', 'q-jumper']);
await stop();
const at = (seconds) =>
run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best));
const onPlatform = PLATFORM_TOP - 20;
const onGround = GROUND_TOP - 20;
check(
'Move lands on the one-way platform, and Ignore one-way drops it through onto the ground',
run.length > 60 &&
Math.abs(at(0.7)['q-dropper'] - onPlatform) < 0.01 &&
Math.abs(run.at(-1)['q-dropper'] - onGround) < 0.01,
JSON.stringify({ frames: run.length, early: at(0.7), last: run.at(-1), onPlatform, onGround }),
);
const highest = Math.min(...run.map((s) => s['q-jumper']));
check(
'a dynamic body shot up from below passes through the platform and lands on top of it',
highest < PLATFORM_TOP - 60 && Math.abs(run.at(-1)['q-jumper'] - onPlatform) < 1,
JSON.stringify({ highest, last: run.at(-1)['q-jumper'], onPlatform }),
);
await clear();
// 43b. The Raycast node in the editor run (#153): a crate casting down every frame, the ray drawn
// in the Scene tab to where it meets the ground.
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-box', {});
behavior('q-eye', 'Query eye', ${JSON.stringify(eyeGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-eyebox', 'crate', '', 200, ${Y}, 'Eye', { width: 40, height: 40 });
put('q-eyebox-col', 'q-col-box', 'q-eyebox', 0, 0);
put('q-eyebox-go', 'q-eye', 'q-eyebox', 0, 0, 'Eye logic', { runInEditor: true });`);
await evaluate(`__gm.lookAt(0, ${Y + 150})`);
await sleep(500);
const rays = await json('__gm.rays()');
const ray = rays.at(-1);
check(
'the editor run casts the ray and the Scene tab draws it, down to the ground it met',
(await json('__gm.editorRun()')).running === true &&
ray?.hit === true &&
ray.from.x === 200 &&
ray.from.y === Y &&
Math.abs(ray.to.y - GROUND_TOP) < 1e-6,
JSON.stringify({ count: rays.length, ray, groundTop: GROUND_TOP }),
);
await shot('43-ray');
await clear();
await sleep(300);
// 43c. On collision stay with an interval (#149): a crate resting on the ground logs how it touches
// every half second – the normal from the ground towards it, and the impulse holding it up.
const logFrom = consoleLog.length;
await setup(`
collider('q-col-ground', { width: 800, height: 40 });
collider('q-col-box', {});
assets.push({ id: 'q-rb', type: 'rigidbody', name: 'q-rb', props: { bodyType: 'dynamic', mass: 0, fixedRotation: true } });
behavior('q-feel', 'Query feel', ${JSON.stringify(feelGraph)});
put('q-ground', 'crate', '', 0, ${Y + 300}, 'Query ground', { width: 800, height: 40 });
put('q-ground-col', 'q-col-ground', 'q-ground', 0, 0);
put('q-rest', 'crate', '', -300, ${Y + 220}, 'Rester', { width: 40, height: 40 });
put('q-rest-col', 'q-col-box', 'q-rest', 0, 0);
put('q-rest-rb', 'q-rb', 'q-rest', 0, 0);
put('q-rest-go', 'q-feel', 'q-rest', 0, 0, 'Feel');`);
await play();
await sleep(2200);
await stop();
const logged = consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Feel]'))
.map((line) => Number(line.split(']').pop()));
const pairs = Array.from({ length: Math.floor(logged.length / 2) }, (_, i) =>
logged.slice(2 * i, 2 * i + 2),
);
check(
'On collision stay with an interval fires every half second, with the normal and the impulse of the contact',
pairs.length >= 2 && pairs.length <= 4 && pairs.every(([ny, impulse]) => ny === -1 && impulse > 0),
JSON.stringify({ pairs, interval: STAY }),
);
await clear();
}
+2
View File
@@ -45,6 +45,7 @@ import { tile } from './39-tile.mjs';
import { tilemap } from './40-tilemap.mjs';
import { physics } from './41-physics.mjs';
import { shapes } from './42-shapes.mjs';
import { queries } from './43-queries.mjs';
export const STEPS = [
hierarchy,
@@ -89,4 +90,5 @@ export const STEPS = [
tilemap,
physics,
shapes,
queries,
];
+19
View File
@@ -23,6 +23,7 @@ export const LANES = {
tilemap: -3100,
physics: -3500,
shapes: -3900,
queries: -4300,
};
/**
@@ -166,3 +167,21 @@ export async function pickAssetType(t, label) {
await t.click(p.x, p.y);
return { box, p, scrolled };
}
/**
* 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
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
* whatever is drawn there instead.
*/
export async function inView(t, panel, point) {
for (let i = 0; i < 15; i++) {
const rect = await t.json(panel);
const at = await t.json(point);
if (!rect) return null;
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
await t.settle();
}
return null;
}