diff --git a/README.md b/README.md index 83cff607..ae6d01c9 100644 --- a/README.md +++ b/README.md @@ -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: ```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 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/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index 84abe5e1..ff3ddd83 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -1,5 +1,5 @@ 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 { clampSymbolScale } from '../model/symbolScale'; import type { Command } from './Command'; @@ -74,3 +74,20 @@ export class SetPlayFromCommand implements Command { 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; + } +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index ba93a9e1..04fa6375 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -7,7 +7,14 @@ import { type SceneAsset, } from './Asset'; 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 { DEFAULT_SYMBOL_SCALE } from './symbolScale'; @@ -46,6 +53,8 @@ export interface Project { symbolScale: number; /** Which scene Play starts (see `playSettings.ts`). */ 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. */ gameScale: 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], symbolScale: DEFAULT_SYMBOL_SCALE, playFrom: DEFAULT_PLAY_FROM, + whilePlaying: DEFAULT_WHILE_PLAYING, gameScale: DEFAULT_GAME_SCALE, sceneVisibility: defaultSceneVisibility(), }; diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index df39f125..d1a88c94 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -134,6 +134,18 @@ export function findGroup(tree: ObjectTree, groupId: string): ObjectGroup | unde 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. */ export function groupMemberIds(tree: ObjectTree, groupId: string): string[] { return groupedOrder(tree.objects) diff --git a/src/core/model/playSettings.ts b/src/core/model/playSettings.ts index 41acc9aa..13c3721f 100644 --- a/src/core/model/playSettings.ts +++ b/src/core/model/playSettings.ts @@ -20,6 +20,23 @@ export function isGameScale(value: unknown): value is GameScale { 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. */ export function playFromOf(raw: unknown): PlayFrom { const value = (raw as { playFrom?: unknown } | null)?.playFrom; diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 481cb90d..026c0925 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -43,7 +43,7 @@ import type { import { parseCellKey } from '../model/tilemap'; import { pluginsOf } from './projectPlugins'; import { collisionLayersOf } from '../model/collisionLayers'; -import { gameScaleOf, playFromOf } from '../model/playSettings'; +import { gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings'; import { sceneVisibilityOf } from '../model/sceneVisibility'; import { symbolScaleOf } from '../model/symbolScale'; import { decodeCells, encodeCells } from './tilemapCells'; @@ -278,6 +278,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { collisionLayers: collisionLayersOf(r), symbolScale: symbolScaleOf(r), playFrom: playFromOf(r), + whilePlaying: whilePlayingOf(r), gameScale: gameScaleOf(r), sceneVisibility: sceneVisibilityOf(r), }; diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index bc3226aa..5ad66d77 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -764,6 +764,7 @@ export class Editor implements PlayHost { this.centre.relayout(); // Hidden, it skipped every change; its pane may come back at the same size and not relayout. this.gameView.redraw(); + this.viewport.catchUp(); 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 * 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 { playing: this.play.isPlaying, paused: this.play.isPaused, gamePaused: this.play.isGamePaused, time: this.play.time, sceneId: this.play.sceneId, + panelUpdates: this.play.panelUpdates(), }; } } diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index d17127ff..19020191 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -20,6 +20,7 @@ import { World } from '../runtime/World'; import { ui } from '../ui'; import { soundLibrary } from '../viewport/SoundLibrary'; import { saveArea } from './saves'; +import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle'; import type { GameViewport } from './viewport/GameViewport'; import type { GraphEditor } from './graph/GraphEditor'; import type { LiveMachineState } from './graph/GraphCanvas'; @@ -47,6 +48,8 @@ export interface WorldSnapshot { export interface PlayHost { 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; stateMachineEditor: StateMachineEditor; showGame(): void; @@ -60,11 +63,20 @@ export class PlaySession { private readonly input = new Input(); /** Set while Play waits for the physics engine, so a second press does not queue a second run. */ 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( private readonly state: EditorState, 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 { return this.world !== null; @@ -83,6 +95,8 @@ export class PlaySession { togglePause(): void { if (!this.world) return; 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 // on mid-note, rather than playing to their end over a still image. soundLibrary.setSuspended(this.state.paused); @@ -134,6 +148,7 @@ export class PlaySession { const runtime = this.state.enterPlay(); const world = World.instantiate(runtime, scene.id); this.world = world; + this.updates = { structure: 0, values: 0 }; this.input.attach(); this.behaviors = addGameSystems(world, runtime, this.input, { audio: soundLibrary, @@ -252,14 +267,43 @@ export class PlaySession { this.loadScene(world.loadSceneRequested); return; } - // Mirror what the systems did into the runtime copy so the panels show live values. - if (world.syncToModel().structure) this.state.preview(); - this.state.runtimeTick(); + // Mirror what the systems did into the runtime copy every frame, even while the panels wait: an + // edit made during the run lands on the model, and it must not land on an old picture of it. + 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(); // 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(); } + /** 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. */ snapshot(): WorldSnapshot | null { const world = this.world; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 227f09f7..6bd8ebed 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -6,6 +6,7 @@ import { SetCollisionLayersCommand, SetPlayFromCommand, SetSymbolScaleCommand, + SetWhilePlayingCommand, } from '../core/commands/projectCommands'; import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore'; 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. */ openProjectSettings(): ProjectSettingsDialog { @@ -243,6 +244,7 @@ export class ProjectSession { const before = { name: project.name, playFrom: this.state.playFrom, + whilePlaying: project.whilePlaying, collisionLayers: [...project.collisionLayers], symbolScale: project.symbolScale, }; @@ -255,6 +257,8 @@ export class ProjectSession { if (change.symbolScale !== undefined) this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom)); + if (change.whilePlaying !== undefined) + this.state.execute(new SetWhilePlayingCommand(change.whilePlaying)); }), ); } diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index 0c033714..e270caf5 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -1,13 +1,14 @@ import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale'; 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'; /** What the dialog hands back when Apply is pressed. */ export interface ProjectSettings { name: string; playFrom: PlayFrom; + whilePlaying: WhilePlaying; collisionLayers: string[]; symbolScale: number; } @@ -22,7 +23,13 @@ export interface ProjectSettings { export function settingsChanges( before: 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 symbolScale = clampSymbolScale(after.symbolScale); return { @@ -32,6 +39,7 @@ export function settingsChanges( : { collisionLayers: [...after.collisionLayers] }), ...(symbolScale === before.symbolScale ? {} : { symbolScale }), ...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }), + ...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }), }; } @@ -40,8 +48,15 @@ const PLAY_FROM_LABEL: Record = { active: 'Active scene', }; +const WHILE_PLAYING_LABEL: Record = { + 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 * only, so this is the one place they are edited. * @@ -52,10 +67,12 @@ const PLAY_FROM_LABEL: Record = { export class ProjectSettingsDialog extends FormDialog { private projectName: string; private playFrom: PlayFrom; + private whilePlaying: WhilePlaying; private layers: string[]; private symbolScale: number; private readonly nameField: TextField; private readonly playFromButton: Button; + private readonly whilePlayingButton: Button; private readonly symbolScaleField: NumberField; private readonly rows = new VStack({ gap: Theme.gap }); private readonly addButton: Button; @@ -64,6 +81,7 @@ export class ProjectSettingsDialog extends FormDialog { super(340, 'Project settings'); this.projectName = settings.name; this.playFrom = settings.playFrom; + this.whilePlaying = settings.whilePlaying; this.layers = [...settings.collisionLayers]; 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, { width: 150, min: SYMBOL_SCALE_MIN, @@ -122,6 +152,7 @@ export class ProjectSettingsDialog extends FormDialog { onApply({ name: this.projectName, playFrom: this.playFrom, + whilePlaying: this.whilePlaying, collisionLayers: this.layers, symbolScale: this.symbolScale, }), @@ -134,9 +165,14 @@ export class ProjectSettingsDialog extends FormDialog { return [...this.layers]; } - /** Name, start-scene switch and symbol scale as they stand (test hook). */ - values(): { name: string; playFrom: PlayFrom; symbolScale: number } { - return { name: this.projectName, playFrom: this.playFrom, symbolScale: this.symbolScale }; + /** Name, start-scene switch, panel preset and symbol scale as they stand (test hook). */ + values(): { name: string; playFrom: PlayFrom; whilePlaying: WhilePlaying; symbolScale: number } { + return { + name: this.projectName, + playFrom: this.playFrom, + whilePlaying: this.whilePlaying, + symbolScale: this.symbolScale, + }; } /** The start-scene switch itself, so a check can click it. */ @@ -144,6 +180,11 @@ export class ProjectSettingsDialog extends FormDialog { 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. */ get symbolScaleControl(): NumberField { return this.symbolScaleField; @@ -170,6 +211,19 @@ export class ProjectSettingsDialog extends FormDialog { 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 { if (this.layers.length >= MAX_COLLISION_LAYERS) return; this.layers.push(layerFallback(this.layers.length)); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 84babe86..0d374cdf 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -238,6 +238,11 @@ export function uiHooks({ app, state, editor }: HookContext) { const dialog = editor.projectSettingsDialog(); 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. */ projectSettingsSymbolScaleAt: () => { const dialog = editor.projectSettingsDialog(); diff --git a/src/editor/playThrottle.ts b/src/editor/playThrottle.ts new file mode 100644 index 00000000..f6373fa4 --- /dev/null +++ b/src/editor/playThrottle.ts @@ -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; + } +} diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 70ade120..b0d8826b 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -25,6 +25,7 @@ import { cellAtPoint } from '../../core/model/tilemap'; import { footprintCorners, iconCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { FramePreview } from '../framePreview'; +import { panelPolicy } from '../playThrottle'; import { type EditorState, objectSelection } from '../state'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; 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. */ protected holder(): ObjectHolder | undefined { return this.state.activeScene; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index b7f6d2ee..cdf9be61 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -5,7 +5,7 @@ import { sameAtRest, withoutObjects } from '../core/graph/liveObjects'; import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties'; import { isPlaceableType } from '../core/model/AssetTypes'; 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 { referenceCandidates } from '../core/model/objectRefs'; import type { Cell } from '../core/model/tilemap'; @@ -276,6 +276,8 @@ export class World { if (!scene || !asset || !isPlaceableType(asset.type)) return null; const parentEntity = parent && this.entities.includes(parent) ? parent : null; 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); // A container brings its content along (parented below it, in template order). const created = asset.type === 'container' ? materializeInstance(this.project, scene, object) : []; diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 43665379..53a414ee 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -154,6 +154,7 @@ describe('CommandStack', () => { collisionLayers: ['Default'], symbolScale: 1, playFrom: 'start' as const, + whilePlaying: 'live' as const, gameScale: 1, sceneVisibility: defaultSceneVisibility(), }; diff --git a/tests/editor/play-throttle.test.ts b/tests/editor/play-throttle.test.ts new file mode 100644 index 00000000..e0cbc318 --- /dev/null +++ b/tests/editor/play-throttle.test.ts @@ -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(); + }); +}); diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts index febdc63c..930f0519 100644 --- a/tests/editor/project-settings.test.ts +++ b/tests/editor/project-settings.test.ts @@ -8,6 +8,7 @@ import { settingsChanges, type ProjectSettings } from '../../src/editor/ProjectS const before: ProjectSettings = { name: 'Demo project', playFrom: 'start', + whilePlaying: 'balanced', collisionLayers: ['Default', 'Enemies'], symbolScale: 1, }; @@ -57,4 +58,9 @@ describe('project settings', () => { expect(settingsChanges(before, { ...before, playFrom: 'active' }).playFrom).toBe('active'); 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(); + }); }); diff --git a/tools/e2e/03-play.mjs b/tools/e2e/03-play.mjs index a2eb8d51..5f29d34a 100644 --- a/tools/e2e/03-play.mjs +++ b/tools/e2e/03-play.mjs @@ -129,9 +129,16 @@ export async function play(t) { spawnWorld.entities.length === 8 && spawnWorld.log.includes('Crate'), 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( - 'hierarchy lists the spawned object', - (await evaluate('__gm.hierarchyExpandAll(); __gm.hierarchyOrder().length')) === 8, + 'hierarchy lists the spawned object in a group named Crate', + 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(); check( diff --git a/tools/e2e/59-while-playing.mjs b/tools/e2e/59-while-playing.mjs new file mode 100644 index 00000000..2d217e60 --- /dev/null +++ b/tools/e2e/59-while-playing.mjs @@ -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 }), + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 4489cf08..de14ebda 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -61,6 +61,7 @@ import { typedLists } from './55-typed-lists.mjs'; import { privateVariables } from './56-private-variables.mjs'; import { objectReferences } from './57-object-references.mjs'; import { clipAreas } from './58-clip-areas.mjs'; +import { whilePlaying } from './59-while-playing.mjs'; export const STEPS = [ hierarchy, @@ -121,4 +122,5 @@ export const STEPS = [ privateVariables, objectReferences, clipAreas, + whilePlaying, ];