Files
fishing-game/AGENTS.md
T
andrey 1f0f78decf Ночные *_WEIGHTS «ночь доходнее» + меньше сельди/скумбрии в Море (Этап 40): без подгонки под ×3/×4/×5 — просто факт. LAKE_NIGHT: +навaga 5→10, сиг 13→18, судак 28→32, линь 22→24; −вьюн 8→4, хариус/плотва 4→3, густера/краснопёрка 3→2.5. SEA_DAY: сельдь/скумбрия 34/34→32/32 (доля 82.3→79.5% — дневное Море ограничено инвариантом «< Океан 329.1»), камбала 11→12, тунец 2.6→3, меч 1.2→1.5. SEA_NIGHT: сельдь 24→18, скумбрия 30→20 (доля 78.5→64.2%), камбала 11→15, тунец 2.6→4, меч 1.2→2.2. OCEAN_NIGHT: +кит 2.5→4, манта 3.5→5, акула/кальмар 3→4.5, осьминог 6→8; −мурена 32→26, горбуна 28→22. Цены/веса видов/река не тронуты. tools/balance.mjs: Озёра 194.1/249.0 (×2.98/×1.98 Реки, ночь/день 1.11→1.28), Море 297.3/476.7 (×4.57/×3.79, 1.16→1.60), Океан 329.1/552.7 (×5.06/×4.39, 1.21→1.68), Река 65.1/125.9. Инвариант «новая вода доходнее в ОБЕ фазы»: Море/Озёра ×1.53/×1.91, Океан/Море ×1.11/×1.16 — дневной запас тонкий (Море упирается в Океан), зафиксировано в AGENTS.md. Средний вес: Море день ≈7.0 (было 6.5), ночь ≈9.4; Озёра ночь ≈13.9 — разрыв «Море легче Озёр» сократился, не закрыт (инвариант). Здоровье роллов: худшее Море день swordfish 55.9 попаданий — ок. Тесты: lc2/lc4/lc4b/lc4c/§9j/M4/M6/§10 без изменений — 193/193 PASS, exit 0; check.mjs OK. Доки: AGENTS.md (пины + пометка тонкого запаса), комментарии ДАННЫЕ index.html, DEVLOG (Этап 40).
2026-09-20 10:32:46 +03:00

31 KiB
Raw Blame History

AGENTS.md

Игра — один файл index.html (HTML+CSS+JS, ~2700 строк): без сборки, зависимостей и бэкенда. Весь код в одном <script>, секции помечены комментариями. UI, комментарии и доки — на русском; при правках сохраняйте этот стиль.

Проверка (lint/typecheck отсутствуют)

  1. Синтаксис: node tools/check.mjs — вынимает JS из index.html и прогоняет node --check (при ошибке показывает номер строки index.html).
  2. Главный рубеж — плейтест (запускать после правок index.html или test.js):
 cd tests
 npm install   # puppeteer-core
 npm test      # = node test.js
    Ждать все PASS и exit 0.
  1. Браузерный плейтест (вкладка встроенного браузера 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 [каталог-сборки] [папка-вывода] [язык] — 10 кадров × 3 вьюпорта (1280×720 / 360×640@2x / 800×450@1.6x), 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, 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. Переименование любого из них ломает тесты молча — имена держать, либо обновлять тест в том же коммите.
  • Ввод на 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 — босс в каждой локации): тест-форс появления forceBoss (работает с боссом ТЕКУЩЕЙ локации), таблица BOSS_BY_LOCATION={0:'sturge',1:'crayfish',2:'swordfish',3:'whale'} (секция ДАННЫЕ) — босс = существующий вид текущей локации (Река=Осётр как в 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 (БЕЗ coinPct — пин §10c(8) achBonus().coinPct=0.16 жив; 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() (gap WEATHER_GAP_MIN..MAX 120–240 с, длительность WEATHER_DURATION 120 с, под форсом не гоняются), weatherActive() — единственная точка чтения (форс приоритетнее), эффекты только в variantChance() (rain) и biteChance() (fog), тест-форс forceWeather ('rain'/'fog'/null, паттерн forceBoss); погода не персистится в сейв. Тост погоды — из T (t('wx_'+weather); WEATHER.*.toast — не используется). Заметка: туман может дать p>1 → насыщение 100% клёва (осознанно).
  • Скины (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 — эксклюзивная ихтиофауна): таблица LOCATIONS (Река 0 — бесплатно / «Озёра» 65536 🪙 / «Море» 131072 🪙 = ×2 Озёр, этап 35 / «Океан» 196608 🪙 = ×1.5 Моря, этап 36; water — 6 hex [dayTop,dayMid,dayBot,nightTop,nightMid,nightBot] для градиента drawWater(), nightMix-логика та же), сейв 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; выбор — только купленное). Таблицы рыб: у КАЖДОЙ воды свои *_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). Этап 38: лестница веса wMin/wMax — Озёра ×8, Море ×6 (рыба-меч ×4), Океан ×4 (синий кит ×2 — чтобы остался вываживаемым), Река НЕ меняется: дороже вода = тяжелее рыба = сложнее бой (дольше d0=45+7·w, сильнее ov→aggr). Этап 40: ночные *_WEIGHTS (Озёра/Море/Океан) подтянуты к «ночь доходнее» + доля сельди/скумбрии в Море снижена (день 82→79%, ночь 78→64%) — цифры см. строку «Баланс Озёр». SPECIES/SPECIES_NIGHT — полный словарь 32 видов; ночные веса реки — в NIGHT_WEIGHTS, ключ обязателен каждому id (иначе weight=undefined). Новые виды добавлять только в КОНЕЦ SPECIES — SPECIES[0..7] запинены тестами (§573/§1524/§2446 SPECIES[0], §9j/M4/M6 по id) и квестами (QUEST_TPLS индексы 0..2); вставка в середину молча сломает их. pickSpecies() выбирает таблицу по save.location (0/1/2/3) и dayPhase (логика ролла та же); босс — вид BOSS_BY_LOCATION[save.location] текущей локации (Этап 37: стурдж/рак/рыба-меч/кит; runtime-флаг, новых видов нет — см. строку «Босс (M4)»); ачивка species_8 — «Поймай все виды» (все 32, check = SPECIES.every, id не менять — старые сейвы). Ачивки локаций (этап 22/36): lake_first/sea_first/ocean_first — locations-based (проверка save.locations), не caught-based — пин §10c(8) achBonus не трогает.
  • Баланс Озёр (этап 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); ночь/день: Река ×1.93, Озёра ×1.28, Море ×1.60, Океан ×1.68 (этап 40 поднял у новых вод с ×1.11–1.21, у Реки ×1.93 — полностью не сравнять: у новых вод ночь богаче днём по составу не настолько, как у Реки); инвариант «новая вода доходнее предыдущей в ОБЕ фазы» держится (Море/Озёра ×1.53/×1.91, Океан/Море день ×1.11 — тонкий запас: дневное Море упирается в дневной Океан 329.1, дальше срезать сельдь/скумбрию днём — только поднимая дневной Океан или цены) — проверять при любой правке *_WEIGHTS/price/wMin/wMax; считать руками не нужно — см. строку «Баланс-скрипт». Минимальный вес вида ≥ ~2.2: ниже — тест lc4 (3000 роллов, «каждый вид ≥1») начинает плавать.
  • Баланс-скрипт (tools/balance.mjs): node tools/balance.mjs — считает доходность рыб ЗА ВАС, ручные прикидки Σ(w·V)/Σw больше не нужны. Вывод: по каждой из 8 таблиц ролла (Река/Озёра/Море/Океан × день/вечер-ночь) — виды (вес/доля %/V/вклад) и итог E[🪙 за улов], сводка-матрица с отношениями соседних вод в обеих фазах (Озёра/Река, Море/Озёра, Океан/Море — инвариант «каждая новая вода доходнее предыдущей в ОБЕ фазы»), «здоровье» (⚠ если у самого редкого вида 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 · Море 297.3/476.7 · Океан 329.1/552.7 (день/ночь 🪙; этапы 38–40: лестница весов + урезанные цены + ночные веса). Цикл настройки баланса новой локации: (1) новые виды — только в КОНЕЦ SPECIES + ключ каждому id в NIGHT_WEIGHTS; (2) свои *_IDS + *_DAY_WEIGHTS/*_NIGHT_WEIGHTS без пересечений с другими водами (пин lc2 + проверка скрипта), развилка таблиц в pickSpecies(); (3) крутить доли прикидками без правки игры: node tools/balance.mjs --set zander:30 --set bleak:5 (id — латиницей!) до нужных цифр — двигают СДВИГ долей, ×константа всех весов доход не меняет; (4) перенести финальные веса в index.html и перегнать скрипт БЕЗ --set — цифры обязаны совпасть с what-if; (5) цена/веса самих видов (SPECIES) — тоже видны скрипту сразу, но их правки могут задевать другие локации (общий словарь!) — пересчитать всё; (6) прогнать плейтест (lc2/lc4 и пины §9j/M4/M6 — река не должна меняться).
  • Сложность вываживания: updateReel().
  • Физика боя (спринт 4): fishForce() (сила рыбы: рывок (16+34·st)·(0.5+0.9·ag)·(0.7+0.5·ov), затишье 5+7·ag, уставшая ×0.45) и updateReel() — выносливость st (старт st0, флаг tired при st≤0.12: рывки ×0.45, втягивание быстрее до ×1.6), тормоз катушки (черта drag в RODS: в рывке выше тормоза леска скользит over·0.22), натяжение (10+over·1.1+22·ag, ×0.35 в затишье), втягивание (11+3·reel)·(1+0.6·(1−st)), свободный бег F·0.32, escape-бар растёт только при затишье без удержания (отпускание в рывок безопасно), near-miss 1.45·d0, d0=45+7·w. Пустые клёвы: ролл biteChance() в ветке WAIT (тест-форс forceBite). Тесты (§10) завязаны на эти константы — при ребалансе гонять Monte-Carlo точными формулами (docs/PHYSICS-RESEARCH.md, §4.7) и править тесты в том же коммите.
  • Награды (M2): шаблоны квестов QUEST_TPLS, награды стрика STREAK_REWARDS, достижения ACHIEVEMENTS — секция «M2. Награды» после сейва. Этап 22: ачивок 29, этап 36: 31 (+sea_first/ocean_first — locations-based, пин §10c(8) не трогает), этап 37: 34 (+lake_boss/sea_boss/ocean_boss — пер-босс, БЕЗ coinPct, пин §10c(8) не трогает) (идут ACHIEVEMENTS-записями по темам: снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации у магазинных); новые счётчики сейва save.kgTotal/nightCatch/wxRainV/wxFog (чтение через ||0 — без миграции) ставятся в catchFish() до checkAchievements() (rain-счётчик — только вариант ≠ normal, босс всегда normal); тест-пин §10c(8): сумма achBonus().coinPct (все 8 видов × 8 вариантов) = 0.16.
    • SDK Яндекс Игр (Y1): секция «Y1: SDK Яндекс Игр» перед «ЦИКЛ» — обёртка YG (стаб при file:// без ?sdk=1: скрипт не создаётся, no-op'ы + счётчики counts), динамическая загрузка /sdk.js (http(s)) с retry/warn, LoadingAPI.ready() первым кадром, GameplayAPI.start/stop в btnPlay/openShop/closeShop/btnGmResume (в гменю-флоу stop даёт YG.onPause(), дублей нет — пин §Y8(4)), game_api_pause/resume + visibilitychange → YG.onPause/onResume (AC.suspend, пауза всего игрового времени в frame()).
  • Реклама (Y2): константы AD_FIRST_MS/AD_COOLDOWN_MS (секция Y1), точка interstitial — tryInterstitial() в хендлере btnCardOk (гейт: первый показ ≥2 мин сессии, кулдаун 3 мин; lastAdTs — осознанно «попытка», не показ — при троттлинге платформы wasShown=false кулдаун сгорает), RV-кнопка #btnRv «📺 Реклама: ×2 за этот улов» на карточке улова (showCard сбрасывает rvVal/rvDone), методы YG.showFullscreen/showRewarded (+watchdog 90 с), endAd(); карточка улова БЕЗ gameplayStop() — осознанно (часть цикла улова); реклама не персистится в сейв (только persist() перед показом и после бонуса).
  • i18n (треб. 2.14/2.2): таблица T={ru,en} (единая точка строк UI), t(key)/tt(key,o), LANG (дефолт ru), forceLang (тест-форс), setLang() (обновляет <html lang>/<title> + пере-рендер меню/статичного UI и открытых оверлеев — магазина/карточки), resolveLang(ysdk) — резолюция: forceLang → ysdk.environment.i18n.lang → ru; стаб (file:// без SDK) — всегда ru (navigator.language НЕ используется — ломает headless-тесты). trName/trDesc/trRarity — выбор по nameEn/descEn/RARITY_NAMES_EN в таблицах данных. Меню — renderMenu() (device-aware: тач/мышь, IS_TOUCH). EN-ветка questText() (RU-ветка с pluralN не меняется).
  • Пауза (треб. 6.3/1.3): HUD-кнопка #btnPause (⏸) → openPause() (гарды MENU/uiOpen/adShowing/pauseOpen; YG.onPause() + оверлей #pause); #btnPauseResume → closePause(), #btnPauseMenu → хаб «Меню» #gamemenu (с YG.onResume()). Гард pauseOpen в YG.onResume (после adShowing) — blur/focus/visibilitychange во время открытой паузы не снимают паузу (снимут только closePause()/«Продолжить» гменю). window blur → YG.onPause(), focus → YG.onResume() (треб. 1.3, дополнение к visibilitychange). Видимость #btnPause и #btnRecall — в syncDom() (вызывается каждый кадр в frame() вне YG.paused — без этого #btnPause «залипает» видимым за оверлеями).
  • Хаб «Меню» (Э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() без аргумента — помнит последнюю вкладку.
  • Встроенный emoji-шрифт (Э28): все emoji UI и canvas рисует вшитый шрифт — субсет Noto Color Emoji (COLRv1, OFL) как base64 @font-face font-family:'EmojiEmbedded' в index.html между маркерами <!-- EMOJI-FONT:BEGIN/END --> (внешний .woff2 невозможен: публикуется только один index.html). В font-stack html,body и во всех ctx.font (константа UI_FONT, секция «ОТРИСОВКА») стоит ПЕРВЫМ — единый Google-стиль emoji на всех платформах; headless-скриншоты не зависят от системных шрифтов. Первый кадр ждёт document.fonts.load('16px "EmojiEmbedded"') с timeout-фолбэком 1.5 с. Генератор tools/emoji-font.py (venv: python3 -m venv tools/.venv && tools/.venv/bin/pip install fonttools brotli; исходник tools/fonts-src/NotoColorEmoji-Regular.ttf из google/fonts, всё в gitignore): python3 tools/emoji-font.py — перегенерация по текущим emoji index.html (ОБЯЗАТЕЛЬНО при добавлении новых emoji), --check — падает, если субсет не покрывает index.html (✓ № ● ○ ↩ — в NCE их нет, это ок, рисуются системными шрифтами). Тест §Y7 (tests/test.js) гоняет document.fonts.check('16px "EmojiEmbedded"','🪙'); семейство переименовывать нельзя (тест + @font-face + UI_FONT).

Запуск и публикация

  • Dev-сервер: python3 -m http.server 8000; игра так же работает с file://.
  • Процесс разработки, публикация в Gitea, гигиена секретов: docs/DEVLOG.md.