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
@@ -353,6 +367,13 @@
+ ЦИКЛ
+
+
+
ОБЪЕКТЫ · 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 {
// ручной режим обязателен: поверх живого цикла шаги удвоятся