Fix collider tunneling, seed the container demo, split the e2e fixture

Move with "Collide with solids" now steps in sub-steps of at most half the
thinnest collider, so a fast step can no longer jump across a collider (the
crates are only 60 high, which is why it showed up mostly on the y axis). A
pre-existing overlap is still never resolved, but it can no longer be
deepened either, so a small overlap cannot open a wall permanently.

seed-demo.mjs now writes the saved demo scene: a "Player" container (green
sprite with collider and the Mover behavior) against two "Enemy" container
instances, camera at zoom 0.5, Spawner dropping another enemy on Space.

The e2e checks assert against the old two-crate layout and locate widgets by
text, which the demo can no longer provide (three identically named sprite
instances, both crates nested in containers). Its fixture therefore moves to
tools/seed-e2e.mjs so the shipped demo can evolve without rewriting 100+
checks: seed-e2e -> e2e -> seed-demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-07 19:35:11 +02:00
1 parent 3379232e64
commit 4900d7bbf6
6 files changed
+269 -39

No files matched your search

+13 -5
View File
@@ -121,9 +121,11 @@ its *solid* colliders back to just touch any solid collider they ran into – so
still moves and the object slides along walls. Its `blocked x` / `blocked y` outputs tell which
axes hit (always `false` with the setting off). A collider with
**Trigger (no blocking)** enabled (`isTrigger`, drawn amber instead of green) still produces
collision events but never blocks. Pairs that already overlapped before the step are left
alone, so an object that starts inside a wall can leave it and stacked objects never jolt apart.
Colliders of the moving object and of anything parented under it travel with it and cannot
collision events but never blocks. An overlap that already exists is never resolved (no jolt
when objects start stacked or inside a wall) but also never deepened, so such an object can
always leave. A move longer than the thinnest collider involved is split into sub-steps – a
fast object cannot jump across a thin wall and end up on the other side. Colliders of the
moving object and of anything parented under it travel with it and cannot
block it. `Move` without the setting and `Set position` ignore colliders entirely.
## Stack
@@ -148,7 +150,7 @@ src/viewport/ scene renderer: Scene tab (model, editable) and Game tab (wo
src/editor/ App composition, state, panels, inspector field factory
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo/seed-e2e.mjs
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -169,10 +171,16 @@ npm run dev # http://localhost:5175/game-maker/dist/ – API calls are
npm test # unit tests (core)
npm run typecheck
npm run build # writes dist/ and regenerates index.html
node tools/seed-demo.mjs <projectId> # reset the demo project to the state the e2e run expects
node tools/seed-e2e.mjs <projectId> # write the fixture scene the e2e checks assert against
node tools/e2e.mjs <projectId> # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
node tools/seed-demo.mjs <projectId> # put the demo project back (the e2e run mutates it)
```
The e2e run overwrites the project it is pointed at, so it seeds its own fixture first and the
demo is restored afterwards. The demo scene (`seed-demo.mjs`) is free to evolve independently:
a "Player" container – green sprite with a collider and the Mover behavior – two "Enemy"
container instances, a camera at zoom 0.5 and a Spawner that drops another enemy on Space.
The workspace folder itself is what nginx serves, so `npm run build` is the deployment.
## API
+58 -11
View File
@@ -1,8 +1,13 @@
import { type Axis, axisCorrection, colliderShape, isTrigger, shapesOverlap } from '../../core/properties/collider';
import { type Axis, axisCorrection, colliderShape, colliderSize, isTrigger, shapesOverlap } from '../../core/properties/collider';
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
/** Overlaps up to this depth (world units) count as contact, not as "already inside". */
const SKIN = 0.05;
/** Upper bound for the sub-steps of a single move, so a huge delta cannot stall a frame. */
const MAX_STEPS = 256;
/** Two collider entities that overlap (or overlapped) – always the same pair order per key. */
export interface CollisionPair {
a: Entity;
@@ -66,9 +71,10 @@ export class CollisionSystem implements System {
* Moves `entity` by (dx, dy) in its local units, one axis at a time (x, then y): after
* each axis step its solid colliders are pushed back to just touch whatever solid collider
* they ran into, so the remaining axis still moves – the object slides along walls.
* Pairs that already overlapped before the step are ignored (an object that starts inside
* a wall may leave it, and stacked objects never get jolted apart). Triggers, the
* entity's own colliders and colliders that travel with it (descendants) never block.
* An overlap that already existed is never resolved (no jolt) but also never deepened, so
* an object inside a wall can leave it without being able to burrow further in. Long moves
* are split into sub-steps so nothing jumps across a thin wall. Triggers, the entity's own
* colliders and colliders that travel with it (descendants) never block.
*/
moveAndSlide(world: World, entity: Entity, dx: number, dy: number): { blockedX: boolean; blockedY: boolean } {
const own = collidersOf(world, entity).filter((c) => !isTrigger(c.props));
@@ -77,22 +83,50 @@ export class CollisionSystem implements System {
}
private slideAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean {
if (delta === 0) return false;
if (delta === 0 || own.length === 0) return false;
// Long steps are split so a fast mover cannot jump across a thin obstacle in one frame.
const steps = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(delta) / stepLimit(own, obstacles, axis))));
for (let i = 0; i < steps; i++) if (this.stepAxis(world, entity, own, obstacles, axis, delta / steps)) return true;
return false;
}
/** One indivisible move along `axis`; returns whether an obstacle stopped it. */
private stepAxis(world: World, entity: Entity, own: Entity[], obstacles: Entity[], axis: Axis, delta: number): boolean {
const dir: 1 | -1 = delta > 0 ? 1 : -1;
// Which pairs are free to collide: only those that were apart before this step.
const candidates: [Entity, Entity][] = [];
for (const a of own) for (const b of obstacles) if (!this.overlapping(world, a, b)) candidates.push([a, b]);
// How deep each pair already sits in the other before the step. A step may never deepen
// that, but resolving it is not our job (no jolt, and leaving a wall is always possible).
// Overlaps below the skin count as plain contact – that is where rounding leftovers of a
// previous correction land, and taking those for "already inside" would open the wall.
const before: [Entity, Entity, number][] = [];
for (const a of own) {
for (const b of obstacles) {
const p = this.penetration(world, a, b, axis, dir);
before.push([a, b, p > SKIN ? p : 0]);
}
}
entity[axis] += delta;
let correction = 0;
for (const [a, b] of candidates) {
const t = axisCorrection(colliderShape(a.props, world.worldPosition(a)), colliderShape(b.props, world.worldPosition(b)), axis, dir);
for (const [a, b, was] of before) {
const t = this.penetration(world, a, b, axis, dir) - was;
if (t > correction) correction = t;
}
if (correction === 0) return false;
if (correction <= 0) return false;
entity[axis] -= dir * Math.min(correction, Math.abs(delta));
return true;
}
/**
* How far `a` would have to be pushed back against `dir` to just clear `b` (0 when they are
* apart). Once a's centre has passed b's centre along `dir` the shorter way out is forwards,
* so nothing blocks – that keeps an object that ended up inside something able to leave.
*/
private penetration(world: World, a: Entity, b: Entity, axis: Axis, dir: 1 | -1): number {
const mover = colliderShape(a.props, world.worldPosition(a));
const obstacle = colliderShape(b.props, world.worldPosition(b));
if (dir * (mover[axis] - obstacle[axis]) >= 0) return 0;
return axisCorrection(mover, obstacle, axis, dir);
}
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
private travelsWith(collider: Entity, entity: Entity): boolean {
for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true;
@@ -100,6 +134,19 @@ export class CollisionSystem implements System {
}
}
/** Longest safe step along `axis`: half of the thinnest collider involved (never below 1). */
function stepLimit(own: Entity[], obstacles: Entity[], axis: Axis): number {
let min = Infinity;
for (const e of own) min = Math.min(min, extentOf(e, axis));
for (const e of obstacles) min = Math.min(min, extentOf(e, axis));
return Math.max(1, min / 2);
}
function extentOf(collider: Entity, axis: Axis): number {
const size = colliderSize(collider.props);
return axis === 'x' ? size.w : size.h;
}
/** The object a collider belongs to: its parent, or the collider itself when top-level. */
export function bodyOf(collider: Entity): Entity {
return collider.parent ?? collider;
+27 -5
View File
@@ -150,6 +150,8 @@ describe('CollisionSystem', () => {
});
describe('move and slide', () => {
const SKIN = 0.05; // mirrors CollisionSystem: overlaps this shallow count as contact
function runtime() {
const s = setup();
const world = World.instantiate(s.project, s.sceneId);
@@ -197,13 +199,33 @@ describe('move and slide', () => {
expect(crateA.x).toBe(260);
});
it('pairs that already overlap are not resolved (no jolt), leaving is always possible', () => {
it('an existing overlap is neither resolved (no jolt) nor deepened; leaving is always possible', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 60; // start overlapped
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(5);
crateB.x = 60; // start overlapped by 40
expect(collisions.moveAndSlide(world, crateA, 5, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(0); // no jolt out, but no burrowing deeper either
expect(collisions.moveAndSlide(world, crateA, -20, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-20); // moving away is always free
expect(collisions.moveAndSlide(world, crateA, -100, 0)).toEqual({ blockedX: false, blockedY: false });
expect(crateA.x).toBe(-95);
expect(crateA.x).toBe(-120);
});
it('keeps blocking while pressed against a wall, however long and at any speed', () => {
const { world, collisions, crateA, crateB } = runtime();
crateB.x = 0;
crateB.y = 300; // wall straight above, colliders are 100×60 → contact at y = 240
for (let i = 0; i < 500; i++) {
// Uneven steps, so contact is never hit at a round number.
const { blockedY } = collisions.moveAndSlide(world, crateA, 0, 3.7 + (i % 7) * 0.13);
expect(crateA.y).toBeLessThanOrEqual(240 + SKIN);
if (i > 60) expect(blockedY).toBe(true);
}
expect(crateA.y).toBeGreaterThan(240 - SKIN);
// A step larger than the obstacle used to jump clean over it: after the move nothing
// overlapped any more, so nothing pushed back and the mover was through the wall.
crateA.y = 0;
expect(collisions.moveAndSlide(world, crateA, 0, 5000)).toEqual({ blockedX: false, blockedY: true });
expect(crateA.y).toBeCloseTo(240, 6);
});
it('the Move node with "Collide with solids" moves the owner and outputs the blocked axes', () => {
+10 -1
View File
@@ -6,6 +6,9 @@
*
* Widgets are located through `window.__gm.locate(text)` (installed by the editor), so
* the test does not depend on pixel coordinates.
*
* Expects the fixture scene from tools/seed-e2e.mjs (not the demo scene) and mutates it:
* node tools/seed-e2e.mjs <id> && node tools/e2e.mjs <id> && node tools/seed-demo.mjs <id>
*/
import { spawn } from 'node:child_process';
import { writeFileSync } from 'node:fs';
@@ -640,11 +643,12 @@ try {
await sleep(ms);
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 });
};
const playAgainstBlue = async (trigger) => {
const playAgainstBlue = async (trigger, speed = 0) => {
await evaluate(`(() => {
const scene = __gm.state.activeScene;
scene.objects.find(o => o.id === 'o2').position = { x: 0, y: 0, z: 0 };
scene.objects.find(o => o.id === 'oc2').overrides = ${trigger ? '{ width: 140, isTrigger: true }' : '{ width: 140 }'};
${speed ? `__gm.state.project.assets.find(a => a.id === 'mover').props.speed = ${speed};` : ''}
__gm.state.select(null);
__gm.state.preview();
})()`);
@@ -660,6 +664,11 @@ try {
check('Move with "Collide with solids" stops the red crate flush against the blue crate\'s solid collider', blocked && Math.abs(blocked.x - -120) < 0.01, `world x=${blocked?.x}`);
const passed = await playAgainstBlue(true);
check('a trigger collider does not block (the crate passes through)', passed && passed.x > -120 + 20, `world x=${passed?.x}`);
// A frame step far larger than the collider must not jump across it (the movement is split
// into sub-steps); speed is restored afterwards so later checks see the seeded value.
const fast = await playAgainstBlue(false, 40000);
check('a very fast mover cannot tunnel through a solid collider', fast && Math.abs(fast.x - -120) < 0.5, `world x=${fast?.x}`);
await evaluate(`(() => { __gm.state.project.assets.find(a => a.id === 'mover').props.speed = 200; __gm.state.preview(); })()`);
await evaluate(`__gm.state.select({ kind: 'object', objectId: 'oc2' })`);
await sleep(200);
await shot('22-trigger-collider');
+52 -17
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* Resets the demo project to the state tools/e2e.mjs expects (the e2e run mutates it).
* Writes the demo project: a "Player" container (green character with the Mover behavior and a
* collider) facing two "Enemy" container instances, plus a Spawner that drops more enemies.
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-demo.mjs <projectId> [baseUrl]
*/
const projectId = process.argv[2];
@@ -34,11 +35,11 @@ const moverGraph = {
],
};
/** "Spawner": pressing Space places a new Crate at world (0, 150) and logs its name. */
/** "Spawner": pressing Space places another Enemy container at world (0, 150) and logs its name. */
const spawnerGraph = {
nodes: [
{ id: 's-key', type: 'OnKeyDown', x: 0, y: 0, params: { key: ' ' } },
{ id: 's-spawn', type: 'Spawn', x: 260, y: 0, params: { asset: 'crate', y: 150 } },
{ id: 's-spawn', type: 'Spawn', x: 260, y: 0, params: { asset: 'enemy', y: 150 } },
{ id: 's-log', type: 'Log', x: 520, y: 0, params: {} },
],
edges: [
@@ -48,6 +49,36 @@ const spawnerGraph = {
],
};
const GREEN = 0x45be2d;
const RED = 0xff4444;
/**
* The "Player" template: the green character sprite, its collider and the Mover behavior that
* owns it. Positions are local to the container origin (the centre of the group).
*/
const playerTemplate = [
{ id: 't-player-body', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { color: GREEN }, parentId: '', templateId: '' },
{ id: 't-player-mover', assetId: 'mover', name: '', position: { x: 0, y: 100, z: 0 }, overrides: {}, parentId: 't-player-body', templateId: '' },
{ id: 't-player-collider', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 't-player-body', templateId: '' },
];
/** The "Enemy" template: a smaller red sprite with a matching 80×80 collider, no behavior. */
const enemyTemplate = [
{ id: 't-enemy-body', assetId: 'crate', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 80, height: 80, color: RED }, parentId: '', templateId: '' },
{ id: 't-enemy-collider', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 80, height: 80 }, parentId: 't-enemy-body', templateId: '' },
];
/** One placed instance of a container: the container object plus a copy of every template object. */
const instance = (containerId, assetId, position, template, ids) => [
{ id: containerId, assetId, name: '', position, overrides: {}, parentId: '', templateId: '' },
...template.map((t) => ({
...t,
id: ids[t.id],
parentId: t.parentId ? ids[t.parentId] : containerId,
templateId: t.id,
})),
];
const project = {
formatVersion: 1,
id: projectId,
@@ -60,22 +91,24 @@ const project = {
name: 'Level 1',
props: { backgroundColor: 0x22252c },
objects: [
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, parentId: '' },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, parentId: '' },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' },
// The mover is parented to the red crate (its owner); its position is local to it.
{ id: 'o4', assetId: 'mover', name: '', position: { x: 0, y: 110, z: 0 }, overrides: {}, parentId: 'o1' },
// Both crates carry the shared "Crate collider" as a centred child; the blue crate's
// instance is widened to match its 140-wide sprite override.
{ id: 'oc1', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o1' },
{ id: 'oc2', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 140 }, parentId: 'o2' },
// Top-level spawner (no owner) left of the red crate: Space spawns a crate.
{ id: 'o5', assetId: 'spawner', name: '', position: { x: -300, y: 0, z: 0 }, overrides: {}, parentId: '' },
// The player container on the left, the camera (zoomed out to 0.5) and the spawner.
...instance('op', 'player', { x: -100, y: 0, z: 0 }, playerTemplate, {
't-player-body': 'o1', 't-player-mover': 'o4', 't-player-collider': 'oc1',
}),
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { zoom: 0.5 }, parentId: '', templateId: '' },
{ id: 'o5', assetId: 'spawner', name: '', position: { x: -100, y: 200, z: 0 }, overrides: {}, parentId: '', templateId: '' },
// Two enemies on the right, above and below the player's line.
...instance('oe1', 'enemy', { x: 100, y: -100, z: 0 }, enemyTemplate, {
't-enemy-body': 'o2', 't-enemy-collider': 'oc2',
}),
...instance('oe2', 'enemy', { x: 100, y: 100, z: 0 }, enemyTemplate, {
't-enemy-body': 'o6', 't-enemy-collider': 'oc3',
}),
],
},
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
{ id: 'collider', type: 'collider', name: 'Crate collider', props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false } },
{ id: 'crate', type: 'sprite', name: 'Character Sprite', props: { width: 100, height: 100, color: 0xe0553f } },
{ id: 'cam', type: 'camera', name: 'Main Camera', props: { width: 640, height: 360, zoom: 1 } },
{ id: 'collider', type: 'collider', name: 'Character Collider', props: { shape: 'box', width: 100, height: 100, radius: 50, isTrigger: false } },
{
id: 'mover',
type: 'behavior',
@@ -85,6 +118,8 @@ const project = {
graph: moverGraph,
},
{ id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph },
{ id: 'player', type: 'container', name: 'Player', props: {}, objects: playerTemplate },
{ id: 'enemy', type: 'container', name: 'Enemy', props: {}, objects: enemyTemplate },
],
};
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Writes the fixture scene tools/e2e.mjs asserts against: two top-level crates with a collider
* child each, a camera, the Mover behavior owning the red crate and a Spawner. Kept separate
* from tools/seed-demo.mjs so the shipped demo can change without rewriting 100+ checks; the
* fixture's objects are uniquely named and positioned exactly where the checks expect them.
*
* Usage: PATH=/opt/node22/bin:$PATH node tools/seed-e2e.mjs <projectId> [baseUrl]
* Run tools/seed-demo.mjs afterwards to put the demo project back.
*/
const projectId = process.argv[2];
const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/';
if (!projectId) {
console.error('usage: node tools/seed-e2e.mjs <projectId> [baseUrl]');
process.exit(2);
}
/**
* "Mover" behavior in five nodes: every frame, move the owner along the normalized arrow-key
* axis, scaled by the `speed` variable per second (On update's dt feeds the dt input).
* Diagonals are not faster than straight moves because Axis input normalizes the vector, and
* Move's "Collide with solids" stops the owner's solid colliders at other solid colliders.
*/
const moverGraph = {
nodes: [
{ id: 'n-update', type: 'OnUpdate', x: 0, y: 0, params: {} },
{ id: 'n-owner', type: 'Owner', x: 0, y: 120, params: {} },
{ id: 'n-axis', type: 'AxisInput', x: 0, y: 200, params: {} },
{ id: 'n-speed', type: 'GetVariable', x: 0, y: 400, params: { variable: 'speed' } },
{ id: 'n-move', type: 'Move', x: 340, y: 0, params: { collide: true } },
],
edges: [
{ id: 'e1', from: { node: 'n-update', port: 'exec' }, to: { node: 'n-move', port: 'exec' } },
{ id: 'e6', from: { node: 'n-update', port: 'dt' }, to: { node: 'n-move', port: 'dt' } },
{ id: 'e2', from: { node: 'n-owner', port: 'object' }, to: { node: 'n-move', port: 'object' } },
{ id: 'e3', from: { node: 'n-axis', port: 'x' }, to: { node: 'n-move', port: 'dx' } },
{ id: 'e4', from: { node: 'n-axis', port: 'y' }, to: { node: 'n-move', port: 'dy' } },
{ id: 'e5', from: { node: 'n-speed', port: 'value' }, to: { node: 'n-move', port: 'scale' } },
],
};
/** "Spawner": pressing Space places a new Crate at world (0, 150) and logs its name. */
const spawnerGraph = {
nodes: [
{ id: 's-key', type: 'OnKeyDown', x: 0, y: 0, params: { key: ' ' } },
{ id: 's-spawn', type: 'Spawn', x: 260, y: 0, params: { asset: 'crate', y: 150 } },
{ id: 's-log', type: 'Log', x: 520, y: 0, params: {} },
],
edges: [
{ id: 's-e1', from: { node: 's-key', port: 'exec' }, to: { node: 's-spawn', port: 'exec' } },
{ id: 's-e2', from: { node: 's-spawn', port: 'exec' }, to: { node: 's-log', port: 'exec' } },
{ id: 's-e3', from: { node: 's-spawn', port: 'object' }, to: { node: 's-log', port: 'message' } },
],
};
const project = {
formatVersion: 1,
id: projectId,
name: 'Demo project',
activeSceneId: 'scene1',
assets: [
{
id: 'scene1',
type: 'scene',
name: 'Level 1',
props: { backgroundColor: 0x22252c },
objects: [
{ id: 'o1', assetId: 'crate', name: '', position: { x: -150, y: 0, z: 0 }, overrides: {}, parentId: '' },
{ id: 'o2', assetId: 'crate', name: 'Blue crate', position: { x: 120, y: -80, z: 0 }, overrides: { width: 140, color: 0x4488ff }, parentId: '' },
{ id: 'o3', assetId: 'cam', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: '' },
// The mover is parented to the red crate (its owner); its position is local to it.
{ id: 'o4', assetId: 'mover', name: '', position: { x: 0, y: 110, z: 0 }, overrides: {}, parentId: 'o1' },
// Both crates carry the shared "Crate collider" as a centred child; the blue crate's
// instance is widened to match its 140-wide sprite override.
{ id: 'oc1', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, parentId: 'o1' },
{ id: 'oc2', assetId: 'collider', name: '', position: { x: 0, y: 0, z: 0 }, overrides: { width: 140 }, parentId: 'o2' },
// Top-level spawner (no owner) left of the red crate: Space spawns a crate.
{ id: 'o5', assetId: 'spawner', name: '', position: { x: -300, y: 0, z: 0 }, overrides: {}, parentId: '' },
],
},
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 100, height: 60, color: 0xe0553f } },
{ id: 'cam', type: 'camera', name: 'Main camera', props: { width: 640, height: 360, zoom: 1 } },
{ id: 'collider', type: 'collider', name: 'Crate collider', props: { shape: 'box', width: 100, height: 60, radius: 50, isTrigger: false } },
{
id: 'mover',
type: 'behavior',
name: 'Mover',
props: { speed: 200 },
variables: [{ key: 'speed', label: 'speed', kind: 'number', default: 0 }],
graph: moverGraph,
},
{ id: 'spawner', type: 'behavior', name: 'Spawner', props: {}, variables: [], graph: spawnerGraph },
],
};
const url = `${baseUrl}api/index.php?r=projects/${projectId}`;
const current = await fetch(url);
if (!current.ok) {
console.error(`GET failed: ${current.status}`);
process.exit(1);
}
const { revision } = await current.json();
const res = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ revision, project }) });
const body = await res.json();
if (!res.ok) {
console.error('PUT failed', res.status, body);
process.exit(1);
}
console.log(`seeded ${projectId} → revision ${body.revision}`);