diff --git a/docs/plan.md b/docs/plan.md index aee9563..896b921 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -55,6 +55,7 @@ | 33 | v2 трек 12: сцены/переходы — стек SceneManager (меню поверх игры), фейд-переходы out→swap→in, параллельные переходы глотаются; демка: DOM-меню (старт/продолжить), автосейв при уходе в меню, Esc + гейт | ✅ (2026-09-09). `core/scene.ts` — перенос схемы v1 без привязки к бэкенду: `Scene` {enter/exit/update/render}, стек push/pop/replace, переход {duration} фазами **затухание → смена сцены → проявление** (каждая фаза длится duration — полный переход 2×duration); `FadeOverlay {setAlpha}` инъекцией (демка — DOM-слой `#fade`, тесты — заглушка); **параллельные переходы глотаются** (pending-проверка в begin), **enter новой сцены не ожидается** (void), update тикает текущую сцену и во время перехода, render рисует весь стек (нижние сцены видны). Демка: `gameScene` (tick+рендер) внизу стека, `menuScene` (DOM-панель) сверху — мир живёт под меню с самого старта; Esc/мост `gotoMenu` — из игры автосохранение в слот `auto` + push меню, из меню — возврат; «Продолжить» грузит `auto` (кнопка disabled без сейва); мост sceneInfo/gotoMenu/startGame. Гейт `scenes.mjs` 11 проверок: старт в меню без перехода, startGame — переход идёт (сцена ещё меню), **фейд в конце фазы out — кадр чёрный**, переход завершён (меню скрыто), кадры меню/игры различаются, gotoMenu пишет автосейв, возврат в меню, «Продолжить» вернул героя на место сохранения, параллельный переход глотается, повторный переход работает. 164 юнит-теста. Уроки: **CSS `display:flex` перебивает атрибут `hidden`** — нужен `#menu[hidden]{display:none}` (гейт save поймал: меню «скрыто», но видно на каждом кадре, испортив попиксельное сравнение); **полный переход — это out+in (2×duration = 48 тиков при 0.4 с), а не 24** — в ручном режиме гейты обязаны пройти все 48 тиков, иначе фейд висит частично затемняя каждый кадр (ловится только попиксельным сравнением); **все гейты демки теперь входят в игру через startGame(false)+60 тиков** — демка стартует в меню, старые гейты снимали кадры поверх панели меню. | | 34 | v2 трек 13: бой — перенос боевой системы v1 на воксельный движок: PlayerCombat (конус «гулкий звон» будит пепел / заряженный резонанс усыпляет), EnemyBrain (стейты dormant/rise/chase/hurt/flee/sleeping, слух по уровням шума, LOS с кэшем), CombatWorld (спавн, снаряды плевуна, трупы, толкучка-сепарация); демка: сгустки-кучки на лугу, атака на Space, HUD сердец, мост combatSpawn/combatClear/combatInfo/combatStats/attackInput + гейт | ✅ (2026-09-09). `combat/` движка: `shapes.ts`/`los.ts`/`stats.ts`/`enemies.ts`/`playerCombat.ts`/`brain.ts` (StateMachine + Cooldown) + `world.ts` — CombatWorld с хуками {onEnemyHurt/onEnemyKilled/onEnemyAwake/onAttack/onSpit} вместо EventBus v1; deps {getPlayerPos, damagePlayer, solid, opaque} — мир инъекцией предикатами. 16 юнит-тестов (217 всего). **Масштаб: 1 боевой юнит = 3 вокселя** — боевые числа v1 в юнитах (герой ≈ 1), конвертация только на границе демки (позиции /3, отброс ×3), CombatWorld живёт в чистом юнит-пространстве. Демка: спавны (патруль (8,10)→(10,12), спящие плевун (19,20) и тяжёлый (22,13)), кучки — приплюснутые эллипсоиды из вокселей (спящий — нижний ряд, «блин»; hurt — вспышка 0.12 с), шум шагов 0.35, HUD `#hp` сердечками (скрывается вне игровой сцены — isBlack-проверка scenes-гейта сканирует весь кадр). Гейт `combat.mjs` 14 проверок: 3 сгустка/2 спят, кучка рисуется (diff ~1100), звон будит без урона (rise), погоня до равновесия 0.55, конус 3→1→труп→исчез, смерть героя 4,3,2,1,5 + респаун (40,60), плевун 5→4 + HUD-зона, резонанс усыпляет обоих. Уроки: **dormant глух к близости героя** — спящий вплотную не просыпается (только шум ≥ 0.7 или урон), гейт будит звоном издали; **равновесие сепарации = сумма радиусов (0.55) > attackRange ползуна (0.4)** — ползун «липнет», но стоящего героя не бьёт (v1-faithful; контактные удары в гейте — телепортом в упор, тяжёлый 0.65 ≤ 0.8 бьёт); **толкучка вталкивает врага в strike-радиус** — два сгустка с разных сторон дают удар без атаки, спавнить с одной стороны; **combatClear обязан чистить и снаряды** — снаряд плевуна летит после удаления врагов и «призраком» наносит урон; **боевые точки проверять прогоном** — у старта героя (40,60) пусто, у (14,84) препятствие (равновесие погони ровно 0.55 — маркер открытости). | | 35 | v2 трек 14: аудио — перенос аудио-подсистемы v1 на воксельный движок: AudioManager (4 шины, спек-синтез sfx без WAV-пайплайна), Voices/Loops/BufferCache/SoundSpec/Music, synth-примитивы в src движка; демка: позиционный звук боя (затухание + изо-панорама), шаги с вариациями, DEV-шпион + гейт | ✅ (2026-09-09). `audio/` движка: `types.ts` (шины/хендлы/опции), `decode.ts` (BufferCache: in-flight дедуп декода), `Voices.ts` (полифония 24, вор самого тихого, restart по ключу, панорама в полёте), `Loops.ts` (слоты музыки/амбиента с кроссфейдом, независимые), `SoundSpec.ts`/`Music.ts` (спек-синтез: hit/chime/scrape/hum/tone + треки bpm/beats/tracks), `synth.ts` (примитивы v1 tools/synth.mjs в TS — рантайм и тулзы делят одни формулы), `AudioManager.ts` (фасад: prepare() синхронно заводит контекст без resume, unlock, playSpec кэширует буфер по ключу, шпион onPlayed). 44 юнит-теста (261 всего, 31 файл), тесты v1 перенесены 1:1 (фейковый граф AudioContext). Демка: `src/audio.ts` — спеки боя/урона/шагов (формулы v1 sfxSpecs), `DemoAudio` (playAt: (1−d/radius)^1.5 + панорама ∝ изо-(dx−dz)·16/240, дедуп 0.08 с; StepVariants: 3 варианта шага, сид = база + i·10), обвязка в main.ts — хуки боя (cone→bell_hit, resonance→bell_low, spit, awake→ash_hiss, kill→ash_die, playerHit→hurt), шаг на границе половины walk-цикла, unlock на первом жесте, prepare() на старте (буферы вариантов компилируются сразу); мост `audioUnlock`/`audioInfo` (кольцевой буфер 24 записей фактических запусков). Гейт `audio.mjs` 8 проверок: тишина на старте (амбиента нет, герой не двигался), шаги играют, атака→bell_hit, плевок→spit (позиционный, в радиусе слышимости 12), резонанс→bell_low, лог ограничен 24. Уроки: **кулдаун конуса 0.45 с не сбрасывается телепортом/очисткой** — атака-пробуждение через ~9 тиков после атаки-звонка не выходит (release глотает), между боевыми секциями гейта ждать ≥30 тиков; **звук в headless работает без жестов** (agent-lib уже передаёт `--autoplay-policy=no-user-gesture-required`), prepare() даёт компиляцию буферов до первого resume. | +| 36 | Режимы камеры v2: изометрия / третье лицо / первое лицо — полная перспектива, мышь (поворот ЛКМ, колесо — дистанция в 3rd), переключалка в дев-панели, мост, гейт | ✅ (2026-09-13). Движок: `CameraMode` + перспективная камера в VoxelRenderer (обе камеры держатся всегда, `setTarget` — «точка интереса» без изменений, repositionCamera ветвится); перспективные подсистемы с изо-замком «бит-в-бит прежнее поведение» — окно частиц пирамидой (`particleWindowPersp`, far cap, пустой фрустум → конечный бокс), туман (`fogBandLayout`: изо — легаси ТОЧНО, persp — след земли фрустума; `#ifdef PERSP` в FOG_FRAG), спрайты (общий блок `makeParticlePerspUniforms`, POINT_VERT с калибровкой `uPxScale·uRefDist = (H/2)/tan(fov/2)`), теневые фрустумы (`perspVisibleRadius`). Игра: `CameraInput` (ЛКМ-drag, колесо в third), движение по yaw орбиты, облако героя скрыто в first, дев-панель «КАМЕРА», мост `setCameraMode/setOrbit/cameraInfo`, `shot.mjs --cam`. Гейт `camera.mjs` 16 проверок: эталон изо бит-в-бит повторяем, third/first работают, drag/колесо, следование, туман в перспективе, окно без NaN, **возврат в изо попиксельно равен эталону** (обратимость), кнопки панели. 407 тестов. Уроки: **DOM-оверлей #fps в кадр-равенстве** — скрыть в гейте; **эталон снят при погоде-дефолтах** (`setAsh(false)` гасит погоду целиком) — после `setWeather({fog:0})` в гейте остаётся база области, кадр-равенство валится на ~18 тыс. px; перед возвратом звать `setAsh(false)` заново. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -602,6 +603,12 @@ спек-синтез без WAV, позиционный звук и шаги в демке, DEV-шпион). Все 14 треков переноса подсистем v1 на v2 закрыты — дальше: воспроизведение демки v1 («Пепельные луга») на v2 по мере движения. +Открытые вопросы (продолжение): **резкая линия включения теней у горизонта +в перспективной камере** (трек 36) — теневой фрустум от `perspVisibleRadius` +покрывает видимую землю конечным радиусом, за ним тени включаются ступенькой; +лечится каскадными shadow map (CSM) — вне скоупа трека, вернуться при +полировке перспективы; **эффекты у объекта в дев-вьювере** (частицы/свет) — +отложено с трека 28. #### Воспроизведение демки v1 на v2 — дорожная карта (2026-09-09) diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index b0a6e34..60bc39f 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -56,6 +56,7 @@ node apps/game/tools/checks/act2.mjs # гейт акта 2: резонатор/Машина/олени (/tmp/v2_act2_*.png) node apps/game/tools/checks/cutscenes.mjs # гейт катсцен: dry-run сцен, камера к герою (/tmp/v2_cutscene_*.png) node apps/game/tools/checks/panels.mjs # гейт панелей UI: лоад-скрин, слоты, сумка, дев-вьювер (/tmp/v2_panels_*.png) +node apps/game/tools/checks/camera.mjs # гейт режимов камеры: изо/third/first, мышь, обратимость (/tmp/v2_camera_*.png) node apps/game/tools/compare-shot.mjs --dir /tmp # сравнительные снимки с v1 (5 участков, пары к v1/apps/game/tools/compare-shot.mjs) node apps/game/tools/shot.mjs --area rust_grove --at 24,16 --flags act2_hook --out /tmp/s.png # оперативный скриншот участка карты агентом @@ -1688,12 +1689,28 @@ — сечение пирамиды на глубине видимой земли (farG fogBandLayout, кламп снизу ¼ изо-полуширины); известный предел — резкая линия включения теней у горизонта в перспективе (каскады вне скоупа, docs/plan.md). + - **Игра**: `src/camera.ts` — `CameraInput` (поворот зажатой ЛКМ — + drag по канвасу с setPointerCapture, колесо — дистанция только в + third); движение `screenMoveToWorld(move, iso ? ISO_YAW : + orbitState().yaw)`; в first облако героя скрыто (`mesh.visible`, + катсцены — исключение); мост `setCameraMode/setOrbit/cameraInfo`, + `windowInfo` дополнен `cam`. Дев-панель — секция «КАМЕРА» (Изо / + От 3-го / От 1-го / Сброс орбиты). `shot.mjs --cam iso|third|first`. + - **Гейт** `tools/checks/camera.mjs` (16 проверок): эталон изо-кадра + бит-в-бит повторяем, third/first — перспектива работает (кадры + различаются, поворот/дистанция меняют кадр), drag/колесо, следование + во всех режимах, туман в перспективе, окно частиц без NaN, + **возврат в изо попиксельно равен эталону** (обратимость), кнопки + панели. Грабля: **эталон снят при погоде-дефолтах** (`setAsh(false)` + гасит погоду целиком) — после туманного теста `setWeather({fog:0})` + оставляет базу области (ветер/облачность) и кадр-равенство валится + на ~18 тыс. пикселей; перед возвратом звать `setAsh(false)` заново. - **VoxelRenderer.dispose()** (ревью): полное освобождение GPU-ресурсов — пост-проход, туман, свет с shadow map, контекст GL. Облако и меши — у владельцев. Обязателен для многоразовых экземпляров (ModelViewer создаёт рендерер на каждое открытие — теперь зовёт dispose); для страничного рендера демки не вызывается. - `check:fast` — 392 ✓ (включая фикс pow-грабли lit-шейдера: степень + `check:fast` — 407 ✓ (включая фикс pow-грабли lit-шейдера: степень из TS в GLSL-шаблоне печаталась «2» (int) — pow не компилировался, lit-слои молча не рисовали; в шаблоны числа — через `.toFixed(1)`). diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 9948653..e3edd67 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1435,6 +1435,10 @@ setOrbit(params: { yaw?: number; pitch?: number; dist?: number }): void { renderer.setOrbit(params); }, + /** Снапшот активной камеры (гейт/дев-панель): режим, fov, позиция, орбита. */ + cameraInfo() { + return renderer.cameraInfo(); + }, /** Погода вручную (дев-панель/гейт): частичный spec поверх базы области; * null — вернуть погоду области. Облачность применяется сразу. */ setWeather(spec: Partial | null): void { diff --git a/v2/apps/game/tools/checks/camera.mjs b/v2/apps/game/tools/checks/camera.mjs new file mode 100644 index 0000000..ea6058b --- /dev/null +++ b/v2/apps/game/tools/checks/camera.mjs @@ -0,0 +1,213 @@ +/** + * Гейт камеры v2 (трек «Режимы камеры»): старт в изо с эталонным кадром, + * third/first — перспектива работает (кадры различаются, поворот/дистанция + * меняют кадр), мышь-ввод (drag крутит орбиту, колесо — дистанция), + * следование за героем во всех режимах, туман в перспективе, окно частиц + * без NaN, возврат в изо ПОПИКСЕЛЬНО равен эталону (главный замок + * обратимости), кнопки дев-панели переключают режим. Гейт начинает и + * заканчивает в изо; режим в сейв не пишется. Скриншоты: /tmp/v2_camera_*.png. + * + * Грабля детерминизма: атмосфера глушится (setAsh(false) — осадки/пепел/ + * дымка/блики/листья с общим elapsed уходят), фауна выключается, часы + * перематываются setClock(720) ПЕРЕД каждым снимком + stepTicks(1) — + * тогда кадр при прочих равных воспроизводится бит-в-бит. + * + * Запуск: node apps/game/tools/checks/camera.mjs [--port 5336] + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5336); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'camera', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Число различающихся пикселей двух кадров (порог 8 на канал). */ +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < a.length; i += 4) { + if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++; + } + return n; +} + +const finiteBox = (w) => [w.minX, w.maxX, w.minZ, w.maxZ].every(Number.isFinite); + +async function shot(client, path) { + await client.screenshot(path); + return decodePng(readFileSync(path)).rgba; +} + +/** Кадр в детерминированном состоянии: часы перемотаны, один тик логики + * (грабля ручного режима: скриншот — последний отрисованный кадр). */ +async function stableShot(client, path) { + await client.call('setClock', 720); + await client.call('stepTicks', 1); + return shot(client, path); +} + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + await client.call('setManual', true); + await client.call('setAsh', false); // детерминизм кадра: без атмосферы + await client.call('setFauna', false); + await client.call('setFollow', false); // цель — float-центр спавна + // DOM-оверлей fps обновляется каждый кадр — в кадр-равенства не входит + await client.evaluate(() => { document.getElementById('fps').hidden = true; }); + const hero0 = await client.call('heroInfo'); // спавн (герой вернётся сюда) + + // ---- Эталон изо-кадра (и его повторяемость) ---- + let ci = await client.call('cameraInfo'); + check('старт в изо (дефолт режима, в сейв не пишется)', ci.mode === 'iso', ci.mode); + const ref = await stableShot(client, '/tmp/v2_camera_iso.png'); + const ref2 = await stableShot(client, '/tmp/v2_camera_iso_ref2.png'); + check('эталон повторяем бит-в-бит (детерминизм ручного тика)', + countDiff(ref, ref2) === 0, { diff: countDiff(ref, ref2) }); + + // ---- Third person: перспектива, поворот, дистанция ---- + await client.call('setCameraMode', 'third'); + await client.call('stepTicks', 1); + ci = await client.call('cameraInfo'); + const third = await shot(client, '/tmp/v2_camera_third.png'); + check('→ third: режим активен, кадр отличается от изо', + ci.mode === 'third' && countDiff(third, ref) > 1000, + { mode: ci.mode, diff: countDiff(third, ref) }); + + const yaw0 = ci.yaw; + await client.call('setOrbit', { yaw: yaw0 + 1.2 }); + await client.call('stepTicks', 1); + const yawed = await shot(client, '/tmp/v2_camera_third_yaw.png'); + check('поворот орбиты меняет кадр', countDiff(yawed, third) > 500, + { diff: countDiff(yawed, third) }); + + const ci2 = await client.call('cameraInfo'); + await client.call('setOrbit', { dist: ci2.dist * 0.5 }); + await client.call('stepTicks', 1); + const close = await shot(client, '/tmp/v2_camera_third_dist.png'); + check('дистанция орбиты меняет кадр', countDiff(close, yawed) > 500, + { diff: countDiff(close, yawed) }); + + // ---- Мышь-ввод: drag крутит yaw, колесо в third — дистанция ---- + const before = (await client.call('cameraInfo')).yaw; + await client.evaluate(() => { + const c = document.getElementById('scene'); + const opts = (x, y) => ({ bubbles: true, button: 0, pointerId: 7, clientX: x, clientY: y }); + c.dispatchEvent(new PointerEvent('pointerdown', opts(200, 200))); + c.dispatchEvent(new PointerEvent('pointermove', opts(320, 200))); + c.dispatchEvent(new PointerEvent('pointerup', opts(320, 200))); + }); + await client.call('stepTicks', 1); + const after = (await client.call('cameraInfo')).yaw; + check('drag ЛКМ по канвасу крутит орбиту', Math.abs(after - before) > 0.1, + { before, after }); + + const d0 = (await client.call('cameraInfo')).dist; + await client.evaluate(() => { + document.getElementById('scene').dispatchEvent( + new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 }), + ); + }); + await client.call('stepTicks', 1); + const d1 = (await client.call('cameraInfo')).dist; + check('колесо в third отдаляет камеру', d1 > d0 * 1.05, { d0, d1 }); + + // ---- Следование в third ---- + await client.call('heroTeleport', 30, 30); + await client.call('stepTicks', 1); + const hi = await client.call('heroInfo'); + const followDx = Math.hypot(hi.camTarget[0] - hi.pos[0], hi.camTarget[2] - hi.pos[2]); + check('следование в third: цель камеры едет за героем', followDx < 2, { followDx }); + + // ---- First person: глаза героя, герой скрыт, цель едет ---- + await client.call('setCameraMode', 'first'); + await client.call('stepTicks', 1); + ci = await client.call('cameraInfo'); + const first = await shot(client, '/tmp/v2_camera_first.png'); + const eyeH = ci.pos[1] - hi.camTarget[1]; + check('→ first: режим активен, глаза над целью (~10), кадр другой', + ci.mode === 'first' && Math.abs(eyeH - 10) < 0.5 && countDiff(first, third) > 1000, + { mode: ci.mode, eyeH, diff: countDiff(first, third) }); + + await client.call('heroTeleport', 36, 34); + await client.call('stepTicks', 1); + const ci3 = await client.call('cameraInfo'); + const movedDx = Math.hypot(ci3.pos[0] - 36, ci3.pos[2] - 34); + check('first: камера (глаза) поехала за героем', movedDx < 2, { movedDx }); + + // ---- Туман в перспективе: конечные числа, кадр меняется ---- + await client.call('setWeather', { fog: 0.8 }); + await client.call('stepTicks', 3); + const fi = await client.call('fogInfo'); + const fogged = await shot(client, '/tmp/v2_camera_first_fog.png'); + const fogFinite = Number.isFinite(fi.strength) && (fi.bands === undefined || Number.isFinite(fi.bands)); + check('туман в перспективе: сила конечна, кадр меняется', + fogFinite && fi.strength > 0.5 && countDiff(fogged, first) > 500, + { fi, diff: countDiff(fogged, first) }); + await client.call('setWeather', null); // правка тумана не тащится дальше + await client.call('stepTicks', 1); + + // ---- Окно частиц без NaN (перспектива) ---- + const w = await client.call('windowInfo', 0, 40); + check('windowInfo в перспективе: окно и камера — конечные числа', + finiteBox(w) && Number.isFinite(w.cam.pos[1]), w.cam); + + // ---- Возврат в изо: кадр попиксельно равен эталону ---- + await client.call('heroTeleport', hero0.pos[0], hero0.pos[2]); // герой на спавне + await client.call('setAsh', false); // состояние мира = эталонное (погода/частицы) + await client.call('setCameraMode', 'iso'); + await client.call('setFollow', false); // та же float-цель спавна, что у эталона + const heroR = await client.call('heroInfo'); + const ret = await stableShot(client, '/tmp/v2_camera_iso_return.png'); + const ret2 = await stableShot(client, '/tmp/v2_camera_iso_return2.png'); + check('возврат в изо: кадр попиксельно равен эталону (обратимость)', + countDiff(ret, ref) === 0, { + diff: countDiff(ret, ref), + rep: countDiff(ret, ret2), + heroYaw: [hero0.yaw, heroR.yaw], phase: [hero0.phase, heroR.phase], + }); + + // ---- Кнопки дев-панели ---- + await client.call('devOpen'); + await client.evaluate(() => document.getElementById('dev-cam-third').click()); + const mThird = (await client.call('cameraInfo')).mode; + await client.evaluate(() => document.getElementById('dev-cam-iso').click()); + const mIso = (await client.call('cameraInfo')).mode; + check('дев-панель: кнопки переключают режим (third → iso)', + mThird === 'third' && mIso === 'iso', { mThird, mIso }); + + // сброс орбиты возвращает дефолт после ручного поворота + await client.call('setCameraMode', 'third'); + await client.call('setOrbit', { yaw: 2.5, pitch: 1.0, dist: 200 }); + await client.evaluate(() => document.getElementById('dev-cam-reset').click()); + const rs = await client.call('cameraInfo'); + check('дев-панель: сброс орбиты (yaw/pitch/dist по умолчанию)', + Math.abs(rs.yaw - yaw0) < 1e-6 && Math.abs(rs.pitch - 0.5) < 1e-6 + && Math.abs(rs.dist - rs.viewSize) < 1e-6, rs); + await client.call('setCameraMode', 'iso'); // гейт заканчивает в изо + await client.call('devClose'); + await client.call('stepTicks', 1); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + report.fatal = String(e); +} + +report.ok = report.checks.length > 0 && report.checks.every((c) => c.ok); +console.log(JSON.stringify(report)); +process.exit(report.ok ? 0 : 1); // chromium-хэндлы держат event loop — выходим явно \ No newline at end of file