66 lines
49 KiB
Markdown
66 lines
49 KiB
Markdown
# AGENTS.md
|
||
|
||
Игра — один файл `index.html` (HTML+CSS+JS, ~2700 строк): без сборки, зависимостей и бэкенда. Весь код в одном `<script>`, секции помечены комментариями. UI, комментарии и доки — на русском; при правках сохраняйте этот стиль.
|
||
|
||
## Проверка (lint/typecheck отсутствуют)
|
||
|
||
1. Синтаксис: `node tools/check.mjs` — вынимает JS из `index.html` и прогоняет `node --check` (при ошибке показывает номер строки index.html).
|
||
2. Главный рубеж — плейтест (запускать после правок `index.html` или `test.js`):
|
||
```bash
|
||
cd tests
|
||
npm install # puppeteer-core
|
||
npm test # = node test.js
|
||
```
|
||
```
|
||
Ждать все PASS и exit 0.
|
||
```
|
||
3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI, только по просьбе пользователя):
|
||
```bash
|
||
node tests/orca-playtest.js # из каталога репо; требует ЗАПУЩЕННЫЙ Orca app и orca-ide (или ORCA_CLI_COMMAND)
|
||
```
|
||
|
||
Проходит тот же геймплей «вживую» в браузере (не headless) и пишет `orca-*.png` в каталог запуска. Если Orca app не запущен — пропустить и НЕ блокировать (главный рубеж — пункт 2). Подробности и подводные камни: `docs/BROWSER-TESTING.md`.
|
||
4. Скриншоты: `node tests/gen-screenshots.js [каталог-сборки] [папка-вывода] [язык]` — 15 кадров × 3 вьюпорта (1280×720 / 360×640@2x / 800×[450@1.6x](mailto:450@1.6x); кадры sea/ocean — этап 43, swamp/swamp-night — этап 45, aquarium — этап 46), 24-битный PNG без альфа. Без аргументов — русские в `screenshots-wiki/`, с `en` — английские в `screenshots-wiki-en/`. README ссылается на `screenshots-wiki/mobp-*` (мобильный портрет). Emoji рисует встроенный шрифт (раздел «Встроенный emoji-шрифт») — системные emoji-шрифты для скриншотов больше не нужны.
|
||
5. Публикация в Яндекс Играх: `node tools/pack.mjs` (сборка dist/fishing-yandex.zip + самопроверка 5 пунктов) → `node tests/pack-smoke.js` (smoke играбельности КОПИИ из архива, 6 чек) → загрузить zip в консоль. Доки: раздел README «Публикация в Яндекс Играх», запись Этап 27 в DEVLOG.
|
||
6. Релизная (уменьшенная) сборка: `node tools/minify.mjs` (зависимости: `npm i --prefix tools` — terser+csso) → `dist/index.release.html` (−35% к размеру) + самопроверка 6 пунктов (размер, node --check, маркеры EMOJI-FONT/FISH-SVG, набор id, видимый текст DOM, base64 шрифта). Методы: terser с `mangle.toplevel:false` (глобалы из буллета «Подводные камни» — «Тесты гоняют игру через внутренние глобалы» — держатся ИМЯМИ, поэтому артефакт можно прогнать тем же tests/test.js), csso в каждом `<style>` кроме блока шрифта (держится побайтово), в FISH-SVG удаляются комменты и whitespace между тегами, прочие HTML-комменты — кроме маркеров, whitespace DOM — только между блок-тегами. ПЕРЕД публикацией артефакта: подставить его под `index.html` и прогнать `node tests/test.js` (полную суиту) и/или цепочку пункта 5.
|
||
|
||
## Подводные камни
|
||
|
||
- `tests/test.js` берёт браузер из `process.env.CHROMIUM || '/usr/bin/chromium-browser'`, а URL игры резолвит динамически от каталога репо (тот же паттерн, что в `gen-screenshots.js`). Если тест падает на старте — поправьте имя/путь chromium под свой чекаут (переменная `CHROMIUM`).
|
||
- Граница полуночи: если прогон стартует в пределах 5 минут от 00:00, test.js сам ждёт безопасное окно (дата не должна смениться посреди прогона — падают date-ассерты квестов/стрика). Это нормально, не баг.
|
||
- Тесты гоняют игру через внутренние глобалы в `page.evaluate`: `state`, `save`, `biteTimer`, `reel`, `SPECIES`, `SPECIES_NIGHT`, `rod()`, `SHINY_CHANCE`, `forceBoss`, `forceBite`, `forceVariant`, `VARIANTS`, `rollVariant()`, `variantChance()`, `fishForce()`, `stateBoss`, `bossPhase`, `dayPhase`, `pickSpecies()`, `setDayTime()`, `todayStr()`, `dateShift()`, `isoWeek()`, `achBonus()`, `forceWeather`, `weather`, `weatherT`, `weatherGap`, `WEATHER`, `WEATHER_GAP_MIN`, `WEATHER_GAP_MAX`, `WEATHER_DURATION`, `weatherActive()`, `BOAT_SKINS`, `BOBBER_SKINS`, `LOCATIONS`, `RIVER_SPECIES`, `RIVER_SPECIES_NIGHT`, `LAKE_SPECIES`, `LAKE_SPECIES_NIGHT`, `SEA_SPECIES`, `SEA_SPECIES_NIGHT`, `OCEAN_SPECIES`, `OCEAN_SPECIES_NIGHT`, `RIVER_IDS`, `LAKE_IDS`, `SEA_IDS`, `OCEAN_IDS`, `SWAMP_SPECIES`, `SWAMP_SPECIES_NIGHT`, `SWAMP_IDS`, `SWAMP_DAY_WEIGHTS`, `SWAMP_NIGHT_WEIGHTS`, `SAVE_KEY`, `loadSave`, `freshSave`, `ACHIEVEMENTS`, `checkAchievements`, `questText`, `YG` (и `YG.counts`), `forceLang`, `setLang()`, `LANG`, `trName()`, `trRarity()` (i18n, тесты §Y5), `pauseOpen` (пауза, тесты §Y6), `uiOpen`, `dayTime` и имена состояний `MENU/IDLE/CHARGE/CAST/WAIT/APPROACH/BITE/REEL/SNAP/CARD`; аквариум (этап 46): `aqOpen`, `aqFishRt`, `aqSel`, `aqTank`, `AQ_BASE_SLOTS`, `AQ_MAX_SLOTS`, `AQ_SLOT_COSTS`, `AQ_VARIANT_RECOLOR`, `AQ_VARIANT_CLASS`, `aqApplyVariant`, `aqFishEls`, `aqBuildFishEl`, `aqPlaceFish`, `initAquarium`, `updateAquarium`, `drawAquarium`, `aqFishSize`, `aqTap`, `showAqPop`, `hideAqPop`, `renderAqBar`, `aqReleaseSel`, `openAquarium`, `closeAquarium`, `syncKeepBtn`, `cardSpec`, `save.aqFish`, `save.aqSlots` (`aqValue` удалён, `AQ_VARIANT_FX`/`AQ_VARIANT_HUE` — тоже, не использовать). Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
|
||
- Ввод на Pointer Events (`pointerdown`/`pointerup`), а не touch/mouse — так тест управляет игрой мышью. Не заменять на тач-обработчики.
|
||
- Сейв: ключ localStorage `fishing-save-v1`. При изменении формата — миграцию добавлять в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
|
||
|
||
## Где крутить
|
||
|
||
- Баланс (рыба/удилища/приманки): константы `SPECIES`, `RODS`, `LURES` в начале скрипта (базы: удочки 875/4500/15000, наживки 625/3000/11250). Апгрейды снастей (уровни 1..4): цена перехода — `upgCost(type,i,lv)`: id 0 — фикс `ROD_UPG_COST`/`LURE_UPG_COST` (70/210/500 и 60/160/400), id>0 — база×`UPG_MULT` (0.2/0.6/1.2, округление до 10); подписи уровней `ROD_UPG_NEXT`/`LURE_UPG_NEXT`, уровни `save.rodLv`/`save.lureLv`, эффективные статы — `effRod()`/`effLure()` (глобалы `rod()`/`lure()` отдают эффективные), гейтинг открытия — в `itemRow()`/`shopClick()`.
|
||
- Ночь (M3): длительность суток `DAY_LENGTH` (сек), ночная таблица рыб `SPECIES_NIGHT`, фонарик — апгрейд 1 любого удилища (`save.rodLv[save.rod]>=2`, проверка `hasLight()` в ветке WAIT, секция «M3: Цикл дня/ночи»).
|
||
- Босс (M4, Этап 37 — босс в каждой локации, Этап 45 — +Болото): тест-форс появления `forceBoss` (работает с боссом ТЕКУЩЕЙ локации), таблица `BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale',4:'spiritfin'}` (секция ДАННЫЕ) — босс = существующий вид текущей локации (Река=Осётр как в M4, Озёра=Гигантский рак, Море=Рыба-меч, Океан=Синий кит, Болото=Дух болота) с runtime-флагом `boss:true` (в `startBite()`, НОВЫХ видов в `SPECIES` нет); вес ЛЮБОГО босса — единое правило 20–50 кг в `hook()` (`w=20+Math.random()*30` вместо ролла `wMin..wMax` вида; стоит до `d0`/`ov`/`st0` и уходит в `reel.w`/`catchFish` тем же `w`); фазы боя `stateBoss`/`bossPhase`, босс-ветки в `updateReel()` (обрыв фазы) и `SNAP` в `updateGame()` (авто-переход), награда ×5 в `catchFish()`; `save.bosses={id:true}` — пер-босс трекинг (дефолт в `save`/`freshSave()` + миграция в `loadSave()`, ставится в boss-ветке `catchFish()`); ачивки: `boss_first` (текст нейтральный «Победи рыбу-босса», бонус не тронут) + `lake_boss`/`sea_boss`/`ocean_boss`/`swamp_boss` (БЕЗ coinPct — пин §10c(8) `achBonus().coinPct=0.18` жив; `bonus:{}` пустой — `bonusText()` требует объект).
|
||
- Варианты рыб (M6): таблица `VARIANTS` — 8 вариантов, порядок ролла = порядок массива: copper ×1.5 @0.12 → silver ×2 @0.08 → quartz ×2.5 @0.07 → gold ×3 @`SHINY_CHANCE` (0.06, живая) → emerald ×4 @0.01 → diamond ×4.5 @0.007 → rainbow ×5 @0.005 (топ), иначе normal; P(особой)≈31% без буста снасти, E[mult]≈1.36. `variantChance()` — единственная точка вероятностей (дождь M8 уже здесь — ×3, см. строку «Погода (M8)»; буст снасти M6+ тоже здесь: `variantBoost()` = `lure().rare×0.15 + (уровень наживки−1)×0.05`, множитель ×(1+B·r), r — номер в лестнице 1..7 — редким больше; простая наживка 1 ур. → B=0; блёстка 4 ур. → B=0.6, rainbow ×5.2), счётчики улова — `VARIANT_KEY` (cp/sv/qz/sn/em/dm/rb; sn = «shiny» = gold), миграция счётчиков в `loadSave()` (дефолт 0), тест-форс `forceVariant`, босс всегда normal. Рыбу нельзя улучшать: «Улучшить вариант» (M6) убрано в этапе 21 — карточка улова только бейдж/оформление по варианту. Пины шансов в тестах (§10c(1)/(5), M8(2)) — при нейтральной снасти (снапшот/рестор `save.lure`/`lureLv` в eval).
|
||
- Погода (M8): таблица `WEATHER` (rain — `variantMult` ×3; fog — `biteMult` ×1,5), таймеры в реальном времени — `updateWeather()` в `frame()` (gap `WEATHER_GAP_MIN..MAX` 120–240 с, длительность `WEATHER_DURATION` 120 с, под форсом не гоняются), `weatherActive()` — единственная точка чтения (форс приоритетнее), эффекты только в `variantChance()` (rain) и `biteChance()` (fog), тест-форс `forceWeather` ('rain'/'fog'/null, паттерн `forceBoss`); погода не персистится в сейв. Тост погоды — из `T` (`t('wx_'+weather)`; `WEATHER.*.toast` — не используется). Заметка: туман может дать p>1 → насыщение 100% клёва (осознанно).
|
||
- Скины (M-skins): таблицы `BOAT_SKINS`/`BOBBER_SKINS` (лодка: Обычная 0 / «Красная лодка» 800 / «Лодка-домик» 2000 🪙; поплавок: Обычный 0 / Красный 500 / Неоновый 1500 🪙), сейв `save.boatSkin(s)`/`save.bobberSkin(s)` + миграция в `loadSave()` (включая гарантию бесплатного скина 0), вкладка «Скины» 🎨 (`#tabSkins`, строка `skinRow(list,i,owned,equipped,group)`, ветки buyskin/select в `shopClick()`: покупка со списанием и авто-экипировкой, выбор — только купленное), отрисовка `drawBoat()`/`drawBobber()` с clamp 0..2 (скин 2: каюта у лодки, неоновое свечение `shadowBlur` в save/restore у поплавка); чистая косметика — без эффектов на геймплей.
|
||
- Локации (этап 5, этап 35, этап 36 — эксклюзивная ихтиофауна, этап 45 — Болото): таблица `LOCATIONS` (Река 0 — бесплатно / «Озёра» 65536 🪙 / «Море» 131072 🪙 = ×2 Озёр, этап 35 / «Океан» 196608 🪙 = ×1.5 Моря, этап 36 / «Волшебное болото» 90000 🪙 — id 4, этап 45: по ЦЕНЕ стоит между Озёрами и Морем, но в массиве `LOCATIONS` — ПОСЛЕДНИЙ; `water` — 6 hex [dayTop,dayMid,dayBot,nightTop,nightMid,nightBot] для градиента `drawWater()`, nightMix-логика та же, у Болота — тёмно-моховая вода и слабая рябь `waves` тише Озёр), сейв `save.locations[]`/`save.location` + миграция в `loadSave()` (гарантия бесплатной Реки, граница `save.location>=LOCATIONS.length` — в этапе 36 ИСПРАВЛЕН баг этапа 5: жёсткое `>1` сбрасывало Море на reload, паттерн скинов), вкладка «Локации» 🗺️ (`#tabLocations`, строка `locationRow(loc,i,owned,selected)`, ветки buylocation/selectlocation в `shopClick()`: покупка — списание, push, АВТО-ПЕРЕХОД `save.location`, тост `welcome_lakes`/`welcome_sea`/`welcome_ocean`/`welcome_swamp` (у Болота — `if/else`-ветка, этап 45); выбор — только купленное). Таблицы рыб: у КАЖДОЙ воды свои `*_IDS`-списки, БЕЗ пересечений (этап 35): `RIVER_SPECIES(_NIGHT)` — ровно 8 базовых (`RIVER_IDS`; роллы реки не меняются: pin-тесты §9j(b)/M4/M6 завязаны на веса и порядок — новые виды в реку НЕ добавлять!), `LAKE_SPECIES(_NIGHT)` — только 10 озёрных (`LAKE_IDS`: zander/chuch/crayfish/bleak/roundel/eelpout/tench/navaga + rudd/cisco этап 35; веса `LAKE_DAY_WEIGHTS`/`LAKE_NIGHT_WEIGHTS`), `SEA_SPECIES(_NIGHT)` — только 5 морских (`SEA_IDS`: herring/mackerel/flounder/tuna/swordfish; веса `SEA_*_WEIGHTS`), `OCEAN_SPECIES(_NIGHT)` — только 9 океанских (этап 36, `OCEAN_IDS`: moray/grouper/turtle/angler/octopus/manta/shark/squid/whale; веса `OCEAN_*_WEIGHTS` — «с потолка», точный баланс отдельным этапом через tools/balance.mjs), `SWAMP_SPECIES(_NIGHT)` — только 5 болотных (этап 45, `SWAMP_IDS`: glowfin/moonfish/emberfin/violetlure/spiritfin; веса `SWAMP_DAY_WEIGHTS`/`SWAMP_NIGHT_WEIGHTS`). Этап 38: лестница веса `wMin/wMax` — Озёра ×8, Море ×6 (рыба-меч ×4), Океан ×4 (синий кит ×2 — чтобы остался вываживаемым), Река НЕ меняется: дороже вода = тяжелее рыба = сложнее бой (дольше `d0=45+7·w`, сильнее `ov→aggr`). Этап 40: ночные `*_WEIGHTS` (Озёра/Море/Океан) подтянуты к «ночь доходнее» + доля сельди/скумбрии в Море снижена (день 82→79%, ночь 78→64%) — цифры см. строку «Баланс Озёр». `SPECIES`/`SPECIES_NIGHT` — полный словарь 37 видов; ночные веса реки — в `NIGHT_WEIGHTS`, ключ обязателен каждому id (иначе `weight=undefined`). **Новые виды добавлять только в КОНЕЦ `SPECIES`** — `SPECIES[0..7]` запинены тестами (§573/§1524/§2446 `SPECIES[0]`, §9j/M4/M6 по id) и квестами (`QUEST_TPLS` индексы 0..2); вставка в середину молча сломает их. `pickSpecies()` выбирает таблицу по `save.location` (0/1/2/3/4) и dayPhase (логика ролла та же) — ТАБЛИЧНЫЙ доступ `SPECIES_BY_LOCATION(_NIGHT)` по `save.location` с guard'ом (вне 0..4 → Река; роллы 0..3 идентичны прежним, этап 45); босс — вид `BOSS_BY_LOCATION[save.location]` текущей локации (Этап 37/45: стурдж/рак/рыба-меч/кит/дух болота; runtime-флаг, новых видов нет — см. строку «Босс (M4)»); ачивка species_8 — «Поймай все виды» (все 37, check = `SPECIES.every`, id не менять — старые сейвы). Ачивки локаций (этап 22/36/45): `lake_first`/`sea_first`/`ocean_first`/`swamp_first` — locations-based (проверка `save.locations`), не caught-based — пин §10c(8) achBonus не трогает, кроме `swamp_first` (её `coinPct:0.02` двигает пин 0.16→0.18, этап 45). Этап 43 (визуальное различение): значения `water` — Море светлая бирюза / Океан глубокий тёмно-синий, Река/Озёра не тронуты (тест пинит только ФОРМАТ 6 hex, значения можно двигать); `LOCATIONS.*.waves` — характер волн поверхности в `drawWater()` (amp/amp2·freq/freq2·speed/speed2; amp/amp2=0 — линия НЕ рисуется: Река — без волн, спокойная вода (ревизия Этапа 44); Озёра — медленная гладь, Море — умеренная волна, Океан — крупные зыби); `AMBIENT_FISH` (секция декораций, перед `initFish()`) — размер/оттенок/число фоновых рыб по локации (Река 9×8–22 как было, Озёра 9×10–26 зелёные, Море 8×12–32 синие, Океан 6×18–44 тёмно-синие, Болото 7×12–30 с `palette` из 4 цветов свечения — циан/зелёный/фиолет/золото; `initFish()` пишет цвет в `f.glow` по индексу рыбы, у локаций 0..3 он `null`); `initFish()` — вызовы: старт + ОБЕ ветки смены локации в `shopClick()` (buylocation/selectlocation) — новую точку смены локации без `initFish()` не добавлять. Чисто визуал — на роллы/баланс/физку не влияет. Этап 44 (береговые силуэты): `scenery`/`initScenery()` (секция декораций, после `initFish()`): Река — береговая полоса НА ВСЮ ШИРИНУ `{k:'bank',h}` (трава 12–17 px + тёмная кромка у воды) + лес в 2 ряда, посаженный НА КРОМКУ `{k:'pine',layer,by}` (`by` = верх кромки; без `by` — линия воды), Озёра — холмы `{k:'hill',rx,ry}` + деревья `{k:'tree',h}`, Море — парусник `{k:'ship',x,v,s,dir}` (дрейфует по курсу) + 3 чайки `{k:'gull',y,v,ph}` (летят, машут), Океан — ПУСТО (открытый горизонт); дрейф парусника/чаек — `updateDecor(dt)`; отрисовка `drawScenery()` — между `drawSky()` и `drawWater()` (вода перекрывает базу силуэтов); ночь — `dusk()` гасит силуэты к #0b1220 (nm×0.8), паруса/чайки темнеют. Вызовы `initScenery()`: `resize()` + ОБЕ ветки смены локации в `shopClick()` (рядом с `initFish` — новую точку без неё не добавлять) + кадры lake/sea/ocean в gen-screenshots.js. Этап 45 (свечение и декор Болота): у 5 болотных видов поле `glow:'#hex'` (5 разных цветов, по одному на редкость r0–r4); ночная аура — ПЛАВНЫЙ ramp `glowIntensity()` (ревизия: 0 при `nightMix()<=0.35` как старт фонаря, smoothstep до 1 при `>=0.9`), компактный ореол `drawGlowAura(x,y,r,hex,I)` (радиальный градиент с крутым профилем 0.55/0.30/0 от тела рыбы + `globalCompositeOperation='lighter'`, `save/restore`, ранний выход при `I<=0.02`; rgba-строки кэширует `GLOW_RGBA`), радиус ambient `size*1.5`, bite/hooked `bs/fs*1.5` — контур рыбы чёткий (силуэт светящегося вида непрозрачный, аура позади), общий хелпер `fishGlow(sp,x,y,r)` для подплывающей/зацепившейся/вываживаемой рыбы, фоновые — в `drawAmbientFish()` по цвету `f.glow` из `palette`; карточка улова — класс `glow-card` + CSS-переменная `--glow` (только normal-вариант — не спорить с фильтрами вариантов; CSS-правило `#card.glow-card .bigemoji`), дневник — inline `filter:drop-shadow` на SVG (`class="glow-fish"`). Декор: ветка `i===4` в `initScenery()` (bank + reed + snag + pine болотной гаммы) и отдельные от `scenery` слои `pads[]` (8–14 кувшинок, у 45% — лилия жёлтая/белая/розовая) и `frogs[]` (3–5 лягушек, размер `s`≈9–14 px — заметны на телефоне; вся отрисовка `drawSwampDecor()` масштабируется от `s`; прыжки `sit`→`jump` по параболе на соседнюю кувшинку, `jh` 16–30) — очищаются/пересобираются в `initScenery()`; покачивание кувшинок и прыжки — `updateDecor(dt)`; отрисовка `drawSwampDecor()` — в `draw()` после `drawWater()`/`drawAmbientFish()` и до `drawBoat()` (поверх воды, чтобы `dusk()` берега не гасил декор); `initScenery()` вместе с `pads`/`frogs` зовётся в `resize()` + ОБЕ ветки смены локации в `shopClick()`; кадры swamp/swamp-night в gen-screenshots.js.
|
||
- **Баланс Озёр (этап 34):** ожидаемая выручка за улов = Σ(w·V)/Σw, V — середина диапазона веса × цена. `pickSpecies()` нормирует веса, так что **умножение всех весов на константу доход НЕ меняет** — для «стало дороже» правят СДВИГ долей (меньше `carass`/`bleak` — самые дешёвые V≈9/6 🪙, больше `sturge`/`gold`/`catfish`/`tench`/`navaga`). Текущие ориентиры: Река день 65.1 / ночь 125.9 🪙 (не меняются с этапа 34); этап 38: лестница веса `wMin/wMax` (Озёра ×8, Море ×6 — меч ×4, Океан ×4 — кит ×2); этап 39: цены Озёр/Моря/Океана урезаны к целям (по дню) Озёра ≈×3 Реки, Море ≈×4, Океан ≈×5; этап 40: ночные `*_WEIGHTS` подтянуты к «ночь доходнее» + доля сельди/скумбрии в Море снижена — актуальные пины: Озёра 194.1 / 249.0 (×2.98/×1.98 Реки), Море 297.3 / 476.7 (×4.57/×3.79), Океан 329.1 / 552.7 (×5.06/×4.39); этап 45: Болото 240.3 / 328.9 🪙 (ночь/день ×1.37; по ЦЕНЕ и доходу стоит между Озёрами и Морем в ОБЕ фазы) — порядок вод по цене/доходу: **Река → Озёра → Болото → Море → Океан**; ночь/день: Река ×1.93, Озёра ×1.28, Болото ×1.37, Море ×1.60, Океан ×1.68 (этап 40 поднял у новых вод с ×1.11–1.21, у Реки ×1.93 — полностью не сравнять: у новых вод ночь богаче днём по составу не настолько, как у Реки); инвариант «новая (по цене) вода доходнее предыдущей в ОБЕ фазы» держится (Озёра/Река ×2.98/×1.98, Болото/Озёра ×1.24/×1.32, Море/Болото ×1.24/×1.45, Океан/Море день **×1.11 — тонкий запас**: дневное Море упирается в дневной Океан 329.1, дальше срезать сельдь/скумбрию днём — только поднимая дневной Океан или цены) — проверять при любой правке `*_WEIGHTS`/`price`/`wMin/wMax`; считать руками не нужно — см. строку «Баланс-скрипт». **Минимальный вес вида ≥ ~2.2**: ниже — тест lc4 (3000 роллов, «каждый вид ≥1») начинает плавать.
|
||
- **Баланс-скрипт (`tools/balance.mjs`):** `node tools/balance.mjs` — считает доходность рыб ЗА ВАС, ручные прикидки Σ(w·V)/Σw больше не нужны. Вывод: по каждой из 10 таблиц ролла (Река/Озёра/Болото/Море/Океан × день/вечер-ночь; Болото добавлено этапом 45) — виды (вес/доля %/V/вклад) и итог E[🪙 за улов], сводка-матрица с отношениями соседних вод в обеих фазах (Озёра/Река, Болото/Озёра, Море/Болото, Океан/Море — пары строятся ПО ЦЕНЕ воды, а не по порядку массива `LOCATIONS`: Болото 90000 стоит последним, но по цене между Озёрами и Морем — инвариант «каждая новая (по цене) вода доходнее предыдущей в ОБЕ фазы»), «здоровье» (⚠ если у самого редкого вида P(промаха за 3000 роллов) > 0.1% — ориентир lc4 «каждый вид ≥1», соответствует вес ≥ ~2.2 при Σw≈95) и проверки целостности (пересечения таблиц, нечисловые веса, недостижимые виды). Данные берёт ПРЯМО из index.html: вырезает секцию «ДАННЫЕ» по якорям `const SPECIES=[` … закрытие таблицы `VARIANTS` и исполняет в изоляции (переименование якоря → понятная ошибка скрипта, чинить в самом скрипте) — правки баланса видны сразу, ничего дублировать не нужно. Модель ролла та же, что `pickSpecies()`: по умолчанию нейтрально (lure rare=0, dist=1 — штраф `minDist` ×0.06 не действует, как в пинах и lc4); опции: `--dist 0..1` (штраф дальних видов), `--lure <rare>` (буст `1+rare·rarity` — снасть может менять сравнение локаций, не путать с пинами), `--set id:вес` (what-if, можно несколько раз: переопределяет вес вида во ВСЕХ его таблицах — день и ночь; НЕ сохраняется), `--price id:цена` (what-if, можно несколько раз: переопределяет цену вида 🪙/кг; НЕ сохраняется — так же настраивали цены этапа 39), `--help`. Варианты рыб — строкой в шапке: E[mult]≈1.36 нейтрально / ≈1.69 при дожде — одинаково для всех локаций, на СРАВНЕНИЕ не влияет (босс «Осётр» — вне ролла, в доходность не входит). **Пины при нейтральной снасти (сверять до и после правок): Река 65.1/125.9 · Озёра 194.1/249.0 · Болото 240.3/328.9 · Море 297.3/476.7 · Океан 329.1/552.7 (день/ночь 🪙; этапы 38–45: лестница весов + урезанные цены + ночные веса + Болото).**
|
||
Цикл настройки баланса новой локации: (1) новые виды — только в КОНЕЦ `SPECIES` + ключ каждому id в `NIGHT_WEIGHTS`; (2) свои `*_IDS` + `*_DAY_WEIGHTS`/`*_NIGHT_WEIGHTS` без пересечений с другими водами (пин lc2 + проверка скрипта), развилка таблиц в `pickSpecies()`; (3) крутить доли прикидками без правки игры: `node tools/balance.mjs --set zander:30 --set bleak:5` (id — латиницей!) до нужных цифр — двигают СДВИГ долей, ×константа всех весов доход не меняет; (4) перенести финальные веса в index.html и перегнать скрипт БЕЗ `--set` — цифры обязаны совпасть с what-if; (5) цена/веса самих видов (`SPECIES`) — тоже видны скрипту сразу, но их правки могут задевать другие локации (общий словарь!) — пересчитать всё; (6) прогнать плейтест (lc2/lc4 и пины §9j/M4/M6 — река не должна меняться).
|
||
- Сложность вываживания: `updateReel()`.
|
||
- Физика боя (спринт 4): `fishForce()` (сила рыбы: рывок `(16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov)`, затишье `5+7·ag`, уставшая ×0.45) и `updateReel()` — выносливость `st` (старт `st0`, флаг `tired` при `st≤0.12`: рывки ×0.45, втягивание быстрее до ×1.6), тормоз катушки (черта `drag` в `RODS`: в рывке выше тормоза леска скользит `over·0.22`), натяжение (`10+over·1.1+22·ag`, ×0.35 в затишье), втягивание `(11+3·reel)·(1+0.6·(1−st))`, свободный бег `F·0.32`, escape-бар растёт только при затишье без удержания (отпускание в рывок безопасно), near-miss `1.45·d0`, `d0=45+7·w`. Пустые клёвы: ролл `biteChance()` в ветке WAIT (тест-форс `forceBite`). Тесты (§10) завязаны на эти константы — при ребалансе гонять Monte-Carlo точными формулами (docs/PHYSICS-RESEARCH.md, §4.7) и править тесты в том же коммите.
|
||
- Награды (M2): шаблоны квестов `QUEST_TPLS`, награды стрика `STREAK_REWARDS`, достижения `ACHIEVEMENTS` — секция «M2. Награды» после сейва. Этап 22: ачивок 29, этап 36: 31 (+`sea_first`/`ocean_first` — locations-based, пин §10c(8) не трогает), этап 37: 34 (+`lake_boss`/`sea_boss`/`ocean_boss` — пер-босс, БЕЗ coinPct, пин §10c(8) не трогает), этап 45: 36 (+`swamp_first` — locations-based, `coinPct:0.02`; `swamp_boss` — пер-босс, БЕЗ coinPct), этап 46: 38 (+`aq_first` «Первый жилец» / `aq_full` «Аквариум полон» — аквариум, `bonus:{}`, пин §10c(8) не трогают) (идут `ACHIEVEMENTS`-записями по темам: снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации/аквариум у магазинных); новые счётчики сейва `save.kgTotal`/`nightCatch`/`wxRainV`/`wxFog` (чтение через `||0` — без миграции) ставятся в `catchFish()` до `checkAchievements()` (rain-счётчик — только вариант ≠ normal, босс всегда normal); тест-пин §10c(8): сумма `achBonus().coinPct` (все 8 видов × 8 вариантов) = 0.18 (этап 45 добавил `swamp_first` +0.02; старые пины 0.16 обновлены; этап 46 не меняет — aq-ачивки с `bonus:{}`).
|
||
- SDK Яндекс Игр (Y1): секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ» — обёртка YG (стаб при file:// без ?sdk=1: скрипт не создаётся, no-op'ы + счётчики counts), динамическая загрузка /sdk.js (http(s)) с retry/warn, LoadingAPI.ready() первым кадром, GameplayAPI.start/stop в btnPlay/openShop/closeShop/btnGmResume (в гменю-флоу stop даёт `YG.onPause()`, дублей нет — пин §Y8(4)), game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend, пауза всего игрового времени в frame()).
|
||
- Реклама (Y2): константы `AD_FIRST_MS`/`AD_COOLDOWN_MS` (секция Y1), точка interstitial — `tryInterstitial()` в хендлере `btnCardOk` (гейт: первый показ ≥2 мин сессии, кулдаун 3 мин; `lastAdTs` — осознанно «попытка», не показ — при троттлинге платформы wasShown=false кулдаун сгорает), RV-кнопка `#btnRv` «📺 Реклама: ×2 за этот улов» на карточке улова (`showCard` сбрасывает `rvVal`/`rvDone`), методы `YG.showFullscreen/showRewarded` (+watchdog 90 с), `endAd()`; карточка улова БЕЗ `gameplayStop()` — осознанно (часть цикла улова); реклама не персистится в сейв (только `persist()` перед показом и после бонуса).
|
||
- i18n (треб. 2.14/2.2): таблица `T={ru,en}` (единая точка строк UI), `t(key)`/`tt(key,o)`, `LANG` (дефолт `ru`), `forceLang` (тест-форс), `setLang()` (обновляет `<html lang>`/`<title>` + пере-рендер меню/статичного UI и открытых оверлеев — магазина/карточки), `resolveLang(ysdk)` — резолюция: `forceLang` → `ysdk.environment.i18n.lang` → `ru`; стаб (file:// без SDK) — всегда `ru` (`navigator.language` НЕ используется — ломает headless-тесты). `trName`/`trDesc`/`trRarity` — выбор по `nameEn`/`descEn`/`RARITY_NAMES_EN` в таблицах данных. Меню — `renderMenu()` (device-aware: тач/мышь, `IS_TOUCH`). EN-ветка `questText()` (RU-ветка с `pluralN` не меняется).
|
||
- Пауза (треб. 6.3/1.3): HUD-кнопка `#btnPause` (⏸) → `openPause()` (гарды MENU/uiOpen/adShowing/pauseOpen; `YG.onPause()` + оверлей `#pause`); `#btnPauseResume` → `closePause()`, `#btnPauseMenu` → хаб «Меню» `#gamemenu` (с `YG.onResume()`). Гард `pauseOpen` в `YG.onResume` (после `adShowing`) — blur/focus/visibilitychange во время открытой паузы не снимают паузу (снимут только `closePause()`/«Продолжить» гменю). `window blur` → `YG.onPause()`, `focus` → `YG.onResume()` (треб. 1.3, дополнение к visibilitychange). Видимость `#btnPause` и `#btnRecall` — в `syncDom()` (вызывается каждый кадр в `frame()` вне `YG.paused` — без этого `#btnPause` «залипает» видимым за оверлеями).
|
||
- Хаб «Меню» (Э29): оверлей `#gamemenu` — кнопки «Продолжить» (`#btnGmResume`: uiOpen=false + `YG.gameplayStart()`), Магазин/Достижения/Дневник (`fromMenuShop(tab)` → `openShop('tackle'|'ach'|'journal')`), «Инструкции» (`#btnGmHowto` → `#menu`; «Играть» вернёт в игру), звук `#btnMute` и сброс `#btnReset` (→ `#resetDlg`) — ПЕРЕНЕСЕНЫ из HUD (id сохранены; в `.hudbtns` остались только 🛒 и ⏸). «В меню» НЕ меняет `state` — сессия (заброс/вываживание) заморожена под оверлеем и продолжается с того же места; `state='MENU'` теперь только при загрузке/после сброса (там показывается `#menu` с инструкциями — «старт новой игры» прежний). Флаг `shopFromMenu`: лавка, открытая из «Меню», при × возвращает в `#gamemenu` (uiOpen остаётся true, БЕЗ `YG.gameplayStart()`); лавка из HUD/`#coins` — как раньше, в игру. Z-порядок: `#gamemenu` в DOM позже `#shop`/`#resetDlg` (тот же z-index) — при открытии лавки/сброса из «Меню» гменю прячется, иначе перекрыл бы их.
|
||
- Монеты → лавка (Э29): клик по `#coins` (pointer-events:auto + cursor в CSS) → `openShop('tackle')` — «Лавка рыбака» на вкладке «Лавка» (`#tabTackle`, эмодзи 🛒, `T.tab_tackle` 'Лавка'/'Shop'; RU-секции «🎣 Удилища»/«🪱 Приманки» внутри не тронуты). `openShop(tab?)` — аргумент форсит вкладку (`syncTabs()` синк активных классов, вынесен из `setTab`); HUD 🛒 зовёт `openShop()` без аргумента — помнит последнюю вкладку.
|
||
- Закрытие по тапу мимо диалога: `backdropClose(el,fn)` (секция «КНОПКИ», после Y2-хендлеров) — `pointerdown`, где `e.target ===` сам оверлей (тап на подложку, не .panel), закрывает диалог тем же действием, что и кнопка: `#shop`→`closeShop`, `#resetDlg`→«Отмена» (`btnResetNo.click()`), `#pause`→`closePause`, `#gamemenu`→«Продолжить» (`btnGmResume.click()`), `#card`→«Отлично!» (`btnCardOk.click()`, вкл. интерстишел). `#menu` — экран старта, не диалог. Новый оверлей-диалог без `backdropClose` не добавлять. Подводный камень: у `#card` pop-анимация (`#card{animation:pop .3s}` — transform scale) — пока идёт (300 мс), hit-area подложки сужена и тап мимо уходит на канвас (в реальном флоу state='CARD' → onDown no-op, безвредно; тест §X ждёт 400 мс после showCard).
|
||
- Встроенный emoji-шрифт (Э28): все emoji UI и canvas рисует вшитый шрифт — субсет Noto Color Emoji (COLRv1, OFL) как base64 `@font-face font-family:'EmojiEmbedded'` в index.html между маркерами `<!-- EMOJI-FONT:BEGIN/END -->` (внешний .woff2 невозможен: публикуется только один index.html). В font-stack `html,body` и во всех `ctx.font` (константа `UI_FONT`, секция «ОТРИСОВКА») стоит ПЕРВЫМ — единый Google-стиль emoji на всех платформах; headless-скриншоты не зависят от системных шрифтов. Первый кадр ждёт `document.fonts.load('16px "EmojiEmbedded"')` с timeout-фолбэком 1.5 с. Генератор `tools/emoji-font.py` (venv: `python3 -m venv tools/.venv && tools/.venv/bin/pip install fonttools brotli`; исходник `tools/fonts-src/NotoColorEmoji-Regular.ttf` из google/fonts, всё в gitignore): `python3 tools/emoji-font.py` — перегенерация по текущим emoji index.html (ОБЯЗАТЕЛЬНО при добавлении новых emoji), `--check` — падает, если субсет не покрывает index.html (✓ № ● ○ ↩ — в NCE их нет, это ок, рисуются системными шрифтами). Тест §Y7 (tests/test.js) гоняет `document.fonts.check('16px "EmojiEmbedded"','🪙')`; семейство переименовывать нельзя (тест + @font-face + UI_FONT).
|
||
- **Портреты рыб (SVG-спрайт)**: исходники `assets/fish/fish-<id>.svg` (37 файлов — 5 болотных добавлено этапом 45, viewBox 0 0 160 100, цвета `var(--f1..f4, hsl-fallback)`; переменные НЕ объявлять на `<svg>`/`<g>`); встраивание — `node tools/embed-fish.mjs` (идемпотентно, блок между маркерами `FISH-SVG:BEGIN/END` в index.html, сразу после `<body>`; самопроверка + `check.mjs` внутри); приёмка — `tools/fish-preview.html` (file://); дневник/карточка показывают через `<use href="#fish-<id>">` (дневник `.jrow .je svg`, карточка `#cardEmoji svg`, `showCard` ставит href у `#cardFishUse`); перекраска вариантов — CSS-переменные `--f1`/`--f3` в `#card.<variant>-card .bigemoji` (наследуются сквозь `<use>`; silver/rainbow — существующие filter); правка портрета = правка `assets/fish/*.svg` + повторный запуск embed-скрипта. Эмодзи рыб УБРАНЫ (этап 42): поле emoji удалено из SPECIES, тосты без эмодзи, иконка квеста catch_species — mini-SVG `<svg class="qfish"><use href="#fish-<id>">`; субсет emoji-шрифта перегенерирован (рыбных глифов нет).
|
||
- **Аквариум (Этап 46)**: декоративная сцена коллекции ЭКЗЕМПЛЯРОВ (вид+вариант+вес), **НЕ локация** — в `LOCATIONS`/`SPECIES_BY_LOCATION`/`BOSS_BY_LOCATION` не входит, роллы/боссы/баланс/пины не трогает (`tools/balance.mjs` не менялся). Данные: `save.aqFish[]` (`{id,v,w}`) + `save.aqSlots` (старт `AQ_BASE_SLOTS=6`, макс `AQ_MAX_SLOTS=12`), константы `AQ_BASE_SLOTS`/`AQ_MAX_SLOTS`/`AQ_SLOT_COSTS=[1500,4000,9000,20000,45000,100000]` в секции ДАННЫЕ рядом со скинами/локациями. **Модель C — бесплатная КОПИЯ**: `catchFish()` НЕ меняется (монеты/квесты/ачивки/реклама начисляются на улове как раньше), кнопка `#btnKeep` «🐠 В аквариум» на карточке улова кладёт копию экземпляра (`cardSpec={sp,w,variant}` в `showCard()`, `syncKeepBtn()` — `disabled` + подпись/`title` «Нет места» при `save.aqFish.length>=save.aqSlots`), затем `persist()`/`checkAchievements()`/тост `aq_kept`/закрытие карточки/`state='IDLE'`/`tryInterstitial()` (единообразно с `#btnCardOk`). Сцена: `aqOpen`, `aqFishRt[]` (раскладка `x,y,dir,ph,spd,vy,tail` — НЕ персистится, индекс = `save.aqFish`), `aqSel` (сентинел `-1` = ничего), `aqTank`/`aqPlants`/`aqBubbles`, `initAquarium()`/`updateAquarium(dt)`/`drawAquarium()` (вода `hexLerp` к `nightMix()` — общий цикл суток; размер `aqFishSize(w)` — от АБСОЛЮТНОГО веса по общей с вываживанием кривой `fishSizeFromW()` (Этап 41): `clamp(fishSizeFromW(w)·AQ_FISH_SCALE·k, AQ_FISH_SIZE_MIN·k, min(W,H)·AQ_FISH_SIZE_REL·k)`, `K=0.7`/`MIN=7`/`REL=0.16`, десктоп-множитель `k=AQ_DESKTOP_SCALE=2` при `min(W,H)>=AQ_DESKTOP_MIN=600` (иначе 1) — ревизия после Этапа 46, `w/wMax`+`rarity` больше не влияют; сетка стартовых позиций, разворот у стенок, пузыри). **Ревизия: жильцы банки — те же SVG-портреты, что на карточке улова** (DOM-слой `#aqFishLayer` поверх canvas, спрайт `#fish-<id>` + вариант `.var<id>`/`--f1`/`--f3` 1:1 с карточкой; `aqBuildFishEl(i)`/`aqPlaceFish(el,rt)`/`aqFishEls[]`, transform обновляется в `updateAquarium`; слой включается только под `body.aq-open`, `pointer-events:none`, `clip-path` по банке). На canvas — вода/грунт/растения/пузыри/стекло, свечение болотных `drawGlowAura`+`glowIntensity()` (уходит ЗА портрет) и рамка выбора; `drawFishShape` в банке больше не используется (остался для рыб в сценах ловли). Точки жизненного цикла: `frame()` (`if(aqOpen) updateAquarium(dt)`), `draw()` (`if(aqOpen){ drawAquarium(); ctx.restore(); return; }` — банка ВМЕСТО озера), `onDown()` (ранний `if(aqOpen){ aqTap(x,y); return; }`), `resize()` (`if(aqOpen) initAquarium()`), `syncDom()` (под `aqOpen` `#btnPause`/`#btnRecall` не показываются). DOM: вход `#btnGmAquarium` в хабе `#gamemenu` → `openAquarium()`/`closeAquarium()` (аналог `shopFromMenu` — флаг `aqFromMenu`, «Назад» возвращает в гменю; `YG.gameplayStop()`; `uiOpen=true`); бар `#aqBar` (`#btnAqBack`, `#aqCount` «N/M», `#btnAqSlot` — цена `AQ_SLOT_COSTS[aqSlots-AQ_BASE_SLOTS]`, при максе `disabled`), поповер `#aqPop` (`#aqPopName`/`#aqPopSub` — вес · редкость, БЕЗ цены), тап по рыбе `aqTap(x,y)` (ближайшая в радиусе → `aqSel` → `showAqPop()`). «Выпустить» = `#btnAqRelease` → `aqReleaseSel()` — точечное удаление экземпляра ИНДЕКСАМИ (без `initAquarium`, позиции остальных сохраняются) **БЕЗ монет** (освобождение слота; продажа из банки была в плане §7, но по решению владельца заменена на выпуск — экономика не затронута). Миграция `loadSave()`: `aqFish` фильтруется по `SPECIES` (`Number.isFinite(w)&&w>0`), вариант → `'normal'`, `aqSlots` — `Math.trunc` + клампы `AQ_BASE_SLOTS..AQ_MAX_SLOTS`, хвост обрезается по слотам. Ачивки 36→38: `aq_first` «Первый жилец» / `aq_full` «Аквариум полон» — обе `bonus:{}` (пин `achBonus().coinPct=0.18` не сдвинут). i18n: `gm_aquarium`, `aq_title`, `aq_keep`, `aq_back`, `aq_slots`, `aq_buy_slot`, `aq_max`, `aq_release`, `aq_released`, `aq_no_room`, `aq_kept` (RU/EN). Emoji: 🐠 добавлен во встроенный субсет (перегенерация `tools/emoji-font.py`, `--check` OK). Подводные камни: `body.aq-open` прячет `#hud` (CSS + `syncDom`), а `#aqBar{pointer-events:none}` + `.aqpill{pointer-events:auto}` — иначе невидимая полоса бара перехватывала бы тапы по сцене; сентинел `aqSel=-1` (все проверки индекса — `aqSel>=0`); «выпустить» без монет — не вводить продажу (сознательное решение, дыра ×2 дохода закрыта).
|
||
|
||
## Запуск и публикация
|
||
|
||
- Dev-сервер: `python3 -m http.server 8000`; игра так же работает с `file://`.
|
||
- Процесс разработки, публикация в Gitea, гигиена секретов: `docs/DEVLOG.md`.
|
||
|