20 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), 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,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): тест-форс появления
forceBoss, фазы бояstateBoss/bossPhase, босс-ветки вupdateReel()(обрыв фазы) иSNAPвupdateGame()(авто-переход), награда ×5 вcatchFish(). - Варианты рыб (M6): таблица
VARIANTS(порядок ролла: gold → silver → rainbow, иначе normal),variantChance()— единственная точка вероятностей (дождь M8 уже здесь — ×2,5, см. строку «Погода (M8)»; gold — живаяSHINY_CHANCE), тест-форсforceVariant, босс всегда normal. Рыбу нельзя улучшать: «Улучшить вариант» (M6) убрано в этапе 21 — карточка улова только бейдж/оформление по варианту. - Погода (M8): таблица
WEATHER(rain —variantMult×2,5; fog —biteMult×1,5), таймеры в реальном времени —updateWeather()вframe()(gapWEATHER_GAP_MIN..MAX120–240 с, длительностьWEATHER_DURATION120 с, под форсом не гоняются),weatherActive()— единственная точка чтения (форс приоритетнее), эффекты только вvariantChance()(rain) иbiteChance()(fog), тест-форсforceWeather('rain'/'fog'/null, паттернforceBoss); погода не персистится в сейв. Заметка: туман может дать 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): таблица
LOCATIONS(Река 0 — бесплатно / «Озёра» 100000 🪙;water— 6 hex [dayTop,dayMid,dayBot,nightTop,nightMid,nightBot] для градиентаdrawWater(), nightMix-логика та же), сейвsave.locations[]/save.location+ миграция вloadSave()(гарантия бесплатной Реки, паттерн скинов), вкладка «Локации» 🗺️ (#tabLocations, строкаlocationRow(loc,i,owned,selected), ветки buylocation/selectlocation вshopClick(): покупка — списание, push, АВТО-ПЕРЕХОДsave.location, тост «Добро пожаловать на Озёра!»; выбор — только купленное). Таблицы рыб:RIVER_SPECIES(_NIGHT)— ТОЛЬКО 8 базовых видов (роллы реки не меняются: pin-тесты §9j(b)/M4/M6 завязаны на веса и порядок — новые виды в реку НЕ добавлять!),LAKE_SPECIES(_NIGHT)— все 11 видов (весаLAKE_DAY_WEIGHTS/LAKE_NIGHT_WEIGHTS);SPECIES/SPECIES_NIGHT— полный словарь 11 видов (новые: zander/chuch/crayfish, ночные веса 6/10/2.5).pickSpecies()выбирает таблицу поsave.locationи dayPhase (логика ролла та же); босс (sturge) есть в обеих локациях; ачивка species_8 — теперь «Поймай все виды» (все 11, id не менять — старые сейвы). - Сложность вываживания:
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 (идутACHIEVEMENTS-записями по темам: снасти у экономики, варианты/погода/вес/ночь у рыболовных, скины/локации у магазинных); новые счётчики сейваsave.kgTotal/nightCatch/wxRainV/wxFog(чтение через||0— без миграции) ставятся вcatchFish()доcheckAchievements()(rain-счётчик — только вариант ≠ normal, босс всегда normal); тест-пин §10c(8): суммаachBonus().coinPct(все 8 видов × 4 варианта) = 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.