diff --git a/apps/game/assets/chars/clump_heavy_1.png b/apps/game/assets/chars/clump_heavy_1.png index 062a423..661c43c 100644 --- a/apps/game/assets/chars/clump_heavy_1.png +++ b/apps/game/assets/chars/clump_heavy_1.png Binary files differ diff --git a/apps/game/assets/chars/clump_heavy_2.png b/apps/game/assets/chars/clump_heavy_2.png index 0bdc30f..b73288b 100644 --- a/apps/game/assets/chars/clump_heavy_2.png +++ b/apps/game/assets/chars/clump_heavy_2.png Binary files differ diff --git a/apps/game/assets/chars/clumps_sheet.png b/apps/game/assets/chars/clumps_sheet.png index f259765..aa8b68a 100644 --- a/apps/game/assets/chars/clumps_sheet.png +++ b/apps/game/assets/chars/clumps_sheet.png Binary files differ diff --git a/apps/game/assets/chars/fauna_deer_1.png b/apps/game/assets/chars/fauna_deer_1.png index c2b54be..d7724fe 100644 --- a/apps/game/assets/chars/fauna_deer_1.png +++ b/apps/game/assets/chars/fauna_deer_1.png Binary files differ diff --git a/apps/game/assets/chars/fauna_deer_2.png b/apps/game/assets/chars/fauna_deer_2.png index 8abf5ec..d7d0d60 100644 --- a/apps/game/assets/chars/fauna_deer_2.png +++ b/apps/game/assets/chars/fauna_deer_2.png Binary files differ diff --git a/apps/game/assets/chars/fauna_sheet.png b/apps/game/assets/chars/fauna_sheet.png index e6f9f9f..b08f29b 100644 --- a/apps/game/assets/chars/fauna_sheet.png +++ b/apps/game/assets/chars/fauna_sheet.png Binary files differ diff --git a/apps/game/assets/tiles/ash.png b/apps/game/assets/tiles/ash.png index 154344a..95dc3a7 100644 --- a/apps/game/assets/tiles/ash.png +++ b/apps/game/assets/tiles/ash.png Binary files differ diff --git a/apps/game/assets/tiles/ash_2.png b/apps/game/assets/tiles/ash_2.png index 23546d4..2c3192f 100644 --- a/apps/game/assets/tiles/ash_2.png +++ b/apps/game/assets/tiles/ash_2.png Binary files differ diff --git a/apps/game/assets/tiles/bellflower.png b/apps/game/assets/tiles/bellflower.png index d7eb36a..212022c 100644 --- a/apps/game/assets/tiles/bellflower.png +++ b/apps/game/assets/tiles/bellflower.png Binary files differ diff --git a/apps/game/assets/tiles/decor_bald.png b/apps/game/assets/tiles/decor_bald.png index c2d6337..7fbb154 100644 --- a/apps/game/assets/tiles/decor_bald.png +++ b/apps/game/assets/tiles/decor_bald.png Binary files differ diff --git a/apps/game/assets/tiles/decor_stone.png b/apps/game/assets/tiles/decor_stone.png index 109f7ef..20fc4c5 100644 --- a/apps/game/assets/tiles/decor_stone.png +++ b/apps/game/assets/tiles/decor_stone.png Binary files differ diff --git a/apps/game/assets/tiles/decor_tuft.png b/apps/game/assets/tiles/decor_tuft.png index 7abb04a..8e17f52 100644 --- a/apps/game/assets/tiles/decor_tuft.png +++ b/apps/game/assets/tiles/decor_tuft.png Binary files differ diff --git a/apps/game/assets/tiles/grass.png b/apps/game/assets/tiles/grass.png index 3c705af..8ead393 100644 --- a/apps/game/assets/tiles/grass.png +++ b/apps/game/assets/tiles/grass.png Binary files differ diff --git a/apps/game/assets/tiles/grass_2.png b/apps/game/assets/tiles/grass_2.png index c76b336..2999cf4 100644 --- a/apps/game/assets/tiles/grass_2.png +++ b/apps/game/assets/tiles/grass_2.png Binary files differ diff --git a/apps/game/assets/tiles/grass_3.png b/apps/game/assets/tiles/grass_3.png index 538dbf3..0ade55d 100644 --- a/apps/game/assets/tiles/grass_3.png +++ b/apps/game/assets/tiles/grass_3.png Binary files differ diff --git a/apps/game/assets/tiles/house.png b/apps/game/assets/tiles/house.png index fceaf88..cbc5e48 100644 --- a/apps/game/assets/tiles/house.png +++ b/apps/game/assets/tiles/house.png Binary files differ diff --git a/apps/game/assets/tiles/house_big.png b/apps/game/assets/tiles/house_big.png index 82b59ba..29c4be7 100644 --- a/apps/game/assets/tiles/house_big.png +++ b/apps/game/assets/tiles/house_big.png Binary files differ diff --git a/apps/game/assets/tiles/machine.png b/apps/game/assets/tiles/machine.png index 818d12e..000d932 100644 --- a/apps/game/assets/tiles/machine.png +++ b/apps/game/assets/tiles/machine.png Binary files differ diff --git a/apps/game/assets/tiles/path.png b/apps/game/assets/tiles/path.png index 7f0df50..7ab718c 100644 --- a/apps/game/assets/tiles/path.png +++ b/apps/game/assets/tiles/path.png Binary files differ diff --git a/apps/game/assets/tiles/path_2.png b/apps/game/assets/tiles/path_2.png index 4251c90..2283872 100644 --- a/apps/game/assets/tiles/path_2.png +++ b/apps/game/assets/tiles/path_2.png Binary files differ diff --git a/apps/game/assets/tiles/tower.png b/apps/game/assets/tiles/tower.png index f550331..a7564da 100644 --- a/apps/game/assets/tiles/tower.png +++ b/apps/game/assets/tiles/tower.png Binary files differ diff --git a/apps/game/assets/tiles/tree.png b/apps/game/assets/tiles/tree.png index fc2bd74..7fc9f06 100644 --- a/apps/game/assets/tiles/tree.png +++ b/apps/game/assets/tiles/tree.png Binary files differ diff --git a/apps/game/assets/tiles/water_1.png b/apps/game/assets/tiles/water_1.png index 5ed40b2..8fe3ff8 100644 --- a/apps/game/assets/tiles/water_1.png +++ b/apps/game/assets/tiles/water_1.png Binary files differ diff --git a/apps/game/assets/tiles/water_2.png b/apps/game/assets/tiles/water_2.png index eada5c3..e4f9879 100644 --- a/apps/game/assets/tiles/water_2.png +++ b/apps/game/assets/tiles/water_2.png Binary files differ diff --git a/apps/game/assets/tiles/wire.png b/apps/game/assets/tiles/wire.png index dde080e..f193cbd 100644 --- a/apps/game/assets/tiles/wire.png +++ b/apps/game/assets/tiles/wire.png Binary files differ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 4d6f080..cca93af 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -97,6 +97,8 @@ ambient?: number; /** Ночной ambient (когда стоит флаг вечера); нет — ночь не меняет тон. */ nightAmbient?: number; + /** Закатный тон (золотой час); нет — движковый DEFAULT_DUSK_COLOR. */ + duskColor?: number; sources?: LightSourceDef[]; } @@ -168,18 +170,18 @@ } ], atmosphere: 'ash', - // Светлый пепельный день: чуть холоднее белого. Вечером (по часам) - // — синие сумерки: лунные колокольчики светятся. + // Солнечный выжженный день: тёплый почти-белый. Вечером — золотой час + // и синяя ночь: прорези колокольчиков несут свет. lighting: { - ambient: 0xd8dade, - nightAmbient: 0x6a7288, + ambient: 0xf7f4ea, + nightAmbient: 0x3e4658, sources: [ { id: 'bells', tiles: [TILES.BELLFLOWER], color: 0xf2cd68, - radius: 1.4, - intensity: 0.5, + radius: 1.0, + intensity: 0.65, flicker: 0.15, whenNight: true } @@ -212,8 +214,9 @@ { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } ], atmosphere: 'fog', - // Молочно-серый день у прудов; вечером — глухие синие сумерки. - lighting: { ambient: 0xc4cad2, nightAmbient: 0x767d92 }, + // Светлый день у прудов (туман даёт молочность сам); ночью — глухая + // синева, туман читается прорезями. + lighting: { ambient: 0xf2f0e8, nightAmbient: 0x444c60 }, // Вода прудов слышна у берега — тихий плеск поверх ветрового амбиента. // Глобальный ветер + вода у берегов (громкость — от дистанции). ambienceLayers: [ @@ -289,17 +292,17 @@ ], interactables: INTERACTABLES.zvenets, atmosphere: 'ash', - // Серые сумерки деревни; вечером (по часам) — синие сумерки и тёплые - // окна трёх домов; после звонка — холодный отсвет резонанса от башни. + // Светлый тёплый день деревни; вечером — золотой час, синяя ночь и + // тёплые окна трёх домов; после звонка — холодный отсвет от башни. lighting: { - ambient: 0xdad6d0, - nightAmbient: 0x7b8298, + ambient: 0xf5f2ea, + nightAmbient: 0x454c62, sources: [ // Окна — на теле спрайта дома (тело занимает тайл выше-левее базового); // днём не горят, зажигаются вечером. - { id: 'window_elder', at: { x: 6.5, y: 5.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1, whenNight: true }, - { id: 'window_north', at: { x: 17.5, y: 4.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1, whenNight: true }, - { id: 'window_shop', at: { x: 20.5, y: 8.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1, whenNight: true }, + { id: 'window_elder', at: { x: 6.5, y: 5.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { id: 'window_north', at: { x: 17.5, y: 4.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, + { id: 'window_shop', at: { x: 20.5, y: 8.5 }, radius: 1.2, intensity: 0.65, flicker: 0.1, whenNight: true }, { id: 'tower_resonance', at: { x: 14, y: 7 }, @@ -342,8 +345,8 @@ ], atmosphere: 'ash', interactables: INTERACTABLES.rust_grove, - // Роща темнее лугов: мёртвый лес глушит свет; вечером — глухая синева. - lighting: { ambient: 0xb0b4c0, nightAmbient: 0x565c74 }, + // Роща чуть темнее лугов: мёртвый лес глушит свет; ночью — глухая синева. + lighting: { ambient: 0xe9e6da, nightAmbient: 0x39405a }, // Ветер в проводах: гул башни не слышен, только кабельный звон. ambienceLayers: [{ key: 'ambience/wind', volume: 0.4 }] }, @@ -378,8 +381,8 @@ } ], atmosphere: 'ash', - // Разъезд ещё темнее рощи: рельсы тянут свет в землю. - lighting: { ambient: 0xa4a8b6, nightAmbient: 0x4e546c }, + // Разъезд темнее рощи: рельсы тянут свет в землю; ночью — глубокая тьма. + lighting: { ambient: 0xe4e2d8, nightAmbient: 0x343a50 }, // Гул снизу: Машина дышит у люка (та же нота, что башня Звенца). ambienceLayers: [ { key: 'ambience/wind', volume: 0.25 }, diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index cfadca2..5ea2f0b 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -62,8 +62,8 @@ import { ResonatorQuest } from '../systems/quest/ResonatorQuest'; import { enemyTextures, heroTextures, tileTextures } from '../data/assetSets'; -/** Лампа героя: тёплый, тихий, слегка мерцает (сумерки мира). */ -const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; +/** Лампа героя: тёплый, компактный, слегка мерцает (прорезает ночную тьму). */ +const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 1.6, intensity: 0.85, flicker: 0.12 }; /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts index d32cd7f..3ccd199 100644 --- a/apps/game/src/systems/Lighting.ts +++ b/apps/game/src/systems/Lighting.ts @@ -7,8 +7,8 @@ import { Camera, Lighting, + dayNightAmbient, dayNightFactor, - lerpAmbient, tileToWorld, unitsToPx, type PulseSpec, @@ -181,7 +181,8 @@ const night = light?.nightAmbient; const base = light?.ambient; if (night === undefined || base === undefined) return; - const target = lerpAmbient(base, night, this.nightFactor); + // Золотой час: день → закатный тон → ночь (рассвет тем же тёплым цветом). + const target = dayNightAmbient(base, night, this.nightFactor, light?.duskColor); if (target !== this.lastAmbientTarget) this.setAmbient(target, 0); } diff --git a/apps/game/src/systems/__tests__/lighting.test.ts b/apps/game/src/systems/__tests__/lighting.test.ts index c2d9afe..dba6e46 100644 --- a/apps/game/src/systems/__tests__/lighting.test.ts +++ b/apps/game/src/systems/__tests__/lighting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Camera, Lighting, tileToWorld } from '@rpg/engine'; +import { Camera, DUSK_PEAK, Lighting, tileToWorld } from '@rpg/engine'; import { GameLighting, type HeroLampDef } from '../Lighting'; import type { AreaDef } from '../../data/locations'; @@ -171,6 +171,19 @@ gl.destroy(); }); + it('золотой час: на пике заката ambient — закатный тон (свой в данных)', () => { + const { gl, engineLighting, setTime } = makeGameLighting( + fakeArea({ ambient: 0xf7f4ea, nightAmbient: 0x3e4658, duskColor: 0xe8a060 }), + { time: 18 + 2 * DUSK_PEAK } // k = DUSK_PEAK + ); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xe8a060); + setTime(12); // день — обратно из заката + gl.update(); + expect(engineLighting.ambientColor).toBe(0xf7f4ea); + gl.destroy(); + }); + it('конструктор сразу применяет ночной микс (без мелькания дня)', () => { const { gl, engineLighting } = makeGameLighting( fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), diff --git a/apps/game/tools/checks/lighting.mjs b/apps/game/tools/checks/lighting.mjs index 14551c4..65fb32c 100644 --- a/apps/game/tools/checks/lighting.mjs +++ b/apps/game/tools/checks/lighting.mjs @@ -18,10 +18,10 @@ /** Ambient областей (те же константы, что в data/locations.ts). */ const AMB = { - meadows: 0xd8dade, - meadowsNight: 0x6a7288, - zvenets: 0xdad6d0, - zvenetsNight: 0x7b8298, + meadows: 0xf7f4ea, + meadowsNight: 0x3e4658, + zvenets: 0xf5f2ea, + zvenetsNight: 0x454c62, house_elder: 0x54586a }; diff --git a/apps/game/tools/checks/time.mjs b/apps/game/tools/checks/time.mjs index effaa36..3e957f5 100644 --- a/apps/game/tools/checks/time.mjs +++ b/apps/game/tools/checks/time.mjs @@ -16,7 +16,9 @@ import { withChecks } from '../lib.mjs'; /** Ambient лугов и его ночной микс на середине заката (19:00). */ -const AMB = { meadows: 0xd8dade, mix: 0xa1a6b3, night: 0x6a7288 }; +const AMB = { meadows: 0xf7f4ea, night: 0x3e4658 }; +/** Золотой час (те же константы, что в движке lightSim). */ +const DUSK = { peak: 0.45, color: 0xe8c090 }; export default async function ({ pretty }) { return withChecks( @@ -51,7 +53,12 @@ const ca = (a >> sh) & 255, cb = (b >> sh) & 255; return Math.round(ca * (1 - k) + cb * k) << sh; }).reduce((x, y) => x | y); - const expected = mix(AMB.meadows, AMB.night, s.time.night); + // Трёхопорный микс суток (день -> золотой час -> ночь), как dayNightAmbient. + const k = s.time.night; + const linear = k < DUSK.peak + ? mix(AMB.meadows, DUSK.color, k / DUSK.peak) + : mix(DUSK.color, AMB.night, (k - DUSK.peak) / (1 - DUSK.peak)); + const expected = k <= 0 ? AMB.meadows : k >= 1 ? AMB.night : linear; c.expect(s.lighting.ambient === expected, `ambient ${s.lighting.ambient.toString(16)} не микс ${expected.toString(16)}`, { ambient: s.lighting.ambient, night: s.time.night }); return null; }); diff --git a/apps/game/tools/pixelart/palette.mjs b/apps/game/tools/pixelart/palette.mjs index a0bf80f..ed6c7b8 100644 --- a/apps/game/tools/pixelart/palette.mjs +++ b/apps/game/tools/pixelart/palette.mjs @@ -8,15 +8,15 @@ P1: '#23232b', P2: '#2f2f38', P3: '#3e3e48', - P4: '#52525c', - P5: '#6a6a74', - P6: '#86868f', - P5H: '#a3a3ac', + P4: '#5b5b66', + P5: '#767681', + P6: '#94949e', + P5H: '#b2b2bb', // Мёртвая трава - G0: '#2b3026', - G1: '#3d4436', - G2: '#525b44', - G2H: '#687053', + G0: '#343b2c', + G1: '#4a5340', + G2: '#626c4c', + G2H: '#7d8662', // Ржавчина и металл R0: '#3a2822', R1: '#5c3b2e', @@ -26,8 +26,8 @@ M2: '#61626c', // Вода W0: '#22343c', - W1: '#32505c', - W2: '#4a737f', + W1: '#3b5e6b', + W2: '#568590', // Тепло — только жизнь B0: '#8a6a2e', B1: '#d99a32', diff --git a/docs/art-style.md b/docs/art-style.md index c670809..6430517 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -17,19 +17,19 @@ | P1 | `#23232b` | тень плащей, ночная сторона | | P2 | `#2f2f38` | тёмный серый | | P3 | `#3e3e48` | пепельный накат, тень тайлов | -| P4 | `#52525c` | базовый серый | -| P5 | `#6a6a74` | светлая пыль | -| P6 | `#86868f` | пепельный бугор, свет | -| P5H| `#a3a3ac` | выгоревший свет (редко!) | +| P4 | `#5b5b66` | базовый серый | +| P5 | `#767681` | светлая пыль | +| P6 | `#94949e` | пепельный бугор, свет | +| P5H| `#b2b2bb` | выгоревший свет (редко!) | ### Мёртвая трава (оливково-серая) | Слот | HEX | Роль | |---|---|---| -| G0 | `#2b3026` | тень травы | -| G1 | `#3d4436` | базовый луг | -| G2 | `#525b44` | трава, свет | -| G2H| `#687053` | сухие стебли | +| G0 | `#343b2c` | тень травы | +| G1 | `#4a5340` | базовый луг | +| G2 | `#626c4c` | трава, свет | +| G2H| `#7d8662` | сухие стебли | ### Ржавчина и металл (индастриал) @@ -47,8 +47,8 @@ | Слот | HEX | Роль | |---|---|---| | W0 | `#22343c` | глубина | -| W1 | `#32505c` | базовая | -| W2 | `#4a737f` | блики, рябь | +| W1 | `#3b5e6b` | базовая | +| W2 | `#568590` | блики, рябь | ### Тепло (только жизнь!) @@ -105,16 +105,15 @@ 4. **Цветы жизни**: цветок = 3 пикселя B1 + 1px B2 сверху. Никаких «нарисованных» лепестков крупнее. 5. **Механизмы**: ржавчина по нижним граням, сталь сверху. Пепел ложится на горизонтальные грани (P5 сверху рельсов). 6. **Анимация ходьбы**: 2 кадра (ноги в разнобой) + idle. Читается на 16×24 — крупность важнее плавности. -7. **Свет сцены — оверлей, не пиксели спрайта**: тёплые гало источников (`Lighting` - в `renderer.lightRoot`) рисуются поверх затемнения ступенчатыми кругами - (4 ступени + дизер-кольцо 1px по внешнему радиусу — «мыло» не размывает - пиксельную сетку) и в палитру спрайтов не входят. Цвет света — из тёплой гаммы - жизни (F1 `#f2b45a` по умолчанию); холодный свет — только для неживого - (резонанс башни — W2). Ночной ambient областей — синие сумерки (`#6a7288` - и темнее), свет колокольчиков — B2 `#f2cd68`. Интенсивность источника ≤ 1.1, - радиус ≤ 5 юнитов; «золото — событие, а не заливка» относится и к свету: - гало — событие сцены, не фон. Виньетка (низкое hp, зона наката) и тёмные - ауры зон — multiply-затемнение краёв/пятна, не рисуются в спрайты. +7. **Свет сцены — оверлей, не пиксели спрайта**: источники **прорезают** тёмный + ambient-слой («тьма с прорезями»: RT-карта силы тьмы + erase-ступени, тон — + лёгкая аддитивная подкраска) и в палитру спрайтов не входят. Цвет света — из + тёплой гаммы жизни (F1 `#f2b45a` по умолчанию); холодный свет — только для + неживого (резонанс башни — W2). Ночной ambient областей — глубокая синева + (`#3e4658` и темнее), свет колокольчиков — B2 `#f2cd68`. Интенсивность + источника ≤ 1.1, радиус ≤ 5 юнитов; «золото — событие, а не заливка» относится + и к свету: гало — событие сцены, не фон. Виньетка (низкое hp, зона наката) и + тёмные ауры зон — multiply-затемнение краёв/пятна, не рисуются в спрайты. ## 4. Ассеты и чеклист diff --git a/docs/engine/render.md b/docs/engine/render.md index 02297fd..997aec1 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -169,9 +169,11 @@ Для непрерывного цикла суток в `lightSim` есть чистая кривая: `dayNightFactor(tHours, spec?)` — фактор ночи 0..1 по часам (mod 24), линейный на рассвете/закате; границы задаёт `DayNightSpec` (`DEFAULT_DAY_NIGHT`: рассвет 06–08, закат 18–20). Игра -смешивает дневной и ночной ambient области: `lerpAmbient(base, night, factor)` и -ставит цель через `setAmbient(target, 0)` каждый тик — переход сам растянут кривой -на десятки секунд, отдельный фейд не нужен. +смешивает ambient области по трём опорам — день → золотой час → ночь: +`dayNightAmbient(base, night, factor, duskColor?)` (пик закатного тона на +`DUSK_PEAK`, дефолт `DEFAULT_DUSK_COLOR`) и ставит цель через +`setAmbient(target, 0)` каждый тик — переход сам растянут кривой на десятки +секунд, отдельный фейд не нужен. ### Импульсы, виньетка, тёмные пятна diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 03ef304..0faacf3 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -173,6 +173,9 @@ pulseTotal, dayNightFactor, DEFAULT_DAY_NIGHT, + dayNightAmbient, + DUSK_PEAK, + DEFAULT_DUSK_COLOR, type LightDef, type LightFrame, type PulseSpec, diff --git a/packages/engine/src/render/__tests__/lightSim.test.ts b/packages/engine/src/render/__tests__/lightSim.test.ts index 3d73a5c..1f60853 100644 --- a/packages/engine/src/render/__tests__/lightSim.test.ts +++ b/packages/engine/src/render/__tests__/lightSim.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { DEFAULT_DAY_NIGHT, + DEFAULT_DUSK_COLOR, + DUSK_PEAK, GLOW_BASE_PX, + dayNightAmbient, dayNightFactor, dimColor, flickerFactor, @@ -114,6 +117,42 @@ }); }); +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 (по каждому каналу)', () => { + let prevR = 256; + for (let k = 0; k <= 1.0001; k += 0.01) { + const c = dayNightAmbient(0xf7f4ea, 0x3e4658, k); + const r = (c >> 16) & 0xff; + expect(r).toBeLessThanOrEqual(prevR); // день светлее ночи по R + prevR = r; + } + // стык на пике: значения слева и справа совпадают + expect(dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK - 0.001)).toBeCloseTo( + dayNightAmbient(0xf7f4ea, 0x3e4658, DUSK_PEAK + 0.001), + 1 + ); + }); +}); + describe('dimColor', () => { it('краи: полная яркость и ноль', () => { expect(dimColor(0x54586a, 1)).toBe(0x54586a); diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts index b48c312..37fcaec 100644 --- a/packages/engine/src/render/lightSim.ts +++ b/packages/engine/src/render/lightSim.ts @@ -186,6 +186,29 @@ /** Дефолтная кривая игры: рассвет 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: день → золотой час → ночь + * (рассвет проходит через тот же тёплый тон — это ожидаемо). Без к≥1/≤0 + * — крайние опоры; непрерывность и монотонность по 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 — ночь, * линейный переход на рассвете и закате. Непрерывен на стыке 24/0.