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), + ); }