31 KiB
Процесс разработки
Как игру «Рыбалка» спроектировали, собрали, проверили и опубликовали. Документ описывает этапы, инструменты и повторяемые команды — чтобы любой (включая вас через полгода) мог воспроизвести и продолжить процесс.
ТЗ → макет-прототип → среда проверки → автотесты → визуал → баги → публ. → поддержка
1 2 3 4 5 6 7 8
Этап 1. Требования и дизайн (ТЗ)
Исходное ТЗ: браузерная игра про рыбалку, играбельная на телефоне.
Ключевые решения:
| Решение | Зачем |
|---|---|
Один самодостаточный index.html (HTML+CSS+JS) |
Нет сборки, зависимостей и бэкенда. Файл можно открыть где угодно: телефон, десктоп, статический хостинг |
| Вся графика на Canvas, без внешних ассетов | Игра работает офлайн, грузится мгновенно |
| Портретная ориентация, управление одним пальцем | Все действия — тап/удержание по экрану, больших тач-мишеней (кнопки ≥ 44 px) |
| HTML-оверлеи для меню/магазина/карточек, Canvas — только мир игры | Кнопки и текст резкие и доступные, игровой мир — 60 fps |
WebAudio для звуков (без файлов), navigator.vibrate для тактильного отклика |
Снова ноль внешних ресурсов; на телефоне — вибрация на клёв/рывки/обрыв |
Прогресс в localStorage |
Мобильная игра без аккаунтов и сервера |
Механика (единый цикл):
бросок (удержание = мощность) → ожидание → клёв («!») → подсечка (тап в окно)
→ вываживание (тяни/отпускай) → {улов / обрыв / срыв} → монеты → магазин
Этап 2. Реализация
Структура index.html (все секции помечены комментариями в файле):
- Данные —
SPECIES(8 рыб: сила, редкость, цена, мин. дистанция),RODS,LURES. Баланс игры крутится здесь. - Сохранение —
save+localStorage(монеты, снасти, коллекция, статистика). - Холст — масштабирование под
devicePixelRatio, пересчёт layout'а приresize. - Звук — генеративный WebAudio: 12 эффектов из осцилляторов и шума.
- Состояния — машина из 9 состояний:
MENU → IDLE → CHARGE → CAST → WAIT → APPROACH → BITE → REEL → SNAP/CARD → (IDLE). - Физика вываживания —
updateReel(): напряжение шнура, дистанция, фазы «тянет/отдыхает». Сложность крутится здесь. - Отрисовка — небо, вода, рыбы-силуэты, лодка, поплавок, частицы, риплы, метры.
- Ввод — Pointer Events (
pointerdown/up), работают и палец, и мышь (важно для автотестов!). - UI — магазин, дневник, карточка улова.
Этап 3. Среда проверки
Игра проверена в контейнере Alpine Linux 3.24. Минимальный набор инструментов:
| Инструмент | Версия (в нашем прогоне) | Зачем | Установка |
|---|---|---|---|
| Node.js | v24.18.1 | Статический анализ JS, запуск автотестов | apk add nodejs npm |
| Chromium | 151.0.7922 | Headless-браузер для настоящего прогона игры | apk add chromium |
| puppeteer-core | 25.9.0 | Управление Chromium через CDP (клики, скриншоты, чтение состояний) | npm i puppeteer-core (в tests/) |
| Noto Color Emoji | — | Чтобы в headless-скриншотах эмодзи рендерились как на телефоне | apk add font-noto-emoji |
| git | 2.54.0 | Публикация | apk add git |
| curl | — | Проверка API Gitea, smoke-запросы | уже есть в Alpine |
В браузере на телефоне для игры не нужен ни один инструмент из таблицы — только сам браузер.
Этап 4. Статическая проверка
Первый рубеж — компиляция кода настоящим движком (V8):
# вытащить JS из HTML и проверить синтаксис
node --check game.js
Ловит опечатки, несбалансированные скобки, висячие конструкции — до запуска в браузере.
Этап 5. Автотесты (плейтест через CDP)
tests/test.js — сценарный автотест: headless Chromium загружает игру, а «игрок» управляет мышью (в браузере мышь порождает те же Pointer Events, что и палец). Тест читает и подталкивает внутренние состояния игры (state, biteTimer, reel.d) через page.evaluate, чтобы покрыть все ветки без ожидания случайностей.
Что покрыто (54 проверки):
- меню → старт → зарядка → бросок → ожидание
- клёв (ускорен через
biteTimer) → подсечка →REEL - все три исхода вываживания: улов → карточка, обрыв шнура (
SNAP), срыв рыбы - магазин: открытие, покупка удочки и приманки (с проверкой баланса и экипировки)
- дневник: рендер, пойманная рыба в коллекции
- сохранение после перезагрузки страницы (coins/снасти/статистика)
- shiny: ×3 к цене, счётчик
sn, бейдж; near-miss: 10% цены за сошедшую рыбу; миграция старого сейва - M2 (9 проверок): квест выполняется уловом (+reward), перегенерация квестов на новый день, стрик (день+1, награда дня), «щит» стрика (прощение пропуска и контр-кейс), достижения (бонус 1% к монетам), вкладки лавки 🎯/🏅
- M3 (6 проверок): фазы суток по
dayTime, ночная таблица рыб (тот же ролл → другая рыба), ночь без фонарика (клёва нет, тост разовый), ночь с фонариком (клёв работает), «Фонарик» в лавке, миграция сейва (dayTime/lastSaveMs) - M4 (5 проверок): старт босс-боя (
stateBoss/bossPhase), 3 фазы боя (обрывы фаз без штрафа, дистанция растёт по фазам), награда ×5 +bossBeaten/bossCount, достижениеboss_first, босс-карточка - ноль ошибок в консоли и
pageerror
Запуск:
cd tests
npm install
node test.js # при необходимости: CHROMIUM=/путь/к/хромью
Этап 6. Визуальная проверка
tests/shots.js — та же техника, но с фиксацией скриншотов каждого состояния (screenshots/shot-*.png). Скриншоты просматривает разработчик: ловит проблемы, которые не видны по коду и логам (сбившиеся метки, отсутствующие глифы, нечитаемые метры).
Этап 7. Найденные баги (ради чего всё это)
Автотест + скриншоты поймали то, что ручное «посмотрел глазами» пропустило:
| # | Баг | Как нашли | Лечение |
|---|---|---|---|
| 1 | Критический: после поимки рыбы игра вешалась навсегда. catchFish() ставил reel=null, но кейс REEL в updateGame() на следующей строке читал reel.holding → исключение убивало цикл requestAnimationFrame |
Автотест: второй бросок не происходил; pageerror показал точную строку |
if(state==='REEL'&&reel){...} |
| 2 | Точка крепления лески висела в углу экрана в меню/карточке | Визуальный осмотр скриншотов | Условие отрисовки по состояниям |
| 3 | Рыбка-маркер не стояла на краю полосы «дистанция» | Визуальный осмотр | Полоса = оставшаяся дистанция, маркер на её конце |
| 4 | Глиф ✕ (U+2715) отсутствовал в некоторых шрифтах |
Скриншот магазина | Заменён на × (U+00D7) |
Вывод: для игр с таймерами и случайностями автотест, который управляет временем и состоянием, покрывает за минуту то, что вручную ищут часами.
Этап 8. Публикация в Gitea
Использовано: Gitea API (создание репозитория) + обычный git push.
# 1. Создать репозиторий (токен: Настройки → Приложения → Токены, права: Repository)
curl -X POST -H "Authorization: token <ТОКЕН>" -H "Content-Type: application/json" \
-d '{"name":"fishing-game","private":false,"auto_init":false}' \
https://git.shstk.ru/api/v1/user/repos
# 2. Наполнить ветку и запушить
git init -b main
git add -A
git commit -m "..."
git remote add origin https://git.shstk.ru/andrey/fishing-game.git
git push -u origin main # git попросит логин/пароль (или токен в URL — см. ниже)
Игра доступна анонимно по сырой ссылке:
https://git.shstk.ru/andrey/fishing-game/raw/branch/main/index.html
🔐 Гигиена секретов (проверено на себе)
- Токен — одноразовый. Для публикации достаточно кратковременного токена с правами на репозиторий; после push его отзываем (в нашем случае — отозван, старый токен теперь отдаёт
401). - Токен не попадает в историю и конфиг. Если передавать токен в URL (
https://user:TOKEN@host/...), после push удалите его из конфига:git remote set-url origin https://git.shstk.ru/andrey/fishing-game.git - В репозиторий и в документы — только плейсхолдеры (
<ТОКЕН>), реальные секреты нигде не фиксируются.
Этап 9. Поддержка и развитие
Крутить баланс: константы SPECIES / RODS / LURES в начале index.html; физика вываживания — в updateReel().
Цикл изменений:
# 1. поправить index.html
# 2. прогнать автотест
cd tests && node test.js # ждём 54/54
# 3. (опционально) свежие скриншоты
node shots.js
# 4. закоммитить и запушить
git add -A && git commit -m "что изменилось" && git push
Идеи развития (по нарастающей сложности):
- продажи улова / рыбный прайс по редкости
- мировая таблица лидеров (нужен простой бэкенд или Gitea-Actions с webhook'ом)
- офлайн-режим PWA (service worker в тот же файл)
Шпаргалка: минимум инструментов по этапам
| Этап | Инструменты |
|---|---|
| Игра (конечный пользователь) | Любой браузер на телефоне/десктопе |
| Написание/изменение кода | Текстовый редактор (или LLM-сессия) |
| Статическая проверка | Node.js |
| Автотесты | Node.js + npm + Chromium + puppeteer-core |
| Визуальная проверка | Скриншоты из автотеста + Noto Color Emoji в headless |
| Публикация | git + curl + Gitea (токен с минимальными правами, отзываемый) |
Этап 10. M1: shiny-рыба, near-miss и починка тест-запуска
Короткий лог по M1:
- Починка запуска автотестов —
tests/test.jsхардкодилexecutablePath: '/usr/bin/chromium'и URLfile:///fishing/index.html; в этом чекауте их нет. Теперь, как вshots.js:process.env.CHROMIUM || '/usr/bin/chromium-browser', а путь к игре резолвится динамически —'file://' + path.resolve(__dirname, '..', 'index.html'). - Shiny-рыба — каждый улов роллит шанс
SHINY_CHANCE(3%): золотая версия вида продаётся заSHINY_MULT(×3) к цене с тем же разбросом ±5%, на карточке — бейдж «✨ ШИНИ!» и золотое оформление (рамка/свечение), в дневнике у вида появляется метка «✨ N» (счётчикsn). Формат сейва:caught[id] = {n, best, sn}; миграция вloadSave()дописываетsn = 0старым записям — числовые и{n,best}-форматы читаются без потерь. - Near-miss при сходе — если рыба сбежала в вываживании (ветка
d ≥ d0×1.4вupdateReel()), игрок получает утешение: 10% её цены (floor(вес × цена/кг × 0.1), если > 0) и тост «Сбежала! …». Обрыв (SNAP) выплаты не даёт. - Тесты — добавлены 4 проверки: shiny (монеты ×3 в диапазоне,
sn = 1, бейдж в DOM), миграция старого сейва (число и{n,best}безsn), near-miss (монеты ≈10% цены и тост «Сбежала»). Итог: 34/34 PASS, exit 0 (число в README и этом документе обновлено).
Этап 11. Инструмент браузерного плейтеста (Orca CLI)
Что. tests/orca-playtest.js — второй слой плейтестов: тот же полный геймплей, но в настоящей вкладке браузера Orca app (не в headless Chromium). Управление — командами Orca CLI (tab create / eval / screenshot / console / tab close), только встроенные модули Node. Проверяет 31 ok(): состояния игры, DOM карточки/лавки/дневника, бейдж «ШИНИ», тост near-miss, чистоту консоли; скриншоты orca-*.png — в каталог запуска. Полное описание, таблица шагов и камешки: docs/BROWSER-TESTING.md.
Почему. Headless puppeteer-тест — главный рубеж, но он живёт в своей среде. Вкладка браузера Orca троттлит requestAnimationFrame (фоновая вкладка), имеет свой CLI-roundtrip и свою консоль: плейтест «вживую» ловит именно эти отличия и гарантирует, что игра играбельна в том браузере, которым пользуются люди.
Найденные камешки (ради чего писали этот слой):
| # | Камень | Как нашли | Лечение в скрипте |
|---|---|---|---|
| 1 | rAF троттлинг фоновой вкладки: игровое время идёт медленнее реального (кадр ≤ 0.05 c игрового) | Фиксированные sleep не доходили до нужного состояния | Все ожидания — polling с таймаутом (шаг 300 мс, таймауты 10–30 c); фиксированные sleep — только «удержание кнопки» |
| 2 | Окно подсечки (0.35–0.8 c) короче CLI-roundtrip (~0.3–0.5 c) — тап опаздывает | Подсечка случайно промахивалась | biteTimer = 0.05 → ждём BITE → biteT = 5 (расширяем окно) → СНАЧАЛА скриншот, потом тап |
| 3 | Цитирование: JS с кавычками, переданный одной строкой через shell (PTY), ломается | Повреждённые выражения в eval |
Только child_process.execFile с массивом аргументов, без shell |
| 4 | Transient-зависания CLI (единичный hang eval на ~1 мин, проходит повтором) |
Прогон вешался на одном вызове | Таймаут execFile 20 c + ретраи ×3; tab create ретраев не имеет (дубль вкладки) |
| 5 | Тосты живут 1.6 c — отдельный вызов для чтения текста опаздывает | Пустой тост в orca-escape |
Текст тоста читаем в ТОМ ЖЕ eval, что и ожидание WAIT |
Как использовать. Запущенный Orca app + orca-ide в PATH (или ORCA_CLI_COMMAND); из каталога репо: node tests/orca-playtest.js. Если Orca не запущен — пропустить и не блокировать (главный рубеж — tests/test.js). В AGENTS.md — пункт 3 раздела «Проверка».
Этап 12. M2: награды — квесты дня, стрик входов, достижения
Что добавлено в index.html (секция «M2. Награды» после сейва):
- Квесты дня —
save.quests = {date, list:[3]}. Три квеста генерируются по дню (seed — FNV-1a отYYYY-MM-DD, шаблоны без повторов): «поймай N вида», «заработай N 🪙», «улов ≥ N кг», «N забросов», «N рыб редкости R», «поймай шини». Прогресс растёт вquestHit()(вызывается изdoCast()/catchFish()); выполненный квест выдаёт награду один раз (save.questsDoneTotal— счётчик для достижения). Новый день (первый заход после смены даты) — перегенерация. - Стрик входов со «щитом» —
save.streak = {last, days, shieldWeek}. При загрузкеapplyStreak(): вчерашний визит → день+1; пропуск в середине недели → день+1, еслиshieldWeekещё не равен текущей ISO-неделе (ISO —isoWeek(), «YYYY-Www», неделя с понедельника); иначе стрик сбрасывается в день 1. Награда дня 1–7: 25/50/75/100/125/150/200 🪙. Первый визит (визитов не было) запускает стрик с дня 1 без награды — иначе миграция старых сейвов (без поляstreak) давала бы «воздушные» монеты. - Достижения — 16 штук в
ACHIEVEMENTS(первый улов, 10/100 забросов, заработано 1k/10k, 3/8 видов, редкая/легендарная рыба, первый шини, 10/20 кг, 10/50 обрывов — для этогоsave.snaps++в ветке SNAP, стрик 7, выполнено 5 квестов).checkAchievements()— по событиям (бросок/улов/обрыв/покупка/пополнение кошелька); выданные дают постоянные бонусы черезachBonus(): +1–2% к монетам, +0.02–0.06 с к окну подсечки, +5–10 к нагрузке шнура. - UI — в лавке две вкладки: 🎯 «Награды» (полоса стрика с точками дня 1–7 + квесты дня с прогрессом и наградой) и 🏅 «Достижения» (выданные — яркие с ✓, остальные приглушённые с бонусом).
- Сейв — новые поля только через миграцию/дефолты в
loadSave(); старые сейвы читаются (проверяется тестом миграции — он не тронут).
Тесты: +9 проверок в tests/test.js (итого 43/43 PASS, exit 0), +2 шага (🎯/🏅) в tests/orca-playtest.js (итого 35/35 PASS, exit 0). Точные проверки монет (M1 shiny, M1 near-miss) получили «гвард»: на время проверки квесты/стрик/счётчики фиксируются в eval, чтобы случайная награда не исказила баланс.
Найденные камешки:
| # | Камень | Как нашли | Лечение |
|---|---|---|---|
| 1 | Ветка else if(shopTab==='journal') терялась при вставке новых вкладок — журнал дублировался, новые вкладки недостижимы |
Проваленный прогон: вкладки 🎯/🏅 рендерили «Снасти» | Явные else if по каждой вкладке + дубликат удалён |
| 2 | h='…' без let в новых ветках renderShop() → ReferenceError в strict mode |
pageerror: h is not defined |
let h в каждой ветке |
| 3 | Стрик давал «воздушные» +25 при миграции старых сейвов (без поля streak) — ломал точную проверку монет M1-теста |
Провал coins===77 после reload |
Первый визит (last=null) — стрик стартует без награды |
| 4 | page.click('#btnRecall') утаскивает мышь на кнопку → следующий mouse.down() в puppeteer-тесте падал на кнопку, а не на canvas |
Тест вешался на WAIT (state=IDLE) |
page.mouse.move(195, 600) после клика по кнопке |
| 5 | На первопланарной вкладке Okno BITE (0.35–0.8 c реального времени) закрывалось до применения отдельного eval biteT = 5 |
Плейтест упал на REEL (state=WAIT) |
Расширение окна — в ТОМ ЖЕ eval, что детекция BITE (state === "BITE" ? (biteT = 5, "yes") : …) |
Этап 13. Спринт 2: ночная рыбалка + босс-рыба (M3+M4)
M3 — день/ночь, что добавлено в index.html (секция «M3: ЦИКЛ ДНЯ/НОЧИ»):
- Цикл суток —
DAY_LENGTH = 300(секунд на полные сутки), глобалыdayTime(0..1) иdayPhase(утро 0–0.25 → день 0.25–0.5 → вечер 0.5–0.75 → ночь 0.75–1). Время идёт в реальном времени: вframe()каждый кадрsetDayTime(dayTime + dt/DAY_LENGTH). Пока игрока не было, сутки догоняются при загрузке (dayTime + (Date.now() - lastSaveMs), mod 1). - Визуал фаз —
SKY_PHASES: свой градиент неба на каждую фазу (утро — оранжевый, день — голубой, вечер — закатный, ночь — тёмно-синий); солнце/луна ходят по дуге (положение изdayTime, ночью — полумесяц), облака ночью приглушены, на воду ложится тёмный оверлей (прозрачность 0–0.3 по фазе). - Ночной клёв —
SPECIES_NIGHT(клонSPECIESсо смещёнными весами спавна: сом/щука/окунь активнее, дневные виды реже).pickSpecies()берёт ночную таблицу вечером и ночью. Ночью без фонарика клёв не стартует:biteTimerтикает вхолостую, разово показывается тост «Ночью нужен фонарик» (флагnightNoLureToast, сброс — новым забросом). - Фонарик — новая приманка
LURES id=4(500 🪙, флагnightOnly, в лавке — пометка «ночь»): на клёв не влияет, но разрешает ночную рыбалку (проверкаsave.lures.includes(4)). - Сейв —
persist()пишетsave.dayTime/save.lastSaveMs; миграция вloadSave(): старый сейв стартует с утра (dayTime≈0),lastSaveMsпроставляется — старые сейвы читаются (есть тест).
M4 — босс-рыба (Осётр-босс):
- Появление — при клёве осётра шанс 30% (проверка в
startBite()— где рождается клёв; воркер M4 отклонился от «вpickSpecies()», отклонение согласовано): рыба помечаетсяsp.boss = trueклоном объекта — таблицаSPECIESне мутируется. Для тестов — глобалforceBoss. Босс отличается: окно подсечки короче (0.25–0.5 c, ветка вbiteWindow()), «злость» выше (сила 0.95), силуэт рыбы ×1.5. - Бой в 3 фазы — глобалы
stateBoss/bossPhase; босс-ветка вupdateReel(): у босса в фазах 1–2d ≤ 0— «босс рвёт леску» (SNAPбез штрафа:save.snapsне растёт) и без ввода игрока начинается следующая фаза глубже (d = 0.6·d0, затем1.0·d0); в фазе 3 — обычный улов. Настоящий обрыв (по натяжению) и near-miss завершают босс-бой (stateBoss=false). - Награда — ×5 к цене улова (множитель в
catchFish()); поляsave.bossBeaten/save.bossCount(дефолты для старых сейвов вloadSave()); тост «🏆 БОСС ПОБЕЖДЁН! +N 🪙»; всплеск и частицы крупнее. - Достижения — 17-е достижение
boss_first«Босс-рыбак» (+3% к монетам). - UI — босс-карточка: имя «Осётр-БОСС», бейдж «🏆 БОСС!», золотое оформление (
boss-card); во время боя над метрами — строка «⚠️ БОСС-РЫБА · ФАЗА N/3».
Тесты: +11 проверок в tests/test.js (M3 — 6: фазы суток, ночная таблица, ночь без/с фонариком, «Фонарик» в лавке, миграция сейва; M4 — 5: старт боя, 3 фазы, награда ×5, достижение, карточка). Итог 54/54 PASS, exit 0. В tests/orca-playtest.js +3 шага (ночь dayTime=0.85 + скриншот, «Фонарик» в лавке, босс-бой до карточки со сбросом forceBoss в finally) — итого 46/46 PASS, exit 0. В tests/shots.js добавлены shot-night.png (ночь, dayTime=0.85) и shot-boss.png (босс-карточка, полный цикл через forceBoss), плюс недостающие shot-wait.png/shot-reset.png — всего 11 кадров, скопированы в screenshots/.
Найденные камешки:
| # | Камень | Как нашли | Лечение |
|---|---|---|---|
| 1 | Тост «Ночью нужен фонарик» показывался бы на каждом кадре: ночью ветка «клёв не стартует» срабатывает на каждом тике biteTimer ≤ 0 |
Ревью при написании M3 | Флаг nightNoLureToast — тост разовый; сброс новым забросом |
| 2 | Босс-флаг «залипал» бы в общей таблице: пометить объект осётра в SPECIES как босса — все следующие осётры тоже боссы |
Ревью при написании M4 | В startBite() босс-осётр клонируется (Object.assign), таблица не трогается |
| 3 | Настоящий обрыв (по натяжению) не завершал бы босс-бой: stateBoss оставался бы true, и логика фаз ездила бы по уже кончившемуся бою |
Тест M4 (проверки после обрывов фаз) | stateBoss=false; bossPhase=0 в ветках натяжения и near-miss |
| 4 | В состоянии SNAP отпускание пальца не сбрасывает reel.holding (onUp слушает только CHARGE/REEL) — после авто-перехода в следующую фазу босса игра «втягивает» сама, как будто палец ещё на экране |
Тест M4: дистанция фазы 2 не сходилась с 0.6·d0 | В тестах holding=false в том же eval, что детекция SNAP; для игрока некритично (окно 0.6 c), в будущем — чистить holding при выходе из SNAP |
| 5 | console-ошибок нет, но квестные тосты затирали проверочный тост фонарика в тесте |
Прогон M3-тестов | На время проверки квесты очищаются (save.quests={list:[]}), как в M2 |