diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 7796ab0..1fd4549 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -183,6 +183,8 @@ - `src/core/loop.ts` — `GameLoop`: фиксированный шаг (60 Гц) + рендер, аккумулятор с maxStepsPerFrame; `setManual` + `stepTick(n)` — ручной режим для моста/гейтов (детерминизм); now/schedule инъектируются; + `maxFps` в конструкторе и `setMaxFps(fps)` на живом цикле (0 — без + лимита; лок 60 fps дев-панели); - `src/core/ecs.ts` — `EntityWorld`: минимальный ECS (перенос v1): сущность-число, компоненты по имени, query, системы по порядку; - `src/core/controls.ts` — ввод как данные: `ControlState` (движение в @@ -1016,6 +1018,12 @@ закрытия. Мир стоит, пока открыта панель/вьювер (как в диалоге). Мост: `devInfo` (каталог + метка часов), `devOpen`/`devOpenEntry(id)`/`devClose`, `devSetClock(m)`, `devStats`. + FPS (2026-09-12): счётчик кадров `#fps` в правом верхнем углу (z-index + выше дев-панели, сводка раз в 0.5 с — кадры рендера цикла, включая + stepTick); в панели секция «ЦИКЛ» — чекбокс «лок 60 fps» → + `loop.setMaxFps(60|0)`; мост: `fpsInfo()` (fps + lock), `devSetFpsLock(on)` + (применить + чекбокс). Грабля: headless-браузер троттлит rAF (2-6 fps) — + в гейте мерить «fps > 0», не «~60». Олень — VoxelModel из `deerVoxels` покоя (`gridFromVoxels`: список [x,y,z,цвет] → сетка с палитрой по уникальным цветам). Грабля: список объектов выталкивал секцию времени за край панели — панель flex-колонка, diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html index 166211e..59a045e 100644 --- a/v2/apps/game/index.html +++ b/v2/apps/game/index.html @@ -51,6 +51,19 @@ opacity: 0; pointer-events: none; } + /* Счётчик FPS: правый верхний угол, поверх всего (и дев-панели) */ + #fps { + position: fixed; + top: 12px; + right: 12px; + padding: 4px 10px; + background: rgba(23, 23, 29, 0.85); + color: #c9c9d2; + font: 14px monospace; + border: 1px solid #44454e; + border-radius: 4px; + z-index: 15; + } /* Трекер квестов: первая активная стадия, левый верхний угол */ #quest { position: fixed; @@ -322,6 +335,7 @@
+
— fps
+

ЦИКЛ

+
+ +

ОБЪЕКТЫ · F3

diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 5ca2fbb..c8d28e5 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1229,12 +1229,33 @@ render: () => { remeshDirty(); renderer.render(); + countFrame(); // счётчик FPS (правый верхний угол) if (!loadingEl.hidden) loadingEl.hidden = true; // лоад-скрин: до первого кадра }, }); setArea(currentArea.id, currentArea.spawn); // старт — спавн лугов loop.start(); +// ---- FPS: кадры с последней сводки → раз в полсекунды в #fps ---- +const fpsEl = document.getElementById('fps') as HTMLDivElement; +let fpsFrames = 0; +let fpsLast = performance.now(); +function countFrame(): void { + fpsFrames++; + const now = performance.now(); + if (now - fpsLast >= 500) { + fpsEl.textContent = `${Math.round((fpsFrames * 1000) / (now - fpsLast))} fps`; + fpsFrames = 0; + fpsLast = now; + } +} +// Лок 60 fps (дев-режим): 0 — без лимита, 60 — интервал 16.7 мс +let fpsLock = false; +document.getElementById('dev-fps-lock')!.addEventListener('change', (e) => { + fpsLock = (e.target as HTMLInputElement).checked; + loop.setMaxFps(fpsLock ? 60 : 0); +}); + // ---- Агентный мост (DEV): гейты областей ---- const agent = { /** Ручной/автоматический режим цикла. */ @@ -1552,6 +1573,17 @@ dev.refreshWeather(); updateHud(); }, + /** FPS: значение счётчика (число из #fps) и состояние лока (гейт). */ + fpsInfo() { + const m = fpsEl.textContent?.match(/(\d+)/); + return { fps: m ? Number(m[1]) : null, lock: fpsLock }; + }, + /** Лок 60 fps из дев-панели агентом: применить + чекбокс сразу. */ + devSetFpsLock(on: boolean): void { + fpsLock = on; + loop.setMaxFps(on ? 60 : 0); + (document.getElementById('dev-fps-lock') as HTMLInputElement).checked = on; + }, /** Дев-панель агентом: открыть список / вьювер / закрыть. */ devOpen(): void { if (!dev.open) dev.openPanel(); diff --git a/v2/apps/game/tools/checks/panels.mjs b/v2/apps/game/tools/checks/panels.mjs index 0ee59ae..1d3fd02 100644 --- a/v2/apps/game/tools/checks/panels.mjs +++ b/v2/apps/game/tools/checks/panels.mjs @@ -140,6 +140,25 @@ await client.call('devSetClock', 480); // вернуть утро (дальнейшие проверки не темнеют) await client.call('devClose'); + // ---- FPS: счётчик обновился, лок 60 fps из дев-панели ---- + await client.call('setManual', false); // живой цикл — FPS мерить честно + await new Promise((r) => setTimeout(r, 800)); // сводка обновляется раз в 0.5 с + const fi = await client.call('fpsInfo'); + check('счётчик FPS в правом верхнем углу показывает кадры', + fi.fps !== null && fi.fps > 0 && fi.lock === false, fi); + const fpsElVisible = await client.evaluate(() => { + const r = document.getElementById('fps').getBoundingClientRect(); + return r.top >= 0 && r.right <= window.innerWidth + 1; + }); + check('счётчик FPS виден в правом верхнем углу', fpsElVisible === true); + await client.call('devSetFpsLock', true); + const fi2 = await client.call('fpsInfo'); + check('лок 60 fps из дев-панели включён (чекбокс синхронен)', fi2.lock === true, fi2); + const checkboxOn = await client.evaluate(() => + document.getElementById('dev-fps-lock').checked) === true; + check('чекбокс лока синхронизирован агентом', checkboxOn === true); + await client.call('devSetFpsLock', false); // вернуть без лока (дальнейшие гейты) + const errs = client.errors(); check('нет pageerror за прогон', errs.length === 0, errs.slice(0, 3)); } finally { diff --git a/v2/packages/engine/src/core/__tests__/loop.test.ts b/v2/packages/engine/src/core/__tests__/loop.test.ts index f410d95..936b8b0 100644 --- a/v2/packages/engine/src/core/__tests__/loop.test.ts +++ b/v2/packages/engine/src/core/__tests__/loop.test.ts @@ -78,4 +78,21 @@ expect(t.renders).toBe(0); t.loop.stop(); }); + + it('setMaxFps на живом цикле: лок 60fps пропускает частые кадры, 0 снимает лок', () => { + const t = makeLoop(60); + t.loop.start(); + t.loop.setMaxFps(30); // интервал 50 мс: кадр каждые ~3 шага + t.advance(16.7); + expect(t.renders).toBe(1); // первый кадр после сброса lastFrame проходит + t.advance(16.7); + t.advance(16.7); + expect(t.renders).toBe(1); // до 50 мс с прошлого кадра — пропуск + t.advance(16.7); + expect(t.renders).toBe(2); + t.loop.setMaxFps(0); // лок снят — каждый кадр + t.advance(16.7); + expect(t.renders).toBe(3); + t.loop.stop(); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/core/loop.ts b/v2/packages/engine/src/core/loop.ts index 9570219..45aa50b 100644 --- a/v2/packages/engine/src/core/loop.ts +++ b/v2/packages/engine/src/core/loop.ts @@ -28,7 +28,7 @@ private readonly callbacks: LoopCallbacks; private readonly now: () => number; private readonly schedule: (cb: (t: number) => void) => void; - private readonly minFrameMs: number; + private minFrameMs: number; private rafId = 0; private lastTime = 0; private lastFrame = 0; @@ -70,6 +70,14 @@ this.lastFrame = this.lastTime; } + /** Лимит отрисовки, кадров/с (0 — без лимита). Меняется на живом цикле + * (дев-переключатель лок 60 fps); lastFrame сбрасывается, чтобы без + * лока не ждать накопления интервала от прошлого кадра. */ + setMaxFps(fps: number): void { + this.minFrameMs = fps > 0 ? 1000 / fps : 0; + this.lastFrame = this.now(); + } + /** Шаги логики вручную (мост/гейт); render — отрисовать после пачки. */ stepTick(n = 1, render = false): number { // ручной режим обязателен: поверх живого цикла шаги удвоятся