73 KiB
AGENTS.md
Игра — один файл index.html (HTML+CSS+JS, ~2700 строк): без сборки, зависимостей и бэкенда. Весь код в одном <script>, секции помечены комментариями. UI, комментарии и доки — на русском; при правках сохраняйте этот стиль.
Проверка (lint/typecheck отсутствуют)
- Синтаксис:
node tools/check.mjs— вынимает JS изindex.htmlи прогоняетnode --check(при ошибке показывает номер строки index.html). - Главный рубеж — плейтест (запускать после правок
index.htmlилиtest.js):
cd tests
npm install # puppeteer-core
npm test # = node test.js
Ждать все PASS и exit 0.
- Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI, только по просьбе пользователя):
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; кадры 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,aqReleaseSel,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(aqCycleFloor/aqCycleDeco/aqDecorName/btnAqFloor/btnAqDeco/btnAqSlot— УДАЛЕНЫ Этапом 62;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?}— цвет дуги/жала,bait— розовый червь на цевье (Червь),bead— красная бусина над крючком (Воблер),fx:'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(); крючок виден только в WAIT/APPROACH/BITE — в REEL он «в рыбе»). На статы/баланс/сейв/ачивки не влияет — миграция не нужна. Тесты §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): идут в реальном времени только в игре, под неигровыми экранами заморожены вместе с днём/ночью (gapWEATHER_GAP_MIN..MAX120–240 с, длительностьWEATHER_DURATION120 с, под форсом не гоняются),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,…)с fallbackBOAT_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), след поплавка в ветке CASTdrawLineAndBobber()(цвет из записи, альфа — прежняя прогрессияi/len*0.35), подводный сегмент bobber→рыба в ветке REELdrawLineAndBobber()(цвет из записи,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/§2446SPECIES[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 разных цветов); ночная аура — ПЛАВНЫЙ rampglowIntensity()(ревизия: 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), радиус ambientsize*1.5, bite/hookedbs/fs*1.5— контур рыбы чёткий (силуэт светящегося вида непрозрачный, аура позади), общий хелперfishGlow(sp,x,y,r)для подплывающей/зацепившейся/вываживаемой рыбы, фоновые — вdrawAmbientFish()по цветуf.glowизpalette; карточка улова — классglow-card+ CSS-переменная--glow(только normal-вариант — не спорить с фильтрами вариантов; CSS-правило#card.glow-card .bigemoji), дневник — inlinefilter: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по параболе на соседнюю кувшинку,jh16–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-miss1.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_coinsgoal×scale (reward = round(goal·0.25));casts×(1+0.15·scale);catch_weightпорог отlocalMedianW()(медиана середин весов рыб ТЕКУЩЕЙ локации ×scale^0.35, а не фикс 2/3/5 кг);catch_rarityr = clamp(1+floor(1.5·log10(scale+1)),1,4);catch_speciesвид — изSPECIES_BY_LOCATION[save.location]с rarity≤2 (не только речные 0..2),questTextRU/EN терпим к неречным id (fallbacktrName).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()).
- Этап 53 (масштабирование квестов):
- Реклама (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-stackhtml,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-fishrise=size*0.65держит крупную рыбу ниже бара/выше грунта), пузыри). Ревизия: жильцы банки — те же SVG-портреты, что на карточке улова (DOM-слой#aqFishLayerповерх canvas, спрайт#fish-<id>+ вариант.var<id>/--f1/--f31: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— вес · редкость, БЕЗ цены), тап по рыбе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не сдвинут). Этап 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— дефолты, клампыfloor0..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,--checkOK). Подводные камни: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()меняет экземпляр в слоте (вытесненная рыба выпускается бесплатно, какaqReleaseSel, экономика не затронута); докупка#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_RECOLORinline--f1/--f3)+имя/вес/редкость; пустой —+; покупаемый (i===save.aqSlots) —data-act="buyaslot"с ценойAQ_SLOT_COSTS; дальние — 🔒 (гейтинг какitemRow). «🪸 Апгрейды» — секции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), покупки — общиеaqBuySlot()/aqEquipFloor(i)/aqEquipDeco(i)(её же зовётaqPickBuySlot); послеbuyaslot— ещёrenderAqBar()(счётчик).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(RU/EN). Новые emoji (🫙🪸🏖️🪨🟫💰🪵🏰🚫) — субсет перегенерирован (65→73 глифа; 🏖️ с FE0F). Тесты §Э62 (открытие/вкладки/покупки/«без декора»/backdrop/слот через диалог/бар из 3 элементов).
Запуск и публикация
- Dev-сервер:
python3 -m http.server 8000; игра так же работает сfile://. - Процесс разработки, публикация в Gitea, гигиена секретов:
docs/DEVLOG.md.