/** * How closely the editor follows a running game (#265): the preset in the project, the rule that * decides frame by frame what the panels get, and the group a spawned object is listed in. */ import { describe, expect, it } from 'vitest'; import { SetWhilePlayingCommand } from '../../src/core/commands/projectCommands'; import { groupMemberIds } from '../../src/core/model/hierarchy'; import { whilePlayingOf } from '../../src/core/model/playSettings'; import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { PANEL_RATE, PanelClock, panelPolicy, type PanelUpdate } from '../../src/editor/playThrottle'; import { EditorState } from '../../src/editor/state'; import { TRACE_FADE } from '../../src/runtime/systems/BehaviorSystem'; import { makeSpriteScene, place, startWorld } from '../helpers'; const FRAME = 1000 / 60; /** Runs `frames` frames at 60 fps and counts what the panels got; `spawnEvery` frames objects change. */ function run(clock: PanelClock, mode: 'live' | 'balanced' | 'minimal', frames: number, spawnEvery = 0) { const policy = panelPolicy(mode); const counts = { structure: 0, values: 0, graphs: 0 }; const updates: PanelUpdate[] = []; for (let i = 0; i < frames; i++) { const update = clock.frame(policy, 1000 + i * FRAME, spawnEvery > 0 && i % spawnEvery === 0); updates.push(update); for (const key of ['structure', 'values', 'graphs'] as const) if (update[key]) counts[key]++; } return { counts, updates }; } describe('While playing preset', () => { it('is Balanced in a new project and in one written before there was a choice', () => { expect(createEmptyProject('p', 'P').whilePlaying).toBe('balanced'); expect(deserializeProject({ id: 'p', name: 'P', assets: [] }).whilePlaying).toBe('balanced'); }); it('survives a save and a load, and reads anything else as the default', () => { const project = { ...createEmptyProject('p', 'P'), whilePlaying: 'live' as const }; expect(deserializeProject(serializeProject(project)).whilePlaying).toBe('live'); expect(whilePlayingOf({ whilePlaying: 'minimal' })).toBe('minimal'); expect(whilePlayingOf({ whilePlaying: 'fast' })).toBe('balanced'); expect(whilePlayingOf(null)).toBe('balanced'); }); it('is one undo step', () => { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); state.execute(new SetWhilePlayingCommand('minimal')); expect(state.project.whilePlaying).toBe('minimal'); expect(state.saveStatus).toBe('dirty'); state.undo(); expect(state.project.whilePlaying).toBe('balanced'); }); }); describe('panel clock', () => { it('Live brings every panel up to date in every frame, a spawn included', () => { const { counts, updates } = run(new PanelClock(), 'live', 60, 1); expect(counts).toEqual({ structure: 60, values: 60, graphs: 60 }); expect(updates.every((u) => u.structure)).toBe(true); }); it(`Balanced catches up ${PANEL_RATE} times a second, however often objects change`, () => { const { counts } = run(new PanelClock(), 'balanced', 120, 1); // The graphs keep up with every frame: they draw only while their tab is on screen. expect(counts).toEqual({ structure: 8, values: 8, graphs: 120 }); }); it('Balanced keeps a change of structure that falls between two updates for the next one', () => { const clock = new PanelClock(); const policy = panelPolicy('balanced'); expect(clock.frame(policy, 0, false).structure).toBe(false); expect(clock.frame(policy, 100, true)).toEqual({ structure: false, values: false, graphs: true }); expect(clock.holdsStructure).toBe(true); expect(clock.frame(policy, 200, false).structure).toBe(false); expect(clock.frame(policy, 250, false)).toEqual({ structure: true, values: true, graphs: true }); expect(clock.frame(policy, 500, false).structure).toBe(false); }); it('Minimal holds hierarchy and values until the game stands still, the graphs stay throttled', () => { const clock = new PanelClock(); const { counts } = run(clock, 'minimal', 120, 30); expect(counts).toEqual({ structure: 0, values: 0, graphs: 8 }); expect(clock.flush()).toEqual({ structure: true, values: true, graphs: true }); expect(clock.flush().structure).toBe(false); }); it('a pause brings everything held back at once', () => { const clock = new PanelClock(); clock.frame(panelPolicy('balanced'), 0, false); clock.frame(panelPolicy('balanced'), 10, true); expect(clock.flush()).toEqual({ structure: true, values: true, graphs: true }); }); it('shows every pulse at least once: the gap between two updates is shorter than a pulse glows', () => { expect(1000 / PANEL_RATE).toBeLessThan(TRACE_FADE * 1000); }); it('keeps the scene view drawing behind the game tab only on Live', () => { expect(panelPolicy('live').hiddenSceneView).toBe(true); expect(panelPolicy('balanced').hiddenSceneView).toBe(false); expect(panelPolicy('minimal').hiddenSceneView).toBe(false); }); }); describe('spawned objects in a group (#265)', () => { it('lists an object spawned at the top level in a group named after its asset, made once', () => { const f = makeSpriteScene(); const { world } = startWorld(f); const scene = () => findScene(f.project, f.sceneId)!; const name = findAsset(f.project, f.spriteId)!.name; const first = world.spawn(f.spriteId, { x: 0, y: 0, z: 0 }, null)!; const second = world.spawn(f.spriteId, { x: 5, y: 0, z: 0 }, null)!; expect(scene().groups).toHaveLength(1); const group = scene().groups![0]!; expect(group.name).toBe(name); expect(groupMemberIds(scene(), group.id)).toEqual([first.objectId, second.objectId]); // The object keeps its asset's name: the group changes the list, not what a Find looks for. expect(first.name).toBe(name); }); it('joins a group of that name that is already there, and leaves a spawned child under its parent', () => { const f = makeSpriteScene(); const parent = place(f, f.spriteId, { x: 0, y: 0 }); const name = findAsset(f.project, f.spriteId)!.name; const scene = findScene(f.project, f.sceneId)!; scene.groups = [{ id: 'mine', name }]; const { world } = startWorld(f); const top = world.spawn(f.spriteId, { x: 0, y: 0, z: 0 }, null)!; const child = world.spawn(f.spriteId, { x: 0, y: 0, z: 0 }, world.findByObjectId(parent)!)!; const objectOf = (id: string) => findScene(f.project, f.sceneId)!.objects.find((o) => o.id === id)!; expect(findScene(f.project, f.sceneId)!.groups).toEqual([{ id: 'mine', name }]); expect(objectOf(top.objectId).groupId).toBe('mine'); expect(objectOf(child.objectId).groupId).toBeUndefined(); }); });