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