Editing while playing: play runs on a runtime copy, new Game tab (Scene | Game | Graph)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 16:34:26 +02:00
1 parent cb8885359f
commit c3a533c4e5
14 files changed
+408 -118

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { CommandStack } from '../../src/core/commands/CommandStack';
import { ClearOverrideCommand, PlaceAssetCommand, RemoveSceneObjectCommand, SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { World } from '../../src/runtime/World';
/** A scene with a 100×100 box asset and two placed boxes. */
function setup() {
const project = createEmptyProject('p', 'Sync');
const stack = new CommandStack(project);
const scene = new CreateAssetCommand('scene', 'Level');
const sprite = new CreateAssetCommand('sprite', 'Box');
stack.execute(scene);
stack.execute(sprite);
const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 0 });
const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 300, y: 0 });
stack.execute(a);
stack.execute(b);
const sceneOf = () => findScene(project, scene.asset.id)!;
const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, sceneOf, object };
}
describe('world ↔ model synchronisation (editing while playing)', () => {
it('syncFromModel picks up placed, moved, edited, re-parented and removed objects', () => {
const { project, stack, sceneId, spriteId, a, b, object } = setup();
const world = World.instantiate(project, sceneId);
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
// Placed while playing → new entity, existing ones keep their identity.
const entityA = world.findByObjectId(a)!;
const c = new PlaceAssetCommand(sceneId, spriteId, { x: 50, y: 50 });
stack.execute(c);
world.syncFromModel();
expect(world.entities).toHaveLength(3);
expect(world.findByObjectId(a)).toBe(entityA);
// Property edits (override, asset value) and parenting are reflected; props bag keeps identity.
const propsA = entityA.props;
stack.execute(new SetOverrideCommand(sceneId, a, 'width', 40));
stack.execute(new SetAssetPropertyCommand(spriteId, 'height', 70));
stack.execute(new SetParentCommand(sceneId, b, a));
world.syncFromModel();
expect(entityA.props).toBe(propsA);
expect(entityA.props.width).toBe(40);
expect(entityA.props.height).toBe(70);
expect(world.findByObjectId(b)!.parent).toBe(entityA);
expect(world.findByObjectId(b)!.x).toBe(object(b).position.x);
// Removed while playing → entity gone.
stack.execute(new RemoveSceneObjectCommand(sceneId, c.objectId));
world.syncFromModel();
expect(world.entities.map((e) => e.objectId)).toEqual([a, b]);
});
it('syncToModel writes positions, changed values as overrides and destroyed entities back', () => {
const { project, sceneId, a, b, object, sceneOf } = setup();
const world = World.instantiate(project, sceneId);
const entityA = world.findByObjectId(a)!;
entityA.x = 123;
entityA.props.width = 999; // what a Set property node would do
entityA.props.height = 100; // unchanged → no override
expect(world.syncToModel()).toBe(false);
expect(object(a).position).toEqual({ x: 123, y: 0, z: 0 });
expect(object(a).overrides).toEqual({ width: 999 });
// Round trip: the model now resolves to the runtime values, so a sync back changes nothing.
world.syncFromModel();
expect(entityA.x).toBe(123);
expect(entityA.props.width).toBe(999);
// Resetting the override in the editor returns the entity to the asset value.
new ClearOverrideCommand(sceneId, a, 'width').execute(project);
world.syncFromModel();
expect(entityA.props.width).toBe(100);
// Destroy (node) removes the entity; the sync drops its object – a structural change.
world.remove(world.findByObjectId(b)!);
expect(world.syncToModel()).toBe(true);
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
});
it('a play-mode copy leaves the original model untouched', () => {
const { project, sceneId, a, object } = setup();
const copy = deserializeProject(serializeProject(project), project.id);
const world = World.instantiate(copy, sceneId);
world.findByObjectId(a)!.x = 500;
world.remove(world.findByObjectId(a)!);
world.syncToModel();
expect(findScene(copy, sceneId)!.objects).toHaveLength(1);
expect(object(a).position.x).toBe(0);
expect(findScene(project, sceneId)!.objects).toHaveLength(2);
});
});