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