gen-screenshots.js: 10 кадров (меню, лавка, достижения, дневник, ожидание, дождь, ночь, Озёра, вываживание, карточка) × 3 вьюпорта

This commit is contained in:
2026-09-09 18:14:13 +03:00
parent 9519559387
commit cca113377e
67 changed files with 262 additions and 176 deletions
+22 -18
View File
@@ -5,19 +5,22 @@
## Проверка (lint/typecheck отсутствуют)
1. Синтаксис: `node tools/check.mjs` — вынимает JS из `index.html` и прогоняет `node --check` (при ошибке показывает номер строки index.html).
2. Главный рубеж — плейтест:
```bash
2. Главный рубеж — плейтест (запускать после правок `index.html` или `test.js`):
```bash
cd tests
npm install # puppeteer-core
npm test # = node test.js
```
Ждать все PASS и exit 0. Сейчас проверок 177 (включая §Y5 i18n, §Y6 паузу, §Y7 шрифт и §Y8 монеты/«Меню»).
3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI):
```bash
```
```
Ждать все PASS и exit 0.
```
3. Браузерный плейтест (вкладка встроенного браузера Orca, через Orca CLI, только по просьбе пользователя):
```bash
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-шрифты для скриншотов больше не нужны.
4. Скриншоты: `node tests/gen-screenshots.js [каталог-сборки] [папка-вывода] [язык]` — 15 кадров × 3 вьюпорта (1280×720 / 360×640@2x / 800×[450@1.6x](mailto: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.
## Подводные камни
@@ -30,25 +33,26 @@
## Где крутить
- Баланс (рыба/удилища/приманки): константы `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()`.
- Баланс (рыба/удилища/приманки): константы `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()` (gap `WEATHER_GAP_MIN..MAX` 120–240 с, длительность `WEATHER_DURATION` 120 с, под форсом не гоняются), `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()`.
- Погода (M8): таблица `WEATHER` (rain — `variantMult` ×2,5; 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`); погода не персистится в сейв. Заметка: туман может дать 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-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 (идут `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()).
- Реклама (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()` без аргумента — помнит последнюю вкладку.
- Реклама (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`.
+4 -4
View File
@@ -2,9 +2,9 @@
Браузерная мобильная игра про рыбалку. Один HTML-файл, без зависимостей — просто открой в браузере и играй.
| Меню | Клёв | Вываживание | Улов |
| Меню | Лавка | Вываживание | Улов |
|---|---|---|---|
| ![меню](screenshots-wiki/mobp-menu.png) | ![клев](screenshots-wiki/mobp-bite.png) | ![вываживание](screenshots-wiki/mobp-reel.png) | ![улов](screenshots-wiki/mobp-card.png) |
| ![меню](screenshots-wiki/mobp-menu.png) | ![лавка](screenshots-wiki/mobp-shop.png) | ![вываживание](screenshots-wiki/mobp-reel.png) | ![улов](screenshots-wiki/mobp-card.png) |
## Как играть
@@ -38,7 +38,7 @@
- Дневник рыбака — коллекция, максимальный улов по каждому виду, статистика
- Магазин с вкладками: Лавка 🛒, Дневник 📔, Награды 🎯, Достижения 🏅, Скины 🎨, Локации 🗺️
- Прогресс и коллекция сохраняются в `localStorage` (старые сейвы мигрируются автоматически)
- Диалог «Меню» (пауза ⏸ → «В меню»): «Продолжить» (сессия сохраняется), Магазин/Достижения/Дневник, «Инструкции», звук 🔊 и сброс 🗑️ (через диалог подтверждения; настройка звука при сбросе сохраняется) — <img src="screenshots-wiki/mobp-gamemenu.png" width="200" alt="меню">
- Ночь: свой состав рыб и тёмная вода при `dayTime=0.85` — <img src="screenshots-wiki/mobp-night.png" width="200" alt="ночь">
- Клик по монетам в HUD открывает лавку на вкладке «Лавка» 🛒
- Звуки на WebAudio, вибрация на телефоне
- Локализация RU/EN — язык определяется автоматически через SDK Яндекс Игр (`ysdk.environment.i18n.lang`); меню адаптируется под устройство (тач/мышь)
@@ -83,4 +83,4 @@ node test.js # 162 проверки
Есть и браузерный плейтест «вживую» во встроенном браузере Orca: `node tests/orca-playtest.js` (из каталога репо, требует запущенный Orca app) — полный геймплей, проверка консоли и pageerror; детали в [docs/BROWSER-TESTING.md](docs/BROWSER-TESTING.md).
Скриншоты состояний — `tests/gen-screenshots.js` (headless, 15 кадров × 3 вьюпорта: десктоп 1280×720, мобильный портрет 360×640@2x, мобильный ландшафт 800×450@1.6x, 24-битный PNG без альфа). Без аргументов — русские скриншоты в `screenshots-wiki/`; с аргументом `en` — английские в `screenshots-wiki-en/`. README ссылается на `screenshots-wiki/mobp-*` (мобильный портрет).
Скриншоты состояний — `tests/gen-screenshots.js` (headless, 10 кадров × 3 вьюпорта: десктоп 1280×720, мобильный портрет 360×640@2x, мобильный ландшафт 800×450@1.6x, 24-битный PNG без альфа). Без аргументов — русские скриншоты в `screenshots-wiki/`; с аргументом `en` — английские в `screenshots-wiki-en/`. README ссылается на `screenshots-wiki/mobp-*` (мобильный портрет).
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 148 KiB

+50
View File
@@ -0,0 +1,50 @@
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_DIR = '/home/andrey/work/fishing-game';
const GAME = 'file://' + path.join(GAME_DIR, 'index.html');
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROMIUM, headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage', '--mute-audio'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
await page.goto(GAME, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForFunction(() => !document.getElementById('menu').classList.contains('hidden'), { timeout: 8000, polling: 50 });
await page.click('#btnPlay');
await page.waitForFunction(() => state === 'IDLE', { timeout: 3000, polling: 50 });
await page.mouse.move(640, 360);
await page.mouse.down(); await sleep(650); await page.mouse.up();
await page.waitForFunction(() => state === 'WAIT', { timeout: 3000, polling: 50 });
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; recall(); });
await sleep(100);
const debug = await page.evaluate(() => {
return {
state: state,
forceBite: forceBite,
biteTimer: biteTimer,
uiOpen: uiOpen,
recallExists: typeof recall === 'function',
};
});
console.log('After recall:', debug);
try {
await page.waitForFunction(() => state === 'BITE', { timeout: 3000, polling: 50 });
console.log('BITE reached!');
} catch (e) {
console.log('BITE timeout, state:', await page.evaluate(() => state));
}
await browser.close();
})();
+31
View File
@@ -0,0 +1,31 @@
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_DIR = '/home/andrey/work/fishing-game';
const GAME = 'file://' + path.join(GAME_DIR, 'index.html');
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROMIUM, headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage', '--mute-audio'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
await page.goto(GAME, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForFunction(() => !document.getElementById('menu').classList.contains('hidden'), { timeout: 8000, polling: 50 });
await page.click('#btnPlay');
await page.waitForFunction(() => state === 'IDLE', { timeout: 3000, polling: 50 });
// Try clicking the canvas directly
const canvas = await page.$('canvas');
await canvas.click();
await sleep(100);
console.log('After click:', await page.evaluate(() => state));
await browser.close();
})();
+55
View File
@@ -0,0 +1,55 @@
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_DIR = '/home/andrey/work/fishing-game';
const GAME = 'file://' + path.join(GAME_DIR, 'index.html');
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROMIUM, headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage', '--mute-audio'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
await page.goto(GAME, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForFunction(() => !document.getElementById('menu').classList.contains('hidden'), { timeout: 8000, polling: 50 });
await page.click('#btnPlay');
await page.waitForFunction(() => state === 'IDLE', { timeout: 3000, polling: 50 });
// Move to canvas center and press mouse
await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const rect = canvas.getBoundingClientRect();
const event = new PointerEvent('pointerdown', {
bubbles: true, cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
button: 0,
});
canvas.dispatchEvent(event);
});
await sleep(100);
console.log('After pointerdown:', await page.evaluate(() => state));
// Release
await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const rect = canvas.getBoundingClientRect();
const event = new PointerEvent('pointerup', {
bubbles: true, cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
button: 0,
});
canvas.dispatchEvent(event);
});
await sleep(100);
console.log('After pointerup:', await page.evaluate(() => state));
await browser.close();
})();
+35
View File
@@ -0,0 +1,35 @@
const puppeteer = require('puppeteer-core');
const path = require('path');
const GAME_DIR = '/home/andrey/work/fishing-game';
const GAME = 'file://' + path.join(GAME_DIR, 'index.html');
const CHROMIUM = process.env.CHROMIUM || '/usr/bin/chromium-browser';
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROMIUM, headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage', '--mute-audio'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720, deviceScaleFactor: 1 });
await page.goto(GAME, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForFunction(() => !document.getElementById('menu').classList.contains('hidden'), { timeout: 8000, polling: 50 });
await page.click('#btnPlay');
await page.waitForFunction(() => state === 'IDLE', { timeout: 3000, polling: 50 });
// Use page.mouse.move + down + up
await page.mouse.move(640, 360);
await page.mouse.down();
await sleep(100);
console.log('After mouse.down:', await page.evaluate(() => state));
await page.mouse.up();
await sleep(100);
console.log('After mouse.up:', await page.evaluate(() => state));
await browser.close();
})();
+65 -154
View File
@@ -3,14 +3,11 @@
* Генерация скриншотов «Рыбалки» — витринный набор (страница вики «Скриншоты»,
* материалы для Яндекс Игр).
*
* Снимает 15 состояний игры (меню, зарядка, ожидание, поклёвка, вываживание,
* карточка улова, карточка с RV-бонусом, ночь, дождь, Озёра, босс, лавка,
* дневник, хаб «Меню», диалог сброса) на трёх вьюпортах:
* Снимает 10 состояний игры (меню, лавка, достижения, дневник, ожидание дня,
* дождь, ночь, Озёра, вываживание, карточка улова) на трёх вьюпортах:
* - desk: 1280x720 @1x (десктоп, 16:9)
* - mobp: 360x640 @2x -> 720x1280 (мобильный, портрет, 9:16)
* - mobl: 800x450 @1.6x -> 1280x720 (мобильный, ландшафт, 16:9;
* CSS 800x450: мобильная «низкая» ветка @media(max-height:620px), но
* карточка улова целиком влезает — на 640x360@2x дно уезжает за экран)
* - mobl: 800x450 @1.6x -> 1280x720 (мобильный, ландшафт, 16:9)
*
* Требования Яндекс Игр: пропорции 16:9 / 9:16, длинная сторона 1280..2560,
* JPEG или 24-разрядный PNG без альфа-канала. Скрипт пишет именно 24-битный
@@ -35,20 +32,15 @@
*
* Детерминизм:
* - новый сейв на каждом вьюпорте (localStorage.clear) — кадры согласованы;
* - поклёвка форсится тест-глобалом forceBite;
* - карточка улова — всегда золотой осётр 12.5 кг (SHINY_CHANCE=1);
* цена catchFish() жмитится фактором (0.95+0.1·rnd) — rnd шпилится в 0.5,
* фактор = ровно 1.0, итог +2438 во всех трёх сетках;
* - закрытие карточки: реальный клик + DOM-фолбэк + жёсткое ожидание
* (без «проглоченных» таймаутов — лавка не снимается поверх карточки).
*
* Зависит от тест-глобалов игры (AGENTS.md, «Подводные камни»):
* state, forceBite, forceBoss, forceWeather, biteTimer, reel, catchFish,
* SHINY_CHANCE, SPECIES, setDayTime, pickSpecies, save, dayTime, dayPhase,
* forceLang, setLang
* и DOM-кнопок: #btnPlay, #btnRv, #btnCardOk, #btnShop, #tabJournal,
* #btnCloseShop, #btnPause, #btnPauseMenu, #btnReset, #btnResetNo, #card,
* #shop, #pause, #gamemenu, #resetDlg, #menu.
* state, forceBite, forceWeather, biteTimer, reel, catchFish, SHINY_CHANCE,
* SPECIES, setDayTime, save, dayTime, dayPhase, forceLang, setLang
* и DOM-кнопок: #btnPlay, #btnShop, #tabAch, #tabJournal, #btnCloseShop,
* #btnCardOk, #card, #shop, #menu.
*/
const fs = require('fs');
const path = require('path');
@@ -66,8 +58,7 @@ const VIEWPORTS = [
{ key: 'mobp', w: 360, h: 640, dsf: 2 },
{ key: 'mobl', w: 800, h: 450, dsf: 1.6 },
];
const FRAMES = ['menu', 'charge', 'wait', 'bite', 'reel', 'card', 'card-rv', 'night',
'weather-rain', 'lake', 'boss', 'shop', 'journal', 'gamemenu', 'reset'];
const FRAMES = ['menu', 'shop', 'ach', 'journal', 'wait', 'weather-rain', 'night', 'lake', 'reel', 'card'];
// ---------- 24-битный PNG (без альфа): ffmpeg предпочтительнее, иначе ImageMagick
function findBin(name) {
@@ -136,29 +127,75 @@ async function captureSet(browser, vp) {
await page.click('#btnPlay');
await waitState(page, 'IDLE');
// зарядка: удержание заряжает бросок (CHARGE)
// лавка рыбака — вкладка «Лавка» (tackle)
await page.click('#btnShop');
await page.waitForFunction(
() => !document.getElementById('shop').classList.contains('hidden'),
{ timeout: 3000, polling: 50 });
await sleep(300);
await waitPaint(page);
await shoot(page, `${vp.key}-shop.png`);
// достижения — вкладка #tabAch
await page.click('#tabAch');
await page.waitForFunction(
() => document.getElementById('tabAch').classList.contains('active'),
{ timeout: 3000, polling: 50 });
await waitPaint(page);
await shoot(page, `${vp.key}-ach.png`);
// дневник — вкладка #tabJournal
await page.click('#tabJournal');
await page.waitForFunction(
() => document.getElementById('shopBody').textContent.indexOf('Бросков') >= 0,
{ timeout: 3000, polling: 50 });
await waitPaint(page);
await shoot(page, `${vp.key}-journal.png`);
await page.click('#btnCloseShop');
await waitState(page, 'IDLE', 3000);
// ожидание днём — заброс + WAIT
await page.mouse.move(cx, cy);
await page.mouse.down();
await waitState(page, 'CHARGE', 3000);
await sleep(650);
await waitPaint(page);
await shoot(page, `${vp.key}-charge.png`);
// заброс
await page.mouse.up();
await waitState(page, 'WAIT');
await sleep(1200);
await waitPaint(page);
await shoot(page, `${vp.key}-wait.png`);
// поклёвка (тест-форс: рыба идёт на крючок, поплавок тонет)
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await sleep(300);
// дождь — forceWeather='rain': полоски дождя + пилюля 🌧️ в HUD
await page.evaluate(() => { forceWeather = 'rain'; });
await sleep(500);
await waitPaint(page);
await shoot(page, `${vp.key}-bite.png`);
await shoot(page, `${vp.key}-weather-rain.png`);
await page.evaluate(() => { forceWeather = null; });
// подсечка -> бой; эффектная рыба (осётр 12.5 кг, фазы детерминированы)
// ночь — dayTime 0.85 (фаза night)
await page.evaluate(() => { setDayTime(0.85); });
await waitPaint(page);
await shoot(page, `${vp.key}-night.png`);
await page.evaluate(() => { setDayTime(0.3); });
// Озёра — save.location=1, день, dayTime 0.3
await page.evaluate(() => { save.locations = [0, 1]; save.location = 1; setDayTime(0.3); });
await waitPaint(page);
await shoot(page, `${vp.key}-lake.png`);
await page.evaluate(() => { save.location = 0; save.locations = [0]; });
// вываживание: recall → заброс + клёв + подсечка → REEL
// biteTimer сбрасывается при завершении CAST, поэтому ставим его после заброса
await page.evaluate(() => { forceBite = true; recall(); });
await page.mouse.move(cx, cy);
await page.mouse.down();
await waitState(page, 'CHARGE', 3000);
await sleep(650);
await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
await page.evaluate(() => {
@@ -177,7 +214,6 @@ async function captureSet(browser, vp) {
await page.mouse.up();
// карточка улова: золотой осётр 12.5 кг (детерминированно);
// цена catchFish() жмитится (0.95+0.1·rnd) — rnd шпилится в 0.5 -> фактор 1.0
await page.evaluate(() => {
SHINY_CHANCE = 1;
if (reel) { reel.w = 12.5; reel.boss = false; }
@@ -188,137 +224,12 @@ async function captureSet(browser, vp) {
}
});
await waitState(page, 'CARD', 4000);
// ждём, пока стихнут тосты (улов/ачивка/квест; toast() гаснет через 1.6 с)
await sleep(2200);
await waitPaint(page);
console.log(' [card]', await page.evaluate(() =>
state + ' card=' + document.getElementById('card').classList.contains('hidden')
+ ' shop=' + document.getElementById('shop').classList.contains('hidden')));
state + ' card=' + document.getElementById('card').classList.contains('hidden')));
await shoot(page, `${vp.key}-card.png`);
// карточка с RV-бонусом: клик по #btnRv (стаб — синхронный бонус ×2)
await page.click('#btnRv');
await sleep(300);
await waitPaint(page);
await shoot(page, `${vp.key}-card-rv.png`);
// закрытие карточки: реальный клик + DOM-фолбэк + жёсткое ожидание
await page.click('#btnCardOk');
const cardHidden = () => page.waitForFunction(
() => document.getElementById('card').classList.contains('hidden'),
{ timeout: 2500, polling: 50 }).then(() => true).catch(() => false);
if (!(await cardHidden())) {
console.log(' btnCardOk: клик мимо, DOM-клик');
await page.evaluate(() => document.getElementById('btnCardOk').click());
await page.waitForFunction(
() => document.getElementById('card').classList.contains('hidden'),
{ timeout: 2500, polling: 50 });
}
await waitState(page, 'IDLE', 5000);
// M3: ночь — новый заброс + dayTime 0.85 (фаза night)
await page.mouse.move(cx, cy);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { setDayTime(0.85); biteTimer = 30; });
await waitPaint(page);
await shoot(page, `${vp.key}-night.png`);
// M8: дождь — forceWeather='rain': полоски дождя + пилюля 🌧️ в HUD
await page.evaluate(() => { forceWeather = 'rain'; });
await sleep(500);
await waitPaint(page);
await shoot(page, `${vp.key}-weather-rain.png`);
await page.evaluate(() => { forceWeather = null; });
// Этап 5: Озёра — save.location=1, свой цвет воды (день, dayTime 0.3)
await page.evaluate(() => { save.locations = [0, 1]; save.location = 1; setDayTime(0.3); });
await waitPaint(page);
await shoot(page, `${vp.key}-lake.png`);
await page.evaluate(() => { save.location = 0; save.locations = [0]; });
// M4: босс — forceBoss + осётр, полный цикл до босс-карточки
await page.click('#btnRecall');
await waitState(page, 'IDLE');
await page.evaluate(() => {
setDayTime(0.3);
window.__pick = pickSpecies;
pickSpecies = () => SPECIES.find(s => s.id === 'sturge');
forceBoss = true; SHINY_CHANCE = 0;
});
await page.mouse.move(cx, cy);
await page.mouse.down(); await sleep(450); await page.mouse.up();
await waitState(page, 'WAIT');
await page.evaluate(() => { forceBite = true; biteTimer = 0.05; });
await waitState(page, 'BITE', 4000);
await page.mouse.down(); await page.mouse.up();
await waitState(page, 'REEL');
// босс-бой: фазы 1-2 — d→0 «босс рвёт леску» (SNAP без штрафа) → авто-REEL глубже; фаза 3 — улов
for (let phase = 1; phase <= 3; phase++) {
await page.evaluate(() => { reel.d = 0.4; reel.phase = 'calm'; reel.phaseT = 5; });
await page.mouse.down();
if (phase < 3) {
await waitState(page, 'SNAP', 5000);
await page.evaluate(() => { if (reel) reel.holding = false; }); // в обрыве не втягиваем
await page.mouse.up();
await waitState(page, 'REEL', 5000);
} else {
await waitState(page, 'CARD', 5000);
await page.mouse.up();
}
}
await waitPaint(page);
await shoot(page, `${vp.key}-boss.png`);
await page.click('#btnCardOk');
await page.evaluate(() => {
forceBoss = false; SHINY_CHANCE = 0.03;
pickSpecies = window.__pick;
});
await waitState(page, 'IDLE', 5000);
// лавка рыбака
await page.evaluate(() => { save.coins = 30000; });
await page.click('#btnShop');
await page.waitForFunction(
() => !document.getElementById('shop').classList.contains('hidden'),
{ timeout: 3000, polling: 50 });
await sleep(300);
await waitPaint(page);
console.log(' [shop]', await page.evaluate(() =>
state + ' card=' + document.getElementById('card').classList.contains('hidden')
+ ' shop=' + document.getElementById('shop').classList.contains('hidden')));
await shoot(page, `${vp.key}-shop.png`);
// дневник: вкладка #tabJournal
await page.click('#tabJournal');
await page.waitForFunction(
() => document.getElementById('shopBody').textContent.indexOf('Бросков') >= 0,
{ timeout: 3000, polling: 50 });
await waitPaint(page);
await shoot(page, `${vp.key}-journal.png`);
await page.click('#btnCloseShop');
// Хаб «Меню» (#gamemenu): пауза → «В меню»
await page.click('#btnPause');
await page.waitForFunction(
() => !document.getElementById('pause').classList.contains('hidden'),
{ timeout: 3000, polling: 50 });
await page.click('#btnPauseMenu');
await page.waitForFunction(
() => !document.getElementById('gamemenu').classList.contains('hidden'),
{ timeout: 3000, polling: 50 });
await waitPaint(page);
await shoot(page, `${vp.key}-gamemenu.png`);
// диалог подтверждения сброса (открывается из «Меню»; закрываем «Отменой»)
await page.click('#btnReset');
await page.waitForFunction(
() => !document.getElementById('resetDlg').classList.contains('hidden'),
{ timeout: 3000, polling: 50 });
await waitPaint(page);
await shoot(page, `${vp.key}-reset.png`);
await page.click('#btnResetNo');
await page.close();
}