From 77f4c2c1a369e740bb83525cb1a95524369e43ff Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 30 Sep 2026 09:44:53 +0200 Subject: [PATCH] Show entities and time per area while playing in the Game View (#275) A game that stutters gave no hint where the frame goes. The Game tab now has a Performance switch. It shows a small overlay in the top left corner of the Game View while a game runs: fps, the number of entities and the mean milliseconds per frame for each area, averaged over half a second like the fps counter. - src/editor/frameMeter.ts holds the pure rule. FrameMeter sums the areas per frame, closes a window every 0.5 s and formats the lines. - World.timeSystem times every system the world ticks. PlaySession maps the systems to Behaviors, Physics and Animation and sets the hook again after Load scene. - Animation has its own line. Counting the AnimationSystem under Behaviors would have skewed that number. - Drawing is the build of the game picture plus app.render(). It is main thread time only. The GPU work after it is not in the number, and app.render() draws the whole canvas including the panels. - Editor is the rest of the editor tick after the systems and the game picture: mirroring into the model, panels and console. - The overlay lets clicks through to the game. It shows dashes until the first window closes. The switch is off by default and is not saved. The released game (#266) runs without a meter. Tests: - Unit frame-meter: 9 passed. - Gate on melektaus-atx: 197 files, 2853 tests, CHECK EXIT 0. - e2e 59 on melektaus-atx: ALL PASSED, 18 checks (13 before, +5 in 59e with a real mouse on the switch). Run twice. - Three mutations were caught: the editor share not subtracting the systems (2 unit tests), systems running in pause not reported (1 unit test), the overlay getting no times (59e). - The e2e total of 1601 is computed (1596 + 5), not measured. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/editor/Editor.ts | 14 +++ src/editor/PlaySession.ts | 30 +++++ src/editor/debug/sceneHooks.ts | 16 +++ src/editor/frameMeter.ts | 86 ++++++++++++++ src/editor/viewport/GamePane.ts | 16 ++- src/editor/viewport/GameViewport.ts | 30 ++++- src/editor/viewport/PerformanceOverlay.ts | 34 ++++++ src/runtime/World.ts | 15 ++- src/ui/index.ts | 1 + src/ui/render.ts | 9 ++ tests/editor/frame-meter.test.ts | 135 ++++++++++++++++++++++ tools/e2e/59-while-playing.mjs | 53 +++++++++ 13 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 src/editor/frameMeter.ts create mode 100644 src/editor/viewport/PerformanceOverlay.ts create mode 100644 tests/editor/frame-meter.test.ts diff --git a/README.md b/README.md index cf7fdafb..5e7e199e 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1596 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1601 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/editor/Editor.ts b/src/editor/Editor.ts index 654da743..e54fc2c3 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -14,6 +14,7 @@ import { clamp, HStack, isRendering, + lastRenderTime, Menu, type MenuEntry, Panel, @@ -904,6 +905,7 @@ export class Editor implements PlayHost { showGame(): void { this.showTab('game'); + this.showPerformance(); } leaveGame(): void { @@ -911,6 +913,8 @@ export class Editor implements PlayHost { } private tick(dt: number): void { + const started = performance.now(); + const drawnBefore = this.gameView.drawTime; this.measureFps(dt); // One step for everything below: a world gets exactly the seconds the particle, shader and // animation clocks moved, so the global time scale cannot pull them apart. @@ -932,6 +936,14 @@ export class Editor implements PlayHost { if (this.tab === 'shader') this.shaderEditor.tick(); if (this.tab === 'pixelart') this.pixelArtEditor.tick(); if (this.movesByItself()) requestRender(); + const drawn = this.gameView.drawTime - drawnBefore; + if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime())) + this.showPerformance(); + } + + private showPerformance(): void { + const reading = this.play.performance(); + if (reading) this.gameView.performanceOverlay.show({ fps: this.fps, ...reading }); } /** @@ -1007,6 +1019,7 @@ export class Editor implements PlayHost { time: number; sceneId: string; panelUpdates: { structure: number; values: number }; + performance: ReturnType; } { return { playing: this.play.isPlaying, @@ -1015,6 +1028,7 @@ export class Editor implements PlayHost { time: this.play.time, sceneId: this.play.sceneId, panelUpdates: this.play.panelUpdates(), + performance: this.play.performance(), }; } } diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 2c5cc674..d1d2061f 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -6,6 +6,7 @@ import { emptySharedState, type SharedState, worldPositionOf } from '../core/graph'; import { namedValue } from '../core/graph/values'; import { GameRun, type RunServices } from '../runtime/GameRun'; +import { AnimationSystem } from '../runtime/systems/AnimationSystem'; import { type BehaviorSystem, behaviorError, type Trace } from '../runtime/systems/BehaviorSystem'; import type { AnimationPlayer } from '../core/model/animation'; import type { PropertyBag } from '../core/model/Properties'; @@ -15,6 +16,7 @@ import { Input } from '../runtime/systems/Input'; import type { World } from '../runtime/World'; import { ui } from '../ui'; import { soundLibrary } from '../viewport/SoundLibrary'; +import { FrameMeter, type FrameTimes } from './frameMeter'; import { saveArea } from './saves'; import { PanelClock, panelPolicy, type PanelUpdate } from './playThrottle'; import type { GameViewport } from './viewport/GameViewport'; @@ -62,6 +64,8 @@ export class PlaySession { private readonly panels = new PanelClock(); /** How often the panels were brought up to date this run (test hook). */ private updates = { structure: 0, values: 0 }; + /** Where the frames of this run go (#275). */ + private readonly meter = new FrameMeter(); constructor( private readonly state: EditorState, @@ -157,6 +161,8 @@ export class PlaySession { }; this.run = GameRun.start(runtime, scene.id, this.input, services); this.updates = { structure: 0, values: 0 }; + this.meter.reset(); + this.timeSystems(this.run); this.input.attach(); this.host.gameView.setWorld(this.run.world); this.host.showGame(); @@ -166,6 +172,7 @@ export class PlaySession { /** A `Load scene` swapped the world (see `GameRun.loadScene`). */ private sceneLoaded(run: GameRun): void { + this.timeSystems(run); this.host.gameView.setWorld(run.world); this.state.setNotice(this.playNotice(), 'quiet'); if (run.world.stopRequested) this.stop(); @@ -280,6 +287,29 @@ export class PlaySession { if (this.host.stateMachineEditor.visible) this.host.stateMachineEditor.canvas.redraw(); } + /** The world tells the meter what each system took; a `Load scene` brings a world of its own. */ + private timeSystems(run: GameRun): void { + run.world.timeSystem = (system, ms) => { + const area = + system === run.behaviors ? 'behaviors' : system instanceof AnimationSystem ? 'animation' : 'physics'; + this.meter.addSystem(area, ms); + }; + } + + /** + * Closes the editor's frame for the meter (see `FrameMeter.endFrame`); true when a new average is + * ready. Nothing is measured while nothing plays. + */ + measureFrame(dt: number, busy: number, drawn: number, rendered: number): boolean { + return this.isPlaying && this.meter.endFrame(dt, busy, drawn, rendered); + } + + /** How many entities run and where the frames went lately; null while nothing plays. */ + performance(): { entities: number; times: FrameTimes | null } | null { + const world = this.world; + return world ? { entities: world.entities.length, times: this.meter.average } : null; + } + /** How often the panels caught up with this run, by kind (test hook). */ panelUpdates(): { structure: number; values: number } { return { ...this.updates }; diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 1d0144af..c4adf32b 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -126,6 +126,22 @@ export function sceneHooks({ state, editor }: HookContext) { headerButton: shownOnStage(gamePane.fullscreenButton) ? centreOf(gamePane.fullscreenButton) : null, corner: shownOnStage(gameView.fullscreenButton) ? centreOf(gameView.fullscreenButton) : null, }), + /** + * The performance overlay (#275): the header switch, whether the overlay is on screen, where, + * and the lines it shows. The numbers behind them are in `playInfo().performance`. + */ + gamePerformance: () => { + const overlay = gameView.performanceOverlay; + const origin = overlay.getGlobalPosition(); + const button = gamePane.performanceButton; + return { + button: { ...centreOf(button), lit: button.toggled }, + shown: shownOnStage(overlay), + box: { x: origin.x, y: origin.y, w: overlay.width, h: overlay.height }, + view: rectOf(gameView), + lines: overlay.lines, + }; + }, /** Which typeface a text ended up drawn in, and how wide it made it. */ textInfo: (id: string) => viewport.textInfo(id), gameTextInfo: (id: string) => gameView.textInfo(id), diff --git a/src/editor/frameMeter.ts b/src/editor/frameMeter.ts new file mode 100644 index 00000000..8b670cb7 --- /dev/null +++ b/src/editor/frameMeter.ts @@ -0,0 +1,86 @@ +/** + * Where the time of a frame goes while playing (#275): the game's systems one by one, drawing the + * game, and what the editor does around them. Averaged per frame over half a second, the same + * window the fps readout counts over. + */ +export const FRAME_AREAS = ['behaviors', 'physics', 'animation', 'drawing', 'editor'] as const; +export type FrameArea = (typeof FRAME_AREAS)[number]; +export type FrameTimes = Record; + +/** Seconds one average runs over. */ +export const FRAME_WINDOW = 0.5; + +const LABELS: Record = { + behaviors: 'Behaviors', + physics: 'Physics', + animation: 'Animation', + drawing: 'Drawing', + editor: 'Editor', +}; + +export interface PerformanceReading { + fps: number | null; + entities: number; + times: FrameTimes | null; +} + +/** What the overlay shows, one area a line; a dash stands for what has not been measured yet. */ +export function performanceLines({ fps, entities, times }: PerformanceReading): string[] { + const width = Math.max(...FRAME_AREAS.map((area) => LABELS[area].length)); + return [ + `${fps ?? '–'} fps · ${entities} ${entities === 1 ? 'entity' : 'entities'}`, + ...FRAME_AREAS.map((area) => { + const ms = times ? `${times[area].toFixed(2)} ms` : '– ms'; + return `${LABELS[area].padEnd(width)} ${ms.padStart(9)}`; + }), + ]; +} + +function noTimes(): FrameTimes { + return { behaviors: 0, physics: 0, animation: 0, drawing: 0, editor: 0 }; +} + +export class FrameMeter { + private sums = noTimes(); + /** What the systems took in the frame under way, so the editor's share can be told apart. */ + private systems = 0; + private frames = 0; + private elapsed = 0; + /** Milliseconds per frame by area over the last whole window; null until one has passed. */ + average: FrameTimes | null = null; + + /** One of the game's systems took `ms` in this frame. */ + addSystem(area: 'behaviors' | 'physics' | 'animation', ms: number): void { + this.sums[area] += ms; + this.systems += ms; + } + + /** + * Closes a frame `dt` seconds long. `busy` is what the editor's tick took in all, systems and + * `drawn` included; `drawn` is the game view drawing the run, `rendered` the renderer putting the + * frame on screen. Returns true when the frame ended a window and `average` is new. + */ + endFrame(dt: number, busy: number, drawn: number, rendered: number): boolean { + this.sums.drawing += drawn + rendered; + this.sums.editor += Math.max(0, busy - this.systems - drawn); + this.systems = 0; + this.frames++; + this.elapsed += dt; + if (this.elapsed < FRAME_WINDOW) return false; + const average = noTimes(); + for (const area of FRAME_AREAS) average[area] = this.sums[area] / this.frames; + this.average = average; + this.sums = noTimes(); + this.frames = 0; + this.elapsed = 0; + return true; + } + + reset(): void { + this.sums = noTimes(); + this.systems = 0; + this.frames = 0; + this.elapsed = 0; + this.average = null; + } +} diff --git a/src/editor/viewport/GamePane.ts b/src/editor/viewport/GamePane.ts index 56f6cd68..e19797d2 100644 --- a/src/editor/viewport/GamePane.ts +++ b/src/editor/viewport/GamePane.ts @@ -7,7 +7,10 @@ import type { GameViewport } from './GameViewport'; /** Room for the longest label, "Auto · 400%". */ const SCALE_WIDTH = 110; -/** The game tab (#225): a header with the scale picker and the fullscreen button above the game view. */ +/** + * The game tab (#225): a header with the scale picker, the performance switch and the fullscreen + * button above the game view. + */ export class GamePane extends VStack { readonly header = new TabHeader(); /** Public for the check scripts. */ @@ -18,13 +21,24 @@ export class GamePane extends VStack { onClick: () => void toggleFullscreen(), }); + /** Switches the performance overlay over the running game (#275). */ + readonly performanceButton: Button; + constructor(readonly view: GameViewport) { super(); this.scalePicker = new Dropdown(scaleLabel(view.scaleMode, null), { width: SCALE_WIDTH, onClick: () => this.openScalePicker(), }); + this.performanceButton = new Button('Performance', { + toggle: true, + onClick: () => { + view.setPerformanceShown(!view.showsPerformance); + this.performanceButton.setToggled(view.showsPerformance); + }, + }); this.header.add(this.scalePicker); + this.header.add(this.performanceButton); this.header.add(new Spacer(), 1); this.header.add(this.fullscreenButton); this.add(this.header); diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 2f592312..b552a589 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -6,13 +6,14 @@ import { Text } from 'pixi.js'; import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; -import { Theme, textStyle, ui } from '../../ui'; +import { requestRender, Theme, textStyle, ui } from '../../ui'; import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import { FullscreenCorner } from '../../viewport/FullscreenCorner'; import { collectHolderItems, collectWorldItems } from '../../viewport/items'; import { routePointer } from '../../viewport/gamePointer'; import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view'; import { type Frame, WorldViewport } from '../../viewport/WorldViewport'; +import { PerformanceOverlay } from './PerformanceOverlay'; export class GameViewport extends WorldViewport { private readonly hint = new Text({ text: 'No active camera', style: textStyle('muted') }); @@ -30,6 +31,11 @@ export class GameViewport extends WorldViewport { /** The way back out of fullscreen (#267); the tab header has the way in. */ readonly fullscreenButton = new FullscreenCorner(this); private fullscreen = false; + /** Shown over a running game while switched on in the tab header (#275). */ + readonly performanceOverlay = new PerformanceOverlay(); + private performanceShown = false; + /** Milliseconds spent in `redraw` so far; the meter takes the difference over a frame. */ + drawTime = 0; constructor(state: EditorState) { super(state); @@ -40,6 +46,9 @@ export class GameViewport extends WorldViewport { this.addChild(this.editorRunCaption); this.addChild(this.fullscreenButton); this.fullscreenButton.sync(false, false); + this.performanceOverlay.position.set(Theme.gap * 2, Theme.gap * 2); + this.performanceOverlay.visible = false; + this.addChild(this.performanceOverlay); routePointer( this, () => this.pointerWorld(), @@ -51,9 +60,26 @@ export class GameViewport extends WorldViewport { setWorld(world: World | null): void { this.playWorld = world; this.fullscreenButton.sync(this.fullscreen, world !== null); + this.syncPerformance(); this.redraw(); } + get showsPerformance(): boolean { + return this.performanceShown; + } + + setPerformanceShown(on: boolean): void { + this.performanceShown = on; + this.syncPerformance(); + } + + private syncPerformance(): void { + const visible = this.performanceShown && this.playWorld !== null; + if (visible === this.performanceOverlay.visible) return; + this.performanceOverlay.visible = visible; + requestRender(); + } + get isFullscreen(): boolean { return this.fullscreen; } @@ -81,7 +107,9 @@ export class GameViewport extends WorldViewport { */ override redraw(): void { if (!this.visible) return; + const start = performance.now(); super.redraw(); + this.drawTime += performance.now() - start; } get scaleMode(): GameScale { diff --git a/src/editor/viewport/PerformanceOverlay.ts b/src/editor/viewport/PerformanceOverlay.ts new file mode 100644 index 00000000..222dc4cc --- /dev/null +++ b/src/editor/viewport/PerformanceOverlay.ts @@ -0,0 +1,34 @@ +import { Container, Graphics, Text } from 'pixi.js'; +import { Theme, textStyle } from '../../ui'; +import { performanceLines, type PerformanceReading } from '../frameMeter'; + +/** Frame rate, entities and the time per frame by area over a running game (#275), top left. */ +export class PerformanceOverlay extends Container { + private readonly bg = new Graphics(); + private readonly text = new Text({ text: '', style: textStyle('mono') }); + + constructor() { + super(); + this.eventMode = 'none'; + this.text.position.set(Theme.padding, Theme.padding / 2); + this.addChild(this.bg, this.text); + } + + get lines(): string[] { + return this.text.text.split('\n'); + } + + show(reading: PerformanceReading): void { + this.text.text = performanceLines(reading).join('\n'); + this.bg + .clear() + .roundRect( + 0, + 0, + Math.ceil(this.text.width) + Theme.padding * 2, + Math.ceil(this.text.height) + Theme.padding, + 4, + ) + .fill({ color: Theme.colors.background, alpha: 0.75 }); + } +} diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 5af59283..2839f36b 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -96,6 +96,8 @@ export class World { /** Until when (run time) an object falls through one-way colliders and tiles – `Ignore one-way` (#155). */ private readonly ignoringOneWay = new Map(); private readonly systems: System[] = []; + /** Told what each system took of a tick, in ms, when set – the editor's performance overlay (#275). */ + timeSystem: ((system: System, ms: number) => void) | null = null; private elapsed = 0; /** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */ private structureChanged = false; @@ -370,8 +372,17 @@ export class World { // this frame sees the same world. this.frameFrozen = this.paused; if (!this.frameFrozen) this.elapsed += dt; - for (const system of this.systems) - if (!this.frameFrozen || system.runsWhilePaused) system.update(this, dt); + const timed = this.timeSystem; + for (const system of this.systems) { + if (this.frameFrozen && !system.runsWhilePaused) continue; + if (!timed) { + system.update(this, dt); + continue; + } + const start = performance.now(); + system.update(this, dt); + timed(system, performance.now() - start); + } // The pointer edges belong to exactly one tick, whatever the systems did with them. this.dropPointerEdges(); } diff --git a/src/ui/index.ts b/src/ui/index.ts index 830a93f1..5410971c 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -9,6 +9,7 @@ export { initRenderLoop, onFrame, isRendering, + lastRenderTime, renderCount, requestRender, resetRenderLoop, diff --git a/src/ui/render.ts b/src/ui/render.ts index 2f64fe43..58402b83 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -21,6 +21,7 @@ const WAKE_MS = 200; let deadline = 0; let rendered = 0; +let renderMs = 0; /** Draw, and keep drawing for a moment. Cheap enough to call from every event. */ export function requestRender(): void { @@ -37,6 +38,11 @@ export function renderCount(): number { return rendered; } +/** What `app.render()` took for the last frame drawn, in ms – the CPU side; the GPU works on after. */ +export function lastRenderTime(): number { + return renderMs; +} + /** Work put off to the next frame by `onFrame`, one entry per key. */ let frameWork = new Map void>(); @@ -109,7 +115,9 @@ export function initRenderLoop(app: Application): void { for (const fn of due.values()) fn(); } rendered += 1; + const start = performance.now(); app.render(); + renderMs = performance.now() - start; // Taken first: a waiter that queues itself again must land in the next round, not this one. const waiters = frameWaiters; frameWaiters = []; @@ -125,6 +133,7 @@ export function initRenderLoop(app: Application): void { export function resetRenderLoop(): void { deadline = 0; rendered = 0; + renderMs = 0; frameWaiters = []; frameWork = new Map(); } diff --git a/tests/editor/frame-meter.test.ts b/tests/editor/frame-meter.test.ts new file mode 100644 index 00000000..8498d0f5 --- /dev/null +++ b/tests/editor/frame-meter.test.ts @@ -0,0 +1,135 @@ +/** + * The performance overlay (#275): the meter averages each area per frame over half a second, the + * editor's share is what its tick took beyond the systems and the drawing, and the world reports + * each system it ticked – and only those. + */ +import { describe, expect, it } from 'vitest'; +import { FrameMeter, performanceLines } from '../../src/editor/frameMeter'; +import type { System } from '../../src/runtime/systems/System'; +import { World } from '../../src/runtime/World'; +import { makeSpriteScene } from '../helpers'; + +/** One frame of 0.1 s: behaviors 2 ms, physics 1 ms, the tick 10 ms in all, 3 ms of it drawing. */ +function frame(meter: FrameMeter): boolean { + meter.addSystem('behaviors', 2); + meter.addSystem('physics', 1); + return meter.endFrame(0.1, 10, 3, 0.5); +} + +describe('FrameMeter', () => { + it('has nothing to show before half a second has passed', () => { + const meter = new FrameMeter(); + for (let i = 0; i < 4; i++) expect(frame(meter)).toBe(false); + expect(meter.average).toBeNull(); + }); + + it('averages every area per frame over the window', () => { + const meter = new FrameMeter(); + for (let i = 0; i < 4; i++) frame(meter); + meter.addSystem('behaviors', 7); + meter.addSystem('animation', 0.5); + expect(meter.endFrame(0.1, 12, 3, 0.5)).toBe(true); + expect(meter.average).toEqual({ + behaviors: (4 * 2 + 7) / 5, + physics: (4 * 1) / 5, + animation: 0.5 / 5, + drawing: 3.5, + // 10 - 3 - 3 in the first four frames, 12 - 7.5 - 3 in the last. + editor: (4 * 4 + 1.5) / 5, + }); + }); + + it('starts the next window from zero', () => { + const meter = new FrameMeter(); + for (let i = 0; i < 5; i++) frame(meter); + for (let i = 0; i < 4; i++) meter.endFrame(0.1, 1, 0, 0); + expect(meter.endFrame(0.1, 1, 0, 0)).toBe(true); + expect(meter.average).toEqual({ behaviors: 0, physics: 0, animation: 0, drawing: 0, editor: 1 }); + }); + + it('never gives the editor less than nothing', () => { + const meter = new FrameMeter(); + meter.addSystem('physics', 5); + meter.endFrame(0.5, 4, 1, 0); + expect(meter.average?.editor).toBe(0); + }); + + it('forgets everything on reset', () => { + const meter = new FrameMeter(); + for (let i = 0; i < 5; i++) frame(meter); + meter.addSystem('behaviors', 100); + meter.reset(); + expect(meter.average).toBeNull(); + for (let i = 0; i < 4; i++) meter.endFrame(0.1, 1, 0, 0); + meter.endFrame(0.1, 1, 0, 0); + expect(meter.average?.behaviors).toBe(0); + }); +}); + +describe('performanceLines', () => { + it('shows dashes until there is something measured', () => { + expect(performanceLines({ fps: null, entities: 1, times: null })).toEqual([ + '– fps · 1 entity', + 'Behaviors – ms', + 'Physics – ms', + 'Animation – ms', + 'Drawing – ms', + 'Editor – ms', + ]); + }); + + it('lines the numbers up in milliseconds with two decimals', () => { + const times = { behaviors: 1.234, physics: 12.5, animation: 0, drawing: 3, editor: 0.006 }; + expect(performanceLines({ fps: 60, entities: 128, times })).toEqual([ + '60 fps · 128 entities', + 'Behaviors 1.23 ms', + 'Physics 12.50 ms', + 'Animation 0.00 ms', + 'Drawing 3.00 ms', + 'Editor 0.01 ms', + ]); + }); +}); + +describe('World.timeSystem', () => { + function counting(runsWhilePaused = false): System & { runs: number } { + return { + runs: 0, + runsWhilePaused, + update() { + this.runs++; + }, + }; + } + + it('reports every system it ticks, once a tick', () => { + const f = makeSpriteScene(); + const world = World.instantiate(f.project, f.sceneId); + const a = counting(); + const b = counting(); + world.addSystem(a).addSystem(b); + const told: System[] = []; + world.timeSystem = (system, ms) => { + expect(ms).toBeGreaterThanOrEqual(0); + told.push(system); + }; + world.tick(1 / 60); + world.tick(1 / 60); + expect(told).toEqual([a, b, a, b]); + expect(a.runs).toBe(2); + }); + + it('does not report a system a paused frame skips', () => { + const f = makeSpriteScene(); + const world = World.instantiate(f.project, f.sceneId); + const still = counting(); + const awake = counting(true); + world.addSystem(still).addSystem(awake); + const told: System[] = []; + world.timeSystem = (system) => told.push(system); + world.paused = true; + world.tick(1 / 60); + expect(told).toEqual([awake]); + expect(still.runs).toBe(0); + }); +}); diff --git a/tools/e2e/59-while-playing.mjs b/tools/e2e/59-while-playing.mjs index 2d217e60..d66f110d 100644 --- a/tools/e2e/59-while-playing.mjs +++ b/tools/e2e/59-while-playing.mjs @@ -140,4 +140,57 @@ export async function whilePlaying(t) { (await json('__gm.state.project.whilePlaying')) === 'balanced' && (await undoSteps()) === steps, JSON.stringify({ steps: await undoSteps(), before: steps }), ); + + // 59e. The performance overlay (#275): switched on in the game tab's header, it shows over a + // running game only, top left – fps, the entities the world has, and a time per area. + const performance = () => json('__gm.gamePerformance()'); + await showTab('game'); + await click((await performance()).button.x, (await performance()).button.y); + const waiting = await performance(); + check( + 'switched on in the header, the overlay waits for a game to run', + waiting.button.lit && !waiting.shown, + JSON.stringify(waiting), + ); + await play(); + await sleep(1200); + const running = await json( + '({ overlay: __gm.gamePerformance(), measured: __gm.playInfo().performance, entities: __gm.world().entities.length })', + ); + const { overlay, measured } = running; + const times = Object.values(measured?.times ?? {}); + check( + 'while playing it shows in the top left corner of the game view', + overlay.shown && + overlay.box.x - overlay.view.x >= 0 && + overlay.box.x - overlay.view.x < 20 && + overlay.box.y - overlay.view.y >= 0 && + overlay.box.y - overlay.view.y < 20 && + overlay.box.w > 100, + JSON.stringify(overlay), + ); + check( + 'it counts the entities the running world has', + measured?.entities === running.entities && overlay.lines[0]?.includes(`· ${running.entities} entities`), + JSON.stringify({ measured, entities: running.entities, line: overlay.lines[0] }), + ); + check( + 'and shows the fps and a time for each of the five areas, drawing included', + /^\d+ fps/.test(overlay.lines[0] ?? '') && + overlay.lines.length === 6 && + overlay.lines.slice(1).every((line) => / \d+\.\d\d ms$/.test(line)) && + times.length === 5 && + times.every((ms) => Number.isFinite(ms) && ms >= 0) && + measured.times.drawing > 0, + JSON.stringify(overlay.lines), + ); + await shot('59c-performance'); + await click(overlay.button.x, overlay.button.y); + const switchedOff = await performance(); + await stop(); + check( + 'a second click switches it off again', + !switchedOff.button.lit && !switchedOff.shown, + JSON.stringify(switchedOff), + ); }