diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 71681d7..0fbfa8f 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -16,6 +16,7 @@ } from '@rpg/engine'; import { AUDIO_KEYS } from './data/audio'; import { THEMES } from './data/music'; +import { GameClock } from './systems/Clock'; import { registerStateMigrations } from './data/migrations'; // Шрифт идёт с движком (OFL): Vite кладёт файл в сборку и даёт готовый URL. import fontUrl from '@rpg/engine/assets/fonts/VT323-Regular.ttf?url'; @@ -37,6 +38,8 @@ readonly state = new GameState(); /** Сумка героя (предметы квестов и торговли). */ readonly inventory = new Inventory(); + /** Время суток (непрерывный цикл; 1 с реального = 1 игровая минута). */ + readonly clock = new GameClock(); /** Настройки игрока (громкости и т.п.) — вне сейвов. */ readonly settings = new Settings(window.localStorage); diff --git a/apps/game/src/agent/GameAgent.ts b/apps/game/src/agent/GameAgent.ts index 6a9df46..3894d3b 100644 --- a/apps/game/src/agent/GameAgent.ts +++ b/apps/game/src/agent/GameAgent.ts @@ -1,5 +1,6 @@ import { EngineAgent, + dayNightFactor, worldToScreen, type AgentHost, type Invariant, @@ -91,7 +92,12 @@ Object.assign(s, gameLayer({ flags: this.game.state.allFlags, vars: { ...this.game.state.serialize().vars }, - inventory: this.game.inventory.all + inventory: this.game.inventory.all, + time: { + minutes: this.game.clock.minutes, + label: this.game.clock.label, + night: dayNightFactor(this.game.clock.hours) + } })); return s; } diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 045c0e5..4980d59 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -1,4 +1,5 @@ import { + dayNightFactor, findPath, checkFinite, checkRange, @@ -167,10 +168,30 @@ enemyChecks, this.registryInvariant(where), this.lightingInvariant(where), + this.timeInvariant(where), d.combat.agentInvariants() ); } + /** Время суток в допустимых пределах: конечные минуты 0..1439, night 0..1, label «H:MM». */ + private timeInvariant(where: string): Invariant[] { + const bad: Invariant = { + id: 'time-bounded', + severity: 'error', + message: 'время суток вне допустимых пределов', + where + }; + const t = { + minutes: this.deps.game.clock.minutes, + label: this.deps.game.clock.label, + night: dayNightFactor(this.deps.game.clock.hours) + }; + if (!Number.isFinite(t.minutes) || t.minutes < 0 || t.minutes >= 1440) return [bad]; + if (!Number.isFinite(t.night) || t.night < 0 || t.night > 1) return [bad]; + if (!/^\d{1,2}:\d{2}$/.test(t.label)) return [bad]; + return []; + } + /** Свет в допустимых пределах: конечные значения, интенсивность 0..2, виньетка 0..1, источников ≤24. */ private lightingInvariant(where: string): Invariant[] { const bad: Invariant = { @@ -224,6 +245,7 @@ value?: number | string | boolean; flag?: string; index?: number; + hours?: number; text?: string; from?: { x?: number; y?: number }; to?: { x?: number; y?: number }; @@ -271,6 +293,11 @@ if (typeof a.flag !== 'string') return null; d.game.state.setFlag(a.flag); return true; + case 'scene:setTime': + // Перемотка времени суток (часы → минуты) — детерминизм проверок света. + if (typeof a.hours !== 'number') return null; + d.game.clock.set(a.hours * 60); + return true; case 'scene:teleport': { if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; d.player.teleportTo({ x: a.x, y: a.y }); diff --git a/apps/game/src/agent/__tests__/snapshot.test.ts b/apps/game/src/agent/__tests__/snapshot.test.ts index 5db5200..3ed7a48 100644 --- a/apps/game/src/agent/__tests__/snapshot.test.ts +++ b/apps/game/src/agent/__tests__/snapshot.test.ts @@ -117,19 +117,27 @@ expect(dialogueLayer(null).dialogue).toBeNull(); }); - it('слой игры: флаги/вары/сумка копируются защитно', () => { + it('слой игры: флаги/вары/сумка/время копируются защитно', () => { const flags = ['metElder']; const vars = { flowers: 2 }; const inv = [{ id: 'flower', count: 2 }]; - const layer = gameLayer({ flags, vars, inventory: inv }); - expect(layer).toEqual({ flags: ['metElder'], vars: { flowers: 2 }, inventory: [{ id: 'flower', count: 2 }] }); + const time = { minutes: 480, label: '8:00', night: 0 }; + const layer = gameLayer({ flags, vars, inventory: inv, time }); + expect(layer).toEqual({ + flags: ['metElder'], + vars: { flowers: 2 }, + inventory: [{ id: 'flower', count: 2 }], + time: { minutes: 480, label: '8:00', night: 0 } + }); // Мутация источника не меняет слой. flags.push('x'); vars.flowers = 99; inv[0]!.count = 99; + time.minutes = 999; expect(layer.flags).toEqual(['metElder']); expect(layer.vars).toEqual({ flowers: 2 }); expect(layer.inventory[0]!.count).toBe(2); + expect(layer.time.minutes).toBe(480); }); }); @@ -163,7 +171,8 @@ const gamePart: GameSnapshot = gameLayer({ flags: [], vars: {}, - inventory: [] + inventory: [], + time: { minutes: 480, label: '8:00', night: 0 } }) as GameSnapshot; const keys = new Set([...Object.keys(locationSample), ...Object.keys(gamePart)]); expect([...keys].filter((k) => !AGENT_SNAPSHOT_KEYS.includes(k))).toEqual([]); @@ -177,7 +186,7 @@ it('в списке нет лишних (список = сцена + игра + движок)', () => { const locationKeys = Object.keys(locationSample); - const gameKeys = ['flags', 'vars', 'inventory']; + const gameKeys = ['flags', 'vars', 'inventory', 'time']; const extra = AGENT_SNAPSHOT_KEYS.filter( (k) => !locationKeys.includes(k) && !gameKeys.includes(k) && !['tick', 'fps', 'fixedStep', 'scenes', 'transitioning', 'camera', 'pointer'].includes(k) ); diff --git a/apps/game/src/agent/snapshot.ts b/apps/game/src/agent/snapshot.ts index c82fd16..914795b 100644 --- a/apps/game/src/agent/snapshot.ts +++ b/apps/game/src/agent/snapshot.ts @@ -25,7 +25,8 @@ // слой игры (gameLayer) 'flags', 'vars', - 'inventory' + 'inventory', + 'time' ]; /** Герой. */ @@ -137,6 +138,16 @@ lastToast: { text: string; tick: number } | null; } +/** Время суток (ход игры — непрерывный, 1 с реального = 1 игровая минута). */ +export interface TimeSnapshot { + /** Целые минуты суток 0..1439. */ + minutes: number; + /** Строка «8:24» (как на часах предмета). */ + label: string; + /** Фактор ночи 0..1 (dayNightFactor по часам). */ + night: number; +} + /** Полный снапшот игры: движковый слой + слои игры. */ export interface GameSnapshot { [k: string]: JsonValue | unknown; @@ -146,6 +157,8 @@ vars: Record; /** Сумка. */ inventory: { id: string; count: number }[]; + /** Время суток. */ + time: TimeSnapshot; } /** Округление до 3 знаков — JSON читабельнее, точность в юнитах не теряется. */ @@ -240,10 +253,12 @@ flags: string[]; vars: Record; inventory: { id: string; count: number }[]; + time: TimeSnapshot; }): GameSnapshot { return { flags: [...o.flags], vars: { ...o.vars }, - inventory: o.inventory.map((s) => ({ id: s.id, count: s.count })) + inventory: o.inventory.map((s) => ({ id: s.id, count: s.count })), + time: { ...o.time } }; } \ No newline at end of file diff --git a/apps/game/src/data/__tests__/migrations.test.ts b/apps/game/src/data/__tests__/migrations.test.ts index 95c260f..5d20426 100644 --- a/apps/game/src/data/__tests__/migrations.test.ts +++ b/apps/game/src/data/__tests__/migrations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { GameState } from '@rpg/engine'; -import { migrateV1toV2, registerStateMigrations, STATE_DATA_VERSION } from '../migrations'; +import { migrateV1toV2, migrateV2toV3, registerStateMigrations, STATE_DATA_VERSION } from '../migrations'; describe('миграции GameStateData', () => { it('v1→v2: знакомства переименованы, остальное перенесено, version=2', () => { @@ -14,7 +14,18 @@ expect(out.vars).toEqual({ flowers: 3 }); }); - it('старый сейв v1 читается state.load с миграцией', () => { + it('v2→v3: флаг evening удалён, остальное перенесено, version=3', () => { + const out = migrateV2toV3({ + version: 2, + flags: ['evening', 'quest_bells_done', 'hint_bells'], + vars: { flowers: 3 } + }); + expect(out.version).toBe(3); + expect(out.flags).toEqual(['quest_bells_done', 'hint_bells']); + expect(out.vars).toEqual({ flowers: 3 }); + }); + + it('старый сейв v1 читается state.load с миграцией до текущей версии', () => { const s = new GameState(); registerStateMigrations(s); expect(s.dataVersion).toBe(STATE_DATA_VERSION); diff --git a/apps/game/src/data/dialogues/elder_hand_in.json b/apps/game/src/data/dialogues/elder_hand_in.json index 50dafda..afe62f1 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", "evening"], + "setFlags": ["quest_bells_done"], "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 62cd767..72f22f0 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -27,9 +27,7 @@ /** Прочитана записка в доме Ирвина (интерактив note_elder). */ read_note: 'read_note', /** Подсказка «как собирать колокольчики» уже показана (вход в пруды). */ - hint_bells: 'hint_bells', - /** Вечер настал (диалог elder_hand_in: «вернусь до темноты» исполнено). */ - evening: 'evening' + hint_bells: 'hint_bells' } 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 f4bdd23..3008d09 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -79,6 +79,8 @@ /** Источник активен только при наличии (whenFlag) / отсутствии (notFlag) флага. */ whenFlag?: string; notFlag?: string; + /** Ночной источник: горит при факторе ночи ≥ 0.5 (вечер/ночь по часам). */ + whenNight?: boolean; } /** Освещение области: ambient (multiply-цвет) + статические источники. */ @@ -156,8 +158,8 @@ } ], atmosphere: 'ash', - // Светлый пепельный день: чуть холоднее белого. Вечером (флаг evening, - // после сдачи квеста) — синие сумерки: лунные колокольчики светятся. + // Светлый пепельный день: чуть холоднее белого. Вечером (по часам) + // — синие сумерки: лунные колокольчики светятся. lighting: { ambient: 0xd8dade, nightAmbient: 0x6a7288, @@ -169,7 +171,7 @@ radius: 1.4, intensity: 0.5, flicker: 0.15, - whenFlag: FLAGS.evening + whenNight: true } ] }, @@ -273,17 +275,17 @@ ], interactables: INTERACTABLES.zvenets, atmosphere: 'ash', - // Серые сумерки деревни; вечером — синие сумерки и тёплые окна трёх - // домов; после звонка — холодный отсвет резонанса от башни. + // Серые сумерки деревни; вечером (по часам) — синие сумерки и тёплые + // окна трёх домов; после звонка — холодный отсвет резонанса от башни. lighting: { ambient: 0xdad6d0, nightAmbient: 0x7b8298, sources: [ // Окна — на теле спрайта дома (тело занимает тайл выше-левее базового); // днём не горят, зажигаются вечером. - { id: 'window_elder', at: { x: 6.5, y: 5.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1, whenFlag: FLAGS.evening }, - { id: 'window_north', at: { x: 17.5, y: 4.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1, whenFlag: FLAGS.evening }, - { id: 'window_shop', at: { x: 20.5, y: 8.5 }, radius: 1.8, intensity: 0.45, flicker: 0.1, whenFlag: FLAGS.evening }, + { 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: 'tower_resonance', at: { x: 14, y: 7 }, diff --git a/apps/game/src/data/migrations.ts b/apps/game/src/data/migrations.ts index 3388a1d..200e756 100644 --- a/apps/game/src/data/migrations.ts +++ b/apps/game/src/data/migrations.ts @@ -8,7 +8,7 @@ */ /** Текущая версия формата данных состояния. */ -export const STATE_DATA_VERSION = 2; +export const STATE_DATA_VERSION = 3; /** v1→v2: знакомства metElder/metTrader → единый snake_case реестра FLAGS. */ export function migrateV1toV2(data: GameStateData): GameStateData { @@ -17,8 +17,18 @@ return { version: 2, flags: data.flags.map(rename), vars: { ...data.vars } }; } +/** v2→v3: флаг evening удалён — вечер теперь наступает по внутриигровым часам. */ +export function migrateV2toV3(data: GameStateData): GameStateData { + return { + version: 3, + flags: data.flags.filter((f) => f !== 'evening'), + vars: { ...data.vars } + }; +} + /** Зарегистрировать все миграции (зовёт Game при создании состояния). */ export function registerStateMigrations(state: GameState): void { state.dataVersion = STATE_DATA_VERSION; state.addMigration(migrateV1toV2); + state.addMigration(migrateV2toV3); } \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 1543fc1..518178d 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -481,7 +481,7 @@ camera: this.camera, heroPos: () => this.player.position, hasFlag: (f) => this.game.state.hasFlag(f), - nightFlag: FLAGS.evening, + timeHours: () => this.game.clock.hours, lamp: HERO_LAMP }); @@ -581,6 +581,7 @@ this.worldAudio.update(dt); this.updateAudioLayers(); this.updateAmbient(dt); + this.game.clock.advance(dt); // время суток идёт и в диалогах/катсценах: свет меняется this.lighting.update(); // свет живёт и в кат-сценах/диалогах: тик до ранних выходов this.lightView.update(dt); // время/лерп ambient/кадры к спрайтам this.updateVignette(); @@ -1293,6 +1294,7 @@ pos, state: this.game.state.serialize(), items: this.game.inventory.serialize().items, + clock: { minutes: this.game.clock.minutes }, returnTo: this.returnTo ?? null, savedAt: Date.now() } satisfies SaveData, diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index bdcf909..6f279dd 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -13,6 +13,7 @@ import { SaveSlotsScene } from './SaveSlotsScene'; import { SettingsScene } from './SettingsScene'; import { normalizeSave, type SaveData } from './saveData'; +import { START_MINUTES } from '../systems/Clock'; export { SAVE_VERSION, normalizeSave, type ReturnToData, type SaveData } from './saveData'; @@ -110,10 +111,11 @@ this.game.renderer.uiRoot.addChild(this.view); } - /** Новая игра: сброс прогресса и сумки, старт на лугах. */ + /** Новая игра: сброс прогресса, сумки и часов, старт на лугах. */ private startNewGame(): void { this.game.state.reset(); this.game.inventory.clear(); + this.game.clock.reset(); this.start(null); } @@ -139,6 +141,7 @@ if (save) { const norm = normalizeSave(save); this.game.inventory.load({ items: norm.items }); + this.game.clock.set(norm.clock?.minutes ?? START_MINUTES); void this.game.scenes.replace( new LocationScene(this.game, norm, areaOf(norm.area), undefined, norm.returnTo ?? undefined), { duration: 0.4 } diff --git a/apps/game/src/scenes/__tests__/saveData.test.ts b/apps/game/src/scenes/__tests__/saveData.test.ts new file mode 100644 index 0000000..d01898d --- /dev/null +++ b/apps/game/src/scenes/__tests__/saveData.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { SAVE_VERSION, normalizeSave, type SaveData } from '../saveData'; + +/** Сейв заданной версии с минимальным содержимым. */ +function saveOf(version: number): SaveData { + return { + version, + area: 'meadows', + pos: { x: 1, y: 2 }, + state: { version: 2, flags: ['evening'], vars: {} }, + items: { flower: 1 }, + savedAt: 0 + } as SaveData; +} + +describe('normalizeSave', () => { + it('v3 (без clock): время дефолтится в старт 8:00, версия поднимается', () => { + const out = normalizeSave(saveOf(3)); + expect(out.version).toBe(SAVE_VERSION); + expect(out.clock).toEqual({ minutes: 480 }); + expect(out.items).toEqual({ flower: 1 }); + }); + + it('v4: проходит без изменений (тот же объект)', () => { + const s = saveOf(SAVE_VERSION); + s.clock = { minutes: 1230 }; + expect(normalizeSave(s)).toBe(s); + }); +}); \ No newline at end of file diff --git a/apps/game/src/scenes/saveData.ts b/apps/game/src/scenes/saveData.ts index ba663fa..43cf9b6 100644 --- a/apps/game/src/scenes/saveData.ts +++ b/apps/game/src/scenes/saveData.ts @@ -1,11 +1,13 @@ import type { GameStateData } from '@rpg/engine'; import type { AreaId } from '../data/locations'; +import { START_MINUTES } from '../systems/Clock'; /** - * Формат сейва: область, позиция героя, сериализованное состояние прохождения - * и сумка. У старых сейвов нет `version`/`items` — normalizeSave дорастает их. + * Формат сейва: область, позиция героя, сериализованное состояние прохождения, + * сумка и время суток. У старых сейвов нет `version`/`items` — normalizeSave + * дорастает их. */ -export const SAVE_VERSION = 3; +export const SAVE_VERSION = 4; /** Откуда герой вошёл в область (для переходов kind 'return'). */ export interface ReturnToData { @@ -21,6 +23,8 @@ state: GameStateData; /** Содержимое сумки: itemId -> количество. */ items: Record; + /** Время суток в минутах (v4; нет — старт 8:00). */ + clock?: { minutes: number } | null; /** Откуда вошли в область (v3; null/undefined — вход не через переход). */ returnTo?: ReturnToData | null; savedAt: number; @@ -29,13 +33,14 @@ /** Дополнить сейв старого формата до текущего (без записи). */ export function normalizeSave(save: SaveData): SaveData { if (save.version === SAVE_VERSION) return save; - // v1/v2: area зовётся location, returnTo ещё нет. + // v1/v2: area зовётся location, returnTo ещё нет; v3: clock ещё нет. const legacy = save as SaveData & { location?: string }; return { ...save, version: SAVE_VERSION, area: save.area ?? legacy.location ?? 'meadows', returnTo: save.returnTo ?? null, - items: save.items ?? {} + items: save.items ?? {}, + clock: save.clock ?? { minutes: START_MINUTES } }; } \ No newline at end of file diff --git a/apps/game/src/systems/Clock.ts b/apps/game/src/systems/Clock.ts new file mode 100644 index 0000000..ba651da --- /dev/null +++ b/apps/game/src/systems/Clock.ts @@ -0,0 +1,50 @@ +/** + * Внутреигровое время суток. Темп: 1 секунда реального времени = 1 игровая + * минута (сутки = 24 минуты реального). Чистый класс без Pixi — тестируется + * в Vitest; живёт на Game (LocationScene пересоздаётся при переходах). + */ + +/** Игровых минут за секунду реального времени. */ +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; + } + + /** Строка для часов предмета: «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/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts index 221d075..88aad03 100644 --- a/apps/game/src/systems/Lighting.ts +++ b/apps/game/src/systems/Lighting.ts @@ -1,10 +1,20 @@ /** * Игровая обвязка освещения: статика из LightingDef области + лампа героя, * проекция в экранные px каждый тик (camera.toScreen), условные источники - * по флагам. Runtime API (setAmbient/addLight) — база day/night и эффектов. + * по флагам и времени суток. Runtime API (setAmbient/addLight) — база + * day/night и эффектов. */ -import { Camera, Lighting, tileToWorld, unitsToPx, type PulseSpec, type Vec2 } from '@rpg/engine'; +import { + Camera, + Lighting, + dayNightFactor, + lerpAmbient, + tileToWorld, + unitsToPx, + type PulseSpec, + type Vec2 +} from '@rpg/engine'; import type { AreaDef, LightSourceDef } from '../data/locations'; /** Параметры лампы героя (следует за героем в тёмных областях). */ @@ -28,8 +38,8 @@ /** Позиция героя в мировых юнитах. */ heroPos: () => Vec2; hasFlag: (flag: string) => boolean; - /** Флаг вечера: включает ночную цель ambient и лампу на светлой улице. */ - nightFlag?: string; + /** Текущее время суток в часах (GameClock.hours) — вход кривой дня. */ + timeHours: () => number; lamp?: HeroLampDef | null; } @@ -40,8 +50,8 @@ sources: { id: string; x: number; y: number; color: number; intensity: number }[]; } -/** Фейд ночного ambient, сек (медленный закат). */ -const NIGHT_FADE_SEC = 6; +/** Порог ночи для ночных источников и лампы на светлой улице (≈ 19:00–07:00). */ +const NIGHT_SOURCE_FACTOR = 0.5; /** Тёмная аура зоны наката: радиус в юнитах и сила затемнения. */ const HAZARD_SPOT_RADIUS = 1.6; @@ -86,6 +96,7 @@ if (deps.area.lighting?.ambient !== undefined) { deps.lighting.setAmbient(deps.area.lighting.ambient); this.lastAmbientTarget = deps.area.lighting.ambient; + this.updateNightAmbient(); // ночной вход в область — без мелькания дня } } @@ -157,14 +168,19 @@ this.updateHazardSpots(); } - /** Ночная цель ambient: evening + nightAmbient в данных → плавный закат. */ + /** Текущий фактор ночи 0..1 по часам (округлён — для снапшота и фильтров). */ + get nightFactor(): number { + return Math.round(dayNightFactor(this.deps.timeHours()) * 1000) / 1000; + } + + /** Цель ambient: микс дневного и ночного по кривой суток (плавный закат — сама кривая). */ 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); + const target = lerpAmbient(base, night, this.nightFactor); + if (target !== this.lastAmbientTarget) this.setAmbient(target, 0); } /** Тёмные ауры зон наката: по тайлу HazardDef, снятие погасших через diff. */ @@ -221,15 +237,16 @@ private activeDefs(): LightSourceDef[] { const out: LightSourceDef[] = []; const all = [...(this.deps.area.lighting?.sources ?? []), ...this.extra]; + const night = this.nightFactor; for (const def of all) { 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; - // Лампа в тёмной области или вечером (ночь на светлой улице тоже темна). - const evening = this.deps.nightFlag !== undefined && this.deps.hasFlag(this.deps.nightFlag); - if (lamp && (isDarkAmbient(this.deps.area.lighting?.ambient) || evening)) { + // Лампа в тёмной области или ночью (вечерняя улица тоже темна). + if (lamp && (isDarkAmbient(this.deps.area.lighting?.ambient) || night >= NIGHT_SOURCE_FACTOR)) { // Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5), // поэтому отнимаем 0.5 — источник ляжет ровно на героя. const hero = this.deps.heroPos(); diff --git a/apps/game/src/systems/__tests__/clock.test.ts b/apps/game/src/systems/__tests__/clock.test.ts new file mode 100644 index 0000000..92a53f4 --- /dev/null +++ b/apps/game/src/systems/__tests__/clock.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { DAY_MINUTES, GameClock, START_MINUTES } from '../Clock'; + +describe('GameClock', () => { + it('старт: 8:00, night-вход 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/apps/game/src/systems/__tests__/lighting.test.ts b/apps/game/src/systems/__tests__/lighting.test.ts index 7f2beca..f8d437c 100644 --- a/apps/game/src/systems/__tests__/lighting.test.ts +++ b/apps/game/src/systems/__tests__/lighting.test.ts @@ -21,13 +21,14 @@ function makeGameLighting( area: AreaDef, - opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null; nightFlag?: string } + opts?: { flags?: Set; hero?: { x: number; y: number }; lamp?: HeroLampDef | null; time?: number } ) { 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 clock = { hours: opts?.time ?? 12 }; const gl = new GameLighting({ area, lighting: engineLighting, @@ -35,10 +36,10 @@ camera, heroPos: () => hero, hasFlag: (f) => flags.has(f), - nightFlag: opts?.nightFlag, + timeHours: () => clock.hours, lamp: opts?.lamp === undefined ? null : opts.lamp }); - return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p) }; + return { gl, engineLighting, camera, setHero: (p: { x: number; y: number }) => (hero = p), setTime: (h: number) => (clock.hours = h) }; } describe('GameLighting: статика области', () => { @@ -104,10 +105,9 @@ }); describe('GameLighting: лампа героя', () => { - it('следует за героем, id стабилен (вечер на светлой улице)', () => { + it('следует за героем, id стабилен (ночь на светлой улице)', () => { const { gl, engineLighting, setHero } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { - flags: new Set(['evening']), - nightFlag: 'evening', + time: 21, lamp: LAMP }); gl.update(); @@ -129,52 +129,69 @@ gl.destroy(); }); - it('на светлой улице днём лампы нет, вечером появляется', () => { - const flags = new Set(); - const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { - flags, - nightFlag: 'evening', + it('на светлой улице днём лампы нет, ночью появляется', () => { + const { gl, engineLighting, setTime } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { + time: 12, lamp: LAMP }); gl.update(); expect(engineLighting.hasLight('lamp')).toBe(false); - flags.add('evening'); + setTime(21); gl.update(); expect(engineLighting.hasLight('lamp')).toBe(true); gl.destroy(); }); }); -describe('GameLighting: день/ночь', () => { - it('ночная цель ambient по флагу вечера — лерп к nightAmbient', () => { - const flags = new Set(); - const { gl, engineLighting } = makeGameLighting( +describe('GameLighting: день/ночь по времени', () => { + it('цель ambient — микс день/ночь по кривой суток', () => { + const { gl, engineLighting, setTime } = makeGameLighting( fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), - { flags, nightFlag: 'evening' } + { time: 12 } ); gl.update(); expect(engineLighting.ambientColor).toBe(0xd8dade); // день - flags.add('evening'); + setTime(19); // k = 0.5 — середина заката gl.update(); - expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь — цель сменилась - engineLighting.update(0.1); - expect(engineLighting.children[0].tint).not.toBe(0xd8dade); // лерп пошёл - flags.delete('evening'); + expect(engineLighting.ambientColor).not.toBe(0xd8dade); // микс, не день + expect(engineLighting.ambientColor).not.toBe(0x6a7288); // и не ночь + setTime(21); + gl.update(); + expect(engineLighting.ambientColor).toBe(0x6a7288); // ночь + setTime(10); 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' - }); + it('без nightAmbient в данных время не трогает ambient', () => { + const { gl, engineLighting } = makeGameLighting(fakeArea({ ambient: 0xd8dade }), { time: 21 }); gl.update(); expect(engineLighting.ambientColor).toBe(0xd8dade); gl.destroy(); }); + + it('конструктор сразу применяет ночной микс (без мелькания дня)', () => { + const { gl, engineLighting } = makeGameLighting( + fakeArea({ ambient: 0xd8dade, nightAmbient: 0x6a7288 }), + { time: 23 } + ); + expect(engineLighting.ambientColor).toBe(0x6a7288); + gl.destroy(); + }); + + it('whenNight: ночные источники горят ночью, гаснут днём', () => { + const { gl, engineLighting, setTime } = makeGameLighting( + fakeArea({ sources: [{ id: 'win', at: { x: 1, y: 1 }, flicker: 0, whenNight: true }] }), + { time: 21 } + ); + gl.update(); + expect(engineLighting.hasLight('win')).toBe(true); + setTime(12); + gl.update(); + expect(engineLighting.hasLight('win')).toBe(false); + gl.destroy(); + }); }); describe('GameLighting: тёмные ауры хазардов', () => { diff --git a/apps/game/tools/agent.mjs b/apps/game/tools/agent.mjs index d4d2fd4..eaa4aa1 100644 --- a/apps/game/tools/agent.mjs +++ b/apps/game/tools/agent.mjs @@ -105,6 +105,7 @@ { 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: 'time', run: () => runScenario('time', 'apps/game/tools/checks/time.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 index 570128d..5618d7b 100644 --- a/apps/game/tools/checks/lighting.mjs +++ b/apps/game/tools/checks/lighting.mjs @@ -1,10 +1,12 @@ /** * Сценарий lighting — освещение сцены через мост: ambient областей, день/ночь - * (флаг evening), окна Звенца, очаг дома Ирвина (флаг квеста), лампа героя, - * мерцание, виньетка при низком hp. - * 1) Луга: светлый дневной ambient, источников нет; - * 2) evening: ночная цель ambient лугов, лампа героя на светлой днём улице; - * 3) Звенец: ночная цель деревни, три окна горят вечером, башня молчит; + * по внутриигровому времени (scene:setTime), окна Звенца, очаг дома Ирвина + * (флаг квеста), лампа героя, мерцание, виньетка при низком hp. + * Перед каждым ambient-ассертом время пинуется — реальный темп (1 с = 1 мин) + * не должен влиять на результат. + * 1) Луга, полдень: светлый дневной ambient, источников нет; + * 2) 21:00: ночная цель ambient лугов, лампа героя на светлой днём улице; + * 3) Звенец ночью: ночная цель деревни, три окна горят, башня молчит; * 4) Дом Ирвина: тёмный интерьер, hearth_low, лампа; мерцание очага гуляет; * 5) телепорт внутри дома — лампа следует за героем (разность векторов); * 6) quest_bells_done: hearth_high вместо hearth_low и строго ярче; @@ -35,35 +37,40 @@ const server = await startDevServer(); let ctx; try { - await c.run('луга: дневной ambient, источников нет', async () => { + 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); - c.expect(s.lighting.vignette === 0, 'виньетка не должна гореть на свежей игре', s.lighting); + await ctx.agent.command('scene:setTime', { hours: 12 }); + const w = await ctx.agent.waitFor( + `s.lighting.ambient === ${AMB.meadows} && s.lighting.sources.length === 0`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'днём луга не светлы или есть источники', w.snapshot?.lighting); + c.expect(w.snapshot.time.night === 0, 'в полдень фактор ночи не нулевой', w.snapshot.time); return null; }); - await c.run('вечер на лугах: ночная цель ambient, лампа героя', async () => { - await ctx.agent.command('scene:setFlag', { flag: 'evening' }); + await c.run('ночь на лугах (21:00): ночная цель ambient, лампа героя', async () => { + await ctx.agent.command('scene:setTime', { hours: 21 }); const w = await ctx.agent.waitFor( `s.lighting.ambient === ${AMB.meadowsNight} && s.lighting.sources.some((l) => l.id === "lamp")`, { timeoutTicks: 300 } ); - c.expect(w.ok, 'вечером луга не потемнели или лампа не зажглась', w.snapshot?.lighting); + c.expect(w.ok, 'ночью луга не потемнели или лампа не зажглась', w.snapshot?.lighting); + c.expect(w.snapshot.time.night === 1, 'в 21:00 фактор ночи не единичный', w.snapshot.time); return null; }); - await c.run('Звенец вечером: ночная цель, три окна, башня молчит', async () => { + 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 }); + await ctx.agent.command('scene:setTime', { hours: 21 }); // пин: ходьба ест время const s = await ctx.agent.snapshot(); c.expect(s.lighting.ambient === AMB.zvenetsNight, 'ambient Звенца не ночной', s.lighting); const windows = s.lighting.sources.filter((l) => l.id.startsWith('window_')); - c.expect(windows.length === 3, 'вечером должно гореть три окна домов', s.lighting.sources); + 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); + c.expect(s.lighting.sources.some((l) => l.id === 'lamp'), 'ночью на улице нет лампы героя', s.lighting.sources); return null; }); await c.run('дом Ирвина: тёмный интерьер, hearth_low, лампа героя', async () => { diff --git a/apps/game/tools/checks/time.mjs b/apps/game/tools/checks/time.mjs new file mode 100644 index 0000000..b221893 --- /dev/null +++ b/apps/game/tools/checks/time.mjs @@ -0,0 +1,77 @@ +/** + * Сценарий time — внутриигровое время суток через мост: старт, ход по тикам, + * кривая суток (закат), ночные источники/лампа, инварианты. + * Темп: 1 реальная секунда = 1 игровая минута, тик фиксированный 1/60 с — + * 600 шагов моста = ровно 10 игровых минут (детерминированно, без ожидания). + * 1) Новая игра: старт 8:00, фактор ночи 0; + * 2) перемотка 8:00 + 600 шагов → +10 минут (±1, float-хвост-floor); + * 3) 19:00 — середина заката: night ≈ 0.5, ambient — микс по фактическому + * фактору (время идёт и после пина, точное равенство неустойчиво); + * 4) 21:00 — ночь: night 1, лампа героя зажглась на светлой днём улице; + * 5) инварианты чисты (time-bounded и прочие). + * Запуск: node tools/agent.mjs run tools/checks/time.mjs + */ +import { startDevServer, openGame, Checks } from '../lib.mjs'; + +/** Ambient лугов и его ночной микс на середине заката (19:00). */ +const AMB = { meadows: 0xd8dade, mix: 0xa1a6b3, night: 0x6a7288 }; + +export default async function ({ pretty }) { + const c = new Checks('time'); + const server = await startDevServer(); + let ctx; + try { + await c.run('новая игра: старт 8:00, день', 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.time.minutes >= 480 && s.time.minutes < 485, `старт не 8:00 (${s.time.label})`, s.time); + c.expect(s.time.night === 0, 'утром фактор ночи не нулевой', s.time); + c.expect(/^\d{1,2}:\d{2}$/.test(s.time.label), `label не часы (${s.time.label})`, s.time); + return null; + }); + await c.run('ход времени: 600 шагов = +10 минут (±1)', async () => { + await ctx.agent.command('scene:setTime', { hours: 8 }); // пин: точка отсчёта 480 + await ctx.agent.step(600); // 600 тиков × (1/60 с) × 1 мин/с = 10 мин + const s = await ctx.agent.snapshot(); + const dt = s.time.minutes - 480; + c.expect(dt >= 9 && dt <= 11, `после 600 шагов прошло ${dt} мин (не ~10)`, s.time); + return null; + }); + await c.run('закат 19:00: night ≈ 0.5, ambient — микс по фактору', async () => { + await ctx.agent.command('scene:setTime', { hours: 19 }); + const w = await ctx.agent.waitFor('Math.abs(s.time.night - 0.5) < 0.02', { timeoutTicks: 300 }); + c.expect(w.ok, 'на середине заката фактор ночи не ~0.5', w.snapshot?.time); + // Время продолжает идти (1 тик ≈ +1/60 мин) — сверяем ambient с миксом + // по фактическому фактору из того же снапшота (округление по каналам). + const s = await ctx.agent.snapshot(); + const mix = (a, b, k) => + [16, 8, 0].map((sh) => { + 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); + 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; + }); + await c.run('ночь 21:00: night 1, ночная цель и лампа героя', async () => { + await ctx.agent.command('scene:setTime', { hours: 21 }); + const w = await ctx.agent.waitFor( + `s.time.night === 1 && s.lighting.ambient === ${AMB.night} && s.lighting.sources.some((l) => l.id === "lamp")`, + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting); + 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/engine/agent.md b/docs/engine/agent.md index f957d99..b0619e3 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -50,10 +50,15 @@ 0..1, источники — экранные px (импульсы видны как `pulse#N`), `intensity` — с учётом мерцания (проверки сравнивают с допуском). Инвариант `lighting-bounded`: источников ≤ 24, значения конечны, 0 ≤ intensity ≤ 2, 0 ≤ vignette ≤ 1. -`MenuScene` отдаёт `{scene: 'menu'}` и команду `menu:newGame`. +Слой игры добавляет `flags`, `vars`, `inventory` и `time {minutes, label, night}` +— время суток (минуты 0..1439, «8:24», фактор ночи 0..1 по кривой суток); +инвариант `time-bounded` держит их в пределах. `MenuScene` отдаёт `{scene: 'menu'}` +и команду `menu:newGame`. Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): `scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, +`scene:setTime {hours}` (перемотка времени суток — пиновать перед каждым +ambient-ассертом: время идёт и после пина, точные равенства неустойчивы), `scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`, `scene:pickChoiceByText {text}` (выбор варианта диалога по тексту реплики — порядок вариантов знать не надо; false — такого варианта нет), diff --git a/docs/engine/assets-audio-save.md b/docs/engine/assets-audio-save.md index d9fd331..5f8fdc4 100644 --- a/docs/engine/assets-audio-save.md +++ b/docs/engine/assets-audio-save.md @@ -277,6 +277,8 @@ interface SaveData { pos: { x: number; y: number }; state: GameStateData; // из gameState.serialize() + items: Record; // сумка + clock?: { minutes: number } | null; // время суток (v4; нет — старт 8:00) savedAt: number; // Date.now() } @@ -285,6 +287,10 @@ if (data) gameState.load(data.state); ``` +Сейв старого формата дорастает до текущего `normalizeSave`-ом (дефолты без +записи) при загрузке. Время суток живёт в самом сейве (не во флагах) — оно +непрерывное и не принадлежит контентной оси флагов/варов. + Версионирование контента — через `GameState.dataVersion` + `addMigration`: при изменении формата флагов старые сейвы чинятся автоматически при загрузке (миграции игры регистрируются один раз при старте — `registerStateMigrations` diff --git a/docs/engine/practices.md b/docs/engine/practices.md index fd7b7f8..44dfbbe 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -362,11 +362,15 @@ 4. Интенсивность в снапшоте — пост-мерцание: проверки сравнивают с допуском (детерминизм даёт `seedOf(id)` по FNV-1a), инвариант `lighting-bounded` — на границы, не на равенство. -5. **День/ночь — сюжетный флаг, не таймер**: смена времени дня ставится контентом - (граф диалога через `setFlags`, валидатор держит флаг упомянутым), а - `GameLighting.update` лишь следит за целью — вечер + `nightAmbient` в данных → - `setAmbient(nightAmbient, 6)` (guard по последней цели — без повторных запусков - лерпа). Ночные источники — обычный `whenFlag` в данных. +5. **День/ночь — непрерывное время суток, не фейд и не сюжетный флаг.** Игра + держит `GameClock` (1 с реального = 1 игровая минута, старт 8:00) и каждый тик + подаёт часы в `GameLighting` через деп `timeHours`; цель ambient — + `lerpAmbient(base, night, dayNightFactor(hours))` с guard по последней цели + (`setAmbient(target, 0)` — закат уже растянут кривой суток на ~2 игровых часа, + отдельный фейд не нужен). Ночные источники — `whenNight: true` в данных + (горят при факторе ночи ≥ 0.5), лампа героя — по тому же порогу. Время пиновать + в проверках (`scene:setTime`) перед каждым ambient-ассертом — реальный темп + уезжает за минуты. 6. **Импульсы (вспышки) — тот же пул источников**: движковый `pulseLight` занимает спрайт пула и снимается сам по концу огибающей (`PulseSpec`/`pulseEnvelope` в lightSim — чистая математика, тестируется в node); снапшот и инварианты diff --git a/docs/plan.md b/docs/plan.md index 4be1985..20d502c 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -27,7 +27,8 @@ | 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`, импульсы переосвещения — позже | -| 10b | Освещение II: импульсы (`pulseLight`/`pulseAmbient`), день/ночь по флагу `evening` (`nightAmbient` + лерп 6 c), виньетка (`setVignette`, низкий hp/накат), тёмные ауры зон (`upsertDarkSpot`), гало: 4 ступени + дизер-кольцо | ✅ сделано (2026-09-07): движок L1 + игра L2 (хуки боя, `scene:damagePlayer`) + контент L3 (ночь лугов/Звенца/прудов, свет колокольчиков, окна по `evening`) | +| 10b | Освещение II: импульсы (`pulseLight`/`pulseAmbient`), день/ночь, виньетка (`setVignette`, низкий hp/накат), тёмные ауры зон (`upsertDarkSpot`), гало: 4 ступени + дизер-кольцо | ✅ сделано (2026-09-07): движок L1 + игра L2 (хуки боя, `scene:damagePlayer`) + контент L3 (ночь лугов/Звенца/прудов, свет колокольчиков, окна) | +| 10c | Время суток: `GameClock` (1 с = 1 игровая минута, старт 8:00), кривая `dayNightFactor` в движке, `GameLighting` по депу `timeHours` (`lerpAmbient`-микс, `whenNight`-источники, лампа по фактору), сейв `clock` (v4), миграция state v3 (−`evening`), мост `s.time`/`scene:setTime`/инвариант `time-bounded`, сценарий `checks/time.mjs` | ✅ сделано (2026-09-07); дальше — предмет «часы» в инвентаре | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт