From 328f978d3c5503e1fabc79fc2674ef9e94ea632f Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 18 Sep 2026 20:08:09 +0200 Subject: [PATCH] Set gravity survives a re-read of the model during play: World.setSceneProp remembers what the run itself set on the scene and syncFromModel lays it back over the copied scene props, so a project event mid-play no longer resets the gravity to the saved 980; the unit test re-reads the model mid-run and e2e 41c forces a project event right after play starts (#147) Co-Authored-By: Claude Opus 5 --- src/runtime/World.ts | 10 +++++++++- src/runtime/physics/PhysicsSystem.ts | 4 ++-- tests/core/physics-system.test.ts | 10 ++++++++-- tools/e2e/41-physics.mjs | 3 +++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 4e6b73a8..1afb658b 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -37,6 +37,8 @@ export class World { readonly entities: Entity[] = []; /** The running scene's own properties – read from the model, and written by an animation over the scene. */ readonly sceneProps: PropertyBag = {}; + /** What the run itself set on the scene (`setSceneProp`); re-reading the model does not undo it. */ + private readonly runSceneProps: PropertyBag = {}; /** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */ stopRequested = false; /** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */ @@ -98,7 +100,7 @@ export class World { // behavior destroy one of them for good. Play does it (on its throwaway copy); the editor run // must not, so there the content stays resolved and `objectRef` writes into the instance edits. if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true; - Object.assign(this.sceneProps, scene.props); + Object.assign(this.sceneProps, scene.props, this.runSceneProps); const objects = new Map(resolveObjects(this.project, scene).map((o) => [o.id, o])); const keep = new Set(); for (const object of objects.values()) { @@ -115,6 +117,12 @@ export class World { } } + /** Sets a scene property for the rest of the run; the scene in the model keeps its own. */ + setSceneProp(key: string, value: PropertyBag[string]): void { + this.sceneProps[key] = value; + this.runSceneProps[key] = value; + } + /** The entity for a scene object, created when missing; position, name and props re-read. */ private materialize(object: SceneObject, asset: Asset): Entity { let entity = this.findByObjectId(object.id); diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index 2852b166..d65b2a20 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -216,8 +216,8 @@ export class PhysicsSystem implements System, CollisionSource { /** The scene's gravity for the rest of this run, in world units per second squared. */ setGravity(world: World, gravity: Vec2): void { - world.sceneProps.gravityX = gravity.x; - world.sceneProps.gravityY = gravity.y; + world.setSceneProp('gravityX', gravity.x); + world.setSceneProp('gravityY', gravity.y); if (this.engine) this.engine.gravity = this.gravity(world); } diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index 8b857db7..e663d578 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -586,13 +586,19 @@ describe('physics from the graph (#147)', () => { it('Set gravity turns the fall around for the rest of the run; the scene in the project keeps its own', () => { const f = physicsScene(); + f.gravity(0, 980); const box = f.add(0, 0, { w: 32, body: {} }); onOwner(f, box, 'OnStart', 'SetGravity', { x: 0, y: -980 }, true); const { seconds, entity, world } = run(f); - seconds(0.5); + seconds(0.25); + // A model event while playing re-reads the scene, after the tick wrote back (as in PlaySession); + // the run's own gravity outlives it. + world.syncToModel(); + world.syncFromModel(); + seconds(0.25); expect(entity(box).y).toBeLessThan(-100); expect(world.sceneProps.gravityY).toBe(-980); - expect(findAsset(f.project, f.sceneId)!.props.gravityY ?? 980).toBe(980); + expect(findAsset(f.project, f.sceneId)!.props.gravityY).toBe(980); }); it('moving a dynamic body directly keeps its speed and says so once; a physics node on an object without one says so once', () => { diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index 47780690..b9ec7253 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -258,6 +258,9 @@ export async function physics(t) { put('phys-pushed-rb', 'phys-rb-free', 'phys-pushed', 0, 0); __gm.state.select(null)`); await play(); + // A model event while playing re-reads the scene (CI had one in the first frames): what the run + // set with Set gravity has to outlive it. + await evaluate(`__gm.state.preview()`); await evaluate(`(() => { const out = (window.__physicsGraph = []); const start = performance.now();