- PATTERNS: авианосец, длинная лодка, каноэ, MWSS, HWSS, Loafer, кролики, ружьё Симкина (22 → 30) - CAMPAIGN_LEVELS: ручные «Флотилия»/«Погоня»/«Кролики» после «Издалека» (100 → 103) - README: раздел «Интересные факты» и список ресурсов - tests: счётчики и проверки новых фигур/уровней синхронизированы
378 lines
106 KiB
Markdown
378 lines
106 KiB
Markdown
# AGENTS.md
|
||
|
||
Игра — один самодостаточный файл `index.html` (HTML+CSS+один `<script>`): без сборки, бандлера, фреймворка, TypeScript, зависимостей и бэкенда. Весь код в одном `<script>`, секции помечены рамками-комментариями. UI-тексты, комментарии и доки — на русском; при правках сохраняйте этот стиль. Этот файл — роль «конфига проекта» (линтеров/CI нет): здесь правила разработки, тестирования и список стабильных глобалов, на которые завязаны автотесты.
|
||
|
||
## О проекте
|
||
|
||
Web-ремейк старого прототипа `old/life` (C++/Qt4): **Conway's Game of Life с территориальной борьбой двух игроков**. Каждый игрок своим цветом расставляет живые клетки, затем поле живёт по правилам Конвея — клетки выживают/рождаются и наследуют цвет большинства живых соседей. Цель — оставить на доске больше своих живых клеток, чем у соперника.
|
||
|
||
Формат партии — **пошаговый hot-seat за одним экраном**: игроки ходят по очереди на одном устройстве, передавая его друг другу. Настраиваемый размер поля (**40/60/80/100/120/150/200/250/300**, плюс прямоугольные **60×100/100×60**), дефолт — **60×60**. Клетки всегда квадратные, поле может быть прямоугольным. Каждый игрок может ставить клетки только в своей **зоне рисования** (P1 — левая половина, P2 — правая) и ограничен **бюджетом клеток** (дефолт 100). Опция **«Замкнутая карта» (torus)** включает заворот границ; по умолчанию границы **жёсткие**. Партия заканчивается при **вымирании** (когда у игрока не осталось ни одной клетки его цвета — бюджет его уже не спасает), **ручным завершением** (кнопка «Завершить партию», с подтверждением) либо **тупиком по бюджету** (поле стабильно, а отстающему нечем ходить — см. «Геймплей и FSM»); победитель определяется по числу живых клеток своего цвета.
|
||
|
||
`old/` — референс старого C++/Qt4 прототипа, **не часть продукта**; его не трогать и не собирать (он даже в `.gitignore`).
|
||
|
||
## Архитектура и стек
|
||
|
||
- **Один файл `index.html`**: HTML + CSS + **один** `<script>`. Никаких отдельных `.js`/`.css`/ассетов — всё инлайн.
|
||
- **Vanilla JS (ES6+)**, без сборки/бандлера/транспиляции/TS/фреймворков; работает напрямую с **`file://`** (без http-сервера) и в любом современном браузере.
|
||
- **Canvas 2D** для отрисовки доски + **DOM-оверлеи/HUD** для меню, диалогов и тулбара.
|
||
- **Адаптивная раскладка управления:** в ландшафте доска занимает всю высоту и прижата влево, а управление (HUD/тулбар/действия) собирается в боковую панель справа (`body.side`, ширина `--side-w`); в портрете/узком ландшафте — прежняя центрированная раскладка (HUD сверху, панели снизу). См. «Рендер, зоны и HUD».
|
||
- **Производительный рендер** больших полей (до 300×300): клетки — через offscreen-канву `fieldW×fieldH` + `ImageData`, масштабируемую одним `drawImage`; пересборка только по dirty-флагу (см. «Рендер, зоны и HUD»).
|
||
- **Внешних ассетов нет** (без картинок/шрифтов/сети); всё рисуется кодом/эмодзи.
|
||
- **Персистентность** — `localStorage`, единственный ключ **`life-save-v1`** (настройки + резюм текущей партии, включая `reason` финала — `'extinct'`/`'manual'`/`'stalemate'`). Смена формата — только с миграцией в `loadSave()`.
|
||
- **Секции внутри `<script>`** помечены рамками-комментариями (`// ==== ДАННЫЕ ====`, `// ==== МОДЕЛЬ ПОЛЯ ====`, `// ==== FSM ====`, `// ==== ОТРИСОВКА ====`, `// ==== СОБЫТИЯ ====`, `// ==== ЦИКЛ ====` и т.п.) — навигация по одному длинному скрипту.
|
||
- **Никакого `Math.random` в игровой логике** — симуляция детерминирована (см. «Подводные камни»).
|
||
|
||
## Структура проекта
|
||
|
||
```
|
||
life/
|
||
├── index.html # вся игра: HTML + CSS + <script>
|
||
├── tests/
|
||
│ ├── package.json # зависимость puppeteer-core
|
||
│ ├── package-lock.json # лок-файл (коммитим для воспроизводимости)
|
||
│ ├── parity-fixture.json # компактная фикстура паритета (clean/proto) для суиты
|
||
│ └── test.js # полная headless-суита (Conway/FSM/скрытие/резюм/паритет/…)
|
||
├── tools/
|
||
│ ├── check.mjs # проверка синтаксиса JS из index.html
|
||
│ ├── gen-campaign-levels.mjs # генератор 96 уровней кампании (seeded PRNG, без Math.random)
|
||
│ └── old-ref/ # harness паритета с прототипом (C++ + генераторы; см. README там)
|
||
├── AGENTS.md # этот файл: правила разработки/тестирования + глобалы
|
||
├── README.md # краткая документация для игрока
|
||
├── .gitignore # old/, node_modules/, .slim/, dist/ и пр.
|
||
├── .opencode/ # агентский конфиг (в .gitignore, не часть продукта)
|
||
└── .slim/deepwork/ # локальное состояние процесса (git-ignored), в т.ч. план
|
||
```
|
||
|
||
- `old/` — референс прототипа, **не часть продукта**, не собирается и не трогается (в `.gitignore`).
|
||
- `tools/old-ref/` — независимый harness паритета: `old_ref.cpp` (порт `Life::nextStep()` + «чистое» правило + torus-эталон), `reference.json`/`reference-clean.json`/`reference-torus.json`, генераторы `gen-clean.js`/`gen-torus.js`/`gen-fixture.js`, дифтест `diff-our.js` и свой `README.md`. Бинарник `old_ref` не коммитится (собирается `g++`, см. `.gitignore`).
|
||
- `.opencode/` — агентский конфиг (в `.gitignore`, не часть продукта).
|
||
- `.slim/deepwork/` — локальное состояние процесса (git-ignored), в т.ч. план; ре-включено для поиска через `.ignore` (`!.slim/deepwork/`).
|
||
- `.slim/deepwork/life-web-remake.md` — зафиксированные решения и план фаз; читать перед началом работы.
|
||
|
||
## Правила игры
|
||
|
||
- **Автомат** — классический Conway **B3/S23**: живая клетка выживает при **2–3** живых соседях, мёртвая рождается **ровно при 3**.
|
||
- **Окрестность — Мура (8 соседей)**.
|
||
- **Границы**: по умолчанию **жёсткие** (клетки за краем поля не существуют и не считаются соседями — как в прототипе `old`); опция **«Замкнутая карта» (`wrap`/torus)** включает заворот, и соседи берутся с противоположной стороны. Дефолт `wrap=false`.
|
||
- **Цвет (владение) клетки**: цвет новорождённой = **цвет большинства из 3 живых соседей**, породивших её. При двух игроках расклад 2-1 всегда однозначен.
|
||
- **Tie-break для ≥3 игроков**: если большинство неоднозначно — выигрывает **наименьший id** цвета (при двух игроках не наблюдаем; пересмотреть при расширении).
|
||
- **Не воспроизводить баг старого прототипа**: в `old/life/life.cpp:19-50` массив `parent[]` не обнулялся на каждую клетку, из-за чего цвета «протекали» между итерациями. Мы реализуем чистое правило «большинство из 3 живых соседей»; поведение старого кода в этом месте — баг, а не спецификация.
|
||
|
||
## Геймплей и FSM
|
||
|
||
Поток партии (hot-seat):
|
||
|
||
1. **P1** расставляет свои клетки (`PLACE_P1`).
|
||
2. Передача устройства сопернику со скрытым его вводом (`HANDOFF_P1_P2`).
|
||
3. **P2** расставляет вслепую, не видя расстановку P1 (`PLACE_P2`).
|
||
4. Симуляция (`RUNNING`) → **авто-пауза при стабилизации поля** (см. ниже) → **каждый игрок по отдельности** дорисовывает свои клетки (`EDIT_P1`/`EDIT_P2`, в любом порядке, оба видят поле) → «Старт симуляции» → … → вымирание (когда цвет исчез — у игрока 0 клеток), тупик по бюджету (поле стабильно, отстающему нечем ходить) или ручное завершение (`GAME_OVER`).
|
||
|
||
Полный список состояний (`state`) и переходы:
|
||
|
||
| Состояние | Смысл | Переходы |
|
||
|---|---|---|
|
||
| `MENU` | стартовый сплэш: краткое описание игры, тема оформления, возврат к отложенной партии; вход в «Кампанию» или на экран «Игра на двоих» | `showTwoSetup()`/`#btnPlay` → `TWO_SETUP`; `resumeFromMenu()` → отложенная партия; `showLevelSelect()`/`#btnCampaign` → `LEVEL_SELECT` |
|
||
| `TWO_SETUP` | экран «Игра на двоих» (**не** резюмируется; `modeForState`→`menu`): своя справка + настройки поля (размер/torus/бюджет) | `#btnStartTwo`/`requestNewGame()` → `PLACE_P1` (подтверждение `'new'` при `suspended`); `toMenu()`/`#btnTwoBack` → `MENU` |
|
||
| `LEVEL_SELECT` | экран выбора уровня кампании (не резюмируется; `modeForState`→`menu`) | `requestCampaignLevel(i)` (подтверждение `'campaign'`) → `PLACE_P1` (кампания); `toMenu()`/`#btnLevelsBack` → `MENU` |
|
||
| `PLACE_P1` | P1 расставляет свои клетки (в кампании — игрок, `commitTurn()` сразу → `RUNNING`) | `commitTurn()` → `HANDOFF_P1_P2`; в кампании → `RUNNING` |
|
||
| `HANDOFF_P1_P2` | передача устройства P1→P2 (или назад к P1) | `handoffNext()` → `PLACE_P2`; `handoffBack()` → `PLACE_P1` |
|
||
| `PLACE_P2` | P2 расставляет вслепую | `commitTurn()` → `HANDOFF_P2_START` |
|
||
| `HANDOFF_P2_START` | расстановка P2 завершена: «Начать симуляцию» или вернуться к P1 (состояние реально существует в коде и раньше не попадало в таблицу) | `handoffNext()` → `RUNNING`; `handoffBack()` → `PLACE_P1` |
|
||
| `RUNNING` | симуляция идёт | `pauseGame()` → `PAUSED`; `checkEnd()` после каждого шага (вымирание) → `GAME_OVER`; авто-`PAUSED`+`fieldStable` при стабилизации поля (tick) и `checkStalemate()` (тупик по бюджету) → `GAME_OVER`/`stalemate`; `requestEndGame()` (подтверждение) → `GAME_OVER`/`manual`; `toMenu()` → `MENU` (партия откладывается) |
|
||
| `PAUSED` | пауза симуляции (без модального диалога); дорисовка доступна **только** при `fieldStable` | `resumeGame()` → `RUNNING`; `startEdit(1)`/`startEdit(2)` → `EDIT_P1`/`EDIT_P2` (только если `fieldStable`); `requestEndGame()` → `GAME_OVER`; `toMenu()` → `MENU` |
|
||
| `EDIT_P1` | P1 дорисовывает на паузе (видит всё) | `commitTurn()` → `PAUSED` |
|
||
| `EDIT_P2` | P2 дорисовывает (видит всё) | `commitTurn()` → `PAUSED` |
|
||
| `GAME_OVER` | итог партии, победитель | `newGame()` → `PLACE_P1`; `toMenu()` → `MENU` |
|
||
|
||
**Стабильность поля — гейт дорисовки:** дорисовка (`EDIT_*`) разрешается **только когда поле перестало меняться**. Во время `RUNNING` `tick()` после каждого шага считает `hashField()` и ищет его среди последних `STABLE_WINDOW` (1024) поколений (`recentHashes`): совпадение = поле вошло в **фиксированную точку** (период 1) или **короткий цикл** (например, блинкер, период 2). Тогда симуляция **сама** встаёт в `PAUSED` и выставляет `fieldStable = true` — кнопки дорисовки `#btnEditP1`/`#btnEditP2` становятся видны. **В кампании** (игрок один) после авто-паузы сразу вызывается `startEdit(1)` — фаза `EDIT_P1` открывается автоматически, без клика «Дорисовать» (если авто-пауза завершила партию поражением, состояние уже `GAME_OVER` и дорисовка не зовётся). Если поле не застывает (глайдеры/рост/хаос — хеш не повторяется в окне), `fieldStable` остаётся `false`: ручная пауза дорисовку **не** открывает, партию завершают вручную (либо она сама завершается вымиранием одной из сторон, см. ниже). `fieldStable` сбрасывается при любом изменении поля/запуске симуляции (`startRunning` засевает окно текущим хешем; `resumeGame`/`newGame`/`setFieldDim`/`setFieldASCII`/`setWrap`/`forceState`), а при выходе из `EDIT_*` (`startPaused`) сохраняется — второй игрок может дорисовать. Коллизия 32-битного хеша даёт ложное срабатывание с вероятностью ~2⁻³² (пренебрежимо).
|
||
|
||
**Причина финала (`reason`):** `'extinct'` — вымирание (вход в `GAME_OVER` из `checkEnd()`), `'manual'` — ручное завершение (`requestEndGame()` → подтверждение → `finish('manual')`), `'stalemate'` — тупик: симуляция сама встала на паузу (поле стабильно), а отстающему игроку нечем ходить (см. ниже), `'win'`/`'defeat'` — исход уровня **кампании** (см. «Кампания»). `reason` выставляется при входе в `GAME_OVER`, входит в форму сейва и сохраняется через `save`/`persist()`.
|
||
|
||
**Кампания.** Отдельный режим (кнопка «Кампания» в меню → состояние `LEVEL_SELECT`) с заранее расставленными стабильными фигурами **врага** (цвет 2, `CAMPAIGN_ENEMY`); игрок играет цветом 1 (`CAMPAIGN_PLAYER`), одна сторона. Враг статичен — поле живёт обычной детерминированной симуляцией. Уровень описывает `CAMPAIGN_LEVELS` (`{id,name,w,h,wrap,budget,zone:{x,y,w,h},cells:[[x,y],…]}`): `cells` — компактные координаты клеток врага (не ASCII-сетка), `buildCampaignField` строит поле прямо из них с проверкой границ. Набор — **103 уровня**: **7 заданы вручную** («Разминка», «Каравай», «Рой» — много фигур, «Издалека» — враг вне зоны игрока, включая ружьё Госпера, и showcase «Флотилия»/«Погоня»/«Кролики» с новыми фигурами), остальные **96 сгенерированы** детерминированным скриптом `tools/gen-campaign-levels.mjs` (seeded PRNG, без `Math.random`) и **вшиты в файл статически** (в рантайме ничего не генерируется; id 5..100). Размер поля у сгенерированных растёт от 60 до 300; зоны разнообразны, а часть уровней — с врагом вне зоны. У **сгенерированных** уровней зоны «всё поле» НЕТ: используются 7 типов (половина / центр / левый-верхний угол / правый-нижний угол / вертикальная полоса / горизонтальная полоса / произвольный прямоугольник), а полная зона оставлена только ручным «Разминке» и «Рою». Порядок массива = порядок в игре; `id` — метаданные (уникальны, 1..103). Ручной «Издалека» (id4) **вставлен на 10-е место** (индекс 9), сгенерированные id5..id10 сдвинуты вверх (индексы 3..8), а сразу за «Издалека» — ручные showcase id 101..103 (индексы 10..12). Размеры монотонны только у **сгенерированных** уровней (id 5..100, от 60 до 300); у ручных вставок размер свой. Решаемость уровней намеренно **не гарантируется**: среди них есть заведомо почти непроходимые (например с ружьём Госпера) — это задумка, а не дефект. Глобалы режима: `campaign` (bool), `levelIndex` (int≥0 | -1), `zoneRect` (прямоугольник зоны игрока | `null`), `campaignProgress = {unlocked}` (прогресс), `pendingLevel` (уровень в диалоге подтверждения). Особенности:
|
||
|
||
- **Зона рисования** — прямоугольник `zoneRect` (может покрывать всё поле), а не половина; предикат `inZoneAt(x,y,player)` в кампании смотрит `zoneRect`, иначе делегирует в `inZone(x,player)`. Игровые места (`paintTool`, `applyIntent`, ввод, ghost-превью, `clearZone`) ходят через `inZoneAt`; сами `inZone`/`zoneSplit` не меняются (контракт тестов).
|
||
- **Врага нельзя трогать:** в `paintTool` клетки `field[i] === CAMPAIGN_ENEMY` пропускаются во всех ветках (краска/штамп/ластик) без траты и без возврата бюджета. `applyBrush` не знает о кампании (геометрический примитив).
|
||
- **Скрытия нет:** `visibleMask()` при `campaign` возвращает `[1,2]` (враг виден всегда).
|
||
- **Своё поле/бюджет:** `startLevel(idx)` (`campaign=true`, `levelIndex=idx`, `zoneRect=campaignZoneFor(idx)`, `wrap` уровня, `buildCampaignField`, `budget.total=lv.budget`, `used=0`, `speedMult=1`, транзиентное сброшено, `beginDraw('PLACE_P1',1)`). `clearZone`/`enterPlace` не зовутся — иначе враг сотрётся из снимка начала хода.
|
||
- **Победа** — 0 клеток цвета 2: `checkEnd()` в кампании при `countByPlayer(field).p2===0` → `finish('win')` (приоритет над поражением); иначе вымирание **не** финал (0 клеток игрока — не проигрыш, пока есть бюджет).
|
||
- **Поражение** — бюджет исчерпан, а враг жив: `checkStalemate()` при `campaign && state==='PAUSED' && fieldStable && remaining(1)<=0 && p2>0` → `finish('defeat')`. Фиксируется только в ветке авто-паузы `tick()` (как `stalemate`), без собственных счётчиков шагов.
|
||
- **Победитель** в кампании — по `reason` (`computeWinner`: win→`'1'`, defeat→`'2'`). При `finish('win')` `campaignProgress.unlocked = max(unlocked, levelIndex+2)` (с клампом по длине).
|
||
- **Переходы:** `commitTurn()` в кампании: и `PLACE_P1`, и `EDIT_P1` → `startRunning()` («Готов» всегда сразу запускает симуляцию, отдельной кнопки «Старт» в цикле дорисовки нет); `startEdit(2)` — no-op; `newGame()`/`showLevelSelect()` сбрасывают `campaign`/`levelIndex`/`zoneRect`; `requestNewGame()` при `campaign && levelIndex>=0` → `startLevel(levelIndex)` («Заново» на экране финала, без подтверждения); `requestRestartLevel()` («Заново» в панели действий, доступна в любой фазе уровня) открывает подтверждение (`confirmAction='restart'`, на время диалога симуляция ставится на паузу, «Отмена» её возвращает); `confirmProceed()` ветки `'restart'` → `startLevel(levelIndex)`, `'campaign'` → `suspended=null; startLevel(pendingLevel)`; `cancelConfirm()` → `pendingLevel=null`.
|
||
- **Настройки пользователя vs активная игра (D1):** `startLevel()` применяет размер/wrap/бюджет УРОВНЯ к «активной игре» (`fieldW/fieldH/wrap/budget.total`), НЕ трогая `menuFieldW/menuFieldH/menuWrap/menuBudget`. Выход из кампании (`toMenu`/`showLevelSelect`) зовёт `applyMenuSettings()` — возвращает активные значения к пользовательским. Поэтому обычная партия после кампании стартует с прежних настроек (60×60 / 100 по умолчанию), а `#optSize`/`#optWrap`/`#optBudget` в `syncDom` всегда отражают `menu*`. `setFieldDim`/`setFieldSize` в кампании — **no-op** (иначе потерялся бы враг, D4); `setWrap`/`setBudgetTotal` обновляют и `menu*`.
|
||
- **Ручное завершение (D3):** `endGame()`/`finish('manual')` в кампании даёт `reason='manual'` и текст «Уровень прерван» (не «Поражение»); `computeWinner` для manual → `'draw'`. Кнопки `#btnEnd`/`#btnEndPause` в кампании скрыты.
|
||
- **`applyIntent` (D5):** возвращает `paintTool(...) > 0` — клик по клетке врага или по своей клетке (нет изменений) даёт `false`.
|
||
- **UI:** `#btnCampaign` (меню), `#levels` + `#levelList` (103 кнопки уровней, замок 🔒 для `i>=unlocked`; список — `max-height`~460px с `overflow-y:auto`, прокручивается, заголовок и «В меню» остаются видимы), `#aboutCampaign` (сворачиваемая справка «Как играть» на экране уровней), `#btnLevelsBack`, `#btnNextLevel` (только победа + есть следующий), `#btnGameoverLevels`; `#turnLabel` = «Уровень N/T» (+ «· поле стабильно»), `#scores` скрыт, `#budgetLabel` виден и в `RUNNING`/`PAUSED` (бюджет игрока 1), `#btnEditP2`/`#btnEnd`/`#btnEndPause` скрыты; кнопка **`#btnRestart`** («Заново») видна в любой фазе уровня (пока `campaign && levelIndex>=0`) и перезапускает уровень **через подтверждение** (`requestRestartLevel` → `confirmAction='restart'`, тогда как `#btnRevenge` на экране финала перезапускает сразу); диалог `#confirm` обслуживает `confirmAction` `'campaign'`/`'restart'`/`'new'`/`'end'`. `syncLevelList()` обновляет подписи/доступность только при `state==='LEVEL_SELECT'` (иначе 103 кнопки синхронятся вхолостую из `syncDom`).
|
||
- **Сейв:** аддитивные поля `campaign`, `levelIndex`, `progress:{unlocked}` (в `suspended` — тоже `campaign`/`levelIndex`), `SAVE_VERSION` остаётся 2; вайтлист `reason` расширен `win`/`defeat`. `LEVEL_SELECT` не резюмируется: при reload → `MENU`.
|
||
|
||
**Вымирание — после каждого такта:** `checkEnd()` вызывается из `tick()` после каждого шага симуляции и объявляет `extinct`, как только `isExtinct(field)` — т.е. у одного из игроков **0 клеток его цвета** (у обоих 0 → ничья в `computeWinner`). Бюджет финал **не** отодвигает: пока у игрока есть хоть одна клетка, он жив; как только цвет исчез — партия окончена, дорисовать (`EDIT_*`) его уже нельзя. Устойчивость поля (`fieldStable`) на финал **не** влияет — вымирание может завершить партию и на «живом» поле. Если оба цвета живы, партия сама не завершится вымиранием — её заканчивают вручную либо она завершается **тупиком по бюджету** (см. ниже).
|
||
|
||
**Тупик по бюджету — авто-финал на стабильной паузе:** сразу после того, как симуляция **сама** встала на паузу (обнаружена устойчивость), `tick()` вызывает `checkStalemate()`. Если поле стабильно и **отстающий** игрок (у кого меньше живых клеток) исчерпал бюджет (`remaining <= 0`), он не может ни поставить, ни стереть ничего (ластик снимает только правку текущего хода, а на паузе правок нет) — исход зафиксирован, партия завершается `finish('stalemate')`. При **равенстве** клеток тупик фиксируется только если бюджет исчерпан у **обоих** (иначе один из них ещё может разорвать ничью) → `winner='draw'`. Если у отстающего есть бюджет — игра продолжается (он может дорисовать). Проверка живёт **только** в ветке авто-паузы `tick()` (ручная пауза/`forceState('PAUSED')` её не запускают); расход остатка уже в фазе `EDIT_*` финал не триггерит — для срабатывания нужно снова запустить симуляцию, поле стабильно, пауза вернётся за пару тиков.
|
||
|
||
**Меню не теряет партию:** `toMenu()` (кнопки «В меню»/⚙️) вызывает `suspendGame()` — снимок текущей резюмируемой партии (размер/wrap/поле/бюджет) кладётся в глобал `suspended`, после чего состояние становится `MENU`. В меню при наличии `suspended` видна кнопка «Продолжить партию» (`resumeFromMenu()` восстанавливает партию целиком). «Игра на двоих»/«Реванш» при наличии `suspended` сначала открывают диалог подтверждения (`confirmAction='new'`; `requestNewGame()`), чтобы случайно не потерять партию; `newGame()` очищает `suspended`.
|
||
|
||
**Никакое действие не теряет партию одним кликом:** диалог подтверждения `#confirm` (`confirmAction` = `'new'` | `'end'` | `'campaign'` | `'restart'`) показывается при новой партии поверх незавершённой, при «Завершить партию» (`requestEndGame()`; `confirmAction='end'`), при выборе уровня кампании (`'campaign'`) и при перезапуске уровня «Заново» (`requestRestartLevel()`; `'restart'`). На время диалога завершения/перезапуска симуляция ставится на паузу (`pauseGame()`), а «Отмена» возвращает `RUNNING` (`cancelConfirm()`/`confirmResume`). Прямой `endGame()` (без диалога) оставлен только для тестов/`checkEnd`-пути.
|
||
|
||
**Скрытие (важнейшее правило):**
|
||
|
||
- чужое скрыто **ТОЛЬКО** в `PLACE_*` (первичная расстановка вслепую);
|
||
- в `EDIT_*` **показываем всё** (дорисовка идёт открыто, оба видят поле);
|
||
- в `HANDOFF_P1_P2` (первичная передача) маска пуста + оверлей «Передайте устройство», счёт **скрыт**; в дорисовке (`EDIT_*`) передачи нет — оба видят поле.
|
||
|
||
**Скрытие — ТОЛЬКО визуальное**: `field` всегда хранит целое поле целиком; `step` всегда считается по полному полю; **рендер не мутирует `field`**. Маска/видимость — это лишь параметр отрисовки, модель данных от неё не зависит.
|
||
|
||
## Инструменты и настройки
|
||
|
||
- **Инструменты:** карандаш **1×1**, кисть **3×3**, ластик **1×1**, ластик **4×4** (таблица `TOOLS`).
|
||
- **Правая кнопка мыши = ластик:** ПКМ (PointerEvent `button === 2`) на время удержания подменяет активный инструмент ластиком через глобал `paintOverride` (в `paintTool` и ghost-превью вместо `tool` берётся `paintOverride || tool`); `tool`/тулбар/сейв при этом не меняются. Если активен ластик, ПКМ сохраняет его размер (1×1/4×4), иначе берётся базовый 1×1 (`toolForButton`). Сбрасывается в `null` на `pointerup`/`pointercancel`. Контекстное меню подавлено глобальным `contextmenu → preventDefault()`.
|
||
- **Готовые паттерны (штампы):** таблица `PATTERNS` (`{ id, name, group, cells }`, `cells` — ASCII-строки, `'1'` = живая, `'.'` = пусто; `group` — категория для группировки дропдауна): **Стойкие** — блок, улей, ванна, лодка, кораблик, каравай, пожиратель, пруд, авианосец, длинная лодка, каноэ; **Осцилляторы** — мигалка, жаба, маяк, часы, пульсар, пентадекатлон; **Корабли** — глайдер, корабль LWSS, корабль MWSS, корабль HWSS, Loafer; **Долгожители** (метуселахи) — R-пентамино, жёлудь, Diehard (полностью гибнет на 130-м поколении), громовая птица, кролики; **Ружья** — ружьё Госпера, ружьё Симкина (36-клеточный вариант; LifeWiki даёт 29-клеточный). НЕ входят в `TOOLS`: выбираются дропдауном `#optPattern` в тулбаре (опции строит `buildPatternOptions()` из `PATTERNS` по порядку `PATTERN_GROUPS` — `<optgroup>` на категорию, пустая опция «нет паттерна» — прямым ребёнком `select`), активный живёт в глобале `stamp` (**транзиентно**, как `speedMult` — в сейв не пишется, между сессиями не переживает). **Нативный popup `<select>` подавлен** (`mousedown` → `preventDefault`), вместо него показывается свой список **`#patternMenu`** (`buildPatternMenu()`, `.patitem`/`.patgroup`, позиция в `positionPatternMenu()` рядом с селектом, но **никогда не поверх него**): на длинном списке (30 фигур) Chromium ставит нативный popup **поверх курсора**, и обычный клик выбирает случайную фигуру — дропдаун «открывается на нажатии и закрывается на отпускании». Свой список открывается/закрывается по клику на `#optPattern` (тумблер), выбор фигуры пишет `value` в `#optPattern` и шлёт `change` (единый путь установки `stamp`), закрывается по выбору/Escape/клику вне/resize/scroll; вне фаз рисования `syncDom` его закрывает. Сам `<select>` остаётся контрактом (value/change/disabled/active/`optgroup`/`page.select`). Разбор ASCII → offsets делает `patternCells(p)` (лениво кэширует `p._cells`/`_w`/`_h`), поиск по id — `findPattern(id)`. Ставится **веткой `stamp` в `paintTool`**: якорь — **левый верхний угол bbox** в кликнутой клетке; зона/бюджет/перезапись чужого — как у краски (вне `inZone` не ставится, своя не тратит, при нехватке `min(new, remaining)` в порядке клеток), плюс кламп по доске. **Одно нажатие = одна постановка** (в `onPointerDown` штамп не включает `painting`, drag-повтор не заливает бюджет); ПКМ штамп не ставит, а стирает. Ghost-превью в `draw()`: клетки в своей зоне — цветом игрока, вылет за зону — `COLORS.stampOut` (клетки за краем доски не рисуются). `selectTool()` сбрасывает `stamp` («последнее действие побеждает»); пока штамп активен, ни одна кнопка инструмента не подсвечена, а `#optPattern` — подсвечен (`.active`). На дефолтном 60×60 зона = 30 столбцов, поэтому ружьё (36×9) обрезается — целиком оно влезает при поле ≥ 80. Порядок групп/записей в дропдауне задаёт `PATTERN_GROUPS` (внутренняя константа, не на `window`).
|
||
- **Поворот штампа (колесо мыши и кнопка «↻»):** глобал `stampRot` (**0..3**, транзиентный, как `stamp`) — поворот активного паттерна на 90° по часовой за шаг. Источники: (1) обработчик `wheel` на `canvas` (`{ passive:false }`, `preventDefault`) — действует **только в фазах рисования и при выбранном `stamp`**, иначе инертен; `deltaY` накапливается в `wheelAccum` до порога `WHEEL_ROT_STEP` (~100px; `deltaMode` нормализуется к пикселям) — один поворот на «щелчок» колеса, а тачпад с мелкими частыми дельтами поворачивает контролируемо; (2) кнопка `#btnRotate` в тулбаре (для сенсорных экранов без колеса) — `stampRot = (stampRot+1)%4` за click, видна по `syncDom` только в фазах рисования при `stamp`, подпись `#rotDeg` показывает угол. `stampCells()` отдаёт offsets с учётом `stampRot` (базовый разбор кэширует `patternCells`; повёрнутый — на объекте, `p._rot1..3`) через `rotate90(cells)` (клетка `(x,y)` в bbox `w×h` → `(h-1-y,x)`, якорь остаётся левым верхним углом). `paintTool` и ghost-превью используют `stampCells()`, поэтому поворот влияет и на постановку, и на превью. `stampRot`/`wheelAccum` сбрасываются при смене паттерна (`#optPattern`) и выборе инструмента (`selectTool`). В сейв не пишется.
|
||
- **Режим «постановка по отпусканию» (тап, сенсорные экраны):** тумблер `#btnTapPlace` в тулбаре + глобал `tapPlace` (**транзиентный**, как `stamp`/`speedMult` — в сейв не пишется, но `newGame` его **не** сбрасывает: это предпочтение устройства в рамках сессии; между сессиями не переживает). При `tapPlace=true` ввод работает иначе: `onPointerDown` только фиксирует удержание (`pressing=true`, `paintOverride` под ПКМ) и показывает ghost-превью, `onPointerMove` ведёт превью (линия не рисуется, `painting` не включается), а постановка/стирание происходит **в `onPointerUp`** одним `paintTool` (в клетке под пальцем; `pointercancel` отменяет — постановки нет). Так палец не закрывает точку постановки, а drag не заливает бюджет. Кнопка подсвечена `.active` при `tapPlace`. См. `tests/test.js` 36e–36j.
|
||
- **Размер поля:** 40 / 60 / 80 / 100 / 120 / 150 / 200 / 250 / 300 (`FIELD_SIZES`, квадраты) плюс прямоугольные `FIELD_RECTS = [[60,100],[100,60]]`; полный список для меню — `FIELD_DIMS` = квадраты + прямоугольники. Дефолт **60×60** (`fieldW`/`fieldH`). Клетки квадратные (`board.cell` — сторона), сама доска может быть прямоугольной (`board.w`×`board.h`). Квадратный размер меняет `setFieldSize(n)` (валидация по `FIELD_SIZES`), произвольный W×H — `setFieldDim(w,h)`.
|
||
- **Зоны рисования:** `inZone(x, player)` — P1 ставит только в левой половине (`2*x < fieldW`), P2 — только в правой (`2*x >= fieldW`); формула целочисленна (корректна и для нечётной ширины). Зона производна от `fieldW`, в сейв не пишется. `zoneSplit()` возвращает границу (`Math.ceil(fieldW/2)`) и предикат. `inZoneAt(x,y,player)` — игровой предикат зоны: вне кампании делегирует `inZone`, в кампании проверяет прямоугольник `zoneRect` (см. «Кампания»).
|
||
- **Пользовательские настройки (D1):** `setFieldDim`/`setFieldSize` пишут и `menuFieldW/menuFieldH`, `setWrap` — `menuWrap`, `setBudgetTotal` — `menuBudget`; в кампании `setFieldDim` — no-op. `applyMenuSettings()` возвращает активную игру (`fieldW/fieldH/wrap/budget.total`) к `menu*` (вызывается `toMenu`/`showLevelSelect`).
|
||
- **Бюджет клеток:** накопительный **на всю партию**, дефолт **100** (`DEFAULT_BUDGET`), задаётся `setBudgetTotal(n)` (применяется в `newGame`). Глобал `budget = { total, used1, used2 }`, `remaining(player)` — остаток. Тратится **только на ручную постановку** клетки (перезапись чужого в своей зоне — тоже трата); своя клетка не тратит; при нехватке ставится `min(new, remaining)` в детерминированном порядке («жадно»). **Ластик возвращает** бюджет. **Симуляция бюджет не меняет** (`used` — инкрементальный счётчик, не выводится из `field`).
|
||
- **Правило «новой клетки»** (краска): ставится, если `field[i] !== player` (пусто или чужой) и `remaining > 0`; `used++`.
|
||
- **Правило ластика:** стирает **только правку текущего хода** (`field[i] === player` и `turnStartField[i] !== player`), восстанавливая `turnStartField[i]` и возвращая бюджет; клетки, существовавшие до хода, не трогаются.
|
||
- **`applyBrush`** — чисто **геометрический** примитив (кламп по полю, без зоны/бюджета). Игровое действие (фаза + зона + бюджет) — в **`paintTool(x,y)`** (единственная точка мутации `budget.used*`); его используют `applyIntent`, `onPointerDown/Move`, `paintLine`.
|
||
- **«Сброс»**: в фазах рисования — возвращает **и поле, и бюджет** к снимкам начала хода (`turnStartField`, `turnStartBudget`) (не трогает расстановку прошлых фаз, не перезапускает партию).
|
||
- **Скорость**: `STEPS_PER_SEC` настраивается, стартово **~8** (подбирается плейтестом). Во время симуляции (`RUNNING`) в панели действий есть ряд из четырёх маленьких кнопок **1×/2×/4×/8×** (`#speedRow`, кнопки `.speedbtn` с `data-speed`): клик выбирает множитель `speedMult` (активная кнопка подсвечена). Эффективная скорость — `STEPS_PER_SEC * speedMult`; множитель **транзиентный** (не в сейв, сбрасывается в `1` при `newGame`); выбор — `setSpeed(m)`.
|
||
- **Клавиатура**: **пробел** (`keydown`, `event.code==='Space'`) дублирует паузу — в `RUNNING` ставит на паузу (`pauseGame`), в `PAUSED` запускает (`resumeGame`). В прочих фазах и при открытом `confirmAction` инертен; не перехватывается при фокусе в `input/select/textarea/button/a` (там пробел делает своё дело) и при `repeat`/Ctrl/Cmd/Alt.
|
||
- **Персистентность (перф):** в `RUNNING` `turnStartField` обнуляется, а `turnStartBudget` — в ноль (`startRunning`); запись сейва в `RUNNING` троттлится по времени (`PERSIST_INTERVAL_MS`, ~3 с).
|
||
|
||
## Рендер, зоны и HUD
|
||
|
||
- **Производительный рендер клеток:** offscreen-канва `fieldCanvas` в координатах `fieldW×fieldH` (1 px = 1 клетка) + `fieldImage` (`ImageData`). `rebuildFieldImage()` делает один проход по полю и пишет RGBA цвета игрока (alpha 255) для **видимых маской** клеток, alpha 0 — для скрытых/мёртвых; `draw()` масштабирует слой одним `ctx.drawImage(fieldCanvas, board.x, board.y, board.w, board.h)` при `imageSmoothingEnabled=false` (nearest-neighbor — цвет клеток **точный**). Рендер **не мутирует `field`**.
|
||
- **Dirty-флаг `renderDirty`:** пересборка offscreen — только при изменении поля/маски/размера. Ставится в `paintTool`, `tick` (после `step`), `beginDraw`/переходах FSM (смена маски), `resetGame`, `setFieldSize`, `setFieldASCII`, `setWrap`, `applySave`, `forceState`; сбрасывается в `draw()` после `rebuildFieldImage()`. В статичных фазах `draw()` — практически только `drawImage`. `resize()` offscreen не пересобирает (он в координатах поля) — пересчитывает лишь геометрию доски. Сетка рисуется только при `board.cell >= 5` (на крупных полях не рисуется).
|
||
- **Подсветка зон рисования:** в `PLACE_*`/`EDIT_*` **своя** половина мягко тонируется цветом текущего игрока (10%, `rgba(P*_RGB,.10)`), а неактивная (чужая) — затемняется оверлеем (`COLORS.zoneDim`); оба оверлея **под** слоем клеток (точный цвет клеток не искажается) + тонкая вертикальная линия границы (`COLORS.zoneLine`, `zoneSplit().boundary`). В `RUNNING`/`PAUSED` рисуется мягкая (5%, `globalAlpha`) подсветка территорий под клетками: левая — цветом P1, правая — P2 (затемнения нет, половины равноправны); в `MENU`/финале не рисуется. Цвета затемнения/линии/ghost — из `COLORS`, зависят от темы (см. «Тема оформления»).
|
||
- **Акцент текущего игрока (UI-цвет):** в фазах рисования `syncDom()` вешает на `body` класс `turn-p1`/`turn-p2`; через CSS-переменную `--accent` им подсвечиваются: рамка боковой панели (`body.side … #ui`, `inset box-shadow` — не меняет габариты), метка фазы `#turnLabel` (`border-color`). Кнопки дорисовки `#btnEditP1`/`#btnEditP2` постоянно подкрашены цветом игрока (`.editp1`/`.editp2`). Вне фаз рисования `--accent` не задан — подсветки нет. Оверлей передачи (`#handoff.to-p2`) — цветом P2; панель финала (`#gameover.win-p1`/`win-p2`) — цветом победителя (ничья без класса).
|
||
- **HUD бюджета (`#budgetLabel`):** компактный текст «N / total» цветом текущего игрока (без префикса — чтобы плашка не переполняла HUD на узких экранах, M1); при нуле — класс `.zero` (красный). Виден **только в фазах рисования**, кроме кампании: там он виден ещё и в `RUNNING`/`PAUSED` (HUD уровня показывает остаток на протяжении всего уровня, всегда бюджет игрока 1). В `HANDOFF_*` (как счёт), `MENU`/`GAME_OVER` и в обычных `RUNNING`/`PAUSED` — скрыт (в симуляции/паузе обычной партии бюджет не расходуется). Синхронизация — в `syncDom()`.
|
||
- **UI меню:** стартовый сплэш `#menu` — короткое описание (одно предложение), селект темы `#optTheme` («Тёмная»/«Светлая») → `setTheme(value)`, `#btnContinue` («Продолжить партию», выделена акцентным `.big`, видна только в `MENU` при наличии `suspended`), `#btnCampaign` («Кампания» → `LEVEL_SELECT`) и `#btnPlay` («Игра на двоих» → `TWO_SETUP`) — оба режима в одинаковом нейтральном стиле `.act wide`; на сплэше нет свёрнутой справки-описания — оно краткое прямо в тексте. Настройки поля (размер/torus/бюджет) перенесены на экран «Игра на двоих» `#setup`: дропдаун размера `#optSize` — опции строит `buildSizeOptions()` из `FIELD_DIMS` (9 квадратов + `60×100`/`100×60`), `value` = `"WxH"`, подпись «W×H» → `setFieldDim(w,h)` по `change`; чекбокс `#optWrap` («Замкнутая карта (torus)») → `setWrap(checked)`; бюджет `#optBudget` — **комбобокс** (`<input type=number list=budgetOptions>` + `<datalist id="budgetOptions">` с пресетами 100/250/500): можно выбрать пресет или ввести любое число, `change`/`blur` → `setBudgetTotal(n)` (не-число/<1 игнорируется, значение нормализуется); `#btnStartTwo` («Начать» → `requestNewGame()`), `#btnTwoBack` («В меню» → `toMenu()`). У партии на двоих (`#aboutTwo`) и у кампании (`#aboutCampaign`) — свои сворачиваемые справки «Как играть» (`<details class="help">`). `syncDom()` отражает `fieldW×fieldH` (в `#optSize`), `wrap`, `theme` и `budget.total` в контролах, не перебивая ввод/фокус.
|
||
- **Тема оформления (`theme`):** `'dark'` (дефолт) | `'light'`. `applyTheme(name)` — **только визуал**: выставляет `data-theme` на `<html>` (CSS-переменные `:root[data-theme="light"]` переопределяют фон/панели/текст), копирует canvas-палитру из `CANVAS_THEME[theme]` в `COLORS` (`bg/grid/border/zoneDim/zoneLine/eraseGhost`) и правит `<meta name="theme-color">`. `setTheme(name)` = `applyTheme` + `syncDom()` + `persist()` (пользовательское действие). DOM-цвета — в CSS-переменных (`--bg/--fg/--line/--chip-bg/--panel-bg/--muted/...`), canvas-цвета — в `COLORS`/`CANVAS_THEME`; цвета игроков `--p1/--p2` одинаковы в обеих темах. Тема сохраняется в сейве (`theme`) и применяется в `applySave()` (нет поля → `'dark'`). Кнопки «В меню» (`.act.menu`) и «Завершить партию» (`.act.danger`) подкрашены статически (не тематические).
|
||
- **Адаптивная раскладка (`layoutSide`):** в ландшафте (`viewW > viewH`) и если сбоку реально влезает панель (≥ `SIDE_MIN = 210px` с учётом ширины доски, зависящей от пропорций поля), `computeBoard()` включает **боковую раскладку**: доска прижата влево и занимает **всю высоту** (`board.h = viewH - 2*pad`, `board.w = board.h/fieldH*fieldW`, `board.x = pad`, `board.y` по центру), а `body.side` собирает управление (`#ui`/`#uiInner`) в фиксированную зону справа шириной `--side-w` (выставляется в `computeBoard`), так что доска и панель **не пересекаются**. Иначе (портрет/узкий ландшафт) — прежняя центрированная раскладка: `#ui`/`#uiInner` — `display:contents`, HUD сверху, тулбар/действия снизу. Обёртки `#ui`/`#uiInner` — только для раскладки; `syncDom()` по-прежнему ходит к `#hud`/`#toolbar`/`#actions` по id.
|
||
- **Пауза — без модального оверлея:** модальный `#pause` удалён; управление паузой живёт инлайн в `#actions` (нижняя панель или боковая зона). Кнопки переключаются по `state` в `syncDom()`: в `RUNNING` видны «Пауза» (`#btnPause`) и «Завершить партию» (`#btnEnd`); в `PAUSED` — «Старт симуляции» (`#btnResume`), «Дорисовать красным» (`#btnEditP1`), «Дорисовать зелёным» (`#btnEditP2`), «Завершить партию» (`#btnEndPause`) и «В меню» (`#btnPauseMenu`). Кнопки дорисовки видны **только при `PAUSED && fieldStable`** (метка фазы получает суффикс «· поле стабильно»); на «живом» поле (ручная пауза, до стабилизации) они скрыты. Отдельные кнопки дорисовки дают независимый выбор игрока (`startEdit(1)`/`startEdit(2)`) без форс-цепочки P1→P2: `commitTurn()` из `EDIT_*` возвращает в `PAUSED`, а симуляцию запускает отдельная кнопка. Кнопки в нижней панели переносятся (`flex-wrap`), а в боковой зоне идут колонкой на всю ширину. Кнопки «Завершить партию» (`#btnEnd`/`#btnEndPause`) вызывают `requestEndGame()` — партия не завершается без подтверждения. Диалог подтверждения `#confirm` (`#btnConfirmOk`/`#btnConfirmCancel`) обслуживает оба случая: новую партию (`confirmAction='new'`) и завершение (`'end'`).
|
||
|
||
## Сохранение и резюм (localStorage `life-save-v1`)
|
||
|
||
Единственный ключ — **`SAVE_KEY = 'life-save-v1'`** (имя **историческое**, не менять), значение — JSON-объект `save` (шаблон `freshSave()`). **Форма сейва v2** (`v` — версия формата, текущая `SAVE_VERSION = 2`):
|
||
|
||
| Поле | Смысл |
|
||
|---|---|
|
||
| `v` | версия формата (текущая `SAVE_VERSION = 2`; для миграций) |
|
||
| `fieldW` / `fieldH` | размер **активной игры** (для резюма; в кампании — размер уровня) |
|
||
| `wrap` | «Замкнутая карта» активной игры |
|
||
| `menuFieldW` / `menuFieldH` | пользовательский размер поля из меню (настройка) |
|
||
| `menuWrap` | пользовательская «Замкнутая карта» (настройка) |
|
||
| `menuBudget` | пользовательский лимит бюджета (настройка) |
|
||
| `theme` | тема оформления `'dark'` \| `'light'` (настройка; дефолт `'dark'`) |
|
||
| `tool` | id активного инструмента (настройка; `pencil`/`brush`/`erase1`/`erase4`) |
|
||
| `stepsPerSec` | скорость симуляции (настройка) |
|
||
| `budget` | `{ total, used1, used2, start1, start2 }` — лимит и израсходовано по игрокам; `startN` — снимок начала хода |
|
||
| `state` / `turn` | состояние FSM и чей ход (резюм партии) |
|
||
| `reason` | причина финала: `'extinct'` \| `'manual'` \| `'stalemate'` \| `'win'` \| `'defeat'` \| `null` |
|
||
| `winner` | `'1'` \| `'2'` \| `'draw'` \| `null` |
|
||
| `field` | **поле целиком** в base64 (1 байт/клетка) — только при резюме, иначе `null` |
|
||
| `turnStartField` | снимок поля на начало хода для «Сброса» (base64 \| `null`) |
|
||
| `stable` | признак устойчивости поля на паузе (`fieldStable`; для гейта дорисовки после reload) — `true` \| `false` |
|
||
| `campaign` | идёт ли уровень кампании (аддитивно; нет поля → `false`) |
|
||
| `levelIndex` | индекс уровня кампании `0..N-1` или `null` (аддитивно; валидный int или `null`) |
|
||
| `progress` | прогресс кампании `{ unlocked }` — число открытых уровней; `normalizeProgress()` клампит в `[1, N]` (нет поля → `{unlocked:1}`) |
|
||
| `suspended` | отложенная в меню партия `{ state, turn, fieldW, fieldH, wrap, field, turnStartField, stable, budget:{total,used1,used2,start1,start2}, campaign, levelIndex }` (base64-поля) или `null` — для кнопки «Продолжить партию» |
|
||
|
||
**Разделение «настройки меню» / «активная игра» (D1):** `fieldW/fieldH/wrap/budget.total` описывают активную игру (её резюмит `field`), а `menuFieldW/menuFieldH/menuWrap/menuBudget` — пользовательские настройки. В обычном потоке они совпадают; кампания применяет к активной игре размер/wrap/бюджет **уровня**, не трогая `menu*` (`startLevel`). Выход из кампании (`toMenu`/`showLevelSelect`) и `#optSize`/`#optWrap`/`#optBudget` в `syncDom` работают с `menu*`, поэтому обычная партия после кампании стартует с прежними настройками пользователя. Старые сейвы без `menu*` читаются как «menu = активным».
|
||
|
||
Зона рисования в сейв **не пишется** (производна от `fieldW`; в кампании восстанавливается из `levelIndex` + конфига уровня). Поля `suspended`, `theme`, `stable`, `campaign`, `levelIndex`, `progress`, `menuFieldW/menuFieldH/menuWrap/menuBudget` — **аддитивные** расширения формата v2 (не ломают старые сейвы: нет поля → `null`/`'dark'`/`false`/`false`/`null`/`{unlocked:1}`; старый код просто игнорирует лишний ключ). Отложенная партия хранит свой размер/wrap/**budget.total** (D2) — в меню настройки могли измениться, но `resumeFromMenu()` восстанавливает снимок целиком (включая режим кампании и `zoneRect`).
|
||
|
||
- **Резюмируемые состояния** (`isResumable`): `PLACE_P1/PLACE_P2/EDIT_P1/EDIT_P2/RUNNING/PAUSED/HANDOFF_P1_P2`. Для них `field` пишется **целиком** (скрытие — только визуальное); в кампании дополнительно пишутся `campaign`/`levelIndex`, и при восстановлении `zoneRect` берётся из конфига уровня. `GAME_OVER`/`MENU`/`LEVEL_SELECT` — это настройки + чистый старт в `MENU` (выбор уровня не резюмируется); при этом в `MENU` отложенная партия/уровень живёт в `suspended`.
|
||
- **Когда пишем** (`persist()`): переходы FSM (`newGame/commitTurn/handoffNext/pauseGame/resumeGame/startEdit/finish/resetGame`), смена инструмента (`selectTool`), размера (`setFieldSize`), опции `wrap` (`setWrap`), темы (`setTheme`), бюджета (`setBudgetTotal`), выход в меню (`toMenu`); а во время `RUNNING` — **троттлинг по времени** (`PERSIST_INTERVAL_MS`, ~раз в 3 с в `tick()`), не каждый шаг. В `RUNNING` `turnStartField` обнуляется (`startRunning`), а `turnStartBudget` — в `{used1:0,used2:0}` (симметрично, чтобы в сейве не было иллюзии снимка хода).
|
||
- **Авто-пауза при уходе со вкладки** (`visibilitychange`) меняет `state` в памяти, но **не** вызывает `persist()` — чтобы скрытие вкладки на миг не перезаписывало `RUNNING` на `PAUSED` и партия корректно резюмилась.
|
||
- **Чтение** (`loadSave()`): нет данных / битый JSON / не объект / `v > SAVE_VERSION` → `null` (старт в `MENU`, без падений). Сейв без `v` трактуется как v1; поля нормализуются (в т.ч. `suspended` через `normalizeSuspended()` — битый/невалидный снимок даёт `null`, `stable` → `false`). `applySave(s)` при старте применяет настройки (`wrap`, `budget`, `rebuildNeighborTables()`), восстанавливает отложенную партию (`suspended`) и, если `s.state` резюмируемое и поле валидной длины, восстанавливает партию целиком (в `RUNNING` окно устойчивости засевается заново, в `PAUSED`/`EDIT_*` берётся `stable` из сейва).
|
||
- **Миграции**: при смене формата добавить шаг в `loadSave()` рядом с образцом-комментарием (`// M1: …`), подняв `SAVE_VERSION`; старые сейвы обязаны оставаться читаемыми. **M1 (v1→v2)**: `wrap = false`, свежий `budget = { total: DEFAULT_BUDGET, used1: 0, used2: 0, start1: 0, start2: 0 }`.
|
||
- **Валидация размера из меню** — квадраты по `FIELD_SIZES` (`setFieldSize` игнорирует чужие значения), прямоугольники — через `setFieldDim(w,h)` (принимает любой корректный `isValidSize`); при загрузке сейва принимается любой корректный размер (тестовые поля могут быть не из списка). `mode` — производный описатель фазы из `state` (`modeForState()`), используется в игровом цикле (`frame`).
|
||
|
||
## Соглашения о коммитах
|
||
|
||
- Сообщение коммита — краткое и по делу: тема одной строкой, при необходимости 2–4 пункта тела.
|
||
- Без лишних трейлеров и служебных подписей (`Co-authored-by` и т.п.) — только смысл изменения.
|
||
- Язык — русский, в стиле существующей истории коммитов (`git log --oneline`).
|
||
|
||
## Проверка
|
||
|
||
Линтеров/typecheck/CI нет — два рубежа:
|
||
|
||
1. **Синтаксис:** `node tools/check.mjs` — вынимает JS-код `<script>` из `index.html` и прогоняет `node --check` (при ошибке показывает номер строки `index.html`). Плюс `tools/check.mjs` печатает `check: OK — …`.
|
||
2. **Главный рубеж — headless-суита** (запускать после правок `index.html` или `tests/test.js`):
|
||
```bash
|
||
cd tests
|
||
npm install # puppeteer-core
|
||
npm test # = node test.js
|
||
```
|
||
Ждать **все PASS и exit 0** (финальный вывод `===== ИТОГ: N/N =====`).
|
||
3. **Паритет с прототипом** (harness — `tools/old-ref/`, не обязателен для каждой правки, но после изменений ядра полезен):
|
||
```bash
|
||
g++ -std=c++17 -O2 -o tools/old-ref/old_ref tools/old-ref/old_ref.cpp
|
||
node tools/old-ref/diff-our.js # exit 0 ⇔ наш step == clean (273/273)
|
||
```
|
||
Подробности и пересборка фикстуры — в `tools/old-ref/README.md`.
|
||
|
||
## Тестирование
|
||
|
||
**Инфраструктура.** `tests/test.js` — headless-суита на **`puppeteer-core`** со своим мини-фреймворком:
|
||
|
||
- `ok(name, cond, extra)` — записывает/печатает `PASS|FAIL`;
|
||
- `waitState(page, st, timeout)` — ждёт `state === st` (поллинг 40 мс);
|
||
- `waitCond(page, expr, timeout)` — ждёт произвольное выражение в странице;
|
||
- `sleep(ms)` — пауза.
|
||
|
||
Параметры запуска:
|
||
|
||
- Chromium из **`process.env.CHROMIUM || '/usr/bin/chromium-browser'`** (при падении на старте — поправьте путь под свой чекаут);
|
||
- **viewport 390×844 @2** (`defaultViewport`), запуск с `--no-sandbox --disable-gpu --hide-scrollbars --mute-audio`;
|
||
- URL игры резолвится от каталога репо (`file://…/index.html`);
|
||
- в конце — `console.log('===== ИТОГ: N/N =====')` и `process.exit(failed ? 1 : 0)`;
|
||
- суита подписывается на `page.on('pageerror')` и `page.on('console')` и **проверяет отсутствие `pageerror`/`console.error`** (молчаливые ошибки — это провал).
|
||
|
||
**Тест-форсы** (часть архитектуры, НЕ тестовый код): `forceState(name)`, `setFieldASCII(str,w)`, `stepN(n)`, `getScores()`, `hashField()` — выставляются на `window`, чтобы `page.evaluate` мог их дёргать. Тесты ходят к внутренним глобалам напрямую, поэтому **имена глобалов — стабильный публичный контракт** (см. раздел «Стабильные глобалы»).
|
||
|
||
**Что обязательно покрыть:**
|
||
|
||
- **Правила Conway:**
|
||
- блок 2×2 неподвижен;
|
||
- блинкер — период 2;
|
||
- глайдер за 4 шага сдвигается на (1,1);
|
||
- рождение при 3 соседях; смерть при <2 и >3; выживание при 2 и 3.
|
||
- **Наследование цвета:** `1,1,2 → 1`; `1,2,2 → 2`; tie-break `1,2,3 → 1` (наименьший id). При двух игроках `2-1` однозначно.
|
||
- **Границы:** `wrap=false` — соседи за краем не считаются (как раньше); `wrap=true` — заворот (сосед с противоположной стороны), рождение через шов. Паритет `step` с эталоном закреплён при `wrap=false` (273/273) и для torus (`reference-torus.json`, 273/273). Переключение глобала `setWrap(b)` влияет на `step`/`stepN` без явного параметра и **не мутирует `field`**.
|
||
- **Чистота `step`:** `step` не мутирует `src` (hash до/после совпадает) — в т.ч. при `wrap`; `stepN(n)` детерминирован (два прогона → одинаковый hash).
|
||
- **Инструменты:** карандаш = 1 клетка, кисть = 9, ластики 1×1 и 4×4, кламп у границ (инструмент не выходит за поле); ПКМ (`page.mouse.down/up({button:'right'})`) стирает клетку, не меняя активный инструмент (`paintOverride`).
|
||
- **Паттерны (штампы):** `patternCells` (число клеток/размер bbox для всех `PATTERNS`, включая новые фигуры); постановка через `paintTool` (все клетки, якорь левый-верхний, бюджет −N), свои клетки не тратят; обрезка по зоне и кламп по доске, «Сброс» возвращает поле+бюджет; корректность фигур на симуляции (глайдер (1,1) за 4 шага, блок неподвижен, мигалка период 2, пульсар период 3, ружьё растит население; **новые:** стойкие ванна/лодка/кораблик/каравай/пожиратель/пруд/авианосец/длинная лодка/каноэ неподвижны, маяк/часы период 2, пентадекатлон период 15, жёлудь/громовая птица/кролики растят население, Diehard полностью гибнет за 130 шагов, MWSS/HWSS период 4 со смещением 2, Loafer период 7 со смещением 1, ружьё Симкина растит население); дропдаун `#optPattern` (выбор → `stamp`, подсветка `.active`, `selectTool` сбрасывает, disabled вне рисования; **группировка** — 5 `optgroup` по `PATTERN_GROUPS`, пустая опция прямым ребёнком `select`, все id на месте); **свой список `#patternMenu`** (клик по `#optPattern` открывает/закрывает, 30 фигур + 5 заголовков групп, выбор фигуры задаёт `stamp`/`.active` и закрывает список, Escape/клик вне закрывают, `stamp` при этом не сбрасывается); одно нажатие = одна постановка (drag не повторяет), ПКМ стирает; транзиентность (`stamp` не переживает reload).
|
||
- **Поворот штампа:** `stampCells` при `stampRot` 0..3 (у симметричного bbox размер сохраняется, у несимметричного w/h меняются местами: мигалка 3×1→1×3), 4 поворота = исходная форма; `wheel` на `canvas` (вниз +1, вверх −1) действует только в фазах рисования при активном `stamp`, иначе инертен; кнопка `#btnRotate` (клик +90°, 4 клика → 0, подпись угла в `#rotDeg`, скрыта без штампа/вне рисования); постановка `paintTool` использует `stampCells` (вертикальная мигалка); `stampRot` сбрасывается при смене паттерна/инструмента.
|
||
- **Режим «постановка по отпусканию»:** тумблер `#btnTapPlace` (клик → `tapPlace`, подсветка `.active`); при `tapPlace=true` `mouse.down`/`mouse.move` (в т.ч. drag) не меняют поле и бюджет, `mouse.up` ставит ровно одну клетку под пальцем; со штампом фигура ставится целиком по отпусканию; `pointercancel` отменяет постановку; ПКМ стирает по отпусканию, не меняя инструмент; `tapPlace` транзиентен (не переживает reload).
|
||
- **Зоны:** `inZone` (P1 лево / P2 право), корректность для чётной и нечётной ширины (полное покрытие без пересечений); `applyIntent`/`paintTool` вне своей зоны/доски не действуют (поле не меняется); кисть обрезается по границе зоны; drag из своей половины в чужую не закрашивает чужую.
|
||
- **Бюджет:** трата на новую/чужую клетку (карандаш +1, кисть 3×3 на пустом +9), своя не тратит; перезапись чужого — трата +1; частичная нехватка — `min(new, remaining)`; исчерпание — новые не ставятся, ластик работает; ластик возвращает только правку текущего хода (и восстанавливает перезаписанный чужой цвет); «Сброс» восстанавливает бюджет к `turnStartBudget`; `setBudgetTotal` применяется в `newGame`; бюджет переживает reload.
|
||
- **Размеры:** `FIELD_SIZES` = 40…300 (9 значений) плюс прямоугольные `FIELD_RECTS` (60×100/100×60); дефолт 60; `setFieldSize` применяет значения из `FIELD_SIZES` и игнорирует чужие, `setFieldDim(w,h)` — произвольный W×H; 300×300 (90000 клеток) рендерится/резюмится без ошибок; прямоугольное поле (60×100) рендерится/резюмится, зона делится по ширине, клетки квадратные.
|
||
- **Кампания:** схема `CAMPAIGN_LEVELS` (103 уровня: 7 ручных + 96 сгенерированных, `cells` — непустой массив координат в границах без дублей, id уникальны и покрывают 1..103, зона внутри поля, свой бюджет; присутствуют все фигуры улей/мигалка/жаба/каравай/пульсар/ружьё, есть уровни с врагом вне зоны; «Издалека» на 10-м месте 60×60/зона {0,0,14,20}/бюджет 60; showcase id 101..103 на индексах 10..12; порядок вставки (id5..id10 на индексах 3..8, id4 на индексе 9); размеры сгенерированных (id 5..100) не убывают, последний == 300; у сгенерированных (id 5..100) нет зоны «всё поле», полная зона только у ручных «Разминка»/«Рой», среди сгенерированных присутствуют все 7 типов зон; детерминизм и `--check` генератора `tools/gen-campaign-levels.mjs`, сверка вывода с вшитым блоком id 5..100); `startLevel` (state/turn/режим/уровень/зона/бюджет/враг); видимость врага (`visibleMask`=[1,2] и пиксельно); `inZoneAt` (делегирование `inZone` вне кампании + прямоугольник `zoneRect`, регресс `zoneRect=null`); запрет трогать врага краской/штампом/ластиком (поле и бюджет неизменны); своя клетка ставится и тратит бюджет; победа (0 врага → `win`/`winner='1'`/`unlocked++`); вымирание игрока — не финал; поражение (`remaining(1)<=0` + враг жив на `PAUSED`/`fieldStable` → `defeat`/`winner='2'` через `checkStalemate` и через авто-паузу `tick`; негативные случаи); прогресс/разблокировка (закрытый уровень не выбирается); сейв/резюм уровня после reload; suspended кампании (+ reload); HUD («Уровень N/103», остаток бюджета, скрытый счёт); проводка UI (`#btnCampaign`/`#btnLevelsBack`/`#btnNextLevel`/`#btnRevenge`/`#btnGameoverLevels`/`#btnRestart`; список `#levelList` из `CAMPAIGN_LEVELS.length` кнопок прокручивается, `#btnLevelsBack` виден; `#btnRestart` видна в любой фазе уровня и перезапускает его через подтверждение `confirmAction='restart'`); авто-пауза кампании сразу открывает `EDIT_P1` (без клика «Дорисовать»); нормализация `reason` win/defeat и `progress`, reload в `LEVEL_SELECT`→`MENU`.
|
||
- **Скрытие:** в `PLACE_P1` рендер **не рисует** клетки цвета 2, но `field`/`hash` неизменны; смена маски **не мутирует `field`**.
|
||
- **Рендер (offscreen):** pixel-тесты скрытия проходят после перехода на `drawImage` (точный цвет клеток: 0 «зелёных» пикселей в `PLACE_P1`, >0 в `PLACE_P2`); `draw()` не мутирует `field`; dirty-флаг ставится на мутациях и сбрасывается в `draw()` (в т.ч. `stepN`); крупное поле (300×300) рисуется без `pageerror`; подсветка зон **не искажает** точный цвет живых клеток (пиксель центра клетки = цвет игрока, и в активной, и в затемнённой половине); 10%-тинт своей половины окрашивает пустую клетку в оттенок игрока (доминирует его канал); классы акцента `body.turn-p1`/`turn-p2` есть только в фазах рисования, `#handoff.to-p2` — в `HANDOFF_P1_P2`, `#gameover.win-p1`/`win-p2` — по победителю.
|
||
- **UI меню:** стартовый сплэш `#menu` (краткое описание + `#optTheme` + `#btnContinue`/`#btnCampaign`/`#btnPlay`) и экран `#setup` («Игра на двоих»): дропдаун размеров `#optSize` (квадраты 40…300 + прямоугольные 60×100/100×60, `value="WxH"`), чекбокс `#optWrap` → `wrap`, селект темы `#optTheme` (Тёмная/Светлая) → `theme`, комбобокс `#optBudget` (ввод + пресеты `#budgetOptions`) → `budget.total`; `#btnStartTwo`/`#btnTwoBack`; сворачиваемые справки `#aboutTwo`/`#aboutCampaign`; `syncDom()` отражает настройки.
|
||
- **FSM:** все переходы из таблицы (включая `HANDOFF_P1_P2` и независимый цикл `EDIT_*` → `PAUSED`: дорисовка любого игрока и отдельный «Старт симуляции»).
|
||
- **Стартовые экраны:** в `MENU` виден сплэш `#menu` (тема и режимы), `#setup` скрыт, настройки поля лежат в `#setup`; `#btnPlay` → `TWO_SETUP` (`#setup` виден, `#menu` скрыт), `#btnTwoBack` → `MENU`; сворачиваемые справки `#aboutTwo`/`#aboutCampaign` присутствуют; `TWO_SETUP` не резюмируется (reload → `MENU`); «Игра на двоих» при `suspended` открывает `#confirm` (`'new'`).
|
||
- **Стабильность/дорисовка:** блок 2×2 (фиксированная точка) и блинкер (период 2) в `RUNNING` → авто-`PAUSED` + `fieldStable=true`, кнопки дорисовки видны; в **кампании** авто-пауза сразу открывает `EDIT_P1` (без клика «Дорисовать»), а «Готов» из неё сразу запускает симуляцию (цикл «нарисовал → побежало»); глайдер (поле не застывает) держит `RUNNING`, `fieldStable=false`, ручная пауза дорисовку не открывает (`startEdit` — no-op); `resumeGame` сбрасывает `fieldStable`; `fieldStable` переживает reload в `PAUSED` и откладывается в `suspended`.
|
||
- **«Сброс»:** восстанавливает поле и бюджет к началу текущего хода.
|
||
- **Финал:** вымирание (`reason='extinct'`) → победа; ничья; ручное завершение (`reason='manual'`); вымирание объявляется после такта, как только у стороны **0 клеток** (бюджет его не отодвигает); реванш/сброс.
|
||
- **Тупик по бюджету (`reason='stalemate'`):** стабильное поле (авто-пауза) + отстающий без бюджета → авто-`GAME_OVER`/`stalemate` с победой лидера; ничья при равном счёте завершается, только если бюджет исчерпан у обоих (иначе игра продолжается); при бюджете у отстающего авто-финала нет; текст финала объясняет кончившийся бюджет; `reason='stalemate'` переживает нормализацию сейва.
|
||
- **Меню не теряет партию:** `toMenu()` откладывает партию в `suspended`, кнопка `#btnContinue` видна только при наличии `suspended`; `resumeFromMenu()` возвращает ту же партию (state/turn/поле/бюджет/размер); «Игра на двоих»/«Реванш» при `suspended` открывают `#confirm` (отмена сохраняет партию, подтверждение запускает новую); `suspended` переживает reload.
|
||
- **Подтверждение «Завершить партию»:** клик `#btnEnd`/`#btnEndPause` не завершает партию, а открывает `#confirm` (`confirmAction='end'`) и ставит симуляцию на паузу (поле за оверлеем не меняется); «Отмена» возвращает `RUNNING` (если шла), подтверждение → `GAME_OVER`/`manual`.
|
||
- **Резюм:** продолжение партии после reload (`localStorage`): `state`/`turn`/`field`/счёт восстанавливаются; сценарии в `PLACE_P2` и в `RUNNING`; партия остаётся играбельной; «Сброс» использует сохранённый снимок начала хода.
|
||
- **Сейв v2:** `freshSave()` даёт `v===2` и новые поля (wrap/бюджет); v1-сейв мигрируется (`wrap=false`, дефолтный бюджет) и **резюмится**; `v > SAVE_VERSION` → `null`.
|
||
- **Миграция/битый сейв:** мусор/массив/старый формат без `v` → `loadSave()` не падает, игра стартует в `MENU`; `v > SAVE_VERSION` → `null`; миграция v1→v2 даёт `wrap=false` и свежий бюджет.
|
||
- **Настройки:** размер из меню применяется к новой партии; `tool`, `STEPS_PER_SEC`, `wrap`, `theme` и `budget.total` переживают reload.
|
||
- **Тема:** дефолт `dark`; `setTheme('light')`/`#optTheme` меняет `data-theme`, `COLORS.bg`, `<meta name="theme-color">` и `save.theme`, переживает reload; смена темы в меню не теряет отложенную партию (`suspended`).
|
||
- **Проводка DOM / пауза инлайн:** реальные `page.click` по `#btnPlay/#btnStartTwo/#btnTwoBack/#btnReady/#btnHandoff/#btnHandoffBack/#btnPause/#speed1/#speed2/#speed4/#speed8/#btnResume/#btnEditP1/#btnEditP2/#btnEnd/#btnEndPause/#btnRevenge/#btnRestart/#btnSettings/#btnPauseMenu/#btnGameoverMenu/#btnContinue/#btnConfirmOk/#btnConfirmCancel` и кнопкам инструментов (размер — `page.select('#optSize', …)`); Pointer Events через `page.mouse`. Модальный `#pause` отсутствует в разметке, кнопки показываются по `state` (`RUNNING` — «Пауза»+«Завершить»+ряд скорости; `PAUSED` — «Старт симуляции»/«Дорисовать красным»/«Дорисовать зелёным»/«Завершить»/«В меню»); кнопки дорисовки видны только в `PAUSED` (и только при `fieldStable`). Обычная партия: `#btnPlay` → `TWO_SETUP`, `#btnStartTwo` → старт, `#btnTwoBack` → `MENU`. Кампания: `#btnCampaign` → `LEVEL_SELECT`, `#levelList` (заблокированные `button:disabled`), `#btnLevelsBack`, `#btnNextLevel`, `#btnGameoverLevels`, `#btnRevenge` («Заново», без подтверждения), `#btnRestart` («Заново» в панели действий, перезапуск через подтверждение), диалог `#confirm` (`confirmAction` `'campaign'`/`'restart'`).
|
||
- **Скорость (`#speedRow`):** ряд из 4 кнопок `1×/2×/4×/8×` виден только в `RUNNING`, скрыт в `PAUSED`; клик по кнопке задаёт `speedMult` (1/2/4/8), активная кнопка — класс `.active` (по умолчанию 1×).
|
||
- **Клавиатура (пробел):** `keydown` пробела (`event.code==='Space'`) в `RUNNING` ставит паузу, в `PAUSED` — старт симуляции (то же, что `#btnPause`/`#btnResume`); вне этих состояний, при `repeat`/модификаторах, при открытом `confirmAction` и при фокусе в `input/select/textarea/button/a` — инертен. Проверяется реальным `page.keyboard.press('Space')` с предварительным `blur()`.
|
||
- **Раскладка:** в ландшафте включена боковая панель (доска во всю высоту слева, управление справа, без пересечения); в портрете и узком ландшафте — центрированная раскладка.
|
||
- **`applyIntent`:** красит/стирает в фазе рисования; вне фаз и вне доски — не действует; возвращает `true` только при фактическом изменении (клик по врагу/своей клетке → `false`, D5).
|
||
- **Дефекты ревью (D1–D5):** настройки пользователя (`menu*`) переживают кампанию и стартуют обычную партию с прежними значениями (D1); `suspended` хранит `budget.total` и возвращает его (D2); ручное завершение уровня → «Уровень прерван» (D3); `setFieldSize`/`setFieldDim` в кампании — no-op (D4); победа кампании проверяется и честным tick-прогоном (враг гибнет под симуляцией).
|
||
- **Resize:** canvas пересчитывается под 2–3 viewport и DPR (кап 2), без `pageerror`.
|
||
|
||
## Подводные камни
|
||
|
||
- **Глобалы — контракт.** Переименование любого имени из раздела «Стабильные глобалы» (или имени состояния) ломает тесты **молча**. Держать имена, либо синхронно править `tests/test.js` в том же коммите.
|
||
- **Ввод — только Pointer Events** (`pointerdown`/`pointermove`/`pointerup`), НЕ touch/mouse-обработчики: так тест управляет игрой мышью через `page.mouse`. Не заменять на тач.
|
||
- **CSS**: `touch-action: none` на игровых поверхностях и `contextmenu` → `preventDefault()` (не всплывает меню на тач-устройствах / правом клике).
|
||
- **Рендер не мутирует `field`.** Отрисовка и логика разделены; скрытие реализуется параметром рендера, а не правкой модели.
|
||
- **Симуляция детерминирована**: никакого `Math.random` в игровой логике; фиксированный шаг через аккумулятор (`STEPS_PER_SEC`), клампить `dt` как в `../fishing-game` (чтобы переживать просадки fps).
|
||
- **Границы по умолчанию жёсткие** (как в прототипе); torus — только явная опция `wrap`. При `wrap=false` семантика соседей **не должна меняться** — иначе ломается паритет `step` с эталоном (273/273).
|
||
- **Правило рождения — большинство из 3**, а не баг прототипа (`old/life/life.cpp:19-50`, необнуляемый `parent[]`). Не восстанавливать.
|
||
- **`applyBrush` — геометрический** (без зоны и бюджета); зона и бюджет живут ТОЛЬКО в `paintTool`. Не тащить игровые правила в `applyBrush` (на нём держатся тесты инструментов).
|
||
- **`budget.used` — инкрементальный** счётчик: симуляция меняет `field`, но бюджет не трогает; `used` нельзя выводить из `field`. Мутируется только в `paintTool` (и сбрасывается/восстанавливается в `newGame`/`resetGame`/`applySave`).
|
||
- **Имена состояний — строго** из списка FSM (`MENU, TWO_SETUP, LEVEL_SELECT, PLACE_P1, HANDOFF_P1_P2, PLACE_P2, HANDOFF_P2_START, RUNNING, PAUSED, EDIT_P1, EDIT_P2, GAME_OVER`): тесты сравнивают `state` строками.
|
||
- **Сейв** — ключ localStorage **`life-save-v1`** (имя историческое); текущая версия формата **`SAVE_VERSION = 2`**; форма включает `wrap`, `budget`, `reason` финала, `stable` и отложенную партию `suspended`; при смене формата добавлять миграцию в `loadSave()` (старые сейвы обязаны оставаться читаемыми).
|
||
- **Резюм**: после reload партия должна продолжаться с сохранённого состояния (не терять `field`/счётчики хода).
|
||
- **Дорисовка — только на стабильном поле.** `startEdit()` — no-op, если `!fieldStable` (на «живом» поле/ручной паузе кнопки скрыты). Признак `fieldStable` ставится ТОЛЬКО в `tick()` (обнаружение повтора хеша), сбрасывается при любом изменении поля/семантики (`startRunning`/`seedStability`, `newGame`, `setFieldDim`, `setFieldASCII`, `setWrap`, `resumeFromMenu`, `applySave`) и сохраняется при выходе из `EDIT_*`. Тест-форс `forceState('PAUSED')` выставляет `fieldStable = true` (тесты переходов не гоняют реальную симуляцию) — не полагаться на это в игровой логике.
|
||
- **`fieldStable` — производное, не в логике `step`.** Устойчивость определяется по `hashField()` (FNV-1a) в окне `STABLE_WINDOW`; коллизия 32-битного хеша даёт ложное срабатывание ~2⁻³². Не тащить детекцию в `step` (чистота/паритет ядра) и не делать `Math.random`.
|
||
|
||
## Где крутить
|
||
|
||
- `COLORS` — палитра игроков/фона/сетки (canvas); структурные цвета UI — CSS-переменные, палитры тем — `CANVAS_THEME`.
|
||
- `TOOLS` — состав и размеры инструментов (карандаш/кисть/ластики); `PATTERNS` — набор готовых паттернов-штампов (`{ id, name, group, cells }`, ASCII-формы `cells`, якорь — левый верхний угол; выбираются `#optPattern`, активный — транзиентный `stamp`); `PATTERN_GROUPS` — порядок групп дропдауна (Стойкие/Осцилляторы/Корабли/Долгожители/Ружья). `stampRot` — поворот штампа `0..3` (колесо мыши или кнопка «↻» `#btnRotate`), `rotate90`/`stampCells` — его реализация; `tapPlace` — режим «постановка по отпусканию» (кнопка «👆 тап» `#btnTapPlace`).
|
||
- `STEPS_PER_SEC` — скорость симуляции (стартово ~8); `speedMult` — её транзиентный множитель 1/2/4/8 (ряд кнопок `1×/2×/4×/8×` в `#speedRow`, не в сейв).
|
||
- `STABLE_WINDOW` — окно распознавания устойчивости поля (1024 поколения; гейт дорисовки, см. «Геймплей и FSM»).
|
||
- `DEFAULT_BUDGET` — лимит клеток на игрока за партию (стартово 100).
|
||
- `CAMPAIGN_LEVELS` — уровни кампании (`{id,name,w,h,wrap,budget,zone,cells}`, `cells` — массив координат клеток врага `[x,y]`): 7 ручных (id 1..4 и 101..103) + 96 сгенерированных (id 5..100); `CAMPAIGN_PLAYER`/`CAMPAIGN_ENEMY` — цвета сторон (1/2).
|
||
- `tools/gen-campaign-levels.mjs` — детерминированный генератор 96 уровней кампании (seeded PRNG, без `Math.random`): размер монотонно растёт 60→300, бюджет — с размером, зоны разнообразны (7 типов, **без «всё поле»**: половина/центр/углы/полосы/прямоугольник), часть уровней с врагом вне зоны. Печатает готовый блок `cells` для вставки в `CAMPAIGN_LEVELS` (`node tools/gen-campaign-levels.mjs`); `--check` — самопроверка (96 уровней, валидность, нет полной зоны, все 7 типов зон, монотонность). После правок генератора перенесите его вывод в `index.html`.
|
||
- Дефолтный размер поля — 60×60 (варианты 40…300, `FIELD_SIZES`; прямоугольные `FIELD_RECTS`, полный список — `FIELD_DIMS`).
|
||
- `menuFieldW`/`menuFieldH`/`menuWrap`/`menuBudget` — пользовательские настройки меню (D1), отдельно от активной игры (`fieldW/fieldH/wrap/budget.total`).
|
||
- `SAVE_KEY` — ключ localStorage (`life-save-v1`); текущий формат — `SAVE_VERSION = 2`.
|
||
|
||
## Стабильные глобалы
|
||
|
||
Полный список имён, на которые завязаны тесты (контракт):
|
||
|
||
```
|
||
state, turn, mode, tool, TOOLS, winner, field, next, fieldW, fieldH,
|
||
step, applyBrush, countByPlayer, isExtinct, liveNeighbors, majorityColor,
|
||
hashField, newGame, commitTurn, handoffNext, handoffBack, pauseGame, resumeGame, startEdit,
|
||
endGame, resetGame, checkEnd, save, SAVE_KEY, freshSave, loadSave, persist,
|
||
forceState, setFieldASCII, stepN, getScores,
|
||
ctx, canvas, viewW, viewH, dpr, visibleMask, applyIntent, reason, STEPS_PER_SEC, speedMult,
|
||
wrap, budget, inZone, zoneSplit, setWrap, setBudgetTotal, remaining, paintTool,
|
||
DEFAULT_BUDGET, renderDirty, fieldCanvas,
|
||
FIELD_SIZES, FIELD_DIMS, setFieldSize, setFieldDim, selectTool, syncDom, board, draw, fieldImage,
|
||
turnStartField, turnStartBudget, bytesToB64, layoutSide,
|
||
suspended, confirmAction, theme, applyTheme, setTheme, fieldStable,
|
||
PATTERNS, stamp, patternCells, findPattern, stampRot, stampCells, rotate90, wheelAccum,
|
||
tapPlace,
|
||
campaign, levelIndex, zoneRect, campaignProgress, pendingLevel,
|
||
CAMPAIGN_LEVELS, CAMPAIGN_PLAYER, CAMPAIGN_ENEMY,
|
||
startLevel, showLevelSelect, showTwoSetup, inZoneAt, campaignZoneFor, requestCampaignLevel, normalizeProgress,
|
||
menuFieldW, menuFieldH, menuWrap, menuBudget
|
||
```
|
||
|
||
Дополнительные имена в последних двух строках найдены прямым поиском по `tests/test.js`
|
||
(реально читаются тестами из контекста страницы): `FIELD_SIZES`/`setFieldSize` (размеры поля),
|
||
`selectTool` (инструменты), `syncDom` (проводка DOM), `board` (геометрия доски),
|
||
`draw`/`fieldImage` (offscreen-рендер), `turnStartField`/`turnStartBudget` (снимок хода),
|
||
`bytesToB64` (сейв), `layoutSide` (адаптивная боковая раскладка), `suspended`/`confirmAction`
|
||
(отложенная в меню партия и диалог подтверждения), `theme`/`applyTheme`/`setTheme` (тема оформления),
|
||
`fieldStable` (устойчивость поля — гейт дорисовки), `campaign`/`levelIndex`/`zoneRect`/`campaignProgress`/
|
||
`pendingLevel`/`CAMPAIGN_LEVELS`/`CAMPAIGN_PLAYER`/`CAMPAIGN_ENEMY`/`startLevel`/`showLevelSelect`/
|
||
`inZoneAt`/`campaignZoneFor`/`requestCampaignLevel`/`normalizeProgress` (кампания),
|
||
`menuFieldW`/`menuFieldH`/`menuWrap`/`menuBudget` (пользовательские настройки меню, D1).
|
||
`COLORS` и `SAVE_VERSION` в тестах встречаются только в комментариях и
|
||
именах кейсов — как глобалы из страницы они не читаются, но входят в контракт настроек/сейва.
|
||
|
||
`useWrap` — **опциональный** параметр `liveNeighbors(f,x,y,useWrap)`, `step(src,dst,useWrap)`,
|
||
`stepN(n,useWrap)`; по умолчанию берётся глобал `wrap` (существующие вызовы без параметра
|
||
сохраняют прежнее поведение). Параметр выбирает ветку обхода, но **таблицы индексов соседей
|
||
(`colL`/`colR`/`rowU`/`rowD`) строятся по глобалу** `wrap` и используются только в ветке
|
||
`wrap=true` (torus); при `useWrap=false` обход идёт brute-force циклом с проверкой границ.
|
||
Пересобираются таблицы только через `setWrap(b)`, поэтому смена режима — через `setWrap`
|
||
(явный `useWrap` держать согласованным с глобалом, иначе таблицы и ветка обхода разойдутся).
|
||
|
||
имена состояний:
|
||
|
||
```
|
||
MENU, TWO_SETUP, LEVEL_SELECT, PLACE_P1, HANDOFF_P1_P2, PLACE_P2, RUNNING, PAUSED,
|
||
EDIT_P1, EDIT_P2, GAME_OVER
|
||
```
|
||
|
||
Ожидаемая роль ключевых из них:
|
||
|
||
- `state` — текущее состояние FSM; `turn` — чей ход; `mode` — производный описатель фазы (`modeForState()`: menu/place/edit/handoff/run/pause/over; используется игровым циклом `frame`); `tool` — выбранный инструмент; `winner` — победитель/ничья;
|
||
- `field` — плоский `Uint8Array(fieldW*fieldH)` цветов (0 = мёртвая, 1 = P1, 2 = P2); `next` — буфер следующего поколения того же размера; `fieldW`/`fieldH` — размеры;
|
||
- `step` — один тик симуляции (читает `src` — текущее поле, пишет `dst` — буфер `next`, не мутирует `src`); `stepN(n)` — n тиков; `hashField()` — детерминированный хеш поля; `setFieldASCII(str,w)` — построить поле из ASCII-строк (согласует снимки `turnStartField`/`turnStartBudget`); `forceState(name)` — форс состояния;
|
||
- `applyBrush(...)` — ГЕОМЕТРИЧЕСКИЙ примитив штампа инструмента (кламп по полю, без зоны/бюджета); `paintTool(x,y)` — ИГРОВОЕ действие (фаза + зона + бюджет, единственная точка мутации `budget.used*`); `countByPlayer(...)` — счётчики живых по цветам; `isExtinct()` — вымирание; `liveNeighbors(...)` — число живых соседей; `majorityColor(...)` — цвет большинства живых с tie-break (наименьший id);
|
||
- `wrap` — глобал-опция «Замкнутая карта»; `setWrap(b)` — установить + пересобрать таблицы; `budget = { total, used1, used2 }` — лимит/расход; `remaining(player)` — остаток; `setBudgetTotal(n)` — задать лимит; `inZone(x,player)` — предикат зоны; `zoneSplit()` — граница зон + предикат; `DEFAULT_BUDGET` — дефолт лимита;
|
||
- `newGame()`, `commitTurn()`, `handoffNext()`, `handoffBack()`, `pauseGame()`, `resumeGame()`, `startEdit(player)`, `endGame()`, `resetGame()`, `checkEnd()` — переходы FSM; `requestEndGame()`/`confirmProceed()`/`cancelConfirm()` — подтверждение завершения партии (и общей опасной операции); `toMenu()`/`suspendGame()`/`resumeFromMenu()` и `requestNewGame()` — меню и отложенная партия (`suspended`);
|
||
- `suspended` — снимок прерванной партии (`{state,turn,fieldW,fieldH,wrap,field,turnStartField,stable,budget}`) или `null`; `confirmAction` — открытый диалог подтверждения (`null` | `'new'` | `'end'` | `'campaign'` | `'restart'`), `confirmResume` — вернуть ли симуляцию при «Отмене»;
|
||
- `visibleMask()` — массив видимых цветов по состоянию (`[1]`/`[2]`/`[]`/`[1,2]`; скрытие только визуальное); `applyIntent(px,py)` — применить активный инструмент в экранной точке (CSS-пиксели холста) для тестов/ввода (учитывает зону);
|
||
- `ctx`, `canvas`, `viewW`, `viewH`, `dpr` — холст и его геометрия (DPR-кап 2); `reason` — причина финала; `STEPS_PER_SEC` — скорость симуляции; `speedMult` — транзиентный множитель 1/2/4/8 (ряд кнопок `1×/2×/4×/8×`, в сейв не пишется);
|
||
- `renderDirty` — dirty-флаг offscreen-слоя клеток; `fieldCanvas` (`var`, доступна как глобал) — offscreen-канва `fieldW×fieldH` в координатах поля (см. «Рендер, зоны и HUD»);
|
||
- `fieldStable` — устойчивость поля на паузе (гейт дорисовки): `true`, только когда симуляция сама остановилась на фиксированной точке/коротком цикле (`recentHashes`/`STABLE_WINDOW`, см. «Геймплей и FSM»); `resetStability()`/`seedStability()` — сброс/засев окна хешей (внутренние, в контракт не входят); `checkStalemate()` — авто-финал тупика по бюджету на стабильной паузе (вызывается из `tick()`, внутренняя);
|
||
- `theme` (`'dark'`/`'light'`) — активная тема; `applyTheme(name)` — применить только визуал (data-theme/CSS-переменные/`COLORS`/meta); `setTheme(name)` — `applyTheme` + `syncDom` + `persist` (сохраняет тему в сейв);
|
||
- `PATTERNS` — таблица готовых паттернов-штампов (`{ id, name, group, cells }`); `PATTERN_GROUPS` — категории дропдауна (Стойкие/Осцилляторы/Корабли/Долгожители/Ружья), `buildPatternOptions()` строит `<optgroup>` по ним (внутренние, в контракт не входят); `patternCells(p)` — разбор ASCII → offsets (ленивый кэш `p._cells`/`_w`/`_h`); `findPattern(id)` — паттерн по id или `null`; `stamp` — активный паттерн (`PATTERNS[i]` | `null`), транзиентно (не в сейв, сбрасывается `selectTool`); `stampRot` — поворот активного штампа `0..3` (колесо мыши/кнопка «↻»), `stampCells()` — offsets с учётом поворота, `rotate90(cells)` — поворот offsets на 90° CW; ставится веткой `stamp` в `paintTool` (см. «Инструменты и настройки»); `tapPlace` — режим «постановка по отпусканию» (транзиентный), `pressing` — удержание в этом режиме (внутренний);
|
||
- `campaign`/`levelIndex`/`zoneRect`/`campaignProgress`/`pendingLevel` — режим «Кампания» (см. «Геймплей и FSM»); `CAMPAIGN_LEVELS` — конфиг уровней (`CAMPAIGN_PLAYER=1`/`CAMPAIGN_ENEMY=2`); `startLevel(idx)` — запуск уровня (враг/зона/бюджет/PLACE_P1), `showLevelSelect()` — экран выбора (`LEVEL_SELECT`), `requestCampaignLevel(idx)` — выбор уровня с подтверждением; `campaignZoneFor(idx)` — копия `zoneRect` уровня; `inZoneAt(x,y,player)` — предикат зоны с учётом кампании; `normalizeProgress(p)` — нормализация `progress`;
|
||
- `TWO_SETUP` / `showTwoSetup()` — экран «Игра на двоих» (`#setup`): своя справка и настройки поля перед обычной партией; вход — `#btnPlay` со сплэша `MENU`, старт — `#btnStartTwo` (`requestNewGame()`), возврат — `#btnTwoBack` (`toMenu()`); не резюмируется (reload → `MENU`);
|
||
- `save`, `SAVE_KEY`, `freshSave()`, `loadSave()`, `persist()` — персистентность (ключ `life-save-v1`; `loadSave()` с миграцией/нормализацией, `persist()` пишет настройки + резюм; форма сейва — см. раздел «Сохранение и резюм»); `getScores()` — счёт для HUD/финала.
|