31 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 [каталог-сборки] [папка-вывода] [язык] — 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()(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 — эксклюзивная ихтиофауна): таблица
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/§2446SPECIES[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-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) не трогает) (идут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()).
- 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()без аргумента — помнит последнюю вкладку. - Встроенный 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).
Запуск и публикация
- Dev-сервер:
python3 -m http.server 8000; игра так же работает сfile://. - Процесс разработки, публикация в Gitea, гигиена секретов:
docs/DEVLOG.md.