diff --git a/docs/plan.md b/docs/plan.md index 896b921..57e2161 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -610,6 +610,26 @@ полировке перспективы; **эффекты у объекта в дев-вьювере** (частицы/свет) — отложено с трека 28. +**Небо** (2026-09-13, трек «солнце, луна, звёзды, градиент» — готово, +S1–S3; детали и грабли — v2/CLAUDE.md): подсистема «крупные небесные +объекты» в движке (`render/sky.ts` — до 4 тел, диск+ореол+фаза; +`SkyLayer` — шейдерная сфера с градиентом/звёздами/дымкой; игра гонит +`setSky` каждый тик теми же функциями, что свет — диски совпадают с +тенями). Облака — отдельная задача (параметр «облачность» сейчас только +темнит/обесцвечивает). Открытые вопросы неба: **луна не освещает сцену** +(светит только на небе — ночная сцена живёт на nightK ambient; лунный +свет как слабый DirectionalLight — при полировке ночи); **терминатор +луны не повёрнут к солнцу** (фаза — просто параметр, плоскость +затенения не связана с направлением на солнце; для реализма — считать +ориентацию из взаимных положений светил); **фаза луны в игре всегда +растущая < 0.146** — GameClock хранит только минуты суток, счётчика дней +нет, полнолуние (phase 1) недостижимо (нужен счётчик суток в сейве); +**резкий стык abyss-тьмы за краем карты и дымки горизонта на закате** +(полоса тьмы — норма трека 34 «край мира читается», но можно смягчить +градиентом плоскости к цвету дымки); **в изо небо не видно вовсе** +(осознанно: конус небесной сферы в изо целиком под горизонтом — диски +светил в изометрии не показываются). + #### Воспроизведение демки v1 на v2 — дорожная карта (2026-09-09) Цель: на движке v2 воспроизвести демо-срез v1 («Пепельные луга») — 8 diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 60bc39f..bfdb33a 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -57,6 +57,7 @@ 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/checks/sky.mjs # гейт неба: градиент/солнце/луна/звёзды, изо не видит (/tmp/v2_sky_*.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 # оперативный скриншот участка карты агентом @@ -734,6 +735,14 @@ Uint8Array) — `Buffer.from(rgba)` перед записью. - **Node-скрипты вне v2 не резолвят `@rpg/engine`** (TS-исходники): одноразовые пробы класть в `apps/*/tools/` и запускать с cwd внутри v2. +- **`gotoArea` восстанавливает погоду базы области** (облачность лугов + 0.15 темнит кадр): гейтам с кадр-равенствами `setAsh(false)` обязателен + ПОСЛЕ каждого `gotoArea` — иначе второй кадр темнее и дифф десятки тысяч + пикселей (areas-гейт молча ломался от коммита погоды — «зелёный раньше» + не гарантия, гонять весь набор при трогании живых слоёв). +- **`#toasts` и `#fps` — живой DOM от настенных часов** (анимация тоста, + сводка fps раз в 0.5 с): дают 39-пиксельный дифф в кадр-равенствах + в headless — перед кадрами скрывать оба (`hidden = true`). ## Ревью-раунд (2026-09-10): багфиксы движка и игры @@ -1714,6 +1723,79 @@ из TS в GLSL-шаблоне печаталась «2» (int) — pow не компилировался, lit-слои молча не рисовали; в шаблоны числа — через `.toFixed(1)`). +## Небо (2026-09-13): солнце, луна, звёзды, градиент — «крупные небесные +## объекты» движка (2 солнца / несколько лун — просто больше тел в списке) + +Запрос: «сделать небо — понадобится в играх от первого и третьего лица: +солнце, луна, звёзды, синее небо; облака — отдельная задача (там сложности)». +Решения с пользователем: плавный градиент (без бандинга), луна с фазой +(параметр), подсистема в движке (`render/sky.ts`), игра — потребитель. + +- **Движок — чистая математика** (`render/sky.ts`, тесты `sky.test.ts`): + `celestialDir(k)` — дуга светила (азимут π(1−k), высота sin(kπ)·π/3+0.12, + БЕЗ клампа — k вне [0,1] светило под горизонтом; луна ходит по ней же); + `sunDirection(k)` — то же С клампом (бит-в-бит прежняя формула setSun); + `moonK(hours)` = ((hours−18)/12) mod 1 — встаёт 18:00, зенит 0:00, + садится 6:00; `moonPhase(hours, cycleDays=8)` — 0 новолуние → 1 + полнолуние; `sunTint(k)` — тёплый тон диска (вынесен из setSun); + `skyColors(nightK, cloudK)` — зенит/горизонт трёхопорным миксом + день→золотой час→ночь + обесцвечивание облачностью; + `starBrightness(nightK, cloudK)`; `packSkyBodies` (упаковка юниформ, + линейный RGB, клампы); `SKY_BODY_MAX = 4`. +- **SkyLayer** (`createSkyLayer(scene, radius)`): сфера BackSide + R = 9·viewSize (край 720 < far 800), `depthWrite: false`, + `renderOrder −1`, `frustumCulled false`; **центр = позиция активной + камеры каждый кадр в `render()`** (на target нельзя — орто-луч попал бы + в сферу за far). Фрагмент: градиент mix(uHorizon, uZenith, + smoothstep(0, 0.55, sin y)) + дымка чуть темнее под горизонтом + звёзды + (хэш по cube-face ячейкам, 96 ячеек/грань, порог 0.1, мерцание sin + от uTime) + диски тел (angRadius/glow/intensity/phase — терминатор + через нормаль псевдо-сферы, тёмная часть ×0.08) + статичный дизер + (детерминизм) + `#include ` (SRGB-грабля трека 32: + юниформы линейные, конверсия — в конце, по текущей ветке RT). +- **API рендера**: `setSky({hours, bodies: SkyBody[]} | null)` / + `skyInfo()` (enabled/hours/bodyCount/bodies[{phase,intensity,glow}]). + `SkyBody {dir (ЕДИНИЧНЫЙ, мировые оси), angRadius, color, glow?, + intensity? (дефолт 1), phase? (дефолт 1 — полный диск)}`. `null` — + снять слой, прежний рендер бит-в-ит; `scene.background` остаётся у + applyCloud (фолбэк, слой перекрывает каждый пиксель). +- **Игра**: флаг `sky` в LightingDef (`data/lighting.ts`) — 5 уличных + областей true, интерьеры нет (над комнатами нет потолка — дневное небо + поверх стен выглядит ошибкой; фон остаётся тинтом setNight). + `GameLighting.update` гонит `setSky` КАЖДЫЙ тик — диски совпадают со + светом, те же функции: солнце dir `sunDirection(sunK)`, цвет + `sunTint`, intensity 1−nightK (диск исчезает к ночи со светом); луна + dir `celestialDir(moonK(hours))`, цвет 0xdfe6f2, phase `moonPhase`, + intensity 0.15+0.85·nightK (полная в глухую ночь). Мост: `skyInfo()`, + `setSky(on)` (гейт-ручка: off — снять слой, тик не включает обратно). +- **Гейт** `tools/checks/sky.mjs` (12 проверок, порт 5338): небо на + улицах (2 тела), градиент зенит/горизонт, диск солнца, ночь темнее дня, + звёзды ночью (днём их нет), луна с фазой по формуле, облачность темнит + день и гасит звёзды, **изо бит-в-бит при setSky(false)** (небо в изо не + видно — конус небесной сферы в изо целиком под горизонтом видимой + сцены), интерьер без неба. +- **Грабли**: + - **мерцание звёзд — от времени цикла (fogTime из setFog), не + настенных часов**: гейты пинят время → кадр детерминирован; + uTime проставляется в `render()`. + - **setClock перед КАЖДЫМ кадром гейта** (в т.ч. внутри firstShot): + без пина часы стоят на моменте загрузки (~8:00) — светила «не там», + фаза не та; stepTicks сдвигает часы на 1/60 мин → допуск по hours, + не точное равенство. + - **градиент живёт на высотах 0..33°** (smoothstep до sin y 0.55): на + pitch −1.2 (взгляд вверх 69°) весь кадр ≈ зенит — градиент проверять + на pitch −0.3..−0.5 (верх кадра зенит, низ горизонт). + - **звёзды/диски считать на камере «от светил»** (yaw 1.6): в полночь + луна стоит ровно в направлении полуденного солнца (+Z) — кадр «на + светило» захватывает диск с ореолом, countBright считает не звёзды. + - **максимум белого диска после пост-цепочки ~191, не 255** (порог + countBright 180): тонмаппинг/линейная цепочка съедают верх; тост- + оверлей занимает x<430, y<52 — замеры неба правее/ниже. + - **полоса тьмы над горизонтом с высоты глаз — НЕ дефект неба**: это + abyss-плоскость за краем карты (трек 34, «край мира читается») между + дальним краем земли и линией горизонта; на закате стык резковат — + открытый вопрос (смягчить градиентом плоскости к цвету дымки). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/data/lighting.ts b/v2/apps/game/src/data/lighting.ts index 844966f..7648f39 100644 --- a/v2/apps/game/src/data/lighting.ts +++ b/v2/apps/game/src/data/lighting.ts @@ -37,6 +37,10 @@ nightAmbient?: number; /** Закатный тон (золотой час); нет — движковый DEFAULT_DUSK_COLOR. */ duskColor?: number; + /** Небо над областью (градиент + светила + звёзды): улицы — да, + * интерьеры — нет (потолка над комнатами нет — дневное небо поверх + * стен выглядит ошибкой; фон остаётся тинтом setNight). */ + sky?: boolean; sources?: LightSourceDef[]; } @@ -48,6 +52,7 @@ // Солнечный выжженный день: тёплый почти-белый. Вечером — золотой час // и синяя ночь: прорези колокольчиков несут свет. meadows: { + sky: true, ambient: 0xf7f4ea, nightAmbient: 0x3e4658, sources: [ @@ -63,10 +68,11 @@ ], }, // Светлый день у прудов; ночью — глухая синева. - ponds: { ambient: 0xf2f0e8, nightAmbient: 0x444c60 }, + ponds: { sky: true, ambient: 0xf2f0e8, nightAmbient: 0x444c60 }, // Светлый тёплый день деревни; вечером — окна трёх домов; после звонка — // холодный отсвет от башни. zvenets: { + sky: true, ambient: 0xf5f2ea, nightAmbient: 0x454c62, sources: [ @@ -85,8 +91,8 @@ }, ], }, - rust_grove: { ambient: 0xe9e6da, nightAmbient: 0x39405a }, - old_siding: { ambient: 0xe4e2d8, nightAmbient: 0x343a50 }, + rust_grove: { sky: true, ambient: 0xe9e6da, nightAmbient: 0x39405a }, + old_siding: { sky: true, ambient: 0xe4e2d8, nightAmbient: 0x343a50 }, // Под землёй темно; единственный свет — холодное тление устья печи. machine_hall: { ambient: 0x3e4252, diff --git a/v2/apps/game/src/lighting.ts b/v2/apps/game/src/lighting.ts index d21abad..0bb043c 100644 --- a/v2/apps/game/src/lighting.ts +++ b/v2/apps/game/src/lighting.ts @@ -5,7 +5,10 @@ * в тёмных областях/ночью. Источники — diff по id каждый тик; мерцание — * детерминированный flickerFactor от времени цикла. */ -import { dayNightAmbient, dayNightFactor, flickerFactor, lerpColor } from '@rpg/engine'; +import { + celestialDir, dayNightAmbient, dayNightFactor, flickerFactor, lerpColor, + moonK, moonPhase, sunDirection, sunTint, +} from '@rpg/engine'; import type { ParticleLight, ParticleSpot, PointLightHandle, SpotLightHandle, VoxelRenderer } from '@rpg/engine'; import { UNIT, GROUND_Y, tileCenter } from './areas'; import type { LightingDef, LightSourceDef } from './data/lighting'; @@ -63,6 +66,12 @@ return ((h >>> 0) % 1000) / 1000; } +/** [r,g,b] 0..1 → 0xRRGGBB (цвет диска солнца из sunTint). */ +function tintHex(t: readonly [number, number, number]): number { + const c = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255); + return (c(t[0]) << 16) | (c(t[1]) << 8) | c(t[2]); +} + export interface GameLightingDeps { renderer: VoxelRenderer; /** Освещение области (нет — нейтральный свет по умолчанию). */ @@ -114,6 +123,8 @@ private spotIntensity = 0; /** Снапшот луча для частиц (null — погашен). */ private spotLight: ParticleSpot | null = null; + /** Ручное выключение неба (гейты кадр-равенств); тик не включает обратно. */ + private skyOn = true; constructor(deps: GameLightingDeps) { this.deps = deps; @@ -155,7 +166,36 @@ // Солнце едет по часам (6:00 восход → 12:00 зенит → 18:00 закат): // тени меняют направление и длину в течение дня (setSun сам гасит // свет ночью — до и после окна клампимся в [0..1]). - this.deps.renderer.setSun(this.sunK()); + const sunK = this.sunK(); + this.deps.renderer.setSun(sunK); + // Небо области (каждый тик — диски совпадают со светом, те же + // функции): улицы — солнце+луна+звёзды; интерьеры — без неба + // (фон остаётся тинтом setNight). skyOn — гейт-ручка кадр-равенств. + const hours = this.deps.timeHours(); + if (light?.sky && this.skyOn) { + this.deps.renderer.setSky({ + hours, + bodies: [ + { + dir: sunDirection(sunK), + angRadius: 0.045, + color: tintHex(sunTint(sunK)), + glow: 0.35, + intensity: 1 - k, // диск исчезает к ночи вместе со светом + }, + { + dir: celestialDir(moonK(hours)), + angRadius: 0.05, + color: 0xdfe6f2, + glow: 0.12, + phase: moonPhase(hours), + intensity: 0.15 + 0.85 * k, // полная в глухую ночь + }, + ], + }); + } else { + this.deps.renderer.setSky(null); + } const next = new Set(); for (const def of this.activeDefs(k)) { @@ -272,6 +312,14 @@ }; } + /** Ручка неба для моста/гейтов: off — снять слой (кадр-равенства без + * мерцающих звёзд), on — вернуть (свет пересчитать сразу). */ + setSky(on: boolean): void { + this.skyOn = on; + if (on) this.update(); + else this.deps.renderer.setSky(null); + } + destroy(): void { for (const h of this.handles.values()) h.handle.dispose(); this.handles.clear(); diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index e3edd67..09f2639 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -1370,6 +1370,15 @@ sources: [], flashlight: { on: false, intensity: 0 }, }; }, + /** Снапшот неба (гейт): enabled/часы/тела (фаза луны в bodies); null — неба нет. */ + skyInfo() { + return renderer.skyInfo(); + }, + /** Ручка неба для гейтов: false — снять слой (кадр-равенства без + * мерцающих звёзд; тик не включает обратно), true — вернуть. */ + setSky(on: boolean): void { + lighting?.setSky(on); + }, /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды/воздух (гейт). */ ambientInfo() { return { diff --git a/v2/apps/game/tools/checks/areas.mjs b/v2/apps/game/tools/checks/areas.mjs index ed6c2de..7350f08 100644 --- a/v2/apps/game/tools/checks/areas.mjs +++ b/v2/apps/game/tools/checks/areas.mjs @@ -91,6 +91,11 @@ // олени тоже живые (бродят в реальном времени до setManual) — без фауны await client.call('setFauna', false); await client.call('stepTicks', 1); // перерисовать кадр без пепла + // тосты — живой DOM (анимация от настенных часов в headless): их + // верхняя кромка «дышит» между кадрами — из кадр-равенств наружу; + // и fps-счётчик (сводка раз в 0.5 с реального времени) — туда же + await client.evaluate(() => { document.getElementById('toasts').hidden = true; }); + await client.evaluate(() => { document.getElementById('fps').hidden = true; }); // Старт: луга, герой на спавне, меши собраны let ai = await client.call('areaInfo'); @@ -107,6 +112,9 @@ await client.call('stepTicks', 1); // отрисовать кадр с фиксированной камерой (следящая — на float-цели) const shotA = await client.screenshot('/tmp/v2_areas_meadows.png'); await client.call('gotoArea', 'meadows'); + // gotoArea восстанавливает погоду базы области (луга — облачность + // 0.15 темнит кадр): гасить заново, иначе кадр-равенство валится + await client.call('setAsh', false); await client.call('setClock', 479); await client.call('stepTicks', 1); const shotA2 = await client.screenshot('/tmp/v2_areas_meadows2.png'); diff --git a/v2/apps/game/tools/checks/sky.mjs b/v2/apps/game/tools/checks/sky.mjs new file mode 100644 index 0000000..66943a3 --- /dev/null +++ b/v2/apps/game/tools/checks/sky.mjs @@ -0,0 +1,204 @@ +/** + * Гейт неба (трек «Небо: солнце, луна, звёзды, градиент»): на улицах + * skyInfo включён (солнце+луна), в изо небо не видно (кадр бит-в-бит при + * setSky(false) — регрессия изо-гейтов), first — градиент зенит/горизонт, + * диск солнца в полдень, звёзды ночью (яркие точки в тёмном небе), луна + * ночью с фазой (skyInfo.phase = формула), день/ночь различаются, + * облачность темнит день и гасит звёзды, интерьер без неба. + * Скриншоты: /tmp/v2_sky_*.png. + * + * Грабли детерминизма (как в camera.mjs): setAsh(false) + setClock ПЕРЕД + * КАЖДЫМ кадром (firstShot пинит сам — иначе часы стоят и светила не там) + * + stepTicks(1); мерцание звёзд идёт от времени цикла (fogTime), которое + * пинится тем же тиком. + * + * Грабли камеры: градиент живёт на высотах 0..33° — на pitch −1.2 весь кадр + * зенит; проверка градиента — pitch −0.5 (верх кадра ~зенит, низ ~горизонт). + * Звёзды/облачность считаем на камере yaw 1.6 — от обоих светил (в полдень + * солнце в +Z, в полночь там же луна; тост-оверлей занимает x<430, y<52 — + * все замеры неба правее x=480). + * + * Запуск: node apps/game/tools/checks/sky.mjs [--port 5338] + */ +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 ?? 5338); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'sky', 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 W = 960, H = 540; + +/** Средняя яркость прямоугольника кадра (в пикселях канваса). */ +function meanLuma(img, x0, y0, x1, y1) { + let s = 0, n = 0; + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + const i = (y * W + x) * 4; + s += (img[i] * 0.3 + img[i + 1] * 0.6 + img[i + 2] * 0.1); + n++; + } + } + return s / n; +} + +/** Число ярких пикселей (все каналы > thr) в прямоугольнике — звёзды/диски. + * Порог 180: после пост-цепочки максимум белого диска ~191 (не 255). */ +function countBright(img, x0, y0, x1, y1, thr = 180) { + let n = 0; + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + const i = (y * W + x) * 4; + if (img[i] > thr && img[i + 1] > thr && img[i + 2] > thr) n++; + } + } + return n; +} + +/** Правая половина неба без тост-оверлея (x<430, y<52). */ +const SKY_X0 = 480, SKY_X1 = 920, SKY_Y0 = 20, SKY_Y1 = 320; + +async function shot(client, path) { + await client.screenshot(path); + return decodePng(readFileSync(path)).rgba; +} + +/** Взгляд из первого лица (pitch < 0 — вверх) + кадр: часы пинятся всегда. */ +async function firstShot(client, path, yaw, pitch, minutes = 720) { + await client.call('setCameraMode', 'first', { yaw, pitch }); + await client.call('setClock', minutes); + await client.call('stepTicks', 1); + return shot(client, path); +} + +// Фаза луны по часам — та же формула, что движка (moonPhase(hours, 8)) +const phaseAt = (hours) => (1 - Math.cos(2 * Math.PI * ((hours / 24) % 8) / 8)) / 2; + +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); + await client.evaluate(() => { document.getElementById('fps').hidden = true; }); + + // ---- Небо на улице: включено, солнце+луна ---- + await client.call('setClock', 720); + await client.call('stepTicks', 1); + const si = await client.call('skyInfo'); + check('луга: небо включено, 2 тела (солнце+луна)', + si !== null && si.enabled === true && si.bodyCount === 2 + && si.stars === true && Math.abs(si.hours - 12) < 0.01, si); + + // ---- Градиент: зенит ≠ горизонт (first, pitch −0.5: верх кадра + // ~зенит, низ ещё небо у горизонта; камера от светил) ---- + const grad = await firstShot(client, '/tmp/v2_sky_gradient.png', 1.6, -0.5); + const top = meanLuma(grad, SKY_X0, SKY_Y0, SKY_X1, 110); + const mid = meanLuma(grad, SKY_X0, 230, SKY_X1, 330); + check('градиент неба: зенит (верх) темнее горизонта (середина)', + mid - top > 8, { top: Math.round(top), mid: Math.round(mid) }); + + // ---- Диск солнца в полдень (yaw на светило, elev ≈ 1.167) ---- + const sun = await firstShot(client, '/tmp/v2_sky_sun.png', -Math.PI / 2, -1.167); + const sunPx = countBright(sun, 280, 90, 680, 450); + check('диск солнца: яркие пиксели при взгляде на светило', + sunPx > 100, { sunPx }); + check('солнце меняет кадр против градиента без светила', + countDiff(sun, grad) > 100, { diff: countDiff(sun, grad) }); + + // ---- Ночь: темнее дня, звёзды — яркие точки (камера от светил: + // в полночь луна стоит ровно в направлении полуденного солнца) ---- + const night = await firstShot(client, '/tmp/v2_sky_night.png', 1.6, -0.5, 0); + check('ночь темнее дня (та же камера)', countDiff(night, grad) > 5000, + { diff: countDiff(night, grad) }); + const dayStars = countBright(grad, SKY_X0, SKY_Y0, SKY_X1, 320); + const nightStars = countBright(night, SKY_X0, SKY_Y0, SKY_X1, 320); + check('звёзды ночью: яркие точки в тёмном небе (днём их нет)', + nightStars > 30 && dayStars < 5, { nightStars, dayStars }); + + // ---- Луна ночью: фаза по формуле, диск виден ---- + // 22:00: moonK = 1/3 (az 2π/3, elev ≈ 1.027), фаза ≈ 0.124 + const moon = await firstShot(client, '/tmp/v2_sky_moon.png', -Math.PI / 3, -1.027, 1320); + const si2 = await client.call('skyInfo'); + const expPhase = phaseAt(22); + const gotPhase = si2.bodies[1].phase; + check('луна: фаза из формулы (22:00 ≈ 0.124), интенсивность ночи', + Math.abs(gotPhase - expPhase) < 0.005 && si2.bodies[1].intensity === 1, + { gotPhase, expPhase, intensity: si2.bodies[1].intensity }); + const moonPx = countBright(moon, 280, 90, 680, 450); + check('луна: диск рисуется (яркие пиксели в направлении тела)', + moonPx > 50, { moonPx }); + + // ---- Облачность: темнит день (та же камера) и гасит звёзды ночью ---- + await client.call('setWeather', { cloud: 1 }); + await client.call('stepTicks', 30); // разгон cloudK к цели + const dayCloudy = await firstShot(client, '/tmp/v2_sky_daycloudy.png', 1.6, -0.5); + const cloudyNight = await firstShot(client, '/tmp/v2_sky_cloudy.png', 1.6, -0.5, 0); + const cloudyStars = countBright(cloudyNight, SKY_X0, SKY_Y0, SKY_X1, 320); + const dayLuma = meanLuma(grad, SKY_X0, SKY_Y0, SKY_X1, 320); + const cloudyLuma = meanLuma(dayCloudy, SKY_X0, SKY_Y0, SKY_X1, 320); + check('облачность темнит день и гасит звёзды', + cloudyLuma < dayLuma && cloudyStars * 2 < nightStars, + { dayLuma: Math.round(dayLuma), cloudyLuma: Math.round(cloudyLuma), + nightStars, cloudyStars }); + await client.call('setWeather', null); // база области дальше + await client.call('stepTicks', 1); + + // ---- Изо-регрессия: небо в изо не видно — кадр бит-в-бит ---- + await client.call('setCameraMode', 'iso'); + await client.call('setFollow', false); + const isoRef = await stableShotRef(client, '/tmp/v2_sky_iso.png'); + await client.call('setSky', false); + const isoNoSky = await stableShotRef(client, '/tmp/v2_sky_iso_nosky.png'); + check('изо: кадр с небом и без попиксельно равен (небо в изо не видно)', + countDiff(isoRef, isoNoSky) === 0, { diff: countDiff(isoRef, isoNoSky) }); + await client.call('setSky', true); + await client.call('stepTicks', 1); + + // ---- Интерьер без неба ---- + await client.call('gotoArea', 'house_elder'); + await client.call('stepTicks', 60); // полный фейд out+in (0.4 с × 2) + const siIn = await client.call('skyInfo'); + check('интерьер (дом старейшины): неба нет', siIn === null, siIn); + } finally { + await browser.close(); + } +} catch (e) { + console.error(e); + report.fatal = String(e); +} + +/** Кадр в изо в детерминированном состоянии (полдень, один тик). */ +async function stableShotRef(client, path) { + await client.call('setClock', 720); + await client.call('stepTicks', 1); + return shot(client, path); +} + +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 diff --git a/v2/packages/engine/src/render/voxelRenderer.ts b/v2/packages/engine/src/render/voxelRenderer.ts index ade1502..5f55f40 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -378,7 +378,8 @@ /** Небо: сфера-градиент вокруг активной камеры + небесные тела + звёзды. * null — выключить и удалить слой (фон сцены остаётся как был). Тела * задаёт игра каждый тик (диски совпадают со светом — одни функции). - * Цвета неба/звёзды рендер ведёт сам от nightK/cloudK (skySyncColors). */ + * Цвета/звёзды рендер ведёт сам от nightK/cloudK; мерцание — от времени + * цикла (fogTime — детерминизм кадра), позиция и uTime — в render(). */ setSky(opts: SkyOptions | null): void { this.skyOpts = opts; if (!opts) { @@ -883,7 +884,12 @@ this.camera.updateMatrixWorld(); // небо центрируем на активной камере каждый кадр (на target нельзя: // орто-луч до сферы ~в 4 раза дальше — далеко за far) - if (this.sky) this.sky.mesh.position.copy(this.camera.position); + if (this.sky) { + this.sky.mesh.position.copy(this.camera.position); + // мерцание звёзд — от времени цикла (fogTime): детерминизм кадра + // в гейтах (время пинится), а не от настенных часов + this.sky.uniforms.uTime.value = this.fogTime; + } if (this.fogStrength > 0.001) { if (!this.fogPass) { const size = new THREE.Vector2();