diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 1bb6427..546b9d3 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -3,6 +3,7 @@ import { NPCS } from './npcs'; import type { ItemId } from './items'; import { TILES } from './map'; +import { FLAGS } from './ids'; import { INTERACTABLES, type InteractableDef } from './interactables'; /** @@ -153,6 +154,8 @@ } ], atmosphere: 'ash', + // Светлый пепельный день: чуть холоднее белого. + lighting: { ambient: 0xd8dade }, interactables: INTERACTABLES.meadows, // Глобальный ветер: порывы свистят выше базового амбиента лугов. ambienceLayers: [{ key: 'ambience/wind', volume: 0.3 }], @@ -180,6 +183,8 @@ { tile: { x: 2, y: 2 }, target: { kind: 'area', area: 'meadows', entry: { x: 3, y: 3 } } } ], atmosphere: 'fog', + // Молочно-серый день у прудов. + lighting: { ambient: 0xc4cad2 }, // Вода прудов слышна у берега — тихий плеск поверх ветрового амбиента. // Глобальный ветер + вода у берегов (громкость — от дистанции). ambienceLayers: [ @@ -251,6 +256,26 @@ ], interactables: INTERACTABLES.zvenets, atmosphere: 'ash', + // Серые сумерки деревни: тёплые окна трёх домов; после звонка — + // холодный отсвет резонанса от башни (спек-синтез звука — её «жизнь»). + lighting: { + ambient: 0xdad6d0, + sources: [ + // Окна — на теле спрайта дома (тело занимает тайл выше-левее базового). + { id: 'window_elder', at: { x: 6.5, y: 5.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1 }, + { id: 'window_north', at: { x: 17.5, y: 4.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1 }, + { id: 'window_shop', at: { x: 20.5, y: 8.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1 }, + { + id: 'tower_resonance', + at: { x: 14, y: 7 }, + color: 0x4a737f, + radius: 2.5, + intensity: 0.6, + flicker: 0.25, + whenFlag: FLAGS.rang_tower + } + ] + }, // Гул колокола башни и скрипы вагонных щитов — слышны вблизи источников. ambienceLayers: [ { key: 'ambience/tower_hum', at: { x: 14, y: 7 }, radius: 8, volume: 0.5 }, @@ -269,7 +294,30 @@ { tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 } ], interactables: INTERACTABLES.house_elder, - atmosphere: 'none' + atmosphere: 'none', + // Тёмный холодный интерьер (без света окна выглядят как улица); + // очаг: тлеет, пока квест не закрыт, после — горит ровнее и ярче. + lighting: { + 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: { id: 'shop', @@ -279,7 +327,12 @@ enemies: [], transitions: [{ tile: { x: 5, y: 8 }, target: { kind: 'return' }, duration: 0.2 }], interactables: INTERACTABLES.shop, - atmosphere: 'none' + atmosphere: 'none', + // Полутёмная лавка: лампа над прилавком. + lighting: { + ambient: 0x5a5e70, + sources: [{ id: 'counter_lamp', at: { x: 5.5, y: 3.5 }, radius: 3, intensity: 0.7, flicker: 0.05 }] + } } }; diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index b1fea93..9724594 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -569,6 +569,7 @@ this.updateAudioLayers(); this.updateAmbient(dt); this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов + this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index b8ce958..d4d2fd4 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -104,6 +104,7 @@ { name: 'interact', run: () => runScenario('interact', 'apps/game/tools/checks/interact.mjs') }, { name: 'interact-world', run: () => runScenario('interact-world', 'apps/game/tools/checks/interact-world.mjs') }, { name: 'quest-bells', run: () => runScenario('quest-bells', 'apps/game/tools/checks/quest-bells.mjs') }, + { name: 'lighting', run: () => runScenario('lighting', 'apps/game/tools/checks/lighting.mjs') }, { name: 'collision', run: () => runScenario('collision', 'apps/game/tools/checks/collision.mjs') }, { name: 'ai', run: () => runScenario('ai', 'apps/game/tools/checks/ai.mjs') }, { name: 'audio', run: () => runScenario('audio', 'apps/game/tools/checks/audio.mjs') }, diff --git a/apps/game/tools/checks/lighting.mjs b/apps/game/tools/checks/lighting.mjs new file mode 100644 index 0000000..6d63d4c --- /dev/null +++ b/apps/game/tools/checks/lighting.mjs @@ -0,0 +1,108 @@ +/** + * Сценарий lighting — освещение сцены через мост: ambient областей, окна + * Звенца, очаг дома Ирвина (флаг квеста), лампа героя, мерцание. + * 1) Луга: светлый дневной ambient, источников нет (и лампы — улица светлая); + * 2) Звенец: серые сумерки, три окна домов, башня молчит (флага нет); + * 3) Дом Ирвина: тёмный интерьер, hearth_low (не hearth_high), лампа героя; + * мерцание очага — интенсивность гуляет по серии снапшотов; + * 4) телепорт внутри дома — лампа следует за героем (дистанция лампа↔очаг + * меняется; разность экранных координат камерой не компенсируется); + * 5) quest_bells_done: hearth_high вместо hearth_low и строго ярче; + * 6) инварианты чисты. + * Запуск: node tools/agent.mjs run tools/checks/lighting.mjs + */ +import { startDevServer, openGame, Checks } from '../lib.mjs'; + +/** Ambient областей (те же константы, что в data/locations.ts). */ +const AMB = { meadows: 0xd8dade, zvenets: 0xdad6d0, house_elder: 0x54586a }; + +/** Экранное смещение источника b относительно a (px). */ +function lightVec(s, a, b) { + const x = s.lighting.sources.find((l) => l.id === a); + const y = s.lighting.sources.find((l) => l.id === b); + return x && y ? { x: y.x - x.x, y: y.y - x.y } : null; +} + +export default async function ({ pretty }) { + const c = new Checks('lighting'); + const server = await startDevServer(); + let ctx; + try { + await c.run('луга: дневной ambient, источников нет', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.meadows, 'ambient лугов не дневной', s.lighting); + c.expect(s.lighting.sources.length === 0, 'на светлой улице не должно быть источников', s.lighting.sources); + return null; + }); + await c.run('Звенец: сумерки, три окна, башня молчит', async () => { + await ctx.agent.walkTo(26, 14, { timeoutTicks: 3000 }); + const w = await ctx.agent.waitFor('s.area === "zvenets"', { timeoutTicks: 600 }); + c.expect(w.ok, 'не пришли в Звенец'); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.zvenets, 'ambient Звенца не сумеречный', s.lighting); + const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_')); + c.expect(windows.length === 3, 'окон домов должно быть три', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'tower_resonance'), 'башня светит без звонка', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'lamp'), 'на улице светлой деревни лампе героя не место', s.lighting.sources); + return null; + }); + await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { + await ctx.agent.tapTile(7, 6); // дверь дома — клик-переход + const w = await ctx.agent.waitFor('s.area === "house_elder"', { timeoutTicks: 1200 }); + c.expect(w.ok, 'не вошли в дом Ирвина'); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 600 }); + const s = await ctx.agent.snapshot(); + c.expect(s.lighting.ambient === AMB.house_elder, 'ambient интерьера не тёмный', s.lighting); + c.expect(s.lighting.sources.some((l) => l.id === 'hearth_low'), 'нет тлеющего очага', s.lighting.sources); + c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_high'), 'очаг горит ровно до сдачи квеста', s.lighting.sources); + c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'в тёмном доме нет лампы героя', s.lighting.sources); + // Мерцание: серия снапшотов — интенсивность очага гуляет (с допуском). + const seen = new Set(); + for (let i = 0; i < 8; i++) { + const t = await ctx.agent.snapshot(); + const h = t.lighting.sources.find((l) => l.id === 'hearth_low'); + if (h) seen.add(Math.round(h.intensity * 100)); + await ctx.agent.step(6); + } + c.expect(seen.size >= 3, `мерцание очага не видно (вариантов ${seen.size})`, [...seen]); + return null; + }); + await c.run('лампа героя: следует за телепортом', async () => { + const v1 = lightVec(await ctx.agent.snapshot(), 'hearth_low', 'lamp'); + await ctx.agent.command('scene:teleport', { x: 2, y: 2 }); + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + const v2 = lightVec(await ctx.agent.snapshot(), 'hearth_low', 'lamp'); + c.expect(v1 !== null && v2 !== null, 'нет лампы или очага в снапшоте', { v1, v2 }); + // Очаг статичен: v2 - v1 — чистое смещение лампы (камера двигает оба поровну). + const shift = Math.hypot(v2.x - v1.x, v2.y - v1.y); + c.expect(shift > 60, `лампа не переехала за героем (смещение ${shift.toFixed(0)} px)`, { v1, v2 }); + return null; + }); + await c.run('сдача квеста: hearth_high вместо hearth_low и ярче', async () => { + await ctx.agent.command('scene:setFlag', { flag: 'quest_bells_done' }); + const w = await ctx.agent.waitFor('s.lighting.sources.some((l) => l.id === "hearth_high")', { + timeoutTicks: 300 + }); + c.expect(w.ok, 'после сдачи квеста очаг не разгорелся', w.snapshot.lighting.sources); + const s = await ctx.agent.snapshot(); + c.expect(!s.lighting.sources.some((l) => l.id === 'hearth_low'), 'тлеющий очаг не погас', s.lighting.sources); + const high = s.lighting.sources.find((l) => l.id === 'hearth_high'); + // Минимум hearth_high (1.05·0.8) выше максимума hearth_low (0.75). + c.expect(high.intensity > 0.84 - 0.02, 'hearth_high не ярче тлеющего', high); + return null; + }); + await c.run('инварианты чисты при работающем свете', async () => { + const inv = await ctx.agent.invariants(); + const bad = inv.filter((i) => i.severity === 'error'); + c.expect(bad.length === 0, 'ошибки инвариантов', bad); + return null; + }); + } finally { + await ctx?.browser?.close(); + server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file diff --git a/docs/art-style.md b/docs/art-style.md index de56f2a..92b35e4 100644 --- a/docs/art-style.md +++ b/docs/art-style.md @@ -105,6 +105,12 @@ 4. **Цветы жизни**: цветок = 3 пикселя B1 + 1px B2 сверху. Никаких «нарисованных» лепестков крупнее. 5. **Механизмы**: ржавчина по нижним граням, сталь сверху. Пепел ложится на горизонтальные грани (P5 сверху рельсов). 6. **Анимация ходьбы**: 2 кадра (ноги в разнобой) + idle. Читается на 16×24 — крупность важнее плавности. +7. **Свет сцены — оверлей, не пиксели спрайта**: тёплые гало источников (`Lighting` + в `renderer.lightRoot`) рисуются поверх затемнения ступенчатыми кругами и в + палитру спрайтов не входят. Цвет света — из тёплой гаммы жизни (F1 `#f2b45a` + по умолчанию); холодный свет — только для неживого (резонанс башни — W2). + Интенсивность источника ≤ 1.1, радиус ≤ 5 юнитов; «золото — событие, а не + заливка» относится и к свету: гало — событие сцены, не фон. ## 4. Ассеты и чеклист diff --git a/docs/demo.md b/docs/demo.md index c0eb1e2..ccc16ce 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -25,6 +25,7 @@ | AudioManager (шины) | амбиент локаций с кроссфейдом, sfx боя/шагов/UI | готово | | Tween + easing | хит-флэш, кольца резонанса, всплывающие тосты, кат-сцены | готово | | Particles (атмосфера + burst) | пепел лугов, туман прудов, удары/смерти сгустков | готово | +| Lighting (ambient multiply + аддитивные источники) | сумерки Звенца и окна домов, тёмный интерьер с очагом, лампа героя в темноте, отсвет башни после звонка | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | | Panel/Button/MenuList | меню, настройки, сейвы, сумка | готово | diff --git a/docs/plan.md b/docs/plan.md index ea8d68c..c8c21b6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -26,6 +26,7 @@ | 7 | Возможности аудио-движка (работа D, ниже): sfx-хендл, полифония, наблюдаемость, буферы | ✅ D0–D2 сделаны (2026-09-07) | | 8 | Синтез звука по запросу для агента (работа E, ниже) | ✅ E1–E4 сделаны (2026-09-07) | | 9 | Инфраструктура аудио + музыкальный слой (работы F/G, ниже) | ✅ сделаны (2026-09-07) | +| 10 | Освещение: ambient multiply + аддитивные источники (движок L1, игра L2, контент L3) | ✅ сделано (2026-09-07); точки расширения: day/night через `setAmbient`-лерп, динамические источники `addLight/removeLight`, импульсы переосвещения — позже | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт