diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 4c3d0bf..829b321 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -33,7 +33,9 @@ LocationSnapshot, InteractableSnapshot } from './snapshot'; -import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer, collisionLayer } from './snapshot'; +import { heroLayer, enemiesLayer, npcsLayer, dialogueLayer, collisionLayer, lightingLayer } from './snapshot'; +import type { GameLighting } from '../systems/Lighting'; +import { MAX_LIGHTS } from '../systems/Lighting'; import { hasLineOfSight } from '../systems/combat/los'; /** Допустимые kind спек-синтеза (как в движке, строками — спек идёт по мосту). */ @@ -63,6 +65,8 @@ cutscene: CutsceneRunner; lastToast(): { text: string; tick: number } | null; inHazard(): HazardDef | null; + /** Игровая обвязка освещения (ambient + источники для снапшота). */ + lighting(): GameLighting; /** Камера в ногах героя (snap — телепорты). */ followCamera(snap: boolean): void; } @@ -122,6 +126,7 @@ transitions, interactables, collision: collisionLayer(d.map.data).collision as unknown as LocationSnapshot['collision'], + lighting: lightingLayer(d.lighting().snapshot()).lighting as unknown as LocationSnapshot['lighting'], dialogue: dialogueLayer(d.dialogue.agentState).dialogue as LocationSnapshot['dialogue'], cutscene: { active: d.cutscene.active }, lastToast: d.lastToast() @@ -158,10 +163,27 @@ checkWalkable('герой', heroTile, d.map, where), enemyChecks, this.registryInvariant(where), + this.lightingInvariant(where), d.combat.agentInvariants() ); } + /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, источников ≤24. */ + private lightingInvariant(where: string): Invariant[] { + const bad: Invariant = { + id: 'lighting-bounded', + severity: 'error', + message: 'источники света вне допустимых пределов', + where + }; + const l = this.deps.lighting().snapshot(); + if (l.sources.length > MAX_LIGHTS || !Number.isFinite(l.ambient)) 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]; + } + return []; + } + /** Реестр согласован с ECS: у каждого живого врага есть запись, позиции совпадают. */ private registryInvariant(where: string): Invariant[] { const out: Invariant[] = []; diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index 4d75191..e0dd58f 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -6,6 +6,7 @@ enemiesLayer, gameLayer, heroLayer, + lightingLayer, npcsLayer, type EnemySnapshot, type GameSnapshot, @@ -68,6 +69,28 @@ }); }); + it('свет: ambient как есть, источники с округлением координат и интенсивности', () => { + const layer = lightingLayer({ + ambient: 0x54586a, + 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 } + ] + }); + expect(layer.lighting).toEqual({ + ambient: 0x54586a, + 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 } + ] + }); + }); + + it('свет: пустой список источников допустим (дневная улица)', () => { + const layer = lightingLayer({ ambient: 0xffffff, sources: [] }); + expect(layer.lighting).toEqual({ ambient: 0xffffff, sources: [] }); + }); + it('NPC: копия с met, без ссылок на источник', () => { const n: NpcSnapshot = { id: 'elder', name: 'Ирвин', tile: { x: 20, y: 12 }, met: false }; const layer = npcsLayer([n]); @@ -128,6 +151,7 @@ transitions: [], interactables: [], collision: { width: 1, height: 1, blocked: [0], props: [] }, + lighting: { ambient: 0xffffff, sources: [] }, dialogue: null, cutscene: null, lastToast: null diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index 286599e..dfe0492 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -18,6 +18,7 @@ 'transitions', 'interactables', 'collision', + 'lighting', 'dialogue', 'cutscene', 'lastToast', @@ -90,6 +91,21 @@ used: boolean; } +/** Источник света (пост-мерцание). */ +export interface LightSourceSnapshot { + id: string; + x: number; + y: number; + color: number; + intensity: number; +} + +/** Освещение сцены: ambient (multiply-цвет) + активные источники. */ +export interface LightingSnapshot { + ambient: number; + sources: LightSourceSnapshot[]; +} + /** Карта коллизий для агента: стены/вода по тайлам + крупные пропы. */ export interface CollisionSnapshot { width: number; @@ -111,6 +127,8 @@ transitions: TransitionSnapshot[]; interactables: InteractableSnapshot[]; collision: CollisionSnapshot; + /** Освещение области (ambient + активные источники). */ + lighting: LightingSnapshot; dialogue: DialogueSnapshot | null; cutscene: { active: boolean } | null; /** Последний тост (текст + тик) — единственный канал текста реакций. */ @@ -198,6 +216,22 @@ return { dialogue: d ? (d as unknown as JsonValue) : null }; } +/** Слой освещения: ambient + активные источники (координаты экранные px). */ +export function lightingLayer(o: LightingSnapshot): SnapshotLayer { + return { + lighting: { + ambient: o.ambient, + sources: o.sources.map((s) => ({ + id: s.id, + x: r(s.x), + y: r(s.y), + color: s.color, + intensity: r(s.intensity) + })) + } as unknown as JsonValue + }; +} + /** Слой уровня игры (вне сцен). */ export function gameLayer(o: { flags: string[]; diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index 67b00dc..1bb6427 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -60,6 +60,33 @@ volume?: number; } +/** Источник света: точка в тайлах или все тайлы заданных id; радиус в юнитах. */ +export interface LightSourceDef { + id: string; + /** Центр источника в тайлах (центр ромба: +0.5); для at/tiles — одно из двух. */ + at?: { x: number; y: number }; + /** Альтернатива: свет на каждом тайле этого id (позиции из карты). */ + tiles?: number[]; + /** Радиус в юнитах (1 юнит = тайл; по умолчанию 3). */ + radius?: number; + /** Цвет свечения (по умолчанию 0xf2b45a — тёплый F1). */ + color?: number; + /** Яркость (по умолчанию 0.9). */ + intensity?: number; + /** Амплитуда мерцания 0..1 (по умолчанию 0 — ровный). */ + flicker?: number; + /** Источник активен только при наличии (whenFlag) / отсутствии (notFlag) флага. */ + whenFlag?: string; + notFlag?: string; +} + +/** Освещение области: ambient (multiply-цвет) + статические источники. */ +export interface LightingDef { + /** 0xffffff — не трогает сцену; тёмный/цветной — тон и затемнение сцены. */ + ambient?: number; + sources?: LightSourceDef[]; +} + export interface AreaDef { id: AreaId; name: string; @@ -76,6 +103,8 @@ interactables?: InteractableDef[]; /** Атмосферные частицы: 'ash' — пепел, 'fog' — туман прудов, 'none' — интерьер. */ atmosphere: 'ash' | 'fog' | 'none'; + /** Освещение: ambient-тон + статические источники (очаг, окна). */ + lighting?: LightingDef; /** Локальные амбиент-слои поверх областного (вода, гул башни, дома). */ ambienceLayers?: AmbienceLayerDef[]; /** Зоны наката (необязательно). */ diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1b72181..b1fea93 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -2,6 +2,7 @@ Container, Graphics, IsoDepthLayer, + Lighting, ParticleEmitter, PixelText, Sprite, @@ -67,9 +68,21 @@ 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 { 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; +} + /** * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. */ @@ -111,6 +124,9 @@ private interactViews: { def: InteractableDef; view: Container; body: Graphics | Sprite; label: PixelText }[] = []; /** Оверлей дымки (зоны наката). Виден только в низинах. */ private fog: Graphics; + /** Освещение: движковый слой (lightRoot) + игровая обвязка. */ + private lightView: Lighting; + private lighting: GameLighting; /** Пейзажная фауна (безгласные олени). */ private fauna: FaunaSystem; /** Позиционный звук мира + события боя (звук — подпиской). */ @@ -378,6 +394,7 @@ cutscene: this.cutscene, lastToast: () => this.lastToast, inHazard: () => this.inHazard, + lighting: () => this.lighting, followCamera: (snap) => this.updateCameraFollow(snap) }); @@ -438,6 +455,23 @@ this.fog.alpha = 0; this.game.renderer.uiRoot.addChildAt(this.fog, 0); + // Освещение: ambient области + статические источники (очаг, окна, лампа). + this.lightView = new Lighting({ + width: Game.VIRTUAL_W, + height: Game.VIRTUAL_H, + renderer: this.game.renderer + }); + this.game.renderer.lightRoot.addChild(this.lightView); + this.lighting = new GameLighting({ + area, + lighting: this.lightView, + layerTilePos: this.layerTilePos, + camera: this.camera, + heroPos: () => this.player.position, + hasFlag: (f) => this.game.state.hasFlag(f), + lamp: darkAreaLamp(area) + }); + // Название локации в правом верхнем углу. const name = new PixelText({ text: area.name, size: 11, color: 0x999988 }); name.anchor.set(1, 0); @@ -518,6 +552,8 @@ this.world.destroy({ children: true }); this.ash?.destroy(); this.moths?.destroy(); + this.lighting.destroy(); + this.lightView.destroy({ children: true }); this.fog.destroy(); this.healthBar.destroy({ children: true }); this.hint.destroy({ children: true }); @@ -532,6 +568,7 @@ this.worldAudio.update(dt); this.updateAudioLayers(); this.updateAmbient(dt); + this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts new file mode 100644 index 0000000..ea3d123 --- /dev/null +++ b/apps/game/src/systems/Lighting.ts @@ -0,0 +1,164 @@ +/** + * Игровая обвязка освещения: статика из LightingDef области + лампа героя, + * проекция в экранные px каждый тик (camera.toScreen), условные источники + * по флагам. Runtime API (setAmbient/addLight) — база day/night и эффектов. + */ + +import { Camera, Lighting, tileToWorld, unitsToPx, type Vec2 } from '@rpg/engine'; +import type { AreaDef, LightSourceDef } from '../data/locations'; + +/** Параметры лампы героя (следует за героем в тёмных областях). */ +export interface HeroLampDef { + color: number; + radius: number; // юниты + intensity: number; + flicker: number; +} + +/** Кап активных источников — защита от опечатки в данных (tiles одного id). */ +export const MAX_LIGHTS = 24; + +export interface GameLightingDeps { + area: AreaDef; + /** Движковый адаптер (уже добавлен в renderer.lightRoot). */ + lighting: Lighting; + /** Позиции тайлов по id (один проход по карте, см. LocationScene). */ + layerTilePos: Map; + camera: Camera; + /** Позиция героя в мировых юнитах. */ + heroPos: () => Vec2; + hasFlag: (flag: string) => boolean; + lamp?: HeroLampDef | null; +} + +export interface LightingSnapshot { + ambient: number; + sources: { id: string; x: number; y: number; color: number; intensity: number }[]; +} + +/** Детерминированная фаза мерцания по 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 class GameLighting { + private readonly deps: GameLightingDeps; + /** Runtime-источники поверх статики области (эффекты, скрипты). */ + private readonly extra: LightSourceDef[] = []; + /** Активные id на прошлом тике — чтобы снимать погасшие (условия по флагам). */ + private activeIds = new Set(); + private destroyed = false; + + constructor(deps: GameLightingDeps) { + this.deps = deps; + if (deps.area.lighting?.ambient !== undefined) { + deps.lighting.setAmbient(deps.area.lighting.ambient); + } + } + + // --- runtime поверх статики области (фундамент day/night и эффектов) --- + + setAmbient(color: number, fadeSec = 0): void { + this.deps.lighting.setAmbient(color, fadeSec); + } + + /** Динамически добавить источник (позиция обязательна — в тайлах). */ + addLight(def: LightSourceDef & { at: { x: number; y: number } }): void { + this.extra.push(def); + } + + removeLight(id: string): void { + const i = this.extra.findIndex((d) => d.id === id); + if (i >= 0) this.extra.splice(i, 1); + } + + /** Тик: пересобрать активные источники и протолкнуть в движковый слой. */ + update(): void { + if (this.destroyed) return; + const next = new Set(); + for (const def of this.activeDefs()) { + for (const pos of this.sourcePositions(def)) { + if (next.size >= MAX_LIGHTS) break; + const id = def.tiles ? `${def.id}@${pos.x},${pos.y}` : def.id; + next.add(id); + const world = tileToWorld(pos.x + 0.5, pos.y + 0.5); + const s = this.deps.camera.toScreen(world.x, world.y); + this.deps.lighting.upsertLight({ + id, + x: s.x, + y: s.y, + color: def.color ?? 0xf2b45a, + intensity: def.intensity ?? 0.9, + radius: unitsToPx(def.radius ?? 3), + flicker: def.flicker ?? 0, + seed: seedOf(id) + }); + } + } + for (const id of this.activeIds) { + if (!next.has(id)) this.deps.lighting.removeLight(id); + } + this.activeIds = next; + } + + /** Состояние для агентного снапшота (интенсивность — пост-мерцание). */ + snapshot(): LightingSnapshot { + const frames = this.deps.lighting.frames(); + return { + ambient: this.deps.lighting.ambientColor, + sources: frames.map((f) => ({ + id: f.id, + x: Math.round(f.x * 10) / 10, + y: Math.round(f.y * 10) / 10, + color: f.tint, + intensity: Math.round(f.alpha * 1000) / 1000 + })) + }; + } + + destroy(): void { + for (const id of this.activeIds) this.deps.lighting.removeLight(id); + this.activeIds.clear(); + this.destroyed = true; + } + + // --- внутреннее --- + + private activeDefs(): LightSourceDef[] { + const out: LightSourceDef[] = []; + const all = [...(this.deps.area.lighting?.sources ?? []), ...this.extra]; + for (const def of all) { + if (def.whenFlag && !this.deps.hasFlag(def.whenFlag)) continue; + if (def.notFlag && this.deps.hasFlag(def.notFlag)) continue; + out.push(def); + } + const lamp = this.deps.lamp; + if (lamp) { + // Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5), + // поэтому отнимаем 0.5 — источник ляжет ровно на героя. + const hero = this.deps.heroPos(); + out.push({ + id: 'lamp', + at: { x: hero.x - 0.5, y: hero.y - 0.5 }, + radius: lamp.radius, + color: lamp.color, + intensity: lamp.intensity, + flicker: lamp.flicker + }); + } + return out; + } + + /** Позиции источника в тайлах: точка at или все тайлы заданных id. */ + private sourcePositions(def: LightSourceDef): { x: number; y: number }[] { + if (def.at) return [def.at]; + const out: { x: number; y: number }[] = []; + for (const id of def.tiles ?? []) out.push(...(this.deps.layerTilePos.get(id) ?? [])); + return out; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/lighting.test.ts b/apps/game/src/systems/__tests__/lighting.test.ts new file mode 100644 index 0000000..eb8fa6f --- /dev/null +++ b/apps/game/src/systems/__tests__/lighting.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { Camera, Lighting, tileToWorld } from '@rpg/engine'; +import { GameLighting, type HeroLampDef } from '../Lighting'; +import type { AreaDef } from '../../data/locations'; + +const LAMP: HeroLampDef = { color: 0xf2b45a, radius: 2.5, intensity: 0.5, flicker: 0.12 }; + +function fakeArea(sources?: AreaDef['lighting']): AreaDef { + return { + id: 'meadows', // AreaId — юнион; значение не важно, свет приходит из fakeArea-аргумента + name: 'Тест', + spawn: { x: 1, y: 1 }, + npcs: [], + enemies: [], + transitions: [], + atmosphere: 'none', + lighting: sources + }; +} + +function makeGameLighting(area: AreaDef, opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null }) { + const engineLighting = new Lighting({ width: 480, height: 270 }); + const camera = new Camera(480, 270); + camera.snap(10, 10); + const flags = opts?.flags ?? new Set(); + let hero = opts?.hero ?? { x: 5, y: 5 }; + const gl = new GameLighting({ + area, + lighting: engineLighting, + layerTilePos: new Map([[11, [{ x: 2, y: 3 }, { x: 8, y: 3 }]]]), // TILES.HOUSE = 11 + camera, + heroPos: () => hero, + hasFlag: (f) => flags.has(f), + lamp: opts?.lamp === undefined ? null : opts.lamp + }); + return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p) }; +} + +describe('GameLighting: статика области', () => { + it('ambient из данных применяется к движковому слою', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0x54586a })); + expect(engineLighting.ambientColor).toBe(0x54586a); + gl.destroy(); + }); + + it('деф с at — один источник в центре ромба', () => { + const { gl, engineLighting, camera } = makeGameLighting( + fakeArea({ sources: [{ id: 'hearth', at: { x: 8, y: 0 }, intensity: 1, flicker: 0 }] }) + ); + gl.update(); + const frames = engineLighting.frames(); + expect(frames.length).toBe(1); + expect(frames[0].id).toBe('hearth'); + // Позиция — ровно проекция центра ромба тайла (8.5, 0.5) + const w = tileToWorld(8.5, 0.5); + const s = camera.toScreen(w.x, w.y); + expect(frames[0].x).toBeCloseTo(s.x, 3); + expect(frames[0].y).toBeCloseTo(s.y, 3); + gl.destroy(); + }); + + it('деф с tiles — источник на каждый тайл id', () => { + const { gl, engineLighting } = makeGameLighting( + fakeArea({ sources: [{ id: 'window', tiles: [11], intensity: 0.4, flicker: 0 }] }) + ); + gl.update(); + const ids = engineLighting.frames().map((f) => f.id).sort(); + expect(ids).toEqual(['window@2,3', 'window@8,3']); + gl.destroy(); + }); + + it('whenFlag/notFlag управляют активностью', () => { + const { gl, engineLighting } = makeGameLighting( + fakeArea({ + sources: [ + { id: 'off', at: { x: 1, y: 1 }, whenFlag: 'quest_bells_done', flicker: 0 }, + { id: 'on', at: { x: 1, y: 1 }, notFlag: 'quest_bells_done', flicker: 0 } + ] + }) + ); + gl.update(); + expect(engineLighting.frames().map((f) => f.id)).toEqual(['on']); + gl.destroy(); + }); + + it('погасшие источники снимаются с движкового слоя (флаг-условие)', () => { + const flags = new Set(); + const { gl, engineLighting } = makeGameLighting( + fakeArea({ sources: [{ id: 'a', at: { x: 1, y: 1 }, flicker: 0, notFlag: 'lit' }] }), + { flags } + ); + gl.update(); + expect(engineLighting.hasLight('a')).toBe(true); + flags.add('lit'); + gl.update(); + expect(engineLighting.hasLight('a')).toBe(false); + gl.destroy(); + }); +}); + +describe('GameLighting: лампа героя', () => { + it('следует за героем, id стабилен', () => { + const { gl, engineLighting, setHero } = makeGameLighting(fakeArea(), { lamp: LAMP }); + gl.update(); + const first = engineLighting.frames()[0]; + setHero({ x: 7.5, y: 9.5 }); + gl.update(); + const second = engineLighting.frames()[0]; + expect(first.id).toBe('lamp'); + expect(second.id).toBe('lamp'); + expect(second.x).not.toBe(first.x); // лампа переехала вместе с героем + gl.destroy(); + }); + + it('радиус в юнитах переводится в px', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea(), { 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(); + }); +}); + +describe('GameLighting: runtime API и снапшот', () => { + it('addLight/removeLight поверх статики', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea()); + gl.addLight({ id: 'fx', at: { x: 3, y: 3 }, flicker: 0 }); + gl.update(); + expect(engineLighting.hasLight('fx')).toBe(true); + gl.removeLight('fx'); + gl.update(); + expect(engineLighting.hasLight('fx')).toBe(false); + gl.destroy(); + }); + + it('setAmbient пробрасывается в движковый слой с лерпом', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xffffff })); + gl.setAmbient(0x000000, 1); + engineLighting.update(0.5); + expect(engineLighting.ambientColor).toBe(0x000000); + expect(engineLighting.children[0].tint).not.toBe(0xffffff); + expect(engineLighting.children[0].tint).not.toBe(0x000000); + gl.destroy(); + }); + + it('snapshot: ambient + источники с пост-мерцанием', () => { + const { gl } = makeGameLighting( + fakeArea({ ambient: 0x334455, sources: [{ id: 'h', at: { x: 1, y: 1 }, intensity: 0.8, flicker: 0 }] }) + ); + gl.update(); + const s = gl.snapshot(); + expect(s.ambient).toBe(0x334455); + expect(s.sources.length).toBe(1); + expect(s.sources[0].id).toBe('h'); + expect(s.sources[0].intensity).toBeCloseTo(0.8, 3); + expect(Number.isFinite(s.sources[0].x)).toBe(true); + gl.destroy(); + }); + + it('destroy снимает все источники', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea(), { lamp: LAMP }); + gl.update(); + gl.destroy(); + expect(engineLighting.frames().length).toBe(0); + }); +}); \ No newline at end of file diff --git a/docs/engine/agent.md b/docs/engine/agent.md index a19dd05..c73f5dc 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -44,7 +44,11 @@ презентационные метаданные узла), `cutscene`, `lastToast {text, tick}` (единственный канал текста реакций — иначе агенту нужен OCR), `collision {width, height, blocked (0/1 по тайлам, включает footprint пропов), props}` — -карта коллизий для проверки движения. `MenuScene` отдаёт `{scene: 'menu'}` и +карта коллизий для проверки движения, `lighting {ambient, sources [{id, x, y, +color, intensity}]}` — освещение сцены: ambient — multiply-цвет (0xffffff — +не затемняет), источники — экранные px, `intensity` — с учётом мерцания +(проверки сравнивают с допуском). Инвариант `lighting-bounded`: источников ≤ 24, +значения конечны, 0 ≤ intensity ≤ 2. `MenuScene` отдаёт `{scene: 'menu'}` и команду `menu:newGame`. Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 9fed86b..1d02e34 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -344,6 +344,25 @@ (тело дома занимает (tx−1,ty−1), отдаём базовому тайлу — двери кликаются по спрайту); роутер подставляет его ДО `resolveClick` — чистый резолвер не меняется. +## Ситуация: добавляю свет / экранное пространство + +1. **Свет живёт в `renderer.lightRoot` — экранное пространство, не в + `worldRoot`.** Ambient обязан быть экранного размера (fullscreen multiply), + источники — над затемнением (additive под multiply умрёт). Всё, что + «пятно на экране» (гало, виньетка, вспышка), — туда же; что едет с миром — + в `worldRoot`. +2. **Проекция мировых точек в экранные слои — `camera.toScreen(wx, wy)` каждый + тик** (см. `GameLighting.update`): без лага на кадр и без чтения + `worldRoot.position` (он обновляется позже — свет отставал бы от камеры на + шаг). Позиции источников сцена считает сама — движок изометрии не знает. +3. **Runtime-статика**: дефы области (`AreaDef.lighting`) — только начальное + состояние; всё эффектное — через runtime API (`setAmbient` с лерпом, + `addLight`/`removeLight`) — база day/night и визуальных эффектов. Условные + источники (очаг после квеста) — через `whenFlag`/`notFlag` в данных, не кодом. +4. Интенсивность в снапшоте — пост-мерцание: проверки сравнивают с допуском + (детерминизм даёт `seedOf(id)` по FNV-1a), инвариант `lighting-bounded` — + на границы, не на равенство. + ## Грабли среды (кратко, подробности в CLAUDE.md) - **Раскладка тулз по правилу «знает ли контент игры»**: знает (палитра, diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts index d609254..3d545c5 100644 --- a/packages/engine/src/render/Lighting.ts +++ b/packages/engine/src/render/Lighting.ts @@ -179,11 +179,11 @@ return out; } - override destroy(): void { + override destroy(options?: Parameters[0]): void { for (const id of [...this.lights.keys()]) this.removeLight(id); for (const s of this.free) s.destroy(); this.free.length = 0; this.ambient.destroy(); - super.destroy(); + super.destroy(options); } } \ No newline at end of file diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts index 9ad1550..186aeb6 100644 --- a/packages/engine/src/render/lightSim.ts +++ b/packages/engine/src/render/lightSim.ts @@ -34,6 +34,7 @@ /** Видимое состояние источника на шаге. */ export interface LightFrame { + id: string; x: number; y: number; tint: number; @@ -61,7 +62,7 @@ const alpha = enabled ? intensity * flickerFactor(time, def.seed ?? 0, def.flicker ?? 0, def.flickerPeriod ?? 0.9) : 0; - return { x: def.x, y: def.y, tint: def.color, alpha, scale: radius / GLOW_BASE_PX }; + return { id: def.id, x: def.x, y: def.y, tint: def.color, alpha, scale: radius / GLOW_BASE_PX }; } /** Лерп ambient-цвета по каналам: k=0 → from, k=1 → to. */