diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index fc44d7d..46b3aac 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -11,6 +11,7 @@ scale: computeScale(Game.VIRTUAL_W, Game.VIRTUAL_H, window.innerWidth, window.innerHeight), background: 0x0b0b12, fixedFps: 60, + maxFps: 30, // лок отрисовки: шаги логики копятся и выполняются пачкой parent: container }; diff --git a/docs/engine/core.md b/docs/engine/core.md index 551f734..d6be692 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -5,15 +5,16 @@ Собирает все подсистемы и владеет игровым циклом: ```ts -const engine = new Engine({ virtualWidth: 480, virtualHeight: 270, scale: 3, background: 0x0b0b12, fixedFps: 60, parent }); -await engine.init(); // WebGL, ввод, оверлей fade-переходов +const engine = new Engine({ virtualWidth: 480, virtualHeight: 270, scale: 3, background: 0x0b0b12, fixedFps: 60, maxFps: 30, parent }); +await engine.init(); // WebGL, ввод, оверлей fade-переходов, FPS-счётчик await engine.start(); // запуск цикла ``` Доступные подсистемы: `engine.renderer`, `engine.camera`, `engine.scenes`, `engine.input`, `engine.tweens`, `engine.events`. -Опции: `autoResize` (по умолчанию true) — пересчитывать целочисленный масштаб +Опции: `maxFps` (по умолчанию 0 — без лимита) — лок частоты отрисовки; +`autoResize` (по умолчанию true) — пересчитывать целочисленный масштаб при ресайзе окна. ## GameLoop (fixed timestep) @@ -22,6 +23,17 @@ всегда `1/60`. После лага («вкладка спала») выполняется максимум 5 шагов за кадр — игра замедляется, а не «телепортируется». +**Лок кадров** (`maxFps`, например 30): срезанные rAF-кадры не делают ни шагов, +ни рендера, но время не теряется — копится в аккумулятор, и шаги выполняются +пачкой на отрисованном кадре. Рендер — не чаще лимита, логика — те же 60 Гц. + +## FPS-счётчик (F3) + +Дебаг-плашка в левом верхнем углу (`engine.fpsMeter.view`): среднее FPS за окно +~полсекунды. Скрыт по умолчанию, переключается по **F3** — движок слушает клавишу +сам (независимо от привязок ввода приложения; F3 в игре может быть занята своим +дебаг-оверлеем — они переключаются вместе, как слои отладки). + Вручную цикл обычно не трогают; Engine.tick делает по порядку: `input.update()` (опрос геймпада) → `tweens.update(dt)` → `scenes.update(dt)` → `input.endTick()`. diff --git a/docs/engine/getting-started.md b/docs/engine/getting-started.md index 97f5092..87cba9b 100644 --- a/docs/engine/getting-started.md +++ b/docs/engine/getting-started.md @@ -42,6 +42,7 @@ scale: computeScale(VW, VH, window.innerWidth, window.innerHeight), background: 0x0b0b12, fixedFps: 60, + maxFps: 30, // лок отрисовки (0 — без лимита); FPS-счётчик — F3 parent: container }; diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index a8f6b62..eea0498 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -3,6 +3,7 @@ import { Camera } from '../render/Camera'; import { computeScale } from '../render/scale'; import { GameLoop } from './GameLoop'; +import { FpsMeter } from './FpsMeter'; import { EventBus } from './EventBus'; import { TweenManager } from './Tween'; import { Updatables } from './Updatables'; @@ -16,6 +17,8 @@ export interface EngineOptions extends RendererOptions { /** FPS фиксированного шага. */ fixedFps: number; + /** Лимит отрисовки, кадров/сек (0 — без лимита; шаги логики копятся). */ + maxFps?: number; /** Пересчитывать масштаб при ресайзе окна (по умолчанию true). */ autoResize?: boolean; } @@ -30,6 +33,8 @@ /** Эффекты/аниматоры/флэши, тикаемые движком сами (фиксированный шаг). */ readonly fx = new Updatables(); input!: InputManager; + /** Дебаг-счётчик FPS (F3); создаётся в init(). */ + fpsMeter!: FpsMeter; private loop: GameLoop; private started = false; private ticks = 0; @@ -49,10 +54,14 @@ this.renderer = new Renderer(options); this.camera = new Camera(options.virtualWidth, options.virtualHeight); this.autoResize = options.autoResize ?? true; - this.loop = new GameLoop(options.fixedFps, { - update: (dt) => this.tick(dt), - render: () => this.render() - }); + this.loop = new GameLoop( + options.fixedFps, + { + update: (dt) => this.tick(dt), + render: () => this.render() + }, + { maxFps: options.maxFps } + ); } /** @@ -72,6 +81,10 @@ this.renderer.uiRoot.addChild(fade); this.scenes.setOverlay(fade, this.renderer.virtualWidth, this.renderer.virtualHeight); + // Дебаг-счётчик FPS (F3) — поверх мира, под фейдом переходов. + this.fpsMeter = new FpsMeter(); + this.renderer.uiRoot.addChild(this.fpsMeter.view); + if (this.autoResize) { window.addEventListener('resize', this.onWindowResize); } @@ -87,6 +100,7 @@ stop(): void { this.loop.stop(); this.input?.dispose(); + this.fpsMeter?.dispose(); window.removeEventListener('resize', this.onWindowResize); this.fx.clear(); this.events.clear(); @@ -154,6 +168,7 @@ } private render(): void { + this.fpsMeter?.frame(); this.camera.apply(this.renderer.worldRoot); this.scenes.render(); this.renderer.render(); diff --git a/packages/engine/src/core/FpsMeter.ts b/packages/engine/src/core/FpsMeter.ts new file mode 100644 index 0000000..4d4d71b --- /dev/null +++ b/packages/engine/src/core/FpsMeter.ts @@ -0,0 +1,62 @@ +import { Container, Graphics } from 'pixi.js'; +import { PixelText } from '../ui/PixelText'; + +/** + * Дебаг-счётчик FPS движка: плашка в левом верхнем углу, среднее за окно + * ~полсекунды (текст перерисовывается не чаще — растеризация дорогая). + * Скрыт по умолчанию; переключение — F3: движок слушает клавишу сам, + * независимо от привязок ввода приложения (preventDefault — F3 в Firefox + * открывает поиск). + */ +export class FpsMeter { + readonly view = new Container(); + private bg = new Graphics(); + private label = new PixelText({ text: 'FPS --', size: 10, color: 0xd7e4ec }); + private frames = 0; + private windowStart = 0; + private shown = false; + private onKey = (e: KeyboardEvent): void => { + if (e.code !== 'F3') return; + e.preventDefault(); + this.toggle(); + }; + + constructor() { + this.view.addChild(this.bg, this.label); + this.label.position.set(4, 2); + this.view.position.set(2, 2); + this.view.visible = false; + window.addEventListener('keydown', this.onKey); + } + + /** Учёт кадра отрисовки (Engine зовёт из render). */ + frame(): void { + if (!this.shown) return; + this.frames++; + const now = performance.now(); + const elapsed = now - this.windowStart; + if (elapsed < 500) return; + this.label.text = `FPS ${Math.round((this.frames * 1000) / elapsed)}`; + this.bg + .clear() + .rect(0, 0, this.label.width + 8, this.label.height + 4) + .fill({ color: 0x101018, alpha: 0.8 }) + .stroke({ color: 0x8899aa, width: 1 }); + this.frames = 0; + this.windowStart = now; + } + + /** Показать/скрыть; после показа окно начинается с нуля. */ + toggle(): void { + this.shown = !this.shown; + this.view.visible = this.shown; + if (this.shown) { + this.frames = 0; + this.windowStart = performance.now(); + } + } + + dispose(): void { + window.removeEventListener('keydown', this.onKey); + } +} \ No newline at end of file diff --git a/packages/engine/src/core/GameLoop.ts b/packages/engine/src/core/GameLoop.ts index 7a4d11d..e5ba056 100644 --- a/packages/engine/src/core/GameLoop.ts +++ b/packages/engine/src/core/GameLoop.ts @@ -1,7 +1,8 @@ /** * Игровой цикл: фиксированный шаг обновления (60 Гц по умолчанию) + отрисовка * после каждого шага (рендер с последним состоянием, без интерполяции — - * на 2D-пиксель-арте это стандартный компромисс). + * на 2D-пиксель-арте это стандартный компромисс). Лок кадров (maxFps) режет + * частоту отрисовки: шаги логики копятся и выполняются пачкой. */ export interface LoopCallbacks { /** dt в секундах, всегда фиксированный. */ @@ -9,6 +10,11 @@ render: () => void; } +export interface LoopOptions { + /** Лимит отрисовки, кадров/сек (0 — без лимита, по умолчанию). */ + maxFps?: number; +} + export class GameLoop { /** Длительность фиксированного шага, сек. */ readonly step: number; @@ -22,16 +28,22 @@ private running = false; /** Ручной режим: rAF не планирует шаги (их крутит мост/тесты через stepTick). */ private manual = false; + /** Минимальный интервал между отрисованными кадрами, мс (0 — без лока). */ + private minFrameMs: number; + /** Время последнего отрисованного кадра, мс. */ + private lastFrame = 0; - constructor(fps: number, callbacks: LoopCallbacks) { + constructor(fps: number, callbacks: LoopCallbacks, { maxFps = 0 }: LoopOptions = {}) { this.step = 1 / fps; this.callbacks = callbacks; + this.minFrameMs = maxFps > 0 ? 1000 / maxFps : 0; } start(): void { if (this.running) return; this.running = true; this.lastTime = performance.now(); + this.lastFrame = this.lastTime; this.rafId = requestAnimationFrame(this.tick); } @@ -53,6 +65,7 @@ resetTiming(): void { this.accumulator = 0; this.lastTime = performance.now(); + this.lastFrame = this.lastTime; } private tick = (now: number): void => { @@ -67,6 +80,11 @@ } this.rafId = requestAnimationFrame(this.tick); + // Лок кадров: кадр режется, но время не теряется — накопится в + // аккумулятор к следующему пропущенному кадру (шаги догонят пачкой). + if (now - this.lastFrame < this.minFrameMs - 1) return; + this.lastFrame = now; + this.accumulator += Math.min((now - this.lastTime) / 1000, 0.25); this.lastTime = now; diff --git a/packages/engine/src/core/__tests__/FpsMeter.test.ts b/packages/engine/src/core/__tests__/FpsMeter.test.ts new file mode 100644 index 0000000..a59338a --- /dev/null +++ b/packages/engine/src/core/__tests__/FpsMeter.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; + +// Pixi-зависимости (Text из PixelText, Graphics, Container) — минимальные стабы. +vi.mock('pixi.js', () => { + class Container { + children: unknown[] = []; + position = { set: () => undefined }; + visible = true; + addChild(...c: unknown[]) { + this.children.push(...c); + return this; + } + } + class Text { + text = ''; + width = 30; + height = 10; + position = { set: () => undefined }; + constructor(opts?: { text?: string }) { + this.text = opts?.text ?? ''; + } + } + class Graphics { + clear() { + return this; + } + rect() { + return this; + } + fill() { + return this; + } + stroke() { + return this; + } + } + return { Container, Text, Graphics }; +}); + +import { FpsMeter } from '../FpsMeter'; + +/** Заглушка performance.now + keydown: клик по F3 и продвижение времени вручную. */ +function installEnv() { + let now = 0; + const listeners: ((e: { code: string; preventDefault: () => void }) => void)[] = []; + vi.stubGlobal('performance', { now: () => now }); + vi.stubGlobal('window', { + addEventListener: (_: string, cb: (e: { code: string; preventDefault: () => void }) => void) => listeners.push(cb), + removeEventListener: (_: string, cb: unknown) => { + const i = listeners.indexOf(cb as never); + if (i >= 0) listeners.splice(i, 1); + } + }); + return { + advance: (ms: number) => { + now += ms; + }, + pressF3: () => { + for (const cb of [...listeners]) cb({ code: 'F3', preventDefault: () => undefined }); + }, + listenerCount: () => listeners.length + }; +} + +/** Текст счётчика (второй ребёнок view — label). */ +function labelOf(meter: FpsMeter): string { + return (meter.view.children[1] as unknown as { text: string }).text; +} + +describe('FpsMeter', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('скрыт по умолчанию; F3 показывает, повторное — прячет', () => { + const env = installEnv(); + const meter = new FpsMeter(); + expect(meter.view.visible).toBe(false); + + env.pressF3(); + expect(meter.view.visible).toBe(true); + env.pressF3(); + expect(meter.view.visible).toBe(false); + + meter.dispose(); + expect(env.listenerCount()).toBe(0); + }); + + it('считает кадры и обновляет текст раз в ~полсекунды', () => { + const env = installEnv(); + const meter = new FpsMeter(); + meter.toggle(); // окно открыто на now = 0 + + env.advance(100); + meter.frame(); // кадр 1 + env.advance(16); + meter.frame(); // кадр 2 + expect(labelOf(meter)).toBe('FPS --'); // окно не дозрело + + env.advance(420); // суммарно ~536 мс с показа + meter.frame(); + expect(labelOf(meter)).toBe('FPS 6'); // 3 кадра за 536 мс + + meter.dispose(); + }); + + it('скрытые кадры не считаются: после нового показа окно чистое', () => { + const env = installEnv(); + const meter = new FpsMeter(); + meter.toggle(); + env.advance(600); + meter.frame(); // 1 кадр за 600 мс + expect(labelOf(meter)).toBe('FPS 2'); + + meter.toggle(); // скрыть + env.advance(600); + meter.frame(); // скрыт — не считается + meter.toggle(); // показать заново: счётчики сброшены + env.advance(600); + meter.frame(); + expect(labelOf(meter)).toBe('FPS 2'); // без утечки прежних кадров + + meter.dispose(); + }); +}); \ No newline at end of file diff --git a/packages/engine/src/core/__tests__/GameLoop.manual.test.ts b/packages/engine/src/core/__tests__/GameLoop.manual.test.ts index 563e036..9d92b3d 100644 --- a/packages/engine/src/core/__tests__/GameLoop.manual.test.ts +++ b/packages/engine/src/core/__tests__/GameLoop.manual.test.ts @@ -41,4 +41,52 @@ loop.stop(); vi.unstubAllGlobals(); }); +}); + +describe('GameLoop — лок кадров (maxFps)', () => { + it('рендер не чаще лимита, шаги копятся и идут пачкой', () => { + const frame = installRaf(); + const updates: number[] = []; + let renders = 0; + const loop = new GameLoop( + 60, + { + update: (dt) => updates.push(dt), + render: () => renders++ + }, + { maxFps: 30 } + ); + loop.start(); + + frame(17); // 17 < 32-1: кадр срезан, ни рендера, ни шагов + expect(renders).toBe(0); + expect(updates.length).toBe(0); + + frame(17); // 34 мс с прошлого рендера: один кадр = 2 шага 60 Гц + expect(renders).toBe(1); + expect(updates.length).toBe(2); + expect(updates[0]).toBeCloseTo(1 / 60); + + frame(17); // снова срезан + frame(100); // крупный скачок: догоняет пачкой до maxStepsPerFrame + expect(renders).toBe(2); + expect(updates.length).toBe(7); // 2 + 5 (лимит за кадр) + loop.stop(); + vi.unstubAllGlobals(); + }); + + it('maxFps = 0 (по умолчанию) — без лока: рендер на каждом rAF', () => { + const frame = installRaf(); + let renders = 0; + const loop = new GameLoop(60, { + update: () => undefined, + render: () => renders++ + }); + loop.start(); + frame(16); + frame(16); + expect(renders).toBe(2); + loop.stop(); + vi.unstubAllGlobals(); + }); }); \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index a6d16ad..413eb6d 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -8,6 +8,7 @@ // core export { Engine, type EngineOptions } from './core/Engine'; export { GameLoop } from './core/GameLoop'; +export { FpsMeter } from './core/FpsMeter'; export { EventBus } from './core/EventBus'; export { TweenManager, type TweenOptions, type TweenHandle } from './core/Tween'; export { Updatables, type Updatable } from './core/Updatables';