183 lines
15 KiB
Markdown
183 lines
15 KiB
Markdown
# План: Аквариум (Этап 46)
|
||
|
||
Статус: **реализован** (Этап 46; см. запись в `docs/DEVLOG.md`).
|
||
**Отступление от плана:** §7 «продажа из банки» (`#btnAqSell`) **заменена на «выпустить»** (`#btnAqRelease` → `aqReleaseSel()`) — удаление экземпляра БЕЗ монет, слот просто освобождается. Решение владельца: продажа из банки давала дыру ×2 дохода (рыба и продана на улове, и снова продана из аквариума) — экономика не затронута. Остальные пункты плана выполнены; вместо `aqSellSel`/`aq_sell`/`aq_sold` в игре `aqReleaseSel`/`aq_release`/`aq_released`.
|
||
Ориентир по коду: `index.html` ~4365 строк; ссылки на строки даны на момент составления плана
|
||
(перед реализацией сверять — номера могли сдвинуться).
|
||
|
||
## 1. Задача
|
||
|
||
Добавить в игру **Аквариум** — специальный экран, куда можно «высаживать» пойманных рыб,
|
||
чтобы они плавали. Не локация для ловли, а **декоративная сцена коллекции**.
|
||
|
||
## 2. Зафиксированные решения (ответы пользователя)
|
||
|
||
| Развилка | Решение |
|
||
|---|---|
|
||
| Роль аквариума | **Косметика + достижения** (пассивного дохода нет — не трогаем экономику). |
|
||
| Модель заселения | **Инвентарь экземпляров**: храним конкретную рыбу `{id, v:вариант, w:вес}`, а не только вид. |
|
||
| Монеты при «оставить» | **Модель C — бесплатная копия**: монеты платятся на улове как сейчас (`catchFish` не меняется), кнопка «В аквариум» добавляет **копию** экземпляра. Экономика/квесты/ачивки/реклама/пины тестов не затрагиваются. |
|
||
| Где открывается | **Сцена на главном canvas** (флаг `aqOpen`, `draw()` рисует банку вместо озера); вход — из хаба «Меню» `#gamemenu`. |
|
||
| Вместимость | **Слоты + расширения за монеты**: старт `AQ_BASE_SLOTS=6`, максимум `AQ_MAX_SLOTS=12`, лестница цен. |
|
||
| Значок | **🐠** — требует перегенерации встроенного emoji-субсета (`tools/emoji-font.py`). |
|
||
|
||
### Почему НЕ в `LOCATIONS`
|
||
|
||
`LOCATIONS` (`index.html:1384`) жёстко связаны с `SPECIES_BY_LOCATION` (`index.html:1454`),
|
||
`BOSS_BY_LOCATION` (`index.html:1461`), `save.location` (`index.html:1646`) и пинами
|
||
доходности `tools/balance.mjs`. Запись аквариума туда сломает роллы, боссов, баланс и тесты.
|
||
Аквариум — **отдельный режим**, а не вода.
|
||
|
||
## 3. Модель C — как обходим экономику
|
||
|
||
Сейчас `catchFish()` платит **сразу при подсечке**, до карточки:
|
||
`save.coins+=val; save.earned+=val` (`index.html:2393`), далее `questHit('earn_coins',…)`
|
||
(`index.html:2410`). На это завязаны точные пины тестов, читающие `save.coins` при открытой
|
||
карточке: `tests/test.js:411, 503, 601, 1465, 1504, 1548` (`coins === N + val`), а также
|
||
rewarded `×2` (`rvVal`, `index.html:4167`), ачивки `earned_1000/10000`, квест `earn_coins`.
|
||
|
||
В модели C **всё это остаётся как есть** — `catchFish` не трогаем. Кнопка «В аквариум» лишь
|
||
**копирует** экземпляр в `save.aqFish`. Плюс: максимум пользы при минимуме риска; вариант и вес
|
||
трофея сохраняются. Минус (осознанно): концептуальные «дубликаты» (рыба и продана, и в банке).
|
||
|
||
## 4. Данные и константы
|
||
|
||
| Что | Где | Правка |
|
||
|---|---|---|
|
||
| Поля сейва | `save` (`index.html:1572`), `freshSave()` (`index.html:1579`) | `aqFish:[]`, `aqSlots:AQ_BASE_SLOTS`. |
|
||
| Константы | секция «ДАННЫЕ», после скинов/локаций (~`index.html:1400`) | `AQ_BASE_SLOTS=6`, `AQ_MAX_SLOTS=12`, `AQ_SLOT_COSTS=[1500,4000,9000,20000,45000,100000]` (кноб; в масштабе цен локаций 65k–196k). |
|
||
| Доступ к данным экземпляра | — | вид: `SPECIES.find(s=>s.id===f.id)`; вариант: `VARIANTS.find(v=>v.id===f.v)`. |
|
||
|
||
## 5. Миграция `loadSave()` (после блока локаций, `index.html:1647`)
|
||
|
||
Паттерн — как у скинов/локаций (`index.html:1633-1641`). `SPECIES` (`1305`) и `VARIANTS` (`1470`)
|
||
объявлены выше `loadSave` (`1586`) — порядок корректен.
|
||
|
||
```js
|
||
if(!Array.isArray(save.aqFish)) save.aqFish=[];
|
||
save.aqFish=save.aqFish
|
||
.filter(f=>f&&SPECIES.some(s=>s.id===f.id)&&typeof f.w==='number'&&f.w>0)
|
||
.map(f=>({id:f.id, v:VARIANTS.some(x=>x.id===f.v)?f.v:'normal', w:f.w}));
|
||
if(typeof save.aqSlots!=='number'||save.aqSlots<AQ_BASE_SLOTS) save.aqSlots=AQ_BASE_SLOTS;
|
||
if(save.aqSlots>AQ_MAX_SLOTS) save.aqSlots=AQ_MAX_SLOTS;
|
||
if(save.aqFish.length>save.aqSlots) save.aqFish.length=save.aqSlots;
|
||
```
|
||
|
||
Старые сейвы без полей читаются (дефолты), битые значения чинятся.
|
||
|
||
## 6. Карточка улова (модель C)
|
||
|
||
- HTML: в `#card` рядом с `#btnCardOk` (`index.html:1294-1296`) — `<button id="btnKeep">🐠 В аквариум</button>`;
|
||
CSS-класс по образцу `.rvbtn` (`index.html:207-213`).
|
||
- Рантайм-хендл: `let cardSpec=null;` — в `showCard()` (`index.html:4143`) сохранять `{sp,w,variant}`;
|
||
кнопку `disabled` + подпись/`title` «Нет места», если `save.aqFish.length>=save.aqSlots`.
|
||
- Хендлер рядом с `#btnCardOk` (`index.html:4306`): push `{id:cardSpec.sp.id, v:cardSpec.variant.id, w:cardSpec.w}`;
|
||
`persist()`; `checkAchievements()`; тост `aq_kept`; закрыть `#card`; `state='IDLE'`; затем `tryInterstitial()`
|
||
(единообразно с `#btnCardOk`).
|
||
- `catchFish()` (`index.html:2385`) **не меняется**.
|
||
- Строки: `renderStaticUI()` (`index.html:3766`), `refreshCardNow()` (`index.html:3798`) — подпись из `T`.
|
||
|
||
## 7. Сцена аквариума
|
||
|
||
Новая секция `АКВАРИУМ` после «ОТРИСОВКА» (перед `Y1`).
|
||
|
||
- Рантайм: `aqOpen` (активна ли сцена), `aqFishRt[]` (`x,y,dir,ph,tail` — НЕ персистится), `aqSel`, `aqFromMenu`.
|
||
- `initAquarium()` — раскладка по `W/H`; начальные позиции/скорости экземпляров.
|
||
- `updateAquarium(dt)` — плавание, разворот у стенок, вертикальный дрейф, анимация хвоста.
|
||
- `drawAquarium()`:
|
||
- вода/стекло через `hexLerp` (`index.html:2643`) с привязкой к `nightMix()` (`index.html:2677`) — общий цикл суток;
|
||
- грунт, растения, пузыри (простые `arc`/`bezier`, без новых ассетов);
|
||
- рыбы: `drawFishShape(x,y,size,dir,sp.hue,alpha,wob)` (`index.html:2933`); `size` — от `w`/`sp.wMax` и `sp.rarity`;
|
||
болотные — `drawGlowAura()` (`index.html:2918`); подкраска варианта — сдвиг hue + блик/искра; рамка при `aqSel===i`.
|
||
- Точки жизненного цикла:
|
||
- `frame()` (`index.html:3864`): `uiOpen` уже глушит `updateGame`; добавить `if(aqOpen) updateAquarium(dt);` рядом с `updateDecor(dt)` (`index.html:3872`).
|
||
- `draw()` (`index.html:3402`): сразу после `ctx.save()`/shake — `if(aqOpen){ drawAquarium(); ctx.restore(); return; }`.
|
||
- `onDown()` (`index.html:3886`): ранний `if(aqOpen){ aqTap(x,y); return; }`.
|
||
- `resize()` (`index.html:1964`): `if(aqOpen) initAquarium();`.
|
||
- `syncDom()` (`index.html:2613`): при `aqOpen` не показывать `#btnRecall`/`#btnPause`.
|
||
- Вход/выход: `#btnGmAquarium` в хабе `#gamemenu` (`index.html:1268-1281`, хендлеры `4232-4250`);
|
||
`openAquarium()` (спрятать гамеменю, `aqOpen=true; uiOpen=true; YG.gameplayStop(); initAquarium(); показать `#aqBar`),
|
||
`closeAquarium()` (назад в `#gamemenu`, `uiOpen=true`). Аналог `fromMenuShop` (`index.html:4238`).
|
||
- DOM-бар `#aqBar`: «Назад» `#btnAqBack`, счётчик `#aqCount` «N/M», «+ Слот (N 🪙)» `#btnAqSlot`,
|
||
поповер `#aqPop` (имя/вариант/вес + `#btnAqSell`).
|
||
- Тап по рыбе: `aqTap` ищет ближайшую → `aqSel` → `#aqPop`. **Продажа из банки** (опция): удалить экземпляр и
|
||
начислить по той же формуле, что `index.html:2392` (`w*sp.price*mult*(1+achBonus().coinPct)`), `persist()`, тост.
|
||
|
||
## 8. Слоты
|
||
|
||
- `#btnAqSlot`: цена `AQ_SLOT_COSTS[save.aqSlots-AQ_BASE_SLOTS]`; при нехватке — `sfx('no')` + `toast(t('not_enough'))`
|
||
(паттерн `shopClick`, `index.html:4102`); иначе списать, `save.aqSlots++`, `persist()`, `renderAqBar()`.
|
||
- При `aqSlots>=AQ_MAX_SLOTS` кнопка `disabled` (или скрыта).
|
||
|
||
## 9. Ачивки и i18n
|
||
|
||
- `ACHIEVEMENTS` (`index.html:1878`): добавить в конец `aq_first` («Первый жилец») и `aq_full` («Аквариум полон»).
|
||
**`bonus:{}`** (как пер-босс-ачивки, `index.html:1913`), чтобы пин `achBonus().coinPct=0.18` (§10c(8)) не сдвинулся.
|
||
`checkAchievements()` уже зовётся в `catchFish`; дополнительно вызвать в хендлере `#btnKeep` и после покупки слота.
|
||
- `T.ru`/`T.en` (`index.html:3544`): `gm_aquarium`, `aq_title`, `aq_keep`, `aq_back`, `aq_slots`, `aq_buy_slot`,
|
||
`aq_max`, `aq_sell`, `aq_no_room`, `aq_kept`, `aq_sold`, имена/описания ачивок.
|
||
- `setLang()` (`index.html:3804`): при `aqOpen` обновлять открытую сцену (`renderAqBar()`).
|
||
- Заметка: `bonusText('')` у пер-босс-ачивок уже даёт `desc · ` (см. `index.html:4042`) — новый случай не хуже;
|
||
при желании отдельно поправить `bonusText` (не обязательно).
|
||
|
||
## 10. Emoji-шрифт
|
||
|
||
- 🐠 добавляется в игру → **обязательна** перегенерация:
|
||
`tools/.venv/bin/python tools/emoji-font.py`, затем `tools/.venv/bin/python tools/emoji-font.py --check`.
|
||
- Системный `python3` без `fontTools` — запускать **venv-интерпретатором** (проверено).
|
||
- Блок между `<!-- EMOJI-FONT:BEGIN/END -->` (`index.html:9,20`) правится только скриптом.
|
||
|
||
## 11. Тесты (`tests/test.js`)
|
||
|
||
Добавить проверки и внести новые глобалы в документированный список:
|
||
- **миграция**: старый сейв без `aqFish` → после reload `Array.isArray(save.aqFish)` и `aqSlots===6`
|
||
(паттерн §9b/M6(6), `tests/test.js:419-427`);
|
||
- **keep**: форс-улов → `CARD` → клик `#btnKeep` → `save.aqFish.length===1`, верные `id/v/w`, монеты выросли (модель C);
|
||
- **лимит**: `save.aqFish.length=save.aqSlots` → кнопка `disabled`;
|
||
- **слот**: монеты списаны, `aqSlots+1`;
|
||
- **сцена**: `#btnGmAquarium` → `aqOpen===true`, `#aqBar` виден; `#btnAqBack` → `#gamemenu`;
|
||
- **продажа из банки**: `aqSellSel()` → экземпляр удалён, монеты +val.
|
||
- Экспонировать глобалы: `aqOpen`, `openAquarium`, `closeAquarium`, `AQ_BASE_SLOTS`, `AQ_MAX_SLOTS`,
|
||
`AQ_SLOT_COSTS`, `save.aqFish`/`save.aqSlots`.
|
||
- Обновить счёт проверок в README/KODA/AGENTS (сейчас `204/204`).
|
||
|
||
## 12. Скриншоты и доки
|
||
|
||
- `tests/gen-screenshots.js`: `FRAMES` (`tests/gen-screenshots.js:63`) + кадр `aquarium`; в кадре засеять
|
||
`save.aqFish` (напр. carass normal, pike silver, gold rainbow, glowfin) и `openAquarium()`; 2 rAF и снимок.
|
||
Кадров 14 → 15 × 3 вьюпорта.
|
||
- `AGENTS.md` — новый пункт «Аквариум» + глобалы/подводные камни.
|
||
- `KODA.md` — контент/структура/«где менять»/счёт тестов.
|
||
- `README.md` — фича, скриншоты, счётчики.
|
||
- `docs/DEVLOG.md` — запись «Этап 46».
|
||
|
||
## 13. Порядок работ (фазы)
|
||
|
||
1. Данные + константы + миграция → `node tools/check.mjs`.
|
||
2. Кнопка карточки `#btnKeep` + хендлер (модель C).
|
||
3. Сцена: `initAquarium`/`updateAquarium`/`drawAquarium`, вход из «Меню», `#aqBar`, тап/продажа.
|
||
4. Слоты + ачивки + i18n.
|
||
5. 🐠 + перегенерация emoji-субсета.
|
||
6. Тесты, `gen-screenshots`, доки.
|
||
|
||
## 14. Проверка (порядок команд)
|
||
|
||
1. `node tools/check.mjs`
|
||
2. `cd tests && npm test` — все PASS, exit 0
|
||
3. `tools/.venv/bin/python tools/emoji-font.py --check`
|
||
4. `node tests/gen-screenshots.js`
|
||
5. `node tools/pack.mjs && node tests/pack-smoke.js`
|
||
|
||
## 15. Риски
|
||
|
||
1. ~~**Canvas = силуэты**, не SVG-портреты (`<symbol id="fish-*">` — DOM-only). Визуал банки — `drawFishShape` +
|
||
вариантовый оттенок + свечение болотных.~~ **Закрыто ревизией:** жильцы — DOM-слой `#aqFishLayer` с теми же
|
||
SVG-портретами, что на карточке улова (вода/стекло/ауры остаются canvas); растеризация SVG не понадобилась.
|
||
2. **`uiOpen`/пауза**: сцена живёт под `uiOpen` — focus/visibility-пауза (`YG.onPause`) должна работать;
|
||
`#aqBar`/`#btnPause` не должны «залипать» (проверить `syncDom` + плейтест).
|
||
3. **Раздувание сейва**: экземпляров ≤ `AQ_MAX_SLOTS` — не проблема.
|
||
4. **Реклама**: `#btnKeep` и `#btnRv` на одной карточке; в модели C конфликта нет, но после «В аквариум»
|
||
карточка закрывается (интерстишел как обычно).
|
||
5. **Баланс не затрагивается** — `tools/balance.mjs` не трогаем, пины доходности живут.
|
||
6. **Emoji**: без перегенерации субсета 🐠 рисуется системным шрифтом и `--check` падает — не забыть шаг 5.
|