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

3379 lines
251 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#111418">
<title>Life — территориальная борьба</title>
<!-- Favicon — инлайн SVG (data:URI), как и всё остальное: без внешних ассетов.
Глайдер Конвея: зелёные клетки P2 и красные P1 на тёмной скруглённой плашке. -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%23111418'/%3E%3Crect%20x='12'%20y='4'%20width='8'%20height='8'%20fill='%232ecc71'/%3E%3Crect%20x='20'%20y='12'%20width='8'%20height='8'%20fill='%232ecc71'/%3E%3Crect%20x='4'%20y='20'%20width='8'%20height='8'%20fill='%23e74c3c'/%3E%3Crect%20x='12'%20y='20'%20width='8'%20height='8'%20fill='%23e74c3c'/%3E%3Crect%20x='20'%20y='20'%20width='8'%20height='8'%20fill='%23e74c3c'/%3E%3C/svg%3E">
<style>
/* ===================== БАЗОВЫЙ КАРКАС ===================== */
:root{
/* Нативные контролы (popup селектов, скроллбары, спиннеры) следуют теме. */
color-scheme:dark;
/* отступы под «чёлку»/жесты — safe-area для HUD и оверлеев */
--safe-t: env(safe-area-inset-top, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
--bg:#111418;
--fg:#e8e8e8;
--p1:#e74c3c; /* цвет игрока 1 */
--p2:#2ecc71; /* цвет игрока 2 */
--panel:#1b2026;
--line:rgba(255,255,255,.14);
--side-w:320px; /* ширина боковой панели управления в ландшафте (см. computeBoard) */
/* Структурные цвета UI (тема). Переопределяются в :root[data-theme="light"]. */
--chip-bg:rgba(20,24,30,.82); /* плашки HUD (метка/счёт/бюджет/кнопки) */
--chip-bg-active:rgba(40,48,58,.9); /* нажатое состояние кнопок HUD */
--chip-bg2:rgba(28,34,42,.92); /* фон кнопок действий .act */
--overlay-bg:rgba(8,10,13,.8); /* подложка модальных оверлеев */
--panel-bg:linear-gradient(165deg,#232a33,#161b21 60%,#12161a); /* панель диалогов */
--panel-fg:#eef2f6; /* основной текст панелей */
--muted:#b7c2ce; /* вторичный текст (описания, подписи) */
--opt-bg:rgba(255,255,255,.05); /* фон строк-опций меню */
--input-bg:rgba(0,0,0,.35); /* фон полей ввода/селектов */
--panel-inset:rgba(255,255,255,.08); /* верхний блик панели */
/* Активное состояние инструмента/штампа/скорости: фон + читаемый текст.
В светлой теме переопределяются (иначе тёмный фон + тёмный текст). */
--hl-bg:rgba(70,60,20,.9);
--hl-fg:#ffe9b0;
}
/* Светлая тема: переключается атрибутом data-theme на <html> (applyTheme).
Цвета игроков (--p1/--p2) одинаковы в обеих темах — различаемость клеток
не зависит от темы; меняется лишь фон/панели/текст. */
:root[data-theme="light"]{
color-scheme:light;
--bg:#eef1f5;
--fg:#1a1f26;
--line:rgba(0,0,0,.14);
--chip-bg:rgba(255,255,255,.88);
--chip-bg-active:rgba(0,0,0,.08);
--chip-bg2:rgba(255,255,255,.92);
--overlay-bg:rgba(238,241,245,.75);
--panel-bg:linear-gradient(165deg,#ffffff,#f2f4f7 60%,#e9edf2);
--panel-fg:#1a1f26;
--muted:#5a646f;
--opt-bg:rgba(0,0,0,.04);
--input-bg:rgba(0,0,0,.06);
--panel-inset:rgba(255,255,255,.7);
/* Активная подсветка: непрозрачное золото (может лежать над canvas) + тёмный текст. */
--hl-bg:#f3cf7d;
--hl-fg:#4a2e00;
}
*{box-sizing:border-box}
html,body{
margin:0;height:100%;overflow:hidden;background:var(--bg);color:var(--fg);
font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
}
/* Игровое полотно — на весь экран; touch-action:none глушит скролл/зум жестами
(ввод — только Pointer Events, см. AGENTS.md). */
#game{position:fixed;inset:0;display:block;touch-action:none;background:var(--bg)}
button{font-family:inherit;cursor:pointer;color:inherit}
.hidden{display:none!important}
/* ===================== HUD ===================== */
#hud{
position:fixed;top:0;left:0;right:0;z-index:5;display:flex;align-items:center;
justify-content:space-between;gap:6px;pointer-events:none;
padding:calc(6px + var(--safe-t)) calc(8px + var(--safe-r)) 6px calc(8px + var(--safe-l));
}
#turnLabel{
pointer-events:auto;font-weight:700;font-size:15px;padding:9px 14px;border-radius:999px;
background:var(--chip-bg);border:1px solid var(--line);
box-shadow:0 3px 12px rgba(0,0,0,.4);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
#hudright{display:flex;align-items:center;gap:5px;pointer-events:auto;min-width:0}
#budgetLabel{
font-weight:700;font-size:12px;padding:5px 8px;border-radius:999px;
background:var(--chip-bg);border:1px solid var(--line);
box-shadow:0 3px 12px rgba(0,0,0,.4);white-space:nowrap;color:var(--fg);
min-width:0;overflow:hidden;text-overflow:ellipsis;
}
#budgetLabel.p1{color:var(--p1)}
#budgetLabel.p2{color:var(--p2)}
#budgetLabel.zero{color:#ff6b5e;border-color:rgba(231,76,60,.6)}
#scores{
display:flex;align-items:center;gap:4px;font-weight:800;font-size:14px;
padding:6px 9px;border-radius:999px;background:var(--chip-bg);
border:1px solid var(--line);box-shadow:0 3px 12px rgba(0,0,0,.4);
}
#scoreP1{color:var(--p1)}
#scoreP2{color:var(--p2)}
.scsep{opacity:.45}
#btnSettings{
width:40px;height:40px;border-radius:50%;font-size:17px;border:1px solid var(--line);
background:var(--chip-bg);box-shadow:0 3px 12px rgba(0,0,0,.4);transition:transform .12s,background .15s;
}
#btnSettings:active{transform:scale(.94);background:var(--chip-bg-active)}
/* Узкие экраны (320…400): сжимаем HUD, чтобы метка фазы НЕ обрезалась и кнопка
настроек не уходила за край. Порог 400px (а не 380) убирает микро-обрезку метки
в полосе 381–388px (minor ре-ревью R2-2). #hud не переносим — computeBoard
резервирует top:64px. */
@media (max-width:400px){
#hud{gap:4px;padding:calc(4px + var(--safe-t)) calc(5px + var(--safe-r)) 4px calc(5px + var(--safe-l))}
#hudright{gap:4px}
#turnLabel{font-size:12px;padding:5px 7px}
#budgetLabel{font-size:11px;padding:4px 5px}
#scores{font-size:12px;padding:5px 6px;gap:2px}
#btnSettings{width:40px;height:40px;font-size:16px}
}
/* ===================== НИЖНИЕ ПАНЕЛИ ===================== */
#toolbar{
position:fixed;left:0;right:0;z-index:5;display:flex;justify-content:center;gap:8px;
flex-wrap:wrap;row-gap:8px; /* с кнопками поворота/тапа инструменты переносятся, а не обрезаются */
bottom:calc(74px + var(--safe-b));padding:0 10px;
}
.toolbtn{
width:62px;height:56px;border-radius:16px;border:1px solid var(--line);
background:var(--chip-bg);display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:2px;font-size:20px;line-height:1;box-shadow:0 3px 12px rgba(0,0,0,.4);
transition:transform .12s,background .15s,border-color .15s;
}
.toolbtn span{font-size:10px;font-weight:700;opacity:.7}
.toolbtn:active{transform:scale(.95)}
.toolbtn.active{border-color:#ffd75e;background:var(--hl-bg);color:var(--hl-fg);box-shadow:0 0 0 2px rgba(255,215,94,.45),0 3px 12px rgba(0,0,0,.4)}
.toolbtn:disabled{opacity:.35}
/* Дропдаун готовых паттернов (штамп) в тулбаре: рядом с инструментами. */
#optPattern{
flex:0 1 auto;min-width:96px;width:auto;max-width:130px;height:56px;padding:0 8px;
border-radius:16px;border:1px solid var(--line);background:var(--chip-bg);color:var(--fg);
font-family:inherit;font-size:13px;font-weight:700;text-overflow:ellipsis;
box-shadow:0 3px 12px rgba(0,0,0,.4);transition:border-color .15s,background .15s;
}
#optPattern:disabled{opacity:.35}
#optPattern.active{border-color:#ffd75e;background:var(--hl-bg);color:var(--hl-fg);box-shadow:0 0 0 2px rgba(255,215,94,.45),0 3px 12px rgba(0,0,0,.4)}
/* Свой список паттернов (заменяет нативный popup <select>). Позиционируется в JS
рядом с #optPattern; на длинном списке не «накрывает» курсор, поэтому клик по
выбранной фигуре не срабатывает сам по себе. */
#patternMenu{
position:fixed;z-index:30;padding:6px;border-radius:14px;border:1px solid var(--line);
background:var(--panel-bg);color:var(--panel-fg);
box-shadow:0 18px 48px rgba(0,0,0,.55);
max-height:min(56vh,420px);overflow-y:auto;overscroll-behavior:contain;
-webkit-overflow-scrolling:touch;
}
#patternMenu .patgroup{
padding:8px 10px 3px;font-size:11px;font-weight:800;letter-spacing:.4px;
text-transform:uppercase;color:var(--muted);
}
#patternMenu .patitem{
display:block;width:100%;text-align:left;padding:9px 10px;border:0;border-radius:9px;
background:transparent;color:inherit;font:inherit;font-size:14px;font-weight:600;cursor:pointer;
transition:background .12s,color .12s;
}
#patternMenu .patitem:hover{background:var(--chip-bg2)}
#patternMenu .patitem.active{background:var(--hl-bg);color:var(--hl-fg)}
#actions{
position:fixed;left:0;right:0;z-index:5;display:flex;justify-content:center;gap:8px;
flex-wrap:wrap;row-gap:8px; /* пауза: до 4 кнопок инлайн — переносим, а не обрезаем */
bottom:calc(12px + var(--safe-b));padding:0 10px;
}
.act{
min-height:48px;padding:0 16px;border-radius:14px;border:1px solid var(--line);
background:var(--chip-bg2);font-weight:700;font-size:15px;white-space:nowrap;
box-shadow:0 3px 12px rgba(0,0,0,.4);transition:transform .12s,background .15s,filter .12s;
}
.act:active{transform:translateY(2px)}
.act.primary{background:linear-gradient(#ffd76e,#f5a623);color:#241a00;border-color:#ffd75e}
/* «Завершить партию» — нейтральная тёмно-серая (не красная): не необратима, а под вопросом. */
.act.danger{background:rgba(48,52,58,.95);border-color:var(--line);color:#fff}
.act.wide{width:100%;margin-top:10px}
/* «В меню» — тёмно-синяя: заметна на фоне прочих действий, но не «опасная». */
.act.menu{background:rgba(26,52,92,.95);border-color:rgba(70,130,200,.55);color:#fff}
/* Экран выбора уровня кампании: длинный список уровней — прокручиваемый, чтобы
заголовок панели и кнопка «В меню» оставались на виду. */
#levelList{max-height:min(58vh,460px);overflow-y:auto;overscroll-behavior:contain;
-webkit-overflow-scrolling:touch;display:flex;flex-direction:column;gap:8px;margin-top:10px}
#levelList .levelbtn{margin-top:0}
/* «Дорисовать» — кнопки конкретного игрока: слегка подсвечены его цветом. */
.act.editp1{border-color:rgba(231,76,60,.7);background:rgba(231,76,60,.18)}
.act.editp2{border-color:rgba(46,204,113,.7);background:rgba(46,204,113,.18)}
/* «Скорость» — ряд из 4 маленьких кнопок 1×/2×/4×/8×; активна выбранная. */
.speedrow{display:inline-flex;gap:4px;align-items:stretch}
.speedrow .speedbtn{min-height:48px;padding:0 9px;font-size:13px}
.speedrow .speedbtn.active{border-color:#ffd75e;background:var(--hl-bg);color:var(--hl-fg);box-shadow:0 0 0 2px rgba(255,215,94,.45)}
/* Акцент текущего игрока: класс на body включается только в фазах рисования
(syncDom). Вне этих фаз --accent не задан — рамка/акценты не проявляются.
box-shadow (inset) и border-color не меняют габариты — раскладка не плывёт. */
body.turn-p1{--accent:var(--p1)}
body.turn-p2{--accent:var(--p2)}
/* Рамка вокруг боковой панели (только ландшафтная раскладка body.side). */
body.side.turn-p1 #ui,body.side.turn-p2 #ui{box-shadow:inset 0 0 0 3px var(--accent)}
/* Метка фазы — акцентная рамка цветом текущего игрока. */
body.turn-p1 #turnLabel,body.turn-p2 #turnLabel{border-color:var(--accent)}
/* ===================== БОКОВАЯ ПАНЕЛЬ УПРАВЛЕНИЯ (ландшафт) =====================
В ландшафте доска занимает всю высоту и прижата влево, а управление собирается
в свободной зоне справа. Ширину зоны задаёт computeBoard (--side-w), класс
body.side включает раскладку. В портрете/узком экране всё как раньше:
#ui — display:contents, панели на своих фиксированных местах. */
#ui{display:contents}
#uiInner{display:contents}
body.side #ui{
display:flex;flex-direction:column;align-items:center;
position:fixed;top:0;bottom:0;right:0;width:var(--side-w,320px);z-index:6;
overflow-y:auto;overscroll-behavior:contain;
padding:calc(12px + var(--safe-t)) calc(14px + var(--safe-r)) calc(12px + var(--safe-b)) 14px;
}
/* margin:auto 0 центрирует стопку контролов по вертикали и остаётся корректным
при переполнении (в отличие от justify-content:center). */
body.side #uiInner{
display:flex;flex-direction:column;gap:14px;margin:auto 0;width:100%;max-width:420px;
}
body.side #hud{
position:static;left:auto;right:auto;padding:0;
flex-direction:column;align-items:stretch;justify-content:flex-start;gap:8px;
}
body.side #turnLabel{text-align:center}
body.side #hudright{justify-content:center;flex-wrap:wrap}
body.side #toolbar{
position:static;left:auto;right:auto;bottom:auto;padding:0;
display:grid;grid-template-columns:repeat(2,1fr);gap:8px;
}
body.side .toolbtn{width:100%;height:64px}
body.side #optPattern{grid-column:1 / -1;width:100%;max-width:none}
body.side #actions{
position:static;left:auto;right:auto;bottom:auto;padding:0;
flex-direction:column;justify-content:flex-start;gap:8px;
}
body.side .act{width:100%}
/* В боковой колонке ряд скорости растягиваем на всю ширину, кнопки делят её поровну. */
body.side .speedrow{width:100%}
body.side .speedrow .speedbtn{width:auto;flex:1 1 0;padding:0 4px}
/* ===================== ОВЕРЛЕИ ===================== */
.overlay{
position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;
padding:calc(var(--safe-t) + 14px) calc(var(--safe-r) + 14px) calc(var(--safe-b) + 14px) calc(var(--safe-l) + 14px);
background:var(--overlay-bg);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);
}
.panel{
width:min(92vw,380px);max-height:88vh;overflow-y:auto;text-align:center;color:var(--panel-fg);
padding:22px;border-radius:22px;border:1px solid var(--line);
background:var(--panel-bg);
box-shadow:0 22px 60px rgba(0,0,0,.6),inset 0 1px 0 var(--panel-inset);
}
.title{font-size:36px;font-weight:800;letter-spacing:1px;margin-bottom:8px;
background:linear-gradient(90deg,var(--p1),#ffd75e,var(--p2));-webkit-background-clip:text;background-clip:text;color:transparent}
.panel p{font-size:14px;line-height:1.5;color:var(--muted);margin:6px 0}
.panelhead{font-size:23px;font-weight:800;margin-bottom:8px}
.big{
width:100%;margin-top:16px;padding:15px;border:none;border-radius:14px;font-size:19px;font-weight:800;
color:#241a00;background:linear-gradient(#ffd76e,#f5a623);
box-shadow:0 4px 0 #b97708,0 8px 18px rgba(245,166,35,.3);transition:transform .12s,filter .12s;
}
.big:active{transform:translateY(2px);box-shadow:0 2px 0 #b97708}
.row{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:16px}
/* Опции меню: размер поля (дропдаун), чекбокс «Замкнутая карта», ввод бюджета.
Компактная вертикаль — меню должно влезать на телефон без прокрутки. */
.opt{
display:flex;align-items:center;justify-content:space-between;gap:10px;
min-height:46px;margin-top:8px;padding:0 14px;border-radius:12px;
border:1px solid var(--line);background:var(--opt-bg);
font-size:15px;font-weight:600;text-align:left;
}
.opt input[type=checkbox]{width:22px;height:22px;accent-color:#f5a623;flex:none}
.opt input[type=number],.opt select{
min-height:36px;border-radius:8px;border:1px solid var(--line);
background:var(--input-bg);color:var(--fg);font:inherit;font-weight:700;
text-align:center;padding:0 8px;
}
.opt input[type=number]{width:118px}
.opt select{width:118px;text-align:left}
/* Опции нативного popup селектов: явные цвета темы (страховка для браузеров,
где color-scheme не покрывает список — иначе в светлой теме чёрный текст
на системном тёмном фоне). */
option{background:var(--bg);color:var(--fg)}
/* Ввод текста описания — чуть плотнее, чтобы меню влезало целиком. */
#menu .panel p{margin:4px 0;font-size:13px;line-height:1.45}
/* Раскрываемое описание (сворачиваемое по кнопке «Как играть»); используется
на сплэше-экранах: у партии на двоих (#aboutTwo) и у кампании (#aboutCampaign). */
.help{margin-top:4px;text-align:left}
.help summary{
list-style:none;cursor:pointer;display:block;padding:6px 4px;border-radius:10px;
font-size:13px;font-weight:700;color:var(--muted);transition:color .15s,background .15s;
}
.help summary::-webkit-details-marker{display:none}
.help summary::before{content:'▸ ';color:#ffd75e}
.help[open] summary::before{content:'▾ '}
.help summary:hover{color:var(--panel-fg);background:var(--opt-bg)}
.help p:first-of-type{margin-top:2px}
#gameoverText{font-size:22px;font-weight:800;margin:10px 0 4px}
#gameoverSub{font-size:14px;color:var(--muted)}
/* Оверлей передачи: панель подсвечена цветом игрока, которому передают устройство. */
#handoff.to-p2 .panel{border-color:var(--p2);box-shadow:0 22px 60px rgba(0,0,0,.6),0 0 0 2px rgba(46,204,113,.35)}
/* Панель финала — рамка цветом победителя (ничья — без изменений). */
#gameover.win-p1 .panel{border-color:var(--p1);box-shadow:0 22px 60px rgba(0,0,0,.6),0 0 0 2px rgba(231,76,60,.4)}
#gameover.win-p2 .panel{border-color:var(--p2);box-shadow:0 22px 60px rgba(0,0,0,.6),0 0 0 2px rgba(46,204,113,.4)}
</style>
</head>
<body>
<!-- Доска -->
<canvas id="game"></canvas>
<!-- Управление. В ландшафте (body.side) собирается в боковую панель справа;
в портрете/узком экране — как раньше (HUD сверху, тулбар и действия снизу). -->
<div id="ui">
<div id="uiInner">
<!-- HUD: состояние/ход + счёт + настройки -->
<div id="hud">
<div id="turnLabel">Меню</div>
<div id="hudright">
<div id="budgetLabel">0 / 0</div>
<div id="scores"><span id="scoreP1">0</span><span class="scsep">:</span><span id="scoreP2">0</span></div>
<button id="btnSettings" title="Настройки">⚙️</button>
</div>
</div>
<!-- Тулбар инструментов (активен в фазах рисования) -->
<div id="toolbar">
<button class="toolbtn active" id="toolPencil" data-tool="pencil">✏️<span>1×1</span></button>
<button class="toolbtn" id="toolBrush" data-tool="brush">🖌️<span>3×3</span></button>
<button class="toolbtn" id="toolErase1" data-tool="erase1" title="Ластик (или удерживайте правую кнопку мыши)">🧽<span>1×1</span></button>
<button class="toolbtn" id="toolErase4" data-tool="erase4" title="Ластик 4×4 (или удерживайте правую кнопку мыши)">🧹<span>4×4</span></button>
<!-- Готовый паттерн (штамп): опции строятся из PATTERNS в JS. -->
<select id="optPattern" title="Готовый паттерн (штамп). Колесо мыши над доской — поворот на 90°"><!-- опции строятся в JS --></select>
<!-- Поворот активного паттерна на 90° (для сенсорных экранов — без колеса мыши). -->
<button class="toolbtn" id="btnRotate" title="Повернуть паттерн на 90°">↻<span id="rotDeg">90°</span></button>
<!-- Режим «постановка по отпусканию» (тап): нажатие — превью, отпускание — постановка. -->
<button class="toolbtn" id="btnTapPlace" title="Постановка по отпусканию (тап)">👆<span>тап</span></button>
</div>
<!-- Контекстные действия (состав зависит от фазы; пауза — инлайн, без диалога поверх) -->
<div id="actions">
<button class="act primary" id="btnReady">Готов</button>
<button class="act" id="btnReset">Сброс</button>
<!-- Кампания: перезапуск уровня с начала (враг/бюджет/зона заново). -->
<button class="act" id="btnRestart" title="Перезапустить уровень">Заново</button>
<button class="act" id="btnPause">Пауза</button>
<div class="speedrow" id="speedRow" title="Скорость симуляции">
<button class="act speedbtn" id="speed1" data-speed="1">1×</button>
<button class="act speedbtn" id="speed2" data-speed="2">2×</button>
<button class="act speedbtn" id="speed4" data-speed="4">4×</button>
<button class="act speedbtn" id="speed8" data-speed="8">8×</button>
</div>
<button class="act primary" id="btnResume">Старт симуляции</button>
<button class="act editp1" id="btnEditP1">Дорисовать красным</button>
<button class="act editp2" id="btnEditP2">Дорисовать зелёным</button>
<button class="act danger" id="btnEnd">Завершить партию</button>
<button class="act danger" id="btnEndPause">Завершить партию</button>
<button class="act menu" id="btnPauseMenu">В меню</button>
</div>
</div>
</div>
<!-- Свой список паттернов вместо нативного popup <select> (см. buildPatternMenu):
на длинном списке (22 фигуры) Chromium позиционирует popup поверх курсора, и
обычный клик выбирает случайную фигуру — дропдаун «не раскрывается». -->
<div id="patternMenu" class="hidden"></div>
<!-- Стартовое меню (сплэш): краткое описание игры, тема и выбор режима.
Настройки поля (размер/torus/бюджет) живут на экране «Игра на двоих» (#setup):
они относятся только к обычной hot-seat партии. -->
<div id="menu" class="overlay">
<div class="panel">
<div class="title">Life</div>
<p>Conway's Game of Life с территориальной борьбой.</p>
<!-- Тема оформления: одна на всё приложение, поэтому остаётся на стартовом экране. -->
<label class="opt"><span>Тема</span>
<select id="optTheme">
<option value="dark">Тёмная</option>
<option value="light">Светлая</option>
</select>
</label>
<!-- Возврат к прерванной партии: виден только если есть отложенная игра.
Выделен (как основное действие вернувшегося игрока) — в отличие от равных кнопок режимов. -->
<button class="big" id="btnContinue">Продолжить партию</button>
<!-- Два равноправных режима — в одинаковом (нейтральном) стиле .act wide. -->
<button class="act wide" id="btnCampaign">Кампания</button>
<button class="act wide" id="btnPlay">Игра на двоих</button>
</div>
</div>
<!-- Экран «Игра на двоих» (состояние TWO_SETUP): своя справка и настройки партии. -->
<div id="setup" class="overlay hidden">
<div class="panel">
<div class="panelhead">Игра на двоих</div>
<details class="help" id="aboutTwo">
<summary>Как играть</summary>
<p>Расставьте свои клетки, передайте устройство сопернику — он расставит вслепую. Дальше поле живёт по правилам Конвея: у кого осталось больше живых клеток, тот и победил.</p>
<p>Рисуйте левой кнопкой мыши, а правой — стирайте (ластик действует, пока кнопка удержана).</p>
<p>В тулбаре есть готовые паттерны (глайдер, улей, ружьё Госпера и др.), сгруппированные по категориям: выберите фигуру в списке и кликните — она встанет целиком, левый верхний угол в точке клика. Часть, выходящая за вашу половину, не ставится (видно по превью). Кнопка «↻» в тулбаре (или колесо мыши над доской) поворачивает выбранную фигуру на 90°.</p>
<p>На сенсорном экране включите кнопку «👆 тап»: тогда нажатие только подсвечивает будущее место, а клетка или фигура ставится в момент отпускания — палец не закрывает точку постановки.</p>
</details>
<label class="opt"><span>Размер поля</span>
<select id="optSize"><!-- опции строятся из FIELD_DIMS в JS --></select>
</label>
<label class="opt"><span>Замкнутая карта (torus)</span><input type="checkbox" id="optWrap"></label>
<label class="opt"><span>Бюджет клеток</span>
<input type="number" id="optBudget" list="budgetOptions" min="1" step="1" inputmode="numeric" value="100">
</label>
<!-- Комбобокс бюджета: подсказки-пресеты в выпадающем списке, но можно ввести любое число. -->
<datalist id="budgetOptions">
<option value="100"></option>
<option value="250"></option>
<option value="500"></option>
</datalist>
<button class="big" id="btnStartTwo">Начать</button>
<button class="act wide menu" id="btnTwoBack">В меню</button>
</div>
</div>
<!-- Экран выбора уровня кампании (список строится из CAMPAIGN_LEVELS в JS). -->
<div id="levels" class="overlay hidden">
<div class="panel">
<div class="panelhead">Кампания</div>
<details class="help" id="aboutCampaign">
<summary>Как играть</summary>
<p>Уничтожьте все клетки врага (зелёные), расставляя свои (красные). Враг неподвижен, поле живёт по правилам Конвея; трогать врага напрямую нельзя.</p>
<p>Вы играете один, передача устройства не нужна. Бюджет клеток ограничен, победа — ноль живых клеток врага; поражение — бюджет исчерпан, а враг ещё жив.</p>
</details>
<div id="levelList"></div>
<button class="act wide menu" id="btnLevelsBack">В меню</button>
</div>
</div>
<!-- Передача устройства / подтверждение старта после расстановки -->
<div id="handoff" class="overlay hidden">
<div class="panel">
<div class="panelhead" id="handoffText">Передайте устройство игроку 2</div>
<p id="handoffNote">Соперник не должен видеть экран.</p>
<button class="big" id="btnHandoff">Передать</button>
<button class="act wide" id="btnHandoffBack">Вернуться к игроку 1</button>
</div>
</div>
<!-- Итог партии -->
<div id="gameover" class="overlay hidden">
<div class="panel">
<div class="panelhead">Итог партии</div>
<div id="gameoverText">Ничья</div>
<div id="gameoverSub"></div>
<button class="big" id="btnRevenge">Реванш</button>
<!-- Кампания: следующий уровень (только при победе и наличии следующего). -->
<button class="big" id="btnNextLevel">Следующий уровень</button>
<button class="act wide menu" id="btnGameoverMenu">В меню</button>
<button class="act wide menu" id="btnGameoverLevels">К уровням</button>
</div>
</div>
<!-- Подтверждение необратимого/рискованного действия (новая партия, завершение) -->
<div id="confirm" class="overlay hidden">
<div class="panel">
<div class="panelhead" id="confirmHead">Начать новую партию?</div>
<p id="confirmText">Незавершённая партия будет потеряна.</p>
<button class="big" id="btnConfirmOk">Начать новую</button>
<button class="act wide" id="btnConfirmCancel">Отмена</button>
</div>
</div>
<script>
'use strict';
/* ============================================================================
ЯДРО ИГРЫ. Модель поля, симуляция, FSM hot-seat, ввод Pointer Events,
отрисовка Canvas 2D, HUD/оверлеи/тулбар, персистентность (localStorage).
============================================================================ */
// ==== КОНСТАНТЫ ====
const SAVE_KEY = 'life-save-v1'; // ключ localStorage (настройки + резюм партии; имя историческое)
const FIELD_SIZES = [40, 60, 80, 100, 120, 150, 200, 250, 300]; // доступные квадратные размеры поля
const FIELD_RECTS = [[60, 100], [100, 60]]; // прямоугольные варианты (W×H): 60×100 и 100×60
// Полный список размеров для дропдауна меню: квадраты + прямоугольники.
const FIELD_DIMS = FIELD_SIZES.map(function (n) { return [n, n]; }).concat(FIELD_RECTS);
const DEFAULT_FIELD = 60; // дефолтный размер поля 60×60
var STEPS_PER_SEC = 8; // скорость симуляции (тиков в секунду)
var speedMult = 1; // множитель скорости: 1/2/4/8 (ряд кнопок «N×», транзиентно, не в сейв)
const SAVE_VERSION = 2; // текущая версия формата сейва life-save-v1
const DEFAULT_BUDGET = 100; // дефолтный лимит клеток на игрока за партию
const PERSIST_INTERVAL_MS = 3000; // троттлинг записи сейва во время RUNNING
// Режим «Кампания»: игрок всегда цвет 1, враг (стабильные фигуры) — цвет 2.
const CAMPAIGN_PLAYER = 1;
const CAMPAIGN_ENEMY = 2;
// ==== ДАННЫЕ ====
// Палитра: фон/сетка/рамка + цвета игроков.
const COLORS = {
bg: '#111418',
grid: 'rgba(255,255,255,.06)',
border: 'rgba(255,255,255,.25)',
zoneDim: 'rgba(0,0,0,.28)', // затемнение чужой половины в фазах рисования
zoneLine: 'rgba(255,255,255,.22)', // линия границы зон рисования
eraseGhost: 'rgba(255,255,255,.22)', // ghost-превью ластика
stampOut: 'rgba(120,120,130,.55)', // клетки паттерна за границей своей зоны (вылет)
p1: '#e74c3c',
p2: '#2ecc71'
};
// Палитры canvas для светлой/тёмной темы. DOM-цвета живут в CSS-переменных
// (:root / :root[data-theme="light"]); applyTheme копирует нужную палитру в COLORS
// и выставляет data-theme на <html>. Цвета игроков (p1/p2) одинаковы в обеих темах.
const CANVAS_THEME = {
dark: {
bg: '#111418',
grid: 'rgba(255,255,255,.06)',
border: 'rgba(255,255,255,.25)',
zoneDim: 'rgba(0,0,0,.28)',
zoneLine: 'rgba(255,255,255,.22)',
eraseGhost: 'rgba(255,255,255,.22)',
stampOut: 'rgba(150,150,165,.55)'
},
light: {
bg: '#eef1f5',
grid: 'rgba(0,0,0,.07)',
border: 'rgba(0,0,0,.28)',
zoneDim: 'rgba(0,0,0,.16)',
zoneLine: 'rgba(0,0,0,.22)',
eraseGhost: 'rgba(0,0,0,.22)',
stampOut: 'rgba(90,90,105,.5)'
}
};
var theme = 'dark'; // 'dark' | 'light' — активная тема (настройка, входит в сейв)
// Применить тему: data-theme на <html>, canvas-палитра (COLORS), meta theme-color.
// Только визуал — состояние партии не трогает и сейв не пишет (это делает setTheme).
function applyTheme(name) {
theme = (name === 'light') ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
const pal = CANVAS_THEME[theme];
COLORS.bg = pal.bg;
COLORS.grid = pal.grid;
COLORS.border = pal.border;
COLORS.zoneDim = pal.zoneDim;
COLORS.zoneLine = pal.zoneLine;
COLORS.eraseGhost = pal.eraseGhost;
COLORS.stampOut = pal.stampOut;
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', pal.bg);
}
// Пользовательское переключение темы: применить и сохранить.
function setTheme(name) {
applyTheme(name);
syncDom();
persist();
}
// Инструменты: карандаш 1×1, кисть 3×3, ластик 1×1, ластик 4×4.
const TOOLS = [
{ id: 'pencil', name: 'Карандаш', size: 1, erase: false },
{ id: 'brush', name: 'Кисть', size: 3, erase: false },
{ id: 'erase1', name: 'Ластик', size: 1, erase: true },
{ id: 'erase4', name: 'Ластик 4×4', size: 4, erase: true }
];
// Готовые паттерны (штампы). cells — ASCII-строки: '1' = живая клетка, '.' = пусто.
// Якорь штампа — ЛЕВЫЙ ВЕРХНИЙ угол bbox (клетка под указателем). Порядок клеток
// фиксирован (сверху вниз, слева направо) — детерминированный расход бюджета.
// group — категория для группировки дропдауна (см. PATTERN_GROUPS).
// Паттерны НЕ входят в TOOLS: выбираются отдельным дропдауном #optPattern, активный
// живёт в глобале stamp (транзиентно, как speedMult — в сейв не пишется).
const PATTERNS = [
// --- Стойкие (неподвижные) ---
{ id: 'block', name: 'Блок', group: 'still', cells: ['11', '11'] },
{ id: 'beehive', name: 'Улей', group: 'still', cells: ['.11.', '1..1', '.11.'] },
{ id: 'tub', name: 'Ванна', group: 'still', cells: ['.1.', '1.1', '.1.'] },
{ id: 'boat', name: 'Лодка', group: 'still', cells: ['.11', '1.1', '.1.'] },
{ id: 'ship', name: 'Кораблик', group: 'still', cells: ['11.', '1.1', '.11'] },
{ id: 'loaf', name: 'Каравай', group: 'still', cells: ['.11.', '1..1', '.1.1', '..1.'] },
{ id: 'eater', name: 'Пожиратель', group: 'still', cells: ['11..', '1.1.', '..1.', '..11'] },
{ id: 'pond', name: 'Пруд', group: 'still', cells: ['.11.', '1..1', '1..1', '.11.'] },
{ id: 'aircraftcarrier', name: 'Авианосец', group: 'still', cells: ['11..', '1..1', '..11'] },
{ id: 'longboat', name: 'Длинная лодка', group: 'still', cells: ['.1..', '1.1.', '.1.1', '..11'] },
{ id: 'canoe', name: 'Каноэ', group: 'still', cells: ['...11', '....1', '...1.', '1.1..', '11...'] },
// --- Осцилляторы ---
{ id: 'blinker', name: 'Мигалка', group: 'osc', cells: ['111'] },
{ id: 'toad', name: 'Жаба', group: 'osc', cells: ['.111', '111.'] },
{ id: 'beacon', name: 'Маяк', group: 'osc', cells: ['11..', '11..', '..11', '..11'] },
{ id: 'clock', name: 'Часы', group: 'osc', cells: ['..1.', '1.1.', '.1.1', '.1..'] },
{ id: 'pulsar', name: 'Пульсар', group: 'osc', cells: [
'..111...111..',
'.............',
'1....1.1....1',
'1....1.1....1',
'1....1.1....1',
'..111...111..',
'.............',
'..111...111..',
'1....1.1....1',
'1....1.1....1',
'1....1.1....1',
'.............',
'..111...111..'
] },
{ id: 'pentadecathlon', name: 'Пентадекатлон', group: 'osc', cells: [
'..1....1..',
'11.1111.11',
'..1....1..'
] },
// --- Корабли ---
{ id: 'glider', name: 'Глайдер', group: 'ship', cells: ['.1.', '..1', '111'] },
{ id: 'lwss', name: 'Корабль LWSS', group: 'ship', cells: ['.1111', '1...1', '....1', '1..1.'] },
{ id: 'mwss', name: 'Корабль MWSS', group: 'ship', cells: ['...1..', '.1...1', '1.....', '1....1', '11111.'] },
{ id: 'hwss', name: 'Корабль HWSS', group: 'ship', cells: ['...11..', '.1....1', '1......', '1.....1', '111111.'] },
{ id: 'loafer', name: 'Loafer', group: 'ship', cells: ['.11..1.11', '1..1..11.', '.1.1.....', '..1......', '........1', '......111', '.....1...', '......1..', '.......11'] },
// --- Долгожители (метуселахи) ---
{ id: 'rpentomino', name: 'R-пентамино', group: 'growth', cells: ['.11', '11.', '.1.'] },
{ id: 'acorn', name: 'Жёлудь', group: 'growth', cells: ['.1.', '...1', '11..111'] },
{ id: 'diehard', name: 'Diehard', group: 'growth', cells: ['......1.', '11......', '.1...111'] },
{ id: 'thunderbird', name: 'Громовая птица', group: 'growth', cells: ['111', '...', '.1.', '.1.', '.1.'] },
{ id: 'bunnies', name: 'Кролики', group: 'growth', cells: ['1.....1.', '..1...1.', '..1..1.1', '.1.1....'] },
// --- Ружья ---
{ id: 'gun', name: 'Ружьё Госпера', group: 'gun', cells: [
'........................1...........',
'......................1.1...........',
'............11......11............11',
'...........1...1....11............11',
'11........1.....1...11..............',
'11........1...1.11....1.1...........',
'..........1.....1.......1...........',
'...........1...1....................',
'............11......................'
] },
// Ружьё Симкина: 36-клеточная версия (bbox 33×21), период 120, два ствола.
// LifeWiki даёт каноническую 29-клеточную / 33×14, но её RLE в доступных
// источниках нет — вшит вариант из patterns/simkinglidergun.rle.
{ id: 'simkingun', name: 'Ружьё Симкина', group: 'gun', cells: [
'11.....11........................',
'11.....11........................',
'.................................',
'....11...........................',
'....11...........................',
'.................................',
'.................................',
'.................................',
'.................................',
'......................11.11......',
'.....................1.....1.....',
'.....................1......1..11',
'.....................111...1...11',
'..........................1......',
'.................................',
'.................................',
'.................................',
'....................11...........',
'....................1............',
'.....................111.........',
'.......................1.........'
] }
];
// Категории паттернов для группировки дропдауна #optPattern (порядок групп и
// порядок записей внутри группы = порядок в PATTERNS). Ключ группы — поле group.
const PATTERN_GROUPS = [
{ key: 'still', label: 'Стойкие' },
{ key: 'osc', label: 'Осцилляторы' },
{ key: 'ship', label: 'Корабли' },
{ key: 'growth', label: 'Долгожители' },
{ key: 'gun', label: 'Ружья' }
];
// ==== КАМПАНИЯ: ДАННЫЕ УРОВНЕЙ ====
// На поле заранее расставлены стабильные фигуры ВРАГА (цвет 2); игрок играет
// цветом 1. Враг статичен (сам не ходит), поле живёт обычной симуляцией Конвея.
// Победа — уничтожить все клетки врага (0 клеток цвета 2); поражение — бюджет
// исчерпан, а враг ещё жив (фиксируется на авто-паузе стабильного поля). Зона
// рисования игрока — прямоугольник zone (может покрывать всё поле). Бюджет — свой.
// cells — координаты клеток ВРАГА [x, y] (компактно, без ASCII-сетки).
// Набор: 103 уровня — 7 ручных + 96 сгенерированных tools/gen-campaign-levels.mjs
// (детерминированно, id 5..100). Порядок массива = порядок уровней в игре;
// id — метаданные, по позиции не обязаны совпадать. Ручной «Издалека» (id4)
// ВСТАВЛЕН на 10-е место (индекс 9), а сгенерированные id 5..id10 сдвинуты на
// позицию вверх (индексы 3..8). Сразу за ним — ручные showcase-уровни id 101..103
// (индексы 10..12, «Флотилия»/«Погоня»/«Кролики»), затем остальные сгенерированные
// id 11..100 (индексы 13..102). Из-за вставок размеры монотонны только у
// сгенерированных (id 5..100, от 60 до 300); у ручных вставок размер свой.
// Зоны разнообразны (половина / центр / углы / вертикальная и горизонтальная
// полосы / произвольный прямоугольник). Зона «всё поле» осталась только у ручных
// «Разминки» и «Роя» — у сгенерированных её нет.
// Решаемость специально НЕ проверяется: среди уровней намеренно есть заведомо
// почти непроходимые (например с ружьём Госпера) — это задумка, а не дефект.
const CAMPAIGN_LEVELS = [
// --- 3 ручных уровня (базовая триада) ---
{ id: 1, name: 'Разминка', w: 30, h: 30, wrap: false, budget: 20,
zone: { x: 0, y: 0, w: 30, h: 30 },
cells: [[10,10], [11,10], [10,11], [11,11]] },
{ id: 2, name: 'Каравай', w: 40, h: 40, wrap: false, budget: 15,
zone: { x: 5, y: 5, w: 15, h: 15 },
cells: [[11,10], [12,10], [10,11], [13,11], [11,12], [13,12], [12,13]] },
{ id: 3, name: 'Рой', w: 60, h: 60, wrap: false, budget: 100,
zone: { x: 0, y: 0, w: 60, h: 60 },
cells: [[8,6], [9,6], [10,6], [14,6], [15,6], [16,6], [6,8], [11,8], [13,8], [18,8], [6,9], [11,9], [13,9], [18,9], [6,10], [11,10], [13,10], [18,10], [8,11], [9,11], [10,11], [14,11], [15,11], [16,11], [8,13], [9,13], [10,13], [14,13], [15,13], [16,13], [6,14], [11,14], [13,14], [18,14], [6,15], [11,15], [13,15], [18,15], [6,16], [11,16], [13,16], [18,16], [8,18], [9,18], [10,18], [14,18], [15,18], [16,18], [25,6], [26,6], [24,7], [27,7], [25,8], [26,8], [32,6], [33,6], [34,6], [39,6], [40,6], [41,6], [38,7], [39,7], [40,7], [47,6], [48,6], [46,7], [49,7], [47,8], [49,8], [48,9], [7,24], [8,24], [6,25], [9,25], [7,26], [8,26], [14,24], [15,24], [16,24], [21,24], [22,24], [23,24], [20,25], [21,25], [22,25], [29,24], [30,24], [28,25], [31,25], [29,26], [31,26], [30,27], [37,24], [38,24], [36,25], [39,25], [37,26], [38,26], [44,24], [45,24], [46,24], [7,34], [8,34], [9,34], [6,35], [7,35], [8,35], [15,34], [16,34], [14,35], [17,35], [15,36], [17,36], [16,37]] },
// --- сгенерированные (96): tools/gen-campaign-levels.mjs (SEED 0x5eed1234), id 5..100 ---
// Ручной «Издалека» вставлен на 10-е место (индекс 9); id5..id10 идут в обычном
// порядке (позиции 4..9), затем ручные showcase id 101..103 (индексы 10..12).
{ id: 5, name: 'Схема №5', w: 60, h: 60, wrap: false, budget: 68,
zone: { x: 13, y: 13, w: 33, h: 33 },
cells: [[21,40], [22,40], [23,40], [27,40], [28,40], [29,40], [19,42], [24,42], [26,42], [31,42], [19,43], [24,43], [26,43], [31,43], [19,44], [24,44], [26,44], [31,44], [21,45], [22,45], [23,45], [27,45], [28,45], [29,45], [21,47], [22,47], [23,47], [27,47], [28,47], [29,47], [19,48], [24,48], [26,48], [31,48], [19,49], [24,49], [26,49], [31,49], [19,50], [24,50], [26,50], [31,50], [21,52], [22,52], [23,52], [27,52], [28,52], [29,52], [57,1], [58,1], [59,1], [5,40], [6,40], [4,41], [7,41], [5,42], [6,42], [13,24], [14,24], [15,24], [12,25], [13,25], [14,25], [21,35], [22,35], [23,35]] },
{ id: 6, name: 'Схема №6', w: 62, h: 62, wrap: false, budget: 70,
zone: { x: 0, y: 0, w: 62, h: 31 },
cells: [[15,9], [16,9], [17,9], [21,9], [22,9], [23,9], [13,11], [18,11], [20,11], [25,11], [13,12], [18,12], [20,12], [25,12], [13,13], [18,13], [20,13], [25,13], [15,14], [16,14], [17,14], [21,14], [22,14], [23,14], [15,16], [16,16], [17,16], [21,16], [22,16], [23,16], [13,17], [18,17], [20,17], [25,17], [13,18], [18,18], [20,18], [25,18], [13,19], [18,19], [20,19], [25,19], [15,21], [16,21], [17,21], [21,21], [22,21], [23,21], [31,54], [32,54], [30,55], [33,55], [31,56], [33,56], [32,57], [49,22], [50,22], [51,22], [43,54], [44,54], [45,54], [42,55], [43,55], [44,55], [58,21], [59,21], [57,22], [60,22], [58,23], [59,23], [33,44], [34,44], [32,45], [35,45], [33,46], [34,46], [8,40], [9,40], [7,41], [10,41], [8,42], [9,42]] },
{ id: 7, name: 'Схема №7', w: 62, h: 62, wrap: false, budget: 70,
zone: { x: 14, y: 14, w: 34, h: 34 },
cells: [[23,29], [24,29], [25,29], [42,46], [43,46], [44,46], [41,47], [42,47], [43,47], [30,27], [31,27], [32,27], [29,28], [30,28], [31,28], [3,54], [4,54], [5,54], [31,24], [32,24], [33,24], [30,25], [31,25], [32,25]] },
{ id: 8, name: 'Схема №8', w: 64, h: 64, wrap: false, budget: 71,
zone: { x: 0, y: 0, w: 21, h: 25 },
cells: [[49,31], [50,31], [51,31], [55,31], [56,31], [57,31], [47,33], [52,33], [54,33], [59,33], [47,34], [52,34], [54,34], [59,34], [47,35], [52,35], [54,35], [59,35], [49,36], [50,36], [51,36], [55,36], [56,36], [57,36], [49,38], [50,38], [51,38], [55,38], [56,38], [57,38], [47,39], [52,39], [54,39], [59,39], [47,40], [52,40], [54,40], [59,40], [47,41], [52,41], [54,41], [59,41], [49,43], [50,43], [51,43], [55,43], [56,43], [57,43], [53,1], [54,1], [52,2], [55,2], [53,3], [55,3], [54,4], [43,29], [44,29], [45,29], [42,30], [43,30], [44,30], [36,21], [37,21], [35,22], [38,22], [36,23], [37,23], [1,2], [2,2], [3,2], [0,3], [1,3], [2,3]] },
{ id: 9, name: 'Схема №9', w: 66, h: 66, wrap: false, budget: 73,
zone: { x: 43, y: 28, w: 23, h: 38 },
cells: [[51,33], [52,33], [53,33], [48,34], [49,34], [47,35], [50,35], [48,36], [49,36], [3,23], [4,23], [2,24], [5,24], [3,25], [4,25], [49,7], [50,7], [48,8], [51,8], [49,9], [51,9], [50,10], [27,33], [28,33], [29,33], [26,34], [27,34], [28,34]] },
{ id: 10, name: 'Схема №10', w: 66, h: 66, wrap: false, budget: 73,
zone: { x: 40, y: 0, w: 11, h: 66 },
cells: [[24,50], [22,51], [24,51], [12,52], [13,52], [20,52], [21,52], [34,52], [35,52], [11,53], [15,53], [20,53], [21,53], [34,53], [35,53], [0,54], [1,54], [10,54], [16,54], [20,54], [21,54], [0,55], [1,55], [10,55], [14,55], [16,55], [17,55], [22,55], [24,55], [10,56], [16,56], [24,56], [11,57], [15,57], [12,58], [13,58], [54,53], [55,53], [56,53], [60,53], [61,53], [62,53], [52,55], [57,55], [59,55], [64,55], [52,56], [57,56], [59,56], [64,56], [52,57], [57,57], [59,57], [64,57], [54,58], [55,58], [56,58], [60,58], [61,58], [62,58], [54,60], [55,60], [56,60], [60,60], [61,60], [62,60], [52,61], [57,61], [59,61], [64,61], [52,62], [57,62], [59,62], [64,62], [52,63], [57,63], [59,63], [64,63], [54,65], [55,65], [56,65], [60,65], [61,65], [62,65], [42,31], [43,31], [44,31], [41,32], [42,32], [43,32], [58,6], [59,6], [57,7], [60,7], [58,8], [59,8], [55,4], [56,4], [57,4], [54,5], [55,5], [56,5], [5,61], [6,61], [4,62], [7,62], [5,63], [6,63], [29,36], [30,36], [28,37], [31,37], [29,38], [31,38], [30,39]] },
// --- ручной уровень «Издалека» (10-е место) ---
{ id: 4, name: 'Издалека', w: 60, h: 60, wrap: false, budget: 60,
zone: { x: 0, y: 0, w: 14, h: 20 },
cells: [[46,8], [44,9], [46,9], [34,10], [35,10], [42,10], [43,10], [56,10], [57,10], [33,11], [37,11], [42,11], [43,11], [56,11], [57,11], [22,12], [23,12], [32,12], [38,12], [42,12], [43,12], [22,13], [23,13], [32,13], [36,13], [38,13], [39,13], [44,13], [46,13], [32,14], [38,14], [46,14], [33,15], [37,15], [34,16], [35,16], [24,24], [25,24], [26,24], [30,24], [31,24], [32,24], [22,26], [27,26], [29,26], [34,26], [22,27], [27,27], [29,27], [34,27], [22,28], [27,28], [29,28], [34,28], [24,29], [25,29], [26,29], [30,29], [31,29], [32,29], [24,31], [25,31], [26,31], [30,31], [31,31], [32,31], [22,32], [27,32], [29,32], [34,32], [22,33], [27,33], [29,33], [34,33], [22,34], [27,34], [29,34], [34,34], [24,36], [25,36], [26,36], [30,36], [31,36], [32,36], [39,24], [40,24], [38,25], [41,25], [39,26], [40,26], [45,24], [46,24], [47,24], [44,25], [45,25], [46,25], [23,40], [24,40], [22,41], [25,41], [23,42], [25,42], [24,43], [30,44], [31,44], [32,44]] },
// --- ручные showcase-уровни (11–13-е места, сразу после «Издалека») ---
// Демонстрируют новые фигуры-штампы: движущиеся корабли MWSS/HWSS/Loafer,
// метуселаху «Кролики» и стойкие авианосец/длинная лодка/каноэ.
{ id: 101, name: 'Флотилия', w: 80, h: 80, wrap: false, budget: 120,
zone: { x: 0, y: 0, w: 40, h: 80 },
cells: [[33,18], [31,19], [35,19], [30,20], [30,21], [35,21], [30,22], [31,22], [32,22], [33,22], [34,22], [33,42], [34,42], [31,43], [36,43], [30,44], [30,45], [36,45], [30,46], [31,46], [32,46], [33,46], [34,46], [35,46], [8,62], [9,62], [8,63], [11,63], [10,64], [11,64], [19,72], [18,73], [20,73], [19,74], [21,74], [20,75], [21,75]] },
{ id: 102, name: 'Погоня', w: 90, h: 90, wrap: false, budget: 140,
zone: { x: 0, y: 0, w: 45, h: 90 },
cells: [[77,10], [76,11], [78,11], [77,12], [79,12], [78,13], [79,13], [21,25], [22,25], [25,25], [27,25], [28,25], [20,26], [23,26], [26,26], [27,26], [21,27], [23,27], [22,28], [28,29], [26,30], [27,30], [28,30], [25,31], [26,32], [27,33], [28,33], [53,55], [54,55], [57,55], [59,55], [60,55], [52,56], [55,56], [58,56], [59,56], [53,57], [55,57], [54,58], [60,59], [58,60], [59,60], [60,60], [57,61], [58,62], [59,63], [60,63], [15,78], [16,78], [16,79], [15,80], [12,81], [14,81], [12,82], [13,82]] },
{ id: 103, name: 'Кролики', w: 100, h: 100, wrap: false, budget: 160,
zone: { x: 0, y: 0, w: 50, h: 100 },
cells: [[12,12], [13,12], [12,13], [15,13], [14,14], [15,14], [40,35], [46,35], [42,36], [46,36], [42,37], [45,37], [47,37], [41,38], [43,38], [85,84], [86,84], [86,85], [85,86], [82,87], [84,87], [82,88], [83,88]] },
// --- остальные сгенерированные (id 11..100) ---
{ id: 11, name: 'Схема №11', w: 68, h: 68, wrap: false, budget: 74,
zone: { x: 0, y: 49, w: 68, h: 14 },
cells: [[30,28], [31,28], [32,28], [36,28], [37,28], [38,28], [28,30], [33,30], [35,30], [40,30], [28,31], [33,31], [35,31], [40,31], [28,32], [33,32], [35,32], [40,32], [30,33], [31,33], [32,33], [36,33], [37,33], [38,33], [30,35], [31,35], [32,35], [36,35], [37,35], [38,35], [28,36], [33,36], [35,36], [40,36], [28,37], [33,37], [35,37], [40,37], [28,38], [33,38], [35,38], [40,38], [30,40], [31,40], [32,40], [36,40], [37,40], [38,40], [39,14], [40,14], [38,15], [41,15], [39,16], [40,16], [11,31], [12,31], [10,32], [13,32], [11,33], [12,33], [60,14], [61,14], [59,15], [62,15], [60,16], [61,16], [17,8], [18,8], [19,8], [16,9], [17,9], [18,9]] },
{ id: 12, name: 'Схема №12', w: 70, h: 70, wrap: false, budget: 76,
zone: { x: 39, y: 10, w: 25, h: 23 },
cells: [[4,18], [5,18], [3,19], [6,19], [4,20], [6,20], [5,21], [22,38], [23,38], [24,38], [23,61], [24,61], [22,62], [25,62], [23,63], [25,63], [24,64], [35,34], [36,34], [37,34], [53,26], [54,26], [52,27], [55,27], [53,28], [55,28], [54,29]] },
{ id: 13, name: 'Схема №13', w: 72, h: 72, wrap: false, budget: 78,
zone: { x: 16, y: 16, w: 40, h: 40 },
cells: [[14,19], [15,19], [16,19], [13,20], [14,20], [15,20], [5,49], [6,49], [4,50], [7,50], [5,51], [6,51], [48,32], [49,32], [47,33], [50,33], [48,34], [49,34], [40,14], [41,14], [39,15], [42,15], [40,16], [41,16], [47,60], [48,60], [46,61], [49,61], [47,62], [48,62]] },
{ id: 14, name: 'Схема №14', w: 74, h: 74, wrap: false, budget: 79,
zone: { x: 0, y: 37, w: 74, h: 37 },
cells: [[20,11], [21,11], [22,11], [26,11], [27,11], [28,11], [18,13], [23,13], [25,13], [30,13], [18,14], [23,14], [25,14], [30,14], [18,15], [23,15], [25,15], [30,15], [20,16], [21,16], [22,16], [26,16], [27,16], [28,16], [20,18], [21,18], [22,18], [26,18], [27,18], [28,18], [18,19], [23,19], [25,19], [30,19], [18,20], [23,20], [25,20], [30,20], [18,21], [23,21], [25,21], [30,21], [20,23], [21,23], [22,23], [26,23], [27,23], [28,23], [10,44], [11,44], [9,45], [12,45], [10,46], [11,46], [37,10], [38,10], [36,11], [39,11], [37,12], [38,12], [13,25], [14,25], [12,26], [15,26], [13,27], [14,27], [10,35], [11,35], [9,36], [12,36], [10,37], [12,37], [11,38], [59,33], [60,33], [61,33], [58,34], [59,34], [60,34], [30,34], [31,34], [32,34]] },
{ id: 15, name: 'Схема №15', w: 74, h: 74, wrap: false, budget: 79,
zone: { x: 16, y: 16, w: 41, h: 41 },
cells: [[44,37], [45,37], [46,37], [43,38], [44,38], [45,38], [49,6], [50,6], [51,6], [27,26], [28,26], [26,27], [29,27], [27,28], [28,28], [31,10], [32,10], [30,11], [33,11], [31,12], [33,12], [32,13], [42,9], [43,9], [44,9], [41,10], [42,10], [43,10]] },
{ id: 16, name: 'Схема №16', w: 76, h: 76, wrap: false, budget: 81,
zone: { x: 0, y: 0, w: 39, h: 35 },
cells: [[42,12], [43,12], [44,12], [41,13], [42,13], [43,13], [54,63], [55,63], [56,63], [12,11], [13,11], [14,11], [11,12], [12,12], [13,12], [46,33], [47,33], [48,33], [45,34], [46,34], [47,34], [51,27], [52,27], [50,28], [53,28], [51,29], [52,29]] },
{ id: 17, name: 'Схема №17', w: 78, h: 78, wrap: false, budget: 82,
zone: { x: 40, y: 34, w: 38, h: 44 },
cells: [[39,7], [40,7], [41,7], [45,7], [46,7], [47,7], [37,9], [42,9], [44,9], [49,9], [37,10], [42,10], [44,10], [49,10], [37,11], [42,11], [44,11], [49,11], [39,12], [40,12], [41,12], [45,12], [46,12], [47,12], [39,14], [40,14], [41,14], [45,14], [46,14], [47,14], [37,15], [42,15], [44,15], [49,15], [37,16], [42,16], [44,16], [49,16], [37,17], [42,17], [44,17], [49,17], [39,19], [40,19], [41,19], [45,19], [46,19], [47,19], [14,56], [15,56], [13,57], [16,57], [14,58], [15,58], [73,65], [74,65], [72,66], [75,66], [73,67], [74,67], [15,3], [16,3], [17,3], [14,4], [15,4], [16,4], [43,70], [44,70], [45,70], [42,71], [43,71], [44,71]] },
{ id: 18, name: 'Схема №18', w: 80, h: 80, wrap: false, budget: 84,
zone: { x: 55, y: 0, w: 19, h: 80 },
cells: [[32,27], [33,27], [34,27], [38,27], [39,27], [40,27], [30,29], [35,29], [37,29], [42,29], [30,30], [35,30], [37,30], [42,30], [30,31], [35,31], [37,31], [42,31], [32,32], [33,32], [34,32], [38,32], [39,32], [40,32], [32,34], [33,34], [34,34], [38,34], [39,34], [40,34], [30,35], [35,35], [37,35], [42,35], [30,36], [35,36], [37,36], [42,36], [30,37], [35,37], [37,37], [42,37], [32,39], [33,39], [34,39], [38,39], [39,39], [40,39], [46,65], [47,65], [48,65], [45,66], [46,66], [47,66], [19,28], [20,28], [18,29], [21,29], [19,30], [20,30], [22,51], [23,51], [24,51], [21,52], [22,52], [23,52], [20,18], [21,18], [19,19], [22,19], [20,20], [22,20], [21,21], [30,9], [31,9], [32,9], [29,10], [30,10], [31,10], [64,30], [65,30], [66,30], [63,31], [64,31], [65,31]] },
{ id: 19, name: 'Схема №19', w: 82, h: 82, wrap: false, budget: 86,
zone: { x: 0, y: 54, w: 82, h: 13 },
cells: [[23,25], [24,25], [22,26], [25,26], [23,27], [25,27], [24,28], [66,39], [67,39], [68,39], [33,32], [34,32], [35,32], [32,33], [33,33], [34,33], [32,76], [33,76], [34,76], [31,77], [32,77], [33,77], [21,45], [22,45], [23,45], [20,46], [21,46], [22,46]] },
{ id: 20, name: 'Схема №20', w: 84, h: 84, wrap: false, budget: 87,
zone: { x: 26, y: 52, w: 31, h: 32 },
cells: [[51,9], [52,9], [53,9], [57,9], [58,9], [59,9], [49,11], [54,11], [56,11], [61,11], [49,12], [54,12], [56,12], [61,12], [49,13], [54,13], [56,13], [61,13], [51,14], [52,14], [53,14], [57,14], [58,14], [59,14], [51,16], [52,16], [53,16], [57,16], [58,16], [59,16], [49,17], [54,17], [56,17], [61,17], [49,18], [54,18], [56,18], [61,18], [49,19], [54,19], [56,19], [61,19], [51,21], [52,21], [53,21], [57,21], [58,21], [59,21], [72,63], [73,63], [71,64], [74,64], [72,65], [73,65], [29,80], [30,80], [28,81], [31,81], [29,82], [30,82], [17,72], [18,72], [16,73], [19,73], [17,74], [19,74], [18,75], [16,77], [17,77], [15,78], [18,78], [16,79], [17,79]] },
{ id: 21, name: 'Схема №21', w: 86, h: 86, wrap: false, budget: 89,
zone: { x: 19, y: 19, w: 47, h: 47 },
cells: [[33,44], [34,44], [32,45], [35,45], [33,46], [34,46], [79,53], [80,53], [78,54], [81,54], [79,55], [80,55], [14,73], [15,73], [16,73], [12,13], [13,13], [11,14], [14,14], [12,15], [14,15], [13,16], [78,16], [79,16], [77,17], [80,17], [78,18], [80,18], [79,19]] },
{ id: 22, name: 'Схема №22', w: 88, h: 88, wrap: false, budget: 90,
zone: { x: 0, y: 0, w: 88, h: 44 },
cells: [[38,54], [36,55], [38,55], [26,56], [27,56], [34,56], [35,56], [48,56], [49,56], [25,57], [29,57], [34,57], [35,57], [48,57], [49,57], [14,58], [15,58], [24,58], [30,58], [34,58], [35,58], [14,59], [15,59], [24,59], [28,59], [30,59], [31,59], [36,59], [38,59], [24,60], [30,60], [38,60], [25,61], [29,61], [26,62], [27,62], [39,15], [40,15], [41,15], [45,15], [46,15], [47,15], [37,17], [42,17], [44,17], [49,17], [37,18], [42,18], [44,18], [49,18], [37,19], [42,19], [44,19], [49,19], [39,20], [40,20], [41,20], [45,20], [46,20], [47,20], [39,22], [40,22], [41,22], [45,22], [46,22], [47,22], [37,23], [42,23], [44,23], [49,23], [37,24], [42,24], [44,24], [49,24], [37,25], [42,25], [44,25], [49,25], [39,27], [40,27], [41,27], [45,27], [46,27], [47,27], [18,21], [19,21], [17,22], [20,22], [18,23], [20,23], [19,24], [14,3], [15,3], [16,3], [13,4], [14,4], [15,4], [83,42], [84,42], [82,43], [85,43], [83,44], [85,44], [84,45], [58,44], [59,44], [57,45], [60,45], [58,46], [60,46], [59,47], [58,2], [59,2], [57,3], [60,3], [58,4], [59,4]] },
{ id: 23, name: 'Схема №23', w: 90, h: 90, wrap: false, budget: 92,
zone: { x: 20, y: 20, w: 50, h: 50 },
cells: [[75,4], [76,4], [77,4], [81,4], [82,4], [83,4], [73,6], [78,6], [80,6], [85,6], [73,7], [78,7], [80,7], [85,7], [73,8], [78,8], [80,8], [85,8], [75,9], [76,9], [77,9], [81,9], [82,9], [83,9], [75,11], [76,11], [77,11], [81,11], [82,11], [83,11], [73,12], [78,12], [80,12], [85,12], [73,13], [78,13], [80,13], [85,13], [73,14], [78,14], [80,14], [85,14], [75,16], [76,16], [77,16], [81,16], [82,16], [83,16], [6,84], [7,84], [8,84], [5,85], [6,85], [7,85], [35,4], [36,4], [37,4], [34,5], [35,5], [36,5], [63,69], [64,69], [62,70], [65,70], [63,71], [64,71], [29,24], [30,24], [28,25], [31,25], [29,26], [30,26], [11,79], [12,79], [13,79], [10,80], [11,80], [12,80]] },
{ id: 24, name: 'Схема №24', w: 92, h: 92, wrap: false, budget: 94,
zone: { x: 0, y: 0, w: 51, h: 34 },
cells: [[19,50], [20,50], [18,51], [21,51], [19,52], [21,52], [20,53], [85,74], [86,74], [84,75], [87,75], [85,76], [86,76], [33,36], [34,36], [32,37], [35,37], [33,38], [35,38], [34,39], [14,81], [15,81], [13,82], [16,82], [14,83], [15,83], [49,80], [50,80], [51,80], [48,81], [49,81], [50,81], [55,55], [56,55], [57,55], [54,56], [55,56], [56,56]] },
{ id: 25, name: 'Схема №25', w: 94, h: 94, wrap: false, budget: 95,
zone: { x: 65, y: 47, w: 29, h: 47 },
cells: [[66,80], [67,80], [65,81], [68,81], [66,82], [67,82], [52,11], [53,11], [54,11], [51,12], [52,12], [53,12], [50,58], [51,58], [52,58], [49,59], [50,59], [51,59], [60,22], [61,22], [62,22], [59,23], [60,23], [61,23], [21,23], [22,23], [23,23], [20,24], [21,24], [22,24], [49,3], [50,3], [51,3], [48,4], [49,4], [50,4]] },
{ id: 26, name: 'Схема №26', w: 96, h: 96, wrap: false, budget: 97,
zone: { x: 60, y: 0, w: 14, h: 96 },
cells: [[42,33], [43,33], [44,33], [48,33], [49,33], [50,33], [40,35], [45,35], [47,35], [52,35], [40,36], [45,36], [47,36], [52,36], [40,37], [45,37], [47,37], [52,37], [42,38], [43,38], [44,38], [48,38], [49,38], [50,38], [42,40], [43,40], [44,40], [48,40], [49,40], [50,40], [40,41], [45,41], [47,41], [52,41], [40,42], [45,42], [47,42], [52,42], [40,43], [45,43], [47,43], [52,43], [42,45], [43,45], [44,45], [48,45], [49,45], [50,45], [11,4], [12,4], [13,4], [10,5], [11,5], [12,5], [19,24], [20,24], [18,25], [21,25], [19,26], [20,26], [64,17], [65,17], [66,17], [28,9], [29,9], [27,10], [30,10], [28,11], [29,11], [92,1], [93,1], [94,1], [74,3], [75,3], [76,3], [73,4], [74,4], [75,4], [2,30], [3,30], [4,30], [1,31], [2,31], [3,31]] },
{ id: 27, name: 'Схема №27', w: 100, h: 100, wrap: false, budget: 100,
zone: { x: 0, y: 64, w: 100, h: 15 },
cells: [[15,12], [16,12], [14,13], [17,13], [15,14], [16,14], [53,69], [54,69], [55,69], [52,70], [53,70], [54,70], [86,40], [87,40], [88,40], [85,41], [86,41], [87,41], [42,75], [43,75], [41,76], [44,76], [42,77], [43,77], [34,75], [35,75], [33,76], [36,76], [34,77], [35,77], [34,63], [35,63], [36,63], [33,64], [34,64], [35,64]] },
{ id: 28, name: 'Схема №28', w: 102, h: 102, wrap: false, budget: 102,
zone: { x: 32, y: 21, w: 59, h: 33 },
cells: [[44,40], [45,40], [43,41], [46,41], [44,42], [45,42], [16,15], [17,15], [15,16], [18,16], [16,17], [17,17], [5,33], [6,33], [4,34], [7,34], [5,35], [7,35], [6,36], [36,56], [37,56], [38,56], [35,57], [36,57], [37,57], [65,14], [66,14], [67,14], [64,15], [65,15], [66,15], [70,25], [71,25], [72,25], [69,26], [70,26], [71,26]] },
{ id: 29, name: 'Схема №29', w: 104, h: 104, wrap: false, budget: 103,
zone: { x: 23, y: 23, w: 57, h: 57 },
cells: [[74,66], [75,66], [76,66], [80,66], [81,66], [82,66], [72,68], [77,68], [79,68], [84,68], [72,69], [77,69], [79,69], [84,69], [72,70], [77,70], [79,70], [84,70], [74,71], [75,71], [76,71], [80,71], [81,71], [82,71], [74,73], [75,73], [76,73], [80,73], [81,73], [82,73], [72,74], [77,74], [79,74], [84,74], [72,75], [77,75], [79,75], [84,75], [72,76], [77,76], [79,76], [84,76], [74,78], [75,78], [76,78], [80,78], [81,78], [82,78], [13,29], [14,29], [15,29], [39,49], [40,49], [41,49], [83,86], [84,86], [82,87], [85,87], [83,88], [85,88], [84,89], [2,35], [3,35], [1,36], [4,36], [2,37], [3,37], [74,54], [75,54], [73,55], [76,55], [74,56], [75,56]] },
{ id: 30, name: 'Схема №30', w: 106, h: 106, wrap: false, budget: 105,
zone: { x: 0, y: 53, w: 106, h: 53 },
cells: [[68,43], [69,43], [70,43], [74,43], [75,43], [76,43], [66,45], [71,45], [73,45], [78,45], [66,46], [71,46], [73,46], [78,46], [66,47], [71,47], [73,47], [78,47], [68,48], [69,48], [70,48], [74,48], [75,48], [76,48], [68,50], [69,50], [70,50], [74,50], [75,50], [76,50], [66,51], [71,51], [73,51], [78,51], [66,52], [71,52], [73,52], [78,52], [66,53], [71,53], [73,53], [78,53], [68,55], [69,55], [70,55], [74,55], [75,55], [76,55], [86,100], [87,100], [85,101], [88,101], [86,102], [87,102], [50,30], [51,30], [49,31], [52,31], [50,32], [51,32], [11,33], [12,33], [10,34], [13,34], [11,35], [12,35], [88,11], [89,11], [87,12], [90,12], [88,13], [90,13], [89,14], [83,75], [84,75], [82,76], [85,76], [83,77], [84,77], [83,96], [84,96], [85,96], [82,97], [83,97], [84,97], [88,61], [89,61], [87,62], [90,62], [88,63], [89,63]] },
{ id: 31, name: 'Схема №31', w: 108, h: 108, wrap: false, budget: 106,
zone: { x: 24, y: 24, w: 59, h: 59 },
cells: [[49,3], [50,3], [48,4], [51,4], [49,5], [51,5], [50,6], [4,32], [5,32], [3,33], [6,33], [4,34], [5,34], [93,16], [94,16], [95,16], [85,73], [86,73], [84,74], [87,74], [85,75], [87,75], [86,76], [60,40], [61,40], [59,41], [62,41], [60,42], [61,42], [52,29], [53,29], [54,29], [51,30], [52,30], [53,30]] },
{ id: 32, name: 'Схема №32', w: 110, h: 110, wrap: false, budget: 108,
zone: { x: 0, y: 0, w: 58, h: 34 },
cells: [[93,10], [94,10], [95,10], [99,10], [100,10], [101,10], [91,12], [96,12], [98,12], [103,12], [91,13], [96,13], [98,13], [103,13], [91,14], [96,14], [98,14], [103,14], [93,15], [94,15], [95,15], [99,15], [100,15], [101,15], [93,17], [94,17], [95,17], [99,17], [100,17], [101,17], [91,18], [96,18], [98,18], [103,18], [91,19], [96,19], [98,19], [103,19], [91,20], [96,20], [98,20], [103,20], [93,22], [94,22], [95,22], [99,22], [100,22], [101,22], [37,77], [38,77], [39,77], [36,78], [37,78], [38,78], [7,59], [8,59], [9,59], [47,15], [48,15], [49,15], [58,18], [59,18], [57,19], [60,19], [58,20], [59,20], [50,51], [51,51], [52,51], [49,52], [50,52], [51,52]] },
{ id: 33, name: 'Схема №33', w: 112, h: 112, wrap: false, budget: 110,
zone: { x: 62, y: 76, w: 50, h: 36 },
cells: [[90,52], [91,52], [92,52], [89,53], [90,53], [91,53], [19,67], [20,67], [21,67], [87,83], [88,83], [86,84], [89,84], [87,85], [89,85], [88,86], [44,26], [45,26], [43,27], [46,27], [44,28], [45,28], [33,1], [34,1], [32,2], [35,2], [33,3], [34,3], [24,102], [25,102], [23,103], [26,103], [24,104], [26,104], [25,105]] },
{ id: 34, name: 'Схема №34', w: 114, h: 114, wrap: false, budget: 111,
zone: { x: 67, y: 0, w: 29, h: 114 },
cells: [[29,44], [27,45], [29,45], [17,46], [18,46], [25,46], [26,46], [39,46], [40,46], [16,47], [20,47], [25,47], [26,47], [39,47], [40,47], [5,48], [6,48], [15,48], [21,48], [25,48], [26,48], [5,49], [6,49], [15,49], [19,49], [21,49], [22,49], [27,49], [29,49], [15,50], [21,50], [29,50], [16,51], [20,51], [17,52], [18,52], [58,92], [59,92], [60,92], [64,92], [65,92], [66,92], [56,94], [61,94], [63,94], [68,94], [56,95], [61,95], [63,95], [68,95], [56,96], [61,96], [63,96], [68,96], [58,97], [59,97], [60,97], [64,97], [65,97], [66,97], [58,99], [59,99], [60,99], [64,99], [65,99], [66,99], [56,100], [61,100], [63,100], [68,100], [56,101], [61,101], [63,101], [68,101], [56,102], [61,102], [63,102], [68,102], [58,104], [59,104], [60,104], [64,104], [65,104], [66,104], [42,17], [43,17], [44,17], [41,18], [42,18], [43,18], [19,31], [20,31], [18,32], [21,32], [19,33], [21,33], [20,34], [84,45], [85,45], [83,46], [86,46], [84,47], [85,47], [69,60], [70,60], [71,60], [68,61], [69,61], [70,61], [46,32], [47,32], [45,33], [48,33], [46,34], [48,34], [47,35], [36,68], [37,68], [38,68], [35,69], [36,69], [37,69]] },
{ id: 35, name: 'Схема №35', w: 118, h: 118, wrap: false, budget: 114,
zone: { x: 0, y: 93, w: 118, h: 16 },
cells: [[70,42], [71,42], [72,42], [76,42], [77,42], [78,42], [68,44], [73,44], [75,44], [80,44], [68,45], [73,45], [75,45], [80,45], [68,46], [73,46], [75,46], [80,46], [70,47], [71,47], [72,47], [76,47], [77,47], [78,47], [70,49], [71,49], [72,49], [76,49], [77,49], [78,49], [68,50], [73,50], [75,50], [80,50], [68,51], [73,51], [75,51], [80,51], [68,52], [73,52], [75,52], [80,52], [70,54], [71,54], [72,54], [76,54], [77,54], [78,54], [30,18], [31,18], [32,18], [29,19], [30,19], [31,19], [10,29], [11,29], [12,29], [9,30], [10,30], [11,30], [107,52], [108,52], [109,52], [106,53], [107,53], [108,53], [39,10], [40,10], [38,11], [41,11], [39,12], [40,12], [93,47], [94,47], [92,48], [95,48], [93,49], [94,49]] },
{ id: 36, name: 'Схема №36', w: 120, h: 120, wrap: false, budget: 116,
zone: { x: 6, y: 51, w: 72, h: 45 },
cells: [[109,71], [110,71], [108,72], [111,72], [109,73], [110,73], [19,65], [20,65], [21,65], [18,66], [19,66], [20,66], [107,67], [108,67], [109,67], [106,68], [107,68], [108,68], [29,55], [30,55], [28,56], [31,56], [29,57], [31,57], [30,58], [82,69], [83,69], [81,70], [84,70], [82,71], [84,71], [83,72], [61,89], [62,89], [63,89], [94,42], [95,42], [93,43], [96,43], [94,44], [96,44], [95,45]] },
{ id: 37, name: 'Схема №37', w: 122, h: 122, wrap: false, budget: 118,
zone: { x: 27, y: 27, w: 67, h: 67 },
cells: [[56,33], [57,33], [58,33], [23,98], [24,98], [25,98], [22,99], [23,99], [24,99], [116,10], [117,10], [118,10], [20,52], [21,52], [19,53], [22,53], [20,54], [22,54], [21,55], [87,91], [88,91], [86,92], [89,92], [87,93], [89,93], [88,94], [95,88], [96,88], [94,89], [97,89], [95,90], [96,90], [1,7], [2,7], [3,7]] },
{ id: 38, name: 'Схема №38', w: 124, h: 124, wrap: false, budget: 119,
zone: { x: 0, y: 0, w: 124, h: 62 },
cells: [[110,91], [111,91], [112,91], [116,91], [117,91], [118,91], [108,93], [113,93], [115,93], [120,93], [108,94], [113,94], [115,94], [120,94], [108,95], [113,95], [115,95], [120,95], [110,96], [111,96], [112,96], [116,96], [117,96], [118,96], [110,98], [111,98], [112,98], [116,98], [117,98], [118,98], [108,99], [113,99], [115,99], [120,99], [108,100], [113,100], [115,100], [120,100], [108,101], [113,101], [115,101], [120,101], [110,103], [111,103], [112,103], [116,103], [117,103], [118,103], [67,92], [68,92], [69,92], [66,93], [67,93], [68,93], [11,102], [12,102], [13,102], [10,103], [11,103], [12,103], [16,58], [17,58], [15,59], [18,59], [16,60], [17,60], [88,84], [89,84], [90,84], [87,85], [88,85], [89,85], [112,82], [113,82], [114,82], [111,83], [112,83], [113,83], [17,14], [18,14], [19,14], [16,15], [17,15], [18,15], [19,92], [20,92], [21,92], [25,51], [26,51], [24,52], [27,52], [25,53], [26,53]] },
{ id: 39, name: 'Схема №39', w: 126, h: 126, wrap: false, budget: 121,
zone: { x: 28, y: 28, w: 69, h: 69 },
cells: [[90,84], [91,84], [89,85], [92,85], [90,86], [91,86], [107,12], [108,12], [106,13], [109,13], [107,14], [109,14], [108,15], [95,87], [96,87], [94,88], [97,88], [95,89], [96,89], [54,15], [55,15], [56,15], [121,44], [122,44], [123,44], [120,45], [121,45], [122,45], [43,23], [44,23], [45,23], [42,24], [43,24], [44,24], [56,30], [57,30], [58,30]] },
{ id: 40, name: 'Схема №40', w: 130, h: 130, wrap: false, budget: 124,
zone: { x: 0, y: 0, w: 84, h: 44 },
cells: [[86,104], [87,104], [88,104], [85,105], [86,105], [87,105], [76,65], [77,65], [78,65], [91,48], [92,48], [90,49], [93,49], [91,50], [93,50], [92,51], [5,34], [6,34], [7,34], [127,117], [128,117], [126,118], [129,118], [127,119], [128,119], [86,96], [87,96], [88,96], [85,97], [86,97], [87,97], [125,10], [126,10], [124,11], [127,11], [125,12], [127,12], [126,13]] },
{ id: 41, name: 'Схема №41', w: 132, h: 132, wrap: false, budget: 126,
zone: { x: 76, y: 85, w: 56, h: 47 },
cells: [[49,111], [50,111], [51,111], [55,111], [56,111], [57,111], [47,113], [52,113], [54,113], [59,113], [47,114], [52,114], [54,114], [59,114], [47,115], [52,115], [54,115], [59,115], [49,116], [50,116], [51,116], [55,116], [56,116], [57,116], [49,118], [50,118], [51,118], [55,118], [56,118], [57,118], [47,119], [52,119], [54,119], [59,119], [47,120], [52,120], [54,120], [59,120], [47,121], [52,121], [54,121], [59,121], [49,123], [50,123], [51,123], [55,123], [56,123], [57,123], [1,130], [2,130], [3,130], [0,131], [1,131], [2,131], [36,49], [37,49], [35,50], [38,50], [36,51], [37,51], [114,96], [115,96], [113,97], [116,97], [114,98], [116,98], [115,99], [103,97], [104,97], [102,98], [105,98], [103,99], [104,99], [115,116], [116,116], [114,117], [117,117], [115,118], [117,118], [116,119], [77,114], [78,114], [79,114], [76,115], [77,115], [78,115]] },
{ id: 42, name: 'Схема №42', w: 134, h: 134, wrap: false, budget: 127,
zone: { x: 37, y: 0, w: 32, h: 134 },
cells: [[85,30], [86,30], [87,30], [91,30], [92,30], [93,30], [83,32], [88,32], [90,32], [95,32], [83,33], [88,33], [90,33], [95,33], [83,34], [88,34], [90,34], [95,34], [85,35], [86,35], [87,35], [91,35], [92,35], [93,35], [85,37], [86,37], [87,37], [91,37], [92,37], [93,37], [83,38], [88,38], [90,38], [95,38], [83,39], [88,39], [90,39], [95,39], [83,40], [88,40], [90,40], [95,40], [85,42], [86,42], [87,42], [91,42], [92,42], [93,42], [57,17], [58,17], [59,17], [56,18], [57,18], [58,18], [28,75], [29,75], [27,76], [30,76], [28,77], [29,77], [110,107], [111,107], [109,108], [112,108], [110,109], [112,109], [111,110], [119,54], [120,54], [121,54], [118,55], [119,55], [120,55], [55,50], [56,50], [54,51], [57,51], [55,52], [56,52], [33,23], [34,23], [32,24], [35,24], [33,25], [34,25], [120,123], [121,123], [122,123], [119,124], [120,124], [121,124], [41,120], [42,120], [43,120], [40,121], [41,121], [42,121]] },
{ id: 43, name: 'Схема №43', w: 136, h: 136, wrap: false, budget: 129,
zone: { x: 0, y: 87, w: 136, h: 34 },
cells: [[50,131], [51,131], [52,131], [49,132], [50,132], [51,132], [64,87], [65,87], [66,87], [111,133], [112,133], [113,133], [110,134], [111,134], [112,134], [101,37], [102,37], [100,38], [103,38], [101,39], [102,39], [42,68], [43,68], [41,69], [44,69], [42,70], [43,70], [62,18], [63,18], [64,18], [61,19], [62,19], [63,19], [72,93], [73,93], [71,94], [74,94], [72,95], [73,95]] },
{ id: 44, name: 'Схема №44', w: 140, h: 140, wrap: false, budget: 132,
zone: { x: 22, y: 53, w: 41, h: 79 },
cells: [[123,30], [124,30], [125,30], [129,30], [130,30], [131,30], [121,32], [126,32], [128,32], [133,32], [121,33], [126,33], [128,33], [133,33], [121,34], [126,34], [128,34], [133,34], [123,35], [124,35], [125,35], [129,35], [130,35], [131,35], [123,37], [124,37], [125,37], [129,37], [130,37], [131,37], [121,38], [126,38], [128,38], [133,38], [121,39], [126,39], [128,39], [133,39], [121,40], [126,40], [128,40], [133,40], [123,42], [124,42], [125,42], [129,42], [130,42], [131,42], [4,11], [5,11], [6,11], [3,12], [4,12], [5,12], [127,101], [128,101], [129,101], [126,102], [127,102], [128,102], [8,38], [9,38], [7,39], [10,39], [8,40], [9,40], [91,3], [92,3], [90,4], [93,4], [91,5], [93,5], [92,6], [70,112], [71,112], [72,112], [69,113], [70,113], [71,113], [26,125], [27,125], [28,125], [25,126], [26,126], [27,126]] },
{ id: 45, name: 'Схема №45', w: 142, h: 142, wrap: false, budget: 134,
zone: { x: 32, y: 32, w: 78, h: 78 },
cells: [[104,129], [105,129], [103,130], [106,130], [104,131], [105,131], [45,50], [46,50], [44,51], [47,51], [45,52], [46,52], [8,93], [9,93], [7,94], [10,94], [8,95], [9,95], [40,32], [41,32], [39,33], [42,33], [40,34], [41,34], [92,96], [93,96], [94,96], [91,97], [92,97], [93,97], [128,130], [129,130], [130,130], [127,131], [128,131], [129,131], [7,53], [8,53], [6,54], [9,54], [7,55], [8,55]] },
{ id: 46, name: 'Схема №46', w: 144, h: 144, wrap: false, budget: 135,
zone: { x: 0, y: 72, w: 144, h: 72 },
cells: [[64,98], [62,99], [64,99], [52,100], [53,100], [60,100], [61,100], [74,100], [75,100], [51,101], [55,101], [60,101], [61,101], [74,101], [75,101], [40,102], [41,102], [50,102], [56,102], [60,102], [61,102], [40,103], [41,103], [50,103], [54,103], [56,103], [57,103], [62,103], [64,103], [50,104], [56,104], [64,104], [51,105], [55,105], [52,106], [53,106], [47,45], [48,45], [49,45], [53,45], [54,45], [55,45], [45,47], [50,47], [52,47], [57,47], [45,48], [50,48], [52,48], [57,48], [45,49], [50,49], [52,49], [57,49], [47,50], [48,50], [49,50], [53,50], [54,50], [55,50], [47,52], [48,52], [49,52], [53,52], [54,52], [55,52], [45,53], [50,53], [52,53], [57,53], [45,54], [50,54], [52,54], [57,54], [45,55], [50,55], [52,55], [57,55], [47,57], [48,57], [49,57], [53,57], [54,57], [55,57], [121,3], [122,3], [123,3], [120,4], [121,4], [122,4], [53,69], [54,69], [55,69], [52,70], [53,70], [54,70], [10,123], [11,123], [9,124], [12,124], [10,125], [12,125], [11,126], [108,30], [109,30], [107,31], [110,31], [108,32], [110,32], [109,33], [36,29], [37,29], [38,29], [35,30], [36,30], [37,30], [31,125], [32,125], [33,125], [30,126], [31,126], [32,126], [130,23], [131,23], [132,23], [129,24], [130,24], [131,24]] },
{ id: 47, name: 'Схема №47', w: 148, h: 148, wrap: false, budget: 138,
zone: { x: 33, y: 33, w: 81, h: 81 },
cells: [[137,78], [138,78], [139,78], [143,78], [144,78], [145,78], [135,80], [140,80], [142,80], [147,80], [135,81], [140,81], [142,81], [147,81], [135,82], [140,82], [142,82], [147,82], [137,83], [138,83], [139,83], [143,83], [144,83], [145,83], [137,85], [138,85], [139,85], [143,85], [144,85], [145,85], [135,86], [140,86], [142,86], [147,86], [135,87], [140,87], [142,87], [147,87], [135,88], [140,88], [142,88], [147,88], [137,90], [138,90], [139,90], [143,90], [144,90], [145,90], [122,83], [123,83], [124,83], [121,84], [122,84], [123,84], [20,141], [21,141], [22,141], [19,142], [20,142], [21,142], [60,25], [61,25], [59,26], [62,26], [60,27], [61,27], [69,38], [70,38], [71,38], [68,39], [69,39], [70,39], [94,47], [95,47], [93,48], [96,48], [94,49], [95,49], [120,73], [121,73], [122,73], [119,74], [120,74], [121,74]] },
{ id: 48, name: 'Схема №48', w: 150, h: 150, wrap: false, budget: 140,
zone: { x: 0, y: 0, w: 65, h: 90 },
cells: [[106,90], [107,90], [108,90], [105,91], [106,91], [107,91], [138,144], [139,144], [140,144], [86,19], [87,19], [88,19], [85,20], [86,20], [87,20], [14,138], [15,138], [16,138], [98,15], [99,15], [100,15], [97,16], [98,16], [99,16], [126,109], [127,109], [128,109], [125,110], [126,110], [127,110], [69,7], [70,7], [68,8], [71,8], [69,9], [71,9], [70,10], [19,21], [20,21], [21,21], [18,22], [19,22], [20,22]] },
{ id: 49, name: 'Схема №49', w: 152, h: 152, wrap: false, budget: 142,
zone: { x: 104, y: 93, w: 48, h: 59 },
cells: [[114,39], [115,39], [113,40], [116,40], [114,41], [115,41], [125,39], [126,39], [124,40], [127,40], [125,41], [126,41], [82,21], [83,21], [81,22], [84,22], [82,23], [84,23], [83,24], [2,135], [3,135], [1,136], [4,136], [2,137], [3,137], [67,146], [68,146], [69,146], [91,147], [92,147], [93,147], [113,91], [114,91], [112,92], [115,92], [113,93], [114,93], [30,70], [31,70], [32,70], [29,71], [30,71], [31,71]] },
{ id: 50, name: 'Схема №50', w: 154, h: 154, wrap: false, budget: 143,
zone: { x: 41, y: 0, w: 36, h: 154 },
cells: [[96,101], [97,101], [98,101], [102,101], [103,101], [104,101], [94,103], [99,103], [101,103], [106,103], [94,104], [99,104], [101,104], [106,104], [94,105], [99,105], [101,105], [106,105], [96,106], [97,106], [98,106], [102,106], [103,106], [104,106], [96,108], [97,108], [98,108], [102,108], [103,108], [104,108], [94,109], [99,109], [101,109], [106,109], [94,110], [99,110], [101,110], [106,110], [94,111], [99,111], [101,111], [106,111], [96,113], [97,113], [98,113], [102,113], [103,113], [104,113], [142,144], [143,144], [141,145], [144,145], [142,146], [144,146], [143,147], [91,23], [92,23], [93,23], [119,137], [120,137], [118,138], [121,138], [119,139], [121,139], [120,140], [124,91], [125,91], [123,92], [126,92], [124,93], [125,93], [57,92], [58,92], [59,92], [56,93], [57,93], [58,93], [120,110], [121,110], [122,110], [119,111], [120,111], [121,111], [134,78], [135,78], [133,79], [136,79], [134,80], [135,80], [38,140], [39,140], [37,141], [40,141], [38,142], [39,142], [85,73], [86,73], [87,73]] },
{ id: 51, name: 'Схема №51', w: 158, h: 158, wrap: false, budget: 146,
zone: { x: 0, y: 68, w: 158, h: 21 },
cells: [[18,81], [19,81], [17,82], [20,82], [18,83], [19,83], [139,6], [140,6], [141,6], [138,7], [139,7], [140,7], [10,146], [11,146], [9,147], [12,147], [10,148], [12,148], [11,149], [91,96], [92,96], [93,96], [90,97], [91,97], [92,97], [91,135], [92,135], [93,135], [90,136], [91,136], [92,136], [151,46], [152,46], [150,47], [153,47], [151,48], [152,48], [105,71], [106,71], [107,71], [129,110], [130,110], [128,111], [131,111], [129,112], [130,112]] },
{ id: 52, name: 'Схема №52', w: 160, h: 160, wrap: false, budget: 148,
zone: { x: 27, y: 28, w: 109, h: 56 },
cells: [[57,74], [58,74], [56,75], [59,75], [57,76], [58,76], [146,109], [147,109], [145,110], [148,110], [146,111], [148,111], [147,112], [105,40], [106,40], [107,40], [124,74], [125,74], [126,74], [123,75], [124,75], [125,75], [10,151], [11,151], [9,152], [12,152], [10,153], [12,153], [11,154], [91,80], [92,80], [93,80], [90,81], [91,81], [92,81], [52,45], [53,45], [54,45], [51,46], [52,46], [53,46], [49,109], [50,109], [51,109]] },
{ id: 53, name: 'Схема №53', w: 162, h: 162, wrap: false, budget: 150,
zone: { x: 36, y: 36, w: 89, h: 89 },
cells: [[78,134], [79,134], [80,134], [84,134], [85,134], [86,134], [76,136], [81,136], [83,136], [88,136], [76,137], [81,137], [83,137], [88,137], [76,138], [81,138], [83,138], [88,138], [78,139], [79,139], [80,139], [84,139], [85,139], [86,139], [78,141], [79,141], [80,141], [84,141], [85,141], [86,141], [76,142], [81,142], [83,142], [88,142], [76,143], [81,143], [83,143], [88,143], [76,144], [81,144], [83,144], [88,144], [78,146], [79,146], [80,146], [84,146], [85,146], [86,146], [75,93], [76,93], [74,94], [77,94], [75,95], [76,95], [111,132], [112,132], [113,132], [8,93], [9,93], [7,94], [10,94], [8,95], [9,95], [44,47], [45,47], [43,48], [46,48], [44,49], [46,49], [45,50], [26,98], [27,98], [28,98], [25,99], [26,99], [27,99], [126,10], [127,10], [125,11], [128,11], [126,12], [128,12], [127,13], [42,149], [43,149], [44,149], [41,150], [42,150], [43,150]] },
{ id: 54, name: 'Схема №54', w: 166, h: 166, wrap: false, budget: 153,
zone: { x: 0, y: 0, w: 166, h: 83 },
cells: [[114,5], [115,5], [116,5], [120,5], [121,5], [122,5], [112,7], [117,7], [119,7], [124,7], [112,8], [117,8], [119,8], [124,8], [112,9], [117,9], [119,9], [124,9], [114,10], [115,10], [116,10], [120,10], [121,10], [122,10], [114,12], [115,12], [116,12], [120,12], [121,12], [122,12], [112,13], [117,13], [119,13], [124,13], [112,14], [117,14], [119,14], [124,14], [112,15], [117,15], [119,15], [124,15], [114,17], [115,17], [116,17], [120,17], [121,17], [122,17], [94,15], [95,15], [96,15], [93,16], [94,16], [95,16], [19,11], [20,11], [18,12], [21,12], [19,13], [21,13], [20,14], [160,107], [161,107], [159,108], [162,108], [160,109], [162,109], [161,110], [132,79], [133,79], [131,80], [134,80], [132,81], [133,81], [89,158], [90,158], [91,158], [88,159], [89,159], [90,159], [44,119], [45,119], [43,120], [46,120], [44,121], [46,121], [45,122], [30,129], [31,129], [32,129], [29,130], [30,130], [31,130], [80,40], [81,40], [82,40], [32,111], [33,111], [34,111]] },
{ id: 55, name: 'Схема №55', w: 168, h: 168, wrap: false, budget: 154,
zone: { x: 38, y: 38, w: 92, h: 92 },
cells: [[54,144], [55,144], [53,145], [56,145], [54,146], [56,146], [55,147], [63,141], [64,141], [65,141], [62,142], [63,142], [64,142], [15,106], [16,106], [17,106], [14,107], [15,107], [16,107], [15,70], [16,70], [14,71], [17,71], [15,72], [16,72], [155,102], [156,102], [157,102], [154,103], [155,103], [156,103], [143,52], [144,52], [145,52], [24,124], [25,124], [23,125], [26,125], [24,126], [26,126], [25,127], [125,20], [126,20], [127,20]] },
{ id: 56, name: 'Схема №56', w: 170, h: 170, wrap: false, budget: 156,
zone: { x: 0, y: 0, w: 99, h: 97 },
cells: [[24,131], [25,131], [26,131], [30,131], [31,131], [32,131], [22,133], [27,133], [29,133], [34,133], [22,134], [27,134], [29,134], [34,134], [22,135], [27,135], [29,135], [34,135], [24,136], [25,136], [26,136], [30,136], [31,136], [32,136], [24,138], [25,138], [26,138], [30,138], [31,138], [32,138], [22,139], [27,139], [29,139], [34,139], [22,140], [27,140], [29,140], [34,140], [22,141], [27,141], [29,141], [34,141], [24,143], [25,143], [26,143], [30,143], [31,143], [32,143], [97,115], [98,115], [96,116], [99,116], [97,117], [98,117], [12,35], [13,35], [14,35], [53,138], [54,138], [55,138], [41,57], [42,57], [43,57], [69,163], [70,163], [68,164], [71,164], [69,165], [71,165], [70,166], [145,10], [146,10], [144,11], [147,11], [145,12], [146,12], [40,146], [41,146], [39,147], [42,147], [40,148], [41,148]] },
{ id: 57, name: 'Схема №57', w: 174, h: 174, wrap: false, budget: 159,
zone: { x: 68, y: 67, w: 106, h: 107 },
cells: [[37,145], [38,145], [36,146], [39,146], [37,147], [38,147], [51,72], [52,72], [53,72], [161,37], [162,37], [160,38], [163,38], [161,39], [162,39], [66,21], [67,21], [65,22], [68,22], [66,23], [67,23], [163,83], [164,83], [165,83], [162,84], [163,84], [164,84], [47,126], [48,126], [49,126], [46,127], [47,127], [48,127], [103,152], [104,152], [102,153], [105,153], [103,154], [105,154], [104,155], [142,25], [143,25], [144,25]] },
{ id: 58, name: 'Схема №58', w: 176, h: 176, wrap: false, budget: 161,
zone: { x: 64, y: 0, w: 24, h: 176 },
cells: [[97,154], [95,155], [97,155], [85,156], [86,156], [93,156], [94,156], [107,156], [108,156], [84,157], [88,157], [93,157], [94,157], [107,157], [108,157], [73,158], [74,158], [83,158], [89,158], [93,158], [94,158], [73,159], [74,159], [83,159], [87,159], [89,159], [90,159], [95,159], [97,159], [83,160], [89,160], [97,160], [84,161], [88,161], [85,162], [86,162], [17,25], [18,25], [19,25], [23,25], [24,25], [25,25], [15,27], [20,27], [22,27], [27,27], [15,28], [20,28], [22,28], [27,28], [15,29], [20,29], [22,29], [27,29], [17,30], [18,30], [19,30], [23,30], [24,30], [25,30], [17,32], [18,32], [19,32], [23,32], [24,32], [25,32], [15,33], [20,33], [22,33], [27,33], [15,34], [20,34], [22,34], [27,34], [15,35], [20,35], [22,35], [27,35], [17,37], [18,37], [19,37], [23,37], [24,37], [25,37], [67,171], [68,171], [69,171], [66,172], [67,172], [68,172], [37,11], [38,11], [39,11], [36,12], [37,12], [38,12], [139,126], [140,126], [138,127], [141,127], [139,128], [140,128], [30,36], [31,36], [32,36], [29,37], [30,37], [31,37], [4,80], [5,80], [3,81], [6,81], [4,82], [5,82], [77,103], [78,103], [76,104], [79,104], [77,105], [78,105], [106,112], [107,112], [108,112], [105,113], [106,113], [107,113], [89,55], [90,55], [91,55]] },
{ id: 59, name: 'Схема №59', w: 178, h: 178, wrap: false, budget: 162,
zone: { x: 0, y: 105, w: 178, h: 41 },
cells: [[122,156], [123,156], [124,156], [128,156], [129,156], [130,156], [120,158], [125,158], [127,158], [132,158], [120,159], [125,159], [127,159], [132,159], [120,160], [125,160], [127,160], [132,160], [122,161], [123,161], [124,161], [128,161], [129,161], [130,161], [122,163], [123,163], [124,163], [128,163], [129,163], [130,163], [120,164], [125,164], [127,164], [132,164], [120,165], [125,165], [127,165], [132,165], [120,166], [125,166], [127,166], [132,166], [122,168], [123,168], [124,168], [128,168], [129,168], [130,168], [25,49], [26,49], [27,49], [24,50], [25,50], [26,50], [76,53], [77,53], [75,54], [78,54], [76,55], [78,55], [77,56], [78,66], [79,66], [80,66], [77,67], [78,67], [79,67], [106,69], [107,69], [108,69], [105,70], [106,70], [107,70], [37,35], [38,35], [36,36], [39,36], [37,37], [39,37], [38,38], [100,162], [101,162], [102,162], [17,144], [18,144], [19,144]] },
{ id: 60, name: 'Схема №60', w: 182, h: 182, wrap: false, budget: 166,
zone: { x: 49, y: 34, w: 61, h: 106 },
cells: [[3,171], [4,171], [2,172], [5,172], [3,173], [5,173], [4,174], [41,86], [42,86], [40,87], [43,87], [41,88], [43,88], [42,89], [30,150], [31,150], [29,151], [32,151], [30,152], [32,152], [31,153], [177,104], [178,104], [179,104], [176,105], [177,105], [178,105], [62,63], [63,63], [61,64], [64,64], [62,65], [63,65], [139,138], [140,138], [141,138], [33,166], [34,166], [35,166], [32,167], [33,167], [34,167], [179,78], [180,78], [181,78], [178,79], [179,79], [180,79], [155,52], [156,52], [157,52], [154,53], [155,53], [156,53]] },
{ id: 61, name: 'Схема №61', w: 184, h: 184, wrap: false, budget: 167,
zone: { x: 41, y: 41, w: 101, h: 101 },
cells: [[159,98], [160,98], [161,98], [158,99], [159,99], [160,99], [177,20], [178,20], [176,21], [179,21], [177,22], [178,22], [133,50], [134,50], [135,50], [132,51], [133,51], [134,51], [111,117], [112,117], [113,117], [110,118], [111,118], [112,118], [42,79], [43,79], [44,79], [41,80], [42,80], [43,80], [116,131], [117,131], [115,132], [118,132], [116,133], [118,133], [117,134], [126,138], [127,138], [128,138], [39,117], [40,117], [38,118], [41,118], [39,119], [41,119], [40,120], [175,152], [176,152], [177,152], [174,153], [175,153], [176,153]] },
{ id: 62, name: 'Схема №62', w: 188, h: 188, wrap: false, budget: 170,
zone: { x: 0, y: 94, w: 188, h: 94 },
cells: [[142,20], [143,20], [144,20], [148,20], [149,20], [150,20], [140,22], [145,22], [147,22], [152,22], [140,23], [145,23], [147,23], [152,23], [140,24], [145,24], [147,24], [152,24], [142,25], [143,25], [144,25], [148,25], [149,25], [150,25], [142,27], [143,27], [144,27], [148,27], [149,27], [150,27], [140,28], [145,28], [147,28], [152,28], [140,29], [145,29], [147,29], [152,29], [140,30], [145,30], [147,30], [152,30], [142,32], [143,32], [144,32], [148,32], [149,32], [150,32], [115,68], [116,68], [117,68], [114,69], [115,69], [116,69], [32,16], [33,16], [31,17], [34,17], [32,18], [34,18], [33,19], [91,32], [92,32], [90,33], [93,33], [91,34], [92,34], [168,43], [169,43], [167,44], [170,44], [168,45], [169,45], [36,116], [37,116], [35,117], [38,117], [36,118], [38,118], [37,119], [45,19], [46,19], [47,19], [23,109], [24,109], [22,110], [25,110], [23,111], [24,111], [165,178], [166,178], [164,179], [167,179], [165,180], [166,180], [85,0], [86,0], [87,0], [84,1], [85,1], [86,1], [72,60], [73,60], [74,60]] },
{ id: 63, name: 'Схема №63', w: 190, h: 190, wrap: false, budget: 172,
zone: { x: 42, y: 42, w: 105, h: 105 },
cells: [[21,139], [22,139], [23,139], [149,3], [150,3], [148,4], [151,4], [149,5], [150,5], [58,71], [59,71], [57,72], [60,72], [58,73], [59,73], [145,63], [146,63], [144,64], [147,64], [145,65], [147,65], [146,66], [72,144], [73,144], [74,144], [71,145], [72,145], [73,145], [20,156], [21,156], [22,156], [19,157], [20,157], [21,157], [38,143], [39,143], [40,143], [37,144], [38,144], [39,144], [174,120], [175,120], [173,121], [176,121], [174,122], [176,122], [175,123], [156,81], [157,81], [155,82], [158,82], [156,83], [157,83]] },
{ id: 64, name: 'Схема №64', w: 192, h: 192, wrap: false, budget: 174,
zone: { x: 0, y: 0, w: 99, h: 63 },
cells: [[89,127], [90,127], [91,127], [88,128], [89,128], [90,128], [91,74], [92,74], [93,74], [90,75], [91,75], [92,75], [86,144], [87,144], [88,144], [85,145], [86,145], [87,145], [54,152], [55,152], [56,152], [67,63], [68,63], [66,64], [69,64], [67,65], [68,65], [156,167], [157,167], [158,167], [74,164], [75,164], [73,165], [76,165], [74,166], [76,166], [75,167], [34,26], [35,26], [36,26], [33,27], [34,27], [35,27], [8,36], [9,36], [10,36], [7,37], [8,37], [9,37]] },
{ id: 65, name: 'Схема №65', w: 196, h: 196, wrap: false, budget: 177,
zone: { x: 99, y: 77, w: 97, h: 119 },
cells: [[10,62], [11,62], [12,62], [16,62], [17,62], [18,62], [8,64], [13,64], [15,64], [20,64], [8,65], [13,65], [15,65], [20,65], [8,66], [13,66], [15,66], [20,66], [10,67], [11,67], [12,67], [16,67], [17,67], [18,67], [10,69], [11,69], [12,69], [16,69], [17,69], [18,69], [8,70], [13,70], [15,70], [20,70], [8,71], [13,71], [15,71], [20,71], [8,72], [13,72], [15,72], [20,72], [10,74], [11,74], [12,74], [16,74], [17,74], [18,74], [132,172], [133,172], [131,173], [134,173], [132,174], [133,174], [150,19], [151,19], [152,19], [149,20], [150,20], [151,20], [22,100], [23,100], [21,101], [24,101], [22,102], [23,102], [162,177], [163,177], [161,178], [164,178], [162,179], [164,179], [163,180], [107,177], [108,177], [109,177], [106,178], [107,178], [108,178], [169,164], [170,164], [168,165], [171,165], [169,166], [171,166], [170,167], [55,168], [56,168], [54,169], [57,169], [55,170], [57,170], [56,171], [158,98], [159,98], [157,99], [160,99], [158,100], [160,100], [159,101]] },
{ id: 66, name: 'Схема №66', w: 198, h: 198, wrap: false, budget: 178,
zone: { x: 85, y: 0, w: 34, h: 198 },
cells: [[100,51], [101,51], [102,51], [106,51], [107,51], [108,51], [98,53], [103,53], [105,53], [110,53], [98,54], [103,54], [105,54], [110,54], [98,55], [103,55], [105,55], [110,55], [100,56], [101,56], [102,56], [106,56], [107,56], [108,56], [100,58], [101,58], [102,58], [106,58], [107,58], [108,58], [98,59], [103,59], [105,59], [110,59], [98,60], [103,60], [105,60], [110,60], [98,61], [103,61], [105,61], [110,61], [100,63], [101,63], [102,63], [106,63], [107,63], [108,63], [63,59], [64,59], [62,60], [65,60], [63,61], [65,61], [64,62], [140,19], [141,19], [142,19], [55,116], [56,116], [57,116], [137,80], [138,80], [136,81], [139,81], [137,82], [138,82], [106,20], [107,20], [105,21], [108,21], [106,22], [107,22], [185,95], [186,95], [184,96], [187,96], [185,97], [186,97], [35,31], [36,31], [37,31], [175,69], [176,69], [174,70], [177,70], [175,71], [176,71], [163,55], [164,55], [162,56], [165,56], [163,57], [164,57], [79,147], [80,147], [78,148], [81,148], [79,149], [80,149]] },
{ id: 67, name: 'Схема №67', w: 202, h: 202, wrap: false, budget: 182,
zone: { x: 0, y: 159, w: 202, h: 41 },
cells: [[58,20], [59,20], [57,21], [60,21], [58,22], [59,22], [36,107], [37,107], [35,108], [38,108], [36,109], [37,109], [4,147], [5,147], [6,147], [47,118], [48,118], [46,119], [49,119], [47,120], [48,120], [85,17], [86,17], [84,18], [87,18], [85,19], [87,19], [86,20], [94,125], [95,125], [93,126], [96,126], [94,127], [95,127], [135,162], [136,162], [137,162], [148,32], [149,32], [147,33], [150,33], [148,34], [149,34], [26,134], [27,134], [25,135], [28,135], [26,136], [27,136]] },
{ id: 68, name: 'Схема №68', w: 204, h: 204, wrap: false, budget: 183,
zone: { x: 15, y: 53, w: 133, h: 68 },
cells: [[144,22], [145,22], [146,22], [150,22], [151,22], [152,22], [142,24], [147,24], [149,24], [154,24], [142,25], [147,25], [149,25], [154,25], [142,26], [147,26], [149,26], [154,26], [144,27], [145,27], [146,27], [150,27], [151,27], [152,27], [144,29], [145,29], [146,29], [150,29], [151,29], [152,29], [142,30], [147,30], [149,30], [154,30], [142,31], [147,31], [149,31], [154,31], [142,32], [147,32], [149,32], [154,32], [144,34], [145,34], [146,34], [150,34], [151,34], [152,34], [0,142], [1,142], [2,142], [142,117], [143,117], [141,118], [144,118], [142,119], [143,119], [116,85], [117,85], [115,86], [118,86], [116,87], [117,87], [14,67], [15,67], [16,67], [13,68], [14,68], [15,68], [75,146], [76,146], [77,146], [74,147], [75,147], [76,147], [4,49], [5,49], [6,49], [3,50], [4,50], [5,50], [145,120], [146,120], [147,120], [144,121], [145,121], [146,121], [153,82], [154,82], [155,82], [152,83], [153,83], [154,83]] },
{ id: 69, name: 'Схема №69', w: 206, h: 206, wrap: false, budget: 185,
zone: { x: 46, y: 46, w: 113, h: 113 },
cells: [[170,43], [171,43], [169,44], [172,44], [170,45], [171,45], [136,60], [137,60], [135,61], [138,61], [136,62], [137,62], [28,157], [29,157], [27,158], [30,158], [28,159], [30,159], [29,160], [144,57], [145,57], [146,57], [143,58], [144,58], [145,58], [144,169], [145,169], [146,169], [143,170], [144,170], [145,170], [129,8], [130,8], [128,9], [131,9], [129,10], [131,10], [130,11], [16,12], [17,12], [15,13], [18,13], [16,14], [18,14], [17,15], [167,119], [168,119], [169,119], [166,120], [167,120], [168,120], [119,12], [120,12], [118,13], [121,13], [119,14], [120,14]] },
{ id: 70, name: 'Схема №70', w: 210, h: 210, wrap: false, budget: 188,
zone: { x: 0, y: 0, w: 210, h: 105 },
cells: [[175,92], [173,93], [175,93], [163,94], [164,94], [171,94], [172,94], [185,94], [186,94], [162,95], [166,95], [171,95], [172,95], [185,95], [186,95], [151,96], [152,96], [161,96], [167,96], [171,96], [172,96], [151,97], [152,97], [161,97], [165,97], [167,97], [168,97], [173,97], [175,97], [161,98], [167,98], [175,98], [162,99], [166,99], [163,100], [164,100], [17,77], [18,77], [19,77], [23,77], [24,77], [25,77], [15,79], [20,79], [22,79], [27,79], [15,80], [20,80], [22,80], [27,80], [15,81], [20,81], [22,81], [27,81], [17,82], [18,82], [19,82], [23,82], [24,82], [25,82], [17,84], [18,84], [19,84], [23,84], [24,84], [25,84], [15,85], [20,85], [22,85], [27,85], [15,86], [20,86], [22,86], [27,86], [15,87], [20,87], [22,87], [27,87], [17,89], [18,89], [19,89], [23,89], [24,89], [25,89], [119,35], [120,35], [121,35], [65,194], [66,194], [67,194], [73,190], [74,190], [75,190], [72,191], [73,191], [74,191], [80,53], [81,53], [79,54], [82,54], [80,55], [81,55], [36,57], [37,57], [38,57], [5,21], [6,21], [4,22], [7,22], [5,23], [6,23], [134,19], [135,19], [133,20], [136,20], [134,21], [136,21], [135,22], [59,143], [60,143], [58,144], [61,144], [59,145], [60,145], [192,39], [193,39], [194,39], [191,40], [192,40], [193,40], [140,79], [141,79], [139,80], [142,80], [140,81], [141,81]] },
{ id: 71, name: 'Схема №71', w: 212, h: 212, wrap: false, budget: 190,
zone: { x: 47, y: 47, w: 117, h: 117 },
cells: [[166,117], [167,117], [168,117], [172,117], [173,117], [174,117], [164,119], [169,119], [171,119], [176,119], [164,120], [169,120], [171,120], [176,120], [164,121], [169,121], [171,121], [176,121], [166,122], [167,122], [168,122], [172,122], [173,122], [174,122], [166,124], [167,124], [168,124], [172,124], [173,124], [174,124], [164,125], [169,125], [171,125], [176,125], [164,126], [169,126], [171,126], [176,126], [164,127], [169,127], [171,127], [176,127], [166,129], [167,129], [168,129], [172,129], [173,129], [174,129], [27,107], [28,107], [26,108], [29,108], [27,109], [28,109], [179,24], [180,24], [178,25], [181,25], [179,26], [180,26], [57,50], [58,50], [56,51], [59,51], [57,52], [58,52], [41,112], [42,112], [40,113], [43,113], [41,114], [42,114], [60,55], [61,55], [62,55], [59,56], [60,56], [61,56], [85,161], [86,161], [87,161], [84,162], [85,162], [86,162], [26,17], [27,17], [28,17], [25,18], [26,18], [27,18], [116,53], [117,53], [118,53], [83,126], [84,126], [82,127], [85,127], [83,128], [84,128]] },
{ id: 72, name: 'Схема №72', w: 216, h: 216, wrap: false, budget: 193,
zone: { x: 0, y: 0, w: 136, h: 96 },
cells: [[136,50], [137,50], [135,51], [138,51], [136,52], [137,52], [15,115], [16,115], [17,115], [14,116], [15,116], [16,116], [165,116], [166,116], [167,116], [164,117], [165,117], [166,117], [56,179], [57,179], [55,180], [58,180], [56,181], [57,181], [99,202], [100,202], [101,202], [98,203], [99,203], [100,203], [91,74], [92,74], [90,75], [93,75], [91,76], [92,76], [121,184], [122,184], [120,185], [123,185], [121,186], [122,186], [170,157], [171,157], [172,157], [169,158], [170,158], [171,158], [37,147], [38,147], [39,147], [52,204], [53,204], [54,204], [51,205], [52,205], [53,205]] },
{ id: 73, name: 'Схема №73', w: 218, h: 218, wrap: false, budget: 194,
zone: { x: 137, y: 124, w: 81, h: 94 },
cells: [[14,15], [15,15], [16,15], [150,132], [151,132], [149,133], [152,133], [150,134], [151,134], [54,210], [55,210], [53,211], [56,211], [54,212], [55,212], [177,72], [178,72], [176,73], [179,73], [177,74], [179,74], [178,75], [165,100], [166,100], [164,101], [167,101], [165,102], [167,102], [166,103], [137,18], [138,18], [139,18], [136,19], [137,19], [138,19], [186,213], [187,213], [188,213], [170,2], [171,2], [169,3], [172,3], [170,4], [171,4], [212,67], [213,67], [214,67], [211,68], [212,68], [213,68], [28,34], [29,34], [27,35], [30,35], [28,36], [29,36]] },
{ id: 74, name: 'Схема №74', w: 222, h: 222, wrap: false, budget: 198,
zone: { x: 134, y: 0, w: 39, h: 222 },
cells: [[211,135], [212,135], [213,135], [217,135], [218,135], [219,135], [209,137], [214,137], [216,137], [221,137], [209,138], [214,138], [216,138], [221,138], [209,139], [214,139], [216,139], [221,139], [211,140], [212,140], [213,140], [217,140], [218,140], [219,140], [211,142], [212,142], [213,142], [217,142], [218,142], [219,142], [209,143], [214,143], [216,143], [221,143], [209,144], [214,144], [216,144], [221,144], [209,145], [214,145], [216,145], [221,145], [211,147], [212,147], [213,147], [217,147], [218,147], [219,147], [106,44], [107,44], [108,44], [105,45], [106,45], [107,45], [165,218], [166,218], [164,219], [167,219], [165,220], [167,220], [166,221], [75,183], [76,183], [77,183], [74,184], [75,184], [76,184], [111,56], [112,56], [113,56], [110,57], [111,57], [112,57], [161,184], [162,184], [160,185], [163,185], [161,186], [162,186], [181,98], [182,98], [183,98], [209,93], [210,93], [208,94], [211,94], [209,95], [211,95], [210,96], [200,184], [201,184], [202,184], [199,185], [200,185], [201,185], [26,129], [27,129], [28,129], [25,130], [26,130], [27,130], [48,180], [49,180], [50,180], [47,181], [48,181], [49,181], [142,21], [143,21], [141,22], [144,22], [142,23], [143,23]] },
{ id: 75, name: 'Схема №75', w: 224, h: 224, wrap: false, budget: 199,
zone: { x: 0, y: 141, w: 224, h: 31 },
cells: [[96,149], [97,149], [95,150], [98,150], [96,151], [97,151], [207,200], [208,200], [209,200], [206,201], [207,201], [208,201], [169,43], [170,43], [171,43], [168,44], [169,44], [170,44], [64,91], [65,91], [66,91], [113,134], [114,134], [112,135], [115,135], [113,136], [114,136], [77,115], [78,115], [79,115], [5,75], [6,75], [4,76], [7,76], [5,77], [6,77], [217,22], [218,22], [216,23], [219,23], [217,24], [218,24], [22,148], [23,148], [24,148], [199,16], [200,16], [198,17], [201,17], [199,18], [200,18]] },
{ id: 76, name: 'Схема №76', w: 228, h: 228, wrap: false, budget: 202,
zone: { x: 106, y: 15, w: 60, h: 135 },
cells: [[187,136], [188,136], [189,136], [186,137], [187,137], [188,137], [14,98], [15,98], [16,98], [116,3], [117,3], [118,3], [115,4], [116,4], [117,4], [46,105], [47,105], [48,105], [45,106], [46,106], [47,106], [48,71], [49,71], [47,72], [50,72], [48,73], [50,73], [49,74], [133,217], [134,217], [135,217], [132,218], [133,218], [134,218], [212,48], [213,48], [211,49], [214,49], [212,50], [213,50], [88,31], [89,31], [87,32], [90,32], [88,33], [89,33], [203,221], [204,221], [202,222], [205,222], [203,223], [204,223], [18,130], [19,130], [20,130], [17,131], [18,131], [19,131]] },
{ id: 77, name: 'Схема №77', w: 230, h: 230, wrap: false, budget: 204,
zone: { x: 51, y: 51, w: 127, h: 127 },
cells: [[136,139], [137,139], [138,139], [142,139], [143,139], [144,139], [134,141], [139,141], [141,141], [146,141], [134,142], [139,142], [141,142], [146,142], [134,143], [139,143], [141,143], [146,143], [136,144], [137,144], [138,144], [142,144], [143,144], [144,144], [136,146], [137,146], [138,146], [142,146], [143,146], [144,146], [134,147], [139,147], [141,147], [146,147], [134,148], [139,148], [141,148], [146,148], [134,149], [139,149], [141,149], [146,149], [136,151], [137,151], [138,151], [142,151], [143,151], [144,151], [68,67], [69,67], [70,67], [67,68], [68,68], [69,68], [159,61], [160,61], [158,62], [161,62], [159,63], [161,63], [160,64], [211,112], [212,112], [213,112], [165,80], [166,80], [167,80], [164,81], [165,81], [166,81], [66,91], [67,91], [68,91], [65,92], [66,92], [67,92], [122,183], [123,183], [121,184], [124,184], [122,185], [124,185], [123,186], [176,107], [177,107], [175,108], [178,108], [176,109], [177,109], [76,182], [77,182], [78,182], [83,197], [84,197], [85,197], [82,198], [83,198], [84,198]] },
{ id: 78, name: 'Схема №78', w: 234, h: 234, wrap: false, budget: 207,
zone: { x: 0, y: 117, w: 234, h: 117 },
cells: [[147,149], [148,149], [149,149], [153,149], [154,149], [155,149], [145,151], [150,151], [152,151], [157,151], [145,152], [150,152], [152,152], [157,152], [145,153], [150,153], [152,153], [157,153], [147,154], [148,154], [149,154], [153,154], [154,154], [155,154], [147,156], [148,156], [149,156], [153,156], [154,156], [155,156], [145,157], [150,157], [152,157], [157,157], [145,158], [150,158], [152,158], [157,158], [145,159], [150,159], [152,159], [157,159], [147,161], [148,161], [149,161], [153,161], [154,161], [155,161], [183,52], [184,52], [185,52], [182,53], [183,53], [184,53], [15,140], [16,140], [14,141], [17,141], [15,142], [16,142], [219,60], [220,60], [218,61], [221,61], [219,62], [221,62], [220,63], [194,191], [195,191], [196,191], [193,192], [194,192], [195,192], [85,76], [86,76], [84,77], [87,77], [85,78], [87,78], [86,79], [178,124], [179,124], [177,125], [180,125], [178,126], [180,126], [179,127], [11,23], [12,23], [13,23], [64,3], [65,3], [63,4], [66,4], [64,5], [65,5], [39,180], [40,180], [38,181], [41,181], [39,182], [40,182], [174,164], [175,164], [173,165], [176,165], [174,166], [175,166], [132,174], [133,174], [131,175], [134,175], [132,176], [133,176]] },
{ id: 79, name: 'Схема №79', w: 236, h: 236, wrap: false, budget: 209,
zone: { x: 53, y: 53, w: 130, h: 130 },
cells: [[159,179], [160,179], [158,180], [161,180], [159,181], [160,181], [61,209], [62,209], [63,209], [15,94], [16,94], [14,95], [17,95], [15,96], [16,96], [165,182], [166,182], [167,182], [164,183], [165,183], [166,183], [207,82], [208,82], [206,83], [209,83], [207,84], [208,84], [136,191], [137,191], [135,192], [138,192], [136,193], [138,193], [137,194], [53,49], [54,49], [52,50], [55,50], [53,51], [55,51], [54,52], [146,96], [147,96], [145,97], [148,97], [146,98], [148,98], [147,99], [208,141], [209,141], [210,141], [207,142], [208,142], [209,142], [8,100], [9,100], [7,101], [10,101], [8,102], [9,102]] },
{ id: 80, name: 'Схема №80', w: 240, h: 240, wrap: false, budget: 212,
zone: { x: 0, y: 0, w: 152, h: 133 },
cells: [[32,145], [33,145], [34,145], [38,145], [39,145], [40,145], [30,147], [35,147], [37,147], [42,147], [30,148], [35,148], [37,148], [42,148], [30,149], [35,149], [37,149], [42,149], [32,150], [33,150], [34,150], [38,150], [39,150], [40,150], [32,152], [33,152], [34,152], [38,152], [39,152], [40,152], [30,153], [35,153], [37,153], [42,153], [30,154], [35,154], [37,154], [42,154], [30,155], [35,155], [37,155], [42,155], [32,157], [33,157], [34,157], [38,157], [39,157], [40,157], [216,87], [217,87], [218,87], [215,88], [216,88], [217,88], [40,197], [41,197], [42,197], [39,198], [40,198], [41,198], [91,88], [92,88], [93,88], [90,89], [91,89], [92,89], [106,2], [107,2], [108,2], [105,3], [106,3], [107,3], [97,206], [98,206], [99,206], [50,29], [51,29], [49,30], [52,30], [50,31], [52,31], [51,32], [88,171], [89,171], [90,171], [97,52], [98,52], [99,52], [96,53], [97,53], [98,53], [52,128], [53,128], [54,128], [39,8], [40,8], [41,8], [38,9], [39,9], [40,9]] },
{ id: 81, name: 'Схема №81', w: 242, h: 242, wrap: false, budget: 214,
zone: { x: 133, y: 165, w: 109, h: 77 },
cells: [[159,35], [160,35], [158,36], [161,36], [159,37], [161,37], [160,38], [13,128], [14,128], [12,129], [15,129], [13,130], [14,130], [177,164], [178,164], [179,164], [9,43], [10,43], [8,44], [11,44], [9,45], [11,45], [10,46], [172,32], [173,32], [171,33], [174,33], [172,34], [174,34], [173,35], [179,21], [180,21], [181,21], [178,22], [179,22], [180,22], [85,162], [86,162], [87,162], [84,163], [85,163], [86,163], [75,161], [76,161], [74,162], [77,162], [75,163], [76,163], [145,128], [146,128], [147,128], [34,184], [35,184], [33,185], [36,185], [34,186], [36,186], [35,187], [161,148], [162,148], [163,148], [160,149], [161,149], [162,149]] },
{ id: 82, name: 'Схема №82', w: 246, h: 246, wrap: false, budget: 217,
zone: { x: 107, y: 0, w: 51, h: 246 },
cells: [[104,103], [102,104], [104,104], [92,105], [93,105], [100,105], [101,105], [114,105], [115,105], [91,106], [95,106], [100,106], [101,106], [114,106], [115,106], [80,107], [81,107], [90,107], [96,107], [100,107], [101,107], [80,108], [81,108], [90,108], [94,108], [96,108], [97,108], [102,108], [104,108], [90,109], [96,109], [104,109], [91,110], [95,110], [92,111], [93,111], [174,143], [175,143], [176,143], [180,143], [181,143], [182,143], [172,145], [177,145], [179,145], [184,145], [172,146], [177,146], [179,146], [184,146], [172,147], [177,147], [179,147], [184,147], [174,148], [175,148], [176,148], [180,148], [181,148], [182,148], [174,150], [175,150], [176,150], [180,150], [181,150], [182,150], [172,151], [177,151], [179,151], [184,151], [172,152], [177,152], [179,152], [184,152], [172,153], [177,153], [179,153], [184,153], [174,155], [175,155], [176,155], [180,155], [181,155], [182,155], [226,127], [227,127], [228,127], [225,128], [226,128], [227,128], [130,207], [131,207], [129,208], [132,208], [130,209], [131,209], [211,64], [212,64], [213,64], [210,65], [211,65], [212,65], [39,138], [40,138], [41,138], [38,139], [39,139], [40,139], [112,3], [113,3], [114,3], [146,236], [147,236], [145,237], [148,237], [146,238], [147,238], [98,23], [99,23], [97,24], [100,24], [98,25], [99,25], [43,153], [44,153], [42,154], [45,154], [43,155], [44,155], [143,58], [144,58], [142,59], [145,59], [143,60], [144,60], [217,243], [218,243], [219,243], [216,244], [217,244], [218,244], [63,140], [64,140], [65,140]] },
{ id: 83, name: 'Схема №83', w: 248, h: 248, wrap: false, budget: 218,
zone: { x: 0, y: 118, w: 248, h: 59 },
cells: [[162,191], [163,191], [164,191], [168,191], [169,191], [170,191], [160,193], [165,193], [167,193], [172,193], [160,194], [165,194], [167,194], [172,194], [160,195], [165,195], [167,195], [172,195], [162,196], [163,196], [164,196], [168,196], [169,196], [170,196], [162,198], [163,198], [164,198], [168,198], [169,198], [170,198], [160,199], [165,199], [167,199], [172,199], [160,200], [165,200], [167,200], [172,200], [160,201], [165,201], [167,201], [172,201], [162,203], [163,203], [164,203], [168,203], [169,203], [170,203], [133,13], [134,13], [135,13], [125,48], [126,48], [124,49], [127,49], [125,50], [126,50], [204,89], [205,89], [206,89], [203,90], [204,90], [205,90], [117,30], [118,30], [116,31], [119,31], [117,32], [119,32], [118,33], [6,107], [7,107], [8,107], [5,108], [6,108], [7,108], [200,130], [201,130], [202,130], [199,131], [200,131], [201,131], [61,140], [62,140], [60,141], [63,141], [61,142], [63,142], [62,143], [23,212], [24,212], [25,212], [103,136], [104,136], [102,137], [105,137], [103,138], [104,138], [214,48], [215,48], [213,49], [216,49], [214,50], [215,50]] },
{ id: 84, name: 'Схема №84', w: 252, h: 252, wrap: false, budget: 222,
zone: { x: 11, y: 88, w: 112, h: 119 },
cells: [[150,229], [151,229], [149,230], [152,230], [150,231], [151,231], [30,116], [31,116], [29,117], [32,117], [30,118], [32,118], [31,119], [228,226], [229,226], [230,226], [227,227], [228,227], [229,227], [34,74], [35,74], [33,75], [36,75], [34,76], [36,76], [35,77], [13,186], [14,186], [15,186], [111,144], [112,144], [113,144], [160,187], [161,187], [162,187], [159,188], [160,188], [161,188], [12,41], [13,41], [11,42], [14,42], [12,43], [13,43], [30,17], [31,17], [29,18], [32,18], [30,19], [32,19], [31,20], [189,177], [190,177], [191,177], [188,178], [189,178], [190,178], [109,239], [110,239], [111,239]] },
{ id: 85, name: 'Схема №85', w: 254, h: 254, wrap: false, budget: 223,
zone: { x: 57, y: 57, w: 140, h: 140 },
cells: [[160,251], [161,251], [162,251], [159,252], [160,252], [161,252], [18,208], [19,208], [20,208], [17,209], [18,209], [19,209], [32,111], [33,111], [34,111], [31,112], [32,112], [33,112], [212,129], [213,129], [211,130], [214,130], [212,131], [213,131], [127,249], [128,249], [129,249], [126,250], [127,250], [128,250], [201,58], [202,58], [203,58], [248,21], [249,21], [250,21], [18,176], [19,176], [17,177], [20,177], [18,178], [20,178], [19,179], [195,107], [196,107], [194,108], [197,108], [195,109], [196,109], [31,127], [32,127], [33,127], [30,128], [31,128], [32,128], [203,35], [204,35], [202,36], [205,36], [203,37], [205,37], [204,38]] },
{ id: 86, name: 'Схема №86', w: 258, h: 258, wrap: false, budget: 226,
zone: { x: 0, y: 0, w: 258, h: 129 },
cells: [[230,184], [231,184], [232,184], [236,184], [237,184], [238,184], [228,186], [233,186], [235,186], [240,186], [228,187], [233,187], [235,187], [240,187], [228,188], [233,188], [235,188], [240,188], [230,189], [231,189], [232,189], [236,189], [237,189], [238,189], [230,191], [231,191], [232,191], [236,191], [237,191], [238,191], [228,192], [233,192], [235,192], [240,192], [228,193], [233,193], [235,193], [240,193], [228,194], [233,194], [235,194], [240,194], [230,196], [231,196], [232,196], [236,196], [237,196], [238,196], [56,165], [57,165], [55,166], [58,166], [56,167], [58,167], [57,168], [87,229], [88,229], [86,230], [89,230], [87,231], [88,231], [145,171], [146,171], [147,171], [144,172], [145,172], [146,172], [64,108], [65,108], [66,108], [63,109], [64,109], [65,109], [122,247], [123,247], [121,248], [124,248], [122,249], [124,249], [123,250], [26,210], [27,210], [28,210], [25,211], [26,211], [27,211], [124,40], [125,40], [126,40], [123,41], [124,41], [125,41], [175,197], [176,197], [177,197], [174,198], [175,198], [176,198], [114,17], [115,17], [113,18], [116,18], [114,19], [116,19], [115,20], [142,48], [143,48], [141,49], [144,49], [142,50], [144,50], [143,51], [68,188], [69,188], [70,188], [67,189], [68,189], [69,189], [86,161], [87,161], [88,161]] },
{ id: 87, name: 'Схема №87', w: 260, h: 260, wrap: false, budget: 228,
zone: { x: 58, y: 58, w: 143, h: 143 },
cells: [[198,52], [199,52], [200,52], [197,53], [198,53], [199,53], [188,79], [189,79], [190,79], [187,80], [188,80], [189,80], [96,144], [97,144], [98,144], [95,145], [96,145], [97,145], [256,55], [257,55], [255,56], [258,56], [256,57], [258,57], [257,58], [209,1], [210,1], [211,1], [208,2], [209,2], [210,2], [237,15], [238,15], [236,16], [239,16], [237,17], [238,17], [38,195], [39,195], [40,195], [37,196], [38,196], [39,196], [67,129], [68,129], [69,129], [66,130], [67,130], [68,130], [5,41], [6,41], [4,42], [7,42], [5,43], [7,43], [6,44], [62,136], [63,136], [61,137], [64,137], [62,138], [63,138], [121,59], [122,59], [123,59], [120,60], [121,60], [122,60]] },
{ id: 88, name: 'Схема №88', w: 264, h: 264, wrap: false, budget: 231,
zone: { x: 0, y: 0, w: 139, h: 128 },
cells: [[33,129], [34,129], [32,130], [35,130], [33,131], [35,131], [34,132], [207,24], [208,24], [206,25], [209,25], [207,26], [209,26], [208,27], [130,75], [131,75], [129,76], [132,76], [130,77], [131,77], [115,149], [116,149], [114,150], [117,150], [115,151], [117,151], [116,152], [241,11], [242,11], [240,12], [243,12], [241,13], [242,13], [60,201], [61,201], [59,202], [62,202], [60,203], [61,203], [133,128], [134,128], [135,128], [61,227], [62,227], [63,227], [234,18], [235,18], [233,19], [236,19], [234,20], [235,20], [214,259], [215,259], [213,260], [216,260], [214,261], [216,261], [215,262], [252,192], [253,192], [254,192], [251,193], [252,193], [253,193]] },
{ id: 89, name: 'Схема №89', w: 266, h: 266, wrap: false, budget: 233,
zone: { x: 144, y: 163, w: 122, h: 103 },
cells: [[122,238], [123,238], [124,238], [128,238], [129,238], [130,238], [120,240], [125,240], [127,240], [132,240], [120,241], [125,241], [127,241], [132,241], [120,242], [125,242], [127,242], [132,242], [122,243], [123,243], [124,243], [128,243], [129,243], [130,243], [122,245], [123,245], [124,245], [128,245], [129,245], [130,245], [120,246], [125,246], [127,246], [132,246], [120,247], [125,247], [127,247], [132,247], [120,248], [125,248], [127,248], [132,248], [122,250], [123,250], [124,250], [128,250], [129,250], [130,250], [25,48], [26,48], [27,48], [167,244], [168,244], [166,245], [169,245], [167,246], [168,246], [260,157], [261,157], [262,157], [259,158], [260,158], [261,158], [216,229], [217,229], [218,229], [215,230], [216,230], [217,230], [212,262], [213,262], [211,263], [214,263], [212,264], [213,264], [214,131], [215,131], [213,132], [216,132], [214,133], [215,133], [171,114], [172,114], [170,115], [173,115], [171,116], [173,116], [172,117], [160,184], [161,184], [162,184], [159,185], [160,185], [161,185], [159,206], [160,206], [161,206], [247,231], [248,231], [249,231], [246,232], [247,232], [248,232]] },
{ id: 90, name: 'Схема №90', w: 270, h: 270, wrap: false, budget: 236,
zone: { x: 110, y: 0, w: 68, h: 270 },
cells: [[91,64], [92,64], [93,64], [97,64], [98,64], [99,64], [89,66], [94,66], [96,66], [101,66], [89,67], [94,67], [96,67], [101,67], [89,68], [94,68], [96,68], [101,68], [91,69], [92,69], [93,69], [97,69], [98,69], [99,69], [91,71], [92,71], [93,71], [97,71], [98,71], [99,71], [89,72], [94,72], [96,72], [101,72], [89,73], [94,73], [96,73], [101,73], [89,74], [94,74], [96,74], [101,74], [91,76], [92,76], [93,76], [97,76], [98,76], [99,76], [196,253], [197,253], [195,254], [198,254], [196,255], [197,255], [143,84], [144,84], [142,85], [145,85], [143,86], [145,86], [144,87], [72,91], [73,91], [74,91], [235,17], [236,17], [237,17], [234,18], [235,18], [236,18], [8,20], [9,20], [7,21], [10,21], [8,22], [10,22], [9,23], [56,178], [57,178], [58,178], [55,179], [56,179], [57,179], [21,109], [22,109], [20,110], [23,110], [21,111], [22,111], [230,236], [231,236], [232,236], [46,13], [47,13], [45,14], [48,14], [46,15], [47,15], [138,213], [139,213], [137,214], [140,214], [138,215], [139,215], [49,190], [50,190], [51,190], [48,191], [49,191], [50,191], [188,25], [189,25], [190,25], [187,26], [188,26], [189,26], [229,88], [230,88], [228,89], [231,89], [229,90], [230,90]] },
{ id: 91, name: 'Схема №91', w: 272, h: 272, wrap: false, budget: 238,
zone: { x: 0, y: 184, w: 272, h: 70 },
cells: [[48,201], [49,201], [50,201], [47,202], [48,202], [49,202], [222,110], [223,110], [221,111], [224,111], [222,112], [223,112], [20,56], [21,56], [22,56], [19,57], [20,57], [21,57], [149,219], [150,219], [151,219], [148,220], [149,220], [150,220], [262,72], [263,72], [264,72], [261,73], [262,73], [263,73], [205,111], [206,111], [204,112], [207,112], [205,113], [206,113], [48,230], [49,230], [50,230], [47,231], [48,231], [49,231], [19,215], [20,215], [21,215], [19,154], [20,154], [18,155], [21,155], [19,156], [20,156], [113,262], [114,262], [112,263], [115,263], [113,264], [114,264], [160,104], [161,104], [162,104], [177,248], [178,248], [176,249], [179,249], [177,250], [178,250]] },
{ id: 92, name: 'Схема №92', w: 276, h: 276, wrap: false, budget: 241,
zone: { x: 41, y: 20, w: 190, h: 170 },
cells: [[201,2], [202,2], [203,2], [207,2], [208,2], [209,2], [199,4], [204,4], [206,4], [211,4], [199,5], [204,5], [206,5], [211,5], [199,6], [204,6], [206,6], [211,6], [201,7], [202,7], [203,7], [207,7], [208,7], [209,7], [201,9], [202,9], [203,9], [207,9], [208,9], [209,9], [199,10], [204,10], [206,10], [211,10], [199,11], [204,11], [206,11], [211,11], [199,12], [204,12], [206,12], [211,12], [201,14], [202,14], [203,14], [207,14], [208,14], [209,14], [162,264], [163,264], [164,264], [161,265], [162,265], [163,265], [72,28], [73,28], [74,28], [273,108], [274,108], [272,109], [275,109], [273,110], [274,110], [39,55], [40,55], [41,55], [20,265], [21,265], [19,266], [22,266], [20,267], [21,267], [133,161], [134,161], [132,162], [135,162], [133,163], [135,163], [134,164], [246,136], [247,136], [248,136], [245,137], [246,137], [247,137], [154,58], [155,58], [156,58], [153,59], [154,59], [155,59], [74,128], [75,128], [76,128], [27,189], [28,189], [29,189], [26,190], [27,190], [28,190], [131,192], [132,192], [130,193], [133,193], [131,194], [132,194]] },
{ id: 93, name: 'Схема №93', w: 278, h: 278, wrap: false, budget: 242,
zone: { x: 62, y: 62, w: 153, h: 153 },
cells: [[265,145], [266,145], [264,146], [267,146], [265,147], [266,147], [111,152], [112,152], [113,152], [110,153], [111,153], [112,153], [25,124], [26,124], [24,125], [27,125], [25,126], [26,126], [160,173], [161,173], [159,174], [162,174], [160,175], [161,175], [160,183], [161,183], [159,184], [162,184], [160,185], [161,185], [232,189], [233,189], [234,189], [161,85], [162,85], [160,86], [163,86], [161,87], [162,87], [19,160], [20,160], [21,160], [18,161], [19,161], [20,161], [110,252], [111,252], [109,253], [112,253], [110,254], [112,254], [111,255], [239,2], [240,2], [238,3], [241,3], [239,4], [240,4], [197,234], [198,234], [199,234], [196,235], [197,235], [198,235], [218,235], [219,235], [217,236], [220,236], [218,237], [219,237]] },
{ id: 94, name: 'Схема №94', w: 282, h: 282, wrap: false, budget: 246,
zone: { x: 0, y: 141, w: 282, h: 141 },
cells: [[26,225], [24,226], [26,226], [14,227], [15,227], [22,227], [23,227], [36,227], [37,227], [13,228], [17,228], [22,228], [23,228], [36,228], [37,228], [2,229], [3,229], [12,229], [18,229], [22,229], [23,229], [2,230], [3,230], [12,230], [16,230], [18,230], [19,230], [24,230], [26,230], [12,231], [18,231], [26,231], [13,232], [17,232], [14,233], [15,233], [172,154], [173,154], [174,154], [178,154], [179,154], [180,154], [170,156], [175,156], [177,156], [182,156], [170,157], [175,157], [177,157], [182,157], [170,158], [175,158], [177,158], [182,158], [172,159], [173,159], [174,159], [178,159], [179,159], [180,159], [172,161], [173,161], [174,161], [178,161], [179,161], [180,161], [170,162], [175,162], [177,162], [182,162], [170,163], [175,163], [177,163], [182,163], [170,164], [175,164], [177,164], [182,164], [172,166], [173,166], [174,166], [178,166], [179,166], [180,166], [253,264], [254,264], [252,265], [255,265], [253,266], [254,266], [203,204], [204,204], [205,204], [193,112], [194,112], [192,113], [195,113], [193,114], [194,114], [125,51], [126,51], [124,52], [127,52], [125,53], [127,53], [126,54], [19,123], [20,123], [21,123], [18,124], [19,124], [20,124], [89,226], [90,226], [91,226], [88,227], [89,227], [90,227], [258,70], [259,70], [260,70], [79,98], [80,98], [78,99], [81,99], [79,100], [80,100], [231,251], [232,251], [233,251], [230,252], [231,252], [232,252], [90,235], [91,235], [89,236], [92,236], [90,237], [92,237], [91,238], [91,217], [92,217], [93,217], [204,118], [205,118], [203,119], [206,119], [204,120], [206,120], [205,121]] },
{ id: 95, name: 'Схема №95', w: 284, h: 284, wrap: false, budget: 247,
zone: { x: 64, y: 64, w: 156, h: 156 },
cells: [[66,229], [67,229], [68,229], [72,229], [73,229], [74,229], [64,231], [69,231], [71,231], [76,231], [64,232], [69,232], [71,232], [76,232], [64,233], [69,233], [71,233], [76,233], [66,234], [67,234], [68,234], [72,234], [73,234], [74,234], [66,236], [67,236], [68,236], [72,236], [73,236], [74,236], [64,237], [69,237], [71,237], [76,237], [64,238], [69,238], [71,238], [76,238], [64,239], [69,239], [71,239], [76,239], [66,241], [67,241], [68,241], [72,241], [73,241], [74,241], [28,9], [29,9], [30,9], [235,43], [236,43], [237,43], [116,59], [117,59], [115,60], [118,60], [116,61], [117,61], [8,160], [9,160], [10,160], [7,161], [8,161], [9,161], [30,251], [31,251], [32,251], [142,256], [143,256], [141,257], [144,257], [142,258], [143,258], [41,138], [42,138], [43,138], [218,243], [219,243], [217,244], [220,244], [218,245], [219,245], [158,181], [159,181], [157,182], [160,182], [158,183], [160,183], [159,184], [228,81], [229,81], [230,81], [227,82], [228,82], [229,82], [74,151], [75,151], [76,151], [73,152], [74,152], [75,152]] },
{ id: 96, name: 'Схема №96', w: 288, h: 288, wrap: false, budget: 250,
zone: { x: 0, y: 0, w: 132, h: 124 },
cells: [[184,214], [185,214], [183,215], [186,215], [184,216], [186,216], [185,217], [208,42], [209,42], [210,42], [207,43], [208,43], [209,43], [200,113], [201,113], [199,114], [202,114], [200,115], [202,115], [201,116], [152,11], [153,11], [154,11], [151,12], [152,12], [153,12], [234,260], [235,260], [233,261], [236,261], [234,262], [235,262], [174,265], [175,265], [173,266], [176,266], [174,267], [175,267], [7,90], [8,90], [6,91], [9,91], [7,92], [9,92], [8,93], [202,38], [203,38], [204,38], [201,39], [202,39], [203,39], [48,269], [49,269], [50,269], [47,270], [48,270], [49,270], [116,211], [117,211], [115,212], [118,212], [116,213], [117,213], [89,3], [90,3], [91,3], [236,198], [237,198], [235,199], [238,199], [236,200], [237,200]] },
{ id: 97, name: 'Схема №97', w: 292, h: 292, wrap: false, budget: 254,
zone: { x: 160, y: 109, w: 132, h: 183 },
cells: [[85,106], [86,106], [84,107], [87,107], [85,108], [87,108], [86,109], [77,189], [78,189], [79,189], [137,145], [138,145], [139,145], [136,146], [137,146], [138,146], [269,20], [270,20], [268,21], [271,21], [269,22], [270,22], [99,167], [100,167], [101,167], [98,168], [99,168], [100,168], [234,190], [235,190], [233,191], [236,191], [234,192], [235,192], [256,166], [257,166], [255,167], [258,167], [256,168], [257,168], [113,262], [114,262], [112,263], [115,263], [113,264], [115,264], [114,265], [276,287], [277,287], [278,287], [275,288], [276,288], [277,288], [21,162], [22,162], [23,162], [20,163], [21,163], [22,163], [126,135], [127,135], [128,135], [125,136], [126,136], [127,136], [139,160], [140,160], [141,160], [138,161], [139,161], [140,161]] },
{ id: 98, name: 'Схема №98', w: 294, h: 294, wrap: false, budget: 255,
zone: { x: 101, y: 0, w: 59, h: 294 },
cells: [[85,19], [86,19], [87,19], [91,19], [92,19], [93,19], [83,21], [88,21], [90,21], [95,21], [83,22], [88,22], [90,22], [95,22], [83,23], [88,23], [90,23], [95,23], [85,24], [86,24], [87,24], [91,24], [92,24], [93,24], [85,26], [86,26], [87,26], [91,26], [92,26], [93,26], [83,27], [88,27], [90,27], [95,27], [83,28], [88,28], [90,28], [95,28], [83,29], [88,29], [90,29], [95,29], [85,31], [86,31], [87,31], [91,31], [92,31], [93,31], [11,51], [12,51], [13,51], [213,7], [214,7], [212,8], [215,8], [213,9], [214,9], [252,279], [253,279], [254,279], [251,280], [252,280], [253,280], [106,160], [107,160], [105,161], [108,161], [106,162], [107,162], [149,230], [150,230], [151,230], [148,231], [149,231], [150,231], [146,2], [147,2], [148,2], [260,201], [261,201], [259,202], [262,202], [260,203], [262,203], [261,204], [241,91], [242,91], [243,91], [226,23], [227,23], [228,23], [225,24], [226,24], [227,24], [140,180], [141,180], [139,181], [142,181], [140,182], [142,182], [141,183], [52,250], [53,250], [54,250], [197,101], [198,101], [199,101], [196,102], [197,102], [198,102], [63,51], [64,51], [65,51]] },
{ id: 99, name: 'Схема №99', w: 298, h: 298, wrap: false, budget: 258,
zone: { x: 0, y: 164, w: 298, h: 47 },
cells: [[237,36], [238,36], [239,36], [236,37], [237,37], [238,37], [161,184], [162,184], [163,184], [160,185], [161,185], [162,185], [295,285], [296,285], [297,285], [294,286], [295,286], [296,286], [291,99], [292,99], [293,99], [290,100], [291,100], [292,100], [96,20], [97,20], [95,21], [98,21], [96,22], [97,22], [274,289], [275,289], [273,290], [276,290], [274,291], [275,291], [9,151], [10,151], [8,152], [11,152], [9,153], [10,153], [185,53], [186,53], [184,54], [187,54], [185,55], [186,55], [160,43], [161,43], [162,43], [159,44], [160,44], [161,44], [196,178], [197,178], [195,179], [198,179], [196,180], [197,180], [266,242], [267,242], [268,242], [265,243], [266,243], [267,243], [92,176], [93,176], [91,177], [94,177], [92,178], [94,178], [93,179]] },
{ id: 100, name: 'Схема №100', w: 300, h: 300, wrap: false, budget: 260,
zone: { x: 106, y: 39, w: 184, h: 105 },
cells: [[17,97], [18,97], [16,98], [19,98], [17,99], [18,99], [265,63], [266,63], [264,64], [267,64], [265,65], [267,65], [266,66], [215,160], [216,160], [217,160], [132,99], [133,99], [134,99], [244,62], [245,62], [246,62], [74,136], [75,136], [76,136], [73,137], [74,137], [75,137], [14,181], [15,181], [13,182], [16,182], [14,183], [15,183], [283,289], [284,289], [285,289], [282,290], [283,290], [284,290], [51,51], [52,51], [50,52], [53,52], [51,53], [53,53], [52,54], [222,78], [223,78], [221,79], [224,79], [222,80], [223,80], [61,223], [62,223], [60,224], [63,224], [61,225], [62,225], [291,153], [292,153], [293,153], [290,154], [291,154], [292,154], [111,25], [112,25], [113,25]] },
];
// Разбор ASCII-паттерна в offsets {x,y} (лениво, с кэшем на объекте: превью каждый
// кадр должно быть дёшево). p._cells — массив offsets, p._w/p._h — размеры bbox.
function patternCells(p) {
if (!p) return [];
if (p._cells) return p._cells;
const out = [];
let w = 0;
for (let y = 0; y < p.cells.length; y++) {
const line = p.cells[y];
if (line.length > w) w = line.length;
for (let x = 0; x < line.length; x++) if (line[x] === '1') out.push({ x: x, y: y });
}
p._cells = out;
p._w = w;
p._h = p.cells.length;
return out;
}
// Паттерн по id (или null, если id неизвестен).
function findPattern(id) {
for (let i = 0; i < PATTERNS.length; i++) if (PATTERNS[i].id === id) return PATTERNS[i];
return null;
}
// Поворот набора offsets на 90° по часовой стрелке: внутри bbox (w×h) клетка
// (x,y) → (h-1-y, x). Размеры bbox меняются местами (w×h → h×w), а offsets снова
// нормализованы к левому верхнему углу (якорь штампа не меняется).
function rotate90(cells) {
let h = 0;
for (let i = 0; i < cells.length; i++) if (cells[i].y + 1 > h) h = cells[i].y + 1;
const out = new Array(cells.length);
for (let i = 0; i < cells.length; i++) out[i] = { x: h - 1 - cells[i].y, y: cells[i].x };
return out;
}
// Offsets активного паттерна с учётом поворота stampRot (0..3). Базовый разбор
// кэширует patternCells (p._cells), повёрнутый — на объекте паттерна (p._rot1..3):
// поворот колесом не пересобирает offsets каждый кадр.
function stampCells() {
if (!stamp) return [];
if (!stampRot) return patternCells(stamp);
const key = '_rot' + stampRot;
if (!stamp[key]) {
let out = patternCells(stamp);
for (let r = 0; r < stampRot; r++) out = rotate90(out);
stamp[key] = out;
}
return stamp[key];
}
// ==== ХОЛСТ И РАЗМЕРЫ ====
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
var viewW = 0; // логическая ширина холста (CSS-пиксели)
var viewH = 0; // логическая высота холста
var dpr = 1; // текущий коэффициент плотности пикселей
// Геометрия доски: клетки квадратные, поле может быть прямоугольным (fieldW×fieldH).
// board.w/board.h — пиксельный размер доски; board.size — legacy-алиас ширины
// (для квадратных полей w == h; читается тестами/отладкой); board.cell — сторона клетки.
var board = { x: 0, y: 0, w: 0, h: 0, size: 0, cell: 0 };
// Пересчёт размеров холста под окно с учётом DPR (кап 2 — как в ../fishing-game).
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
viewW = window.innerWidth;
viewH = window.innerHeight;
canvas.width = Math.round(viewW * dpr);
canvas.height = Math.round(viewH * dpr);
canvas.style.width = viewW + 'px';
canvas.style.height = viewH + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
computeBoard();
}
// Вычисление положения/размера доски. Клетки квадратные, поле может быть прямоугольным
// (fieldW×fieldH), поэтому доска — прямоугольник board.w×board.h. Две раскладки:
// • боковая (ландшафт, body.side): доска во всю высоту, прижата влево, управление
// в свободной зоне справа (ширина -> CSS-переменная --side-w);
// • обычная: доска по центру, с резервами сверху (HUD) и снизу (тулбар+действия).
// layoutSide — активна ли боковая раскладка (читается тестами/отладкой).
var layoutSide = false;
var SIDE_MIN = 210; // минимальная ширина боковой панели: иначе обычная раскладка
function computeBoard() {
const pad = 10;
const fullH = viewH - pad * 2; // доска во всю высоту
// Боковая раскладка: доска занимает всю высоту. Её ширина зависит от пропорций поля,
// поэтому проверяем, что справа реально остаётся место под панель управления.
const sideBoardW = (fullH / fieldH) * fieldW;
layoutSide = viewW > viewH && (viewW - sideBoardW - pad * 3) >= SIDE_MIN;
if (layoutSide) {
board.cell = fullH / fieldH;
board.w = sideBoardW;
board.h = fullH;
const sideW = Math.max(SIDE_MIN, viewW - board.w - pad * 3);
document.documentElement.style.setProperty('--side-w', sideW + 'px');
board.x = pad; // доска прижата влево, во всю высоту
board.y = Math.round((viewH - board.h) / 2);
} else {
// bottom резервирует место под тулбар (с кнопками поворота/тапа он может занимать
// две строки) и нижнюю панель действий — чтобы доска их не перекрывала.
const top = 64, bottom = 210;
const availW = viewW - pad * 2;
const availH = viewH - top - bottom;
// Одна клетка на обе оси (клетки квадратные): вписываем поле по меньшей стороне,
// но не даём доске выродиться (мин. 60px по ограничивающей оси).
const cell = Math.min(availW / fieldW, Math.max(60, availH) / fieldH);
board.cell = cell;
board.w = cell * fieldW;
board.h = cell * fieldH;
board.x = Math.round((viewW - board.w) / 2);
board.y = Math.round(top + Math.max(0, (availH - board.h) / 2));
}
board.size = board.w; // legacy-алиас (квадратное поле: w == h)
document.body.classList.toggle('side', layoutSide);
}
// ==== СОСТОЯНИЕ ИГРЫ ====
var state = 'MENU'; // текущее состояние FSM
var mode = 'menu'; // режим: menu/place/edit/handoff/run/pause/over
var turn = 1; // чей сейчас ход (1 или 2)
var tool = TOOLS[0]; // активный инструмент
var stamp = null; // активный паттерн-штамп (PATTERNS[i]) или null; транзиентно (не в сейв)
var stampRot = 0; // поворот активного штампа: 0..3 (90° по часовой за шаг); транзиентно
// Режим «постановка по отпусканию» (тап для сенсорных экранов): при нажатии — только
// превью под пальцем, постановка/стирание — в момент отпускания. Drag-рисование линии
// отключено. Транзиентный, как speedMult/stamp (в сейв не пишется). pressing — удержание.
var tapPlace = false;
var pressing = false;
var winner = null; // '1' | '2' | 'draw' (после финала)
var reason = null; // 'extinct' | 'manual' | 'stalemate' (причина финала)
var fieldW = DEFAULT_FIELD;
var fieldH = DEFAULT_FIELD;
var field = new Uint8Array(fieldW * fieldH); // 0 = мёртвая, 1 = P1, 2 = P2
var next = new Uint8Array(fieldW * fieldH); // буфер следующего поколения
var turnStartField = null; // снимок поля на начало текущей фазы рисования («Сброс»)
var wrap = false; // «Замкнутая карта» (torus): соседи за краем берутся с другой стороны
// Бюджет клеток на партию: total — лимит на игрока (настройка), usedN — потрачено (инкрементально).
var budget = { total: DEFAULT_BUDGET, used1: 0, used2: 0 };
// Снимок бюджета на начало текущего хода (для «Сброса» рядом с turnStartField).
var turnStartBudget = { used1: 0, used2: 0 };
// Прерванная партия, отложенная в меню. Кнопка «В меню»/⚙️ не теряет игру:
// снимок целиком (размер/wrap/поле/бюджет) живёт здесь, пока игрок в меню,
// и «Продолжить партию» возвращает ровно в ту же партию.
var suspended = null;
// Открытый диалог подтверждения рискованного действия: null | 'new' | 'end'.
// Ни одна кнопка не завершает/не теряет партию без такого подтверждения.
var confirmAction = null;
// Был ли RUNNING до открытия подтверждения завершения (чтобы вернуть симуляцию
// при «Отмене»; на время диалога симуляция ставится на паузу).
var confirmResume = false;
// --- Режим «Кампания» ---
// campaign — идёт ли уровень кампании; levelIndex — индекс уровня (0..) или -1;
// zoneRect — прямоугольник зоны рисования игрока на уровне или null (обычная игра);
// campaignProgress — прогресс (сколько уровней разблокировано) — хранится в сейве;
// pendingLevel — уровень, ожидающий подтверждения в диалоге (confirmAction='campaign').
var campaign = false;
var levelIndex = -1;
var zoneRect = null;
var campaignProgress = { unlocked: 1 };
var pendingLevel = null;
// --- Пользовательские настройки (из меню) vs «активная игра» (D1) ---
// Кампания применяет размер/wrap/бюджет УРОВНЯ к активной игре (fieldW/fieldH/wrap/
// budget.total), но НЕ трогает эти значения. Они живут отдельно и восстанавливаются
// при выходе из кампании (toMenu/showLevelSelect) и в меню-контролах syncDom.
// persist() пишет активную игру в fieldW/fieldH/wrap/budget (для резюма) и отдельно —
// пользовательские настройки в menuFieldW/menuFieldH/menuWrap/menuBudget.
var menuFieldW = DEFAULT_FIELD;
var menuFieldH = DEFAULT_FIELD;
var menuWrap = false;
var menuBudget = DEFAULT_BUDGET;
// Нужно ли пересобрать offscreen-слой клеток при следующем draw (см. секцию ОТРИСОВКА).
// Ставится при любом изменении поля/маски/размера; в статичных фазах draw — только drawImage.
var renderDirty = true;
// --- Стабильность поля (гейт дорисовки) ---
// Дорисовка разрешена ТОЛЬКО когда поле перестало меняться: step не меняет поле
// (фиксированная точка, период 1) ИЛИ новое поколение повторило одно из последних
// STABLE_WINDOW (короткий цикл — ловит блинкеры/осцилляторы). Отслеживается во
// время RUNNING: при обнаружении цикличности симуляция САМА встаёт на паузу и
// открывает дорисовку. Если поле не застывает (глайдеры/рост/хаос) — дорисовки
// нет, партию завершают вручную. Сравнение по хешу поля (FNV-1a): коллизия
// 32-битного хеша даёт ложное срабатывание с вероятностью ~2^-32 — пренебрежимо.
var fieldStable = false; // поле на паузе устойчиво (дорисовка разрешена)
var recentHashes = []; // скользящее окно хешей последних поколений (RUNNING)
const STABLE_WINDOW = 1024; // максимальный распознаваемый период цикла
function resetStability() { recentHashes.length = 0; fieldStable = false; }
// Засеять окно хешем текущего поколения (старт симуляции/тест-форс RUNNING):
// фиксированная точка ловится на 1-м тике, блинкер — на 2-м.
function seedStability() { recentHashes.length = 0; recentHashes.push(hashField(field)); fieldStable = false; }
// Списки состояний для быстрых проверок.
const DRAW_STATES = { PLACE_P1: 1, PLACE_P2: 1, EDIT_P1: 1, EDIT_P2: 1 };
// Handoff — только в первичной расстановке: HANDOFF_P1_P2 (передать вслепую игроку 2)
// и HANDOFF_P2_START (расстановка завершена: вернуться к P1 или начать симуляцию).
// В дорисовке (EDIT_*) оба видят поле, передача устройства не нужна: «Готов» возвращает
// в паузу, откуда игрок сам выбирает — дорисовать второму или запустить симуляцию.
const HANDOFF_STATES = { HANDOFF_P1_P2: 1, HANDOFF_P2_START: 1 };
function isDrawState(s) { return !!DRAW_STATES[s]; }
function isHandoffState(s) { return !!HANDOFF_STATES[s]; }
// Состояния с незавершённой партией — подлежат резюму после reload.
const RESUMABLE_STATES = { PLACE_P1: 1, PLACE_P2: 1, EDIT_P1: 1, EDIT_P2: 1,
RUNNING: 1, PAUSED: 1, HANDOFF_P1_P2: 1, HANDOFF_P2_START: 1 };
function isResumable(s) { return !!RESUMABLE_STATES[s]; }
// Производный описатель фазы (menu/place/edit/handoff/run/pause/over) из state.
function modeForState(s) {
if (s === 'LEVEL_SELECT' || s === 'TWO_SETUP') return 'menu'; // экраны выбора/настройки
if (s === 'PLACE_P1' || s === 'PLACE_P2') return 'place';
if (s === 'EDIT_P1' || s === 'EDIT_P2') return 'edit';
if (isHandoffState(s)) return 'handoff';
if (s === 'RUNNING') return 'run';
if (s === 'PAUSED') return 'pause';
if (s === 'GAME_OVER') return 'over';
return 'menu';
}
// ==== МОДЕЛЬ ПОЛЯ (чистая, без DOM) ====
// Таблицы индексов соседей: колонки (colL/colR) и строки (rowU/rowD).
// При wrap=false содержат координату x±1 / y±1 (за краем -> -1 или W/H, отсекается
// guard'ом), при wrap=true — координату с другой стороны (torus). Пересобираются
// при смене размера/опции — в горячем цикле НЕТ деления по модулю.
// NB: таблицы индексов строятся по ГЛОБАЛЬНОМУ wrap; передавай явный useWrap только
// согласованно с setWrap, иначе обход (ветка wrap=true) будет неверным.
var colL = new Int16Array(0), colR = new Int16Array(0);
var rowU = new Int16Array(0), rowD = new Int16Array(0);
function rebuildNeighborTables() {
colL = new Int16Array(fieldW);
colR = new Int16Array(fieldW);
rowU = new Int16Array(fieldH);
rowD = new Int16Array(fieldH);
for (let x = 0; x < fieldW; x++) {
colL[x] = wrap ? (x - 1 + fieldW) % fieldW : x - 1;
colR[x] = wrap ? (x + 1) % fieldW : x + 1;
}
for (let y = 0; y < fieldH; y++) {
rowU[y] = wrap ? (y - 1 + fieldH) % fieldH : y - 1;
rowD[y] = wrap ? (y + 1) % fieldH : y + 1;
}
}
// Установка опции «Замкнутая карта»: глобал + пересборка таблиц соседей + рендер.
function setWrap(b) {
wrap = !!b;
menuWrap = !!b; // пользовательская настройка (см. menuFieldW)
rebuildNeighborTables();
resetStability(); // смена правила соседей — окно хешей прежнего правила недействительно
renderDirty = true; // смена заворота может поменять видимую маску/поле к следующему draw
syncDom();
persist();
}
// Число живых соседей Мура (8). useWrap опционален: по умолчанию — глобал wrap.
// wrap=false — границы ЖЁСТКИЕ (соседи за краем не считаются, семантика как раньше).
// wrap=true — torus (противоположная сторона через таблицы индексов).
function liveNeighbors(f, x, y, useWrap) {
const w = (useWrap === undefined) ? wrap : useWrap;
let n = 0;
if (w) {
const l = colL[x], r = colR[x];
const u = rowU[y] * fieldW, d = rowD[y] * fieldW, c = y * fieldW;
if (f[u + l] !== 0) n++;
if (f[u + x] !== 0) n++;
if (f[u + r] !== 0) n++;
if (f[c + l] !== 0) n++;
if (f[c + r] !== 0) n++;
if (f[d + l] !== 0) n++;
if (f[d + x] !== 0) n++;
if (f[d + r] !== 0) n++;
} else {
for (let dy = -1; dy <= 1; dy++) {
const ny = y + dy;
if (ny < 0 || ny >= fieldH) continue;
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
if (nx < 0 || nx >= fieldW) continue;
if (f[ny * fieldW + nx] !== 0) n++;
}
}
}
return n;
}
// Цвет большинства живых цветов; при неоднозначности — наименьший id (tie-break).
// Принимает любое число аргументов (для рождения передаём 8 соседей, нули игнорируются).
function majorityColor() {
const counts = {};
for (let i = 0; i < arguments.length; i++) {
const c = arguments[i];
if (c > 0) counts[c] = (counts[c] || 0) + 1;
}
let best = 0, bestN = 0;
for (const k in counts) {
const c = +k, n = counts[k];
if (n > bestN || (n === bestN && c < best)) { best = c; bestN = n; }
}
return best;
}
// Один тик автомата: читает src, пишет dst; src НЕ мутируется. B3/S23.
// useWrap опционален: по умолчанию — глобал wrap. При wrap=false ветка идентична
// прежней (жёсткие границы) — паритет с прототипом сохраняется.
// NB: ветка wrap=true использует таблицы индексов, построенные по ГЛОБАЛУ wrap;
// явный useWrap передавай только согласованно с setWrap, иначе обход неверен.
function step(src, dst, useWrap) {
const w = (useWrap === undefined) ? wrap : useWrap;
if (w) {
for (let y = 0; y < fieldH; y++) {
const c = y * fieldW;
const u = rowU[y] * fieldW, d = rowD[y] * fieldW;
for (let x = 0; x < fieldW; x++) {
const i = c + x;
const cur = src[i];
const l = colL[x], r = colR[x];
const vl = src[u + l], vu = src[u + x], vr = src[u + r];
const cl = src[c + l], cr = src[c + r];
const dl = src[d + l], du = src[d + x], dr = src[d + r];
const n = (vl !== 0 ? 1 : 0) + (vu !== 0 ? 1 : 0) + (vr !== 0 ? 1 : 0) +
(cl !== 0 ? 1 : 0) + (cr !== 0 ? 1 : 0) +
(dl !== 0 ? 1 : 0) + (du !== 0 ? 1 : 0) + (dr !== 0 ? 1 : 0);
if (cur !== 0) {
dst[i] = (n === 2 || n === 3) ? cur : 0;
} else if (n === 3) {
dst[i] = majorityColor(vl, vu, vr, cl, cr, dl, du, dr);
} else {
dst[i] = 0;
}
}
}
return dst;
}
for (let y = 0; y < fieldH; y++) {
const row = y * fieldW;
for (let x = 0; x < fieldW; x++) {
const i = row + x;
const cur = src[i];
const n = liveNeighbors(src, x, y, false);
if (cur !== 0) {
dst[i] = (n === 2 || n === 3) ? cur : 0; // выживание при 2–3
} else if (n === 3) {
// Рождение: цвет большинства из 3 живых соседей.
let k = 0, a = 0, b = 0, c = 0;
for (let dy = -1; dy <= 1; dy++) {
const ny = y + dy;
if (ny < 0 || ny >= fieldH) continue;
for (let dx = -1; dx <= 1; dx++) {
if (!dx && !dy) continue;
const nx = x + dx;
if (nx < 0 || nx >= fieldW) continue;
const v = src[ny * fieldW + nx];
if (v !== 0) { if (k === 0) a = v; else if (k === 1) b = v; else c = v; k++; }
}
}
dst[i] = majorityColor(a, b, c);
} else {
dst[i] = 0;
}
}
}
return dst;
}
// n тиков подряд, синхронно, по полному полю (с учётом буферов).
// useWrap опционален: по умолчанию — глобал wrap.
function stepN(n, useWrap) {
const w = (useWrap === undefined) ? wrap : useWrap;
let src = field, dst = next;
for (let i = 0; i < n; i++) {
step(src, dst, w);
const t = src; src = dst; dst = t;
}
field = src; next = dst;
renderDirty = true; // поле изменилось — пересобрать offscreen-слой к следующему draw
return field;
}
// Штамп инструмента size×size с центрированием и клампом к границам поля.
// ЧИСТО ГЕОМЕТРИЧЕСКИЙ примитив: без зоны и бюджета (на него завязаны тесты);
// игровое действие (зона + бюджет) — в paintTool ниже.
function applyBrush(f, x, y, t, player) {
t = t || tool;
const size = t.size;
const half = size >> 1;
for (let dy = -half; dy < size - half; dy++) {
for (let dx = -half; dx < size - half; dx++) {
const nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= fieldW || ny >= fieldH) continue;
f[ny * fieldW + nx] = t.erase ? 0 : player;
}
}
return f;
}
// Зона рисования: P1 — левая половина, P2 — правая. Целочисленно (ок и для нечётной ширины).
function inZone(x, player) {
return player === 1 ? (2 * x < fieldW) : (2 * x >= fieldW);
}
// Граница зон (первый x правой половины) + предикат — для UI/рендера (R2-2).
function zoneSplit() {
return { boundary: Math.ceil(fieldW / 2), inZone: inZone };
}
// Зона рисования с учётом кампании: в кампании — прямоугольник zoneRect (любой
// размер/позиция, вплоть до всего поля), иначе — половина (inZone). inZone/zoneSplit
// НЕ меняются (контракт тестов): игровые места ходят через inZoneAt(x,y,player).
function inZoneAt(x, y, player) {
if (campaign) {
if (!zoneRect) return false;
return x >= zoneRect.x && x < zoneRect.x + zoneRect.w &&
y >= zoneRect.y && y < zoneRect.y + zoneRect.h;
}
return inZone(x, player);
}
// Остаток бюджета игрока (1|2).
function remaining(player) {
const used = player === 2 ? budget.used2 : budget.used1;
return budget.total - used;
}
// Установка лимита бюджета (настройка; применяется в newGame). Значение — целое ≥ 0.
function setBudgetTotal(n) {
n = Math.floor(Number(n));
if (!Number.isFinite(n) || n < 0) return;
budget.total = n;
menuBudget = n; // пользовательская настройка (см. menuFieldW)
persist();
}
// ИГРОВОЕ действие инструмента в клетке (x,y): учитывает фазу, зону и бюджет.
// Возвращает число изменённых клеток. Единственная точка мутации budget.used*.
// - краска: новая/чужая клетка при remaining>0 -> player (+1 бюджет); своя — без траты;
// при частичной нехватке ставим min(new, remaining) в детерминированном порядке;
// - ластик: снимает ТОЛЬКО правку текущего хода (field===player и turnStartField!==player),
// восстанавливая turnStartField[i] и возвращая бюджет; чужое/старшее — не трогает.
function paintTool(x, y) {
if (!isDrawState(state)) return 0;
if (!inZoneAt(x, y, turn)) return 0;
const t = paintOverride || tool, player = turn, size = t.size, half = size >> 1;
let changed = 0;
if (t.erase) {
for (let dy = -half; dy < size - half; dy++) {
const ny = y + dy;
if (ny < 0 || ny >= fieldH) continue;
for (let dx = -half; dx < size - half; dx++) {
const nx = x + dx;
if (nx < 0 || nx >= fieldW) continue;
if (!inZoneAt(nx, ny, player)) continue;
const i = ny * fieldW + nx;
if (campaign && field[i] === CAMPAIGN_ENEMY) continue; // врага стирать нельзя
const ts = turnStartField ? turnStartField[i] : 0;
if (field[i] === player && ts !== player) {
field[i] = ts;
if (player === 2) budget.used2--; else budget.used1--;
changed++;
}
}
}
} else if (stamp) {
// Штамп готового паттерна: якорь — левый верхний угол bbox в клетке (x,y).
// Зона/бюджет — как у краски: вне зоны не ставим, чужая/пустая тратит 1,
// своя не тратит, при нехватке ставим min(new, remaining) в порядке клеток.
let used = player === 2 ? budget.used2 : budget.used1;
let rem = budget.total - used;
const cells = stampCells(); // с учётом поворота колесом (stampRot)
for (let k = 0; k < cells.length; k++) {
const nx = x + cells[k].x, ny = y + cells[k].y;
if (nx < 0 || ny < 0 || nx >= fieldW || ny >= fieldH) continue; // кламп по доске
if (!inZoneAt(nx, ny, player)) continue; // обрезка по зоне
const i = ny * fieldW + nx;
if (campaign && field[i] === CAMPAIGN_ENEMY) continue; // врага не перезаписываем
if (field[i] === player) continue; // своя клетка — бюджет не тратит
if (rem <= 0) continue; // бюджет исчерпан — новые не пишем
field[i] = player;
used++; rem--;
changed++;
}
if (player === 2) budget.used2 = used; else budget.used1 = used;
} else {
let used = player === 2 ? budget.used2 : budget.used1;
let rem = budget.total - used;
for (let dy = -half; dy < size - half; dy++) {
const ny = y + dy;
if (ny < 0 || ny >= fieldH) continue;
for (let dx = -half; dx < size - half; dx++) {
const nx = x + dx;
if (nx < 0 || nx >= fieldW) continue;
if (!inZoneAt(nx, ny, player)) continue;
const i = ny * fieldW + nx;
if (campaign && field[i] === CAMPAIGN_ENEMY) continue; // врага не перезаписываем
if (field[i] === player) continue; // своя клетка — бюджет не тратит
if (rem <= 0) continue; // бюджет исчерпан — новые не пишем
field[i] = player;
used++; rem--;
changed++;
}
}
if (player === 2) budget.used2 = used; else budget.used1 = used;
}
if (changed) renderDirty = true;
return changed;
}
// Счётчики живых клеток по цветам.
function countByPlayer(f) {
f = f || field;
let p1 = 0, p2 = 0;
for (let i = 0; i < f.length; i++) {
const v = f[i];
if (v === 1) p1++; else if (v === 2) p2++;
}
return { p1: p1, p2: p2 };
}
function getScores() { return countByPlayer(field); }
// Вымирание: на доске остался не более одного цвета.
function isExtinct(f) {
f = f || field;
let a = false, b = false;
for (let i = 0; i < f.length; i++) {
if (f[i] === 1) a = true; else if (f[i] === 2) b = true;
if (a && b) return false;
}
return true;
}
// Детерминированный хеш поля (FNV-1a, 32 бита).
function hashField(f) {
const arr = f || field;
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < arr.length; i++) {
h ^= arr[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
// Маска видимых цветов по состоянию (скрытие — только визуальное).
function visibleMask() {
if (campaign) return [1, 2]; // в кампании скрытия нет: враг виден всегда
switch (state) {
case 'PLACE_P1': return [1];
case 'PLACE_P2': return [2];
case 'HANDOFF_P1_P2': case 'HANDOFF_P2_START': return [];
default: return [1, 2]; // RUNNING/PAUSED/EDIT_*/GAME_OVER/MENU — всё видно
}
}
// ==== FSM / ЛОГИКА ====
// Начать фазу рисования: выставить состояние/ход и снять снимок поля для «Сброса».
function beginDraw(st, tn) {
state = st;
turn = tn;
mode = modeForState(st);
turnStartField = field.slice();
turnStartBudget = { used1: budget.used1, used2: budget.used2 };
renderDirty = true; // смена маски/фазы — пересобрать слой клеток
}
// Обнулить в массиве клетки зоны игрока (для «чистого» снимка первичной расстановки).
function clearZone(arr, player) {
if (campaign) return; // в кампании «чистый» снимок зоны не нужен (нельзя терять врага)
for (let y = 0; y < fieldH; y++) {
for (let x = 0; x < fieldW; x++) {
if (inZoneAt(x, y, player)) arr[y * fieldW + x] = 0;
}
}
}
// Вход в фазу первичной расстановки с «чистым» снимком начала хода для текущего
// игрока: в снимке обнуляется его зона (прошлая расстановка этого игрока), клетки
// соперника сохраняются. Так «Сброс»/ластик откатывают именно расстановку игрока,
// а возврат от P2 к P1 (и повторная передача) не теряет чужую половину и корректно
// возвращает бюджет (текущий игрок начинает «с чистого листа»).
function enterPlace(st, tn) {
beginDraw(st, tn);
clearZone(turnStartField, tn);
turnStartBudget = { used1: tn === 1 ? 0 : budget.used1, used2: tn === 2 ? 0 : budget.used2 };
}
// Новая партия: чистое поле, обнуление бюджета, ход игрока 1, первичная расстановка.
function newGame() {
field.fill(0);
next.fill(0);
winner = null;
reason = null;
suspended = null; // новая партия вытесняет отложенную
confirmAction = null;
confirmResume = false;
campaign = false; // обычная hot-seat партия — не кампания
levelIndex = -1;
zoneRect = null;
pendingLevel = null;
budget.used1 = 0;
budget.used2 = 0;
speedMult = 1; // новая партия стартует с обычной скорости
resetStability(); // чистое поле — устойчивость ещё не оценивалась
beginDraw('PLACE_P1', 1);
simAcc = 0;
last = performance.now();
syncDom();
persist();
}
// «Готов» в фазе рисования.
function commitTurn() {
if (campaign) {
// Кампания: без передачи устройства и без P2. «Готов» и из расстановки, и из
// дорисовки сразу запускает симуляцию — дорисовка замкнута в цикл «нарисовал →
// сразу запустил», отдельной кнопки «Старт симуляции» не требуется.
if (state === 'PLACE_P1' || state === 'EDIT_P1') startRunning();
} else {
switch (state) {
case 'PLACE_P1': state = 'HANDOFF_P1_P2'; break;
case 'PLACE_P2': state = 'HANDOFF_P2_START'; break; // выбор: вернуться к P1 или старт
case 'EDIT_P1': startPaused(); break; // дорисовка → пауза: дальше выбор (P2/старт)
case 'EDIT_P2': startPaused(); break;
}
}
mode = modeForState(state);
renderDirty = true; // HANDOFF меняет маску видимости
syncDom();
persist();
}
// Кнопка на оверлее передачи/подтверждения.
function handoffNext() {
if (state === 'HANDOFF_P1_P2') enterPlace('PLACE_P2', 2);
else if (state === 'HANDOFF_P2_START') startRunning();
mode = modeForState(state);
renderDirty = true; // HANDOFF меняет маску видимости
syncDom();
persist();
}
// Возврат от P2 к игроку 1 после расстановки P2 (HANDOFF_P2_START): P1 правит свою
// половину, расстановка P2 (в правой половине) сохраняется и снова скрыта — слепая
// расстановка не нарушается. Затем P1 снова передаёт устройство P2.
function handoffBack() {
if (state !== 'HANDOFF_P2_START') return;
enterPlace('PLACE_P1', 1);
syncDom();
persist();
}
// Вернуться в паузу после хода дорисовки (между ходами снимок не нужен).
function startPaused() {
state = 'PAUSED';
mode = modeForState(state);
turnStartField = null;
turnStartBudget = { used1: 0, used2: 0 };
renderDirty = true;
simAcc = 0;
last = performance.now();
lastPersistAt = performance.now();
}
// Запуск симуляции (сбрасываем аккумулятор, чтобы шаг не «догонял»).
function startRunning() {
state = 'RUNNING';
mode = modeForState(state);
turnStartField = null; // в симуляции снимок хода не нужен (и не пишем его в сейв)
turnStartBudget = { used1: 0, used2: 0 }; // симметрично: снимок бюджета тоже не храним
seedStability(); // начинаем следить за устойчивостью поля с текущего поколения
renderDirty = true;
simAcc = 0;
last = performance.now();
lastPersistAt = performance.now();
}
function pauseGame() {
if (state === 'RUNNING') { state = 'PAUSED'; mode = modeForState(state); renderDirty = true; syncDom(); persist(); }
}
function resumeGame() {
if (state === 'PAUSED') { startRunning(); syncDom(); persist(); }
}
// Ряд скорости: 4 кнопки 1×/2×/4×/8×. Выбор множителя транзиентный — в сейв не пишем.
function setSpeed(m) {
speedMult = (m === 2 || m === 4 || m === 8) ? m : 1;
syncDom();
}
// Дорисовка на паузе. Разрешена ТОЛЬКО когда поле устойчиво (fieldStable) — т.е.
// симуляция сама остановилась, достигнув фиксированной точки/короткого цикла; на
// «живом» поле (глайдеры/хаос) дорисовки нет. Игрок сам выбирает, кто дорисовывает
// (кнопки «Дорисовать красным»/«Дорисовать зелёным»), и отдельно запускает симуляцию.
// Оба видят поле целиком (скрытие — только в первичной расстановке). Без аргумента — P1.
function startEdit(player) {
if (state !== 'PAUSED' || !fieldStable) return;
if (campaign && player === 2) return; // в кампании игрок только один (P1)
const p = player === 2 ? 2 : 1;
beginDraw(p === 2 ? 'EDIT_P2' : 'EDIT_P1', p);
syncDom();
persist();
}
// Копия прямоугольника зоны уровня (или null) — чтобы не мутировать CAMPAIGN_LEVELS.
function campaignZoneFor(idx) {
const lv = CAMPAIGN_LEVELS[idx];
if (!lv || !lv.zone) return null;
return { x: lv.zone.x, y: lv.zone.y, w: lv.zone.w, h: lv.zone.h };
}
// Заполнить поле клетками врага уровня (не через setFieldASCII — без побочек:
// снимок начала хода делает beginDraw, а «чистить» зону в кампании нельзя).
function buildCampaignField(lv) {
fieldW = lv.w;
fieldH = lv.h;
field = new Uint8Array(fieldW * fieldH);
next = new Uint8Array(fieldW * fieldH);
const cells = lv.cells || [];
for (let i = 0; i < cells.length; i++) {
const x = cells[i][0], y = cells[i][1];
if (x < 0 || y < 0 || x >= fieldW || y >= fieldH) continue; // защита от выхода за поле
field[y * fieldW + x] = CAMPAIGN_ENEMY;
}
}
// Запустить уровень кампании idx: враг на поле, зона-прямоугольник, свой бюджет,
// сразу фаза расстановки P1 (без передачи устройства). Враг остаётся в снимке хода
// (clearZone/enterPlace НЕ зовём — иначе враг сотрётся из turnStartField).
function startLevel(idx) {
const lv = CAMPAIGN_LEVELS[idx];
if (!lv) return;
campaign = true;
levelIndex = idx;
zoneRect = campaignZoneFor(idx);
wrap = !!lv.wrap;
buildCampaignField(lv);
budget.total = lv.budget;
budget.used1 = 0;
budget.used2 = 0;
turnStartBudget = { used1: 0, used2: 0 };
winner = null;
reason = null;
suspended = null;
confirmAction = null;
confirmResume = false;
pendingLevel = null;
speedMult = 1;
stamp = null; stampRot = 0; wheelAccum = 0;
paintOverride = null; painting = false; pressing = false; lastCell = null;
resetStability();
rebuildNeighborTables();
computeBoard();
beginDraw('PLACE_P1', CAMPAIGN_PLAYER);
simAcc = 0;
last = performance.now();
lastPersistAt = performance.now();
syncDom();
persist();
}
// Вернуть «активную игру» к пользовательским настройкам меню: размер/wrap/бюджет.
// Вызывается при выходе из кампании (toMenu/showLevelSelect) — уровень мог подменить
// эти значения, и обычная партия обязана стартовать с прежних настроек пользователя (D1).
function applyMenuSettings() {
fieldW = menuFieldW;
fieldH = menuFieldH;
wrap = menuWrap;
budget.total = menuBudget;
field = new Uint8Array(fieldW * fieldH);
next = new Uint8Array(fieldW * fieldH);
turnStartField = null;
budget.used1 = 0;
budget.used2 = 0;
turnStartBudget = { used1: 0, used2: 0 };
rebuildNeighborTables();
resetStability();
renderDirty = true;
computeBoard();
}
// Экран «Игра на двоих» (состояние TWO_SETUP, не резюмируется): справка и настройки
// поля перед стартом обычной партии. Настройки относятся только к hot-seat-партии,
// поэтому с «сплэша» MENU их не видно — они живут здесь.
function showTwoSetup() {
campaign = false;
levelIndex = -1;
zoneRect = null;
pendingLevel = null;
confirmAction = null;
confirmResume = false;
applyMenuSettings(); // активная игра возвращается к настройкам пользователя
state = 'TWO_SETUP';
mode = 'menu';
renderDirty = true;
syncDom();
persist();
}
// Экран выбора уровня кампании (отдельное состояние LEVEL_SELECT, не резюмируется).
function showLevelSelect() {
campaign = false;
levelIndex = -1;
zoneRect = null;
pendingLevel = null;
confirmAction = null;
confirmResume = false;
applyMenuSettings(); // активная игра возвращается к настройкам пользователя
state = 'LEVEL_SELECT';
mode = 'menu';
renderDirty = true;
syncDom();
persist();
}
// Выбор уровня из списка: если уровень открыт — спросить подтверждение (текущая/отложенная
// партия будет потеряна), затем startLevel. Закрытый уровень игнорируем.
function requestCampaignLevel(idx) {
if (!Number.isInteger(idx) || idx < 0 || idx >= CAMPAIGN_LEVELS.length) return;
if (idx >= campaignProgress.unlocked) return;
pendingLevel = idx;
confirmAction = 'campaign';
confirmResume = false;
syncDom();
}
// Итог партии: победитель по числу живых клеток.
function computeWinner() {
if (campaign) {
// В кампании исход определяется причиной финала: победа — уничтожен враг,
// поражение — исчерпан бюджет при живом враге.
winner = reason === 'win' ? '1' : (reason === 'defeat' ? '2' : 'draw');
return winner;
}
const s = countByPlayer(field);
winner = s.p1 > s.p2 ? '1' : (s.p2 > s.p1 ? '2' : 'draw');
return winner;
}
function finish(rc) {
reason = rc;
computeWinner();
// Победа в кампании разблокирует следующий уровень (unlocked — 1-based число
// открытых уровней), с клампом по числу уровней.
if (campaign && rc === 'win' && levelIndex >= 0) {
const nextUnlocked = Math.min(CAMPAIGN_LEVELS.length, levelIndex + 2);
if (nextUnlocked > campaignProgress.unlocked) campaignProgress.unlocked = nextUnlocked;
}
state = 'GAME_OVER';
mode = modeForState(state);
renderDirty = true; // финал меняет маску видимости
syncDom();
persist();
}
// Ручное завершение партии (без диалога). Прямой вызов оставлен для тестов;
// кнопки «Завершить партию» идут через requestEndGame() с подтверждением.
function endGame() {
if (state === 'RUNNING' || state === 'PAUSED') finish('manual');
}
// Кнопка «Завершить партию»: сначала подтверждение (партию нельзя терять одним кликом).
// На время диалога симуляция ставится на паузу, чтобы поле не жило за оверлеем.
function requestEndGame() {
if (state !== 'RUNNING' && state !== 'PAUSED') return;
confirmResume = (state === 'RUNNING');
if (state === 'RUNNING') pauseGame();
confirmAction = 'end';
syncDom();
}
// Проверка вымирания (вызывается после каждого шага симуляции).
// Как только у игрока не осталось клеток его цвета — партия окончена: бюджет
// на дорисовку финал больше не отодвигает, а устойчивость поля не требуется.
function checkEnd() {
if (state !== 'RUNNING' && state !== 'PAUSED') return;
if (campaign) {
// Победа — не осталось клеток ВРАГА (цвет 2). Имеет приоритет над поражением.
// Вымирание игрока (0 клеток цвета 1) финалом НЕ является: пока есть бюджет,
// можно продолжать. Поражение ловится отдельно (checkStalemate на авто-паузе).
if (countByPlayer(field).p2 === 0) { finish('win'); return; }
return;
}
if (!isExtinct(field)) return;
finish('extinct');
}
// Тупик на стабильной паузе: отстающий игрок исчерпал бюджет и не может ни
// поставить, ни стереть ничего (ластик снимает только правку текущего хода),
// а поле устойчиво — значит исход зафиксирован, партию можно завершать.
// При равенстве клеток завершаем, только если бюджет исчерпан у ОБОИХ (иначе
// один из них ещё может разорвать ничью в свою пользу).
function checkStalemate() {
if (state !== 'PAUSED' || !fieldStable) return false;
if (campaign) {
// Поражение: поле стабильно, у игрока исчерпан бюджет, а враг ещё жив.
// Фиксируется ТОЛЬКО здесь (в ветке авто-паузы tick), как и stalemate.
if (remaining(1) <= 0 && countByPlayer(field).p2 > 0) { finish('defeat'); return true; }
return false;
}
const s = countByPlayer(field);
const r1 = remaining(1), r2 = remaining(2);
if (s.p1 === s.p2) {
if (r1 <= 0 && r2 <= 0) { finish('stalemate'); return true; }
return false;
}
const trailing = s.p1 < s.p2 ? 1 : 2;
if (remaining(trailing) <= 0) { finish('stalemate'); return true; }
return false;
}
// «Сброс»: только в фазах рисования — вернуть поле И бюджет к снимку начала хода.
function resetGame() {
if (isDrawState(state) && turnStartField) {
field.set(turnStartField);
budget.used1 = turnStartBudget.used1;
budget.used2 = turnStartBudget.used2;
renderDirty = true;
syncDom();
persist();
}
}
// ==== ПЕРСИСТЕНТНОСТЬ (localStorage, ключ SAVE_KEY) ====
// Формат сейва v2 (см. AGENTS.md «Форма сейва»):
// v — версия формата (для миграций; текущая 2);
// fieldW/fieldH — размер поля (настройка);
// wrap — «Замкнутая карта» (настройка);
// tool — id активного инструмента (настройка);
// stepsPerSec — скорость симуляции (настройка);
// budget — { total, used1, used2, start1, start2 };
// state/turn — состояние FSM и чей ход (резюм партии);
// reason/winner — финал (reason='extinct'|'manual'|'stalemate', winner='1'|'2'|'draw');
// field — поле ЦЕЛИКОМ в base64 (1 байт/клетка), только при резюме;
// turnStartField — снимок начала хода для «Сброса» (base64 | null). Зона не хранится
// (производна от fieldW).
function freshSave() {
return {
v: SAVE_VERSION,
fieldW: DEFAULT_FIELD, fieldH: DEFAULT_FIELD,
wrap: false,
// Пользовательские настройки меню (отдельно от активной игры; см. menuFieldW).
menuFieldW: DEFAULT_FIELD, menuFieldH: DEFAULT_FIELD, menuWrap: false, menuBudget: DEFAULT_BUDGET,
theme: 'dark',
tool: TOOLS[0].id, stepsPerSec: STEPS_PER_SEC,
budget: { total: DEFAULT_BUDGET, used1: 0, used2: 0, start1: 0, start2: 0 },
state: 'MENU', turn: 1,
reason: null, winner: null,
stable: false, // устойчивость поля на паузе (гейт дорисовки)
field: null, turnStartField: null,
suspended: null, // отложенная в меню партия (для кнопки «Продолжить партию»)
campaign: false, // идёт ли уровень кампании
levelIndex: null, // индекс уровня кампании (0..) или null
progress: { unlocked: 1 } // прогресс кампании: сколько уровней открыто
};
}
var save = freshSave();
// Нормализация блока бюджета сейва (защита от битых/частичных данных).
function normalizeBudget(b) {
const out = { total: DEFAULT_BUDGET, used1: 0, used2: 0, start1: 0, start2: 0 };
if (b && typeof b === 'object') {
if (Number.isFinite(b.total) && b.total >= 0) out.total = Math.floor(b.total);
if (Number.isFinite(b.used1) && b.used1 >= 0) out.used1 = Math.floor(b.used1);
if (Number.isFinite(b.used2) && b.used2 >= 0) out.used2 = Math.floor(b.used2);
if (Number.isFinite(b.start1) && b.start1 >= 0) out.start1 = Math.floor(b.start1);
if (Number.isFinite(b.start2) && b.start2 >= 0) out.start2 = Math.floor(b.start2);
}
return out;
}
// Нормализация прогресса кампании: unlocked — число открытых уровней, клампится в [1, N].
function normalizeProgress(p) {
const out = { unlocked: 1 };
if (p && typeof p === 'object') {
let u = Math.floor(Number(p.unlocked));
if (!Number.isFinite(u) || u < 1) u = 1;
if (u > CAMPAIGN_LEVELS.length) u = CAMPAIGN_LEVELS.length;
out.unlocked = u;
}
return out;
}
// Инструмент по id (или null, если id неизвестен).
function findTool(id) {
for (let i = 0; i < TOOLS.length; i++) if (TOOLS[i].id === id) return TOOLS[i];
return null;
}
// Допустимый размер поля в сейве (не путать с FIELD_SIZES — это валидация выбора в меню).
function isValidSize(n) { return Number.isInteger(n) && n >= 1 && n <= 1000; }
// Поле (Uint8Array, 0/1/2) <-> base64: компактно и без spread-лимитов на больших массивах.
function bytesToB64(u8) {
let s = '';
for (let i = 0; i < u8.length; i += 0x8000) {
s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000));
}
return btoa(s);
}
function b64ToBytes(b64) {
const bin = atob(b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
// Нормализация отложенной партии из сейва: валидный резюмируемый снимок или null.
// Защита от битых/частичных данных — при любой проблеме отложенной партии нет.
function normalizeSuspended(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
if (!isResumable(raw.state)) return null;
if (!isValidSize(raw.fieldW) || !isValidSize(raw.fieldH)) return null;
if (typeof raw.field !== 'string') return null;
let f = null;
try { f = b64ToBytes(raw.field); } catch (e) { return null; }
if (!f || f.length !== raw.fieldW * raw.fieldH) return null;
let ts = null;
try { ts = typeof raw.turnStartField === 'string' ? b64ToBytes(raw.turnStartField) : null; } catch (e) { ts = null; }
return {
state: raw.state,
turn: raw.turn === 2 ? 2 : 1,
fieldW: raw.fieldW,
fieldH: raw.fieldH,
wrap: !!raw.wrap,
field: f,
turnStartField: (ts && ts.length === raw.fieldW * raw.fieldH) ? ts : null,
stable: !!raw.stable,
budget: normalizeBudget(raw.budget),
campaign: !!raw.campaign,
levelIndex: (Number.isInteger(raw.levelIndex) && raw.levelIndex >= 0 &&
raw.levelIndex < CAMPAIGN_LEVELS.length) ? raw.levelIndex : null
};
}
// Чтение life-save-v1 с миграцией. Возвращает нормализованный объект сейва,
// либо null при отсутствии/битых данных (тогда старт в MENU).
function loadSave() {
let raw = null;
try { raw = localStorage.getItem(SAVE_KEY); } catch (e) { return null; }
if (!raw) return null;
let obj;
try { obj = JSON.parse(raw); } catch (e) { return null; }
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
// -- миграции формата (образец для будущих версий) --
// Старые сейвы обязаны оставаться читаемыми; нечитаемые — null (старт в MENU).
if (typeof obj.v !== 'number') obj.v = 1; // сейв без версии трактуем как v1
if (obj.v > SAVE_VERSION) return null; // формат новее — не рискуем
if (obj.v < SAVE_VERSION) {
// M1 (v1→v2): добавлены опция «Замкнутая карта» (дефолт выкл.) и бюджет клеток.
if (obj.v < 2) {
if (obj.wrap === undefined) obj.wrap = false;
if (!obj.budget) obj.budget = { total: DEFAULT_BUDGET, used1: 0, used2: 0, start1: 0, start2: 0 };
obj.v = 2;
}
}
// -- нормализация (защита от частичных/битых сейвов) --
const nW = isValidSize(obj.fieldW) ? obj.fieldW : DEFAULT_FIELD;
const nH = isValidSize(obj.fieldH) ? obj.fieldH : nW; // поле квадратное: fieldH дефолтится в fieldW
const nBudget = normalizeBudget(obj.budget);
const s = {
v: SAVE_VERSION,
fieldW: nW,
fieldH: nH,
wrap: !!obj.wrap,
// Пользовательские настройки меню: старые сейвы не знают этих полей — берём активные
// (в обычном потоке они совпадают), бюджет — из budget.total.
menuFieldW: isValidSize(obj.menuFieldW) ? obj.menuFieldW : nW,
menuFieldH: isValidSize(obj.menuFieldH) ? obj.menuFieldH : nH,
menuWrap: (obj.menuWrap === undefined) ? !!obj.wrap : !!obj.menuWrap,
menuBudget: (Number.isFinite(obj.menuBudget) && obj.menuBudget >= 0) ? Math.floor(obj.menuBudget) : nBudget.total,
theme: (obj.theme === 'light' || obj.theme === 'dark') ? obj.theme : 'dark',
tool: findTool(obj.tool) ? obj.tool : TOOLS[0].id,
stepsPerSec: (typeof obj.stepsPerSec === 'number' && obj.stepsPerSec > 0) ? obj.stepsPerSec : 8,
budget: nBudget,
state: typeof obj.state === 'string' ? obj.state : 'MENU',
turn: obj.turn === 2 ? 2 : 1,
reason: (obj.reason === 'extinct' || obj.reason === 'manual' || obj.reason === 'stalemate' ||
obj.reason === 'win' || obj.reason === 'defeat') ? obj.reason : null,
winner: (obj.winner === '1' || obj.winner === '2' || obj.winner === 'draw') ? obj.winner : null,
stable: !!obj.stable,
field: typeof obj.field === 'string' ? obj.field : null,
turnStartField: typeof obj.turnStartField === 'string' ? obj.turnStartField : null,
suspended: normalizeSuspended(obj.suspended),
campaign: !!obj.campaign,
levelIndex: (Number.isInteger(obj.levelIndex) && obj.levelIndex >= 0 &&
obj.levelIndex < CAMPAIGN_LEVELS.length) ? obj.levelIndex : null,
progress: normalizeProgress(obj.progress)
};
// Резюм возможен только для игровых состояний; финал/MENU/выбор уровня — это
// настройки + чистый старт (кампания при этом не продолжается).
if (!isResumable(s.state)) {
s.state = 'MENU';
s.field = null;
s.turnStartField = null;
s.campaign = false;
s.levelIndex = null;
}
return s;
}
// Запись сейва: настройки всегда + резюм партии в игровых состояниях.
// Вызывается на значимых переходах (FSM, инструмент, размер, пауза/ход), не каждый кадр.
function persist() {
save.v = SAVE_VERSION;
save.fieldW = fieldW; // активная игра (нужна для резюма; в кампании — размер уровня)
save.fieldH = fieldH;
save.wrap = wrap;
// Пользовательские настройки меню (кампания их не перезаписывает) — D1.
save.menuFieldW = menuFieldW;
save.menuFieldH = menuFieldH;
save.menuWrap = menuWrap;
save.menuBudget = menuBudget;
save.theme = theme;
save.tool = tool ? tool.id : TOOLS[0].id;
save.stepsPerSec = STEPS_PER_SEC;
save.state = state;
save.turn = turn;
save.reason = reason;
save.winner = winner;
save.stable = fieldStable;
save.campaign = campaign;
save.levelIndex = campaign ? levelIndex : null;
save.progress = { unlocked: campaignProgress.unlocked };
save.budget = {
total: budget.total, used1: budget.used1, used2: budget.used2,
start1: turnStartBudget.used1, start2: turnStartBudget.used2
};
if (isResumable(state) && field && field.length === fieldW * fieldH) {
try {
save.field = bytesToB64(field);
save.turnStartField = turnStartField ? bytesToB64(turnStartField) : null;
} catch (e) { save.field = null; save.turnStartField = null; }
} else {
save.field = null;
save.turnStartField = null;
}
// Отложенная партия (если игрок в меню): сохраняем снимок целиком, чтобы
// «Продолжить партию» пережило reload.
if (suspended) {
try {
save.suspended = {
state: suspended.state, turn: suspended.turn,
fieldW: suspended.fieldW, fieldH: suspended.fieldH, wrap: suspended.wrap,
field: bytesToB64(suspended.field),
turnStartField: suspended.turnStartField ? bytesToB64(suspended.turnStartField) : null,
stable: suspended.stable,
campaign: !!suspended.campaign,
levelIndex: (suspended.campaign && Number.isInteger(suspended.levelIndex)) ? suspended.levelIndex : null,
budget: {
total: suspended.budget.total, // лимит отложенной игры, а не текущий активный (D2)
used1: suspended.budget.used1, used2: suspended.budget.used2,
start1: suspended.budget.start1, start2: suspended.budget.start2
}
};
} catch (e) { save.suspended = null; }
} else {
save.suspended = null;
}
try { localStorage.setItem(SAVE_KEY, JSON.stringify(save)); } catch (e) {}
}
// Применить загруженный сейв при старте: настройки + (если есть) резюм партии.
function applySave(s) {
if (!s) {
save = freshSave(); suspended = null; confirmAction = null; confirmResume = false;
campaign = false; levelIndex = -1; zoneRect = null; pendingLevel = null;
campaignProgress = normalizeProgress(null);
menuFieldW = DEFAULT_FIELD; menuFieldH = DEFAULT_FIELD; menuWrap = false; menuBudget = DEFAULT_BUDGET;
applyTheme('dark');
applyMenuSettings(); // активная игра = пользовательским дефолтам (единый путь, D1)
return;
}
save = s;
renderDirty = true; // поле/маска/размер могли измениться — пересобрать offscreen
// Отложенная партия (кнопка «Продолжить партию» в меню).
suspended = s.suspended || null;
confirmAction = null;
confirmResume = false;
pendingLevel = null;
// Кампания сбрасывается; в ветке резюма восстановится из сейва + конфига уровня.
campaign = false;
levelIndex = -1;
zoneRect = null;
campaignProgress = normalizeProgress(s.progress);
// Настройки. Активная игра — из fieldW/fieldH/wrap/budget (для резюма, в т.ч. уровня
// кампании); пользовательские настройки меню — из menu* (старые сейвы: = активным).
fieldW = isValidSize(s.fieldW) ? s.fieldW : DEFAULT_FIELD;
fieldH = isValidSize(s.fieldH) ? s.fieldH : fieldW;
wrap = !!s.wrap;
menuFieldW = isValidSize(s.menuFieldW) ? s.menuFieldW : fieldW;
menuFieldH = isValidSize(s.menuFieldH) ? s.menuFieldH : fieldH;
menuWrap = (s.menuWrap === undefined) ? wrap : !!s.menuWrap;
menuBudget = (typeof s.menuBudget === 'number' && Number.isFinite(s.menuBudget) && s.menuBudget >= 0)
? Math.floor(s.menuBudget) : normalizeBudget(s.budget).total;
applyTheme(s.theme);
STEPS_PER_SEC = (typeof s.stepsPerSec === 'number' && s.stepsPerSec > 0) ? s.stepsPerSec : 8;
window.STEPS_PER_SEC = STEPS_PER_SEC;
tool = findTool(s.tool) || TOOLS[0];
const b = normalizeBudget(s.budget);
budget.total = b.total;
budget.used1 = b.used1;
budget.used2 = b.used2;
turnStartBudget = { used1: b.start1, used2: b.start2 };
rebuildNeighborTables();
// Резюм незавершённой партии: поле ЦЕЛОЕ (скрытие — только визуальное).
if (isResumable(s.state)) {
let f = null;
try { f = s.field ? b64ToBytes(s.field) : null; } catch (e) { f = null; }
if (f && f.length === fieldW * fieldH) {
state = s.state;
mode = modeForState(state);
turn = s.turn === 2 ? 2 : 1;
reason = null;
winner = null;
field = f;
next = new Uint8Array(fieldW * fieldH);
let ts = null;
try { ts = s.turnStartField ? b64ToBytes(s.turnStartField) : null; } catch (e) { ts = null; }
turnStartField = (ts && ts.length === fieldW * fieldH) ? ts
: (isDrawState(state) ? field.slice() : null);
// Кампания: восстанавливаем режим/уровень/прямоугольник зоны из сейва и конфига.
if (s.campaign && Number.isInteger(s.levelIndex) && CAMPAIGN_LEVELS[s.levelIndex]) {
campaign = true;
levelIndex = s.levelIndex;
zoneRect = campaignZoneFor(levelIndex);
}
// Устойчивость: на паузе восстанавливаем сохранённый флаг; в RUNNING — заново
// засеваем окно (детекция с этого поколения, как при обычном старте).
if (state === 'RUNNING') seedStability();
else { recentHashes.length = 0; fieldStable = !!s.stable; }
simAcc = 0;
last = performance.now();
return;
}
}
// Нет незавершённой/валидной партии — чистый старт в меню с настройками ПОЛЬЗОВАТЕЛЯ.
// Активная игра могла остаться от уровня кампании (напр. 30×30 / бюджет 20) — тогда
// fieldW/budget.total тянули бы настройки уровня; applyMenuSettings() возвращает их к
// menu* (D1). Покрывает и фолбэк «резюмируемое состояние, но битое поле».
applyMenuSettings();
state = 'MENU';
mode = 'menu';
turn = 1;
reason = null;
winner = null;
}
// ==== ОТРИСОВКА ====
var hoverCell = null; // клетка под указателем (для ghost-превью)
var painting = false; // идёт ли рисование (drag)
var lastCell = null; // последняя обработанная клетка при drag
// Инструмент, временно подменяющий активный на время drag (правой кнопкой — ластик).
// Не трогает глобал tool (иначе поехали бы подсветка тулбара/сейв): paintTool смотрит сюда.
var paintOverride = null;
// Производительный слой клеток для больших полей (до 300×300 = 90 000 клеток):
// offscreen-канва fieldW×fieldH (1 px = 1 клетка) + ImageData. Пересобирается только
// при изменении поля/маски/размера (флаг renderDirty), затем масштабируется на доску
// одним drawImage. Цвет живых клеток остаётся точным (nearest-neighbor, alpha=255).
var fieldCanvas = document.createElement('canvas');
var fieldCtx = fieldCanvas.getContext('2d');
var fieldImage = null; // ImageData fieldW×fieldH
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const P1_RGB = hexToRgb(COLORS.p1);
const P2_RGB = hexToRgb(COLORS.p2);
// Пересборка offscreen-слоя: один проход по полю, RGBA цвета игрока для ВИДИМЫХ маской
// клеток (alpha 255), alpha 0 — для скрытых/мёртвых. Рендер не мутирует field.
function rebuildFieldImage() {
if (!fieldImage || fieldImage.width !== fieldW || fieldImage.height !== fieldH) {
fieldCanvas.width = fieldW;
fieldCanvas.height = fieldH;
fieldImage = fieldCtx.createImageData(fieldW, fieldH);
}
const mask = visibleMask();
const show1 = mask.indexOf(1) >= 0, show2 = mask.indexOf(2) >= 0;
const d = fieldImage.data;
const r1 = P1_RGB[0], g1 = P1_RGB[1], b1 = P1_RGB[2];
const r2 = P2_RGB[0], g2 = P2_RGB[1], b2 = P2_RGB[2];
for (let i = 0, j = 0; i < field.length; i++, j += 4) {
const v = field[i];
if (v === 1 && show1) { d[j] = r1; d[j + 1] = g1; d[j + 2] = b1; d[j + 3] = 255; }
else if (v === 2 && show2) { d[j] = r2; d[j + 1] = g2; d[j + 2] = b2; d[j + 3] = 255; }
else { d[j] = 0; d[j + 1] = 0; d[j + 2] = 0; d[j + 3] = 0; }
}
fieldCtx.putImageData(fieldImage, 0, 0);
renderDirty = false;
}
// Зона рисования кампании — прямоугольник zoneRect (под слоем клеток, чтобы не
// искажать их точный цвет). active (фазы рисования): затемняем ВСЁ вне прямоугольника
// и мягко тонируем его цветом игрока + рамка; иначе (RUNNING/PAUSED): лёгкий тинт +
// рамка-ориентир без затемнения.
function drawCampaignZone(active) {
if (!zoneRect) return;
const cs = board.cell, bx = board.x, by = board.y, bw = board.w, bh = board.h;
const rx = bx + zoneRect.x * cs, ry = by + zoneRect.y * cs;
const rw = zoneRect.w * cs, rh = zoneRect.h * cs;
const own = turn === 1 ? P1_RGB : P2_RGB;
if (active) {
// Затемнение четырёх областей вокруг прямоугольника (может покрывать всё поле — тогда пусто).
ctx.fillStyle = COLORS.zoneDim;
if (ry > by) ctx.fillRect(bx, by, bw, ry - by);
if (ry + rh < by + bh) ctx.fillRect(bx, ry + rh, bw, by + bh - (ry + rh));
if (rx > bx) ctx.fillRect(bx, ry, rx - bx, rh);
if (rx + rw < bx + bw) ctx.fillRect(rx + rw, ry, bx + bw - (rx + rw), rh);
ctx.fillStyle = 'rgba(' + own[0] + ',' + own[1] + ',' + own[2] + ',.10)';
ctx.fillRect(rx, ry, rw, rh);
} else {
ctx.globalAlpha = 0.05;
ctx.fillStyle = turn === 1 ? COLORS.p1 : COLORS.p2;
ctx.fillRect(rx, ry, rw, rh);
ctx.globalAlpha = 1;
}
ctx.strokeStyle = COLORS.zoneLine;
ctx.lineWidth = 1;
ctx.strokeRect(Math.round(rx) + 0.5, Math.round(ry) + 0.5, rw, rh);
}
function draw() {
ctx.fillStyle = COLORS.bg;
ctx.fillRect(0, 0, viewW, viewH);
if (!field) return;
const cs = board.cell, bx = board.x, by = board.y, bw = board.w, bh = board.h;
// Подсветка зон рисования (только PLACE_*/EDIT_*): свою половину мягко тонируем
// цветом текущего игрока (10%), чужую затемняем — оба оверлея ПОД клетками
// (точный цвет живых клеток не искажается). Плюс тонкая линия границы.
if (isDrawState(state)) {
// Кампания: зона — прямоугольник (затемняем всё вне него). Иначе — половина.
if (campaign && zoneRect) drawCampaignZone(true);
else {
const split = zoneSplit().boundary;
// Тинт своей половины 10% — строим из RGB игрока (без дублирования hex).
const own = turn === 1 ? P1_RGB : P2_RGB;
ctx.fillStyle = 'rgba(' + own[0] + ',' + own[1] + ',' + own[2] + ',.10)';
if (turn === 1) ctx.fillRect(bx, by, split * cs, bh);
else ctx.fillRect(bx + split * cs, by, (fieldW - split) * cs, bh);
// Затемнение чужой половины.
ctx.fillStyle = COLORS.zoneDim;
if (turn === 1) ctx.fillRect(bx + split * cs, by, (fieldW - split) * cs, bh); // затемняем правую (чужая)
else ctx.fillRect(bx, by, split * cs, bh); // затемняем левую (чужая)
ctx.strokeStyle = COLORS.zoneLine;
ctx.lineWidth = 1;
const gx = Math.round(bx + split * cs) + 0.5;
ctx.beginPath(); ctx.moveTo(gx, by); ctx.lineTo(gx, by + bh); ctx.stroke();
}
} else if (state === 'RUNNING' || state === 'PAUSED') {
// При симуляции/паузе — мягкая (5%) подсветка территорий ПОД клетками. В кампании
// это ориентир зоны-прямоугольника; в обычной игре — половины без затемнения.
if (campaign && zoneRect) drawCampaignZone(false);
else {
const split = zoneSplit().boundary;
ctx.globalAlpha = 0.05;
ctx.fillStyle = COLORS.p1;
ctx.fillRect(bx, by, split * cs, bh);
ctx.fillStyle = COLORS.p2;
ctx.fillRect(bx + split * cs, by, (fieldW - split) * cs, bh);
ctx.globalAlpha = 1;
ctx.strokeStyle = COLORS.zoneLine;
ctx.lineWidth = 1;
const gx = Math.round(bx + split * cs) + 0.5;
ctx.beginPath(); ctx.moveTo(gx, by); ctx.lineTo(gx, by + bh); ctx.stroke();
}
}
// Клетки: offscreen-слой (пересобирается при renderDirty) одним масштабированным
// drawImage. imageSmoothingEnabled=false — nearest-neighbor, без размытия/подмеса.
if (renderDirty) rebuildFieldImage();
ctx.imageSmoothingEnabled = false;
ctx.drawImage(fieldCanvas, bx, by, bw, bh);
// Сетка — только когда клетка достаточно крупная.
if (cs >= 5) {
ctx.strokeStyle = COLORS.grid;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= fieldW; i++) {
const gx = Math.round(bx + i * cs) + 0.5;
ctx.moveTo(gx, by); ctx.lineTo(gx, by + bh);
}
for (let j = 0; j <= fieldH; j++) {
const gy = Math.round(by + j * cs) + 0.5;
ctx.moveTo(bx, gy); ctx.lineTo(bx + bw, gy);
}
ctx.stroke();
}
// Ghost-превью инструмента под указателем (вне drag).
if (hoverCell && isDrawState(state)) {
const gTool = paintOverride || tool;
const ghostColor = turn === 1 ? 'rgba(231,76,60,.35)' : 'rgba(46,204,113,.35)';
if (stamp && !gTool.erase) {
// Превью штампа: якорь — левый верхний угол bbox. Клетки в своей зоне — цветом
// игрока, вылет за зону — тревожным цветом (игрок видит, что часть не поместится);
// клетки за пределами доски не рисуем.
const cells = stampCells(); // с учётом поворота колесом (stampRot)
for (let k = 0; k < cells.length; k++) {
const hx = hoverCell.x + cells[k].x, hy = hoverCell.y + cells[k].y;
if (hx < 0 || hy < 0 || hx >= fieldW || hy >= fieldH) continue;
ctx.fillStyle = inZoneAt(hx, hy, turn) ? ghostColor : COLORS.stampOut;
ctx.fillRect(bx + hx * cs, by + hy * cs, cs, cs);
}
} else {
const half = gTool.size >> 1;
ctx.fillStyle = gTool.erase ? COLORS.eraseGhost : ghostColor;
for (let dy = -half; dy < gTool.size - half; dy++) {
for (let dx = -half; dx < gTool.size - half; dx++) {
const hx = hoverCell.x + dx, hy = hoverCell.y + dy;
if (hx < 0 || hy < 0 || hx >= fieldW || hy >= fieldH) continue;
if (!inZoneAt(hx, hy, turn)) continue; // превью только в своей зоне рисования
ctx.fillRect(bx + hx * cs, by + hy * cs, cs, cs);
}
}
}
}
// Рамка доски.
ctx.strokeStyle = COLORS.border;
ctx.lineWidth = 2;
ctx.strokeRect(bx - 1, by - 1, bw + 2, bh + 2);
}
// ==== ЦИКЛ ====
var last = performance.now();
var simAcc = 0;
var lastPersistAt = 0; // метка времени последней записи сейва (троттлинг RUNNING)
function tick() {
step(field, next);
const t = field; field = next; next = t; // двойной буфер
renderDirty = true; // поле изменилось
checkEnd();
// Детекция устойчивости: если новое поколение уже встречалось среди последних
// STABLE_WINDOW — поле вошло в фиксированную точку/короткий цикл. Тогда симуляция
// сама встаёт на паузу и открывает дорисовку (см. startEdit/fieldStable).
if (state === 'RUNNING') {
const h = hashField(field);
if (recentHashes.indexOf(h) >= 0) {
fieldStable = true;
state = 'PAUSED';
mode = modeForState(state);
persist(); // фиксируем стабильную паузу немедленно (не ждём троттлинг RUNNING)
checkStalemate(); // отстающему нечем ходить — сразу финал (см. checkStalemate)
// Кампания: игрок один и ему всегда есть что дорисовать, поэтому сразу открываем
// фазу дорисовки — не надо каждый раз жать «Дорисовать». Если авто-пауза завершила
// партию поражением (checkStalemate), состояние уже GAME_OVER — дорисовку не зовём.
if (campaign && state === 'PAUSED') startEdit(1);
} else {
recentHashes.push(h);
if (recentHashes.length > STABLE_WINDOW) recentHashes.shift();
}
}
// Сейв в RUNNING троттлим по времени (~раз в 3 с): запись больших полей недёшева.
const now = performance.now();
if (state === 'RUNNING' && now - lastPersistAt >= PERSIST_INTERVAL_MS) {
lastPersistAt = now;
persist();
}
}
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); // кламп dt — переживаем просадки fps
last = now;
if (mode === 'run') {
simAcc += dt;
const stepDt = 1 / (STEPS_PER_SEC * speedMult);
let guard = 0;
while (simAcc >= stepDt && guard < 8) {
tick();
simAcc -= stepDt;
guard++;
if (state !== 'RUNNING') break;
}
} else {
simAcc = 0;
}
draw();
syncDom();
requestAnimationFrame(frame);
}
// ==== ВВОД (только Pointer Events) ====
// Координаты указателя в CSS-пикселях относительно холста.
function evtPos(e) {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
// Экранные координаты → клетка (или null, если вне доски).
function pixelToCell(px, py) {
const cs = board.cell;
const x = Math.floor((px - board.x) / cs);
const y = Math.floor((py - board.y) / cs);
if (x < 0 || y < 0 || x >= fieldW || y >= fieldH) return null;
return { x: x, y: y };
}
// Применить инструмент в точке (CSS-пиксели холста). Публичный тест-форс.
// Вне поля или вне своей зоны рисования — не действует (false).
function applyIntent(px, py) {
if (!isDrawState(state)) return false;
const c = pixelToCell(px, py);
if (!c) return false;
if (!inZoneAt(c.x, c.y, turn)) return false;
// Возврат по факту изменения: клик по клетке врага (в кампании) или по своей клетке
// ничего не меняет → false (иначе «действие применилось» ложно, D5).
return paintTool(c.x, c.y) > 0;
}
// Интерполяция линии между клетками (Брезенхэм) — без разрывов при drag.
// Внутри одной половины x монотонен, поэтому линия не выходит из зоны.
function paintLine(a, b) {
let x0 = a.x, y0 = a.y;
const x1 = b.x, y1 = b.y;
const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
for (;;) {
paintTool(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
// Инструмент для кнопки мыши: правая (button 2) — ластик, остальные — активный.
// Если выбран ластик, ПКМ сохраняет его размер (1×1/4×4), иначе берём базовый 1×1.
function toolForButton(button) {
if (button === 2) return (tool && tool.erase) ? tool : (findTool('erase1') || TOOLS[2]);
return tool;
}
function onPointerDown(e) {
e.preventDefault();
if (e.button !== 0 && e.button !== 2) return; // только ЛКМ/ПКМ (тач/перо — button 0)
const p = evtPos(e);
const c = pixelToCell(p.x, p.y);
if (!isDrawState(state) || !c) return;
hoverCell = c;
if (!inZoneAt(c.x, c.y, turn)) return; // вне своей зоны — рисование не начинаем
const override = toolForButton(e.button);
// Режим «постановка по отпусканию»: нажатие только показывает превью, а ставит/стирает
// инструмент по pointerup (см. onPointerUp). Так палец не закрывает точку постановки.
// paintOverride храним на время удержания — превью сразу отражает ПКМ-ластик.
if (tapPlace) {
paintOverride = override;
pressing = true;
if (canvas.setPointerCapture) { try { canvas.setPointerCapture(e.pointerId); } catch (_) {} }
return;
}
// Штамп паттерна: одно нажатие = одна постановка (без drag-повтора — иначе зальёт
// бюджет повторами на каждом шаге линии). Якорь штампа может быть в своей зоне, а
// часть паттерна выйти за неё — там просто не ставится. Правая кнопка (ластик)
// паттерн не штампует: работает обычным ластиком.
if (stamp && !(override && override.erase)) {
paintOverride = null;
paintTool(c.x, c.y);
return;
}
paintOverride = override;
painting = true;
lastCell = c;
paintTool(c.x, c.y);
if (canvas.setPointerCapture) { try { canvas.setPointerCapture(e.pointerId); } catch (_) {} }
}
function onPointerMove(e) {
const p = evtPos(e);
const c = pixelToCell(p.x, p.y);
hoverCell = c;
if (tapPlace && pressing) return; // тап: превью следует за пальцем, постановка — на отпускании
if (painting) {
if (!c || !inZoneAt(c.x, c.y, turn)) {
lastCell = null; // вышли из зоны/доски — не продлеваем линию
} else {
if (lastCell && inZoneAt(lastCell.x, lastCell.y, turn)) paintLine(lastCell, c);
else paintTool(c.x, c.y);
lastCell = c;
}
}
}
function onPointerUp(e) {
// Тап: постановка происходит в момент отпускания (pointercancel — отмена, без действия).
if (tapPlace && pressing) {
if (!e || e.type !== 'pointercancel') {
const p = evtPos(e);
const c = pixelToCell(p.x, p.y);
if (c && inZoneAt(c.x, c.y, turn)) paintTool(c.x, c.y);
}
pressing = false;
}
painting = false;
lastCell = null;
paintOverride = null; // правый ластик действует только на время удержания
// Ghost-превью: после отпускания подсветка остаётся только если указатель на доске.
if (e) { const p = evtPos(e); hoverCell = pixelToCell(p.x, p.y); }
else hoverCell = null;
}
// ==== UI ====
const el = {};
['turnLabel', 'scoreP1', 'scoreP2', 'scores', 'menu', 'handoff', 'handoffText', 'handoffNote',
'gameover', 'gameoverText', 'gameoverSub', 'toolbar', 'actions',
'btnReady', 'btnReset', 'btnRestart', 'btnPause', 'btnEnd', 'btnEndPause', 'btnResume', 'speedRow',
'btnEditP1', 'btnEditP2', 'btnHandoff', 'btnHandoffBack', 'btnPlay', 'btnRevenge', 'btnPauseMenu', 'btnGameoverMenu',
'btnContinue', 'confirm', 'confirmHead', 'confirmText', 'btnConfirmOk', 'btnConfirmCancel',
'optSize', 'toolPencil', 'toolBrush', 'toolErase1', 'toolErase4',
'budgetLabel', 'optWrap', 'optBudget', 'optTheme', 'optPattern',
'patternMenu',
'btnRotate', 'rotDeg', 'btnTapPlace',
'setup', 'btnStartTwo', 'btnTwoBack',
'levels', 'levelList', 'btnCampaign', 'btnLevelsBack', 'btnNextLevel', 'btnGameoverLevels'].forEach(function (id) {
el[id] = document.getElementById(id);
});
const TOOL_BTN = { pencil: el.toolPencil, brush: el.toolBrush, erase1: el.toolErase1, erase4: el.toolErase4 };
// Кнопки ряда скорости (1×/2×/4×/8×) — общий обработчик на контейнере #speedRow.
const SPEED_BTNS = Array.prototype.slice.call(el.speedRow.querySelectorAll('.speedbtn'));
// Опции дропдауна размера поля строим из FIELD_DIMS (квадраты + прямоугольники),
// value = "WxH" (парсится в обработчике change), подпись — «W×H».
function buildSizeOptions() {
if (!el.optSize) return;
el.optSize.innerHTML = '';
FIELD_DIMS.forEach(function (d) {
const o = document.createElement('option');
o.value = d[0] + 'x' + d[1];
o.textContent = d[0] + '×' + d[1];
el.optSize.appendChild(o);
});
}
buildSizeOptions();
// Опции дропдауна паттернов строим из PATTERNS; первая (value="") — «нет паттерна».
// Остальные сгруппированы в optgroup по PATTERN_GROUPS (порядок групп и записей).
// Выбор хранится в глобале stamp (транзиентно): value="" → null.
function buildPatternOptions() {
if (!el.optPattern) return;
el.optPattern.innerHTML = '';
const none = document.createElement('option');
none.value = '';
none.textContent = 'Паттерн: нет';
el.optPattern.appendChild(none);
PATTERN_GROUPS.forEach(function (g) {
const og = document.createElement('optgroup');
og.label = g.label;
PATTERNS.forEach(function (p) {
if (p.group !== g.key) return;
const o = document.createElement('option');
o.value = p.id;
o.textContent = p.name;
og.appendChild(o);
});
if (og.children.length) el.optPattern.appendChild(og);
});
}
buildPatternOptions();
// Свой список паттернов (замена нативного popup). Нативный popup <select> на длинном
// списке (22 фигуры) в Chromium позиционируется поверх курсора, и обычный клик выбирает
// случайную фигуру — дропдаун «открывается на нажатии и закрывается на отпускании».
// Поэтому нативный popup подавляем (mousedown → preventDefault) и показываем свой
// список #patternMenu. Сам <select> остаётся контролом/контрактом: value/change/
// disabled/active/optgroup и page.select в тестах работают как раньше.
function buildPatternMenu() {
if (!el.patternMenu) return;
el.patternMenu.innerHTML = '';
const none = document.createElement('button');
none.type = 'button';
none.className = 'patitem';
none.setAttribute('data-id', '');
none.textContent = 'Паттерн: нет';
el.patternMenu.appendChild(none);
PATTERN_GROUPS.forEach(function (g) {
const head = document.createElement('div');
head.className = 'patgroup';
head.textContent = g.label;
el.patternMenu.appendChild(head);
PATTERNS.forEach(function (p) {
if (p.group !== g.key) return;
const b = document.createElement('button');
b.type = 'button';
b.className = 'patitem';
b.setAttribute('data-id', p.id);
b.textContent = p.name;
el.patternMenu.appendChild(b);
});
});
}
buildPatternMenu();
// Открыт ли свой список паттернов.
function patternMenuOpen() {
return !!(el.patternMenu && !el.patternMenu.classList.contains('hidden'));
}
// Подсветить в списке активный паттерн (stamp).
function syncPatternMenu() {
if (!el.patternMenu) return;
const cur = stamp ? stamp.id : '';
Array.prototype.forEach.call(el.patternMenu.querySelectorAll('.patitem'), function (b) {
b.classList.toggle('active', b.getAttribute('data-id') === cur);
});
}
// Позиция списка рядом с #optPattern: снизу, а если не влезает — сверху. Высота
// подрезается так, чтобы список НИКОГДА не перекрывал сам селект (иначе mouseup по
// селекту попал бы на фигуру — та же проблема, что у нативного popup).
function positionPatternMenu() {
const m = el.patternMenu, s = el.optPattern;
if (!m || !s) return;
const r = s.getBoundingClientRect();
const gap = 6, pad = 8, cap = 420;
const w = Math.max(180, Math.min(Math.max(r.width, 200), 320));
m.style.width = w + 'px';
m.style.maxHeight = ''; // сброс, чтобы измерить естественную высоту
const want = Math.min(m.scrollHeight || cap, cap);
const below = viewH - pad - (r.bottom + gap);
const above = (r.top - gap) - pad;
let top, avail;
if (below >= want) { top = r.bottom + gap; avail = below; }
else if (above >= want) { top = r.top - gap - want; avail = above; }
else if (below >= above) { top = r.bottom + gap; avail = below; }
else { avail = above; top = r.top - gap - avail; }
m.style.maxHeight = Math.max(120, Math.min(avail, cap)) + 'px';
let left = r.left;
if (left + w > viewW - pad) left = Math.max(pad, viewW - pad - w);
m.style.left = Math.round(left) + 'px';
m.style.top = Math.round(top) + 'px';
}
function closePatternMenu() {
if (el.patternMenu) el.patternMenu.classList.add('hidden');
}
function togglePatternMenu() {
if (!el.patternMenu || !el.optPattern || el.optPattern.disabled) return;
if (patternMenuOpen()) { closePatternMenu(); return; }
syncPatternMenu();
el.patternMenu.classList.remove('hidden');
positionPatternMenu();
}
// Список уровней кампании: кнопки по CAMPAIGN_LEVELS. Заблокированные (индекс >=
// campaignProgress.unlocked) — disabled с замком. Клик → requestCampaignLevel(i).
function buildLevelList() {
if (!el.levelList) return;
el.levelList.innerHTML = '';
CAMPAIGN_LEVELS.forEach(function (lv, i) {
const b = document.createElement('button');
b.className = 'act wide levelbtn';
b.dataset.level = String(i);
b.addEventListener('click', function () { requestCampaignLevel(i); });
el.levelList.appendChild(b);
});
syncLevelList();
}
// Обновить подписи/доступность кнопок уровней (вызывается из syncDom).
function syncLevelList() {
if (!el.levelList) return;
if (state !== 'LEVEL_SELECT') return; // 100 кнопок синхронятся только на экране уровней
const btns = el.levelList.children;
for (let i = 0; i < btns.length; i++) {
const idx = Number(btns[i].dataset.level);
if (!Number.isInteger(idx)) continue;
const locked = idx >= campaignProgress.unlocked;
btns[i].disabled = locked;
btns[i].textContent = (locked ? '🔒 ' : '') + 'Уровень ' + (idx + 1) + ' · ' + CAMPAIGN_LEVELS[idx].name;
}
}
buildLevelList();
function labelFor(s) {
switch (s) {
case 'MENU': return 'Меню';
case 'LEVEL_SELECT': return 'Кампания';
case 'PLACE_P1': return 'Игрок 1 · расстановка';
case 'PLACE_P2': return 'Игрок 2 · расстановка';
case 'EDIT_P1': return 'Игрок 1 · дорисовка';
case 'EDIT_P2': return 'Игрок 2 · дорисовка';
case 'HANDOFF_P1_P2': return 'Передача устройства';
case 'HANDOFF_P2_START': return 'Расстановка завершена';
case 'RUNNING': return 'Симуляция';
case 'PAUSED': return 'Пауза';
case 'GAME_OVER': return 'Итог партии';
}
return '';
}
// Синхронизация HUD/кнопок/оверлеев с состоянием (каждый кадр).
function syncDom() {
const drawPhase = isDrawState(state);
const handoff = isHandoffState(state);
// Акцент текущего игрока (цвет) — только в фазах рисования: им подсвечиваются
// метка фазы и рамка боковой панели (CSS через --accent, см. стили).
document.body.classList.toggle('turn-p1', drawPhase && turn === 1);
document.body.classList.toggle('turn-p2', drawPhase && turn === 2);
// Метка фазы. В кампании — «Уровень N/T» (+ признак стабильности поля на паузе).
let phaseLabel;
if (campaign && levelIndex >= 0) {
phaseLabel = 'Уровень ' + (levelIndex + 1) + '/' + CAMPAIGN_LEVELS.length;
} else {
phaseLabel = labelFor(state);
}
el.turnLabel.textContent = phaseLabel
+ (state === 'PAUSED' && fieldStable ? ' · поле стабильно' : '');
// Счёт: в HANDOFF скрываем («—»). В кампании счёт не нужен вовсе — скрыт.
const s = countByPlayer(field);
el.scoreP1.textContent = handoff ? '—' : s.p1;
el.scoreP2.textContent = handoff ? '—' : s.p2;
if (el.scores) el.scores.classList.toggle('hidden', campaign);
// Бюджет текущего игрока: виден в фазах рисования, а в кампании — ещё и в
// RUNNING/PAUSED (HUD уровня показывает остаток на протяжении всего уровня).
// В HANDOFF/MENU/финале скрыт. При нуле — визуальное предупреждение (красный).
if (el.budgetLabel) {
const showBudget = drawPhase || (campaign && (state === 'RUNNING' || state === 'PAUSED'));
if (showBudget) {
const bp = campaign ? CAMPAIGN_PLAYER : turn; // в кампании бюджет всегда у игрока 1
const rem = remaining(bp);
// Компактный текст (N / total) — плашка не переполняет HUD на узких экранах (M1).
el.budgetLabel.textContent = rem + ' / ' + budget.total;
el.budgetLabel.classList.remove('hidden');
el.budgetLabel.classList.toggle('p1', bp === 1);
el.budgetLabel.classList.toggle('p2', bp === 2);
el.budgetLabel.classList.toggle('zero', rem <= 0);
} else {
el.budgetLabel.classList.add('hidden');
}
}
// Настройки меню: размер поля, чекбокс «Замкнутая карта» и поле бюджета
// (не перебиваем ввод, пока контрол в фокусе).
// Меню-контролы отражают ПОЛЬЗОВАТЕЛЬСКИЕ настройки (menu*), а не активную игру:
// в кампании активный размер/wrap/бюджет — уровня, но меню должно показывать своё (D1).
if (el.optSize && document.activeElement !== el.optSize) el.optSize.value = menuFieldW + 'x' + menuFieldH;
if (el.optWrap) el.optWrap.checked = menuWrap;
if (el.optTheme) el.optTheme.value = theme;
if (el.optBudget && document.activeElement !== el.optBudget) el.optBudget.value = menuBudget;
// Оверлеи. Пауза — БЕЗ оверлея: её кнопки инлайн в нижней панели/боковой зоне.
el.menu.classList.toggle('hidden', state !== 'MENU');
el.handoff.classList.toggle('hidden', !handoff);
// Передаём вслепую игроку 2 — панель подсвечена его цветом.
el.handoff.classList.toggle('to-p2', state === 'HANDOFF_P1_P2');
el.gameover.classList.toggle('hidden', state !== 'GAME_OVER');
// Финал: рамка панели цветом победителя.
el.gameover.classList.toggle('win-p1', state === 'GAME_OVER' && winner === '1');
el.gameover.classList.toggle('win-p2', state === 'GAME_OVER' && winner === '2');
// Экран выбора уровня кампании.
if (el.levels) el.levels.classList.toggle('hidden', state !== 'LEVEL_SELECT');
// Экран «Игра на двоих»: своя справка и настройки поля (только обычная партия).
if (el.setup) el.setup.classList.toggle('hidden', state !== 'TWO_SETUP');
if (el.levelList) syncLevelList();
// Диалог подтверждения: текст и кнопка зависят от действия.
el.confirm.classList.toggle('hidden', !confirmAction);
if (confirmAction === 'campaign') {
el.confirmHead.textContent = 'Начать уровень?';
el.confirmText.textContent = 'Текущая партия будет потеряна.';
el.btnConfirmOk.textContent = 'Начать';
} else if (confirmAction === 'restart') {
el.confirmHead.textContent = 'Начать уровень заново?';
el.confirmText.textContent = 'Расстановка и бюджет уровня сбросятся.';
el.btnConfirmOk.textContent = 'Заново';
} else if (confirmAction === 'end') {
el.confirmHead.textContent = 'Завершить партию?';
el.confirmText.textContent = 'Партия завершится досрочно — победитель определится по текущим клеткам.';
el.btnConfirmOk.textContent = 'Завершить';
} else {
el.confirmHead.textContent = 'Начать новую партию?';
el.confirmText.textContent = 'Незавершённая партия будет потеряна.';
el.btnConfirmOk.textContent = 'Начать новую';
}
if (handoff) {
const startGate = state === 'HANDOFF_P2_START';
el.handoffText.textContent = startGate ? 'Расстановка завершена' : 'Передайте устройство игроку 2';
el.handoffNote.classList.toggle('hidden', startGate);
el.btnHandoff.textContent = startGate ? 'Начать симуляцию' : 'Передать';
el.btnHandoffBack.classList.toggle('hidden', !startGate); // возврат к P1 — после расстановки P2
}
// «Продолжить партию» — только в меню и только если есть отложенная партия.
if (el.btnContinue) el.btnContinue.classList.toggle('hidden', !(state === 'MENU' && suspended));
// Нижние панели. Действия видны в рисовании, симуляции и на паузе (пауза инлайн).
const paused = state === 'PAUSED';
const canEdit = paused && fieldStable; // дорисовка — только когда поле устойчиво
el.toolbar.classList.toggle('hidden', !drawPhase);
el.actions.classList.toggle('hidden', !(drawPhase || state === 'RUNNING' || paused));
el.btnReady.classList.toggle('hidden', !drawPhase);
el.btnReset.classList.toggle('hidden', !drawPhase);
// «Заново» (перезапуск уровня) — только в кампании и только когда идёт уровень.
if (el.btnRestart) el.btnRestart.classList.toggle('hidden', !(campaign && levelIndex >= 0));
el.btnPause.classList.toggle('hidden', state !== 'RUNNING');
// «Завершить партию» в кампании не нужна (исход определяют победа/поражение).
el.btnEnd.classList.toggle('hidden', state !== 'RUNNING' || campaign);
// Скорость: ряд кнопок 1×/2×/4×/8×, активна текущая; виден только во время симуляции.
el.speedRow.classList.toggle('hidden', state !== 'RUNNING');
SPEED_BTNS.forEach(function (b) { b.classList.toggle('active', Number(b.dataset.speed) === speedMult); });
// На паузе — отдельные кнопки дорисовки каждого игрока и запуск симуляции.
// Дорисовка доступна ТОЛЬКО когда поле стабильно (canEdit).
el.btnResume.classList.toggle('hidden', !paused);
el.btnEditP1.classList.toggle('hidden', !canEdit);
el.btnEditP2.classList.toggle('hidden', !canEdit || campaign); // в кампании игрок один
el.btnEndPause.classList.toggle('hidden', !paused || campaign);
el.btnPauseMenu.classList.toggle('hidden', !paused);
// Финал кампании: «Следующий уровень» (только при победе и наличии следующего)
// и «К уровням»; «Реванш» превращается в «Заново».
if (el.btnNextLevel) el.btnNextLevel.classList.toggle('hidden',
!(state === 'GAME_OVER' && campaign && reason === 'win' && levelIndex + 1 < CAMPAIGN_LEVELS.length));
if (el.btnGameoverLevels) el.btnGameoverLevels.classList.toggle('hidden',
!(state === 'GAME_OVER' && campaign));
if (el.btnRevenge) el.btnRevenge.textContent = (campaign && state === 'GAME_OVER') ? 'Заново' : 'Реванш';
// Инструменты: активная подсветка + доступность только в фазах рисования.
// Пока выбран паттерн (stamp) — ни одна кнопка инструмента не подсвечена: штамп
// перекрывает активный инструмент (кроме ПКМ-ластика, который действует всегда).
TOOLS.forEach(function (t) {
const b = TOOL_BTN[t.id];
if (!b) return;
b.classList.toggle('active', tool === t && !stamp);
b.disabled = !drawPhase;
});
// Дропдаун паттернов: доступен только в фазах рисования; значение не перебиваем,
// пока контрол в фокусе (как у размера поля). Активен (подсвечен) при выбранном штампе.
if (el.optPattern) {
if (document.activeElement !== el.optPattern) el.optPattern.value = stamp ? stamp.id : '';
el.optPattern.disabled = !drawPhase;
el.optPattern.classList.toggle('active', !!stamp);
// Свой список: вне фаз рисования закрываем, иначе держим подсветку активной фигуры.
if (patternMenuOpen()) {
if (!drawPhase) closePatternMenu();
else syncPatternMenu();
}
}
// Кнопка поворота паттерна: видна только в фазах рисования и при выбранном штампе.
// Подпись показывает текущий угол — полезно, когда палец закрывает превью.
if (el.btnRotate) {
el.btnRotate.classList.toggle('hidden', !(drawPhase && stamp));
if (el.rotDeg) el.rotDeg.textContent = (stampRot * 90) + '°';
}
// Режим «постановка по отпусканию»: тумблер (подсвечен, когда включён).
if (el.btnTapPlace) {
el.btnTapPlace.classList.toggle('active', tapPlace);
el.btnTapPlace.classList.toggle('hidden', !drawPhase);
}
// Итог партии.
if (state === 'GAME_OVER') {
if (campaign) {
if (reason === 'win') {
el.gameoverText.textContent = 'Победа!';
el.gameoverSub.textContent = 'Уровень ' + (levelIndex + 1) + ' пройден';
} else if (reason === 'manual') {
// Ручное завершение уровня (endGame/requestEndGame): это не поражение (D3).
el.gameoverText.textContent = 'Уровень прерван';
el.gameoverSub.textContent = 'Уровень ' + (levelIndex + 1) + ' не завершён';
} else {
el.gameoverText.textContent = 'Поражение';
el.gameoverSub.textContent = 'Осталось клеток врага: ' + s.p2 + ' · бюджет исчерпан';
}
} else {
const pre = winner === '1' ? 'Игрок 1 победил ' : winner === '2' ? 'Игрок 2 победил ' : 'Ничья ';
el.gameoverText.textContent = pre + s.p1 + ':' + s.p2;
el.gameoverSub.textContent = reason === 'manual'
? 'Партия завершена вручную'
: reason === 'stalemate'
? (winner === 'draw' ? 'У обоих игроков закончился бюджет' : 'У отстающего игрока закончился бюджет')
: 'У одного из игроков не осталось клеток';
}
}
}
// Переключение активного инструмента по id.
function selectTool(id) {
const t = findTool(id);
if (t) tool = t;
stamp = null; // выбор инструмента сбрасывает активный паттерн («последнее действие побеждает»)
stampRot = 0; // вместе с паттерном сбрасываем и его поворот
wheelAccum = 0; // и накопленную дельту колеса
syncDom();
persist();
}
// Смена размера поля на произвольный W×H (квадратный или прямоугольный) — сброс доски.
function setFieldDim(w, h) {
if (!isValidSize(w) || !isValidSize(h)) return;
if (campaign) return; // в кампании доска уровня: смена размера потеряла бы врага (D4)
fieldW = w; fieldH = h;
// Пользовательская настройка (из меню) — переживёт кампанию и reload.
menuFieldW = w; menuFieldH = h;
field = new Uint8Array(w * h);
next = new Uint8Array(w * h);
turnStartField = null;
rebuildNeighborTables();
resetStability(); // новое поле — устойчивость не оценивалась
renderDirty = true; // сменился размер поля/offscreen
computeBoard();
syncDom();
persist();
}
// Смена квадратного размера поля из меню (значение — только из FIELD_SIZES).
function setFieldSize(n) {
if (FIELD_SIZES.indexOf(n) < 0) return; // чужие значения игнорируем (квадрат только из FIELD_SIZES)
setFieldDim(n, n);
}
// Отложить текущую (резюмируемую) партию в меню, чтобы «Продолжить партию»
// вернуло ровно в неё. Снимок хранит размер/wrap/поле/бюджет: настройки в меню
// могли измениться, а прерванная партия обязана восстановиться целиком.
function suspendGame() {
if (!isResumable(state) || !field || field.length !== fieldW * fieldH) return;
suspended = {
state: state,
turn: turn,
fieldW: fieldW,
fieldH: fieldH,
wrap: wrap,
field: field.slice(),
turnStartField: turnStartField ? turnStartField.slice() : null,
stable: fieldStable,
budget: {
total: budget.total, // лимит тоже храним: отложенная игра возвращается с ним же (D2)
used1: budget.used1, used2: budget.used2,
start1: turnStartBudget.used1, start2: turnStartBudget.used2
},
campaign: campaign,
levelIndex: (campaign && levelIndex >= 0) ? levelIndex : null
};
}
// Вернуться из меню к отложенной партии (восстанавливает размер/wrap/поле/бюджет).
function resumeFromMenu() {
if (!suspended) return;
const s = suspended;
suspended = null;
confirmAction = null;
confirmResume = false;
fieldW = s.fieldW;
fieldH = s.fieldH;
wrap = s.wrap;
rebuildNeighborTables();
field = s.field;
next = new Uint8Array(fieldW * fieldH);
turnStartField = s.turnStartField;
// Кампания: восстановить режим/уровень/зону-прямоугольник (если откладывали уровень).
campaign = !!(s.campaign && Number.isInteger(s.levelIndex) && CAMPAIGN_LEVELS[s.levelIndex]);
levelIndex = campaign ? s.levelIndex : -1;
zoneRect = campaign ? campaignZoneFor(levelIndex) : null;
// Лимит бюджета — из снимка отложенной игры (смена бюджета в меню её не трогает, D2).
budget.total = (typeof s.budget.total === 'number' && s.budget.total >= 0) ? s.budget.total : menuBudget;
budget.used1 = s.budget.used1;
budget.used2 = s.budget.used2;
turnStartBudget = { used1: s.budget.start1, used2: s.budget.start2 };
state = s.state;
turn = s.turn;
mode = modeForState(state);
if (state === 'RUNNING') seedStability();
else { recentHashes.length = 0; fieldStable = !!s.stable; }
renderDirty = true;
simAcc = 0;
last = performance.now();
lastPersistAt = performance.now();
computeBoard();
syncDom();
persist();
}
// «Заново» в кампании: перезапуск уровня — с подтверждением (нельзя терять прогресс
// уровня одним кликом). На время диалога симуляция ставится на паузу; «Отмена» её возвращает.
function requestRestartLevel() {
if (!campaign || levelIndex < 0) return;
confirmResume = (state === 'RUNNING');
if (state === 'RUNNING') pauseGame();
confirmAction = 'restart';
syncDom();
}
// «Играть»/«Реванш»: если есть отложенная партия — сначала спросить подтверждение.
function requestNewGame() {
// Кампания: «Реванш»/«Заново» просто перезапускает текущий уровень.
if (campaign && levelIndex >= 0) { startLevel(levelIndex); return; }
if (suspended) { confirmAction = 'new'; confirmResume = false; syncDom(); return; }
newGame();
}
// Нажатие кнопки в диалоге подтверждения — выполняем отложенное действие.
function confirmProceed() {
const act = confirmAction;
confirmAction = null;
confirmResume = false;
if (act === 'new') { suspended = null; newGame(); }
else if (act === 'end') { finish('manual'); }
else if (act === 'restart') {
if (campaign && levelIndex >= 0) startLevel(levelIndex);
else syncDom();
}
else if (act === 'campaign') {
const idx = pendingLevel;
pendingLevel = null;
suspended = null;
if (idx != null) startLevel(idx);
else syncDom();
} else syncDom();
}
// «Отмена»: возвращаем симуляцию, если она шла до открытия подтверждения завершения.
function cancelConfirm() {
const wasRunning = confirmResume;
confirmAction = null;
confirmResume = false;
pendingLevel = null;
if (wasRunning && state === 'PAUSED') resumeGame();
else syncDom();
}
// В меню (настройки). Прерванную партию не теряем — откладываем её в suspended.
function toMenu() {
suspendGame();
state = 'MENU';
mode = 'menu';
confirmAction = null;
confirmResume = false;
// Уровень кампании отложен в suspended целиком: активную кампанию в глобалах
// сбрасываем (вернётся через resumeFromMenu). Прогресс кампании сохраняется.
campaign = false;
levelIndex = -1;
zoneRect = null;
pendingLevel = null;
applyMenuSettings(); // активная игра возвращается к пользовательским настройкам (D1)
renderDirty = true; // маска MENU ([1,2]) отличается от боевых фаз
syncDom();
persist();
}
// ==== КНОПКИ ====
el.btnReady.addEventListener('click', commitTurn);
el.btnReset.addEventListener('click', resetGame);
el.btnPause.addEventListener('click', pauseGame);
el.speedRow.addEventListener('click', function (e) {
const b = e.target.closest('.speedbtn');
if (b) setSpeed(Number(b.dataset.speed));
});
el.btnEnd.addEventListener('click', requestEndGame);
el.btnEndPause.addEventListener('click', requestEndGame);
el.btnResume.addEventListener('click', resumeGame);
el.btnEditP1.addEventListener('click', function () { startEdit(1); });
el.btnEditP2.addEventListener('click', function () { startEdit(2); });
el.btnHandoff.addEventListener('click', handoffNext);
el.btnHandoffBack.addEventListener('click', handoffBack);
el.btnPlay.addEventListener('click', showTwoSetup);
el.btnRevenge.addEventListener('click', requestNewGame);
// Кампания: «Заново» перезапускает текущий уровень с начала (с подтверждением).
if (el.btnRestart) el.btnRestart.addEventListener('click', requestRestartLevel);
// Экран «Игра на двоих»: «Начать» = новая партия (с подтверждением, если есть отложенная),
// «В меню» = возврат на сплэш.
if (el.btnStartTwo) el.btnStartTwo.addEventListener('click', requestNewGame);
if (el.btnTwoBack) el.btnTwoBack.addEventListener('click', toMenu);
el.btnPauseMenu.addEventListener('click', toMenu);
el.btnGameoverMenu.addEventListener('click', toMenu);
if (el.btnContinue) el.btnContinue.addEventListener('click', resumeFromMenu);
// Кампания: вход/выход из выбора уровня, следующий уровень, «К уровням».
if (el.btnCampaign) el.btnCampaign.addEventListener('click', showLevelSelect);
if (el.btnLevelsBack) el.btnLevelsBack.addEventListener('click', toMenu);
if (el.btnNextLevel) el.btnNextLevel.addEventListener('click', function () { startLevel(levelIndex + 1); });
if (el.btnGameoverLevels) el.btnGameoverLevels.addEventListener('click', showLevelSelect);
if (el.btnConfirmOk) el.btnConfirmOk.addEventListener('click', confirmProceed);
if (el.btnConfirmCancel) el.btnConfirmCancel.addEventListener('click', cancelConfirm);
document.getElementById('btnSettings').addEventListener('click', toMenu);
Object.keys(TOOL_BTN).forEach(function (id) {
if (TOOL_BTN[id]) TOOL_BTN[id].addEventListener('click', function () { selectTool(id); });
});
if (el.optSize) {
el.optSize.addEventListener('change', function () {
const p = String(el.optSize.value).split('x'); // value = "WxH"
const w = parseInt(p[0], 10), h = parseInt(p[1], 10);
if (Number.isFinite(w) && Number.isFinite(h)) setFieldDim(w, h);
});
}
// Опция «Замкнутая карта (torus)».
if (el.optWrap) {
el.optWrap.addEventListener('change', function () { setWrap(el.optWrap.checked); });
}
// Выбор готового паттерна (штамп). Транзиентно (как speedMult) — в сейв не пишем;
// пустое значение → «нет паттерна» (stamp = null).
if (el.optPattern) {
el.optPattern.addEventListener('change', function () {
stamp = findPattern(el.optPattern.value) || null;
stampRot = 0; // новый паттерн — стартуем без поворота
wheelAccum = 0; // и без накопленной дельты колеса
syncDom();
});
// Подавляем нативный popup и открываем свой список (см. buildPatternMenu).
el.optPattern.addEventListener('mousedown', function (e) {
e.preventDefault();
togglePatternMenu();
});
}
// Выбор фигуры в своём списке: пишем значение в <select> и шлём change — единый путь
// установки stamp (см. обработчик change выше).
if (el.patternMenu) {
el.patternMenu.addEventListener('click', function (e) {
const b = e.target && e.target.closest ? e.target.closest('.patitem') : null;
if (!b) return;
if (el.optPattern) {
el.optPattern.value = b.getAttribute('data-id');
el.optPattern.dispatchEvent(new Event('change'));
}
closePatternMenu();
});
}
// Закрытие своего списка: клик вне, Escape, resize/scroll.
window.addEventListener('pointerdown', function (e) {
if (!patternMenuOpen()) return;
if (el.patternMenu.contains(e.target) || e.target === el.optPattern) return;
closePatternMenu();
}, true);
window.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && patternMenuOpen()) closePatternMenu();
});
window.addEventListener('resize', closePatternMenu);
// Прокрутку самого списка (#patternMenu) не считаем «уходом» — иначе колесо,
// которым листают фигуры, тут же закрывало бы список. Закрываем только при
// прокрутке внешних контейнеров (страница/#ui в боковой раскладке).
window.addEventListener('scroll', function (e) {
if (el.patternMenu && e.target && el.patternMenu.contains(e.target)) return;
closePatternMenu();
}, true);
// Поворот активного паттерна кнопкой «↻» (+90° за нажатие) — доступно и на тач-экранах,
// где нет колеса мыши. Колесо над доской по-прежнему работает (см. обработчик wheel).
if (el.btnRotate) {
el.btnRotate.addEventListener('click', function () {
if (!stamp) return;
stampRot = (stampRot + 1) % 4;
wheelAccum = 0;
syncDom();
});
}
// Тумблер режима «постановка по отпусканию» (тап). Транзиентно (в сейв не пишем).
if (el.btnTapPlace) {
el.btnTapPlace.addEventListener('click', function () {
tapPlace = !tapPlace;
pressing = false; // смена режима отменяет незавершённое удержание
syncDom();
});
}
// Тема оформления: селект в меню → applyTheme + сохранение.
if (el.optTheme) {
el.optTheme.addEventListener('change', function () { setTheme(el.optTheme.value); });
}
// Ввод бюджета: по change/blur применяем целое ≥1, чужое — игнорируем; поле нормализуем.
// Комбобокс: подсказки-пресеты (datalist #budgetOptions) не заменяют ввод произвольного числа.
if (el.optBudget) {
const applyBudget = function () {
const v = parseInt(el.optBudget.value, 10);
if (Number.isFinite(v) && v >= 1) setBudgetTotal(v);
el.optBudget.value = menuBudget;
syncDom();
};
el.optBudget.addEventListener('change', applyBudget);
el.optBudget.addEventListener('blur', applyBudget);
}
// ==== ТЕСТ-ФОРСЫ (часть архитектуры, выставляются на window) ====
// Форсировать состояние с корректной установкой производных (turn/mode/снимок).
function forceState(name) {
if (name === 'PLACE_P1' || name === 'EDIT_P1') { beginDraw(name, 1); }
else if (name === 'PLACE_P2' || name === 'EDIT_P2') { beginDraw(name, 2); }
else {
state = name;
mode = modeForState(name);
}
// Тест-форс устойчивости: на паузе/в дорисовке считаем поле устойчивым (дорисовка
// разрешена — тесты переходов не гоняются через реальную симуляцию); в RUNNING
// засеваем окно; в остальных фазах устойчивость не оценивалась.
if (name === 'RUNNING') seedStability();
else if (name === 'PAUSED' || name === 'EDIT_P1' || name === 'EDIT_P2') fieldStable = true;
else resetStability();
renderDirty = true; // смена маски/фазы
syncDom();
return state;
}
// Построить поле из ASCII-строк ('.'/'0' = мёртвая, '1'/'2'/'3' = цвета).
function setFieldASCII(str, w) {
let rows;
if (Array.isArray(str)) rows = str.slice();
else {
rows = String(str).split('\n');
if (rows.length && rows[rows.length - 1] === '') rows.pop();
}
const h = rows.length;
fieldW = w || (rows[0] ? rows[0].length : DEFAULT_FIELD);
fieldH = h;
field = new Uint8Array(fieldW * fieldH);
next = new Uint8Array(fieldW * fieldH);
for (let y = 0; y < h; y++) {
const line = String(rows[y]);
for (let x = 0; x < fieldW; x++) {
const ch = line[x] || '.';
const v = ch === '1' ? 1 : ch === '2' ? 2 : ch === '3' ? 3 : 0;
field[y * fieldW + x] = v;
}
}
turnStartField = field.slice();
// Согласованность снимков: бюджет старта = текущее «потрачено» (тест-форс).
turnStartBudget = { used1: budget.used1, used2: budget.used2 };
rebuildNeighborTables();
resetStability(); // новое поле — устойчивость не оценивалась
renderDirty = true; // новое поле/размер — пересобрать слой клеток
computeBoard();
syncDom();
return field;
}
// ==== СТАРТ ====
window.addEventListener('resize', resize);
window.addEventListener('contextmenu', function (e) { e.preventDefault(); }); // без меню на правом клике/лонгтапе
document.addEventListener('visibilitychange', function () {
// Авто-пауза при уходе со вкладки. Сейв при этом НЕ трогаем: последнее
// сохранённое состояние (например, RUNNING) должно пережить возврат/reload,
// иначе скрытие вкладки на миг превращало бы незавершённую партию в PAUSED.
if (document.hidden && state === 'RUNNING') { state = 'PAUSED'; mode = modeForState(state); syncDom(); }
});
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
canvas.addEventListener('pointerleave', function () { hoverCell = null; });
window.addEventListener('pointerup', onPointerUp);
// Колесо мыши над доской поворачивает активный паттерн на 90° за «щелчок» (stampRot 0..3,
// по часовой стрелке при прокрутке вниз). Действует только в фазах рисования и при выбранном
// штампе; вне этого — инертно (страница всё равно не скроллится: overflow:hidden).
// deltaY накапливается до порога WHEEL_ROT_STEP: мышь (один «щелчок» ≈ 100px) даёт один
// поворот, а тачпад с мелкими частыми дельтами — контролируемо, а не «крутится бешено».
// deltaMode нормализуем к пикселям (1 = строки, 2 = страницы). passive:false — чтобы
// preventDefault гасил инерционную прокрутку/зум.
const WHEEL_ROT_STEP = 100;
var wheelAccum = 0;
canvas.addEventListener('wheel', function (e) {
if (!isDrawState(state) || !stamp || !e.deltaY) { wheelAccum = 0; return; }
e.preventDefault();
const d = e.deltaY * (e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 400 : 1);
wheelAccum += d;
if (Math.abs(wheelAccum) < WHEEL_ROT_STEP) return;
stampRot = (stampRot + (wheelAccum > 0 ? 1 : 3)) % 4;
wheelAccum = 0;
syncDom();
}, { passive: false });
// ==== КЛАВИАТУРА ====
// Пробел ставит/снимает паузу (RUNNING↔PAUSED), как у медиаплеера. В остальных
// фазах (меню, расстановка, передача, финал) и при открытом диалоге подтверждения
// ничего не делаем. Фокус в интерактивном элементе (поле ввода/кнопка) не трогаем —
// пробел там имеет своё назначение (ввод, клик по кнопке).
window.addEventListener('keydown', function (e) {
if (e.repeat || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.code !== 'Space' && e.key !== ' ' && e.key !== 'Spacebar') return;
const tag = e.target && e.target.tagName ? e.target.tagName.toLowerCase() : '';
if (tag === 'input' || tag === 'select' || tag === 'textarea' || tag === 'button' || tag === 'a') return;
if (confirmAction) return; // диалог подтверждения — не мешаем паузе/возврату
if (state === 'RUNNING') { e.preventDefault(); pauseGame(); }
else if (state === 'PAUSED') { e.preventDefault(); resumeGame(); }
});
// Константы в window (let/const не становятся свойствами window сами по себе).
window.SAVE_KEY = SAVE_KEY;
window.COLORS = COLORS;
window.TOOLS = TOOLS;
window.PATTERNS = PATTERNS;
window.FIELD_SIZES = FIELD_SIZES;
window.FIELD_DIMS = FIELD_DIMS;
window.STEPS_PER_SEC = STEPS_PER_SEC;
window.DEFAULT_BUDGET = DEFAULT_BUDGET;
window.CAMPAIGN_LEVELS = CAMPAIGN_LEVELS;
window.CAMPAIGN_PLAYER = CAMPAIGN_PLAYER;
window.CAMPAIGN_ENEMY = CAMPAIGN_ENEMY;
window.canvas = canvas;
window.ctx = ctx;
// Резюм прошлой партии из localStorage (или чистый старт в MENU).
// Настройки (размер/инструмент/скорость/wrap/бюджет) применяются к новой партии.
// applySave() сам пересобирает таблицы соседей под текущий размер/wrap.
applySave(loadSave());
resize();
syncDom();
draw();
requestAnimationFrame(frame);
</script>
</body>
</html>