With the behaviors fixed, the scene Performance test still ran at about 23 fps with 500 entities. The rest of the frame went to the editor: every spawn or destroy rebuilt the hierarchy with all its rows, redrew the scene view behind the game tab and refreshed every panel. The game itself is cheap. What the panels show of it now follows a preset in the project settings. - New section While playing in the project settings with three presets: Live, Balanced and Minimal. Every project reads as Balanced, including those saved before the setting existed. - Live keeps today's behavior. Balanced brings hierarchy and panel values up to date 4 times a second and stops redrawing the hidden scene view. Minimal updates hierarchy and values only on pause and throttles the graph tabs as well. - The graph and state machine tabs stay live under Balanced. At 4 updates a second the beads on exec edges stutter, and the tabs draw only while on screen. This differs from the issue proposal. - The game itself is never throttled. Pause, step and stop bring every panel up to date at once, and a change of structure that falls between two updates is kept for the next one. - The rule lives in editor/playThrottle.ts as panelPolicy and PanelClock. PlaySession asks it after every frame. - Objects spawned at the top level go into a hierarchy group named after their asset, so hundreds of gold pieces fold into one row. Groups start folded. Measured on a throwaway copy of Performance test, 3 s each, fps: - About 500 entities: Live 38.6, Balanced 56, Minimal 60.6. - About 315 entities: Live 56.9, Balanced 60.5, Minimal 60.6. - p95 frame time at 500 entities: 60 ms, 41 ms and 23 ms. Tests: - ./check.sh: 193 files, 2799 tests, CHECK EXIT 0. - GM_STEPS=1,18,59: ALL PASSED, 54 checks. - Full e2e run (local): ALL PASSED, 1562 checks. - tests/editor/play-throttle.test.ts: presets, update rates, kept structure changes, flush, spawn group and loading old projects. - New e2e module 59: preset chosen with the mouse, measured update rates, scene view behind the game tab, Minimal with pause. - Module 03 now expects spawned objects in their group row after the next update. - Counter-checks: 4 in unit tests and 2 in the browser, each failing as expected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
6.7 KiB
TypeScript
137 lines
6.7 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|