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>
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
/**
|
|
* Container assets hold placed objects just like scenes do, so anything that walks "all placed
|
|
* objects" has to cover both. Plus: a rejected undo leaves the stacks usable.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import { type Command, CommandError } from '../../src/core/commands/Command';
|
|
import { CommandStack } from '../../src/core/commands/CommandStack';
|
|
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
|
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
|
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
|
import type { ContainerAsset } from '../../src/core/model/Asset';
|
|
import { findAsset, findObjectHolder, objectHolders } from '../../src/core/model/Project';
|
|
import { defaultSceneVisibility } from '../../src/core/model/sceneVisibility';
|
|
import { instanceChildId } from '../../src/core/model/instance';
|
|
import { findUsages } from '../../src/core/model/usages';
|
|
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
|
|
|
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
|
|
function containerWithOverride() {
|
|
const f = makeSpriteScene();
|
|
const behavior = makeBehavior(f, 'B', ['speed']);
|
|
const objectId = place(f, behavior.id, { x: 0, y: 0 });
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, 'speed', 42));
|
|
const container = new CreateContainerCommand(f.sceneId, [objectId]);
|
|
f.stack.execute(container);
|
|
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
|
|
// The object moved into the template; what the scene shows is resolved from it.
|
|
const placed = () => f.object(instanceChildId(container.objectId, template().id));
|
|
return { ...f, behaviorId: behavior.id, containerId: container.assetId, objectId, template, placed };
|
|
}
|
|
|
|
describe('object holders', () => {
|
|
it('counts scenes and container assets alike', () => {
|
|
const { project } = containerWithOverride();
|
|
expect(
|
|
objectHolders(project)
|
|
.map((h) => h.type)
|
|
.sort(),
|
|
).toEqual(['container', 'scene']);
|
|
});
|
|
|
|
it('resolves a scene or a container by id, but no other asset', () => {
|
|
const { project, sceneId, containerId, spriteId, behaviorId } = containerWithOverride();
|
|
expect(findObjectHolder(project, sceneId)?.type).toBe('scene');
|
|
expect(findObjectHolder(project, containerId)?.type).toBe('container');
|
|
expect(findObjectHolder(project, spriteId)).toBeUndefined();
|
|
expect(findObjectHolder(project, behaviorId)).toBeUndefined();
|
|
expect(findObjectHolder(project, 'nope')).toBeUndefined();
|
|
});
|
|
|
|
it('finds usages inside container templates, not just in scenes', () => {
|
|
const { project, behaviorId, containerId, template } = containerWithOverride();
|
|
// Only the template: what the scene shows is resolved from it, never stored.
|
|
const usages = findUsages(project, behaviorId);
|
|
expect(usages).toHaveLength(1);
|
|
expect(usages[0]!.holder.id).toBe(containerId);
|
|
expect(template().overrides.speed).toBe(42);
|
|
});
|
|
});
|
|
|
|
describe('RemoveVariableCommand', () => {
|
|
it('clears the override on container templates too, and puts it back on undo', () => {
|
|
const { stack, behaviorId, template, placed } = containerWithOverride();
|
|
|
|
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
|
|
expect(placed().overrides.speed).toBeUndefined();
|
|
expect(template().overrides.speed).toBeUndefined();
|
|
|
|
stack.undo();
|
|
expect(placed().overrides.speed).toBe(42);
|
|
expect(template().overrides.speed).toBe(42);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A behavior inside a placed container is not stored in the scene, and a value changed on it lives
|
|
* in the instance's `instanceEdits`. Anything rewriting overrides project-wide has to reach in
|
|
* there, or renaming a variable silently drops the value.
|
|
*/
|
|
describe('variables overridden on container content', () => {
|
|
function withInstanceEdit() {
|
|
const f = containerWithOverride();
|
|
f.stack.execute(new SetOverrideCommand(f.sceneId, f.placed().id, 'speed', 7));
|
|
const edits = () =>
|
|
f.sceneOf().objects.find((o) => o.id !== f.objectId)!.instanceEdits[f.template().id]!.overrides;
|
|
return { ...f, edits };
|
|
}
|
|
|
|
it('renames the key kept on the instance', () => {
|
|
const { stack, behaviorId, edits, placed } = withInstanceEdit();
|
|
expect(edits().speed).toBe(7);
|
|
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
|
|
expect(edits().speed).toBeUndefined();
|
|
expect(edits().velocity).toBe(7);
|
|
expect(placed().overrides.velocity).toBe(7);
|
|
stack.undo();
|
|
expect(edits().speed).toBe(7);
|
|
});
|
|
|
|
it('removes the value kept on the instance, and puts it back on undo', () => {
|
|
const { stack, behaviorId, edits } = withInstanceEdit();
|
|
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
|
|
expect(edits().speed).toBeUndefined();
|
|
stack.undo();
|
|
expect(edits().speed).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe('RenameVariableCommand', () => {
|
|
it('renames the override on container templates too', () => {
|
|
const { stack, behaviorId, template } = containerWithOverride();
|
|
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
|
|
expect(template().overrides.speed).toBeUndefined();
|
|
expect(template().overrides.velocity).toBe(42);
|
|
|
|
stack.undo();
|
|
expect(template().overrides.speed).toBe(42);
|
|
expect(template().overrides.velocity).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
class RefusingUndo implements Command {
|
|
readonly label = 'Refusing';
|
|
execute(project: { name: string }): void {
|
|
project.name = 'executed';
|
|
}
|
|
undo(): void {
|
|
throw new CommandError('target is gone');
|
|
}
|
|
}
|
|
|
|
describe('CommandStack', () => {
|
|
it('keeps a command on the undo stack when its undo is rejected', () => {
|
|
const { project, stack } = makeSpriteScene();
|
|
stack.execute(new RefusingUndo());
|
|
expect(stack.canUndo).toBe(true);
|
|
|
|
expect(() => stack.undo()).toThrow(CommandError);
|
|
// Not swallowed by the history: still undoable, and not queued for redo.
|
|
expect(stack.canUndo).toBe(true);
|
|
expect(stack.canRedo).toBe(false);
|
|
expect(project.name).toBe('executed');
|
|
});
|
|
|
|
it('keeps a command on the redo stack when its redo is rejected', () => {
|
|
const project = {
|
|
formatVersion: 1,
|
|
id: 'p',
|
|
name: 'P',
|
|
assets: [],
|
|
activeSceneId: '',
|
|
startSceneId: '',
|
|
plugins: [],
|
|
collisionLayers: ['Default'],
|
|
symbolScale: 1,
|
|
playFrom: 'start' as const,
|
|
whilePlaying: 'live' as const,
|
|
gameScale: 1,
|
|
sceneVisibility: defaultSceneVisibility(),
|
|
};
|
|
const stack = new CommandStack(project);
|
|
let failing = false;
|
|
stack.execute({
|
|
label: 'Toggle',
|
|
execute: () => {
|
|
if (failing) throw new CommandError('nope');
|
|
},
|
|
undo: () => {},
|
|
});
|
|
stack.undo();
|
|
failing = true;
|
|
|
|
expect(() => stack.redo()).toThrow(CommandError);
|
|
expect(stack.canRedo).toBe(true);
|
|
expect(stack.canUndo).toBe(false);
|
|
});
|
|
});
|