diff --git a/docs/plan.md b/docs/plan.md index 278d22d..5aad660 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -743,3 +743,21 @@ - **Трек 21 — сборка**: сейв полного состояния, меню (подтверждено фидбеком: пользователь заметил, что меню нет — игра стартует сразу в мир), полный прогон, сравнительные скриншоты с v1 как референсом. + **Меню и сейв закрыты раньше срока** — раундом полировки (ниже); из + трека 21 осталось: полный прогон и сравнительные скриншоты с v1. +- **Раунд полировки** (2026-09-10, «улучшим игру технически и визуально»): + недоделки визуального слоя трека 18 + техполировка. Меню (Esc) + + автосейв/«Продолжить» (движковый SaveManager, localStorage `rpg2:`; + `GameSave` — полный снапшот сюжета: область/тайл, флаги+вары, сумка, + часы, правки цветков — применяются до `setArea`, мир строится цветущим); + виньетка опасности + пелена наката (`screenFx.ts`, DOM); частицы v1 + Atmosphere (`ambientParticles.ts`: мотыльки лугов, дымка прудов — клочья + якорятся на клетки воды); шиммер воды прудов (`waterShimmer.ts`: редкие + блики-вспышки по фазам sin). Мост: `ambientInfo`, `menuInfo`, + `startGame(load)`; страница стартует в меню — всем гейтам и `shot.mjs` + сначала `startGame(false)`. Грабли: **`addVoxelCloud` после пересоздания + облака возвращает castShadow=true** — гасить после каждого update; + **z-fighting копланарных instanced-кубов с world-мешем** (блик на воде + мерцает на кромках пруда от порядка отрисовки, кадр-равенства падают) — + сдвиг EPS 0.03 из плоскости; **зомби-vite переживает process.exit + тулзы** — kill по PID из `ss -tlnp`. diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 633b6d5..763856d 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -606,6 +606,25 @@ центрируется на центр тайла. - **solidAt для blocked-гейта — колонка НАД землёй (y≥2)**: земля y0-1 сплошная всюду; проверка с y=0 даёт «всё заблокировано». +- **Voxel-облако после пересоздания снова бросает тень**: `addVoxelCloud` + ставит `castShadow=true`, а при росте списка ОБЛАКО пересоздаётся + (update подменяет mesh) — частицам/бликам `castShadow=false` ставить + после КАЖДОГО `update`, не только в конструкторе (иначе чёрные квадраты + теней на воде). +- **Z-fighting копланарных instanced-кубов с world-мешем**: куб облака, + объёмно перекрывающийся с кубом мира (блик/клочок на воде), имеет + копланарные вертикальные грани; у culled-меша воды боковые грани + выживают только на кромках пруда — там пиксели «мерцают» от порядка + отрисовки, который меняется при пересоздании облака (кадр-равенства + гейтов падают на 19 px). Лечится сдвигом позы на EPS 0.03 из плоскости + куба воды. +- **Зомби-vite держит порт после `process.exit()` тулзы** (дочерний vite + переживает выход): `startDevServer` падает «vite упал (code 1)» — + убивать по PID из `ss -tlnp` (`pkill -f` убивает и свою сессию). +- **`encodePng` принимает Buffer** (`rgba.copy is not a function` с + Uint8Array) — `Buffer.from(rgba)` перед записью. +- **Node-скрипты вне v2 не резолвят `@rpg/engine`** (TS-исходники): + одноразовые пробы класть в `apps/*/tools/` и запускать с cwd внутри v2. ## Ревью-раунд (2026-09-10): багфиксы движка и игры @@ -646,6 +665,39 @@ камера входа в область на float-цели без округления (микро-скачок в начале фейда-ин). +## Раунд полировки (2026-09-10): меню/сейвы, живые слои воды и воздуха + +Игра `v2/apps/game`, недоделки визуального слоя трека 18 + техполировка: + +- **Меню (Esc) + автосейв**: страница стартует в меню («Продолжить» disabled + без сейва / «Войти в мир»), Esc — пауза с автосейвом в слот `auto` + (движковый `SaveManager`, localStorage с префиксом `rpg2:`), `#menu` + поверх канваса (CSS `#menu[hidden]{display:none}` — грабля flex+hidden). + Пейлоад — полный снапшот сюжета `src/save.ts` (`GameSave`): область/тайл + героя, флаги+вары, сумка, часы суток, правки цветков по областям; + правки цветков применяются ДО `setArea` (мир строится уже цветущим). + Мост: `menuInfo` {open, hasAuto}, `startGame(load)`, `gotoMenu()` + (сбрасывает открытый диалог). +- **Виньетка опасности + пелена наката** `src/screenFx.ts` (DOM-слои + `#vignette`/`#haze`, alpha из состояний наката — полотно без полотна). +- **Атмосферные частицы** `src/ambientParticles.ts` (v1 Atmosphere): + мотыльки лугов (тёплые, орбитальный дрейф вокруг камеры) и дымка прудов + (серые клочья 2×2, якорятся на клетки поверхности воды, блуждают на + месте — раньше дрейфовал по окну камеры и вылезал на берег). +- **Шиммер воды прудов** `src/waterShimmer.ts`: редкие светлые блики на + поверхности воды (кубы-«вспышки» по фазам sin от времени цикла, + детерминировано; STEP/GLOW подобраны визуально: ~30 бликов на пруд). +- Мост: `ambientInfo` {ash, ambient: {kind, count} | null, + shimmer: {cells, visible} | null}. Гейты кадр-равенств гасят живые слои + как раньше (`setAsh(false)` гасит и ambient, и shimmer). +- **Все гейты игры и `tools/shot.mjs` обязаны начинаться с + `startGame(false)`** — страница стартует в меню, тики/скриншоты до входа + снимают панель меню. + +Грабли раунда (добавлены в «Известные грабли»): тени voxel-облаков после +пересоздания, z-fighting копланарных кубов над world-мешем, зомби-vite, +`encodePng` Buffer, резолв `@rpg/engine` вне v2. + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/index.html b/v2/apps/game/index.html index da76914..cf94b79 100644 --- a/v2/apps/game/index.html +++ b/v2/apps/game/index.html @@ -27,6 +27,22 @@ border-radius: 4px; white-space: nowrap; } + /* Пелена наката: полупрозрачная пелена низин (alpha из screenFx) */ + #haze { + position: fixed; + inset: 0; + background: #5a6a78; + opacity: 0; + pointer-events: none; + } + /* Виньетка опасности: края экрана темнеют (накат без полотна) */ + #vignette { + position: fixed; + inset: 0; + background: radial-gradient(ellipse at center, transparent 55%, rgba(10, 10, 14, 0.9) 100%); + opacity: 0; + pointer-events: none; + } /* Фейд перехода областей: чёрный слой поверх всего */ #fade { position: fixed; @@ -113,16 +129,56 @@ } #dialog .choices button:hover { border-color: #d99a32; color: #fff; } #dialog .continue { margin-top: 8px; color: #626c4c; font-size: 13px; } + /* Меню: Esc — пауза с автосейвом; старт страницы — выбор входа */ + #menu { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + justify-content: center; + background: rgba(10, 10, 14, 0.72); + z-index: 10; + } + #menu[hidden] { display: none; } /* display:flex перебивает атрибут hidden */ + #menu h1 { + margin: 0 0 8px; + color: #d99a32; + font: bold 26px monospace; + letter-spacing: 2px; + } + #menu button { + width: 260px; + padding: 8px 0; + background: #23232c; + color: #c9c9d2; + border: 1px solid #44454e; + border-radius: 4px; + font: 16px monospace; + cursor: pointer; + } + #menu button:hover:not(:disabled) { border-color: #d99a32; color: #fff; } + #menu button:disabled { opacity: 0.4; cursor: default; } + #menu p { margin-top: 10px; color: #626c4c; font: 13px monospace; } +
+
+ \ No newline at end of file diff --git a/v2/apps/game/src/ambientParticles.ts b/v2/apps/game/src/ambientParticles.ts new file mode 100644 index 0000000..8377616 --- /dev/null +++ b/v2/apps/game/src/ambientParticles.ts @@ -0,0 +1,88 @@ +/** + * Атмосферные частицы (v1 Atmosphere): тёплые мотыльки над лугами + * (светятся цветом, дрейф по синусу) и дымка прудов (серые клочья, + * якорятся на клетки воды и медленно бродят на месте). Voxel-облако, + * позы — детерминированные синусы от времени цикла, гейт-стабильно. + */ +import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; +import type { WaterCell } from './waterShimmer'; + +/** Профиль частиц области. */ +export type AmbientKind = 'moths' | 'fog'; + +const MOTH_COUNT = 10; +const MOTH_COLORS = ['#f0d878', '#d8b050']; // B-тёплые: только «жизнь» +const FOG_COUNT = 22; +const FOG_COLOR = '#a7b3bd'; // светлее воды — клочья читаются туманом +const RADIUS = 22; // половина ширины облака мотыльков вокруг камеры (как пепел) +/** Клок тумана: квадрат 2×2 вокселя ЛЕЖИТ на воде (как полупрозрачные + * пятна v1 — непрозрачный куб в воздухе читался бы «ящиком»). */ +const FOG_TILE: readonly (readonly [number, number])[] = [[0, 0], [1, 0], [0, 1], [1, 1]]; +/** Сдвиг клочьев из плоскости куба воды — против z-fighting копланарных + * граней на кромках (как у бликов шиммера, EPS 0.03). */ +const FOG_EPS = 0.03; + +export class AmbientParticles { + private readonly cloud: VoxelCloud; + + constructor( + renderer: VoxelRenderer, + private readonly kind: AmbientKind, + /** Клетки поверхности воды — якоря клочьев дымки (только для fog). */ + private readonly cells: readonly WaterCell[] = [], + ) { + this.cloud = renderer.addVoxelCloud([]); + this.cloud.mesh.castShadow = false; // частицы не бросают теней + } + + /** Снапшот для моста: профиль и число частиц (у мотыльков всегда видимы). */ + get info(): { kind: AmbientKind; count: number } { + return { + kind: this.kind, + count: this.kind === 'moths' ? MOTH_COUNT : this.cells.length ? FOG_COUNT * FOG_TILE.length : 0, + }; + } + + /** Поза облака: цель камеры и время цикла (детерминизм гейтов). */ + update(t: number, target: readonly [number, number, number]): void { + const voxels: { pos: [number, number, number]; color: string }[] = []; + if (this.kind === 'moths') { + for (let i = 0; i < MOTH_COUNT; i++) { + const p = i * 2.399; // золотое сечение: разброс фаз + // кружение вокруг камеры: медленный орбитальный дрейф + const x = target[0] + Math.sin(t * 0.13 + p) * RADIUS * 0.8; + const z = target[2] + Math.cos(t * 0.11 + p * 1.7) * RADIUS * 0.8; + // полёт: плавная волна над травой + трепет + const y = 4.5 + Math.sin(t * 0.4 + p * 2) * 2.2; + voxels.push({ + pos: [x + Math.sin(t * 1.7 + p) * 0.6, y, z + Math.cos(t * 1.5 + p) * 0.6], + color: MOTH_COLORS[i % MOTH_COLORS.length]!, + }); + } + } else { + // клочья дымят НА воде: якорь — клетка поверхности, медленное + // блуждание на месте (без якорей — клочьев нет: дымка только прудам) + const n = this.cells.length; + for (let i = 0; i < FOG_COUNT && n > 0; i++) { + const p = i * 2.399; + const a = this.cells[(i * 131) % n]!; // 131 взаимно прост с длиной — разброс + const x = a.x + Math.sin(t * 0.05 + p) * 2.5; + const z = a.z + Math.cos(t * 0.04 + p * 1.3) * 2.5; + for (const [dx, dz] of FOG_TILE) { + voxels.push({ pos: [x + dx + FOG_EPS, 2.56, z + dz + FOG_EPS], color: FOG_COLOR }); + } + } + } + this.cloud.update(voxels); + this.cloud.mesh.castShadow = false; // пересоздание облака возвращает тень + } + + /** Скрыть облако (мост setAsh / смена профиля). */ + hide(): void { + this.cloud.update([]); + } + + dispose(): void { + this.cloud.dispose(); + } +} \ No newline at end of file diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 37a24b2..1e2619e 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -16,7 +16,7 @@ DialogueEffectOp, Interactable, } from '@rpg/engine'; import * as THREE from 'three'; -import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode } from './areas'; +import { AREA_LAYOUTS, UNIT, GROUND_Y, buildAreaWorld, tileCenter, rleDecode, type AreaLayout } from './areas'; import { areaOf, type AreaDef, type TransitionDef, type Tile } from './data/areas'; import { GameState } from './state'; import { DIALOGUES } from './data/dialogues'; @@ -28,11 +28,15 @@ import { stampProp, clearProp } from './props'; import { FlowerQuest } from './flowers'; import { GameClock } from './clock'; -import { LIGHTING, ATMOSPHERE, type LightingDef } from './data/lighting'; +import { LIGHTING, ATMOSPHERE, type Atmosphere, type LightingDef } from './data/lighting'; import { GameLighting, type HeroLampDef } from './lighting'; import { AshParticles } from './ash'; +import { AmbientParticles, type AmbientKind } from './ambientParticles'; +import { WaterShimmer, type WaterCell } from './waterShimmer'; +import { createSaveManager, type GameSave } from './save'; import { GameAudio } from './audio'; import { FaunaSystem } from './fauna'; +import { ScreenFx } from './screenFx'; // Слоты палитры сцены: арт-библия v1 (1..10) + тайлы областей (11..17) const PALETTE = { @@ -116,8 +120,19 @@ /** Глобальный вкл/выкл пепла (мост: гейт кадр-равенства). */ let ashEnabled = true; let areaAsh = false; // играет ли пепел в текущей области +/** Мотыльки лугов / дымка прудов (v1 Atmosphere): частицы области. */ +let ambient: AmbientParticles | null = null; +/** Шиммер воды (блики на поверхности прудов). */ +let shimmer: WaterShimmer | null = null; const audio = new GameAudio(); +/** Профиль частиц по атмосфере области: луга — мотыльки, пруды — дымка. */ +function ambientOf(atm: Atmosphere): AmbientKind | null { + if (atm === 'ash') return 'moths'; + if (atm === 'fog') return 'fog'; + return null; +} + /** Позиции тайлов раскладки по id (источники `tiles`: колокольчики и пр.). */ function layerTilePos(): Map { const l = layout; @@ -133,6 +148,21 @@ return out; } +/** Воксели поверхности воды области (тайл T_WATER=2 v1): центры кубов слоя. */ +function waterSurfaceCells(l: AreaLayout): WaterCell[] { + const tiles = rleDecode(l.tiles, l.width * l.height); + const out: WaterCell[] = []; + for (let ty = 0; ty < l.height; ty++) + for (let tx = 0; tx < l.width; tx++) { + if (tiles[ty * l.width + tx] !== 2) continue; // T_WATER раскладки v1 + for (let dz = 0; dz < UNIT; dz++) + for (let dx = 0; dx < UNIT; dx++) { + out.push({ x: tx * UNIT + dx + 0.5, z: ty * UNIT + dz + 0.5 }); + } + } + return out; +} + /** Живой слой тайлов света текущей области (правки цветков двигают тайлы). */ let layerPos = new Map(); /** Тайл колокольчика (T_BELLFLOWER раскладки — источник света `bells`). */ @@ -279,6 +309,7 @@ prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера) nearPick = null; // подсказка старой области не должна пережить переход inHazard = null; + screenFx.reset(); // старый накат не переезжает в новую область showHint(''); // свет области заново (источники/тон), пепел по атмосфере, тема с кроссфейдом lighting?.destroy(); @@ -302,6 +333,14 @@ }); areaAsh = ashEnabled && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); // интерьер/туман — облако пепла не рисуется + // Мотыльки лугов / дымка прудов (v1 Atmosphere): облако на профиль области. + ambient?.dispose(); + const amb = ashEnabled ? ambientOf(ATMOSPHERE[currentArea.id]) : null; + const waterCells = ashEnabled ? waterSurfaceCells(layout) : []; // якоря дымки/блики + ambient = amb ? new AmbientParticles(renderer, amb, waterCells) : null; + // Шиммер воды: блики на поверхности прудов (слой y=2 — вода) + shimmer?.dispose(); + shimmer = ashEnabled ? new WaterShimmer(renderer, waterCells) : null; fauna?.dispose(); // фауна области заново (олени по спавнам v1) fauna = faunaEnabled ? new FaunaSystem({ spawns: currentArea.fauna ?? [], @@ -369,6 +408,11 @@ let nearPick: PickCandidate | null = null; /** Зона наката под ногами в прошлом тике (тост при входе — один раз). */ let inHazard: string | null = null; +/** Экранные слои атмосферы: виньетка опасности + пелена наката (v1). */ +const screenFx = new ScreenFx( + document.getElementById('vignette') as HTMLElement, + document.getElementById('haze') as HTMLElement, +); /** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */ let prevTile: Tile | null = null; @@ -666,6 +710,71 @@ showHint(''); } +// ---- Меню и сейвы: Esc — пауза с автосейвом; «Продолжить» после перезагрузки ---- +const menuEl = document.getElementById('menu') as HTMLDivElement; +const menuContinue = document.getElementById('menu-continue') as HTMLButtonElement; +const menuNew = document.getElementById('menu-new') as HTMLButtonElement; +const saves = createSaveManager(window.localStorage); +let menuOpen = true; // страница стартует в меню (мир виден под панелью) + +/** Снапшот текущего мира в пейлоад сейва. */ +function saveSnapshot(): GameSave { + const flowersData: [string, [number, boolean][]][] = []; + for (const [area, per] of flowers.edits) flowersData.push([area, [...per]]); + return { + version: 1, + area: currentArea.id, + tile: heroTile(), + state: state.toData(), + inv: inv.serialize(), + minutes: clock.minutes, + flowers: flowersData, + }; +} + +/** Применить сейв: правки цветков ДО setArea (мир и слой света читают журнал). */ +function applySave(s: GameSave): void { + state.fromData(s.state); + inv.load(s.inv); + clock.set(s.minutes); + flowers.edits.clear(); + for (const [area, list] of s.flowers) flowers.edits.set(area, new Map(list)); + setArea(s.area, s.tile); + updateHud(); // мутации вне тика обязаны обновить HUD сами (грабля) +} + +/** Открыть меню: мир на паузе, автосейв текущего состояния в слот auto. */ +function openMenu(): void { + menuOpen = true; + menuEl.hidden = false; + saves.save('auto', saveSnapshot(), { + title: `Автосейв — ${currentArea.id}`, + savedAt: Date.now(), + version: 1, + extras: { area: currentArea.id }, + }); + menuContinue.disabled = !saves.has('auto'); +} + +/** Закрыть меню без загрузки (Esc в меню; «Войти в мир» — мир уже стоит). */ +function closeMenu(): void { + menuOpen = false; + menuEl.hidden = true; +} + +/** Кнопки меню: продолжить с автосейва (load) или начать заново. */ +function startGame(load: boolean): void { + if (load) { + const s = saves.load('auto'); + if (s) applySave(s); + } + closeMenu(); +} + +menuContinue.addEventListener('click', () => startGame(true)); +menuNew.addEventListener('click', () => startGame(false)); +menuContinue.disabled = !saves.has('auto'); // сейвы прошлой сессии готовы к загрузке + // ---- Ввод: клавиатура (экранные оси) + виртуальный стик агента ---- const heldKeys = new Set(); const KEYMAP: Record = { @@ -676,6 +785,17 @@ let followHero = true; window.addEventListener('keydown', (e) => { + // Esc: меню-пауза с автосейвом; открытый диалог прерывается (сейвы без диалогов) + if (e.code === 'Escape') { + e.preventDefault(); + if (menuOpen) closeMenu(); + else { + if (runner.active) runner.abort(); + openMenu(); + } + updateHud(); + return; + } if (KEYMAP[e.code]) { heldKeys.add(e.code); e.preventDefault(); @@ -716,11 +836,14 @@ /** Один шаг логики: часы/свет → фейд → катсцена → движение/накат → цели «E». */ function tick(dt: number): void { + if (menuOpen) return; // меню-пауза: мир стоит (автосейв снимается при открытии) // время суток и свет идут всегда (в фейде/катсцене — тоже, как в v1) clock.advance(dt); elapsed += dt; lighting?.update(); if (areaAsh) ash.update(elapsed, renderer.currentTarget()); + ambient?.update(elapsed, renderer.currentTarget()); // мотыльки/дымка области + shimmer?.update(elapsed); // блики на воде прудов fauna?.update(dt, elapsed); // олени бродят (в фейде/катсцене мир замер) if (fade.active) { fade.t += dt; @@ -770,6 +893,12 @@ ); } inHazard = hazard?.name ?? null; + // виньетка (накат без полотна) и пелена низины — с фейдом (v1) + screenFx.update( + dt, + hazard !== null && !masked ? 0.35 : 0, + hazard === null ? 0 : masked ? 0.12 : 0.28, + ); if (hazard !== null && !masked) { hero.setMove([wm[0] * 0.5, wm[1] * 0.5]); // ползём по свежему пеплу } else { @@ -923,6 +1052,14 @@ lightingInfo(): ReturnType { return lighting?.snapshot() ?? { nightFactor: 0, sources: [] }; }, + /** Снапшот частиц области: пепел/мотыльки-дымка/шиммер воды (гейт). */ + ambientInfo() { + return { + ash: areaAsh, + ambient: ambient?.info ?? null, + shimmer: shimmer?.info ?? null, + }; + }, /** Снапшот аудио: тема области + кольцевой буфер фактических запусков. */ audioInfo() { return { @@ -935,6 +1072,11 @@ ashEnabled = on; areaAsh = on && ATMOSPHERE[currentArea.id] === 'ash'; if (!areaAsh) ash.hide(); + // частицы области гасятся тем же мостом (кадр-равенства) + ambient?.hide(); + ambient = null; + shimmer?.hide(); + shimmer = null; }, /** Фауна вкл/выкл (гейт: кадр-равенство — олени бродят в реальном времени). */ setFauna(on: boolean): void { @@ -1093,6 +1235,21 @@ stateInfo() { return { ...state.toData(), inv: inv.all.map((s) => ({ id: s.id, count: s.count })) }; }, + /** Снапшот меню: открыто ли, есть ли автосейв (гейт меню/сейвов). */ + menuInfo() { + return { open: menuOpen, hasAuto: saves.has('auto') }; + }, + /** Меню: открытие с автосейвом / закрытие / вход в игру (гейт). */ + gotoMenu(): void { + if (menuOpen) return; + if (runner.active) runner.abort(); + openMenu(); + updateHud(); + }, + startGame(load: boolean): void { + startGame(load); + updateHud(); + }, }; (window as unknown as Record).__agent = agent; (window as unknown as Record).__beacon = true; \ No newline at end of file diff --git a/v2/apps/game/src/save.ts b/v2/apps/game/src/save.ts new file mode 100644 index 0000000..19e2227 --- /dev/null +++ b/v2/apps/game/src/save.ts @@ -0,0 +1,30 @@ +/** + * Сейвы игры (движковый SaveManager, хранилище — localStorage): автосейв + * при меню Esc, «Продолжить» после перезагрузки страницы. Пейлоад — + * полный снапшот сюжета: область/тайл героя, флаги+вары (used-интерактивы + * и знакомства тоже флаги), сумка, часы суток, правки цветков по областям. + */ +import { SaveManager, type InventoryData } from '@rpg/engine'; +import type { GameStateData } from './state'; +import type { Tile } from './data/areas'; + +/** Снапшот игры для слота (version — формат пейлоада, не движка). */ +export interface GameSave { + version: 1; + /** Область и тайл героя (вход = тайл, без повторного триггера перехода). */ + area: string; + tile: Tile; + /** Сюжет: флаги + числовые вары (GameStateData). */ + state: GameStateData; + /** Сумка героя. */ + inv: InventoryData; + /** Минуты суток (GameClock.set). */ + minutes: number; + /** Правки цветков по областям: areaId → [индекс тайла, стоит ли]. */ + flowers: [string, [number, boolean][]][]; +} + +/** Менеджер слотов игры (localStorage; в тестах — Map, движковый интерфейс). */ +export function createSaveManager(storage: Storage): SaveManager { + return new SaveManager(storage, 'rpg2:'); +} \ No newline at end of file diff --git a/v2/apps/game/src/screenFx.ts b/v2/apps/game/src/screenFx.ts new file mode 100644 index 0000000..ef69a6a --- /dev/null +++ b/v2/apps/game/src/screenFx.ts @@ -0,0 +1,45 @@ +/** + * Экранные слои атмосферы (v1 Atmosphere + движковая виньетка): виньетка + * опасности (края экрана темнеют — накат без полотна) и пелена наката. + * DOM-оверлеи поверх канваса: уровень плавно следует за целью (фейд, как + * v1 vignette.set / fog.alpha), детерминированно от ручного времени. + */ + +/** Фейд уровней (сек) — v1 VIGNETTE_FADE. */ +const FADE = 0.5; + +/** Линейный подход к цели с ограничением шага. */ +function approach(v: number, target: number, maxStep: number): number { + if (v < target) return Math.min(target, v + maxStep); + return Math.max(target, v - maxStep); +} + +export class ScreenFx { + private vignette = 0; + private haze = 0; + + constructor( + private readonly vignetteEl: HTMLElement, + private readonly hazeEl: HTMLElement, + ) {} + + /** + * Тик слоёв: виньетка 0..1 и пелена наката (alpha 0..1; v1: 0.12 с + * полотном, 0.28 без). В диалоге/катсцене не зовётся — уровни держатся. + */ + update(dt: number, vignetteTarget: number, hazeTarget: number): void { + const step = dt / FADE; + this.vignette = approach(this.vignette, vignetteTarget, step); + this.haze = approach(this.haze, hazeTarget, step); + this.vignetteEl.style.opacity = this.vignette.toFixed(3); + this.hazeEl.style.opacity = this.haze.toFixed(3); + } + + /** Мгновенный сброс (смена области — старый накат не переезжает). */ + reset(): void { + this.vignette = 0; + this.haze = 0; + this.vignetteEl.style.opacity = '0'; + this.hazeEl.style.opacity = '0'; + } +} \ No newline at end of file diff --git a/v2/apps/game/src/waterShimmer.ts b/v2/apps/game/src/waterShimmer.ts new file mode 100644 index 0000000..68a5308 --- /dev/null +++ b/v2/apps/game/src/waterShimmer.ts @@ -0,0 +1,64 @@ +/** + * Шиммер воды (полировка прудов): редкие светлые блики на поверхности. + * Отдельное voxel-облако НАД водой (не world.set — без ремешинга мира): + * блики вспыхивают и гаснут фазами от времени цикла, детерминированно + * (гейты кадр-равенств выключают слой вместе с пеплом мостом setAsh). + */ +import type { VoxelCloud, VoxelRenderer } from '@rpg/engine'; + +/** Воксель поверхности воды: центр куба слоя (блик выступает на 0.05). */ +export interface WaterCell { + x: number; // центр куба по x (воксель + 0.5) + z: number; // центр куба по z +} + +const STEP = 19; // каждый N-й воксель поверхности — потенциальный блик (редкие) +const GLOW = 0.84; // порог фазы: выше — блик виден (вспышка короткая) +/** Сдвиг блика из плоскости куба воды: куб блика иначе объёмно перекрывается + * с кубом воды (копланарные вертикальные грани) — на кромках пруда z-fighting, + * порядок отрисовки облаков меняется от пересоздания → мерцающие пиксели. */ +const EPS = 0.03; +/** Блик: приглушённая рябь и яркая вспышка (поверх W2 #568590). */ +const COLORS = ['#8fb8bf', '#cfe4e6'] as const; + +export class WaterShimmer { + private readonly cloud: VoxelCloud; + /** Снапшот для моста: бликов в последней позе. */ + private lastCount = 0; + + constructor(renderer: VoxelRenderer, private readonly cells: readonly WaterCell[]) { + this.cloud = renderer.addVoxelCloud([]); + this.cloud.mesh.castShadow = false; // блик — свет, не объект: без тени + } + + /** Вокселей воды в области и бликов сейчас (мост shimmerInfo). */ + get info(): { cells: number; visible: number } { + return { cells: this.cells.length, visible: this.lastCount }; + } + + /** Поза бликов: время цикла (детерминизм гейтов). */ + update(t: number): void { + const voxels: { pos: [number, number, number]; color: string }[] = []; + for (let i = 0; i < this.cells.length; i += STEP) { + const c = this.cells[i]!; + const phase = Math.sin(t * 0.9 + i * 2.399); // золотое сечение фаз + if (phase <= GLOW) continue; + voxels.push({ + pos: [c.x + EPS, 2.55, c.z + EPS], + color: phase > 0.93 ? COLORS[1] : COLORS[0], // яркая вспышка редка + }); + } + this.lastCount = voxels.length; + this.cloud.update(voxels); + this.cloud.mesh.castShadow = false; // пересоздание облака возвращает тень + } + + /** Скрыть блики (не водная область / мост setAsh). */ + hide(): void { + this.cloud.update([]); + } + + dispose(): void { + this.cloud.dispose(); + } +} \ No newline at end of file diff --git a/v2/apps/game/tools/checks/act2.mjs b/v2/apps/game/tools/checks/act2.mjs index 5608901..e8299ec 100644 --- a/v2/apps/game/tools/checks/act2.mjs +++ b/v2/apps/game/tools/checks/act2.mjs @@ -32,6 +32,7 @@ const browser = await launchBrowser(); try { const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); diff --git a/v2/apps/game/tools/checks/areas.mjs b/v2/apps/game/tools/checks/areas.mjs index fc68f1f..b7afb13 100644 --- a/v2/apps/game/tools/checks/areas.mjs +++ b/v2/apps/game/tools/checks/areas.mjs @@ -80,6 +80,7 @@ const browser = await launchBrowser(); try { const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); diff --git a/v2/apps/game/tools/checks/atmosphere.mjs b/v2/apps/game/tools/checks/atmosphere.mjs index e66f467..02e7d3c 100644 --- a/v2/apps/game/tools/checks/atmosphere.mjs +++ b/v2/apps/game/tools/checks/atmosphere.mjs @@ -45,6 +45,7 @@ const browser = await launchBrowser(); try { const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); diff --git a/v2/apps/game/tools/checks/interact.mjs b/v2/apps/game/tools/checks/interact.mjs index 5122296..dd2a7e7 100644 --- a/v2/apps/game/tools/checks/interact.mjs +++ b/v2/apps/game/tools/checks/interact.mjs @@ -51,6 +51,7 @@ const browser = await launchBrowser(); try { const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); diff --git a/v2/apps/game/tools/checks/npc.mjs b/v2/apps/game/tools/checks/npc.mjs index 83cc341..0ab1597 100644 --- a/v2/apps/game/tools/checks/npc.mjs +++ b/v2/apps/game/tools/checks/npc.mjs @@ -45,6 +45,7 @@ const browser = await launchBrowser(); try { const client = await openGame(browser, { url: `http://localhost:${port}/` }); + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) const errs = client.errors(); check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); diff --git a/v2/apps/game/tools/shot.mjs b/v2/apps/game/tools/shot.mjs index 507ebab..2c11a20 100644 --- a/v2/apps/game/tools/shot.mjs +++ b/v2/apps/game/tools/shot.mjs @@ -44,6 +44,7 @@ await new Promise((r) => setTimeout(r, 1200)); // первый кадр, прогрев const client = new AgentClient(page); client.pageErrors = pageErrors; // openGame делает это сам — тут вручную + await client.call('startGame', false); // страница стартует в меню (меню/автосейв) await client.call('setManual', true); if (args.ash === 'off') await client.call('setAsh', false); // чистый мир if (args.clock !== undefined) await client.call('setClock', Number(args.clock));