15 KiB
План: Аквариум (Этап 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) — порядок корректен.
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→ после reloadArray.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. Порядок работ (фазы)
- Данные + константы + миграция →
node tools/check.mjs. - Кнопка карточки
#btnKeep+ хендлер (модель C). - Сцена:
initAquarium/updateAquarium/drawAquarium, вход из «Меню»,#aqBar, тап/продажа. - Слоты + ачивки + i18n.
- 🐠 + перегенерация emoji-субсета.
- Тесты,
gen-screenshots, доки.
14. Проверка (порядок команд)
node tools/check.mjscd tests && npm test— все PASS, exit 0tools/.venv/bin/python tools/emoji-font.py --checknode tests/gen-screenshots.jsnode tools/pack.mjs && node tests/pack-smoke.js
15. Риски
Canvas = силуэты, не SVG-портреты (Закрыто ревизией: жильцы — DOM-слой<symbol id="fish-*">— DOM-only). Визуал банки —drawFishShape+ вариантовый оттенок + свечение болотных.#aqFishLayerс теми же SVG-портретами, что на карточке улова (вода/стекло/ауры остаются canvas); растеризация SVG не понадобилась.uiOpen/пауза: сцена живёт подuiOpen— focus/visibility-пауза (YG.onPause) должна работать;#aqBar/#btnPauseне должны «залипать» (проверитьsyncDom+ плейтест).- Раздувание сейва: экземпляров ≤
AQ_MAX_SLOTS— не проблема. - Реклама:
#btnKeepи#btnRvна одной карточке; в модели C конфликта нет, но после «В аквариум» карточка закрывается (интерстишел как обычно). - Баланс не затрагивается —
tools/balance.mjsне трогаем, пины доходности живут. - Emoji: без перегенерации субсета 🐠 рисуется системным шрифтом и
--checkпадает — не забыть шаг 5.