diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 829b321..045c0e5 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -13,7 +13,8 @@ type SceneRegistry, type SnapshotLayer, type SoundKind, - type SoundSpec + type SoundSpec, + type Vec2 } from '@rpg/engine'; import type { CutsceneRunner } from '@rpg/engine'; import type { Game } from '../Game'; @@ -65,6 +66,8 @@ cutscene: CutsceneRunner; lastToast(): { text: string; tick: number } | null; inHazard(): HazardDef | null; + /** Урон герою полным боевым путём (как у сгустков — for scene:damagePlayer). */ + damagePlayer(damage: number, from?: Vec2): void; /** Игровая обвязка освещения (ambient + источники для снапшота). */ lighting(): GameLighting; /** Камера в ногах героя (snap — телепорты). */ @@ -168,7 +171,7 @@ ); } - /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, источников ≤24. */ + /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, виньетка 0..1, источников ≤24. */ private lightingInvariant(where: string): Invariant[] { const bad: Invariant = { id: 'lighting-bounded', @@ -178,6 +181,7 @@ }; const l = this.deps.lighting().snapshot(); if (l.sources.length > MAX_LIGHTS || !Number.isFinite(l.ambient)) return [bad]; + if (!Number.isFinite(l.vignette) || l.vignette < 0 || l.vignette > 1) return [bad]; for (const s of l.sources) { if (!Number.isFinite(s.x) || !Number.isFinite(s.y) || s.intensity < 0 || s.intensity > 2) return [bad]; } @@ -248,6 +252,13 @@ } return false; } + case 'scene:damagePlayer': { + // Урон герою полным боевым путём (CombatWorld.deps → onPlayerDamaged): + // вспышка, шейк, виньетка при низком hp. + if (typeof a.value !== 'number') return null; + d.damagePlayer(a.value); + return true; + } case 'scene:give': if (typeof a.id !== 'string') return null; d.game.inventory.add(a.id); diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index e0dd58f..5db5200 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -72,6 +72,7 @@ it('свет: ambient как есть, источники с округлением координат и интенсивности', () => { const layer = lightingLayer({ ambient: 0x54586a, + vignette: 0.34567, sources: [ { id: 'hearth', x: 123.4567, y: 67.8912, color: 0xf2b45a, intensity: 0.87654 }, { id: 'lamp', x: 240, y: 135, color: 0xf2b45a, intensity: 0.5 } @@ -79,6 +80,7 @@ }); expect(layer.lighting).toEqual({ ambient: 0x54586a, + vignette: 0.346, sources: [ { id: 'hearth', x: 123.457, y: 67.891, color: 0xf2b45a, intensity: 0.877 }, { id: 'lamp', x: 240, y: 135, color: 0xf2b45a, intensity: 0.5 } @@ -87,8 +89,8 @@ }); it('свет: пустой список источников допустим (дневная улица)', () => { - const layer = lightingLayer({ ambient: 0xffffff, sources: [] }); - expect(layer.lighting).toEqual({ ambient: 0xffffff, sources: [] }); + const layer = lightingLayer({ ambient: 0xffffff, vignette: 0, sources: [] }); + expect(layer.lighting).toEqual({ ambient: 0xffffff, vignette: 0, sources: [] }); }); it('NPC: копия с met, без ссылок на источник', () => { @@ -151,7 +153,7 @@ transitions: [], interactables: [], collision: { width: 1, height: 1, blocked: [0], props: [] }, - lighting: { ambient: 0xffffff, sources: [] }, + lighting: { ambient: 0xffffff, vignette: 0, sources: [] }, dialogue: null, cutscene: null, lastToast: null diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index dfe0492..c82fd16 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -100,9 +100,11 @@ intensity: number; } -/** Освещение сцены: ambient (multiply-цвет) + активные источники. */ +/** Освещение сцены: ambient (multiply-цвет) + виньетка + активные источники. */ export interface LightingSnapshot { ambient: number; + /** Целевая интенсивность виньетки 0..1. */ + vignette: number; sources: LightSourceSnapshot[]; } @@ -216,11 +218,12 @@ return { dialogue: d ? (d as unknown as JsonValue) : null }; } -/** Слой освещения: ambient + активные источники (координаты экранные px). */ +/** Слой освещения: ambient + виньетка + активные источники (координаты экранные px). */ export function lightingLayer(o: LightingSnapshot): SnapshotLayer { return { lighting: { ambient: o.ambient, + vignette: r(o.vignette), sources: o.sources.map((s) => ({ id: s.id, x: r(s.x), diff --git a/apps/game/src/data/dialogues/elder_hand_in.json b/apps/game/src/data/dialogues/elder_hand_in.json index afe62f1..50dafda 100644 --- a/apps/game/src/data/dialogues/elder_hand_in.json +++ b/apps/game/src/data/dialogues/elder_hand_in.json @@ -7,7 +7,7 @@ "next": "accept" }, "accept": { - "setFlags": ["quest_bells_done"], + "setFlags": ["quest_bells_done", "evening"], "do": [{ "kind": "custom", "id": "plant_flowers" }], "next": "ring" }, diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts index 72f22f0..62cd767 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -27,7 +27,9 @@ /** Прочитана записка в доме Ирвина (интерактив note_elder). */ read_note: 'read_note', /** Подсказка «как собирать колокольчики» уже показана (вход в пруды). */ - hint_bells: 'hint_bells' + hint_bells: 'hint_bells', + /** Вечер настал (диалог elder_hand_in: «вернусь до темноты» исполнено). */ + evening: 'evening' } as const; export type FlagId = keyof typeof FLAGS; diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 546b9d3..b020236 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -85,6 +85,8 @@ export interface LightingDef { /** 0xffffff — не трогает сцену; тёмный/цветной — тон и затемнение сцены. */ ambient?: number; + /** Ночной ambient (когда стоит флаг вечера); нет — ночь не меняет тон. */ + nightAmbient?: number; sources?: LightSourceDef[]; } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 9724594..1543fc1 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -19,6 +19,7 @@ worldToTile, screenToWorld, tileToWorld, + unitsToPx, DebugOverlay, SpriteDebugView, VirtualJoystick, @@ -68,20 +69,17 @@ import { HealthBar } from '../systems/combat/HealthBar'; import { PLAYER_COMBAT } from '../systems/combat/stats'; import { Interactables } from '../systems/Interactables'; -import { GameLighting, type HeroLampDef } from '../systems/Lighting'; +import { GameLighting, MAX_LIGHTS, type HeroLampDef } from '../systems/Lighting'; import { SceneObjects } from '../systems/SceneObjects'; import type { InteractableDef } from '../data/interactables'; /** Лампа героя: тёплый, тихий, слегка мерцает (сумерки мира). */ const HERO_LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; -/** Порог «темноты» ambient по самому светлому каналу: интерьеры и пруды. */ -function darkAreaLamp(area: AreaDef): HeroLampDef | null { - const a = area.lighting?.ambient; - if (a === undefined) return null; - const bright = Math.max((a >> 16) & 0xff, (a >> 8) & 0xff, a & 0xff) / 255; - return bright < 0.75 ? HERO_LAMP : null; -} +/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */ +const VIGNETTE_LOW_HP = 0.3; +const VIGNETTE_HAZARD = 0.35; +const VIGNETTE_FADE = 0.5; /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. @@ -148,6 +146,8 @@ private cutscene = new CutsceneRunner(); /** Зона наката, в которой герой был в прошлом кадре (для тоста при входе). */ private inHazard: HazardDef | null = null; + /** Текущая зона наката перекрыта полотном (для виньетки). */ + private hazardMasked = false; /** Тач-джойстик (активен только для касаний). */ private joystick: VirtualJoystick; private debug: DebugOverlay; @@ -295,6 +295,16 @@ const s = worldToScreen(en.pos.x, en.pos.y); if (en.brain.dead) this.combatViews.deathBurst(s); else this.combatViews.hitBurst(s); + // Световой импульс в точке попадания (lightRoot — экранное пространство). + const ls = this.camera.toScreen(en.pos.x, en.pos.y); + this.lighting.pulseLight({ + x: ls.x, + y: ls.y, + color: 0xf2b45a, + intensity: 0.45, + radius: unitsToPx(1.5), + spec: { attack: 0.02, decay: 0.2 } + }); }); // Пейзажная фауна: безгласные олени у берегов (сама по себе, вне боя). @@ -394,6 +404,7 @@ cutscene: this.cutscene, lastToast: () => this.lastToast, inHazard: () => this.inHazard, + damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from ?? this.player.position), lighting: () => this.lighting, followCamera: (snap) => this.updateCameraFollow(snap) }); @@ -459,7 +470,8 @@ this.lightView = new Lighting({ width: Game.VIRTUAL_W, height: Game.VIRTUAL_H, - renderer: this.game.renderer + renderer: this.game.renderer, + maxLights: MAX_LIGHTS }); this.game.renderer.lightRoot.addChild(this.lightView); this.lighting = new GameLighting({ @@ -469,7 +481,8 @@ camera: this.camera, heroPos: () => this.player.position, hasFlag: (f) => this.game.state.hasFlag(f), - lamp: darkAreaLamp(area) + nightFlag: FLAGS.evening, + lamp: HERO_LAMP }); // Название локации в правом верхнем углу. @@ -570,6 +583,7 @@ this.updateAmbient(dt); this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам + this.updateVignette(); this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -673,6 +687,15 @@ if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); } + /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */ + private updateVignette(): void { + const target = Math.max( + this.playerCombat.hp <= 2 ? VIGNETTE_LOW_HP : 0, + this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0 + ); + if (this.lighting.vignetteLevel !== target) this.lighting.setVignette(target, VIGNETTE_FADE); + } + /** * Зона наката под ногами: без маски герой еле идёт и рискует голосом, * звон здесь «проверяет воздух» — пепел на секунду видно волной. @@ -693,6 +716,7 @@ void this.game.audio.play('sfx/ash_hiss', 0.5); } this.inHazard = hazard; + this.hazardMasked = masked; // Дымка: плотная без маски, лёгкая с ней, вне низин её нет. this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28; @@ -734,6 +758,17 @@ const s = worldToScreen(from.x, from.y); this.combatViews.resonanceRing(s); this.game.camera.addShake(1.5, 0.25); + // Звон вспыхивает: тёплый импульс у героя и лёгкий подъём ambient. + const ls = this.camera.toScreen(from.x, from.y); + this.lighting.pulseLight({ + x: ls.x, + y: ls.y, + color: 0xd99a32, + intensity: 0.6, + radius: unitsToPx(2.5), + spec: { attack: 0.05, decay: 0.45 } + }); + this.lighting.pulseAmbient({ color: 0xd99a32, peak: 0.12, spec: { attack: 0.05, decay: 0.5 } }); this.game.state.setVar(VARS.resonances, this.game.state.getNumber(VARS.resonances) + (slept > 0 ? 1 : 0)); // Звон как «проверка воздуха»: в накате волна на миг подсвечивает пепел. if (this.inHazard !== null) { @@ -774,6 +809,8 @@ if (applied <= 0) return; this.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); this.game.camera.addShake(2.5, 0.3); + // Красная вспышка на весь экран — экранная реакция на боль. + this.lighting.pulseAmbient({ color: 0xb0453f, peak: 0.18, spec: { attack: 0.02, decay: 0.35 } }); // Отброс от источника урона (направление — по метрике проекции) const dx = this.player.position.x - from.x; const dy = this.player.position.y - from.y; @@ -963,9 +1000,24 @@ this.stemHandle?.setVolume(this.stemLevel * 0.8); } + /** Смена тайла с поддержанием индекса layerTilePos (свет по тайлам живой). */ + private setTileTracked(x: number, y: number, id: number): void { + const prev = this.tileId(x, y); + this.map.setTile(x, y, id); + const from = this.layerTilePos.get(prev); + if (from) { + const i = from.findIndex((p) => p.x === x && p.y === y); + if (i >= 0) from.splice(i, 1); + if (from.length === 0) this.layerTilePos.delete(prev); + } + const list = this.layerTilePos.get(id) ?? []; + list.push({ x, y }); + this.layerTilePos.set(id, list); + } + /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ private collectFlower(x: number, y: number): void { - this.map.setTile(x, y, TILES.GRASS); + this.setTileTracked(x, y, TILES.GRASS); this.game.inventory.add('bellflower'); const n = this.game.state.getNumber(VARS.flowers) + 1; this.game.state.setVar(VARS.flowers, n); @@ -1009,7 +1061,7 @@ [15, 15] ]; for (const [tx, ty] of planted) { - this.map.setTile(tx, ty, TILES.BELLFLOWER); + this.setTileTracked(tx, ty, TILES.BELLFLOWER); this.combatViews.hitBurst(this.tileCenter(tx, ty)); } // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). @@ -1021,7 +1073,7 @@ [0, -1] ]) { const id = this.tileId(tx + dx, ty + dy); - if (id === TILES.GRASS || id === TILES.ASH) this.map.setTile(tx + dx, ty + dy, TILES.BELLFLOWER); + if (id === TILES.GRASS || id === TILES.ASH) this.setTileTracked(tx + dx, ty + dy, TILES.BELLFLOWER); } } void this.game.audio.play('sfx/bell_low', 0.8); @@ -1057,7 +1109,7 @@ [14, 9] ]) { if (this.tileId(tx, ty) === TILES.GRASS || this.tileId(tx, ty) === TILES.ASH) { - this.map.setTile(tx, ty, TILES.BELLFLOWER); + this.setTileTracked(tx, ty, TILES.BELLFLOWER); this.combatViews.hitBurst(this.tileCenter(tx, ty)); } } diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts index ea3d123..221d075 100644 --- a/apps/game/src/systems/Lighting.ts +++ b/apps/game/src/systems/Lighting.ts @@ -4,7 +4,7 @@ * по флагам. Runtime API (setAmbient/addLight) — база day/night и эффектов. */ -import { Camera, Lighting, tileToWorld, unitsToPx, type Vec2 } from '@rpg/engine'; +import { Camera, Lighting, tileToWorld, unitsToPx, type PulseSpec, type Vec2 } from '@rpg/engine'; import type { AreaDef, LightSourceDef } from '../data/locations'; /** Параметры лампы героя (следует за героем в тёмных областях). */ @@ -28,14 +28,37 @@ /** Позиция героя в мировых юнитах. */ heroPos: () => Vec2; hasFlag: (flag: string) => boolean; + /** Флаг вечера: включает ночную цель ambient и лампу на светлой улице. */ + nightFlag?: string; lamp?: HeroLampDef | null; } export interface LightingSnapshot { ambient: number; + /** Целевая интенсивность виньетки 0..1 (текущая — пост-лерп движка). */ + vignette: number; sources: { id: string; x: number; y: number; color: number; intensity: number }[]; } +/** Фейд ночного ambient, сек (медленный закат). */ +const NIGHT_FADE_SEC = 6; + +/** Тёмная аура зоны наката: радиус в юнитах и сила затемнения. */ +const HAZARD_SPOT_RADIUS = 1.6; +const HAZARD_SPOT_ALPHA = 0.22; + +/** Кап тёмных пятен — под пул движка (maxDarkSpots, по умолчанию 16). */ +const MAX_DARK_SPOTS = 16; + +/** Считается ли область тёмной для лампы (тот же порог, что darkAreaLamp сцены). */ +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; @@ -52,12 +75,17 @@ private readonly extra: LightSourceDef[] = []; /** Активные id на прошлом тике — чтобы снимать погасшие (условия по флагам). */ private activeIds = new Set(); + /** Активные id тёмных пятен на прошлом тике (ауры хазардов). */ + private spotIds = new Set(); + /** Последняя заданная цель ambient — guard от повторного setAmbient. */ + private lastAmbientTarget: number | null = null; private destroyed = false; constructor(deps: GameLightingDeps) { this.deps = deps; if (deps.area.lighting?.ambient !== undefined) { deps.lighting.setAmbient(deps.area.lighting.ambient); + this.lastAmbientTarget = deps.area.lighting.ambient; } } @@ -65,6 +93,27 @@ setAmbient(color: number, fadeSec = 0): void { this.deps.lighting.setAmbient(color, fadeSec); + this.lastAmbientTarget = color; + } + + /** Виньетка 0..1 (низкий hp, опасная зона); guard по изменению — у сцены. */ + setVignette(intensity: number, fadeSec = 0): void { + this.deps.lighting.setVignette(intensity, fadeSec); + } + + /** Текущая целевая виньетка. */ + get vignetteLevel(): number { + return this.deps.lighting.vignetteLevel; + } + + /** Импульс света в точке (позиция уже в экранных px, радиус — в px). */ + pulseLight(args: { x: number; y: number; color: number; intensity?: number; radius?: number; spec: PulseSpec }): void { + this.deps.lighting.pulseLight(args); + } + + /** Аддитивная вспышка на весь экран (color, peak 0..1, огибающая). */ + pulseAmbient(args: { color: number; peak: number; spec: PulseSpec }): void { + this.deps.lighting.pulseAmbient(args); } /** Динамически добавить источник (позиция обязательна — в тайлах). */ @@ -77,9 +126,10 @@ if (i >= 0) this.extra.splice(i, 1); } - /** Тик: пересобрать активные источники и протолкнуть в движковый слой. */ + /** Тик: ночная цель ambient, источники, тёмные пятна хазардов. */ update(): void { if (this.destroyed) return; + this.updateNightAmbient(); const next = new Set(); for (const def of this.activeDefs()) { for (const pos of this.sourcePositions(def)) { @@ -104,6 +154,42 @@ if (!next.has(id)) this.deps.lighting.removeLight(id); } this.activeIds = next; + this.updateHazardSpots(); + } + + /** Ночная цель ambient: evening + nightAmbient в данных → плавный закат. */ + private updateNightAmbient(): void { + const light = this.deps.area.lighting; + const night = light?.nightAmbient; + const base = light?.ambient; + if (night === undefined || base === undefined) return; + const target = this.deps.nightFlag && this.deps.hasFlag(this.deps.nightFlag) ? night : base; + if (target !== this.lastAmbientTarget) this.setAmbient(target, NIGHT_FADE_SEC); + } + + /** Тёмные ауры зон наката: по тайлу HazardDef, снятие погасших через diff. */ + private updateHazardSpots(): void { + const next = new Set(); + for (const hazard of this.deps.area.hazards ?? []) { + for (const t of hazard.tiles) { + if (next.size >= MAX_DARK_SPOTS) break; + const id = `hazard@${t.x},${t.y}`; + next.add(id); + const world = tileToWorld(t.x + 0.5, t.y + 0.5); + const s = this.deps.camera.toScreen(world.x, world.y); + this.deps.lighting.upsertDarkSpot({ + id, + x: s.x, + y: s.y, + radius: unitsToPx(HAZARD_SPOT_RADIUS), + alpha: HAZARD_SPOT_ALPHA + }); + } + } + for (const id of this.spotIds) { + if (!next.has(id)) this.deps.lighting.removeDarkSpot(id); + } + this.spotIds = next; } /** Состояние для агентного снапшота (интенсивность — пост-мерцание). */ @@ -111,6 +197,7 @@ const frames = this.deps.lighting.frames(); return { ambient: this.deps.lighting.ambientColor, + vignette: Math.round(this.deps.lighting.vignetteLevel * 1000) / 1000, sources: frames.map((f) => ({ id: f.id, x: Math.round(f.x * 10) / 10, @@ -124,6 +211,8 @@ destroy(): void { for (const id of this.activeIds) this.deps.lighting.removeLight(id); this.activeIds.clear(); + for (const id of this.spotIds) this.deps.lighting.removeDarkSpot(id); + this.spotIds.clear(); this.destroyed = true; } @@ -138,7 +227,9 @@ out.push(def); } const lamp = this.deps.lamp; - if (lamp) { + // Лампа в тёмной области или вечером (ночь на светлой улице тоже темна). + const evening = this.deps.nightFlag !== undefined && this.deps.hasFlag(this.deps.nightFlag); + if (lamp && (isDarkAmbient(this.deps.area.lighting?.ambient) || evening)) { // Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5), // поэтому отнимаем 0.5 — источник ляжет ровно на героя. const hero = this.deps.heroPos(); diff --git a/apps/game/src/systems/__tests__/lighting.test.ts b/apps/game/src/systems/__tests__/lighting.test.ts index eb8fa6f..7f2beca 100644 --- a/apps/game/src/systems/__tests__/lighting.test.ts +++ b/apps/game/src/systems/__tests__/lighting.test.ts @@ -5,7 +5,7 @@ const LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; -function fakeArea(sources?: AreaDef['lighting']): AreaDef { +function fakeArea(sources?: AreaDef['lighting'], hazards?: AreaDef['hazards']): AreaDef { return { id: 'meadows', // AreaId — юнион; значение не важно, свет приходит из fakeArea-аргумента name: 'Тест', @@ -14,11 +14,15 @@ enemies: [], transitions: [], atmosphere: 'none', - lighting: sources + lighting: sources, + hazards }; } -function makeGameLighting(area: AreaDef, opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null }) { +function makeGameLighting( + area: AreaDef, + opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null; nightFlag?: string } +) { const engineLighting = new Lighting({ width: 480, height: 270 }); const camera = new Camera(480, 270); camera.snap(10, 10); @@ -31,6 +35,7 @@ camera, heroPos: () => hero, hasFlag: (f) => flags.has(f), + nightFlag: opts?.nightFlag, lamp: opts?.lamp === undefined ? null : opts.lamp }); return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p) }; @@ -99,8 +104,12 @@ }); describe('GameLighting: лампа героя', () => { - it('следует за героем, id стабилен', () => { - const { gl, engineLighting, setHero } = makeGameLighting(fakeArea(), { lamp: LAMP }); + it('следует за героем, id стабилен (вечер на светлой улице)', () => { + const { gl, engineLighting, setHero } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { + flags: new Set(['evening']), + nightFlag: 'evening', + lamp: LAMP + }); gl.update(); const first = engineLighting.frames()[0]; setHero({ x: 7.5, y: 9.5 }); @@ -113,12 +122,72 @@ }); it('радиус в юнитах переводится в px', () => { - const { gl, engineLighting } = makeGameLighting(fakeArea(), { lamp: LAMP }); + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a }), { lamp: LAMP }); gl.update(); // unitsToPx(2.5) = 2.5 * 32 = 80; scale = 80 / GLOW_BASE_PX(32) = 2.5 expect(engineLighting.frames()[0].scale).toBeCloseTo(2.5, 6); gl.destroy(); }); + + it('на светлой улице днём лампы нет, вечером появляется', () => { + const flags = new Set(); + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { + flags, + nightFlag: 'evening', + lamp: LAMP + }); + gl.update(); + expect(engineLighting.hasLight('lamp')).toBe(false); + flags.add('evening'); + gl.update(); + expect(engineLighting.hasLight('lamp')).toBe(true); + gl.destroy(); + }); +}); + +describe('GameLighting: день/ночь', () => { + it('ночная цель ambient по флагу вечера — лерп к nightAmbient', () => { + const flags = new Set(); + const { gl, engineLighting } = makeGameLighting( + fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), + { flags, nightFlag: 'evening' } + ); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xd8dade); // день + flags.add('evening'); + gl.update(); + expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь — цель сменилась + engineLighting.update(0.1); + expect(engineLighting.children[0].tint).not.toBe(0xd8dade); // лерп пошёл + flags.delete('evening'); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xd8dade); // и обратно к дню + gl.destroy(); + }); + + it('без nightAmbient в данных вечер не трогает ambient', () => { + const flags = new Set(['evening']); + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { + flags, + nightFlag: 'evening' + }); + gl.update(); + expect(engineLighting.ambientColor).toBe(0xd8dade); + gl.destroy(); + }); +}); + +describe('GameLighting: тёмные ауры хазардов', () => { + it('пятно на каждый тайл зоны, снятие при исчезновении зоны', () => { + const hazards = [{ name: 'накат', tiles: [{ x: 3, y: 4 }, { x: 4, y: 4 }], requiresItem: 'cloth' as const }]; + const { gl, engineLighting } = makeGameLighting(fakeArea(undefined, hazards)); + gl.update(); + expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(true); + expect(engineLighting.hasDarkSpot('hazard@4,4')).toBe(true); + gl.destroy(); + // Пятна сняты destroy + expect(engineLighting.hasDarkSpot('hazard@3,4')).toBe(false); + }); }); describe('GameLighting: runtime API и снапшот', () => { @@ -143,13 +212,15 @@ gl.destroy(); }); - it('snapshot: ambient + источники с пост-мерцанием', () => { + it('snapshot: ambient + виньетка + источники с пост-мерцанием', () => { const { gl } = makeGameLighting( fakeArea({ ambient: 0x334455, sources: [{ id: 'h', at: { x: 1, y: 1 }, intensity: 0.8, flicker: 0 }] }) ); gl.update(); + gl.setVignette(0.35); const s = gl.snapshot(); expect(s.ambient).toBe(0x334455); + expect(s.vignette).toBe(0.35); expect(s.sources.length).toBe(1); expect(s.sources[0].id).toBe('h'); expect(s.sources[0].intensity).toBeCloseTo(0.8, 3); diff --git a/docs/engine/agent.md b/docs/engine/agent.md index c73f5dc..f957d99 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -44,12 +44,13 @@ презентационные метаданные узла), `cutscene`, `lastToast {text, tick}` (единственный канал текста реакций — иначе агенту нужен OCR), `collision {width, height, blocked (0/1 по тайлам, включает footprint пропов), props}` — -карта коллизий для проверки движения, `lighting {ambient, sources [{id, x, y, -color, intensity}]}` — освещение сцены: ambient — multiply-цвет (0xffffff — -не затемняет), источники — экранные px, `intensity` — с учётом мерцания -(проверки сравнивают с допуском). Инвариант `lighting-bounded`: источников ≤ 24, -значения конечны, 0 ≤ intensity ≤ 2. `MenuScene` отдаёт `{scene: 'menu'}` и -команду `menu:newGame`. +карта коллизий для проверки движения, `lighting {ambient, vignette, sources +[{id, x, y, color, intensity}]}` — освещение сцены: ambient — multiply-цвет +(0xffffff — не затемняет), `vignette` — целевая интенсивность затемнения краёв +0..1, источники — экранные px (импульсы видны как `pulse#N`), `intensity` — с +учётом мерцания (проверки сравнивают с допуском). Инвариант `lighting-bounded`: +источников ≤ 24, значения конечны, 0 ≤ intensity ≤ 2, 0 ≤ vignette ≤ 1. +`MenuScene` отдаёт `{scene: 'menu'}` и команду `menu:newGame`. Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): `scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, @@ -58,7 +59,9 @@ порядок вариантов знать не надо; false — такого варианта нет), `scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}` -(урон сгустку — проверки отступления), `scene:walkable {x, y}` (проходим ли +(урон сгустку — проверки отступления), `scene:damagePlayer {value}` (урон +герою полным боевым путём: вспышка, шейк, виньетка при низком hp), +`scene:walkable {x, y}` (проходим ли тайл: стены, вода, footprint пропов), `scene:raycast {from, to}` (прямая видимость между тайлами: false — высокий объект на отрезке), `scene:synthesize {spec, key?, volume?}` — **звук по описанию**: агент без слуха diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 1d02e34..fd7b7f8 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -362,6 +362,17 @@ 4. Интенсивность в снапшоте — пост-мерцание: проверки сравнивают с допуском (детерминизм даёт `seedOf(id)` по FNV-1a), инвариант `lighting-bounded` — на границы, не на равенство. +5. **День/ночь — сюжетный флаг, не таймер**: смена времени дня ставится контентом + (граф диалога через `setFlags`, валидатор держит флаг упомянутым), а + `GameLighting.update` лишь следит за целью — вечер + `nightAmbient` в данных → + `setAmbient(nightAmbient, 6)` (guard по последней цели — без повторных запусков + лерпа). Ночные источники — обычный `whenFlag` в данных. +6. **Импульсы (вспышки) — тот же пул источников**: движковый `pulseLight` занимает + спрайт пула и снимается сам по концу огибающей (`PulseSpec`/`pulseEnvelope` + в lightSim — чистая математика, тестируется в node); снапшот и инварианты + видят импульсы бесплатно. Вспышка «на весь экран» (`pulseAmbient`) — один + слот, последний побеждает. В сценах не забыть `maxLights: MAX_LIGHTS` — + иначе движковый пул (16) молча теряет источники сверх себя. ## Грабли среды (кратко, подробности в CLAUDE.md)