diff --git a/docs/plan.md b/docs/plan.md index 077a17c..c2135fb 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -689,10 +689,30 @@ отдельным прогоном); тайлы зелени башни — тропа (PATH=1), а raiseTowerGreen сажает только на GRASS/ASH (v1-faithful — зелень у башни не всходит и в v1). -- **Трек 18 — время суток и атмосфера**: GameClock (день/вечер/ночь), - источники света (колокольчики ночью), частицы пепла/тумана, темы - областей (renderMusic) + локальные амбиент-слои (спек-лупы вместо WAV); - гейт. +- **Трек 18 — время суток и атмосфера** ✅ (2026-09-10): часы суток + (GameClock, 1 сек = 1 игровая минута, старт 8:00 — порт v1 1:1), + кривая день/ночь из движка (dayNightFactor + dayNightAmbient, пик + золотого часа 0.45 в 18:54) через новые API рендера: setNight(k, tint) + (hemisphere → tint, фон = tint, солнце × (1 − 0.92·k)) и + setDayAmbient(color) (дневная база области; тёмные интерьеры гасят + hemisphere и солнце по яркости — аналог multiply-ambient v1), + addPointLight (distance = radius×UNIT, decay 1.6). Игра: данные + освещения 8 областей v1 1:1 (ambient/nightAmbient/duskColor/sources с + whenFlag/notFlag/whenNight), GameLighting (diff по id, мерцание + flickerFactor, лампа героя HERO_LAMP в темноте/ночью, MAX_LIGHTS 24), + пепел лугов (AshParticles, 36 вокселей, детерминированный дрейф от + elapsed), темы областей (GameAudio: playTheme с кроссфейдом, тема + только у лугов — v1 1:1). Мост: clockInfo/setClock/lightingInfo/ + audioInfo/setAsh. Гейт `tools/checks/atmosphere.mjs` — 18 проверок + (кривая ночи по часам, источники по флагам/ночи, мерцание, кадр ночи + темнее, пепел дрейфует, очаг hearth_low → hearth_high, отсвет башни по + rang_tower, тема лугов / тишина в интерьере). Вне скоупа (отложено): + туман прудов, тёмные ауры наката (darkSpot), виньетка, локальные + амбиент-слои. Уроки: страница в гейте живёт в реальном времени до + setManual (время уплывает — фиксировать setClock в начале); пепел — + живой слой кадра (гейты кадр-равенств зовут setAsh(false) + stepTicks + перед скриншотами); интерьеры без nightAmbient — только setDayAmbient; + пик золотого часа t = 18 + 0.45×2 = 18:54, не 18:48. - **Трек 19 — акт 2**: Клинт, ResonatorQuest, тон-деревья, Машина (machine_hall), фауна (олени); гейт. - **Трек 20 — сборка**: сейв полного состояния, меню, полный прогон, diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index cc2cb70..203979e 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -52,6 +52,7 @@ node apps/game/tools/checks/areas.mjs # гейт областей игры (/tmp/v2_areas_*.png) node apps/game/tools/checks/npc.mjs # гейт NPC/квестов игры (/tmp/v2_npc_*.png) node apps/game/tools/checks/interact.mjs # гейт интерактива/цветков игры (/tmp/v2_interact_*.png) +node apps/game/tools/checks/atmosphere.mjs # гейт времени суток/света/музыки игры (/tmp/v2_atmosphere_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 npx vite --port 5315 # игра «Пепельные луга» (в apps/game) ``` @@ -395,6 +396,47 @@ действий); **тайлы зелени башни — тропа (PATH=1)** — raiseTowerGreen ставит цветок только на GRASS/ASH (v1-faithful), у башни зелень не всходит, не считать это багом конвертации. + Трек 18 «время суток, атмосфера, музыка»: движок — `packages/engine/src/render/daynight.ts` + (чистая математика суток, перенос v1 lightSim: `dayNightFactor` по часам, + трёхопорный `dayNightAmbient` день→золотой час→ночь с DUSK_PEAK 0.45, + `flickerFactor` детерминированного мерцания, `lerpColor`/`dimColor`; тесты + — перенос lightSim) + рендер: `VoxelRenderer.setNight(k, tint)` (hemisphere + от дневной базы к tint, фон сцены = tint, солнце гасится до 8%), `setDayAmbient` + (дневная база области: тёмный интерьер затемняет hemisphere и гасит + прямое солнце — аналог multiply-ambient v1; улицы — P5 по умолчанию), + `addPointLight` (three.js PointLight, distance = radius×2, decay 1.6; + `PointLightHandle` setIntensity/setPos — мерцание и лампа героя двигаются + на стороне игры). Игра: `src/clock.ts` (GameClock v1 1:1: 1 сек реального = + 1 игровая минута, сутки 24 мин, старт 8:00) + тесты (порт clock.test v1); + `src/data/lighting.ts` (LIGHTING 8 областей v1 1:1 — ambient/nightAmbient/ + источники bells/окна/очаг по флагам/башня/прилавок + ATMOSPHERE); + `src/data/music.ts` (MEADOW_THEME/COMBAT_THEME — партитуры нот, сиды, + рендер renderMusicStereo → playLoopBuffer); `src/lighting.ts` (GameLighting: + setNight+dayNightAmbient каждый тик, источники diff по id с мерцанием + flickerFactor(seedOf(id)), фильтры whenFlag/notFlag/whenNight (порог 0.5), + лампа героя HERO_LAMP в тёмных областях/ночью, MAX_LIGHTS 24); + `src/ash.ts` (пепел — voxel-облако 36 хлопков вокруг камеры, детерминированные + синусы от времени цикла); `src/audio.ts` (GameAudio: AudioManager + тема + области кроссфейдом). В main.ts: часы и свет тикают всегда (и в фейде/ + катсцене), setArea пересоздаёт свет, включает пепел по ATMOSPHERE и стартует + тему. Мост: `clockInfo`/`setClock` (перемотка гейтом)/`lightingInfo` + (nightFactor + активные источники пост-мерцание)/`audioInfo` (тема + буфер + запусков)/`setAsh` (выключить атмосферу для кадр-равенств). Гейт + `tools/checks/atmosphere.mjs` (18 проверок): часы старт/золотой час/ночь/ + mod 1440, кривая 0.45 на пике, bells ночью по тайлам + лампа героя, мерцание + пульсирует, кадр ночи темнее дня (яркость), пепел дрейфует, очаг по флагу + сдачи, отсвет башни по rang_tower, тема лугов играет/в интерьере тишина. + Грабли трека 18: **страница грузится в реальном времени** — до setManual + цикла часы уже ушли от 8:00 (первый снапшот 8:01): гейту нормировать + `setClock(480)` перед проверками времени; **пепел — живой слой кадра**: + попиксельные сравнения «пересборка = тот же кадр» падают (дрейф с тиками) — + в гейтах мира `setAsh(false)` + 1 тик перерисовки до скриншотов; + **интерьеры без nightAmbient** — тон задаёт только setDayAmbient (тёмный + ambient гасит hemisphere и солнце), иначе лавка светится дневным солнцем; + **пик золотого часа** — nightFactor 0.45 на 18:54 (18 + 0.45×2 ч), не + «полдень заката». Вне скоупа (документировано): туман прудов, тёмные ауры + наката (darkSpot), виньетка, амбиент-слои (вода/гул) — вернуть в треке + полировки или акта 2. ## Известные грабли (уже собранные) diff --git a/v2/apps/game/src/areas.ts b/v2/apps/game/src/areas.ts index fba4d94..926773d 100644 --- a/v2/apps/game/src/areas.ts +++ b/v2/apps/game/src/areas.ts @@ -265,10 +265,10 @@ } /** RLE-пары → плоский массив (недостающие — пепел). */ -function rleDecode(pairs: [number, number][], total: number): number[] { +export function rleDecode(pairs: [number, number][], total: number, fallback = T.ASH): number[] { const out: number[] = []; for (const [id, run] of pairs) for (let k = 0; k < run; k++) out.push(id); - while (out.length < total) out.push(T.ASH); + while (out.length < total) out.push(fallback); return out; } diff --git a/v2/apps/game/src/ash.ts b/v2/apps/game/src/ash.ts new file mode 100644 index 0000000..2f9ddc7 --- /dev/null +++ b/v2/apps/game/src/ash.ts @@ -0,0 +1,59 @@ +/** + * Атмосфера области (v1 Atmosphere, вариант 'ash'): медленные хлопья пепла + * в воздухе вокруг камеры. Voxel-облако (instanced-кубы рендера): поза + * каждого хлопка — детерминированные синусы от времени цикла (гейт-стабильно), + * дрейф по ветру + лёгкое кружение; хлопья всегда в кадре — облако следует + * за целью камеры (v1: частицы в screen-space, v2 — кубы вокруг target). + */ +import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; + +/** Параметры пепла (масштаб мира v2: радиус в вокселях). */ +const COUNT = 36; // хлопков в облаке (v1 ~40 частиц) +const RADIUS = 22; // половина ширины облака вокруг камеры +const TOP = 14; // высота верхнего слоя пепла над землёй +const SPEED = 0.35; // дрейф по ветру (вокс/сек) + +/** Цвет пепла (палитра сцены: P3 — светлый пепел). */ +const ASH_COLOR = '#3e3e48'; + +export class AshParticles { + private readonly cloud: VoxelCloud; + + constructor(renderer: VoxelRenderer) { + this.cloud = renderer.addVoxelCloud([]); + } + + /** Поза облака: цель камеры и время цикла (детерминизм гейтов). */ + update(t: number, target: readonly [number, number, number]): void { + const voxels: { pos: [number, number, number]; color: string }[] = []; + for (let i = 0; i < COUNT; i++) { + // сид хлопка — от индекса; фазы кружения разнесены + const p = i * 2.399; // золотое сечение: равномерный разброс фаз + // базовый дрейф по ветру: хлопок циклично проходит окно вокруг камеры + const drift = (t * SPEED + i * (RADIUS * 2) / COUNT) % (RADIUS * 2); + const x = target[0] - RADIUS + drift; + const z = target[2] - RADIUS + ((i * 7919) % (RADIUS * 2)); + // вертикаль: медленное оседание + лёгкое кружение по горизонтали + const fall = ((t * (0.25 + (i % 5) * 0.06) + (i * 3.7) % TOP) % TOP); + const y = TOP - fall; + voxels.push({ + pos: [ + x + Math.sin(t * 0.7 + p) * 1.5, + y, + z + Math.cos(t * 0.5 + p) * 1.5, + ], + color: ASH_COLOR, + }); + } + this.cloud.update(voxels); + } + + /** Скрыть облако (область без атмосферы). */ + hide(): void { + this.cloud.update([]); + } + + dispose(): void { + this.cloud.dispose(); + } +} \ No newline at end of file diff --git a/v2/apps/game/src/audio.ts b/v2/apps/game/src/audio.ts new file mode 100644 index 0000000..091f2c9 --- /dev/null +++ b/v2/apps/game/src/audio.ts @@ -0,0 +1,55 @@ +/** + * Аудио игры (трек 18): AudioManager движка + темы областей. Тема — процедурная + * партитура (data/music.ts): renderMusicStereo → AudioBuffer → луп на шине + * music с кроссфейдом при смене области (v1 LocationAudio.playTheme). Амбиент + * (климатические лупы v1) — не входит: на лугах тишина (по фидбеку v1), + * шаги и боевые звуки — треки 19+. + */ +import { AudioManager, renderMusicStereo } from '@rpg/engine'; +import type { MusicSpec } from '@rpg/engine'; +import { themeOf } from './data/music'; + +/** Ширина кроссфейда темы (сек) — как fade v1 (2 с лупов амбиента, музыка резче). */ +const THEME_FADE = 1.2; + +export class GameAudio { + readonly manager = new AudioManager((key) => key); + /** Запущенные темы (для моста/гейта: факт запуска видно в шпионе). */ + private current: string | null = null; + /** Кольцевой буфер фактических запусков (мост/гейт — как в демке). */ + readonly log: { key: string; volume: number }[] = []; + + constructor() { + // контекст и шины синхронно: буферы компилируются до первого жеста + this.manager.prepare(); + this.manager.unlock(); // в headless контекст разрешён без жеста + this.manager.onPlayed = (key, opts) => { + this.log.push({ key, volume: opts.volume ?? 1 }); + if (this.log.length > 24) this.log.shift(); + }; + } + + /** Тема области: кроссфейд на шине music; нет темы — тишина (v1: нет theme). */ + async playTheme(areaId: string): Promise { + const spec: MusicSpec | null = themeOf(areaId); + if (spec === null) { + if (this.current !== null) { + this.current = null; + this.manager.stopMusic(THEME_FADE); + } + return; + } + if (this.current === areaId) return; // та же область — не перезапускать + this.current = areaId; + const buf = this.manager.createBuffer(renderMusicStereo(spec), 22050, 2); + if (!buf) return; + await this.manager.playLoopBuffer(`theme/${areaId}`, buf, { + bus: 'music', fade: THEME_FADE, volume: 0.55, + }); + } + + /** Что играет (мост/гейт). */ + get theme(): string | null { + return this.current; + } +} \ No newline at end of file diff --git a/v2/apps/game/src/clock.test.ts b/v2/apps/game/src/clock.test.ts new file mode 100644 index 0000000..dbdf969 --- /dev/null +++ b/v2/apps/game/src/clock.test.ts @@ -0,0 +1,58 @@ +/** Тесты часов суток (перенос v1 clock.test.ts 1:1). */ +import { describe, expect, it } from 'vitest'; +import { DAY_MINUTES, GameClock, START_MINUTES } from './clock'; + +describe('GameClock', () => { + it('старт: 8:00, hours = 8', () => { + const c = new GameClock(); + expect(c.minutes).toBe(480); + expect(c.hours).toBe(8); + expect(c.label).toBe('8:00'); + }); + + it('advance: dtSec секунды = столько же игровых минут', () => { + const c = new GameClock(); + c.advance(60); // 1 реальная минута = 1 игровой час + expect(c.minutes).toBe(540); + expect(c.label).toBe('9:00'); + }); + + it('advance: переход через полночь', () => { + const c = new GameClock(); + c.set(23 * 60 + 50); + c.advance(20); + expect(c.minutes).toBe(10); + expect(c.label).toBe('0:10'); + }); + + it('label: минуты с ведущим нулём, часы без', () => { + const c = new GameClock(); + c.set(13 * 60 + 5); + expect(c.label).toBe('13:05'); + c.set(45); + expect(c.label).toBe('0:45'); + }); + + it('set: mod 1440, отрицательные в диапазон', () => { + const c = new GameClock(); + c.set(1500); + expect(c.minutes).toBe(60); + c.set(-60); + expect(c.minutes).toBe(23 * 60); + }); + + it('reset возвращает к 8:00', () => { + const c = new GameClock(); + c.set(0); + c.reset(); + expect(c.minutes).toBe(START_MINUTES); + expect(START_MINUTES).toBe(480); + expect(DAY_MINUTES).toBe(1440); + }); + + it('hours — float для кривой света', () => { + const c = new GameClock(); + c.set(19 * 60 + 30); + expect(c.hours).toBeCloseTo(19.5, 10); + }); +}); \ No newline at end of file diff --git a/v2/apps/game/src/clock.ts b/v2/apps/game/src/clock.ts new file mode 100644 index 0000000..9642609 --- /dev/null +++ b/v2/apps/game/src/clock.ts @@ -0,0 +1,51 @@ +/** + * Внутреигровое время суток (перенос v1 Clock.ts 1:1). Темп: 1 секунда + * реального времени = 1 игровая минута (сутки = 24 минуты реального). + * Чистый класс без рендера — тестируется в Vitest; живёт поверх цикла + * (области пересоздаются при переходах, часы — нет). + */ + +/** Игровых минут за секунду реального времени. */ +export const GAME_MIN_PER_SEC = 1; +/** Минут в сутках. */ +export const DAY_MINUTES = 1440; +/** Время старта новой игры (8:00). */ +export const START_MINUTES = 8 * 60; + +export class GameClock { + /** Сырые минуты суток (float, 0..1440). */ + private m = START_MINUTES; + + /** Целые минуты суток 0..1439. */ + get minutes(): number { + return Math.floor(this.m) % DAY_MINUTES; + } + + /** Часы суток float 0..24 — вход кривой dayNightFactor. */ + get hours(): number { + return this.m / 60; + } + + /** Строка для часов HUD: «8:00», «13:05» (часы без ведущего нуля). */ + get label(): string { + const m = this.minutes; + const h = Math.floor(m / 60); + const mm = m % 60; + return `${h}:${mm < 10 ? '0' : ''}${mm}`; + } + + /** Продвинуть время на dtSec секунд реального времени. */ + advance(dtSec: number): void { + this.m = (this.m + dtSec * GAME_MIN_PER_SEC) % DAY_MINUTES; + } + + /** Перемотка (мост/загрузка): mod 1440, отрицательные — в диапазон. */ + set(minutes: number): void { + this.m = ((minutes % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; + } + + /** Новая игра: 8:00. */ + reset(): void { + this.set(START_MINUTES); + } +} \ No newline at end of file diff --git a/v2/apps/game/src/data/lighting.ts b/v2/apps/game/src/data/lighting.ts new file mode 100644 index 0000000..6623b30 --- /dev/null +++ b/v2/apps/game/src/data/lighting.ts @@ -0,0 +1,133 @@ +/** + * Освещение и атмосфера областей (перенос v1 locations.ts lighting/atmosphere + * 1:1): ambient-тон (день/ночь/закат) + точечные источники по флагам и времени + * суток. Радиусы — в юнитах v1 (на границе конвертируются: 1 юнит = 3 вокселя). + * Позиции `at` — тайлы v1; `tiles` — свет на каждом тайле заданного id. + */ + +import { FLAGS } from './ids'; + +/** Источник света: точка в тайлах или все тайлы заданного id; радиус в юнитах. */ +export interface LightSourceDef { + id: string; + /** Центр источника в тайлах (центр ромба v1: +0.5). */ + at?: { x: number; y: number }; + /** Альтернатива: свет на каждом тайле этого id (позиции из раскладки). */ + tiles?: number[]; + /** Радиус в юнитах (по умолчанию 3). */ + radius?: number; + /** Цвет свечения (по умолчанию 0xf2b45a — тёплый F1). */ + color?: number; + /** Яркость (по умолчанию 0.9). */ + intensity?: number; + /** Амплитуда мерцания 0..1 (по умолчанию 0 — ровный). */ + flicker?: number; + /** Источник активен только при наличии (whenFlag) / отсутствии (notFlag) флага. */ + whenFlag?: string; + notFlag?: string; + /** Ночной источник: горит при факторе ночи ≥ 0.5 (вечер/ночь по часам). */ + whenNight?: boolean; +} + +/** Освещение области: дневной/ночной ambient + статические источники. */ +export interface LightingDef { + /** Тон области днём (тёмный интерьер гасит hemisphere и солнце). */ + ambient?: number; + /** Ночной ambient (кривая суток); нет — ночь не меняет тон. */ + nightAmbient?: number; + /** Закатный тон (золотой час); нет — движковый DEFAULT_DUSK_COLOR. */ + duskColor?: number; + sources?: LightSourceDef[]; +} + +/** Тайлы раскладки v1 (TILES из data/map.ts v1). */ +const T = { BELLFLOWER: 5 } as const; + +/** Освещение областей по id (области без записи — без своего освещения). */ +export const LIGHTING: Record = { + // Солнечный выжженный день: тёплый почти-белый. Вечером — золотой час + // и синяя ночь: прорези колокольчиков несут свет. + meadows: { + ambient: 0xf7f4ea, + nightAmbient: 0x3e4658, + sources: [ + { + id: 'bells', + tiles: [T.BELLFLOWER], + color: 0xf2cd68, + radius: 1.0, + intensity: 0.65, + flicker: 0.15, + whenNight: true, + }, + ], + }, + // Светлый день у прудов; ночью — глухая синева. + ponds: { ambient: 0xf2f0e8, nightAmbient: 0x444c60 }, + // Светлый тёплый день деревни; вечером — окна трёх домов; после звонка — + // холодный отсвет от башни. + zvenets: { + ambient: 0xf5f2ea, + nightAmbient: 0x454c62, + sources: [ + // Окна — на теле дома; днём не горят, зажигаются вечером. + { id: 'window_elder', at: { x: 12.5, y: 11.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { id: 'window_north', at: { x: 36.5, y: 9.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { id: 'window_shop', at: { x: 42.5, y: 18.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { + id: 'tower_resonance', + at: { x: 28, y: 14 }, + color: 0x4a737f, + radius: 2.5, + intensity: 0.6, + flicker: 0.25, + whenFlag: FLAGS.rang_tower, + }, + ], + }, + rust_grove: { ambient: 0xe9e6da, nightAmbient: 0x39405a }, + old_siding: { ambient: 0xe4e2d8, nightAmbient: 0x343a50 }, + // Под землёй темно; единственный свет — холодное тление устья печи. + machine_hall: { + ambient: 0x3e4252, + sources: [ + { + id: 'machine_mouth', + at: { x: 12.5, y: 1.5 }, + color: 0x4a737f, + radius: 3.5, + intensity: 0.55, + flicker: 0.3, + }, + ], + }, + // Тёмный холодный интерьер; очаг: тлеет, пока квест не закрыт, + // после — горит ровнее и ярче. + house_elder: { + ambient: 0x54586a, + sources: [ + { id: 'hearth_low', at: { x: 8.5, y: 0.5 }, radius: 4, intensity: 0.75, flicker: 0.35, notFlag: FLAGS.quest_bells_done }, + { id: 'hearth_high', at: { x: 8.5, y: 0.5 }, radius: 5, intensity: 1.05, flicker: 0.2, whenFlag: FLAGS.quest_bells_done }, + ], + }, + // Полутёмная лавка: лампа над прилавком. + shop: { + ambient: 0x5a5e70, + sources: [{ id: 'counter_lamp', at: { x: 5.5, y: 3.5 }, radius: 3, intensity: 0.7, flicker: 0.05 }], + }, +}; + +/** Атмосферные частицы области (v1 atmosphere; fog прудов — вне скоупа v2). */ +export type Atmosphere = 'ash' | 'fog' | 'none'; + +/** Атмосфера по id: пепел на улицах, интерьеры — чистые. */ +export const ATMOSPHERE: Record = { + meadows: 'ash', + ponds: 'fog', + zvenets: 'ash', + rust_grove: 'ash', + old_siding: 'ash', + machine_hall: 'none', + house_elder: 'none', + shop: 'none', +}; \ No newline at end of file diff --git a/v2/apps/game/src/data/music.ts b/v2/apps/game/src/data/music.ts new file mode 100644 index 0000000..9d50ee2 --- /dev/null +++ b/v2/apps/game/src/data/music.ts @@ -0,0 +1,83 @@ +/** + * Музыкальные темы (перенос v1 data/music.ts): генератор партитур + * (детерминированный, сиды) + компиляция движковым секвенсором + * (renderMusicStereo → playLoopBuffer) прямо в рантайме — WAV-файлов нет. + * Тема лугов — пепельная ля-минорная пентатоника: пэд-дрон + редкая + * плак-мелодия восьмыми. + */ +import { Rng, type MusicSpec, type NoteEvent } from '@rpg/engine'; + +/** Ля-минорная пентатоника от A3 (A C D E G + октава). */ +const PENTATONIC = [57, 60, 62, 64, 67, 69, 72] as const; + +/** Дрон-пэд: тоника/субмедианта по половинам лупа. */ +function drone(from: number, to: number, beats: number): NoteEvent[] { + return [ + { beat: 0, dur: beats / 2, midi: from, vel: 0.5 }, + { beat: beats / 2, dur: beats / 2, midi: to, vel: 0.5 }, + ]; +} + +/** Мелодия восьмыми с паузами (вероятность ноты 0.55) — сид фиксирует рисунок. */ +function melody(seed: number, beats: number): NoteEvent[] { + const rng = new Rng(seed); + const notes: NoteEvent[] = []; + for (let eighth = 0; eighth < beats * 2; eighth++) { + if (!rng.chance(0.55)) continue; + notes.push({ + beat: eighth * 0.5, + dur: 0.5, + midi: PENTATONIC[rng.int(PENTATONIC.length)]!, + vel: 0.35 + rng.next() * 0.25, + }); + } + return notes; +} + +/** Тема лугов: 16 долей на 70 bpm, дрон A2→G2 + плак по пентатонике (стерео). */ +export const MEADOW_THEME: MusicSpec = { + bpm: 70, + beats: 16, + tracks: [ + { instrument: 'pad', notes: drone(45, 43, 16), gain: 0.9, pan: -0.3 }, + { instrument: 'pluck', notes: melody(20260907, 16), pan: 0.3 }, + ], +}; + +/** Ритмичный рифф боя: восьмые с пропусками, низкая пентатоника. */ +function riff(seed: number, beats: number): NoteEvent[] { + const rng = new Rng(seed); + const low = [45, 48, 50, 52, 55] as const; // A2-минорная низь + const notes: NoteEvent[] = []; + for (let eighth = 0; eighth < beats * 2; eighth++) { + if (!rng.chance(0.7)) continue; + notes.push({ + beat: eighth * 0.5, + dur: 0.5, + midi: low[rng.int(low.length)]!, + vel: 0.5 + rng.next() * 0.3, + }); + } + return notes; +} + +/** Тема боя: 112 bpm, глухой дрон A1 + настойчивый низкий рифф. */ +export const COMBAT_THEME: MusicSpec = { + bpm: 112, + beats: 8, + tracks: [ + { instrument: 'pad', notes: drone(33, 33, 8), gain: 1, pan: 0 }, + { instrument: 'pluck', notes: riff(20260908, 8), pan: -0.2 }, + ], +}; + +/** Реестр тем (ключ → спек); область ссылается на тему полем `theme`. */ +export const THEMES: Record = { + meadows: MEADOW_THEME, + combat: COMBAT_THEME, +}; + +/** Тема области по ключу (нет ключа или нет спека — тишина). */ +export function themeOf(area: string): MusicSpec | null { + return THEMES[area] ?? null; +} \ No newline at end of file diff --git a/v2/apps/game/src/lighting.ts b/v2/apps/game/src/lighting.ts new file mode 100644 index 0000000..da23908 --- /dev/null +++ b/v2/apps/game/src/lighting.ts @@ -0,0 +1,199 @@ +/** + * Игровая обвязка освещения (перенос v1 Lighting.ts на v2-рендер): ночная + * кривая ambient (setNight), точечные источники области по флагам и времени + * суток (three.js PointLight через renderer.addPointLight) и лампа героя + * в тёмных областях/ночью. Источники — diff по id каждый тик; мерцание — + * детерминированный flickerFactor от времени цикла. + */ +import { dayNightAmbient, dayNightFactor, flickerFactor } from '@rpg/engine'; +import type { PointLightHandle, VoxelRenderer } from '@rpg/engine'; +import { UNIT, GROUND_Y, tileCenter } from './areas'; +import type { LightingDef, LightSourceDef } from './data/lighting'; + +/** Лампа героя (следует за героем в тёмных областях и ночью). */ +export interface HeroLampDef { + color: number; + radius: number; // юниты v1 + intensity: number; + flicker: number; +} + +/** Кап активных источников — защита от опечатки в данных (tiles одного id). */ +export const MAX_LIGHTS = 24; + +/** Порог ночи для ночных источников и лампы (≈ 19:00–07:00). */ +const NIGHT_SOURCE_FACTOR = 0.5; + +/** Считается ли область тёмной для лампы (та же эвристика, что v1). */ +function isDarkAmbient(ambient: number | undefined): boolean { + if (ambient === undefined) return false; + const r = (ambient >> 16) & 0xff; + const g = (ambient >> 8) & 0xff; + const b = ambient & 0xff; + return (r * 0.3 + g * 0.6 + b * 0.1) / 255 < 0.75; +} + +/** Детерминированная фаза мерцания по id: снапшот воспроизводим. */ +function seedOf(id: string): number { + let h = 2166136261; + for (let i = 0; i < id.length; i++) { + h ^= id.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return ((h >>> 0) % 1000) / 1000; +} + +export interface GameLightingDeps { + renderer: VoxelRenderer; + /** Освещение области (нет — нейтральный свет по умолчанию). */ + lighting: LightingDef | undefined; + /** Позиции тайлов по id раскладки (источники `tiles`; один проход по карте). */ + layerTilePos: Map; + /** Позиция героя в вокселях (для лампы). */ + heroPos: () => { x: number; z: number }; + hasFlag: (flag: string) => boolean; + /** Время суток в часах (GameClock.hours) — вход кривой дня. */ + timeHours: () => number; + /** Время цикла в секундах (тик/60) — детерминизм мерцания в гейтах. */ + timeSeconds: () => number; + /** Лампа героя (v1 HERO_LAMP); null — без лампы. */ + lamp?: HeroLampDef | null; +} + +/** Снапшот для моста: фактор ночи + активные источники (пост-мерцание). */ +export interface LightingSnapshot { + nightFactor: number; + sources: { id: string; x: number; y: number; intensity: number }[]; +} + +export class GameLighting { + private readonly deps: GameLightingDeps; + /** Активные источники прошлого тика (diff: снять погасшие по флагам). */ + private readonly handles = new Map(); + + constructor(deps: GameLightingDeps) { + this.deps = deps; + this.applyDayAmbient(); + this.update(); // вход в область сразу при нужном свете (без мелькания дня) + } + + /** Дневной базовый тон области: тёмный интерьер гасит hemisphere и солнце. */ + private applyDayAmbient(): void { + const ambient = this.deps.lighting?.ambient; + this.deps.renderer.setDayAmbient( + ambient !== undefined && isDarkAmbient(ambient) ? ambient : 0x94949e, + ); + } + + /** Текущий фактор ночи 0..1 (округлён — для снапшота). */ + get nightFactor(): number { + return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000; + } + + /** Тик: ночная цель ambient + источники (diff по id, мерцание). */ + update(): void { + const light = this.deps.lighting; + const k = this.nightFactor; + // цель ambient: микс день → золотой час → ночь (рассвет тем же тёплым) + const tint = + light?.ambient !== undefined && light?.nightAmbient !== undefined + ? dayNightAmbient(light.ambient, light.nightAmbient, k, light.duskColor) + : light?.ambient ?? 0x94949e; + this.deps.renderer.setNight(k, tint); + + const next = new Set(); + for (const def of this.activeDefs(k)) { + for (const pos of this.positions(def)) { + if (next.size >= MAX_LIGHTS) break; + const key = def.tiles ? `${def.id}@${pos.x},${pos.y}` : def.id; + next.add(key); + const c = tileCenter(pos.x, pos.y); + const flicker = def.flicker ?? 0; + const intensity = + (def.intensity ?? 0.9) * flickerFactor(this.deps.timeSeconds(), seedOf(key), flicker, 0.9); + const existing = this.handles.get(key); + if (existing) { + existing.handle.setIntensity(intensity); + existing.handle.setPos([c.x, GROUND_Y + 2, c.z]); // лампа героя едет + existing.intensity = intensity; + } else { + this.handles.set(key, { + handle: this.deps.renderer.addPointLight({ + pos: [c.x, GROUND_Y + 2, c.z], + color: def.color ?? 0xf2b45a, + intensity, + radius: (def.radius ?? 3) * UNIT, + }), + intensity, + }); + } + } + } + for (const [key, entry] of this.handles) { + if (!next.has(key)) { + entry.handle.dispose(); + this.handles.delete(key); + } + } + } + + /** Состояние для агентного снапшота (интенсивность — пост-мерцание). */ + snapshot(): LightingSnapshot { + return { + nightFactor: this.nightFactor, + sources: [...this.handles.entries()].map(([key, entry]) => { + const [id, at] = key.split('@'); + const [tx, ty] = at ? at.split(',').map(Number) : [null, null]; + return { + id: id!, + x: tx ?? -1, + y: ty ?? -1, + intensity: Math.round(entry.intensity * 1000) / 1000, + }; + }), + }; + } + + destroy(): void { + for (const h of this.handles.values()) h.handle.dispose(); + this.handles.clear(); + } + + // --- внутреннее --- + + /** Активные def области (фильтры флагов/ночи) + лампа героя. */ + private activeDefs(night: number): LightSourceDef[] { + const out: LightSourceDef[] = []; + for (const def of this.deps.lighting?.sources ?? []) { + if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue; + if (def.notFlag && this.deps.hasFlag(def.notFlag)) continue; + if (def.whenNight && night < NIGHT_SOURCE_FACTOR) continue; + out.push(def); + } + const lamp = this.deps.lamp; + if (lamp && (isDarkAmbient(this.deps.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR)) { + const hero = this.deps.heroPos(); + out.push({ + id: 'hero_lamp', + // позиция в тайлах (tileCenter конвертирует обратно): центр героя + at: { x: (hero.x - UNIT / 2) / UNIT, y: (hero.z - UNIT / 2) / UNIT }, + radius: lamp.radius, + color: lamp.color, + intensity: lamp.intensity, + flicker: lamp.flicker, + }); + } + return out; + } + + /** Позиции источника в тайлах: точка at или все тайлы заданных id. */ + private positions(def: LightSourceDef): { x: number; y: number }[] { + if (def.at) return [def.at]; + const out: { x: number; y: number }[] = []; + for (const id of def.tiles ?? []) { + const positions = this.deps.layerTilePos.get(id); + if (positions) out.push(...positions); + } + return out; + } +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 9ccab8e..ab4d3d7 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -16,7 +16,7 @@ DialogueEffectOp, Interactable, } from '@rpg/engine'; import * as THREE from 'three'; -import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter } from './areas'; +import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode } from './areas'; import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas'; import { GameState } from './state'; import { DIALOGUES } from './data/dialogues'; @@ -27,6 +27,11 @@ import { type InteractableDef } from './data/interactables'; import { stampProp, clearProp } from './props'; import { FlowerQuest } from './flowers'; +import { GameClock } from './clock'; +import { LIGHTING, ATMOSPHERE, type LightingDef } from './data/lighting'; +import { GameLighting, type HeroLampDef } from './lighting'; +import { AshParticles } from './ash'; +import { GameAudio } from './audio'; // Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17) const PALETTE = { @@ -98,6 +103,35 @@ const inv = new Inventory({ maxSlots: 8 }); const invWorld = { hasItem: (id: string) => inv.has(id) }; +// ---- Время суток: 1 сек реального = 1 игровая минута (сутки 24 мин) ---- +const clock = new GameClock(); +/** Время цикла в секундах (тик/60) — детерминизм мерцания/пепла в гейтах. */ +let elapsed = 0; + +// ---- Освещение и атмосфера (трек 18): области задают тон и источники ---- +const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.0, intensity: 1.0, flicker: 0.12 }; +let lighting: GameLighting | null = null; +const ash = new AshParticles(renderer); +/** Глобальный вкл/выкл пепла (мост: гейт кадр-равенства). */ +let ashEnabled = true; +let areaAsh = false; // играет ли пепел в текущей области +const audio = new GameAudio(); + +/** Позиции тайлов раскладки по id (источники `tiles`: колокольчики и пр.). */ +function layerTilePos(): Map { + const l = layout; + const tiles = rleDecode(l.tiles, l.width * l.height); + const out = new Map(); + for (let ty = 0; ty < l.height; ty++) + for (let tx = 0; tx < l.width; tx++) { + const t = tiles[ty * l.width + tx]!; + let list = out.get(t); + if (!list) out.set(t, (list = [])); + list.push({ x: tx, y: ty }); + } + return out; +} + // ---- NPC: статичные по области + размещения по флагам (Клинт) ---- const NPC_RADIUS = 5; // радиус подхода (v1: 1.5 юнита = 4.5 вокселя, с запасом) interface NpcEntity { @@ -182,7 +216,7 @@ if (name === 'lull_machine') showToast('Машина дышит тише. Впервые за годы — тише.'); }; -/** Смена области: новый мир + мешы, герой на входном тайле, NPC заново. */ +/** Смена области: новый мир + мешы, герой на входном тайле, NPC, свет, музыка. */ function setArea(id: string, entry: Tile): void { currentArea = areaOf(id); layout = AREA_LAYOUTS[currentArea.id]!; @@ -204,6 +238,21 @@ nearFlower = null; inHazard = null; showHint(''); + // свет области заново (источники/тон), пепел по атмосфере, тема с кроссфейдом + lighting?.destroy(); + lighting = new GameLighting({ + renderer, + lighting: LIGHTING[currentArea.id] as LightingDef | undefined, + layerTilePos: layerTilePos(), + heroPos: () => ({ x: hero.body.pos[0], z: hero.body.pos[2] }), + hasFlag: (f) => state.hasFlag(f), + timeHours: () => clock.hours, + timeSeconds: () => elapsed, + lamp: HERO_LAMP, + }); + areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; + if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется + void audio.playTheme(currentArea.id); renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]); } @@ -544,8 +593,13 @@ /** Радиус взаимодействия с интерактивами/цветками: 1.5 юнита v1 = 4.5 вокселя. */ const INTERACT_RANGE = 4.5; -/** Один шаг логики: фейд → катсцена → движение/накат → цели «E» → подсказка. */ +/** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */ function tick(dt: number): void { + // время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1) + clock.advance(dt); + elapsed += dt; + lighting?.update(); + if (areaAsh) ash.update(elapsed, renderer.currentTarget()); if (fade.active) { fade.t += dt; if (fade.phase === 'out') { @@ -732,6 +786,36 @@ setSun(k: number): void { renderer.setSun(k); }, + /** Время суток: снапшот, перемотка (гейт золотого часа/ночи). */ + clockInfo() { + return { + minutes: clock.minutes, + hours: Math.round(clock.hours * 1000) / 1000, + label: clock.label, + nightFactor: lighting?.nightFactor ?? 0, + }; + }, + setClock(minutes: number): void { + clock.set(minutes); + lighting?.update(); // свет пересчитать сразу, не дожидаясь тика + }, + /** Снапшот освещения: ночь + активные источники (пост-мерцание). */ + lightingInfo(): ReturnType { + return lighting?.snapshot() ?? { nightFactor: 0, sources: [] }; + }, + /** Снапшот аудио: тема области + кольцевой буфер фактических запусков. */ + audioInfo() { + return { + theme: audio.theme, + played: audio.log, + }; + }, + /** Пепел вкл/выкл (гейт: кадр-равенство сборки — мир без атмосферы). */ + setAsh(on: boolean): void { + ashEnabled = on; + areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash'; + if (!areaAsh) ash.hide(); + }, /** Снапшот области: раскладка, переходы, фейд, стек возврата, меши. */ areaInfo() { return { diff --git a/v2/apps/game/tools/checks/areas.mjs b/v2/apps/game/tools/checks/areas.mjs index 19687c8..1d24a60 100644 --- a/v2/apps/game/tools/checks/areas.mjs +++ b/v2/apps/game/tools/checks/areas.mjs @@ -85,6 +85,9 @@ await client.call('setManual', true); await client.call('stepTicks', 10); + // пепел — живая система (дрейф с тиками): в этом гейте мир без атмосферы + await client.call('setAsh', false); + await client.call('stepTicks', 1); // перерисовать кадр без пепла // Старт: луга, герой на спавне, меши собраны let ai = await client.call('areaInfo'); diff --git a/v2/apps/game/tools/checks/atmosphere.mjs b/v2/apps/game/tools/checks/atmosphere.mjs new file mode 100644 index 0000000..e66f467 --- /dev/null +++ b/v2/apps/game/tools/checks/atmosphere.mjs @@ -0,0 +1,159 @@ +/** + * Гейт атмосферы v2 (трек 18 «время суток, освещение, музыка»): часы суток + * (1 сек = 1 игровая минута), кривая ночи по часам, точечные источники + * по флагам/времени/области (bells, окна, очаг, лампа героя), мерцание, + * пепел как живой слой кадра, тема области с кроссфейдом. Скриншоты: + * /tmp/v2_atmosphere_*.png. + * + * Запуск: node apps/game/tools/checks/atmosphere.mjs [--port 5320] + */ +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 ?? 5320); + +const server = startDevServer({ port, cwd: fileURLToPath(new URL('../../', import.meta.url)) }); +const report = { name: 'atmosphere', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Средняя яркость кадра 0..255 (день/ночь сравнение). */ +function brightness(rgba) { + let sum = 0, n = 0; + for (let i = 0; i < rgba.length; i += 4) { + sum += rgba[i] * 0.3 + rgba[i + 1] * 0.6 + rgba[i + 2] * 0.1; + n++; + } + return sum / n; +} + +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; +} + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + 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('setClock', 480); // страница грузилась в реальном времени + await client.call('stepTicks', 5); + + // ---- Часы суток ---- + let ci = await client.call('clockInfo'); + check('старт: 8:00, ночь 0 (день)', ci.label === '8:00' && ci.minutes === 480 && ci.nightFactor === 0, ci); + + await client.call('setClock', 1140); // 19:00 — половина заката + ci = await client.call('clockInfo'); + check('19:00 — фактор ночи 0.5 (золотой час)', ci.label === '19:00' && Math.abs(ci.nightFactor - 0.5) < 0.01, ci); + + await client.call('setClock', 1134); // 18:54 → пик золотого часа (0.45) + ci = await client.call('clockInfo'); + check('18:54 — пик золотого часа 0.45', Math.abs(ci.nightFactor - 0.45) < 0.01, ci); + + await client.call('setClock', 1200); // 20:00 — ночь + ci = await client.call('clockInfo'); + check('20:00 — ночь (фактор 1)', ci.label === '20:00' && ci.nightFactor === 1, ci); + + await client.call('setClock', 1495); // > суток: mod 1440 → 0:55 + ci = await client.call('clockInfo'); + check('перемотка mod 1440: 0:55', ci.label === '0:55' && ci.minutes === 55, ci); + + // ---- Ночные источники лугов (bells) + лампа героя ---- + await client.call('setClock', 480); // назад в день + let li = await client.call('lightingInfo'); + check('день на лугах: источников нет (bells whenNight)', li.sources.length === 0, li); + + await client.call('setClock', 1200); // ночь + li = await client.call('lightingInfo'); + check('ночь на лугах: колокольчики светятся (bells@тайлы)', + li.sources.some((s) => s.id === 'bells' && s.intensity > 0), li); + check('ночь: лампа героя у героя', li.sources.some((s) => s.id === 'hero_lamp'), li); + + // Мерцание: интенсивность bells меняется со временем (flicker 0.15) + const i0 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1; + let i1 = i0; + for (let k = 0; k < 4 && i1 === i0; k++) { + await client.call('stepTicks', 9); + li = await client.call('lightingInfo'); + i1 = li.sources.find((s) => s.id === 'bells')?.intensity ?? -1; + } + check('мерцание: интенсивность пульсирует', i1 !== i0, { i0, i1 }); + + // ---- Кадр дня против ночи (фон и свет гаснут) ---- + await client.call('setFollow', false); + await client.call('setClock', 720); // полдень + await client.call('stepTicks', 1); + const dayShot = await client.screenshot('/tmp/v2_atmosphere_day.png'); + await client.call('setClock', 1320); // 22:00 — глухая ночь + await client.call('stepTicks', 1); + const nightShot = await client.screenshot('/tmp/v2_atmosphere_night.png'); + const dayBright = brightness(decodePng(readFileSync(dayShot)).rgba); + const nightBright = brightness(decodePng(readFileSync(nightShot)).rgba); + check('кадр ночи темнее кадра дня', nightBright < dayBright * 0.85, { dayBright, nightBright }); + + // ---- Пепел: живой слой кадра ---- + await client.call('setClock', 720); + await client.call('setAsh', true); + await client.call('stepTicks', 1); + const shotNoAsh = await client.screenshot('/tmp/v2_atmosphere_ash0.png'); + await client.call('setAsh', true); + await client.call('stepTicks', 40); // пепел дрейфует с тиками + const shotAsh = await client.screenshot('/tmp/v2_atmosphere_ash1.png'); + const ashDiff = countDiff(decodePng(readFileSync(shotNoAsh)).rgba, decodePng(readFileSync(shotAsh)).rgba); + check('пепел дрейфует: кадры различаются', ashDiff > 100, { ashDiff }); + + // ---- Источники по флагам: очаг, башня ---- + await client.call('setAsh', false); + await client.call('gotoArea', 'house_elder'); + li = await client.call('lightingInfo'); + check('очаг до сдачи: hearth_low тлеет', li.sources.some((s) => s.id === 'hearth_low'), li); + await client.call('setState', { flags: ['quest_bells_done'] }); + await client.call('stepTicks', 1); + li = await client.call('lightingInfo'); + check('после сдачи: hearth_high горит, hearth_low погас', + li.sources.some((s) => s.id === 'hearth_high') && !li.sources.some((s) => s.id === 'hearth_low'), li); + + await client.call('gotoArea', 'zvenets'); + await client.call('stepTicks', 1); + li = await client.call('lightingInfo'); + check('башня без звонка: отсвета нет', !li.sources.some((s) => s.id === 'tower_resonance'), li); + await client.call('setState', { flags: ['rang_tower'] }); + await client.call('stepTicks', 1); + li = await client.call('lightingInfo'); + check('после звонка: холодный отсвет башни', li.sources.some((s) => s.id === 'tower_resonance'), li); + + // ---- Тема области ---- + await client.call('gotoArea', 'meadows'); // назад на луга (тема области) + await client.call('stepTicks', 1); + let ai = await client.call('audioInfo'); + check('луга: тема meadows играет', ai.theme === 'meadows' && ai.played.some((p) => p.key === 'theme/meadows'), ai); + await client.call('gotoArea', 'house_elder'); + await client.call('stepTicks', 1); + ai = await client.call('audioInfo'); + check('интерьер: темы нет (тишина, как в v1)', ai.theme === null, ai); + } 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 diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index d4fbb7d..1b0aa89 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -89,3 +89,10 @@ export { renderMusic, renderMusicStereo } from './audio/Music'; export type { MusicSpec, TrackSpec, NoteEvent, InstrumentKind } from './audio/Music'; export { BufferCache } from './audio/decode'; +export { + DEFAULT_DAY_NIGHT, DEFAULT_DUSK_COLOR, DUSK_PEAK, + dayNightAmbient, dayNightFactor, dimColor, flickerFactor, lerpAmbient, lerpColor, +} from './render/daynight'; +export type { DayNightSpec } from './render/daynight'; +export type { PointLightOptions, PointLightHandle } from './render/voxelRenderer'; +// setDayAmbient/setNight/addPointLight — методы класса VoxelRenderer diff --git a/v2/packages/engine/src/render/__tests__/daynight.test.ts b/v2/packages/engine/src/render/__tests__/daynight.test.ts new file mode 100644 index 0000000..eab5983 --- /dev/null +++ b/v2/packages/engine/src/render/__tests__/daynight.test.ts @@ -0,0 +1,87 @@ +/** Тесты чистой математики суток (перенос релевантных проверок v1 lightSim). */ +import { describe, expect, it } from 'vitest'; +import { + DUSK_PEAK, DEFAULT_DUSK_COLOR, dayNightAmbient, dayNightFactor, + dimColor, flickerFactor, lerpAmbient, lerpColor, +} from '../daynight'; + +describe('lerpColor/lerpAmbient', () => { + it('краи и середина по каналам', () => { + expect(lerpColor(0x000000, 0xffffff, 0)).toBe(0x000000); + expect(lerpColor(0x000000, 0xffffff, 1)).toBe(0xffffff); + expect(lerpAmbient(0x102030, 0x304050, 0.5)).toBe(0x203040); + }); +}); + +describe('dayNightAmbient (золотой час)', () => { + it('краи: k=0 — день, k=1 — ночь', () => { + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, 0)).toBe(0xf7f4ea); + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, 1)).toBe(0x3e4658); + }); + it('на пике (k = DUSK_PEAK) ambient равен закатному тону', () => { + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK)).toBe(DEFAULT_DUSK_COLOR); + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK, 0xffcc88)).toBe(0xffcc88); + }); + it('до пика — лерп день→закат, после — закат→ночь', () => { + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK / 2)).toBe( + lerpAmbient(0xf7f4ea, DEFAULT_DUSK_COLOR, 0.5), + ); + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + (1 - DUSK_PEAK) / 2)).toBe( + lerpAmbient(DEFAULT_DUSK_COLOR, 0x3e4658, 0.5), + ); + }); + it('непрерывен и монотонен по k (день светлее ночи по R)', () => { + let prevR = 256; + for (let k = 0; k <= 1.0001; k += 0.01) { + const r = (dayNightAmbient(0xf7f4ea, 0x3e4658, k) >> 16) & 0xff; + expect(r).toBeLessThanOrEqual(prevR); + prevR = r; + } + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK - 0.001)).toBeCloseTo( + dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + 0.001), 1, + ); + }); +}); + +describe('dayNightFactor', () => { + it('день (8..18) — 0, ночь (20..6) — 1', () => { + expect(dayNightFactor(12)).toBe(0); + expect(dayNightFactor(0)).toBe(1); + expect(dayNightFactor(23)).toBe(1); + expect(dayNightFactor(5.5)).toBe(1); + }); + it('переходы линейны: 7:00 — половина рассвета, 19:00 — половина заката', () => { + expect(dayNightFactor(7)).toBeCloseTo(0.5); + expect(dayNightFactor(19)).toBeCloseTo(0.5); + expect(dayNightFactor(18.5)).toBeCloseTo(0.25); + }); + it('непрерывен на стыке 24/0', () => { + expect(dayNightFactor(24)).toBe(dayNightFactor(0)); + }); +}); + +describe('dimColor', () => { + it('краи: полная яркость и ноль', () => { + expect(dimColor(0x54586a, 1)).toBe(0x54586a); + expect(dimColor(0x54586a, 0)).toBe(0x000000); + }); +}); + +describe('flickerFactor', () => { + it('детерминирован: одинаковые (time, seed) — одинаковый результат', () => { + expect(flickerFactor(2.5, 0.3, 0.4, 0.9)).toBe(flickerFactor(2.5, 0.3, 0.4, 0.9)); + }); + it('разные seed дают разные значения', () => { + expect(flickerFactor(1, 0.1, 0.5, 0.9)).not.toBe(flickerFactor(1, 0.9, 0.5, 0.9)); + }); + it('в диапазоне [1-amount, 1] на сетке времени', () => { + for (let t = 0; t < 10; t += 0.05) { + const f = flickerFactor(t, 0.42, 0.35, 0.9); + expect(f).toBeLessThanOrEqual(1); + expect(f).toBeGreaterThanOrEqual(1 - 0.35 - 1e-9); + } + }); + it('amount = 0 — ровный свет', () => { + expect(flickerFactor(3.7, 0.5, 0, 0.9)).toBe(1); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/render/daynight.ts b/v2/packages/engine/src/render/daynight.ts new file mode 100644 index 0000000..1383dd1 --- /dev/null +++ b/v2/packages/engine/src/render/daynight.ts @@ -0,0 +1,86 @@ +/** + * Чистая математика суток (перенос v1 lightSim): фактор ночи по часам, + * трёхопорный ambient-микс (день → золотой час → ночь), лерп/затемнение + * цвета и детерминированное мерцание источников. Без three и рендера — + * тестируется в Vitest. + */ + +/** Лерп цвета 0xRRGGBB по каналам: t=0 → a, t=1 → b. */ +export function lerpColor(a: number, b: number, t: number): number { + const ar = (a >> 16) & 0xff; + const ag = (a >> 8) & 0xff; + const ab = a & 0xff; + const br = (b >> 16) & 0xff; + const bg = (b >> 8) & 0xff; + const bb = b & 0xff; + const r = Math.round(ar + (br - ar) * t); + const g = Math.round(ag + (bg - ag) * t); + const bl = Math.round(ab + (bb - ab) * t); + return (r << 16) | (g << 8) | bl; +} + +/** Лерп ambient-цвета (алиас lerpColor — имя v1). */ +export const lerpAmbient = lerpColor; + +/** Яркость ambient-цвета: dimColor(c, 1) === c, dimColor(c, 0) === 0x000000. */ +export function dimColor(color: number, brightness: number): number { + const k = Math.max(0, Math.min(1, brightness)); + const r = Math.round(((color >> 16) & 0xff) * k); + const g = Math.round(((color >> 8) & 0xff) * k); + const b = Math.round((color & 0xff) * k); + return (r << 16) | (g << 8) | b; +} + +/** Границы суток в часах: рассвет dawn..day, закат dusk..night. */ +export interface DayNightSpec { + dawn: number; + day: number; + dusk: number; + night: number; +} + +/** Дефолтная кривая игры: рассвет 06–08, закат 18–20, ночь 20–06. */ +export const DEFAULT_DAY_NIGHT: DayNightSpec = { dawn: 6, day: 8, dusk: 18, night: 20 }; + +/** Доля перехода день→ночь, на которой пик золотого часа. */ +export const DUSK_PEAK = 0.45; + +/** Дефолтный закатный тон (тёпло-золотой) — игра может переопределить. */ +export const DEFAULT_DUSK_COLOR = 0xe8c090; + +/** + * Трёхопорный микс суток по фактору ночи k: день → золотой час → ночь + * (рассвет проходит через тот же тёплый тон — это ожидаемо). + */ +export function dayNightAmbient( + day: number, + night: number, + k: number, + duskColor: number = DEFAULT_DUSK_COLOR, +): number { + if (k <= 0) return day; + if (k >= 1) return night; + if (k < DUSK_PEAK) return lerpAmbient(day, duskColor, k / DUSK_PEAK); + return lerpAmbient(duskColor, night, (k - DUSK_PEAK) / (1 - DUSK_PEAK)); +} + +/** Фактор ночи 0..1 по часам (mod 24): 0 — день, 1 — ночь, линейные переходы. */ +export function dayNightFactor(tHours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): number { + const t = ((tHours % 24) + 24) % 24; + if (t >= spec.night || t < spec.dawn) return 1; + if (t < spec.day) return 1 - (t - spec.dawn) / (spec.day - spec.dawn); + if (t < spec.dusk) return 0; + return (t - spec.dusk) / (spec.night - spec.dusk); +} + +/** + * Детерминированное мерцание: сумма двух синусов с фазой от seed. + * Одинаковые (time, seed) — одинаковый результат; диапазон [1-amount, 1]. + */ +export function flickerFactor(time: number, seed: number, amount: number, period: number): number { + if (amount <= 0) return 1; + const p = period > 0 ? period : 0.9; + const f = time / p + seed * Math.PI * 2; + const wave = (Math.sin(f) + Math.sin(f * 2.37 + seed * 5)) * 0.5; // -1..1 + return 1 - amount * (wave * 0.5 + 0.5); +} \ 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 f89f4a4..bfbc53d 100644 --- a/v2/packages/engine/src/render/voxelRenderer.ts +++ b/v2/packages/engine/src/render/voxelRenderer.ts @@ -10,6 +10,7 @@ import * as THREE from 'three'; import type { VoxelMesh } from '../voxel/mesher'; import type { PosedVoxel } from '../animation/rig'; +import { lerpColor } from './daynight'; /** Облако деформированных вокселей (позы рига — не выровнены по сетке). */ export interface VoxelCloud { @@ -19,6 +20,24 @@ mesh: THREE.InstancedMesh; } +/** Параметры точечного источника света (окна, очаг, лампа героя). */ +export interface PointLightOptions { + /** Позиция в мировых единицах. */ + pos: [number, number, number]; + color: number; + /** Яркость 0..~1.5 (множитель движка). */ + intensity: number; + /** Радиус действия в мировых единицах (дальше — гаснет). */ + radius: number; +} + +/** Ручка точечного источника: игра меняет яркость (мерцание) сама. */ +export interface PointLightHandle { + setIntensity(intensity: number): void; + setPos(pos: [number, number, number]): void; + dispose(): void; +} + export interface VoxelRendererOptions { /** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */ width?: number; @@ -42,6 +61,11 @@ private readonly target: THREE.Vector3; private readonly viewSize: number; private sunK = 0.5; + private nightK = 0; + /** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */ + private dayHemisphere = 0x94949e; // P5 + /** Дневной множитель солнца (тёмный интерьер гасит и прямое солнце). */ + private daySunScale = 1; constructor(canvas: HTMLCanvasElement, opts: VoxelRendererOptions = {}) { const width = opts.width ?? 480; @@ -88,6 +112,52 @@ return [this.target.x, this.target.y, this.target.z]; } + /** + * Дневной базовый тон области: цвет неба hemisphere днём (улицы — P5, + * тёмные интерьеры — их ambient) + дневной множитель солнца по яркости + * (аналог multiply-ambient v1: тёмная лавка гасит и прямое солнце). + */ + setDayAmbient(color: number): void { + this.dayHemisphere = color & 0xffffff; + const r = (color >> 16) & 0xff; + const g = (color >> 8) & 0xff; + const b = color & 0xff; + const lum = (r * 0.3 + g * 0.6 + b * 0.1) / 255; + this.daySunScale = Math.min(1, Math.max(0, lum * 1.6)); + } + + /** + * Ночь: k — фактор ночи 0..1 (кривая суток игры), tint — целевой + * ambient-цвет области (микс день/закат/ночь, кривая dayNightAmbient). + * Гасит солнце и сдвигает дневное небо hemisphere к tint; фон сцены — tint. + */ + setNight(k: number, tint: number): void { + this.nightK = Math.min(1, Math.max(0, k)); + this.ambient.color.set(lerpColor(this.dayHemisphere, tint & 0xffffff, this.nightK)); + this.setSun(this.sunK); // солнце пересчитает себя с учётом nightK + if (!this.scene.background) this.scene.background = new THREE.Color(); + (this.scene.background as THREE.Color).set(tint & 0xffffff); + } + + /** Точечный источник (не бросает тень): мерцание — через setIntensity. */ + addPointLight(opts: PointLightOptions): PointLightHandle { + const light = new THREE.PointLight(opts.color, opts.intensity, opts.radius * 2, 1.6); + light.position.set(...opts.pos); + this.scene.add(light); + return { + setIntensity: (i) => { + light.intensity = i; + }, + setPos: (p) => { + light.position.set(p[0], p[1], p[2]); + }, + dispose: () => { + this.scene.remove(light); + light.dispose(); + }, + }; + } + /** Материал вокселей: вершинные цвета (палитра × AO) + ламберт от солнца. */ static material(): THREE.MeshLambertMaterial { return new THREE.MeshLambertMaterial({ vertexColors: true }); @@ -169,7 +239,9 @@ this.target.z + dist * Math.sin(az) * Math.cos(elev), ); this.sun.target.position.copy(this.target); - this.sun.intensity = 0.4 + 1.1 * Math.sin(kk * Math.PI); + // ночь гасит солнце до остаточной засветки (луна/небо), тёмный интерьер — и днём + this.sun.intensity = + (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK) * this.daySunScale; // тёплый свет у горизонта (F1), нейтральный в полдень const warm = 1 - Math.sin(kk * Math.PI); this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45);