76 lines
75 KiB
Markdown
76 lines
75 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`, `LINE_SKINS`, `SKIN_GROUPS` (Этап 59), `RODS`, `LURES`, `rodLook()`, `hookRec()` (внешний вид снасти, Этап 63, тесты §10e2), `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`, `genQuests`, `questScale`, `localMedianW` (квесты Этапа 53, тесты §9e2), `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`, `aqReleaseAt` (выпуск по индексу, Э64), `openAquarium`, `closeAquarium`, `syncKeepBtn`, `cardSpec`, `save.aqFish`, `save.aqSlots`, `aqPickOpen`, `aqPickPending`, `openAqPick`, `closeAqPick`, `renderAqPick`, `aqPickPlace`, `aqPickConfirmReplace`, `aqPickBuySlot`, `aqPickFinish`, декора аквариума (Этап 59): `AQ_FLOORS`, `AQ_DECOS`, `save.aqDecor`/`save.aqFloorOwned`/`save.aqDecoOwned`, `drawAqDeco`; диалога улучшений (Этап 62): `aqShopOpen`, `aqShopTab`, `openAqShop`, `closeAqShop`, `setAqTab`, `renderAqShop`, `aqDecorRow`, `aqBuySlot`, `aqEquipFloor`, `aqEquipDeco`, `aqShopRelPending` (pending выпуска, Э64) (`aqCycleFloor`/`aqCycleDeco`/`aqDecorName`/`btnAqFloor`/`btnAqDeco`/`btnAqSlot` — УДАЛЕНЫ Этапом 62; `aqReleaseSel`/`btnAqRelease` — УДАЛЕНЫ Э64 (выпуск только из диалога); `aqValue` удалён, `AQ_VARIANT_FX`/`AQ_VARIANT_HUE` — тоже, не использовать). Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
|
||
- Ввод на Pointer Events (`pointerdown`/`pointerup`), а не touch/mouse — так тест управляет игрой мышью. Не заменять на тач-обработчики.
|
||
- Сейв: ключ localStorage `fishing-save-v1`. При изменении формата — миграцию добавлять в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
|
||
|
||
## Где крутить
|
||
|
||
- Баланс (рыба/удилища/приманки): константы `SPECIES`, `RODS`, `LURES` в начале скрипта (базы: удочки **0/150/500/30000**, наживки **0/100/2000/22000** — Этап 53). Апгрейды снастей (уровни 1..4): цена перехода — `upgCost(type,i,lv)`: id 0 — фикс `ROD_UPG_COST`/`LURE_UPG_COST` (**20/50/100** и **15/40/80**), id>0 — база×`UPG_MULT` (**0.15/0.35/0.6**, округление до 10); подписи уровней `ROD_UPG_NEXT`/`LURE_UPG_NEXT`, уровни `save.rodLv`/`save.lureLv`, эффективные статы — `effRod()`/`effLure()` (глобалы `rod()`/`lure()` отдают эффективные), гейтинг открытия — в `itemRow()`/`shopClick()`. **Этап 53 (ребаланс прогрессии):** ранний темп — Стеклопластик/Червь и Озёра ≤5 мин, затем плавное замедление (Карбон 5–10 мин, Болото +15 мин, Титан ~1 ч, Море ~2 ч, Океан ~4 ч). Цены калиброваны `tools/progression.mjs`; менять их — только перепрогнав этот скрипт и плейтест.
|
||
- **Внешний вид снасти (Этап 63, чистый визуал):** бланк удочки и крючок меняются от ЭКИПИРОВАННОЙ снасти, а не от скина (четвёртый класс скинов сознательно НЕ вводили — иначе два источника истины на один пиксель + миграция сейва/ачивки/пины). Данные только в записях таблиц (паттерн Этапа 57 — в отрисовке нет `switch` по id): `RODS[i].look={c,w,band?,fx?}` — цвет/толщина бланка, `band` — серебристое кольцо у кончика (Карбон/Титан), `fx:'glint'` — холодное мерцание (Титан); `LURES[i].hook={c,fx?,bait?,bead?,body?,blade?}` — цвет дуги/жала, `fx:'gold'` — золотое мерцание (Блёстка); **Э64:** `bait` — розовый червь НА конце крючка (по жалу, с головным утолщением; стал крупнее), `body` — тельце-минноу воблера над крючком (хвост, светлое брюшко, лопатка; `bead` — красный глаз), `blade` — золотой лепесток-«вращалка» Блёстки (ширина ходит `sin(time·4.5)`, рисуется внутри gold-тени). Чтение — хелперы `rodLook()`/`hookRec()` (кламп `length-1` + fallback на запись 0, паттерн `lineSkinRec()`); `effRod`/`effLure` — `Object.assign`-копии, косметика проходит через `rod()`/`lure()`. Отрисовка: `drawBoat()` (stroke бланка из `look`, glint/band в save/restore — тень не перетекает на лодку/флажок; **длина бланка и `tip` не тронуты — `tip` якорит леску/заброс, менять нельзя без перепроверки всей геометрии**) и `drawRig()` (крючок из `hook`, gold — паттерн `lineShadow()`; насадка поверх крючка ветвлением по полям записи, без switch по id: `blade` — внутри gold-тени до restore, `body`/`bait` — после; `body`+`bead` рисуются даже с крючком под тельцем — «тройник под вобом»; крючок виден только в WAIT/APPROACH/BITE — в REEL он «в рыбе»; геометрия `hx/hy/hookPos` Э64 НЕ тронута — только начертание). На статы/баланс/сейв/ачивки не влияет — миграция не нужна. Тесты §10e2 (данные + проход rod/lure 0..3 в WAIT без pageerror, со снимком экипировки).
|
||
- Ночной и погодный декор (Этап 58, чисто визуал): `fireflies[]`/`bats[]` собираются в `initScenery()` (Река/Озёра/Болото; у Моря/Океана пусто) там же очищаются; ход — только в `updateDecor(dt)` (дрейф Лиссажу, горизонтальный полёт мышей, старт/жизнь `meteor`, спавн/затухание `rainRipple`, `lightningT`). Отрисовка: светлячки `drawFireflies()` в `draw()` после `drawSwampDecor()` (гейт `nightMix()>0.35`), кольца дождя `drawRainRipple()` рядом до `drawBoat()` (массив отдельный от игровых `ripples[]`), мыши + падающая звезда — ВНУТРИ `drawSky()` (гейты 0.5/0.6). DOM/геймплей не трогает.
|
||
- Ночь (M3): длительность суток `DAY_LENGTH` (сек), ночная таблица рыб `SPECIES_NIGHT`, фонарик — апгрейд 1 любого удилища (`save.rodLv[save.rod]>=2`, проверка `hasLight()` в ветке WAIT, секция «M3: Цикл дня/ночи»); фонарь на носу лодки в `drawBoat()` горит под тем же гейтом `hasLight()` — без апгрейда ночная лодка тёмная (Этап 61, чистый визуал; ночные кадры `gen-screenshots.js` ставят `rodLv=2`, чтобы фонарь в скриптах оставался горел). Мировое время (`setDayTime(...)` + `updateWeather()` в `frame()`) идёт ТОЛЬКО в игре под `if(!uiOpen)` — под любым неигровым экраном (#menu/#gamemenu/лавка/аквариум/#resetDlg/#pause) день/ночь и погода заморожены; `time`/`updateDecor`/`updateAquarium` не морозятся (визуал). Под `YG.paused` (реклама/скрытая вкладка) заморожено как и раньше — весь блок кадра.
|
||
- Босс (M4, Этап 37 — босс в каждой локации, Этап 45 — +Болото, Этап 55/56 — босс Болота: Которыбка): тест-форс появления `forceBoss` (работает с боссом ТЕКУЩЕЙ локации), таблица `BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale',4:'meowfish'}` (секция ДАННЫЕ) — босс = существующий вид текущей локации (Река=Осётр как в M4, Озёра=Гигантский рак, Море=Рыба-меч, Океан=Синий кит, Болото=Которыбка (meowfish, экс-целакант этапа 55); Дух болота с Этапа 55 — обычная рыба) с 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()` под `if(!uiOpen)`: идут в реальном времени только в игре, под неигровыми экранами заморожены вместе с днём/ночью (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% клёва (осознанно). **Этап 58 (визуал):** `drawWeather()` больше не заливает экран целиком — пелена прижата к ватерлинии (градиент от 0 на `waterY−H*0.22` до 0.28 на `waterY−8` и ниже), НАД водой непрозрачность ≤ именованной `FOG_TOP_ALPHA` (0.10 — бюджет читаемости поплавка/HUD); 2 полосы дрейфуют горизонтально у поверхности под `clip`. В дождь — редкая молния: `lightningT` (8–16 с) в `updateDecor` ставит `lightningFlash=1` (затухание `-dt*3`), `drawWeather` рисует белый экран альфой `lightningFlash*0.35` (≤0.35); поля рантайм, в сейв не идут.
|
||
- Скины (M-skins, рефактор Этапа 57 — скины управляются ТОЛЬКО таблицей): таблицы `BOAT_SKINS`/`BOBBER_SKINS` (секция ДАННЫЕ) — **цвета и спец-эффекты живут в записях**, в отрисовке нет `[skin]`-массивов. Формат: лодка `{id,name,nameEn,cost,cols:[верх градиента,низ градиента,кромка борта], fx?}` (fx: `'cabin'` каюта / `'dragon'` драконья голова на носу + щит / `'wake'` бурун под корпусом); поплавок `{id,name,nameEn,cost,bot,top,ring, fx?}` (fx: `'neon'` постоянное зелёное свечение / `'firefly'` свечение только при `nightMix()>0.35` / `'bell'` двойной диск + металлический блик). Цены (Этап 57): лодка **0 / 800 / 2000 / 6000 (Драккар/Longship) / 18000 (Катер/Speedboat)**; поплавок **0 / 500 / 1500 / 4000 (Светлячок/Firefly) / 12000 (Бубенчик/Bell)** — строго возрастающие. Сейв `save.boatSkin(s)`/`save.bobberSkin(s)` + миграция в `loadSave()` (включая гарантию бесплатного скина 0); **жёсткие границы — `>BOAT_SKINS.length-1`/`>BOBBER_SKINS.length-1`** (было `>2` — новые скины молча сбрасывались на 0 при reload!), отрисовка — `Math.min(BOAT_SKINS.length-1,…)` с fallback `BOAT_SKINS[0]`/`BOBBER_SKINS[0]`; вкладка «Скины» 🎨 (`#tabSkins`, строка `skinRow(list,i,owned,equipped,group)`, ветки buyskin/select в `shopClick()`: покупка со списанием и авто-экипировкой, выбор — только купленное); чистая косметика — без эффектов на геймплей. Свечение поплавка — `shadowBlur` в save/restore (не перетекает на воду). Ачивки `skin_first` (>1 скина лодки/поплавка) и `skins_all` (все id обеих таблиц — табличная проверка `BOAT_SKINS.every`/`BOBBER_SKINS.every`, подхватила новые скины без правки; `bonus.coinPct` не менялся — пин §10c(8) 0.18 жив).
|
||
**Этап 59 — ТРЕТИЙ класс: леска (`LINE_SKINS`).** Таблица рядом с лодкой/поплавком, тот же принцип «цвет/эффект только в записи»: `{id,name,nameEn,cost, c, w, fx?}` — `c` цвет основы (rgba-строка), `w` толщина на воде, `fx:'gold'` золотое мерцание (shadowBlur в save/restore, не перетекает). Скины: **0 Обычная / 3000 Флуоресцент / 12000 Золотая нить** (строго возрастающие, nameEn обязателен у всех). Сейв `save.lineSkins[]`/`save.lineSkin` + миграция в `loadSave()` (дефолты, гарантия индекса 0, клампы `LINE_SKINS.length-1`, купленные фильтруются от нечисловых/вне таблицы; у boatSkins/bobberSkins фильтра нет, только гарантия 0). Отрисовка — ЧЕТЫРЕ точки, все читают `lineSkinRec()` (`Math.min(length-1,…)` + fallback на скин 0): `drawLineTo()` (цвет+c.lineWidth из записи, `lineShadow()` для fx gold), поводок в `drawRig()` (тот же цвет, `globalAlpha=0.35` в save/restore), след поплавка в ветке CAST `drawLineAndBobber()` (цвет из записи, альфа — прежняя прогрессия `i/len*0.35`), подводный сегмент bobber→рыба в ветке REEL `drawLineAndBobber()` (цвет из записи, `globalAlpha=0.35`, `lineWidth=Math.min(1.2,ls.w)`, `lineShadow()` с парным restore). Удочка/катушка НЕ тронуты. Магазин: **таблица групп `SKIN_GROUPS={boat,bobber,line}`** (`list()`/`owned()`/`cur` — имя поля экипировки) — ветка `if(SKIN_GROUPS[type])` в `shopClick()` одна на все классы (прежний тернарник `type==='boat'?…:…` при третьем классе молча отдал бы поплавок — так и написан комментарий); `renderShop()` — секция `t('sec_line')` («Леска»/«Line») + `skinRow(...,'line')`. После покупки — `checkAchievements()` (Этап 59): ачивка `line_first` «Тонкая снасть» (`bonus:{}` — пин §10c(8) жив; `skin_first`/`skins_all` про лодку/поплавок НЕ расширены).
|
||
- Локации (этап 5, этап 35, этап 36 — эксклюзивная ихтиофауна, этап 45 — Болото): таблица `LOCATIONS` (Река 0 — бесплатно / «Озёра» **500** 🪙 / «Море» **100000** 🪙, этап 35 / «Океан» **300000** 🪙, этап 36 / «Волшебное болото» **10000** 🪙 — id 4, этап 45: в массиве `LOCATIONS` — ПОСЛЕДНИЙ; **Этап 53: цены пересмотрены** — Озёра 65536→500, Болото 90000→10000, Море 131072→100000, Океан 196608→300000; порядок по цене Река < Озёра < Болото < Море < Океан сохранён; `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)` — только 6 болотных (этап 45, `SWAMP_IDS`: glowfin/moonfish/emberfin/violetlure/spiritfin + meowfish «Которыбка» (экс-целакант), Этап 56; веса `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` — полный словарь 38 видов; ночные веса реки — в `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/56: стурдж/рак/рыба-меч/кит/которыбка; runtime-флаг, новых видов нет — см. строку «Босс (M4)»); ачивка species_8 — «Поймай все виды» (все 38, 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 (свечение и декор Болота): у болотных видов поле `glow:'#hex'` (у 5 видов этапа 45 — по одному на редкость r0–r4; Этап 56: +которыбка `#ff9ecb`, второй r4 — всего 6 разных цветов); ночная аура — ПЛАВНЫЙ 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/53/55: Болото **302.9 / 342.3** 🪙 (ночь/день ×1.13; **Этап 53 доходность Болота опускали до уровня Озёр и ниже, но Этап 55/56 добавил Которыбку (meowfish, r4, 50🪙/кг, 20–80 кг, ролл 4) и сознательно вернул Болото выше Озёр — решение владельца: «дорогая которыбка важнее инвариантов»; по 🪙/мин Болото лидирует среди «средних» вод**) — порядок вод по цене: **Река → Озёра → Болото → Море → Океан**; ночь/день: Река ×1.93, Озёра ×1.28, Болото ×1.13, Море ×1.60, Океан ×1.68; инвариант «новая (по цене) вода доходнее предыдущей в ОБЕ фазы» для Болота **не выполняется сознательно с Этапа 55** (Болото/Озёра per-catch ×1.56/×1.37, а днём Болото обгоняет и Море — ×1.02; Море/Болото ×0.98/×1.39) — остальные пары держатся (Озёра/Река ×2.98/×1.98, Океан/Море день **×1.11 — тонкий запас**) — проверять при любой правке `*_WEIGHTS`/`price`/`wMin/wMax`; считать руками не нужно — см. строки «Баланс-скрипт»/«Прогрессия». **Минимальный вес вида ≥ ~2.2**: ниже — тест lc4 (3000 роллов, «каждый вид ≥1») начинает плавать.
|
||
- **Баланс-скрипт (`tools/balance.mjs`):** `node tools/balance.mjs` — считает доходность рыб ЗА ВАС, ручные прикидки Σ(w·V)/Σw больше не нужны. Вывод: по каждой из 10 таблиц ролла (Река/Озёра/Болото/Море/Океан × день/вечер-ночь; Болото добавлено этапом 45) — виды (вес/доля %/V/вклад) и итог E[🪙 за улов], сводка-матрица с отношениями соседних вод в обеих фазах (Озёра/Река, Болото/Озёра, Море/Болото, Океан/Море — пары строятся ПО ЦЕНЕ воды, а не по порядку массива `LOCATIONS`: Болото 10000 стоит последним, но по цене между Озёрами и Морем — инвариант «каждая новая (по цене) вода доходнее предыдущей в ОБЕ фазы»; для Болота — сознательное исключение с Этапов 53/55), «здоровье» (⚠ если у самого редкого вида 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 · Болото 302.9/342.3 · Море 297.3/476.7 · Океан 329.1/552.7 (день/ночь 🪙; этапы 38–55: лестница весов + урезанные цены + ночные веса + Болото с которыбкой).**
|
||
- **Прогрессия (`tools/progression.mjs`, Этап 53):** `node tools/progression.mjs` — модель «за сколько минут игрок дойдёт до цели»: берёт цены/статы из index.html, моделирует улов (`pickSpecies`+`rollWeight`+варианты+обрыв по `maxW`) и цикл ловли (`biteChance`/таймеры/fishForce/updateReel ботом), прогоняет жадный маршрут покупок и печатает доход по водам (🪙/мин) + ETA целей. Опции: `--route`, `--start-coins`, `--per-day`, `--samples`, what-if `--rod/--lure/--loc/--rod-upg/--lure-upg/--upg-mult/--set/--set-day/--set-night`. **Калибровочные цели Этапа 53, пересняты на Этапе 55/56 после добавления болотной Которыбки** (модель «идеального» игрока): Стекло ~4.5 мин · Озёра ~10.5 · Карбон ~7 · Болото 22.7 (12.2 после Озёр) · Титан ~38 мин · Море 1 ч 29 · Океан 3 ч 14. Любую правку цен/весов сверять этим скриптом.
|
||
Цикл настройки баланса новой локации: (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) не трогают), **этап 59: 39** (+`line_first` «Тонкая снасть» — скин лески, `bonus:{}`, пин §10c(8) не трогает; тест §22(2) пинит `ACHIEVEMENTS.length===39`) (идут `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:{}`).
|
||
- **Этап 53 (масштабирование квестов):** `questScale()` = `clamp(round((save.earned/2000)^0.6),1,30)` — метрика `save.earned` (только доход с рыбалки; награды квестов его не разгоняют). `QUEST_TPLS.make(r)` масштабирует ВСЕ шаблоны: `earn_coins` goal×scale (reward = round(goal·0.25)); `casts` ×(1+0.15·scale); `catch_weight` порог от `localMedianW()` (медиана середин весов рыб ТЕКУЩЕЙ локации ×scale^0.35, а не фикс 2/3/5 кг); `catch_rarity` r = clamp(1+floor(1.5·log10(scale+1)),1,4); `catch_species` вид — из `SPECIES_BY_LOCATION[save.location]` с rarity≤2 (не только речные 0..2), `questText` RU/EN терпим к неречным id (fallback `trName`). `qReward(base)` = `round(base·scale^0.6)`. Первые 8 видов и порядок `SPECIES` не тронуты. Тесты §9e2.
|
||
- 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` «залипает» видимым за оверлеями). Сам оверлей `#pause` — частный случай правила «мировое время стоит под `uiOpen`» (см. строку «Ночь (M3)»): под паузой `uiOpen=true`, поэтому день/ночь и погода не идут.
|
||
- Хаб «Меню» (Э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) — при открытии лавки/сброса из «Меню» гменю прячется, иначе перекрыл бы их. **Этап 59:** «Аквариум» и «Инструкции» стоят ПАРОЙ в строке (`.mbtn.mbhalf` + `.mbtns.flexrow`) — бар сцены банки вырос на 2 пилюли, и на телефоне 9 кнопок столбиком не влезали в 844px (кнопка «Аквариум» уезжала за экран — клики в тестах падали).
|
||
- Монеты → лавка (Э29): клик по `#coins` (pointer-events:auto + cursor в CSS) → `openShop('tackle')` — «Лавка рыбака» на вкладке «Лавка» (`#tabTackle`, эмодзи 🛒, `T.tab_tackle` 'Лавка'/'Shop'; RU-секции «🎣 Удилища»/«🪱 Приманки» внутри не тронуты). `openShop(tab?)` — аргумент форсит вкладку (`syncTabs()` синк активных классов, вынесен из `setTab`); HUD 🛒 зовёт `openShop()` без аргумента — помнит последнюю вкладку.
|
||
- **Иконки лавки (Этап 60, чистый визуал):** у записей `LOCATIONS`/`BOAT_SKINS`/`BOBBER_SKINS`/`LINE_SKINS` поле `icon` (как у `RODS`/`LURES`); `skinRow`/`locationRow` рисуют его `<span class="itemicon">` ДО `.itemname` — ВНУТРЯ нельзя: тесты сверяют текст `.itemname` строго (`=== 'Красная лодка'`, §скины(4)/(7)); у строк снастей иконка по-прежнему инлайн в `.itemname` (тесты там через `includes`). Кнопка апгрейда снасти вместо generic `⬆` показывает `item.icon` (fallback `⬆`) — тест M3(e) проверяет `indexOf`, зелёный. Иконки: локации 🏞️ 🛶 ⛵ 🌊 🐸; лодки 🛶 🔴 🏠 🐉 🚤; поплавки ⚪ 🔴 💚 ✨ 🔔; лески 🪢 💚 🪡. Новые emoji — перегенерирован `tools/emoji-font.py` (субсет 53→65 глифов); для ⛵/⚪/🏞️ FE0F (текстовая презентация по умолчанию — как 🗺️/⚠️ в файле).
|
||
- Закрытие по тапу мимо диалога: `backdropClose(el,fn)` (секция «КНОПКИ», после Y2-хендлеров) — `pointerdown`, где `e.target ===` сам оверлей (тап на подложку, не .panel), закрывает диалог тем же действием, что и кнопка: `#shop`→`closeShop`, `#resetDlg`→«Отмена» (`btnResetNo.click()`), `#pause`→`closePause`, `#gamemenu`→«Продолжить» (`btnGmResume.click()`), `#aqPick`→`closeAqPick` (Этап 52), `#aqShop`→`closeAqShop` (Этап 62). `#menu` — экран старта, не диалог. Новый оверлей-диалог без `backdropClose` не добавлять. **Карточка улова `#card` — ОСОЗНАННО ВНЕ `backdropClose`** (ревизия): случайный тап мимо НЕ закрывает её и не дёргает интерстишел; закрывается только явными кнопками (`#btnCardOk`/`#btnKeep`, каждая со своим хендлером и гейтом рекламы). Тест §X(5) пинит именно это (тап мимо → карточка открыта, `state='CARD'`; ждёт 400 мс — pop-анимация `#card{animation:pop .3s}` сужает hit-area подложки).
|
||
- Встроенный 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` (38 файлов — болотных 6, посл. «Которыбка» (meowfish), этапы 55/56, 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-шрифта перегенерирован (рыбных глифов нет).
|
||
- **Favicon (Этап 54)**: логотип вшит в `index.html` как `data:`-URI PNG — внешняя иконка в архив Яндекс Игр не доехала бы (публикуется только `index.html`), а data:-URI работает и под `file://`, и в iframe площадки. Генератор `tools/favicon.mjs` (чистый Node, без зависимостей, идемпотентно, маркеры `<!-- FAVICON:BEGIN/END -->` сразу после `<title>`): декодирует `logo.png` (512×512 RGB, собственный PNG-декодер на zlib), area-average ресемплит в 64×64 (`rel="icon"`) и 180×180 (`rel="apple-touch-icon"`), кодирует PNG с адаптивным фильтром строк (эвристика libpng), кладёт base64 в `<link>`; самопроверка + `check.mjs` внутри. Обновить иконку = заменить `logo.png` + `node tools/favicon.mjs`. Маркеры снимаются минификатором (их нет в списке исключений EMOJI-FONT/FISH-SVG) — в релизе остаются только `<link>` с data:-URI; проверки `pack.mjs`/`minify.mjs` не затронуты.
|
||
- **Аквариум (Этап 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()`) **Этап 52: открывает DOM-диалог выбора слота `#aqPick`**, а не кладёт рыбу сразу (см. отдельный пункт «Диалог слота аквариума (Этап 52)»). `syncKeepBtn()` теперь держит кнопку **всегда активной** (подпись/`title` = `aq_keep`), логика «Нет места»/`disabled` убрана: из диалога можно заменить занятый слот или докупить новый. Сцена: `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)`, `AQ_FISH_SCALE=2.1`/`AQ_FISH_SIZE_MIN=10`/`AQ_FISH_SIZE_REL=0.30`, масштаб экрана `k=clamp(min(W,H)/AQ_TANK_REF, 1, AQ_MAX_K)` — ПЛАВНЫЙ (`AQ_TANK_REF=360` → k=1, `AQ_MAX_K=2.4`; десктоп 1280×720 → k=2), верхний кап НЕ умножается на `k`; правка после Этапа 46 + ревизия размера (`w/wMax`+`rarity` не влияют), раньше был бинарный `AQ_DESKTOP_SCALE=2` при `min(W,H)>=600`; сетка стартовых позиций, разворот у стенок (per-fish `rise=size*0.65` держит крупную рыбу ниже бара/выше грунта), пузыри). **Ревизия: жильцы банки — те же 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` (Этап 62: `#btnAqBack`, `#aqCount` «N/M», пилюля `#btnAqShop` «🛒 Улучшения» → диалог `#aqShop`; прежние `#btnAqSlot`/`#btnAqFloor`/`#btnAqDeco` убраны), поповер `#aqPop` (`#aqPopName`/`#aqPopSub` — вес · редкость, БЕЗ цены; Э64: кнопок в поповере нет — чистое инфо), тап по рыбе `aqTap(x,y)` (ближайшая в радиусе → `aqSel` → `showAqPop()`). «Выпустить» — Э64: **только из диалога «Слоты»** (`#aqShop`, кнопка на строке жильца + подтверждение, см. пункт Этапа 62) → `aqReleaseAt(i)` — точечное удаление экземпляра ИНДЕКСАМИ (без `initAquarium`, позиции остальных сохраняются; `aqSel` при необходимости сдвигается/снимается) **БЕЗ монет** (освобождение слота; продажа из банки была в плане §7, но по решению владельца заменена на выпуск — экономика не затронута; прежняя кнопка `#btnAqRelease` в поповере убрана). Миграция `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` не сдвинут).
|
||
**Этап 59 — декор сцены (чистая косметика, coin-sink):** таблицы `AQ_FLOORS` (`{id,name,nameEn,cost,c,c2}` — песок **0** / галька **2500** / тёмный грунт **8000**; `c` — цвет толщи, `c2` — кромки, ночное затемнение то же `nm*0.75`, что у старого песка) и `AQ_DECOS` (`{id,name,nameEn,cost}` — сундук **5000** / коряга **9000** / замок **20000**) в секции ДАННЫЕ рядом с `AQ_*`. Сейв: `save.aqDecor={floor:0,deco:-1}` (экипировка; `deco:-1` — без орнамента), `save.aqFloorOwned:[0]` / `save.aqDecoOwned:[]` (купленные — массивы индексов); миграция в `loadSave()` рядом с `aqFish`/`aqSlots` — дефолты, клампы `floor` 0..2 / `deco` −1..2 (невалид → дефолт), фильтр мусора в купленных, экипировка обязана быть куплена. Отрисовка: грунт в `drawAquarium()` берёт `AQ_FLOORS[save.aqDecor.floor]`; орнамент — `drawAqDeco(nm)` (после грунта/растений, ДО пузырей/стекла/блика, чтобы стекло было сверху; рыбы-жильцы — DOM-слой и так выше): простые фигуры (сундук у правого края: корпус+крышка+золотые полосы; коряга у левого: ствол+отростки; замок по центру у грунта мельче — `s=0.72*k`), масштаб — тот же плавный `k=clamp(min(W,H)/AQ_TANK_REF,1,AQ_MAX_K)`, ночь — `hexLerp(t,'#0b1220',nm*0.55)`; всё в save/restore. UI — **Этап 62: вместо пилюль в баре диалог `#aqShop`** (см. строку «Диалог улучшений аквариума (Этап 62)»); тосты покупки/выбора — `aq_floor_bought`/`aq_deco_bought`/`aq_floor`/`aq_deco`, отказ — `sfx('no')`+`not_enough`. `setLang()` пере-рендерит открытый диалог. Экономику улова/слотов не трогает. 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`, `aq_pick_title`, `aq_empty_slot`, `aq_replace_q`, `aq_replace`, `aq_replace_short` (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 дохода закрыта).
|
||
- **Диалог слота аквариума (Этап 52)**: кнопка `#btnKeep` на карточке улова не кладёт рыбу сразу, а открывает DOM-оверлей `#aqPick` поверх `#card` (тот же `.overlay`/z-index 20, в DOM позже — лёг сверху; карточку `uiOpen` не ставит и не морозит мир). Список `save.aqSlots` строк `.aqpickrow` (занятые — `<use href="#fish-<id>">` + имя/вариант/вес, вариант через `aqApplyVariant()` на строку, CSS-фильтры `#aqPickList .aqpickrow.var* .je svg`; пустые — `+`/`aq_empty_slot`). Логика: клик по пустой строке — `aqPickPlace(i)` (поселить копию `cardSpec` сразу); клик по занятой — `aqPickPending=i`, inline-подтверждение `#aqPickConfirm` (текст `aq_replace_q` с именем, кнопки «Отмена»/«Заменить»), `aqPickConfirmReplace()` меняет экземпляр в слоте (вытесненная рыба выпускается **бесплатно**, как выпуск через `aqReleaseAt` в диалоге «Слоты», экономика не затронута); докупка `#btnAqPickSlot` → `aqPickBuySlot()` — общая `aqBuySlot()` (Этап 62, она же в `#aqShop`; `AQ_SLOT_COSTS`, `disabled` при максе/во время подтверждения). Финиш `aqPickFinish()`: `persist()`/`checkAchievements()`/тост `aq_kept`/`cardSpec=null`/закрытие `#aqPick` И `#card`/`state='IDLE'`/`tryInterstitial()` (как прежний `#btnKeep`). Глобалы `aqPickOpen`/`aqPickPending`, функции `openAqPick`/`closeAqPick`/`renderAqPick`/`aqPickPortrait`/`aqPickPlace`/`aqPickConfirmReplace`/`aqPickBuySlot`/`aqPickFinish`; DOM: `#aqPick`, `#aqPickTitle`, `#aqPickList`, `.aqpickrow(.empty/.replace)`, `#aqPickConfirm`, `#aqPickConfirmText`, `#btnAqPickConfirmNo`, `#btnAqPickConfirmYes`, `#btnAqPickSlot`, `#btnAqPickCancel`. `backdropClose($('aqPick'),closeAqPick)` (тап мимо = отмена, возврат к карточке); `setLang()` пере-рендерит открытый диалог; сброс прогресса закрывает `#aqPick`. Тесты §AQ(4)/(5)/(5b) Этапа 52. `aq_no_room` оставлен в `T`, но не используется.
|
||
- **Диалог улучшений аквариума (Этап 62)**: DOM-оверлей `#aqShop` поверх сцены банки — вместо прежних пилюль `#btnAqFloor`/`#btnAqDeco`/`#btnAqSlot` в баре. Вход — пилюля `#btnAqShop` «🛒 Улучшения» в `#aqBar` (в баре остались «← Назад», счётчик `#aqCount`, пилюля входа — media-сжатие ≤520px перенесено на `#btnAqShop`); `openAqShop(tab?)`/`closeAqShop()`/`setAqTab()`/`renderAqShop()`, глобалы `aqShopOpen`/`aqShopTab` (паттерн `aqPickOpen`). **z-index 27** — выше `#aqBar`/`#aqPop` (26), иначе бар лёг бы поверх диалога (общий `.overlay` = 20, переопределён). Две вкладки (`.tabs`, кнопки `#tabAqSlots`/`#tabAqUpg`, пере-подпись из `T`): **«🫙 Слоты»** — все `AQ_MAX_SLOTS` строк `.aqpickrow` (стили `#aqPick`, CSS-фильтры вариантов расширены на `#aqShopBody`): жилец — `<use href="#fish-<id>">`+вариант (`AQ_VARIANT_RECOLOR` inline `--f1/--f3`)+имя/вес/редкость; пустой — `+`; покупаемый (`i===save.aqSlots`) — `data-act="buyaslot"` с ценой `AQ_SLOT_COSTS`; дальние — 🔒 (гейтинг как `itemRow`); **Э64: жилец — кнопка `Выпустить` (`data-act="askrelease"`)**. **«🪸 Апгрейды»** — секции `sec_aq_floor`/`sec_aq_deco`, строки `aqDecorRow()` по образцу `skinRow` (иконка — поле `icon` в `AQ_FLOORS`/`AQ_DECOS`, паттерн Э60; «Купить N 🪙 / Выбрать / Выбрано ✓»), первой «🚫 Без декора» (`selnone` → `aqEquipDeco(-1)` — снимает орнамент, заменяет прежний цикл к `-1`). Клик — делегирование `#aqShopBody` (`data-act`: `buyaslot`/`buyfloor`/`selfloor`/`buydeco`/`seldeco`/`selnone`/`askrelease`), покупки — общие `aqBuySlot()`/`aqEquipFloor(i)`/`aqEquipDeco(i)` (её же зовёт `aqPickBuySlot`); после `buyaslot` — ещё `renderAqBar()` (счётчик). **Э64 — выпуск только отсюда:** `askrelease(i)` ставит `aqShopRelPending=i` (строка подсвечивается `.replace`), под списком — inline-подтверждение `#aqShopConfirm` (`#aqShopConfirmText` = `aq_release_q` с именем; `#btnAqShopConfirmNo`/`#btnAqShopConfirmYes`, паттерн `#aqPickConfirm`); «Выпустить» → `aqReleaseAt(i)` (без монет), «Отмена» снимает pending; pending сбрасывается в `openAqShop`/`closeAqShop`/`setAqTab` и при сбросе прогресса; кнопка `#btnAqRelease` из поповера сцены убрана. `aqCycleFloor`/`aqCycleDeco`/`aqDecorName`/`aq_floor_short`/`aq_deco_short` — УДАЛЕНЫ. `closeAquarium()` закрывает диалог; `setLang()` пере-рендерит открытый; сброс прогресса — тоже; `backdropClose`. Новое в `T`: `aq_shop_btn`, `tab_aq_slots`, `tab_aq_upg`, `aq_shop_slots`, `aq_slot`, `aq_slot_locked`, `sec_aq_floor`, `sec_aq_deco` + Э64 `aq_release_q` (RU/EN). Новые emoji (🫙🪸🏖️🪨🟫💰🪵🏰🚫) — субсет перегенерирован (65→73 глифа; 🏖️ с FE0F). Тесты §Э62 (открытие/вкладки/покупки/«без декора»/backdrop/слот через диалог/бар из 3 элементов).
|
||
|
||
## Запуск и публикация
|
||
|
||
- Dev-сервер: `python3 -m http.server 8000`; игра так же работает с `file://`.
|
||
- Процесс разработки, публикация в Gitea, гигиена секретов: `docs/DEVLOG.md`.
|
||
|