diff --git a/README.md b/README.md index 7a6d39f8..adf49f94 100644 --- a/README.md +++ b/README.md @@ -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 # reset the demo project to the state the e2e run expects +node tools/seed-e2e.mjs # write the fixture scene the e2e checks assert against node tools/e2e.mjs # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png +node tools/seed-demo.mjs # 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 diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index 916c822e..172072c8 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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; diff --git a/tests/core/collider.test.ts b/tests/core/collider.test.ts index ec3ffdf2..022df6b4 100644 --- a/tests/core/collider.test.ts +++ b/tests/core/collider.test.ts @@ -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', () => { diff --git a/tools/e2e.mjs b/tools/e2e.mjs index f25c013f..fee8ce4e 100644 --- a/tools/e2e.mjs +++ b/tools/e2e.mjs @@ -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 && node tools/e2e.mjs && node tools/seed-demo.mjs */ 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'); diff --git a/tools/seed-demo.mjs b/tools/seed-demo.mjs index 061bccc3..8df662f9 100644 --- a/tools/seed-demo.mjs +++ b/tools/seed-demo.mjs @@ -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 [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 }, ], }; diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs new file mode 100644 index 00000000..1c89706e --- /dev/null +++ b/tools/seed-e2e.mjs @@ -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 [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 [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}`);