diff --git a/src/runtime/World.ts b/src/runtime/World.ts index c2eea983..13c720a0 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -2,10 +2,10 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from import { newId } from '../core/ids'; import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset'; import { sameAtRest, withoutObjects } from '../core/graph/liveObjects'; -import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties'; +import { bagWithoutItemTypes, type PropertyBag, setBagValue, type Vec2 } from '../core/model/Properties'; import { isPlaceableType } from '../core/model/AssetTypes'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; -import { groupNamed, treeOrder } from '../core/model/hierarchy'; +import { groupNamed, type ObjectTree, treeOrder } from '../core/model/hierarchy'; import { liveTileAt, withTile } from '../core/model/liveTilemap'; import { referenceCandidates } from '../core/model/objectRefs'; import { type Cell, presentTile } from '../core/model/tilemap'; @@ -47,6 +47,36 @@ export interface ModelWrite { values: boolean; } +/** + * What the model said of an entity when the world last read it or wrote it. `syncToModel` runs every + * frame and compares against this instead of resolving every object anew (#287), which holds as long + * as whoever else changes the model has it read again (`syncFromModel`). + */ +interface ModelState { + asset: Asset; + x: number; + y: number; + z: number; + props: PropertyBag; +} + +const NO_KEYS: readonly string[] = []; + +/** The keys whose value in the run no longer says what the model does. */ +function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] { + let keys: string[] | undefined; + for (const key of Object.keys(live)) { + const value = live[key]; + const atRest = model.props[key]; + if (atRest === value || !(key in model.props)) continue; + // What a run holds of an object stays in the run; the scene gets the empty one. + // A private variable (#257) has no override to hold its value: that stays in the run. + if (isPrivateVariable(model.asset, key) || sameAtRest(atRest, value)) continue; + (keys ??= []).push(key); + } + return keys ?? NO_KEYS; +} + /** * The running game: entities of one scene plus the systems ticking them. Built on the play copy * (discarded on stop) or, for the editor run, on the saved model, where its writes stay. @@ -103,6 +133,9 @@ export class World { private structureChanged = false; /** Children a Destroy moved up to their grandparent; written into the scene by `syncToModel`. */ private readonly movedUp = new Set(); + /** Set when an entity left the run; the next `syncToModel` drops its object. */ + private entitiesGone = false; + private readonly modelStates = new WeakMap(); private tilesChanged = false; private constructor( @@ -257,7 +290,8 @@ export class World { entity.x = object.position.x; entity.y = object.position.y; entity.z = object.position.z; - const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); + const resolved = this.modelProps(asset, object); + this.modelStates.set(entity, { asset, ...object.position, props: resolved }); for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; // The scene only ever got the objects' empty places, so a value that differs by them alone stays. // A private value never reaches the scene, so the scene's is only where it starts. @@ -268,6 +302,11 @@ export class World { return entity; } + /** An object's values as a run holds them. */ + private modelProps(asset: Asset, object: SceneObject): PropertyBag { + return bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); + } + /** * Adds the object to the scene and creates only its entity, leaving the others alone (they may have * moved this tick already). Position is local to `parent`, world when there is none. @@ -318,10 +357,47 @@ export class World { } const reparented = this.movedUp.size > 0; this.movedUp.clear(); - // Looked up once for the whole pass: asking the list per object made this grow with the square - // of the entities, every frame of a run (#284). The first of an id counts, as `findByObjectId` has it. - const alive = new Map(); - for (const e of this.entities) if (!alive.has(e.objectId)) alive.set(e.objectId, e); + const dropped = this.entitiesGone && this.dropGone(scene); + this.entitiesGone = false; + for (const entity of this.entities) { + // A dragged object's place belongs to the drag, which reaches the entity on the next re-read. + if (this.held.has(entity.objectId)) continue; + const model = this.modelStates.get(entity); + if (!model) continue; + const moved = model.x !== entity.x || model.y !== entity.y || model.z !== entity.z; + const drifted = driftedKeys(entity.props, model); + if (!moved && drifted.length === 0) continue; + // Only now, because a ref for resolved content costs a resolve of its own – and because a + // direct write to such an object would land in the freshly built copy and be lost. + const ref = objectRef(this.project, scene, entity.objectId); + if (!ref) continue; + if (moved) { + ref.setPosition({ x: entity.x, y: entity.y, z: entity.z }); + model.x = entity.x; + model.y = entity.y; + model.z = entity.z; + } + if (drifted.length > 0) { + // Read again rather than patched: an override may bring rows of its own (a sprite's shader). + // A container's content is a copy made before the write, so the written values go on top. + const overrides = { ...ref.object.overrides }; + for (const key of drifted) { + const value = withoutObjects(entity.props[key]); + ref.setOverride(key, value); + setBagValue(overrides, key, value); + } + model.props = this.modelProps(model.asset, { ...ref.object, overrides }); + } + values = true; + } + return { structure: spawned || reparented || dropped, values }; + } + + /** Drops the objects whose entity left the run; true when there was one. */ + private dropGone(scene: ObjectTree): boolean { + // Looked up once for the whole pass: asking the list per object grows with the square of the + // entities (#284). The first of an id counts, as `findByObjectId` has it. + const alive = new Set(this.entities.map((e) => e.objectId)); const assets = new Map(); for (const asset of this.project.assets) if (!assets.has(asset.id)) assets.set(asset.id, asset); const before = scene.objects.length; @@ -330,34 +406,7 @@ export class World { const asset = assets.get(object.assetId); if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1); } - for (const object of resolveObjects(this.project, scene)) { - // A dragged object's place belongs to the drag, which reaches the entity on the next re-read. - if (this.held.has(object.id)) continue; - const entity = alive.get(object.id); - const asset = entity && assets.get(entity.assetId); - if (!entity || !asset) continue; - const p = object.position; - const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z; - const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); - // What a run holds of an object stays in the run; the scene gets the empty one. - // A private variable (#257) has no override to hold its value: that stays in the run. - const drifted = Object.keys(entity.props).filter( - (k) => - k in base && - !isPrivateVariable(asset, k) && - base[k] !== entity.props[k] && - !sameAtRest(base[k], entity.props[k]), - ); - if (!moved && drifted.length === 0) continue; - // Only now, because a ref for resolved content costs a resolve of its own – and because a - // direct write to such an object would land in the freshly built copy and be lost. - const ref = objectRef(this.project, scene, object.id); - if (!ref) continue; - if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z }); - for (const key of drifted) ref.setOverride(key, withoutObjects(entity.props[key])); - values = true; - } - return { structure: spawned || reparented || scene.objects.length !== before, values }; + return scene.objects.length !== before; } /** Registers a system; it starts immediately and is ticked in registration order. */ @@ -520,7 +569,9 @@ export class World { remove(entity: Entity): void { for (const child of this.childrenOf(entity)) this.remove(child); const index = this.entities.indexOf(entity); - if (index >= 0) this.entities.splice(index, 1); + if (index < 0) return; + this.entities.splice(index, 1); + this.entitiesGone = true; } /** diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts index f5fa73db..a17e9046 100644 --- a/tests/core/container.test.ts +++ b/tests/core/container.test.ts @@ -220,6 +220,31 @@ describe('placing a container', () => { expect(f.sceneOf().objects).toHaveLength(1); expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined(); }); + + it('writes what a run changes of unbaked content into the instance edits, once (#287)', () => { + const f = templateSetup(); + const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false }); + const instance = () => f.sceneOf().objects[0]!; + const content = world.findByObjectId(f.child(f.tA))!; + const home = { x: content.x, y: content.y }; + + content.x = home.x + 7; + content.props.width = 12; + expect(world.syncToModel()).toEqual({ structure: false, values: true }); + expect(instance().instanceEdits[f.tA]).toMatchObject({ + position: { x: home.x + 7 }, + overrides: { width: 12 }, + }); + expect(world.syncToModel()).toEqual({ structure: false, values: false }); + + // Back at the template's place the edit of the place is gone, and the next frame has nothing to say. + content.x = home.x; + expect(world.syncToModel().values).toBe(true); + expect(instance().instanceEdits[f.tA]).toEqual({ position: {}, overrides: { width: 12 } }); + expect(world.syncToModel().values).toBe(false); + world.syncFromModel(); + expect({ x: content.x, y: content.y, width: content.props.width }).toEqual({ ...home, width: 12 }); + }); }); describe('editing a container template', () => { diff --git a/tests/core/world-sync.test.ts b/tests/core/world-sync.test.ts index 635bbef5..7729109b 100644 --- a/tests/core/world-sync.test.ts +++ b/tests/core/world-sync.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; import { ClearOverrideCommand, + MoveSceneObjectCommand, RemoveSceneObjectsCommand, SetOverrideCommand, SetParentCommand, } from '../../src/core/commands/sceneObjectCommands'; import { worldMatrixOf } from '../../src/core/graph'; -import { findScene } from '../../src/core/model/Project'; +import { findAsset, findScene } from '../../src/core/model/Project'; +import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; import { World } from '../../src/runtime/World'; import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; @@ -81,6 +84,102 @@ describe('world ↔ model synchronisation (editing while playing)', () => { expect(sceneOf().objects.map((o) => o.id)).toEqual([a]); }); + it('syncToModel asks the model again only for an object whose values it wrote (#287)', () => { + const f = setup(); + const { project, spriteId, a, b, object } = f; + const { world } = startWorld(f); + // Every resolve of a placed box reads its asset's values, so the reads count the resolves. + const sprite = findAsset(project, spriteId)!; + let reads = 0; + sprite.props = new Proxy(sprite.props, { + get(bag, key, receiver) { + reads++; + return Reflect.get(bag, key, receiver) as unknown; + }, + }); + for (let frame = 0; frame < 60; frame++) + expect(world.syncToModel()).toEqual({ structure: false, values: false }); + expect(reads).toBe(0); + + // A place is three numbers: written, and nothing to resolve. + world.findByObjectId(b)!.x = 310; + expect(world.syncToModel().values).toBe(true); + expect(object(b).position.x).toBe(310); + expect(reads).toBe(0); + expect(world.syncToModel().values).toBe(false); + + // A value is written as an override, and that one object is read again – once. + world.findByObjectId(a)!.props.width = 999; + expect(world.syncToModel().values).toBe(true); + expect(object(a).overrides).toEqual({ width: 999 }); + const afterWrite = reads; + expect(afterWrite).toBeGreaterThan(0); + expect(world.syncToModel()).toEqual({ structure: false, values: false }); + expect(reads).toBe(afterWrite); + }); + + it('an edit made during the run arrives and is not overwritten by an older picture (#287)', () => { + const f = setup(); + const { stack, sceneId, spriteId, a, b, object } = f; + const { world } = startWorld(f); + const entityA = world.findByObjectId(a)!; + const entityB = world.findByObjectId(b)!; + entityA.props.width = 999; + world.syncToModel(); + + // The editor has the model read again after every command, as `Editor` does on a project event. + const edit = (command: Parameters[0]) => { + stack.execute(command); + world.syncFromModel(); + }; + edit(new SetOverrideCommand(sceneId, a, 'width', 40)); + expect(entityA.props.width).toBe(40); + expect(world.syncToModel()).toEqual({ structure: false, values: false }); + expect(object(a).overrides).toEqual({ width: 40 }); + + edit(new SetAssetPropertyCommand(spriteId, 'height', 70)); + expect(entityB.props.height).toBe(70); + expect(world.syncToModel()).toEqual({ structure: false, values: false }); + expect(object(b).overrides).toEqual({}); + + edit(new MoveSceneObjectCommand(sceneId, b, { x: 5, y: 6, z: 0 })); + expect(world.syncToModel().values).toBe(false); + expect(object(b).position).toEqual({ x: 5, y: 6, z: 0 }); + + // What the run changes afterwards goes on top of the edit, key by key. + entityA.props.height = 11; + expect(world.syncToModel().values).toBe(true); + expect(object(a).overrides).toEqual({ width: 40, height: 11 }); + + // An object placed during the run is mirrored like the others. + const c = place(f, spriteId, { x: 50, y: 50 }); + world.syncFromModel(); + world.findByObjectId(c)!.y = 77; + expect(world.syncToModel().values).toBe(true); + expect(object(c).position.y).toBe(77); + }); + + it('an override that brings rows of its own is seen with them from the next frame on (#287)', () => { + const f = makeSpriteScene(); + const text = new CreateAssetCommand('text', 'Title'); + const shader = new CreateAssetCommand('shader', 'Glow'); + f.stack.execute(text); + f.stack.execute(shader); + const declared = { name: 'glow', default: 3, min: 0, max: 10 }; + f.stack.execute(new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, declared)); + const id = place(f, text.asset.id, { x: 0, y: 0 }); + const { world } = startWorld(f); + const entity = world.findByObjectId(id)!; + + entity.props[SHADER_KEY] = shader.asset.id; + world.syncToModel(); + expect(f.object(id).overrides).toEqual({ [SHADER_KEY]: shader.asset.id }); + // The shader's row is the object's now, so a value the run sets for it is one to write. + entity.props.glow = 8; + expect(world.syncToModel().values).toBe(true); + expect(f.object(id).overrides.glow).toBe(8); + }); + it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => { const f = setup(); const { sceneId, spriteId, a, sceneOf } = f;