diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md
index 1fd4549..1c8c8f4 100644
--- a/v2/CLAUDE.md
+++ b/v2/CLAUDE.md
@@ -184,7 +184,7 @@
аккумулятор с maxStepsPerFrame; `setManual` + `stepTick(n)` — ручной
режим для моста/гейтов (детерминизм); now/schedule инъектируются;
`maxFps` в конструкторе и `setMaxFps(fps)` на живом цикле (0 — без
- лимита; лок 60 fps дев-панели);
+ лимита; лок fps дев-панели: 30/60);
- `src/core/ecs.ts` — `EntityWorld`: минимальный ECS (перенос v1):
сущность-число, компоненты по имени, query, системы по порядку;
- `src/core/controls.ts` — ввод как данные: `ControlState` (движение в
@@ -1020,10 +1020,16 @@
`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».
+ stepTick); в панели секция «ЦИКЛ» — селект «лок fps» (0 = без лока / 30 /
+ 60) → `loop.setMaxFps(n)`; мост: `fpsInfo()` (fps + lock: число, 0 — без
+ лока), `devSetFpsLock(n)` (применить + селект). Грабля: headless-браузер
+ троттлит rAF (2-6 fps) — в гейте мерить «fps > 0», не «~60». Урок
+ фидбека «unlocked 60 = locked 60»: **потолок, равный герцовке rAF,
+ неотличим от «без лока» по построению** (rAF 60 Гц → каждый кадр проходит
+ порог 16.7−1 мс; замер живой страницы: лок 30 даёт ровно 30 fps, 60/0 —
+ 60) — осмысленный демо-лимит обязан быть НИЖЕ герцовки, отсюда вариант
+ «30 fps». Лимитер — CAP с допуском −1 мс: кадры с интервалом ≥
+ minFrameMs−1 проходят (не «ровно N», а «не больше N»).
Олень — VoxelModel из `deerVoxels` покоя (`gridFromVoxels`: список
[x,y,z,цвет] → сетка с палитрой по уникальным цветам). Грабля: список
объектов выталкивал секцию времени за край панели — панель flex-колонка,
diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html
index 59a045e..4064d48 100644
--- a/v2/apps/game/index.html
+++ b/v2/apps/game/index.html
@@ -370,8 +370,12 @@
ЦИКЛ
ОБЪЕКТЫ · F3
diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts
index c8d28e5..463ccb9 100644
--- a/v2/apps/game/src/main.ts
+++ b/v2/apps/game/src/main.ts
@@ -1249,11 +1249,13 @@
fpsLast = now;
}
}
-// Лок 60 fps (дев-режим): 0 — без лимита, 60 — интервал 16.7 мс
-let fpsLock = false;
+// Лок fps (дев-режим): 0 — без лимита, иначе потолок кадров/с. При герцовке
+// rAF 60 Гц потолок 60 неотличим от «без лока» (каждый кадр проходит) —
+// осмысленный выбор ниже герцовки даёт 30.
+let fpsLock = 0;
document.getElementById('dev-fps-lock')!.addEventListener('change', (e) => {
- fpsLock = (e.target as HTMLInputElement).checked;
- loop.setMaxFps(fpsLock ? 60 : 0);
+ fpsLock = Number((e.target as HTMLSelectElement).value) || 0;
+ loop.setMaxFps(fpsLock);
});
// ---- Агентный мост (DEV): гейты областей ----
@@ -1578,11 +1580,12 @@
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;
+ /** Лок fps из дев-панели агентом: лимит (0 — без лока) + селект сразу. */
+ devSetFpsLock(limit: number): void {
+ fpsLock = limit > 0 ? limit : 0;
+ loop.setMaxFps(fpsLock);
+ (document.getElementById('dev-fps-lock') as HTMLSelectElement).value =
+ String(fpsLock);
},
/** Дев-панель агентом: открыть список / вьювер / закрыть. */
devOpen(): void {
diff --git a/v2/apps/game/tools/checks/panels.mjs b/v2/apps/game/tools/checks/panels.mjs
index 1d3fd02..79e7723 100644
--- a/v2/apps/game/tools/checks/panels.mjs
+++ b/v2/apps/game/tools/checks/panels.mjs
@@ -145,19 +145,20 @@
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);
+ fi.fps !== null && fi.fps > 0 && fi.lock === 0, 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);
+ await client.call('devSetFpsLock', 30);
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); // вернуть без лока (дальнейшие гейты)
+ check('лок 30 fps из дев-панели включён (селект синхронен)', fi2.lock === 30, fi2);
+ const selectVal = await client.evaluate(() =>
+ document.getElementById('dev-fps-lock').value);
+ check('селект лока синхронизирован агентом (30)', selectVal === '30', selectVal);
+ await client.call('devSetFpsLock', 0); // вернуть без лока (дальнейшие гейты)
+ check('лок снят обратно', (await client.call('fpsInfo')).lock === 0);
const errs = client.errors();
check('нет pageerror за прогон', errs.length === 0, errs.slice(0, 3));
diff --git a/v2/packages/engine/src/core/__tests__/loop.test.ts b/v2/packages/engine/src/core/__tests__/loop.test.ts
index 936b8b0..0934694 100644
--- a/v2/packages/engine/src/core/__tests__/loop.test.ts
+++ b/v2/packages/engine/src/core/__tests__/loop.test.ts
@@ -79,20 +79,37 @@
t.loop.stop();
});
- it('setMaxFps на живом цикле: лок 60fps пропускает частые кадры, 0 снимает лок', () => {
+ it('setMaxFps на живом цикле: лок 30fps пропускает частые кадры, 0 снимает лок', () => {
const t = makeLoop(60);
t.loop.start();
- t.loop.setMaxFps(30); // интервал 50 мс: кадр каждые ~3 шага
+ t.loop.setMaxFps(30); // интервал 33.3 мс: кадр каждые ~2 шага
+ // сброс lastFrame не «пробивает» лок: до 33.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(0);
+ t.advance(16.7); // 33.4 мс — кадр проходит
+ expect(t.renders).toBe(1);
+ t.advance(16.7); // 16.7 мс с прошлого — пропуск
+ expect(t.renders).toBe(1);
+ t.advance(16.7); // 33.4 — снова кадр
expect(t.renders).toBe(2);
t.loop.setMaxFps(0); // лок снят — каждый кадр
t.advance(16.7);
expect(t.renders).toBe(3);
t.loop.stop();
});
+
+ it('лок 60 различим с «без лока» при герцовке выше 60: 120Гц rAF даёт вдвое реже', () => {
+ const t = makeLoop(60);
+ t.loop.start();
+ for (let i = 0; i < 24; i++) t.advance(8.3); // без лока: рендер каждый кадр rAF
+ expect(t.renders).toBe(24);
+ t.loop.stop();
+
+ const u = makeLoop(60);
+ u.loop.start();
+ u.loop.setMaxFps(60);
+ for (let i = 0; i < 24; i++) u.advance(8.3); // интервал 16.7 мс — каждый 2-й кадр
+ expect(u.renders).toBe(12);
+ u.loop.stop();
+ });
});
\ No newline at end of file