342 lines
25 KiB
Markdown
342 lines
25 KiB
Markdown
# Архитектура проекта
|
||
|
||
Коллекция терминальных игр на Go (Bubble Tea + Lipgloss). Этот файл
|
||
описывает, как устроен код: что за что отвечает, где и как хранятся
|
||
сохранения, и — отдельным разделом — подробно разбирает экономику и
|
||
вероятности режима истории (сторимода), самой сложной части проекта.
|
||
|
||
Общий объём: ~27 000 строк кода, ~18 000 строк тестов, 17 игр.
|
||
|
||
## 1. Общая структура файлов
|
||
|
||
### Инфраструктура (общая для всех игр)
|
||
|
||
| Файл | За что отвечает |
|
||
|---|---|
|
||
| `main.go` | Точка входа — запуск программы Bubble Tea |
|
||
| `menu.go` | Главное меню: выбор языка, реестр игр (`gameRegistry`), все экраны настройки сложности/вариантов перед началом партии, диспетчеризация состояний верхнего уровня (`AppModel.Update`/`View`) |
|
||
| `tui.go` | Общие для всех игр стили (`lipgloss`), рендер карт (`renderCardBox`/`renderCardBoxHand`), `wrapText` (перенос длинного текста по ширине терминала), тип `ColorChoice` (общий выбор цвета/стороны для 2-игроцких игр) |
|
||
| `i18n.go` | Механизм переключения языка (`SetLanguage`/`CurrentLang`) |
|
||
| `translations.go` | Все строки интерфейса на русском и английском (один общий словарь `map[string][2]string`, доступ через `T(key)`/`Tf(key, args...)`) |
|
||
| `translations_wayfarer{1..4}.go` | То же самое, но переводы Странника вынесены в отдельные файлы — их одних около 900 строк текста квестов |
|
||
| `rules.go` | Тексты правил каждой игры (RU/EN), показываются по `--help`/`--help-en` и из меню |
|
||
| `banner.go` | ASCII-баннер главного меню и его компактная замена для низких терминалов |
|
||
| `card.go`, `deck.go` | Общий тип карты/масти/ранга и колода — используются всеми карточными играми |
|
||
| `player.go` | Общий вспомогательный тип игрока для карточных игр |
|
||
| `bot.go`, `bot_heuristics.go` | Общие для нескольких карточных ботов эвристики и генератор имён ботов (`newBotRoster`) |
|
||
| `meld.go` | Логика мелдов (сетов/последовательностей), используется Тонком |
|
||
|
||
### Каждая игра: три (иногда четыре) файла по своему шаблону
|
||
|
||
Для игр с ботом принят единый шаблон из трёх файлов:
|
||
|
||
- **`<игра>_game.go`** — чистое состояние партии и правила: структуры
|
||
данных, конструктор `New...Game(...)`, методы применения ходов,
|
||
определение результата. Никакого Bubble Tea, никакого ввода-вывода
|
||
— это чистая логика, которую тестируют без построения интерфейса.
|
||
- **`<игра>_bot.go`** — стратегия бота: `type ...Bot struct`,
|
||
`DecideAction`/`Move` и подобное. Уровни сложности (`Easy`/
|
||
`Medium`/`Hard`, где есть) определены здесь же.
|
||
- **`<игра>_tui.go`** — обёртка Bubble Tea: `type ...Model struct`,
|
||
`Update`/`View`, обработка нажатий клавиш, отрисовка.
|
||
|
||
Игры без бота (Косынка, Сапёр) обходятся без `_bot.go`. У игр с
|
||
сохранением на диске (Странник, Трупные батончики, сторимод) есть
|
||
ещё и **`<игра>_save.go`**.
|
||
|
||
| Игра | Файлы |
|
||
|---|---|
|
||
| Тонк | `game.go`, `bot.go` (часть), `tui.go` — исторически первая игра, поэтому без префикса |
|
||
| Дурак | `durak_game.go`, `durak_bot.go`, `durak_tui.go`, `durak_deck.go` (своя колода на 36 карт) |
|
||
| Блэкджек | `blackjack_game.go`, `blackjack_bot.go`, `blackjack_tui.go` |
|
||
| 101 | `oneohone_game.go`, `oneohone_bot.go`, `oneohone_tui.go` |
|
||
| 1000 | `thousand_game.go`, `thousand_bot.go`, `thousand_tui.go` |
|
||
| Косынка | `klondike_game.go`, `klondike_tui.go` (без бота — пасьянс) |
|
||
| Шашки | `checkers_game.go`, `checkers_bot.go`, `checkers_tui.go` |
|
||
| Шахматы | `chess_game.go`, `chess_bot.go`, `chess_tui.go` |
|
||
| Го | `go_game.go`, `go_bot.go`, `go_tui.go` |
|
||
| Сапёр | `minesweeper_game.go`, `minesweeper_tui.go` (без бота — головоломка) |
|
||
| Крестики-нолики | `tictactoe_game.go`, `tictactoe_bot.go`, `tictactoe_tui.go` |
|
||
| Нарды | `nardy_game.go`, `nardy_bot.go`, `nardy_tui.go` |
|
||
| Игра Ур | `ur_game.go`, `ur_bot.go`, `ur_tui.go` |
|
||
| Сенет | `senet_game.go`, `senet_bot.go`, `senet_tui.go` |
|
||
| Покер | `poker_game.go`, `poker_bot.go`, `poker_hand.go` (оценка комбинаций), `poker_tui.go` |
|
||
| Трупные батончики | `corpsestarch_game.go`, `corpsestarch_tui.go`, `corpsestarch_save.go` |
|
||
| Странник | `wayfarer_ship.go` (состояние корабля/капитала), `wayfarer_galaxy.go` (карта звёздной системы), `wayfarer_economy.go`, `wayfarer_combat.go`, `wayfarer_encounters.go`, `wayfarer_env.go`, `wayfarer_quests.go`/`wayfarer_quest_state.go`/`wayfarer_quest_loader.go` (квесты), `wayfarer_tui.go`, `wayfarer_save.go` |
|
||
| Сторимод | `story_game.go`, `story_tui.go`, `story_save.go` — разбор ниже, в разделе 3 |
|
||
|
||
Странник — самая крупная игра, поэтому единственная, где движок
|
||
разнесён по смыслу на 9 файлов вместо одного `_game.go`.
|
||
|
||
### Тесты
|
||
|
||
Для каждого `<файл>.go` есть парный `<файл>_test.go` — тесты лежат
|
||
рядом с тем, что тестируют, а не в отдельном дереве. Общее число
|
||
тестов на момент этого файла — около 940.
|
||
|
||
## 2. Сохранения
|
||
|
||
Только три игры коллекции пишут что-либо на диск — у остальных 14
|
||
партия начинается с чистого листа при каждом запуске, и никакие
|
||
настройки (сложность, вариант правил) между запусками не
|
||
запоминаются.
|
||
|
||
| Игра | Файл сохранения | Что внутри |
|
||
|---|---|---|
|
||
| Странник | `wayfarer_save.go` | `WayfarerShip` — корабль, груз, капитал, прогресс квестов |
|
||
| Трупные батончики | `corpsestarch_save.go` | `CorpseStarchGameState` — весь прогресс кликера, включая переход на сторону Хаоса |
|
||
| Сторимод | `story_save.go` | `StoryModeState` — см. раздел 3 |
|
||
|
||
Путь у всех трёх один и тот же шаблон — системная конфигурационная
|
||
директория пользователя (`os.UserConfigDir()` в Go: `~/.config` на
|
||
Linux, `~/Library/Application Support` на macOS, `%AppData%` на
|
||
Windows) плюс общая поддиректория `go-games-collection`:
|
||
|
||
```
|
||
~/.config/go-games-collection/wayfarer_save.json
|
||
~/.config/go-games-collection/corpsestarch_save.json
|
||
~/.config/go-games-collection/story_save.json
|
||
```
|
||
|
||
Формат — обычный `json.MarshalIndent` прямо по Go-структуре, без
|
||
отдельной описанной схемы: что в структуре, то и в JSON, поля один в
|
||
один по именам. У Странника и Трупных батончиков при загрузке старого
|
||
сохранения есть миграция недостающих полей (на случай, если
|
||
сохранение создано более ранней версией игры, где каких-то полей ещё
|
||
не было) — у сторимода такой миграции пока нет, он младше и ещё не
|
||
переживал смену формата при существующих сохранениях.
|
||
|
||
Каждый из трёх файлов сохранения полностью независим от двух других
|
||
— сброс прогресса в одной игре не трогает остальные.
|
||
|
||
## 3. Режим истории (сторимод) — устройство подробно
|
||
|
||
### 3.1. Общая идея
|
||
|
||
Сторимод — надстройка над обычными играми коллекции: выбираешь
|
||
сложность один раз в начале, дальше играешь в любые из 15 подключённых
|
||
игр (все, кроме Странника и Трупных батончиков — см. ниже почему), и
|
||
победа в каждой начисляет свой уникальный ресурс. Цель — накопить
|
||
нужное количество каждого ресурса.
|
||
|
||
Файлы:
|
||
|
||
- **`story_game.go`** — вся логика: типы ресурсов, начисление,
|
||
расход, суточные события, показатели выживания. Чистая логика без
|
||
Bubble Tea, как и `<игра>_game.go` у обычных игр.
|
||
- **`story_tui.go`** — экраны сторимода (хаб, выбор сложности,
|
||
приветствия, использование ресурсов, скрытая концовка) и реестр
|
||
подключённых игр (`storyGameDefs`) с условием победы для каждой.
|
||
- **`story_save.go`** — сохранение/загрузка/удаление, тот же шаблон,
|
||
что у Странника и Батончиков.
|
||
- Диспетчеризация состояний сторимода (какое состояние ведёт в
|
||
какое) прописана в общем `menu.go`, наравне с обычным меню.
|
||
|
||
### 3.2. Почему только 15 игр из 17
|
||
|
||
Странник (открытый мир без единого условия "партия окончена") и
|
||
Трупные батончики (кликер без дискретной победы/поражения) не
|
||
вписываются в модель "победил — получил ресурс". Обе доступны как
|
||
обычно в режиме "просто игры", но в сторимоде просто отсутствуют.
|
||
|
||
### 3.3. Ресурсы — таблица целиком
|
||
|
||
Один ресурс на игру, идентификатор — `StoryResourceID` (перечисление
|
||
в `story_game.go`), в этом же порядке показывается в хабе:
|
||
|
||
| Игра | Ресурс | Цель | Условие начисления |
|
||
|---|---|---|---|
|
||
| Тонк | Бутылка воды | 100 | 3+ раунда; баланс не ниже, чем у соперников, при выходе |
|
||
| Дурак | Патроны для ружья | 100 | 3 партии подряд без "дурака" |
|
||
| Шашки | Банка тушёнки | 100 | Победа |
|
||
| Нарды | Сигарета | 100 | Победа |
|
||
| Блэкджек | Самогон | 100 | 3+ раунда; баланс выше стартового при выходе |
|
||
| Шахматы | Аптечка | 100 | Победа |
|
||
| Сапёр | Антидот | 100 | 3 партии подряд без взрыва |
|
||
| Го | Фильтр для воздуха | 100 | Победа |
|
||
| Игра Ур | Спички | 100 | Победа |
|
||
| Сенет | Верёвка | 100 | Победа |
|
||
| 101 | Батарейки | 100 | Победа во всём матче (не в отдельном раунде) |
|
||
| 1000 | Изолента | 100 | 3+ раздачи; первое место по очкам при выходе |
|
||
| Косынка | Антидепрессант | 100 | 3 победы **за один день** (не подряд, не общий счётчик) |
|
||
| Крестики-нолики | Кусок мыла | **10** | Победа **или ничья** |
|
||
| Покер | Золотые зубы | 100 | Победа во всём турнире |
|
||
|
||
Целевые числа — отдельные именованные константы
|
||
(`StoryTargetTonk`, `StoryTargetDurak`, ...), а не одно общее число,
|
||
специально для того, чтобы их было легко подправить по отдельности.
|
||
|
||
Условия победы реализованы как функции `storyOutcome<Игра>(m
|
||
tea.Model) (finished, won bool)` в `story_tui.go` — каждая проверяет
|
||
конкретное поле результата СВОЕЙ игры (`Result.Winner`,
|
||
`RoundsPlayed`, и т.п.). Три игры (Тонк, Блэкджек, 1000) потребовали
|
||
добавить в их собственные `_game.go` счётчик `RoundsPlayed`/`HandNumber`
|
||
— минимальное дополнение, не влияющее на обычную игру вне сторимода.
|
||
Дурак и Сапёр хранят "серию без поражения" прямо в своей `Model`
|
||
(`storyNonFoolStreak`/`storyWinStreak`) — поле, которого не существует
|
||
для обычного режима "просто игры".
|
||
|
||
### 3.4. Начисление ресурса — суточный лимит
|
||
|
||
Победа сама по себе не гарантирует ресурс: `AwardResource` (в
|
||
`story_game.go`) пропускает начисление, если сегодня уже случилось
|
||
`StoryDailyGrantsLimit` (= 3) начислений — играть можно сколько
|
||
угодно, просто пятая-шестая победа за день ничего не даст. У Косынки
|
||
это накладывается НА её собственное правило "3 победы за день" —
|
||
сначала должно сработать правило Косынки, и только потом на этот факт
|
||
начисления натягивается общий суточный лимит.
|
||
|
||
Счётчик `DailyGrantsUsed` (как и `KlondikeWinsToday`) сбрасывается в
|
||
`Tick()` при переходе на новые виртуальные сутки.
|
||
|
||
### 3.5. Запирание в сейф — и что это НЕ значит
|
||
|
||
Как только `Resources[id] >= Target`, ресурс помечается
|
||
`Locked[id] = true`. Это НЕ блокирует ни саму игру (можно продолжать
|
||
играть и дальше копить тот же ресурс без верхнего предела), ни
|
||
расход — единственный эффект: пока ресурс заперт, суточные события,
|
||
способные его как-то уменьшить, его не трогают (сейчас таких событий,
|
||
уменьшающих СЧЁТ ресурса, нет — есть только расход самим игроком,
|
||
`spendResource`, который снимает пометку `Locked`, если после траты
|
||
осталось меньше цели).
|
||
|
||
### 3.6. Этап знакомства (онбординг)
|
||
|
||
Новая партия сторимода не сразу открывает все 15 игр. Три этапа
|
||
(`StoryOnboardingStage` в `story_game.go`, монотонное продвижение
|
||
только вперёд — не завязано на текущие значения ресурсов, чтобы
|
||
суточные события их не откатили):
|
||
|
||
1. **Только крестики-нолики.** Числа ресурсов и весь остальной
|
||
антураж лагеря скрыты.
|
||
2. **+ Шашки** — после `storyStartingAmount + 3` очков ресурса
|
||
крестиков-ноликов (то есть 3 реальных победы/ничьих сверх
|
||
стартовых трёх).
|
||
3. **Обычный режим** — после `storyStartingAmount + 1` очков ресурса
|
||
шашек (1 победа). В момент перехода на этот этап показывается
|
||
одноразовое приветствие от ИИ убежища (`stateStoryWelcome`,
|
||
печатается по слову каждые 250мс) — и только оно, никогда больше
|
||
не повторяется для этой партии.
|
||
|
||
### 3.7. Показатели выживания
|
||
|
||
Три процентных показателя (0–100), видны в лагере только после
|
||
завершения знакомства:
|
||
|
||
- **Стресс** (`Stress`) — не опускается ниже `StoryStressFloor` (20)
|
||
сам по себе, но антидепрессант может увести его вплоть до 0, с
|
||
последующим часовым восстановлением обратно к 20 (см. 3.8).
|
||
- **Отравление** (`Poisoning`).
|
||
- **Токсин в воздухе** (`ToxinLevel`).
|
||
|
||
Ни один из трёх не меняется от одного факта течения времени — только
|
||
по конкретным событиям (см. 3.9) и по расходу ресурсов (см. 3.8).
|
||
|
||
### 3.8. Расходуемые ресурсы — экран `[u]` в лагере
|
||
|
||
Четыре ресурса имеют не только "цель для сейфа", но и практический
|
||
расходуемый эффект (`storyUseItemDefs` в `story_tui.go`):
|
||
|
||
| Ресурс | Эффект (метод в `story_game.go`) |
|
||
|---|---|
|
||
| Антидепрессант (Косынка) | `SpendAntidepressant` — стресс −20%, может уйти ниже пола вплоть до 0; если ушёл ниже пола, запускает часовое восстановление обратно к 20% (`ApplyStressRecovery`, линейная интерполяция, как и очистка воздуха ниже) |
|
||
| Антидот (Сапёр) | `SpendAntidote` — отравление −20%, не ниже 0 |
|
||
| Фильтр для воздуха (Го) | `SpendAirFilter` — отмечает фильтр использованным сегодня (`FilterUsedToday`), что на сегодняшние сутки снимает риск выброса нейротоксина (см. 3.9) и при обработке суток сбрасывает счётчик дней без замены |
|
||
| Батарейки (101) | `SpendBattery` — запускает очистку воздуха: токсин плавно идёт к 0 в течение часа (`ApplyAirPurification`) |
|
||
|
||
Оба "часовых" эффекта (восстановление стресса, очистка воздуха)
|
||
устроены одинаково: запоминается начальное значение и момент запуска,
|
||
а при каждом входе в лагерь (`enterStoryHub`/
|
||
`enterStoryHubFromModeSelect`) линейно интерполируется текущее
|
||
значение по прошедшему времени, вплоть до полного завершения через
|
||
час.
|
||
|
||
Трата ресурса (`spendResource`) снимает пометку `Locked`, если после
|
||
списания остаток опустился ниже цели — расход реально расходует
|
||
накопленное, а не просто "ест" бесплатный излишек сверху.
|
||
|
||
### 3.9. Суточные события — вероятности
|
||
|
||
Оба события разыгрываются в `processDayEvents` (`story_game.go`),
|
||
по одному разу за каждые виртуальные сутки, обработанные в `Tick`
|
||
(если игрок не заходил несколько дней подряд, каждый день
|
||
розыгрывается отдельно, а не одним махом).
|
||
|
||
**Выброс нейротоксина** — зависит от того, сколько суток подряд
|
||
фильтр для воздуха НЕ менялся (`DaysSinceFilterUsed`,
|
||
`storyNeurotoxinProbability`):
|
||
|
||
| Суток без замены фильтра | Вероятность выброса за эти сутки |
|
||
|---|---|
|
||
| 0 (фильтр менялся сегодня) | 0% |
|
||
| 1 | 50% |
|
||
| 2 | 75% |
|
||
| 3 и более | 100% (гарантированно) |
|
||
|
||
Если выброс произошёл — токсин в воздухе растёт на случайное
|
||
значение от 10% до 70% (равномерно, `10 + rnd.Intn(61)`), с
|
||
насыщением на 100%. Счётчик дней без фильтра НЕ сбрасывается самим
|
||
фактом выброса — только реальной заменой фильтра, так что при долгом
|
||
пренебрежении выбросы могут случаться несколько дней подряд один за
|
||
другим.
|
||
|
||
**Стук в дверь** — не зависит ни от какого ресурса, только от того,
|
||
сколько суток его не было (`DaysSinceDoorKnock`,
|
||
`storyDoorKnockProbability`):
|
||
|
||
| Суток без стука | Вероятность за эти сутки |
|
||
|---|---|
|
||
| 0 | 0% |
|
||
| 1 | 10% |
|
||
| 2 | 50% |
|
||
| 3 и более | 100% |
|
||
|
||
Если событие происходит — стресс +20% (потолок 100%), счётчик
|
||
сбрасывается. Этот прирост стресса НЕ уходит сам по себе — снять его
|
||
можно только антидепрессантом.
|
||
|
||
Оба события показываются игроку только при входе через меню режимов
|
||
(`enterStoryHubFromModeSelect`) — не при обычном возврате из партии
|
||
(`enterStoryHub`), чтобы не прерывать уже идущую игровую сессию
|
||
всплывающими уведомлениями после каждой отдельной партии. Флаги
|
||
`PendingNeurotoxinAlert`/`PendingDoorKnockAlert` копятся до следующего
|
||
входа через меню и показываются по очереди, после приветствия дня.
|
||
|
||
### 3.10. Приветствие дня
|
||
|
||
Тоже только при входе через меню режимов, одно из трёх
|
||
(`storyGreetingForDays`, по числу суток с прошлого такого входа):
|
||
|
||
| Суток с прошлого входа | Приветствие |
|
||
|---|---|
|
||
| 0 (тот же день) | «Снова решил поиграть? Смотри, чтобы не началась зависимость.» |
|
||
| 1 | «Ты проснулся! Возьми свой дневной паёк.» |
|
||
| 2 и более | «Я думала с тобой что-то случилось!..» |
|
||
|
||
Плюс при каждом обычном возврате из партии (не через меню) в лагере
|
||
показывается одна случайная короткая фраза-наблюдение из пула 12
|
||
штук (`storyPostGameFlavorKeys`) — про пульс, дыхание, зрачки и т.п.,
|
||
от лица ИИ убежища.
|
||
|
||
### 3.11. Скрытая концовка
|
||
|
||
Пункт «Сбежать из бункера» появляется в конце списка игр в лагере,
|
||
когда одновременно выполнены **все три** условия
|
||
(`storyEscapeUnlocked`):
|
||
|
||
- Кусок мыла (Крестики-нолики) ≥ 50
|
||
- Верёвка (Сенет) ≥ 50
|
||
- Стресс ≥ 60%
|
||
|
||
Выбор пункта ведёт на экран уговоров остаться (`stateStoryEscapeAttempt`) с двумя вариантами: принять успокоительное
|
||
(стресс мгновенно падает до 0, без последующего часового
|
||
восстановления — в отличие от антидепрессанта) или закончить игру
|
||
досрочно (ведёт на финальный экран и обратно в меню режимов;
|
||
сохранение сторимода при этом не удаляется).
|
||
|
||
### 3.12. Сброс прогресса
|
||
|
||
Клавиша `[N]` в лагере (доступна и на этапе знакомства) запрашивает
|
||
подтверждение (`y`/`n`), и по «да» удаляет `story_save.json` и ведёт
|
||
на экран выбора сложности заново — тот же паттерн `N` + подтверждение,
|
||
что уже был в Трупных батончиках.
|