Files
andrey 3061247ce5 Паттерны и кампания: 8 новых фигур, 3 showcase-уровня, факты в README
- PATTERNS: авианосец, длинная лодка, каноэ, MWSS, HWSS, Loafer, кролики, ружьё Симкина (22 → 30)
- CAMPAIGN_LEVELS: ручные «Флотилия»/«Погоня»/«Кролики» после «Издалека» (100 → 103)
- README: раздел «Интересные факты» и список ресурсов
- tests: счётчики и проверки новых фигур/уровней синхронизированы
2026-10-13 15:06:52 +03:00

106 KiB
Raw Permalink Blame History

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):
    cd tests
    npm install   # puppeteer-core
    npm test      # = node test.js
    
    Ждать все PASS и exit 0 (финальный вывод ===== ИТОГ: N/N =====).
  3. Паритет с прототипом (harness — tools/old-ref/, не обязателен для каждой правки, но после изменений ядра полезен):
    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/финала.