- PATTERNS: авианосец, длинная лодка, каноэ, MWSS, HWSS, Loafer, кролики, ружьё Симкина (22 → 30) - CAMPAIGN_LEVELS: ручные «Флотилия»/«Погоня»/«Кролики» после «Издалека» (100 → 103) - README: раздел «Интересные факты» и список ресурсов - tests: счётчики и проверки новых фигур/уровней синхронизированы
106 KiB
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):
- P1 расставляет свои клетки (
PLACE_P1). - Передача устройства сопернику со скрытым его вводом (
HANDOFF_P1_P2). - P2 расставляет вслепую, не видя расстановку P1 (
PLACE_P2). - Симуляция (
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)в bboxw×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.js36e–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. - Персистентность (перф): в
RUNNINGturnStartFieldобнуляется, а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()), не каждый шаг. ВRUNNINGturnStartFieldобнуляется (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 нет — два рубежа:
- Синтаксис:
node tools/check.mjs— вынимает JS-код<script>изindex.htmlи прогоняетnode --check(при ошибке показывает номер строкиindex.html). Плюсtools/check.mjsпечатаетcheck: OK — …. - Главный рубеж — headless-суита (запускать после правок
index.htmlилиtests/test.js):Ждать все PASS и exit 0 (финальный выводcd tests npm install # puppeteer-core npm test # = node test.js===== ИТОГ: N/N =====). - Паритет с прототипом (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-break1,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 вне рисования; группировка — 5optgroupпоPATTERN_GROUPS, пустая опция прямым ребёнкомselect, все id на месте); свой список#patternMenu(клик по#optPatternоткрывает/закрывает, 30 фигур + 5 заголовков групп, выбор фигуры задаётstamp/.activeи закрывает список, Escape/клик вне закрывают,stampпри этом не сбрасывается); одно нажатие = одна постановка (drag не повторяет), ПКМ стирает; транзиентность (stampне переживает reload). - Поворот штампа:
stampCellsприstampRot0..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=truemouse.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(без клика «Дорисовать»); нормализацияreasonwin/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/финала.