/** * Objects that travel through a `Load scene` (#165). A top-level object with "Keep on scene load" * moves into the next scene as it is – its children, its running behaviors, how its body moves – * while the scene itself is built as it is stored, whatever an earlier visit left of it. */ import { beforeAll, describe, expect, it } from 'vitest'; import { AddAnimationTrackCommand, SetAnimationKeyCommand, SetAnimationRootCommand, } from '../../src/core/commands/animationCommands'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { findScene } from '../../src/core/model/Project'; import type { PropertyValue } from '../../src/core/model/Properties'; import { KEEP_ON_SCENE_LOAD_KEY, schemaFor } from '../../src/core/properties/PropertySchema'; import { GameRun } from '../../src/runtime/GameRun'; import { keptEntities } from '../../src/runtime/keptObjects'; import { loadPhysics, readyPhysics } from '../../src/runtime/physics/engine'; import type { AudioSink } from '../../src/runtime/systems/Audio'; import { Input } from '../../src/runtime/systems/Input'; import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers'; beforeAll(async () => { await loadPhysics(); }); type Fixture = SceneFixture & { otherId: string }; /** The sprite scene plus a second, empty one. */ function twoScenes(): Fixture { const f = makeSpriteScene('Box'); const second = new CreateAssetCommand('scene', 'Level 2'); f.stack.execute(second); return { ...f, otherId: second.asset.id }; } function placeIn(f: Fixture, sceneId: string, assetId: string, x: number, parentId = ''): string { const command = new PlaceAssetCommand(sceneId, assetId, { x, y: 0 }, parentId); f.stack.execute(command); return command.objectId; } function set(f: Fixture, sceneId: string, objectId: string, key: string, value: PropertyValue): void { f.stack.execute(new SetOverrideCommand(sceneId, objectId, key, value)); } function keep(f: Fixture, sceneId: string, objectId: string): void { set(f, sceneId, objectId, KEEP_ON_SCENE_LOAD_KEY, true); } function start(f: Fixture, audio?: AudioSink) { const stoppedOwners: string[] = []; const sink: AudioSink = audio ?? { play: () => 0, playNote: () => 0, release: () => {}, tune: () => {}, length: () => 0, stop: () => {}, tail: () => 0, stopVoice: () => {}, stopOwner: (owner: string) => stoppedOwners.push(owner), }; const run = GameRun.start(f.project, f.sceneId, new Input(), { audio: sink, physics: readyPhysics()!, clock: { scale: 1 }, saves: { read: () => ({}), write: () => {} }, }); const frames = (count: number) => { for (let i = 0; i < count; i++) run.tick(1 / 60, 1 / 60); }; const names = () => run.world.entities.map((e) => e.name); return { run, frames, names, stoppedOwners }; } /** A behavior that says `start` and `destroy` in the log, placed below `ownerId`. */ function brainBelow(f: Fixture, ownerId: string): string { const behavior = makeBehavior(f, 'Brain', ['count']); const edit = graphEditor(f, behavior); edit.connect(edit.addNode('OnStart'), 'exec', edit.addNode('Log', { message: 'start' }), 'exec'); edit.connect(edit.addNode('OnDestroy'), 'exec', edit.addNode('Log', { message: 'destroy' }), 'exec'); return place(f, behavior.id, { x: 0, y: 0 }, ownerId); } describe('Keep on scene load', () => { it('is a switch every placeable object has, off unless set', () => { for (const type of ['sprite', 'behavior', 'container', 'camera'] as const) expect(schemaFor(type).find((d) => d.key === KEEP_ON_SCENE_LOAD_KEY)?.default).toBe(false); expect(schemaFor('scene').some((d) => d.key === KEEP_ON_SCENE_LOAD_KEY)).toBe(false); }); it('takes the object into the next scene as the same entity, where the run left it', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 10, y: 0 }); const hat = place(f, f.spriteId, { x: 1, y: 2 }, hero); const other = place(f, f.spriteId, { x: 50, y: 0 }); keep(f, f.sceneId, hero); const { run } = start(f); const before = run.world.findByObjectId(hero)!; const child = run.world.findByObjectId(hat)!; before.x = 77; before.props.rotation = 45; run.loadScene(f.otherId); expect(run.world.sceneId).toBe(f.otherId); expect(run.world.findByObjectId(hero)).toBe(before); expect(run.world.findByObjectId(hat)).toBe(child); expect(child.parent).toBe(before); expect(run.world.findByObjectId(other)).toBeUndefined(); expect([before.x, before.props.rotation]).toEqual([77, 45]); // The new scene holds the objects now, and the next write-back says where the run has them – // and that the scene is another one, which is what brings the panels up to date. expect(run.world.syncToModel().structure).toBe(true); const stored = findScene(f.project, f.otherId)!.objects; expect(stored.map((o) => o.id)).toEqual([hero, hat]); expect(stored[0]!.position.x).toBe(77); expect(stored[0]!.overrides.rotation).toBe(45); }); it('keeps only top-level objects: a switch further down keeps nothing', () => { const f = twoScenes(); const parent = place(f, f.spriteId, { x: 0, y: 0 }); const child = place(f, f.spriteId, { x: 0, y: 0 }, parent); set(f, f.sceneId, child, KEEP_ON_SCENE_LOAD_KEY, true); const { run } = start(f); expect(keptEntities(run.world.entities)).toEqual([]); run.loadScene(f.otherId); expect(run.world.entities).toEqual([]); }); it('parents what it brings as the run has it, written back or not', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 0, y: 0 }); const arm = place(f, f.spriteId, { x: 5, y: 0 }, hero); const hand = place(f, f.spriteId, { x: 5, y: 0 }, arm); keep(f, f.sceneId, hero); const { run } = start(f); const top = run.world.findByObjectId(hero)!; // A released game never writes the run back, so the scene still says the hand hangs on the arm. run.world.removeKeepingChildren(run.world.findByObjectId(arm)!); run.loadScene(f.otherId); expect(run.world.entities.map((e) => e.objectId)).toEqual([hero, hand]); expect(run.world.findByObjectId(hand)!.parent).toBe(top); }); it('carries a running behavior on: no second On start, no On destroy, its values and sounds stay', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 0, y: 0 }); const brain = brainBelow(f, hero); const lost = brainBelow(f, place(f, f.spriteId, { x: 0, y: 0 })); keep(f, f.sceneId, hero); const { run, frames, stoppedOwners } = start(f); frames(1); const first = run.world; const entity = first.findByObjectId(brain)!; entity.props.count = 5; expect(first.log).toEqual(['start', 'start']); run.loadScene(f.otherId); frames(2); // The behavior left behind said goodbye in its own world, the kept one said nothing anywhere. expect(first.log).toEqual(['start', 'start', 'destroy']); expect(run.world.log).toEqual([]); expect(run.behaviors.runningIds()).toEqual([brain]); expect(run.world.findByObjectId(brain)!.props.count).toBe(5); expect(stoppedOwners).toEqual([first.findByObjectId(lost)?.id ?? `e-${lost}`]); }); it('times the trace of a carried behavior by the world it runs in now', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 0, y: 0 }); const behavior = makeBehavior(f, 'Ticker'); const edit = graphEditor(f, behavior); const update = edit.addNode('OnUpdate'); edit.connect(update, 'exec', edit.addNode('Log', { message: 'tick' }), 'exec'); place(f, behavior.id, { x: 0, y: 0 }, hero); keep(f, f.sceneId, hero); const { run, frames } = start(f); frames(30); run.loadScene(f.otherId); frames(1); const seen = run.behaviors.traceFor(behavior.id).nodes.get(update.id)!; expect(seen).toBeLessThanOrEqual(run.world.time); expect(run.world.log).toEqual(['tick']); }); it('lets a dynamic body move on as it moved', () => { const f = twoScenes(); const ball = place(f, f.spriteId, { x: 0, y: 0 }); f.stack.execute(new AddColliderCommand(f.sceneId, ball)); f.stack.execute(new AddRigidbodyCommand(f.sceneId, ball)); keep(f, f.sceneId, ball); const { run, frames } = start(f); const entity = run.world.findByObjectId(ball)!; frames(30); const y = entity.y; frames(1); const falling = entity.y - y; expect(falling).toBeGreaterThan(1); run.loadScene(f.otherId); // The picture is drawn between the last two steps, so the first frame of a new engine stands still. frames(1); const arrived = entity.y; frames(1); // From rest it would fall a fraction of a unit by now, not as far as it did before the swap. expect(entity.y - arrived).toBeGreaterThan(falling * 0.9); }); it('lets an animation play on from where it is', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 0, y: 0 }); const walk = new CreateAssetCommand('animation', 'Walk', { fps: 10, playback: 'once' }); f.stack.execute(walk); f.stack.execute(new SetAnimationRootCommand(walk.asset.id, f.spriteId)); const row = new AddAnimationTrackCommand(walk.asset.id, '', 'x'); f.stack.execute(row); f.stack.execute(new SetAnimationKeyCommand(walk.asset.id, row.trackId, 0, 0)); f.stack.execute(new SetAnimationKeyCommand(walk.asset.id, row.trackId, 10, 100)); const behavior = makeBehavior(f, 'Walker'); const edit = graphEditor(f, behavior); const play = edit.addNode('PlayAnimation', { animation: walk.asset.id }); edit.wire([ [edit.addNode('OnStart'), 'exec', play, 'exec'], [edit.addNode('Owner'), 'object', play, 'object'], ]); place(f, behavior.id, { x: 0, y: 0 }, hero); keep(f, f.sceneId, hero); const { run, frames } = start(f); const entity = run.world.findByObjectId(hero)!; frames(19); const half = entity.x; expect(half).toBeGreaterThan(20); expect(half).toBeLessThan(40); run.loadScene(f.otherId); frames(18); // 0.3 s more of the same second: it went on from where it was instead of starting over or stopping. expect(entity.x).toBeCloseTo(half + 30, 5); expect(entity.animations).toHaveLength(1); }); it('finds collisions anew: two kept objects that were touching get their Enter again', () => { const f = twoScenes(); const ids = [0, 1].map(() => { const id = place(f, f.spriteId, { x: 0, y: 0 }); f.stack.execute(new AddColliderCommand(f.sceneId, id)); keep(f, f.sceneId, id); return id; }); const behavior = makeBehavior(f, 'Feeler'); const edit = graphEditor(f, behavior); edit.connect(edit.addNode('OnCollisionEnter'), 'exec', edit.addNode('Log', { message: 'enter' }), 'exec'); place(f, behavior.id, { x: 0, y: 0 }, ids[0]); const { run, frames } = start(f); frames(3); expect(run.world.log).toEqual(['enter']); run.loadScene(f.otherId); frames(3); expect(run.world.log).toEqual(['enter']); }); it('wins over what the next scene holds of the same thing, and answers for it', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 10, y: 0 }); keep(f, f.sceneId, hero); const twin = placeIn(f, f.otherId, f.spriteId, 500); const twinHat = placeIn(f, f.otherId, f.spriteId, 0, twin); keep(f, f.otherId, twin); const plain = placeIn(f, f.otherId, f.spriteId, 900); const { run } = start(f); const entity = run.world.findByObjectId(hero)!; run.loadScene(f.otherId); // The scene's own kept object of that asset is not built, one that is not kept is. expect(run.world.entities.map((e) => e.objectId)).toEqual([plain, hero]); expect(entity.x).toBe(10); // Whatever in the scene named its own one finds the one that came instead. expect(run.world.findByObjectId(twin)).toBe(entity); expect(run.world.findByObjectId(twinHat)).toBeUndefined(); run.world.remove(entity); expect(run.world.findByObjectId(twin)).toBeUndefined(); }); it('comes back to the scene it left as the one object, not as two', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 10, y: 0 }); const other = place(f, f.spriteId, { x: 50, y: 0 }); keep(f, f.sceneId, hero); const { run } = start(f); const entity = run.world.findByObjectId(hero)!; entity.x = 77; run.loadScene(f.otherId); run.loadScene(f.sceneId); expect(run.world.entities.map((e) => e.objectId)).toEqual([other, hero]); expect(run.world.findByObjectId(hero)).toBe(entity); expect(entity.x).toBe(77); // The scene holds it once as well: its own object of that id made way for the one coming back. expect(findScene(f.project, f.sceneId)!.objects.map((o) => o.id)).toEqual([other, hero]); // Loading the scene it is in keeps it just the same. run.loadScene(f.sceneId); expect(run.world.entities.map((e) => e.objectId)).toEqual([other, hero]); expect(run.world.findByObjectId(hero)).toBe(entity); }); it('keeps an object the run itself switched to kept, and it too comes back as the one object', () => { const f = twoScenes(); const hero = place(f, f.spriteId, { x: 10, y: 0 }); const { run } = start(f); const entity = run.world.findByObjectId(hero)!; // What a `Properties` node set to "Set" does; the scene's own object never hears of it. entity.props[KEEP_ON_SCENE_LOAD_KEY] = true; run.loadScene(f.otherId); run.loadScene(f.sceneId); expect(run.world.entities).toEqual([entity]); expect(findScene(f.project, f.sceneId)!.objects.map((o) => o.id)).toEqual([hero]); }); it('takes a placed container along with its content', () => { const f = twoScenes(); const a = place(f, f.spriteId, { x: 0, y: 0 }); const b = place(f, f.spriteId, { x: 20, y: 0 }); const create = new CreateContainerCommand(f.sceneId, [a, b]); f.stack.execute(create); const placed = findScene(f.project, f.sceneId)!.objects[0]!; keep(f, f.sceneId, placed.id); const { run } = start(f); const before = [...run.world.entities]; expect(before).toHaveLength(3); run.loadScene(f.otherId); expect(run.world.entities).toEqual(before); expect(run.world.entities.slice(1).every((e) => e.parent === before[0])).toBe(true); }); }); describe('Load scene builds the scene as it is stored', () => { it('whatever the run did in it on an earlier visit', () => { const f = twoScenes(); const moved = place(f, f.spriteId, { x: 10, y: 0 }); const destroyed = place(f, f.spriteId, { x: 20, y: 0 }); const { run } = start(f); run.world.findByObjectId(moved)!.x = 99; run.world.remove(run.world.findByObjectId(destroyed)!); run.world.spawn(f.spriteId, { x: 0, y: 0, z: 0 }, null); run.world.syncToModel(); expect(findScene(f.project, f.sceneId)!.objects).toHaveLength(2); run.loadScene(f.sceneId); expect(run.world.entities.map((e) => [e.objectId, e.x])).toEqual([ [moved, 10], [destroyed, 20], ]); run.world.findByObjectId(moved)!.x = 55; run.world.syncToModel(); run.loadScene(f.otherId); run.loadScene(f.sceneId); expect(run.world.findByObjectId(moved)!.x).toBe(10); }); it('and the end of a run gives every scene back what it held before', () => { const f = twoScenes(); const a = place(f, f.spriteId, { x: 0, y: 0 }); const hero = place(f, f.spriteId, { x: 20, y: 0 }); f.stack.execute(new CreateContainerCommand(f.sceneId, [a])); keep(f, f.sceneId, hero); placeIn(f, f.otherId, f.spriteId, 5); const before = structuredClone([f.sceneId, f.otherId].map((id) => findScene(f.project, id)!.objects)); const { run } = start(f); run.world.spawn(f.spriteId, { x: 0, y: 0, z: 0 }, null); run.loadScene(f.otherId); run.world.syncToModel(); expect(findScene(f.project, f.otherId)!.objects).toHaveLength(2); run.end(); expect([f.sceneId, f.otherId].map((id) => findScene(f.project, id)!.objects)).toEqual(before); expect(findScene(f.project, f.sceneId)!.groups).toBeUndefined(); }); });