diff --git a/README.md b/README.md index 49da083a..93a5cbc7 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1619 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1625 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/model/animation.ts b/src/core/model/animation.ts index 825c80c1..7c526769 100644 --- a/src/core/model/animation.ts +++ b/src/core/model/animation.ts @@ -1,5 +1,10 @@ import { isKindWith } from '../properties/kinds'; -import { type PropertyDef, schemaFor, shaderParamRows } from '../properties/PropertySchema'; +import { + KEEP_ON_SCENE_LOAD_KEY, + type PropertyDef, + schemaFor, + shaderParamRows, +} from '../properties/PropertySchema'; import { shaderOf } from '../properties/shaders'; import { type AnimationAsset, @@ -354,7 +359,8 @@ function isFlat(def: PropertyDef): boolean { * so neither offers a position – a behavior's variable `x` is the variable. */ export function animatableDefs(type: AssetType): PropertyDef[] { - const own = schemaFor(type).filter(isFlat); + // Whether an object is kept through a scene swap is nothing a timeline moves (#165). + const own = schemaFor(type).filter((def) => isFlat(def) && def.key !== KEEP_ON_SCENE_LOAD_KEY); if (!hasPosition(type)) return own; const position: PropertyDef[] = POSITION_KEYS.map((key) => ({ key, diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index f457e2ec..e9359963 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -234,12 +234,16 @@ export function flatDef( return { key, label, kind, default: coerceField(value, kind) } as FlatDef; } +export const KEEP_ON_SCENE_LOAD_KEY = 'keepOnSceneLoad'; + /** * Properties every placeable asset has. A disabled object is not drawn, does not collide and its - * behavior does not run – and the same goes for everything parented below it. + * behavior does not run – and the same goes for everything parented below it. A kept one travels + * through a `Load scene` with everything below it (#165), which only a top-level object can. */ export const COMMON_PROPS: PropertySchema = [ { key: 'enabled', label: 'Enabled', kind: 'boolean', default: true }, + { key: KEEP_ON_SCENE_LOAD_KEY, label: 'Keep on scene load', kind: 'boolean', default: false }, ]; /** diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index ccb6f75f..6a92140c 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -36,6 +36,7 @@ import { isRowShown, isSortProp, isTransformProp, + KEEP_ON_SCENE_LOAD_KEY, type PropertyDef, schemaFor, schemaForObject, @@ -166,7 +167,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object // The master switch, right where it is looked for: above everything type-specific. const resolved = resolveSceneObjectProps(host.state.project, asset, object); - for (const def of COMMON_PROPS) propertyRow(host, holderId, objectId, asset, def, resolved); + // Only a top-level object of a scene travels through a `Load scene` (#165), so only there is the row. + const travels = object.parentId === '' && findAsset(project, holderId)?.type === 'scene'; + for (const def of COMMON_PROPS) + if (travels || def.key !== KEEP_ON_SCENE_LOAD_KEY) + propertyRow(host, holderId, objectId, asset, def, resolved); // A behavior's own switches sit with it rather than in the section below: that one lists what // the behavior *declares*, and `Run in editor` is not something it declared. if (asset.type === 'behavior') diff --git a/src/player/Player.ts b/src/player/Player.ts index 7ae40365..3cf7c195 100644 --- a/src/player/Player.ts +++ b/src/player/Player.ts @@ -71,7 +71,8 @@ export class Player { gameClock.reset(); const error = behaviorError(run.world); if (error) console.error('[game]', error); - run.world.destroy(); + // The game plays the project itself, so what the run wrote into its scenes is taken back. + run.end(); this.screen.setWorld(null); this.screen.say('Click to play again', true); } diff --git a/src/runtime/GameRun.ts b/src/runtime/GameRun.ts index ba31e3a9..5ef20dd2 100644 --- a/src/runtime/GameRun.ts +++ b/src/runtime/GameRun.ts @@ -3,10 +3,12 @@ * editor's Play and a released game (#266), so both build, swap and end a scene the same way. */ import type { SaveArea } from '../core/graph/savedValues'; -import type { Project } from '../core/model/Project'; +import type { SceneAsset } from '../core/model/Asset'; +import { findScene, type Project } from '../core/model/Project'; import type { AudioSink } from './systems/Audio'; import { addGameSystems, type BehaviorSystem, type TimeScaled } from './systems/BehaviorSystem'; import type { Input } from './systems/Input'; +import { bringObjects, keptEntities, objectsToBring } from './keptObjects'; import type { Rapier } from './physics/engine'; import { World } from './World'; @@ -17,7 +19,16 @@ export interface RunServices { saves: SaveArea; } +/** What a scene holds of objects, which is all a run writes into it. */ +type StoredObjects = Pick; + export class GameRun { + /** + * Every scene this run built, as it was before: a run writes what it does into the scene it is in, + * and a scene loaded again has to be the stored one, not what was left of it. + */ + private readonly stored = new Map(); + private constructor( private readonly project: Project, private readonly input: Input, @@ -27,8 +38,12 @@ export class GameRun { ) {} static start(project: Project, sceneId: string, input: Input, services: RunServices): GameRun { + const scene = findScene(project, sceneId); + const before = scene && structuredClone({ objects: scene.objects, groups: scene.groups }); const world = World.instantiate(project, sceneId); - return new GameRun(project, input, services, world, addGameSystems(world, project, input, services)); + const run = new GameRun(project, input, services, world, addGameSystems(world, project, input, services)); + if (before) run.stored.set(sceneId, before); + return run; } /** @@ -49,14 +64,59 @@ export class GameRun { /** * Swaps the running scene for another one, inside the same run: the world is torn down and built - * again, while what belongs to the run – the clock, the shared variables, the global machines – - * carries on. The sounds of the old scene end with it, because the behaviors playing them are - * destroyed; a voice that was started detached is nobody's and plays on. + * again from the scene as it is stored, while what belongs to the run – the clock, the shared + * variables, the global machines – carries on. So do the objects that are kept (#165): they move + * into the next scene as they are, with their running behaviors and how their bodies move. The + * sounds of the old scene end with it, because the behaviors playing them are destroyed; a voice + * that was started detached is nobody's and plays on, and so does one of a kept behavior. */ loadScene(sceneId: string): void { + const scene = findScene(this.project, sceneId); + const from = findScene(this.project, this.world.sceneId); + if (!scene) throw new Error(`Scene ${sceneId} not found`); + const leaving = from + ? objectsToBring(from, keptEntities(this.world.entities)) + : { entities: [], objects: [] }; const carried = this.behaviors.shared; + const kept = this.behaviors.release(leaving.entities); this.world.destroy(); - this.world = World.instantiate(this.project, sceneId); - this.behaviors = addGameSystems(this.world, this.project, this.input, { ...this.services, carried }); + this.asStored(scene); + const standIns = bringObjects(this.project, scene, leaving.objects); + this.world = World.instantiate(this.project, sceneId, { + brought: { entities: leaving.entities, standIns }, + }); + this.behaviors = addGameSystems(this.world, this.project, this.input, { + ...this.services, + carried, + kept, + }); + } + + /** + * Ends the run: the world is torn down, and the scenes get back what they held before it. A host + * that runs on a copy it throws away has no use for the second half, one that plays the project + * itself does. + */ + end(): void { + this.world.destroy(); + for (const [sceneId, before] of this.stored) { + const scene = findScene(this.project, sceneId); + if (scene) restore(scene, before); + } + this.stored.clear(); + } + + /** Puts a scene back to what it stored before this run first built it; remembers that on the first time. */ + private asStored(scene: SceneAsset): void { + const before = this.stored.get(scene.id); + if (before) restore(scene, before); + else this.stored.set(scene.id, structuredClone({ objects: scene.objects, groups: scene.groups })); } } + +function restore(scene: SceneAsset, before: StoredObjects): void { + const { objects, groups } = structuredClone(before); + scene.objects = objects; + if (groups) scene.groups = groups; + else delete scene.groups; +} diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 13c720a0..6f871658 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -39,6 +39,17 @@ export interface WorldOptions { * but what it writes goes into the instance edits and it cannot destroy itself for good. */ bakeContainers?: boolean; + /** What a `Load scene` brings along from the world before (#165); given, the scene counts as changed. */ + brought?: BroughtEntities; +} + +/** + * The kept entities of the world before, whose objects the scene holds by now, and the ids of the + * scene's own objects they stand in for (see `keptObjects.ts`). + */ +export interface BroughtEntities { + entities: readonly Entity[]; + standIns: ReadonlyMap; } /** What `syncToModel` wrote: `structure` = objects appeared or vanished, `values` = the rest. */ @@ -137,6 +148,10 @@ export class World { private entitiesGone = false; private readonly modelStates = new WeakMap(); private tilesChanged = false; + /** Entities of the world before, by object id, until the scene's object takes each one up. */ + private readonly arriving = new Map(); + /** Objects this scene did not build because one brought along is it already, by their id. */ + private readonly standIns = new Map(); private constructor( readonly project: Project, @@ -148,7 +163,16 @@ export class World { static instantiate(project: Project, sceneId: string, options: WorldOptions = {}): World { if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`); const world = new World(project, sceneId, options.bakeContainers ?? true); + const brought = options.brought; + for (const entity of brought?.entities ?? []) world.arriving.set(entity.objectId, entity); world.syncFromModel(); + world.arriving.clear(); + if (!brought) return world; + for (const [id, standIn] of brought.standIns) { + const entity = world.entities.find((e) => e.objectId === standIn); + if (entity) world.standIns.set(id, entity); + } + world.structureChanged = true; return world; } @@ -263,7 +287,18 @@ export class World { /** 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); + let entity = this.entities.find((e) => e.objectId === object.id); + const arrived = entity ? undefined : this.arriving.get(object.id); + if (arrived) { + // It comes as the run left it: what the scene says of it is only what the next write compares + // against, so the run's values reach the scene instead of the scene's replacing them. + this.entities.push(arrived); + arrived.assetId = asset.id; + arrived.type = asset.type; + arrived.name = objectName(object, asset); + this.modelStates.set(arrived, { asset, ...object.position, props: this.modelProps(asset, object) }); + return arrived; + } if (!entity) { entity = { [ENTITY_HANDLE]: true, @@ -516,8 +551,12 @@ export class World { ); } + /** An object the scene left out for one brought along (#165) answers as that one. */ findByObjectId(objectId: string): Entity | undefined { - return this.entities.find((e) => e.objectId === objectId); + const own = this.entities.find((e) => e.objectId === objectId); + if (own) return own; + const standIn = this.standIns.get(objectId); + return standIn && this.entities.includes(standIn) ? standIn : undefined; } /** The entity a reference on `from` names (#258): its own container placement's copy first. */ diff --git a/src/runtime/keptObjects.ts b/src/runtime/keptObjects.ts new file mode 100644 index 00000000..1e621b26 --- /dev/null +++ b/src/runtime/keptObjects.ts @@ -0,0 +1,91 @@ +/** + * Objects that travel through a `Load scene` (#165): which entities of the running world are kept, + * and how their objects are put into the scene loaded next. + */ +import type { SceneAsset } from '../core/model/Asset'; +import { removeSubtrees } from '../core/model/hierarchy'; +import { findAsset, type Project } from '../core/model/Project'; +import type { SceneObject } from '../core/model/SceneObject'; +import { KEEP_ON_SCENE_LOAD_KEY } from '../core/properties/PropertySchema'; +import { resolveSceneObjectProps } from '../core/properties/resolve'; +import type { Entity } from './Entity'; + +/** Whether the switch is on for an object as the scene stores it. */ +function isKept(project: Project, object: SceneObject): boolean { + const asset = findAsset(project, object.assetId); + return ( + asset !== undefined && resolveSceneObjectProps(project, asset, object)[KEEP_ON_SCENE_LOAD_KEY] === true + ); +} + +/** + * The entities a scene swap keeps: every top-level one with the switch on, and all below it. Only a + * top-level object can be kept – one further down would arrive without the parent it is placed in. + */ +export function keptEntities(entities: readonly Entity[]): Entity[] { + const kept = new Set(); + // A parent may come after its child in the list, so the chain is walked rather than the order trusted. + const travels = (entity: Entity): boolean => { + let top = entity; + while (top.parent) top = top.parent; + return top.props[KEEP_ON_SCENE_LOAD_KEY] === true; + }; + for (const entity of entities) if (travels(entity)) kept.add(entity); + return entities.filter((e) => kept.has(e)); +} + +/** + * The stored objects of the kept entities, as copies ready for another scene: parented as the run + * has them now, and in no hierarchy folder, which belongs to the scene they leave. An entity the + * scene stores no object for cannot travel and is left out of `entities`. + */ +export function objectsToBring( + scene: SceneAsset, + entities: readonly Entity[], +): { entities: Entity[]; objects: SceneObject[] } { + const stored = new Map(scene.objects.map((o) => [o.id, o])); + const brought: Entity[] = []; + const objects: SceneObject[] = []; + for (const entity of entities) { + const object = stored.get(entity.objectId); + if (!object) continue; + const copy = structuredClone(object); + copy.parentId = entity.parent?.objectId ?? ''; + delete copy.groupId; + brought.push(entity); + objects.push(copy); + } + return { entities: brought, objects }; +} + +/** + * Puts the brought objects into the scene, after its own. What the scene holds of the same thing is + * not built, the one brought along wins: an object of the same id – the scene the object came from, + * loaded again – and a top-level object of the same asset that is kept itself, which is how each + * level can place its own player and still end up with one. Answers the ids of the objects left out + * that way, each with the id of the object standing in for it. + */ +export function bringObjects( + project: Project, + scene: SceneAsset, + brought: readonly SceneObject[], +): Map { + const ids = new Set(brought.map((o) => o.id)); + const tops = brought.filter((o) => o.parentId === ''); + const standIns = new Map(); + const leftOut: string[] = []; + for (const object of scene.objects) { + if (ids.has(object.id)) { + leftOut.push(object.id); + continue; + } + if (object.parentId !== '') continue; + const standIn = tops.find((top) => top.assetId === object.assetId); + if (!standIn || !isKept(project, object)) continue; + leftOut.push(object.id); + standIns.set(object.id, standIn.id); + } + removeSubtrees(scene, leftOut); + scene.objects.push(...brought); + return standIns; +} diff --git a/src/runtime/physics/PhysicsSystem.ts b/src/runtime/physics/PhysicsSystem.ts index ca4ec2e5..955bbe81 100644 --- a/src/runtime/physics/PhysicsSystem.ts +++ b/src/runtime/physics/PhysicsSystem.ts @@ -112,6 +112,12 @@ interface Part { owner: Body; } +/** How a dynamic body moves, in the engine's own units. */ +export interface BodyMotion { + linear: Vec2; + angular: number; +} + /** * The played game's physics on Rapier (#146): rigidbodies fall, bounce and rest, and the collision * events come from the engine's contact pairs. Ticked first, like the collision system it stands in @@ -147,6 +153,8 @@ export class PhysicsSystem implements System, CollisionSource { private frameDt = PHYSICS_STEP; private readonly hinted = new Set(); private world: World | null = null; + /** How bodies moved in the world before (#165), until the body of each is made here. */ + private resumed = new Map(); constructor(private readonly rapier: Rapier) {} @@ -183,6 +191,7 @@ export class PhysicsSystem implements System, CollisionSource { this.backlog = 0; this.frameDt = PHYSICS_STEP; this.hinted.clear(); + this.resumed.clear(); } update(world: World, dt: number): void { @@ -249,6 +258,23 @@ export class PhysicsSystem implements System, CollisionSource { return this.measure(this.engine!.getCollider(handles[0]), this.engine!.getCollider(handles[1])); } + /** How the dynamic bodies among these objects move right now, for the next world to `resume`. */ + motionOf(entities: readonly Entity[]): Map { + const motion = new Map(); + for (const entity of entities) { + const body = this.bodies.get(entity); + if (body?.kind !== 'rigid' || !body.body.isDynamic()) continue; + const v = body.body.linvel(); + motion.set(entity, { linear: { x: v.x, y: v.y }, angular: body.body.angvel() }); + } + return motion; + } + + /** Bodies made for these objects start out moving as they did; call before the first tick. */ + resume(motion: ReadonlyMap): void { + this.resumed = new Map(motion); + } + /** A dynamic body's velocity in world units per second; null for an object the engine does not move. */ velocityOf(target: Entity): Vec2 | null { const body = this.dynamicBodyOf(target)?.body; @@ -378,6 +404,8 @@ export class PhysicsSystem implements System, CollisionSource { this.place(body, want, key, world); this.build(body, want, key, world); } + // Only the body an object arrives with moves on: one it gets later starts at rest like any other. + this.resumed.clear(); } /** @@ -422,6 +450,9 @@ export class PhysicsSystem implements System, CollisionSource { ? R.RigidBodyDesc.fixed() : R.RigidBodyDesc.kinematicPositionBased(); desc.setTranslation(toMetres(pose.x), toMetres(pose.y)).setRotation(pose.angle); + const motion = + want.kind === 'rigid' && want.settings!.type === 'dynamic' ? this.resumed.get(key) : undefined; + if (motion) desc.setLinvel(motion.linear.x, motion.linear.y).setAngvel(motion.angular); return { kind: want.kind, body: this.engine!.createRigidBody(desc), diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 259f1e9a..7312e3d5 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -30,7 +30,7 @@ import { type AudioSink, SILENT_AUDIO } from './Audio'; import type { Entity } from '../Entity'; import type { World } from '../World'; import type { Rapier } from '../physics/engine'; -import { PhysicsSystem } from '../physics/PhysicsSystem'; +import { type BodyMotion, PhysicsSystem } from '../physics/PhysicsSystem'; import { behaviorContext, type TimeScaled } from './behaviorContext'; import { animationFinishedPulses, @@ -79,11 +79,23 @@ const MAX_STATE_ROUNDS = 100; const ERROR_PREFIX = 'Error in behavior'; +/** + * What the kept objects of a scene take into the world of the next one besides themselves (#165): + * their running behaviors, the index of the graphs those run on, and how their bodies were moving. + */ +export interface KeptState { + instances: Instance[]; + graphs: GraphCache; + motion: ReadonlyMap; +} + export interface GameSystemOptions { shouldRun?: (entity: Entity) => boolean; audio?: AudioSink; /** What a scene swap hands over from the world before it (see `BehaviorSystem.carryOver`). */ carried?: Readonly | null; + /** What the kept objects of the world before bring along (see `BehaviorSystem.release`). */ + kept?: KeptState | null; /** * The loaded engine: a played game simulates its rigidbodies on it (#146). Without one the * collisions are detected only, which is what the editor run still does. @@ -107,6 +119,7 @@ export function addGameSystems( shouldRun, audio = SILENT_AUDIO, carried = null, + kept = null, physics = null, clock = null, saves = null, @@ -117,6 +130,10 @@ export function addGameSystems( const sources = { collisions, animations, audio, clock, saves }; const behaviors = new BehaviorSystem(project, input, sources, shouldRun); if (carried) behaviors.carryOver(carried); + if (kept) { + behaviors.adopt(kept); + if (collisions instanceof PhysicsSystem) collisions.resume(kept.motion); + } world.addSystem(collisions); world.addSystem(animations); world.addSystem(behaviors); @@ -164,7 +181,9 @@ export class BehaviorSystem implements System { /** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */ private declared: Record = {}; /** Shared by every instance; an edit to the project empties it (see `syncFromModel`). */ - private readonly graphs = new GraphCache(); + private graphs = new GraphCache(); + /** Running behaviors of kept objects, handed over by the world before; consumed by the next `start`. */ + private adopted: Instance[] = []; private readonly collisions: CollisionSource | null; private readonly animations: AnimationSystem | null; private readonly audio: AudioSink; @@ -227,9 +246,42 @@ export class BehaviorSystem implements System { this.restarted = restartedAssets(this.project); } + /** + * Hands out the running behaviors on these entities, so the end of this world is not theirs: no + * `On destroy`, their sounds play on, and the next world's system carries them on where they are + * (see `adopt`). Call before the world is destroyed. + */ + release(entities: readonly Entity[]): KeptState { + const leaving = new Set(entities); + const instances = this.instances.filter((i) => leaving.has(i.entity)); + this.instances = this.instances.filter((i) => !leaving.has(i.entity)); + const physics = this.collisions instanceof PhysicsSystem ? this.collisions : null; + return { instances, graphs: this.graphs, motion: physics?.motionOf(entities) ?? new Map() }; + } + + /** + * Takes over behaviors that ran in the world before. They are no new instances, so they hear no + * `On start`; their timers, variables and local machines stand where they stood. Call before the + * world is started. + */ + adopt(kept: KeptState): void { + this.adopted = kept.instances; + // Their interpreters read this index, and an edit to the project has to reach it through us. + this.graphs = kept.graphs; + } + start(world: World): void { this.world = world; - this.instances = []; + this.instances = this.adopted; + this.adopted = []; + // The trace is timed by the world, and this one starts at second zero. + for (const instance of this.instances) { + instance.trace.edges.clear(); + instance.trace.nodes.clear(); + for (const byNode of instance.values.values()) + for (const produced of byNode.values()) produced.time = 0; + this.listen(instance, world); + } this.pending = []; const carried = this.carried; this.carried = null; @@ -296,16 +348,8 @@ export class BehaviorSystem implements System { const trace = { edges: new Map(), nodes: new Map() }; const values = new Map>(); const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project), this.graphs); - interpreter.trace = { - onEdge: (id) => trace.edges.set(id, world.time), - onNode: (id) => trace.nodes.set(id, world.time), - onValues: (graph, id, produced) => { - let byNode = values.get(graph); - if (!byNode) values.set(graph, (byNode = new Map())); - byNode.set(id, { time: world.time, values: produced }); - }, - } satisfies TraceListener; const instance: Instance = { entity, interpreter, trace, values, states: {}, active: true }; + this.listen(instance, world); this.instances.push(instance); born.add(instance); } @@ -318,6 +362,19 @@ export class BehaviorSystem implements System { this.flushStateChanges(); } + /** Has the instance note what it runs, timed by the world it runs in. */ + private listen({ interpreter, trace, values }: Instance, world: World): void { + interpreter.trace = { + onEdge: (id) => trace.edges.set(id, world.time), + onNode: (id) => trace.nodes.set(id, world.time), + onValues: (graph, id, produced) => { + let byNode = values.get(graph); + if (!byNode) values.set(graph, (byNode = new Map())); + byNode.set(id, { time: world.time, values: produced }); + }, + } satisfies TraceListener; + } + /** * Puts the instances in hierarchy order – what equal orders fall back on (#264). Only when one is * born or the scene was edited, since the hierarchy is walked in full. diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index ba36b56b..e1040168 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -24,6 +24,7 @@ describe('asset commands', () => { expect(project.activeSceneId).toBe(sceneId); expect(findAsset(project, spriteId)?.props).toEqual({ enabled: true, + keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, diff --git a/tests/core/enabled.test.ts b/tests/core/enabled.test.ts index 02710844..5ad68036 100644 --- a/tests/core/enabled.test.ts +++ b/tests/core/enabled.test.ts @@ -38,7 +38,12 @@ function wallAndMover() { describe('the enabled switch', () => { it('is a common property of every placeable type, defaults to on and overrides per object', () => { expect(schemaFor('sprite')[0]).toMatchObject({ key: 'enabled', kind: 'boolean', default: true }); - expect(schemaFor('behavior').map((d) => d.key)).toEqual(['enabled', 'runInEditor', 'runWhilePaused']); + expect(schemaFor('behavior').map((d) => d.key)).toEqual([ + 'enabled', + 'keepOnSceneLoad', + 'runInEditor', + 'runWhilePaused', + ]); expect(schemaFor('scene').some((d) => d.key === 'enabled')).toBe(false); const f = makeSpriteScene(); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 278c8e72..909c9edd 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -518,7 +518,13 @@ describe('variable commands', () => { stack.execute(new RenameVariableCommand(behavior.id, 'speed', 'velocity')); expect(behavior.variables[0]!.key).toBe('velocity'); - expect(behavior.props).toEqual({ enabled: true, runInEditor: false, runWhilePaused: false, velocity: 0 }); + expect(behavior.props).toEqual({ + enabled: true, + keepOnSceneLoad: false, + runInEditor: false, + runWhilePaused: false, + velocity: 0, + }); expect(object(logic).overrides).toEqual({ velocity: 5 }); expect(findNode(behavior.graph, nodes.speed.id)!.ports).toEqual([{ key: 'velocity', kind: 'number' }]); stack.undo(); @@ -543,7 +549,13 @@ describe('behavior serialization', () => { const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset; expect(loaded.variables).toEqual(behavior.variables); expect(loaded.graph).toEqual(behavior.graph); - expect(loaded.props).toEqual({ enabled: true, runInEditor: false, runWhilePaused: false, speed: 0 }); + expect(loaded.props).toEqual({ + enabled: true, + keepOnSceneLoad: false, + runInEditor: false, + runWhilePaused: false, + speed: 0, + }); const raw = serializeProject(project) as { assets: { id: string; graph?: { nodes: unknown[]; edges: unknown[] } }[]; diff --git a/tests/core/keep-on-scene-load.test.ts b/tests/core/keep-on-scene-load.test.ts new file mode 100644 index 00000000..b7abd05e --- /dev/null +++ b/tests/core/keep-on-scene-load.test.ts @@ -0,0 +1,392 @@ +/** + * 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: () => {}, + stop: () => {}, + 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(); + }); +}); diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index 0efc3386..a816355f 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -25,6 +25,7 @@ describe('PropertySchema', () => { it('provides defaults for every key', () => { expect(defaultsFor('sprite')).toEqual({ enabled: true, + keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, @@ -56,6 +57,7 @@ describe('resolve', () => { sprite.props = { width: 50, bogus: 1 }; expect(resolveAssetProps(project, sprite)).toEqual({ enabled: true, + keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, @@ -75,6 +77,7 @@ describe('resolve', () => { const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); expect(resolveSceneObjectProps(project, sprite, object)).toEqual({ enabled: true, + keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index fd08acb3..f8df1f2c 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -173,6 +173,7 @@ describe('serialize', () => { expect(project.assets[1]!.name).toBe('sprite'); expect(project.assets[1]!.props).toEqual({ enabled: true, + keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 360a4fd1..ac64e3e7 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -608,6 +608,7 @@ describe('a sprite wearing a shader', () => { const keys = () => schemaForAsset(f.project, f.sprite()).map((d) => d.key); expect(keys()).toEqual([ 'enabled', + 'keepOnSceneLoad', 'scale', 'rotation', 'mirrorX', @@ -627,6 +628,7 @@ describe('a sprite wearing a shader', () => { f.stack.execute(new SetAssetPropertyCommand(f.spriteId, SHADER_KEY, f.s.id)); expect(keys()).toEqual([ 'enabled', + 'keepOnSceneLoad', 'scale', 'rotation', 'mirrorX', @@ -644,14 +646,14 @@ describe('a sprite wearing a shader', () => { expect(assetDeviates(f.project, f.sprite())).toBe(true); // A dangling id adds no rows and draws nothing – the picker still shows it. f.sprite().props[SHADER_KEY] = 'gone'; - expect(keys()).toHaveLength(11); + expect(keys()).toHaveLength(12); }); it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => { const f = wearing(); const id = place(f, f.spriteId, { x: 0, y: 0 }); const object = () => f.object(id); - expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(11); + expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(12); f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id)); expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation'); expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({ diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index 59976e67..949eea79 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -63,6 +63,7 @@ describe('CreateAssetCommand with starting props', () => { expect(findAsset(project, sprite.asset.id)?.props).toEqual({ enabled: true, + keepOnSceneLoad: false, scale: 1, rotation: 0, mirrorX: false, diff --git a/tools/e2e/35-load-scene.mjs b/tools/e2e/35-load-scene.mjs index 69858494..ac2824e4 100644 --- a/tools/e2e/35-load-scene.mjs +++ b/tools/e2e/35-load-scene.mjs @@ -280,6 +280,88 @@ export async function loadScene(t) { JSON.stringify(await playFrom()), ); + // 35f². Keep on scene load (#165). The second box gets the switch, by hand in the inspector, and + // then travels through the swap with its loader below it. The loader is the witness twice + // over: it counts on start, so a behavior that started again would say 2, and it is the one + // humming, so a behavior that was destroyed would have gone silent. + await evaluate("__gm.state.select({ kind: 'asset', assetId: 'ls-scene' })"); + const secondRow = await json('__gm.assetRow("ls-scene")'); + await doubleClick(secondRow.x, secondRow.y); + const boxRow = await json("__gm.hierarchyRow('ls-box')"); + await click(boxRow.x, boxRow.y); + const keepRow = await json("__gm.inspectorField('Keep on scene load')"); + if (keepRow) await click(keepRow.x, keepRow.y); + const keptBox = await json( + "__gm.state.project.assets.find((a) => a.id === 'ls-scene').objects.find((o) => o.id === 'ls-box')", + ); + check( + 'a top-level object has the switch "Keep on scene load", and a click writes it as an override', + Boolean(keepRow) && keptBox.overrides.keepOnSceneLoad === true, + JSON.stringify({ keepRow, overrides: keptBox.overrides }), + ); + await evaluate( + "__gm.state.select({ kind: 'object', holderId: 'ls-scene', objectIds: ['ls-brain'], objectId: 'ls-brain' })", + ); + check( + 'an object below another one has no such switch, only a top-level one travels', + (await json("__gm.inspectorField('Keep on scene load')")) === null && + (await json("__gm.inspectorField('Enabled')")) !== null, + JSON.stringify(await json("__gm.inspectorField('Keep on scene load')")), + ); + + await play(); + await waitFor( + () => json('__gm.world()'), + (w) => (w?.shared?.variables?.['ls-vars']?.score ?? 0) === 1, + ); + await waitFor(stats, (s) => s.playing > 0); + await press('g'); + const swapped = await waitFor( + () => json('__gm.playInfo()'), + (info) => info.sceneId === firstId, + ); + const keptWorld = await json('__gm.world()'); + const ofBox = (w) => w.entities.filter((e) => e.objectId === 'ls-box' || e.objectId === 'ls-brain'); + check( + 'a kept object is in the next scene with what hangs below it, among that scene’s own objects', + swapped.sceneId === firstId && + ofBox(keptWorld).length === 2 && + keptWorld.entities.find((e) => e.objectId === 'ls-brain')?.parent === 'ls-box' && + keptWorld.entities.length > 2, + JSON.stringify({ swapped: swapped.sceneId, kept: ofBox(keptWorld), all: keptWorld.entities.length }), + ); + check( + 'its behavior runs on rather than starting again, and its sound plays on', + keptWorld.shared?.variables?.['ls-vars']?.score === 1 && (await stats()).playing >= 1, + JSON.stringify({ shared: keptWorld.shared?.variables, sound: await stats() }), + ); + await shot('35-kept-object'); + // Loading the scene it is in by now: the box is the scene's own by its id, and stays the one box. + const timeBefore = (await playInfo()).time; + await press('g'); + await waitFor(playInfo, (info) => info.time < timeBefore); + const again = await json('__gm.world()'); + check( + 'loading the scene again keeps the one object, and its behavior still has not started twice', + ofBox(again).length === 2 && again.shared?.variables?.['ls-vars']?.score === 1, + JSON.stringify({ kept: ofBox(again), shared: again.shared?.variables }), + ); + await stop(); + const storedIn = (sceneId) => + json( + `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sceneId)}).objects.map((o) => o.id)`, + ); + check( + 'Stop leaves the stored scenes as they were: the box is back in its own and in no other', + (await storedIn('ls-scene')).join() === 'ls-box,ls-brain' && + !(await storedIn(firstId)).includes('ls-box'), + JSON.stringify({ second: await storedIn('ls-scene'), first: await storedIn(firstId) }), + ); + await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(firstId)} })`); + const firstRow = await json(`__gm.assetRow(${JSON.stringify(firstId)})`); + await doubleClick(firstRow.x, firstRow.y); + await evaluate('__gm.state.select(null)'); + // 35g. Everything this step made goes again, the start scene included. await mutate(` const p = __gm.state.project;