Throttle what the editor shows while a game runs (#265)
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>
This commit is contained in:
1 parent
a7e793f6de
commit
94c1055895
20 files changed
+592
-19
No files matched your search
@@ -43,7 +43,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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1551 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1562 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { collisionLayersOf } from '../model/collisionLayers';
|
import { collisionLayersOf } from '../model/collisionLayers';
|
||||||
import type { PlayFrom } from '../model/playSettings';
|
import { DEFAULT_WHILE_PLAYING, type PlayFrom, type WhilePlaying } from '../model/playSettings';
|
||||||
import type { Project } from '../model/Project';
|
import type { Project } from '../model/Project';
|
||||||
import { clampSymbolScale } from '../model/symbolScale';
|
import { clampSymbolScale } from '../model/symbolScale';
|
||||||
import type { Command } from './Command';
|
import type { Command } from './Command';
|
||||||
@@ -74,3 +74,20 @@ export class SetPlayFromCommand implements Command {
|
|||||||
project.playFrom = this.previous;
|
project.playFrom = this.previous;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How closely the panels follow a running game (#265). */
|
||||||
|
export class SetWhilePlayingCommand implements Command {
|
||||||
|
readonly label = 'While playing';
|
||||||
|
private previous: WhilePlaying = DEFAULT_WHILE_PLAYING;
|
||||||
|
|
||||||
|
constructor(private readonly whilePlaying: WhilePlaying) {}
|
||||||
|
|
||||||
|
execute(project: Project): void {
|
||||||
|
this.previous = project.whilePlaying;
|
||||||
|
project.whilePlaying = this.whilePlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(project: Project): void {
|
||||||
|
project.whilePlaying = this.previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,14 @@ import {
|
|||||||
type SceneAsset,
|
type SceneAsset,
|
||||||
} from './Asset';
|
} from './Asset';
|
||||||
import { DEFAULT_LAYER_NAME } from './collisionLayers';
|
import { DEFAULT_LAYER_NAME } from './collisionLayers';
|
||||||
import { DEFAULT_GAME_SCALE, DEFAULT_PLAY_FROM, type GameScale, type PlayFrom } from './playSettings';
|
import {
|
||||||
|
DEFAULT_GAME_SCALE,
|
||||||
|
DEFAULT_PLAY_FROM,
|
||||||
|
DEFAULT_WHILE_PLAYING,
|
||||||
|
type GameScale,
|
||||||
|
type PlayFrom,
|
||||||
|
type WhilePlaying,
|
||||||
|
} from './playSettings';
|
||||||
import { defaultSceneVisibility, type SceneVisibility } from './sceneVisibility';
|
import { defaultSceneVisibility, type SceneVisibility } from './sceneVisibility';
|
||||||
import { DEFAULT_SYMBOL_SCALE } from './symbolScale';
|
import { DEFAULT_SYMBOL_SCALE } from './symbolScale';
|
||||||
|
|
||||||
@@ -46,6 +53,8 @@ export interface Project {
|
|||||||
symbolScale: number;
|
symbolScale: number;
|
||||||
/** Which scene Play starts (see `playSettings.ts`). */
|
/** Which scene Play starts (see `playSettings.ts`). */
|
||||||
playFrom: PlayFrom;
|
playFrom: PlayFrom;
|
||||||
|
/** How closely the panels follow a running game (see `playSettings.ts`). */
|
||||||
|
whilePlaying: WhilePlaying;
|
||||||
/** The scale the game view draws the camera at. Saved, but not an undo step: it is how you look. */
|
/** The scale the game view draws the camera at. Saved, but not an undo step: it is how you look. */
|
||||||
gameScale: GameScale;
|
gameScale: GameScale;
|
||||||
/** Which symbols, names and collider outlines the scene view shows, per type. Saved like `gameScale`. */
|
/** Which symbols, names and collider outlines the scene view shows, per type. Saved like `gameScale`. */
|
||||||
@@ -64,6 +73,7 @@ export function createEmptyProject(id: string, name: string): Project {
|
|||||||
collisionLayers: [DEFAULT_LAYER_NAME],
|
collisionLayers: [DEFAULT_LAYER_NAME],
|
||||||
symbolScale: DEFAULT_SYMBOL_SCALE,
|
symbolScale: DEFAULT_SYMBOL_SCALE,
|
||||||
playFrom: DEFAULT_PLAY_FROM,
|
playFrom: DEFAULT_PLAY_FROM,
|
||||||
|
whilePlaying: DEFAULT_WHILE_PLAYING,
|
||||||
gameScale: DEFAULT_GAME_SCALE,
|
gameScale: DEFAULT_GAME_SCALE,
|
||||||
sceneVisibility: defaultSceneVisibility(),
|
sceneVisibility: defaultSceneVisibility(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ export function findGroup(tree: ObjectTree, groupId: string): ObjectGroup | unde
|
|||||||
return tree.groups?.find((g) => g.id === groupId);
|
return tree.groups?.find((g) => g.id === groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first group of that name, made at the end of the list when there is none – where a run puts
|
||||||
|
* the objects it spawns at the top level (#265), so hundreds of them fold into one row.
|
||||||
|
*/
|
||||||
|
export function groupNamed(tree: ObjectTree, name: string, newGroupId: () => string): ObjectGroup {
|
||||||
|
const found = tree.groups?.find((g) => g.name === name);
|
||||||
|
if (found) return found;
|
||||||
|
const group = { id: newGroupId(), name };
|
||||||
|
tree.groups = [...(tree.groups ?? []), group];
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
/** The top-level objects listed in the group, in tree order. */
|
/** The top-level objects listed in the group, in tree order. */
|
||||||
export function groupMemberIds(tree: ObjectTree, groupId: string): string[] {
|
export function groupMemberIds(tree: ObjectTree, groupId: string): string[] {
|
||||||
return groupedOrder(tree.objects)
|
return groupedOrder(tree.objects)
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ export function isGameScale(value: unknown): value is GameScale {
|
|||||||
return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number);
|
return value === 'auto' || value === 'fit' || GAME_SCALE_STEPS.includes(value as number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How closely the editor's panels follow a running game (#265). The game itself runs every frame
|
||||||
|
* in each of them; only what the hierarchy, the inspector and the graphs show of it is held back.
|
||||||
|
*/
|
||||||
|
export type WhilePlaying = 'live' | 'balanced' | 'minimal';
|
||||||
|
|
||||||
|
export const WHILE_PLAYING: readonly WhilePlaying[] = ['live', 'balanced', 'minimal'];
|
||||||
|
|
||||||
|
/** Also what a project written before there was a choice reads as: the editor stays fast by default. */
|
||||||
|
export const DEFAULT_WHILE_PLAYING: WhilePlaying = 'balanced';
|
||||||
|
|
||||||
|
/** Read off untrusted JSON; anything that is not one of the choices is the default. */
|
||||||
|
export function whilePlayingOf(raw: unknown): WhilePlaying {
|
||||||
|
const value = (raw as { whilePlaying?: unknown } | null)?.whilePlaying;
|
||||||
|
return WHILE_PLAYING.includes(value as WhilePlaying) ? (value as WhilePlaying) : DEFAULT_WHILE_PLAYING;
|
||||||
|
}
|
||||||
|
|
||||||
/** Read off untrusted JSON; anything else is the default. */
|
/** Read off untrusted JSON; anything else is the default. */
|
||||||
export function playFromOf(raw: unknown): PlayFrom {
|
export function playFromOf(raw: unknown): PlayFrom {
|
||||||
const value = (raw as { playFrom?: unknown } | null)?.playFrom;
|
const value = (raw as { playFrom?: unknown } | null)?.playFrom;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import type {
|
|||||||
import { parseCellKey } from '../model/tilemap';
|
import { parseCellKey } from '../model/tilemap';
|
||||||
import { pluginsOf } from './projectPlugins';
|
import { pluginsOf } from './projectPlugins';
|
||||||
import { collisionLayersOf } from '../model/collisionLayers';
|
import { collisionLayersOf } from '../model/collisionLayers';
|
||||||
import { gameScaleOf, playFromOf } from '../model/playSettings';
|
import { gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings';
|
||||||
import { sceneVisibilityOf } from '../model/sceneVisibility';
|
import { sceneVisibilityOf } from '../model/sceneVisibility';
|
||||||
import { symbolScaleOf } from '../model/symbolScale';
|
import { symbolScaleOf } from '../model/symbolScale';
|
||||||
import { decodeCells, encodeCells } from './tilemapCells';
|
import { decodeCells, encodeCells } from './tilemapCells';
|
||||||
@@ -278,6 +278,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
|||||||
collisionLayers: collisionLayersOf(r),
|
collisionLayers: collisionLayersOf(r),
|
||||||
symbolScale: symbolScaleOf(r),
|
symbolScale: symbolScaleOf(r),
|
||||||
playFrom: playFromOf(r),
|
playFrom: playFromOf(r),
|
||||||
|
whilePlaying: whilePlayingOf(r),
|
||||||
gameScale: gameScaleOf(r),
|
gameScale: gameScaleOf(r),
|
||||||
sceneVisibility: sceneVisibilityOf(r),
|
sceneVisibility: sceneVisibilityOf(r),
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-1
@@ -764,6 +764,7 @@ export class Editor implements PlayHost {
|
|||||||
this.centre.relayout();
|
this.centre.relayout();
|
||||||
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
|
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
|
||||||
this.gameView.redraw();
|
this.gameView.redraw();
|
||||||
|
this.viewport.catchUp();
|
||||||
ui().focus.validate();
|
ui().focus.validate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,13 +926,21 @@ export class Editor implements PlayHost {
|
|||||||
* is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the
|
* is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the
|
||||||
* swap leaves the editor's own active scene alone.
|
* swap leaves the editor's own active scene alone.
|
||||||
*/
|
*/
|
||||||
playInfo(): { playing: boolean; paused: boolean; gamePaused: boolean; time: number; sceneId: string } {
|
playInfo(): {
|
||||||
|
playing: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
gamePaused: boolean;
|
||||||
|
time: number;
|
||||||
|
sceneId: string;
|
||||||
|
panelUpdates: { structure: number; values: number };
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
playing: this.play.isPlaying,
|
playing: this.play.isPlaying,
|
||||||
paused: this.play.isPaused,
|
paused: this.play.isPaused,
|
||||||
gamePaused: this.play.isGamePaused,
|
gamePaused: this.play.isGamePaused,
|
||||||
time: this.play.time,
|
time: this.play.time,
|
||||||
sceneId: this.play.sceneId,
|
sceneId: this.play.sceneId,
|
||||||
|
panelUpdates: this.play.panelUpdates(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ import { World } from '../runtime/World';
|
|||||||
import { ui } from '../ui';
|
import { ui } from '../ui';
|
||||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||||
import { saveArea } from './saves';
|
import { saveArea } from './saves';
|
||||||
|
import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle';
|
||||||
import type { GameViewport } from './viewport/GameViewport';
|
import type { GameViewport } from './viewport/GameViewport';
|
||||||
import type { GraphEditor } from './graph/GraphEditor';
|
import type { GraphEditor } from './graph/GraphEditor';
|
||||||
import type { LiveMachineState } from './graph/GraphCanvas';
|
import type { LiveMachineState } from './graph/GraphCanvas';
|
||||||
@@ -47,6 +48,8 @@ export interface WorldSnapshot {
|
|||||||
|
|
||||||
export interface PlayHost {
|
export interface PlayHost {
|
||||||
gameView: GameViewport;
|
gameView: GameViewport;
|
||||||
|
/** The scene view: while a run holds the panels back, it still draws every frame it is on screen. */
|
||||||
|
viewport: { visible: boolean; redraw(): void };
|
||||||
graphEditor: GraphEditor;
|
graphEditor: GraphEditor;
|
||||||
stateMachineEditor: StateMachineEditor;
|
stateMachineEditor: StateMachineEditor;
|
||||||
showGame(): void;
|
showGame(): void;
|
||||||
@@ -60,11 +63,20 @@ export class PlaySession {
|
|||||||
private readonly input = new Input();
|
private readonly input = new Input();
|
||||||
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
|
/** Set while Play waits for the physics engine, so a second press does not queue a second run. */
|
||||||
private waiting = false;
|
private waiting = false;
|
||||||
|
/** When the panels catch up with the run (#265). */
|
||||||
|
private readonly panels = new PanelClock();
|
||||||
|
/** How often the panels were brought up to date this run (test hook). */
|
||||||
|
private updates = { structure: 0, values: 0 };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
private readonly host: PlayHost,
|
private readonly host: PlayHost,
|
||||||
) {}
|
) {
|
||||||
|
// An object picked during a run is looked at now: the hierarchy has to list it to show it picked.
|
||||||
|
state.on('selection', () => {
|
||||||
|
if (this.world && this.panels.holdsStructure) this.follow(this.panels.flush());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
get isPlaying(): boolean {
|
get isPlaying(): boolean {
|
||||||
return this.world !== null;
|
return this.world !== null;
|
||||||
@@ -83,6 +95,8 @@ export class PlaySession {
|
|||||||
togglePause(): void {
|
togglePause(): void {
|
||||||
if (!this.world) return;
|
if (!this.world) return;
|
||||||
this.state.setPaused(!this.state.paused);
|
this.state.setPaused(!this.state.paused);
|
||||||
|
// What you inspect while paused is exact, whatever the panels were holding back.
|
||||||
|
if (this.state.paused) this.follow(this.panels.flush());
|
||||||
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
|
// A held note is part of the picture a pause freezes: the sounds stop where they are and carry
|
||||||
// on mid-note, rather than playing to their end over a still image.
|
// on mid-note, rather than playing to their end over a still image.
|
||||||
soundLibrary.setSuspended(this.state.paused);
|
soundLibrary.setSuspended(this.state.paused);
|
||||||
@@ -134,6 +148,7 @@ export class PlaySession {
|
|||||||
const runtime = this.state.enterPlay();
|
const runtime = this.state.enterPlay();
|
||||||
const world = World.instantiate(runtime, scene.id);
|
const world = World.instantiate(runtime, scene.id);
|
||||||
this.world = world;
|
this.world = world;
|
||||||
|
this.updates = { structure: 0, values: 0 };
|
||||||
this.input.attach();
|
this.input.attach();
|
||||||
this.behaviors = addGameSystems(world, runtime, this.input, {
|
this.behaviors = addGameSystems(world, runtime, this.input, {
|
||||||
audio: soundLibrary,
|
audio: soundLibrary,
|
||||||
@@ -252,14 +267,43 @@ export class PlaySession {
|
|||||||
this.loadScene(world.loadSceneRequested);
|
this.loadScene(world.loadSceneRequested);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
// Mirror what the systems did into the runtime copy every frame, even while the panels wait: an
|
||||||
if (world.syncToModel().structure) this.state.preview();
|
// edit made during the run lands on the model, and it must not land on an old picture of it.
|
||||||
this.state.runtimeTick();
|
const structure = world.syncToModel().structure;
|
||||||
|
const update = this.panels.frame(
|
||||||
|
panelPolicy(this.state.project.whilePlaying),
|
||||||
|
performance.now(),
|
||||||
|
structure,
|
||||||
|
);
|
||||||
|
// A game that pauses itself is looked at like one paused from the toolbar.
|
||||||
|
this.follow(world.paused && !wasPaused ? this.panels.flush() : update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings the panels up to date as far as `update` says; the game's own picture never waits. */
|
||||||
|
private follow(update: PanelUpdate): void {
|
||||||
|
if (update.structure) {
|
||||||
|
this.updates.structure++;
|
||||||
|
this.state.preview();
|
||||||
|
}
|
||||||
|
if (update.values) {
|
||||||
|
this.updates.values++;
|
||||||
|
this.state.runtimeTick();
|
||||||
|
} else {
|
||||||
|
// The views draw on `runtime`, and what is on screen of the game has to move every frame.
|
||||||
|
if (this.host.gameView.visible) this.host.gameView.redraw();
|
||||||
|
if (this.host.viewport.visible) this.host.viewport.redraw();
|
||||||
|
}
|
||||||
|
if (!update.graphs) return;
|
||||||
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
if (this.host.graphEditor.visible) this.host.graphEditor.canvas.redraw();
|
||||||
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
|
// The machine tab shows the state a run is in, so it repaints for the same reason the graph does.
|
||||||
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
|
if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How often the panels caught up with this run, by kind (test hook). */
|
||||||
|
panelUpdates(): { structure: number; values: number } {
|
||||||
|
return { ...this.updates };
|
||||||
|
}
|
||||||
|
|
||||||
/** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */
|
/** The check scripts get the run as JSON, so an object a bag holds is handed over as its name. */
|
||||||
snapshot(): WorldSnapshot | null {
|
snapshot(): WorldSnapshot | null {
|
||||||
const world = this.world;
|
const world = this.world;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
SetCollisionLayersCommand,
|
SetCollisionLayersCommand,
|
||||||
SetPlayFromCommand,
|
SetPlayFromCommand,
|
||||||
SetSymbolScaleCommand,
|
SetSymbolScaleCommand,
|
||||||
|
SetWhilePlayingCommand,
|
||||||
} from '../core/commands/projectCommands';
|
} from '../core/commands/projectCommands';
|
||||||
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
|
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
|
||||||
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
|
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
|
||||||
@@ -235,7 +236,7 @@ export class ProjectSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog
|
* Name, the scene Play starts, how closely the panels follow it, the symbol scale and the collision layers (#152) in one dialog
|
||||||
* (#183). Each is an undo step of its own – one each, and only for what actually changed.
|
* (#183). Each is an undo step of its own – one each, and only for what actually changed.
|
||||||
*/
|
*/
|
||||||
openProjectSettings(): ProjectSettingsDialog {
|
openProjectSettings(): ProjectSettingsDialog {
|
||||||
@@ -243,6 +244,7 @@ export class ProjectSession {
|
|||||||
const before = {
|
const before = {
|
||||||
name: project.name,
|
name: project.name,
|
||||||
playFrom: this.state.playFrom,
|
playFrom: this.state.playFrom,
|
||||||
|
whilePlaying: project.whilePlaying,
|
||||||
collisionLayers: [...project.collisionLayers],
|
collisionLayers: [...project.collisionLayers],
|
||||||
symbolScale: project.symbolScale,
|
symbolScale: project.symbolScale,
|
||||||
};
|
};
|
||||||
@@ -255,6 +257,8 @@ export class ProjectSession {
|
|||||||
if (change.symbolScale !== undefined)
|
if (change.symbolScale !== undefined)
|
||||||
this.state.execute(new SetSymbolScaleCommand(change.symbolScale));
|
this.state.execute(new SetSymbolScaleCommand(change.symbolScale));
|
||||||
if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom));
|
if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom));
|
||||||
|
if (change.whilePlaying !== undefined)
|
||||||
|
this.state.execute(new SetWhilePlayingCommand(change.whilePlaying));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
|
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
|
||||||
import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale';
|
import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale';
|
||||||
import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack } from '../ui';
|
import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack } from '../ui';
|
||||||
import type { PlayFrom } from '../core/model/playSettings';
|
import { type PlayFrom, WHILE_PLAYING, type WhilePlaying } from '../core/model/playSettings';
|
||||||
import { FormDialog } from './FormDialog';
|
import { FormDialog } from './FormDialog';
|
||||||
|
|
||||||
/** What the dialog hands back when Apply is pressed. */
|
/** What the dialog hands back when Apply is pressed. */
|
||||||
export interface ProjectSettings {
|
export interface ProjectSettings {
|
||||||
name: string;
|
name: string;
|
||||||
playFrom: PlayFrom;
|
playFrom: PlayFrom;
|
||||||
|
whilePlaying: WhilePlaying;
|
||||||
collisionLayers: string[];
|
collisionLayers: string[];
|
||||||
symbolScale: number;
|
symbolScale: number;
|
||||||
}
|
}
|
||||||
@@ -22,7 +23,13 @@ export interface ProjectSettings {
|
|||||||
export function settingsChanges(
|
export function settingsChanges(
|
||||||
before: ProjectSettings,
|
before: ProjectSettings,
|
||||||
after: ProjectSettings,
|
after: ProjectSettings,
|
||||||
): { name?: string; collisionLayers?: string[]; symbolScale?: number; playFrom?: PlayFrom } {
|
): {
|
||||||
|
name?: string;
|
||||||
|
collisionLayers?: string[];
|
||||||
|
symbolScale?: number;
|
||||||
|
playFrom?: PlayFrom;
|
||||||
|
whilePlaying?: WhilePlaying;
|
||||||
|
} {
|
||||||
const name = after.name.trim() || before.name;
|
const name = after.name.trim() || before.name;
|
||||||
const symbolScale = clampSymbolScale(after.symbolScale);
|
const symbolScale = clampSymbolScale(after.symbolScale);
|
||||||
return {
|
return {
|
||||||
@@ -32,6 +39,7 @@ export function settingsChanges(
|
|||||||
: { collisionLayers: [...after.collisionLayers] }),
|
: { collisionLayers: [...after.collisionLayers] }),
|
||||||
...(symbolScale === before.symbolScale ? {} : { symbolScale }),
|
...(symbolScale === before.symbolScale ? {} : { symbolScale }),
|
||||||
...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }),
|
...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }),
|
||||||
|
...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +48,15 @@ const PLAY_FROM_LABEL: Record<PlayFrom, string> = {
|
|||||||
active: 'Active scene',
|
active: 'Active scene',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WHILE_PLAYING_LABEL: Record<WhilePlaying, string> = {
|
||||||
|
live: 'Live',
|
||||||
|
balanced: 'Balanced',
|
||||||
|
minimal: 'Minimal',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything about the project that is not an asset (#183): its name, which scene Play starts, the
|
* Everything about the project that is not an asset (#183): its name, which scene Play starts and
|
||||||
|
* how closely the panels follow it (#265), the
|
||||||
* size of the scene view's symbol icons, and the collision layers (#152). The top bar carries icons
|
* size of the scene view's symbol icons, and the collision layers (#152). The top bar carries icons
|
||||||
* only, so this is the one place they are edited.
|
* only, so this is the one place they are edited.
|
||||||
*
|
*
|
||||||
@@ -52,10 +67,12 @@ const PLAY_FROM_LABEL: Record<PlayFrom, string> = {
|
|||||||
export class ProjectSettingsDialog extends FormDialog {
|
export class ProjectSettingsDialog extends FormDialog {
|
||||||
private projectName: string;
|
private projectName: string;
|
||||||
private playFrom: PlayFrom;
|
private playFrom: PlayFrom;
|
||||||
|
private whilePlaying: WhilePlaying;
|
||||||
private layers: string[];
|
private layers: string[];
|
||||||
private symbolScale: number;
|
private symbolScale: number;
|
||||||
private readonly nameField: TextField;
|
private readonly nameField: TextField;
|
||||||
private readonly playFromButton: Button;
|
private readonly playFromButton: Button;
|
||||||
|
private readonly whilePlayingButton: Button;
|
||||||
private readonly symbolScaleField: NumberField;
|
private readonly symbolScaleField: NumberField;
|
||||||
private readonly rows = new VStack({ gap: Theme.gap });
|
private readonly rows = new VStack({ gap: Theme.gap });
|
||||||
private readonly addButton: Button;
|
private readonly addButton: Button;
|
||||||
@@ -64,6 +81,7 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
super(340, 'Project settings');
|
super(340, 'Project settings');
|
||||||
this.projectName = settings.name;
|
this.projectName = settings.name;
|
||||||
this.playFrom = settings.playFrom;
|
this.playFrom = settings.playFrom;
|
||||||
|
this.whilePlaying = settings.whilePlaying;
|
||||||
this.layers = [...settings.collisionLayers];
|
this.layers = [...settings.collisionLayers];
|
||||||
this.symbolScale = clampSymbolScale(settings.symbolScale);
|
this.symbolScale = clampSymbolScale(settings.symbolScale);
|
||||||
|
|
||||||
@@ -88,6 +106,18 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.whilePlayingButton = new Button(WHILE_PLAYING_LABEL[this.whilePlaying], {
|
||||||
|
width: 150,
|
||||||
|
align: 'left',
|
||||||
|
onClick: () => this.openWhilePlayingMenu(),
|
||||||
|
});
|
||||||
|
this.content.add(this.formRow(this.fieldLabel('While playing'), this.whilePlayingButton));
|
||||||
|
this.content.add(
|
||||||
|
new Label('How often the panels follow a game: every frame, 4 times a second, or on pause.', {
|
||||||
|
style: 'muted',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
this.symbolScaleField = new NumberField(this.symbolScale, {
|
this.symbolScaleField = new NumberField(this.symbolScale, {
|
||||||
width: 150,
|
width: 150,
|
||||||
min: SYMBOL_SCALE_MIN,
|
min: SYMBOL_SCALE_MIN,
|
||||||
@@ -122,6 +152,7 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
onApply({
|
onApply({
|
||||||
name: this.projectName,
|
name: this.projectName,
|
||||||
playFrom: this.playFrom,
|
playFrom: this.playFrom,
|
||||||
|
whilePlaying: this.whilePlaying,
|
||||||
collisionLayers: this.layers,
|
collisionLayers: this.layers,
|
||||||
symbolScale: this.symbolScale,
|
symbolScale: this.symbolScale,
|
||||||
}),
|
}),
|
||||||
@@ -134,9 +165,14 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
return [...this.layers];
|
return [...this.layers];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Name, start-scene switch and symbol scale as they stand (test hook). */
|
/** Name, start-scene switch, panel preset and symbol scale as they stand (test hook). */
|
||||||
values(): { name: string; playFrom: PlayFrom; symbolScale: number } {
|
values(): { name: string; playFrom: PlayFrom; whilePlaying: WhilePlaying; symbolScale: number } {
|
||||||
return { name: this.projectName, playFrom: this.playFrom, symbolScale: this.symbolScale };
|
return {
|
||||||
|
name: this.projectName,
|
||||||
|
playFrom: this.playFrom,
|
||||||
|
whilePlaying: this.whilePlaying,
|
||||||
|
symbolScale: this.symbolScale,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The start-scene switch itself, so a check can click it. */
|
/** The start-scene switch itself, so a check can click it. */
|
||||||
@@ -144,6 +180,11 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
return this.playFromButton;
|
return this.playFromButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The panel preset switch, so a check can click it. */
|
||||||
|
get whilePlayingControl(): Button {
|
||||||
|
return this.whilePlayingButton;
|
||||||
|
}
|
||||||
|
|
||||||
/** The symbol scale field, so a check can type into it. */
|
/** The symbol scale field, so a check can type into it. */
|
||||||
get symbolScaleControl(): NumberField {
|
get symbolScaleControl(): NumberField {
|
||||||
return this.symbolScaleField;
|
return this.symbolScaleField;
|
||||||
@@ -170,6 +211,19 @@ export class ProjectSettingsDialog extends FormDialog {
|
|||||||
new Menu([entry('start'), entry('active')]).openBelow(this.playFromButton);
|
new Menu([entry('start'), entry('active')]).openBelow(this.playFromButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openWhilePlayingMenu(): void {
|
||||||
|
new Menu(
|
||||||
|
WHILE_PLAYING.map((mode) => ({
|
||||||
|
label: WHILE_PLAYING_LABEL[mode],
|
||||||
|
current: this.whilePlaying === mode,
|
||||||
|
onSelect: () => {
|
||||||
|
this.whilePlaying = mode;
|
||||||
|
this.whilePlayingButton.setText(WHILE_PLAYING_LABEL[mode]);
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
).openBelow(this.whilePlayingButton);
|
||||||
|
}
|
||||||
|
|
||||||
private addLayer(): void {
|
private addLayer(): void {
|
||||||
if (this.layers.length >= MAX_COLLISION_LAYERS) return;
|
if (this.layers.length >= MAX_COLLISION_LAYERS) return;
|
||||||
this.layers.push(layerFallback(this.layers.length));
|
this.layers.push(layerFallback(this.layers.length));
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
const dialog = editor.projectSettingsDialog();
|
const dialog = editor.projectSettingsDialog();
|
||||||
return dialog ? centreOf(dialog.playFromControl) : null;
|
return dialog ? centreOf(dialog.playFromControl) : null;
|
||||||
},
|
},
|
||||||
|
/** Where to click the While playing switch inside that dialog (#265). */
|
||||||
|
projectSettingsWhilePlayingAt: () => {
|
||||||
|
const dialog = editor.projectSettingsDialog();
|
||||||
|
return dialog ? centreOf(dialog.whilePlayingControl) : null;
|
||||||
|
},
|
||||||
/** Where to click the symbol scale field inside that dialog. */
|
/** Where to click the symbol scale field inside that dialog. */
|
||||||
projectSettingsSymbolScaleAt: () => {
|
projectSettingsSymbolScaleAt: () => {
|
||||||
const dialog = editor.projectSettingsDialog();
|
const dialog = editor.projectSettingsDialog();
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { WhilePlaying } from '../core/model/playSettings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How closely the editor follows a running game (#265). The game runs every frame whatever the
|
||||||
|
* preset says; what is held back is only what the panels show of it. With hundreds of objects
|
||||||
|
* appearing and vanishing, rebuilding the hierarchy on every one of them cost more than the game.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Updates a second on a throttled part. One rate for all of them, so the setting stays one choice. */
|
||||||
|
export const PANEL_RATE = 4;
|
||||||
|
|
||||||
|
/** When a part of the editor catches up: every frame, `PANEL_RATE` times a second, or only on pause. */
|
||||||
|
export type Follow = 'live' | 'throttled' | 'paused';
|
||||||
|
|
||||||
|
export interface PanelPolicy {
|
||||||
|
/** The hierarchy and every panel that rebuilds when objects appear, vanish or move to a parent. */
|
||||||
|
structure: Follow;
|
||||||
|
/** Inspector, variables and tile maps: values that change without an object appearing. */
|
||||||
|
values: Follow;
|
||||||
|
/** Pulses and active states in the graph and state machine tabs. */
|
||||||
|
graphs: 'live' | 'throttled';
|
||||||
|
/** Whether the scene view keeps redrawing while the game tab hides it. */
|
||||||
|
hiddenSceneView: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelPolicy(mode: WhilePlaying): PanelPolicy {
|
||||||
|
switch (mode) {
|
||||||
|
case 'live':
|
||||||
|
return { structure: 'live', values: 'live', graphs: 'live', hiddenSceneView: true };
|
||||||
|
case 'balanced':
|
||||||
|
// The graph tab draws only while it is on screen and cost nothing measurable; at 4 frames a
|
||||||
|
// second its beads would stutter and bunch up, so it keeps up with the game.
|
||||||
|
return { structure: 'throttled', values: 'throttled', graphs: 'live', hiddenSceneView: false };
|
||||||
|
case 'minimal':
|
||||||
|
// A graph has no "on pause": a pulse shown only when the game stands still would never be seen.
|
||||||
|
return { structure: 'paused', values: 'paused', graphs: 'throttled', hiddenSceneView: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to bring up to date after a frame. */
|
||||||
|
export interface PanelUpdate {
|
||||||
|
structure: boolean;
|
||||||
|
values: boolean;
|
||||||
|
graphs: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides frame by frame what the panels get. A change of structure that falls between two updates
|
||||||
|
* is kept, not dropped: the next update that is due carries it, and `flush` hands it out at once.
|
||||||
|
*/
|
||||||
|
export class PanelClock {
|
||||||
|
private lastAt = Number.NEGATIVE_INFINITY;
|
||||||
|
private pendingStructure = false;
|
||||||
|
|
||||||
|
/** One frame of the game. `now` is wall time in milliseconds, `structure` whether objects changed. */
|
||||||
|
frame(policy: PanelPolicy, now: number, structure: boolean): PanelUpdate {
|
||||||
|
this.pendingStructure ||= structure;
|
||||||
|
const due = now - this.lastAt >= 1000 / PANEL_RATE;
|
||||||
|
if (due) this.lastAt = now;
|
||||||
|
const follows = (follow: Follow) => follow === 'live' || (follow === 'throttled' && due);
|
||||||
|
const update = {
|
||||||
|
structure: this.pendingStructure && follows(policy.structure),
|
||||||
|
values: follows(policy.values),
|
||||||
|
graphs: policy.graphs === 'live' || due,
|
||||||
|
};
|
||||||
|
if (update.structure) this.pendingStructure = false;
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The game stands still or something must be exact now: everything held back comes out at once. */
|
||||||
|
flush(): PanelUpdate {
|
||||||
|
const structure = this.pendingStructure;
|
||||||
|
this.pendingStructure = false;
|
||||||
|
return { structure, values: true, graphs: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a change of structure is waiting for its update. */
|
||||||
|
get holdsStructure(): boolean {
|
||||||
|
return this.pendingStructure;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import { cellAtPoint } from '../../core/model/tilemap';
|
|||||||
import { footprintCorners, iconCorners } from '../../core/properties/bounds';
|
import { footprintCorners, iconCorners } from '../../core/properties/bounds';
|
||||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||||
import { FramePreview } from '../framePreview';
|
import { FramePreview } from '../framePreview';
|
||||||
|
import { panelPolicy } from '../playThrottle';
|
||||||
import { type EditorState, objectSelection } from '../state';
|
import { type EditorState, objectSelection } from '../state';
|
||||||
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
||||||
import { activeDrag, drawBand, drawGrid, endDrag, Menu, onDragChange, Theme, textStyle } from '../../ui';
|
import { activeDrag, drawBand, drawGrid, endDrag, Menu, onDragChange, Theme, textStyle } from '../../ui';
|
||||||
@@ -180,6 +181,27 @@ export class SceneViewport extends FreeViewport {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set when a redraw was skipped behind the game tab (#265); the next time on screen draws it. */
|
||||||
|
private missedRedraw = false;
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
if (
|
||||||
|
!this.visible &&
|
||||||
|
this.state.mode === 'play' &&
|
||||||
|
!panelPolicy(this.state.project.whilePlaying).hiddenSceneView
|
||||||
|
) {
|
||||||
|
this.missedRedraw = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.missedRedraw = false;
|
||||||
|
super.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coming back on screen: draws what was skipped while hidden. */
|
||||||
|
catchUp(): void {
|
||||||
|
if (this.missedRedraw) this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
/** What this view shows and edits; the Container tab returns its container asset instead. */
|
/** What this view shows and edits; the Container tab returns its container asset instead. */
|
||||||
protected holder(): ObjectHolder | undefined {
|
protected holder(): ObjectHolder | undefined {
|
||||||
return this.state.activeScene;
|
return this.state.activeScene;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { sameAtRest, withoutObjects } from '../core/graph/liveObjects';
|
|||||||
import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties';
|
import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties';
|
||||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||||||
import { treeOrder } from '../core/model/hierarchy';
|
import { groupNamed, treeOrder } from '../core/model/hierarchy';
|
||||||
import { liveTileAt, withTile } from '../core/model/liveTilemap';
|
import { liveTileAt, withTile } from '../core/model/liveTilemap';
|
||||||
import { referenceCandidates } from '../core/model/objectRefs';
|
import { referenceCandidates } from '../core/model/objectRefs';
|
||||||
import type { Cell } from '../core/model/tilemap';
|
import type { Cell } from '../core/model/tilemap';
|
||||||
@@ -276,6 +276,8 @@ export class World {
|
|||||||
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
|
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
|
||||||
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
|
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
|
||||||
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
|
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
|
||||||
|
// Spawned at the top level, it is listed in a group named after its asset, not as a row of its own.
|
||||||
|
if (!parentEntity) object.groupId = groupNamed(scene, asset.name, newId).id;
|
||||||
scene.objects.push(object);
|
scene.objects.push(object);
|
||||||
// A container brings its content along (parented below it, in template order).
|
// A container brings its content along (parented below it, in template order).
|
||||||
const created = asset.type === 'container' ? materializeInstance(this.project, scene, object) : [];
|
const created = asset.type === 'container' ? materializeInstance(this.project, scene, object) : [];
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ describe('CommandStack', () => {
|
|||||||
collisionLayers: ['Default'],
|
collisionLayers: ['Default'],
|
||||||
symbolScale: 1,
|
symbolScale: 1,
|
||||||
playFrom: 'start' as const,
|
playFrom: 'start' as const,
|
||||||
|
whilePlaying: 'live' as const,
|
||||||
gameScale: 1,
|
gameScale: 1,
|
||||||
sceneVisibility: defaultSceneVisibility(),
|
sceneVisibility: defaultSceneVisibility(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { settingsChanges, type ProjectSettings } from '../../src/editor/ProjectS
|
|||||||
const before: ProjectSettings = {
|
const before: ProjectSettings = {
|
||||||
name: 'Demo project',
|
name: 'Demo project',
|
||||||
playFrom: 'start',
|
playFrom: 'start',
|
||||||
|
whilePlaying: 'balanced',
|
||||||
collisionLayers: ['Default', 'Enemies'],
|
collisionLayers: ['Default', 'Enemies'],
|
||||||
symbolScale: 1,
|
symbolScale: 1,
|
||||||
};
|
};
|
||||||
@@ -57,4 +58,9 @@ describe('project settings', () => {
|
|||||||
expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active');
|
expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active');
|
||||||
expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBeUndefined();
|
expect(settingsChanges(before, { ...before, playFrom: 'start' }).playFrom).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes the While playing preset only when it changed (#265)', () => {
|
||||||
|
expect(settingsChanges(before, { ...before, whilePlaying: 'live' }).whilePlaying).toBe('live');
|
||||||
|
expect(settingsChanges(before, { ...before, whilePlaying: 'balanced' }).whilePlaying).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -129,9 +129,16 @@ export async function play(t) {
|
|||||||
spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'),
|
spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'),
|
||||||
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }),
|
JSON.stringify({ n: spawnWorld.entities.length, log: spawnWorld.log }),
|
||||||
);
|
);
|
||||||
|
// The panels catch up four times a second while playing (#265), and a crate spawned at the top
|
||||||
|
// level is listed in the group named after its asset.
|
||||||
|
await t.sleep(300);
|
||||||
|
const crateGroup = await json(`__gm.state.activeScene.groups?.find((g) => g.name === 'Crate')?.id ?? ''`);
|
||||||
check(
|
check(
|
||||||
'hierarchy lists the spawned object',
|
'hierarchy lists the spawned object in a group named Crate',
|
||||||
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8,
|
spawned?.groupId === crateGroup &&
|
||||||
|
(await evaluate(`__gm.hierarchyRow('group:${crateGroup}') !== null`)) &&
|
||||||
|
(await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 9,
|
||||||
|
JSON.stringify({ crateGroup, spawned }),
|
||||||
);
|
);
|
||||||
info = await gameInfo();
|
info = await gameInfo();
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* While playing (#265): how closely the panels follow a running game. The preset is picked in the
|
||||||
|
* Project settings with the mouse; Live brings the panels up to date every frame, Balanced four
|
||||||
|
* times a second, and neither holds the game back. On Balanced the scene view stops drawing behind
|
||||||
|
* the game tab and catches up when it comes back; on Minimal the hierarchy waits for a pause. A
|
||||||
|
* crate spawned at the top level is listed in a group named "Crate".
|
||||||
|
*/
|
||||||
|
export async function whilePlaying(t) {
|
||||||
|
const {
|
||||||
|
check,
|
||||||
|
json,
|
||||||
|
evaluate,
|
||||||
|
click,
|
||||||
|
clickText,
|
||||||
|
clickToolbar,
|
||||||
|
press,
|
||||||
|
play,
|
||||||
|
stop,
|
||||||
|
shot,
|
||||||
|
showTab,
|
||||||
|
undo,
|
||||||
|
sleep,
|
||||||
|
} = t;
|
||||||
|
const playInfo = () => json('__gm.playInfo()');
|
||||||
|
/** Panel updates so far and the page's clock, so a rate is measured in the page, not over CDP. */
|
||||||
|
const sample = () => json('({ at: performance.now(), ...__gm.playInfo().panelUpdates })');
|
||||||
|
const valuesPerSecond = async (ms) => {
|
||||||
|
const a = await sample();
|
||||||
|
await sleep(ms);
|
||||||
|
const b = await sample();
|
||||||
|
return { rate: ((b.values - a.values) * 1000) / (b.at - a.at), a, b };
|
||||||
|
};
|
||||||
|
const setWhilePlaying = async (label) => {
|
||||||
|
await clickToolbar('menu');
|
||||||
|
await clickText('Project settings…');
|
||||||
|
const before = (await json('__gm.projectSettings()')).whilePlaying;
|
||||||
|
const at = await json('__gm.projectSettingsWhilePlayingAt()');
|
||||||
|
await click(at.x, at.y);
|
||||||
|
await clickText(label);
|
||||||
|
const chosen = (await json('__gm.projectSettings()')).whilePlaying;
|
||||||
|
await clickText('Apply');
|
||||||
|
return { before, chosen };
|
||||||
|
};
|
||||||
|
const undoSteps = () => json('__gm.state.commands.undoStack.length');
|
||||||
|
/** Whether the hierarchy *shows* the row – `hierarchyOrder` reads the model, not the list on screen. */
|
||||||
|
const listed = (rowId) => json(`__gm.hierarchyRow(${JSON.stringify(rowId)}) !== null`);
|
||||||
|
const crateGroups = () => json(`(__gm.state.activeScene.groups ?? []).filter((g) => g.name === 'Crate')`);
|
||||||
|
|
||||||
|
await press('Escape');
|
||||||
|
await evaluate('__gm.state.select(null)');
|
||||||
|
await showTab('scene');
|
||||||
|
|
||||||
|
// 59a. The fixture was written without the setting, so it reads as Balanced; Live is picked with
|
||||||
|
// the mouse and is one undo step.
|
||||||
|
check(
|
||||||
|
'a project written before there was a choice plays Balanced',
|
||||||
|
(await json('__gm.state.project.whilePlaying')) === 'balanced',
|
||||||
|
);
|
||||||
|
const steps = await undoSteps();
|
||||||
|
const picked = await setWhilePlaying('Live');
|
||||||
|
check(
|
||||||
|
'the Project settings show Balanced and take Live from the menu',
|
||||||
|
picked.before === 'balanced' &&
|
||||||
|
picked.chosen === 'live' &&
|
||||||
|
(await json('__gm.state.project.whilePlaying')) === 'live' &&
|
||||||
|
(await undoSteps()) === steps + 1,
|
||||||
|
JSON.stringify({ picked, steps: await undoSteps(), before: steps }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 59b. Live follows every frame; Balanced four times a second.
|
||||||
|
await play();
|
||||||
|
const live = await valuesPerSecond(1500);
|
||||||
|
await stop();
|
||||||
|
await undo();
|
||||||
|
check('undo sets it back to Balanced', (await json('__gm.state.project.whilePlaying')) === 'balanced');
|
||||||
|
await play();
|
||||||
|
const balanced = await valuesPerSecond(1500);
|
||||||
|
check(
|
||||||
|
'Live brings the panels up to date every frame, Balanced at most four times a second',
|
||||||
|
live.rate > 20 && balanced.rate > 2 && balanced.rate <= 4.5,
|
||||||
|
JSON.stringify({ live: live.rate, balanced: balanced.rate }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 59c. Behind the game tab the scene view does not draw on Balanced – three crates spawned, not
|
||||||
|
// one redraw – and catches up when its tab comes back. The crates are in a group "Crate".
|
||||||
|
const hidden = (await json('__gm.viewportInfo()')).redraws;
|
||||||
|
const entitiesBefore = (await json('__gm.world()')).entities.length;
|
||||||
|
for (let i = 0; i < 3; i++) await press('Space');
|
||||||
|
await sleep(600);
|
||||||
|
const stillHidden = (await json('__gm.viewportInfo()')).redraws;
|
||||||
|
const groups = await crateGroups();
|
||||||
|
const members = await json(
|
||||||
|
`__gm.state.activeScene.objects.filter((o) => o.groupId === ${JSON.stringify(groups[0]?.id ?? '')}).length`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'the game ran on – three crates spawned – and they are listed in one group named Crate',
|
||||||
|
(await json('__gm.world()')).entities.length === entitiesBefore + 3 &&
|
||||||
|
groups.length === 1 &&
|
||||||
|
members === 3,
|
||||||
|
JSON.stringify({ entitiesBefore, groups, members }),
|
||||||
|
);
|
||||||
|
check('the hierarchy shows the group after the next update', await listed(`group:${groups[0]?.id}`));
|
||||||
|
check(
|
||||||
|
'behind the game tab the scene view did not draw once',
|
||||||
|
stillHidden === hidden,
|
||||||
|
JSON.stringify({ hidden, stillHidden }),
|
||||||
|
);
|
||||||
|
await showTab('scene');
|
||||||
|
const shown = (await json('__gm.viewportInfo()')).redraws;
|
||||||
|
check(
|
||||||
|
'and it draws again when its tab comes back',
|
||||||
|
shown > stillHidden,
|
||||||
|
JSON.stringify({ stillHidden, shown }),
|
||||||
|
);
|
||||||
|
await shot('59a-balanced-group');
|
||||||
|
await stop();
|
||||||
|
|
||||||
|
// 59d. Minimal: the hierarchy waits while the game runs, and a pause brings it up to date at once.
|
||||||
|
await showTab('scene');
|
||||||
|
await setWhilePlaying('Minimal');
|
||||||
|
await play();
|
||||||
|
await press('Space');
|
||||||
|
await sleep(600);
|
||||||
|
const spawnedGroups = await crateGroups();
|
||||||
|
const groupRow = `group:${spawnedGroups[0]?.id}`;
|
||||||
|
const listedRunning = await listed(groupRow);
|
||||||
|
check(
|
||||||
|
'on Minimal a spawn reaches the scene, but the hierarchy waits',
|
||||||
|
spawnedGroups.length === 1 && !listedRunning,
|
||||||
|
JSON.stringify({ spawnedGroups, listedRunning }),
|
||||||
|
);
|
||||||
|
await clickToolbar('pause');
|
||||||
|
check('and a pause lists it at once', (await playInfo()).paused && (await listed(groupRow)));
|
||||||
|
await shot('59b-minimal-paused');
|
||||||
|
await stop();
|
||||||
|
|
||||||
|
await undo();
|
||||||
|
check(
|
||||||
|
'the fixture is left on Balanced with the undo stack as found',
|
||||||
|
(await json('__gm.state.project.whilePlaying')) === 'balanced' && (await undoSteps()) === steps,
|
||||||
|
JSON.stringify({ steps: await undoSteps(), before: steps }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ import { typedLists } from './55-typed-lists.mjs';
|
|||||||
import { privateVariables } from './56-private-variables.mjs';
|
import { privateVariables } from './56-private-variables.mjs';
|
||||||
import { objectReferences } from './57-object-references.mjs';
|
import { objectReferences } from './57-object-references.mjs';
|
||||||
import { clipAreas } from './58-clip-areas.mjs';
|
import { clipAreas } from './58-clip-areas.mjs';
|
||||||
|
import { whilePlaying } from './59-while-playing.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -121,4 +122,5 @@ export const STEPS = [
|
|||||||
privateVariables,
|
privateVariables,
|
||||||
objectReferences,
|
objectReferences,
|
||||||
clipAreas,
|
clipAreas,
|
||||||
|
whilePlaying,
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user