Files
zangramru/zangramru/static/index.html
T
shared_mutex 129a1ea5f7
Testing zangramru / lint (push) Successful in 33s
Testing zangramru / pytest (push) Has been cancelled
Increased contrast of letter connections for readability.
Bumped the board connection line color in both themes so the links
between letters read more clearly:
- light: #e2dbc7 -> #c7bb95
- dark:  #33333f -> #52525f

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:55:32 +03:00

1089 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Занграммы</title>
<meta property="og:title" content="Занграммы">
<meta property="og:description" content="Игра со словами — находи слова на сетке">
<meta property="og:image" content="/preview.png">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Занграммы">
<meta name="twitter:description" content="Игра со словами — находи слова на сетке">
<meta name="twitter:image" content="/preview.png">
<script>
// Set theme before first paint to avoid a flash of the wrong theme.
(() => {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', saved || (prefersDark ? 'dark' : 'light'));
})();
</script>
<style>
:root {
--bg: #faf4e6;
--grid: #efe6cf;
--ink: #14110c;
--card: #ffffff;
--border: #14110c;
--shadow: #14110c;
--accent: #ffd400;
--accent2: #00e0c6;
--sel: #ff3d81;
--found: #12b886;
--bonus: #ff7a00;
--muted: #6f6650;
--line: #e2dbc7;
}
[data-theme="dark"] {
--bg: #0e0e13;
--grid: #17171f;
--ink: #f4efe3;
--card: #1b1b23;
--border: #f4efe3;
--shadow: #000000;
--accent: #ffd400;
--accent2: #00e0c6;
--sel: #ff479a;
--found: #24d38a;
--bonus: #ffa733;
--muted: #9a927f;
--line: #2b2b36;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
background-color: var(--bg);
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 26px 26px;
color: var(--ink);
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
padding: 1.5rem 1rem 3rem;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* ---------- Brutalist primitives ---------- */
.brut {
border: 3px solid var(--border);
box-shadow: 6px 6px 0 var(--shadow);
background: var(--card);
}
/* ---------- Header ---------- */
header {
width: 100%;
max-width: 460px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
h1 {
font-size: clamp(1.6rem, 7vw, 2.3rem);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.02em;
line-height: 1;
background: var(--accent);
color: #14110c;
padding: 0.35rem 0.6rem;
border: 3px solid var(--border);
box-shadow: 5px 5px 0 var(--shadow);
transform: rotate(-2deg);
}
#themeBtn {
font-size: 1.3rem;
line-height: 1;
width: 3rem;
height: 3rem;
flex-shrink: 0;
display: grid;
place-items: center;
}
/* ---------- Stat chips ---------- */
.stats {
width: 100%;
max-width: 460px;
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.chip {
flex: 1 1 auto;
min-width: 0;
text-align: center;
padding: 0.45rem 0.4rem;
border: 3px solid var(--border);
background: var(--card);
box-shadow: 4px 4px 0 var(--shadow);
font-weight: 700;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip small {
display: block;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 0.15rem;
}
.chip b { font-size: 1.15rem; }
/* ---------- Buttons ---------- */
button {
font-family: inherit;
padding: 0.55rem 1rem;
border: 3px solid var(--border);
background: var(--card);
color: var(--ink);
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
cursor: pointer;
box-shadow: 4px 4px 0 var(--shadow);
transition: transform 0.06s ease, box-shadow 0.06s ease, background 0.15s;
}
button:hover { background: var(--accent); color: #14110c; }
button:active { transform: translate(4px, 4px); box-shadow: 0 0 0 var(--shadow); }
button.primary { background: var(--accent); color: #14110c; }
button.pink { background: var(--sel); color: #14110c; }
button.cyan { background: var(--accent2); color: #14110c; }
/* ---------- Word display ---------- */
#wordDisplay {
min-width: 3rem;
min-height: 3.4rem;
padding: 0.4rem 1rem;
margin-bottom: 0.6rem;
font-size: 2rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.28em;
text-indent: 0.28em;
color: var(--ink);
background: var(--card);
border: 3px solid var(--border);
box-shadow: 5px 5px 0 var(--shadow);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.06s, box-shadow 0.06s;
}
#wordDisplay:active { transform: translate(3px, 3px); box-shadow: 2px 2px 0 var(--shadow); }
#wordDisplay:empty::before {
content: '········';
color: var(--line);
letter-spacing: 0.28em;
}
#message {
min-height: 1.6rem;
margin-bottom: 0.6rem;
font-size: 0.95rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent);
text-align: center;
}
/* ---------- Board ---------- */
.board-wrap {
padding: 14px;
border: 4px solid var(--border);
box-shadow: 8px 8px 0 var(--shadow);
background: var(--card);
width: -moz-fit-content;
width: fit-content;
max-width: 100%;
}
#board {
display: block;
touch-action: none;
max-width: 100%;
height: auto;
}
.hint {
margin: 0.9rem 0 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
text-align: center;
max-width: 420px;
}
/* ---------- Found list ---------- */
#found { margin-top: 1.6rem; max-width: 460px; width: 100%; }
#found h2 {
margin-bottom: 0.7rem;
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-block;
background: var(--accent2);
color: #14110c;
padding: 0.25rem 0.6rem;
border: 3px solid var(--border);
box-shadow: 4px 4px 0 var(--shadow);
}
.found-grid { display: flex; flex-direction: column; gap: 0.55rem; margin-top: 0.7rem; }
.word-item {
background: var(--card);
border: 3px solid var(--border);
box-shadow: 4px 4px 0 var(--shadow);
padding: 0.6rem 0.8rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.word-item.bonus { border-style: dashed; }
.word-item-inner { display: flex; align-items: center; gap: 0.6rem; }
.word-color { width: 16px; height: 16px; border: 2px solid var(--border); flex-shrink: 0; }
.word-text { font-weight: 800; font-size: 1.05rem; text-transform: uppercase; letter-spacing: 0.04em; }
.word-tag {
margin-left: auto;
font-size: 0.6rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--bonus);
color: #14110c;
padding: 0.1rem 0.35rem;
border: 2px solid var(--border);
}
.word-def { color: var(--muted); font-size: 0.85rem; padding-left: 1.7rem; }
/* ---------- Modals ---------- */
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
z-index: 100;
justify-content: center;
align-items: center;
padding: 1rem;
}
.modal.show { display: flex; }
.modal .popup {
background: var(--card);
color: var(--ink);
border: 4px solid var(--border);
box-shadow: 10px 10px 0 var(--shadow);
padding: 1.8rem 2rem;
text-align: center;
max-width: 420px;
width: 100%;
}
.modal .popup h2 {
font-size: 1.25rem;
text-transform: uppercase;
letter-spacing: 0.02em;
margin-bottom: 0.3rem;
}
.modal .popup .time {
color: #14110c;
background: var(--found);
display: inline-block;
font-size: 2.2rem;
font-weight: 800;
padding: 0.3rem 1rem;
margin: 1rem 0;
border: 3px solid var(--border);
box-shadow: 5px 5px 0 var(--shadow);
}
.modal .popup .buttons { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 1.3rem; }
.modal .popup .buttons button { width: 100%; }
#numberSelector .popup { padding: 1.4rem 1.5rem; max-height: 78vh; overflow-y: auto; }
#numberSelector .popup h3 { text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 1rem; }
#numberSelector .number-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; }
#numberSelector .number-grid button { padding: 0.55rem 0.2rem; font-size: 0.85rem; box-shadow: 3px 3px 0 var(--shadow); }
#numberSelector .number-grid button.solved { background: var(--found); color: #14110c; }
#numberSelector .number-grid button.current { background: var(--accent); color: #14110c; }
#numberSelector .popup .close-btn { margin-top: 1rem; width: 100%; }
/* ---------- Confetti overlay ---------- */
#confetti {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 200;
}
</style>
</head>
<body>
<header>
<h1>Занграммы</h1>
<button id="themeBtn" class="brut" title="Сменить тему" aria-label="Сменить тему"></button>
</header>
<div class="stats">
<div class="chip"><small>Занграмма</small><b id="statNumber"></b></div>
<div class="chip"><small>Время</small><b id="statTime">00:00</b></div>
<div class="chip"><small>Осталось</small><b id="statLeft"></b></div>
</div>
<div id="wordDisplay"></div>
<div id="message"></div>
<div class="board-wrap">
<canvas id="board"></canvas>
</div>
<p class="hint">Веди пальцем/мышью, кликай или печатай на клавиатуре. Enter — проверить · Backspace — стереть · Esc — сбросить.</p>
<div id="found"></div>
<!-- Game complete modal -->
<div id="gameComplete" class="modal">
<div class="popup">
<h2>Все слова найдены!</h2>
<div class="time" id="gameTime"></div>
<div class="buttons">
<button id="copyResultBtn" class="primary">Скопировать результат</button>
<button id="prevPuzzlesBtn" class="cyan">Предыдущие занграммы</button>
<button id="randomPuzzleBtn" class="pink">Случайная занграмма</button>
</div>
</div>
</div>
<!-- Number selector modal -->
<div id="numberSelector" class="modal">
<div class="popup">
<h3>Выберите занграмму</h3>
<div class="number-grid" id="numberGrid"></div>
<button class="close-btn" id="closeNumberBtn">Закрыть</button>
</div>
</div>
<canvas id="confetti"></canvas>
<script>
(() => {
// ===================== DOM =====================
const $ = id => document.getElementById(id);
const canvas = $('board');
const ctx = canvas.getContext('2d');
const wordDisplay = $('wordDisplay');
const msgEl = $('message');
const foundDiv = $('found');
const gameCompleteEl = $('gameComplete');
const gameTimeEl = $('gameTime');
const numberSelectorEl = $('numberSelector');
const numberGridEl = $('numberGrid');
const themeBtn = $('themeBtn');
const statNumber = $('statNumber');
const statTime = $('statTime');
const statLeft = $('statLeft');
const confettiCanvas = $('confetti');
const cctx = confettiCanvas.getContext('2d');
// ===================== Constants =====================
const CELL = 70, GAP = 20, GRID = 4, PITCH = CELL + GAP, SH = 5;
const ENTRANCE_DUR = 360, ENTRANCE_STAGGER = 42, PULSE_DUR = 520;
const PALETTE = [
'#ff3d81','#ffd400','#00e0c6','#7c5cff','#54a0ff',
'#12b886','#ff7a00','#ff479a','#b6ff00','#00b8d4',
'#f368e0','#ffa502','#2ed573','#1e90ff','#ff4757',
'#eccc68','#a4b0be','#48dbfb','#badc58','#5f27cd'
];
const CONFETTI_COLORS = ['#ffd400','#00e0c6','#ff3d81','#b6ff00','#7c5cff','#ff7a00'];
const THEMES = {
light: {
tile: '#ffffff', border: '#14110c', shadow: '#14110c',
ink: '#14110c', inkOn: '#14110c',
line: '#c7bb95', sel: '#14110c', found: '#12b886', bonus: '#ff7a00', selTile: '#ff3d81',
msgDefault: '#c98a00', muted: '#6f6650',
},
dark: {
tile: '#262633', border: '#f4efe3', shadow: '#000000',
ink: '#f4efe3', inkOn: '#14110c',
line: '#52525f', sel: '#f4efe3', found: '#24d38a', bonus: '#ffa733', selTile: '#ff479a',
msgDefault: '#ffd400', muted: '#9a927f',
},
};
// ===================== State =====================
let theme = document.documentElement.getAttribute('data-theme') || 'light';
let board = null;
let allSolutions = {};
let extraWords = {};
let wordDefs = {};
let foundWords = new Set();
let selected = [];
let puzzleNumber = null;
let elapsed = 0;
let lastSaveTime = 0;
let timerInterval = null;
let OFFSET_X, OFFSET_Y;
// Animation
let animRAF = null;
let entranceStart = 0;
const pulses = {};
// Drag
let dragging = false;
let dragExtended = false;
const PAL = () => THEMES[theme];
// ===================== Helpers =====================
function formatTime(ms) {
const s = Math.floor(ms / 1000);
return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
}
function cellKey(x, y) { return x + ',' + y; }
function cellCenter(x, y) {
return { x: OFFSET_X + x * PITCH + CELL / 2, y: OFFSET_Y + y * PITCH + CELL / 2 };
}
function currentWord() { return selected.map(([x, y]) => board[x][y]).join('').toLowerCase(); }
function initBoard() {
canvas.width = GRID * PITCH + 40;
canvas.height = GRID * PITCH + 40;
OFFSET_X = (canvas.width - (GRID - 1) * PITCH - CELL) / 2;
OFFSET_Y = (canvas.height - (GRID - 1) * PITCH - CELL) / 2;
}
function getActiveCells() {
const cells = new Set();
for (const [word, path] of Object.entries(allSolutions)) {
if (foundWords.has(word)) continue;
for (const c of path) cells.add(cellKey(c[0], c[1]));
}
return cells;
}
function getActiveConnections() {
const conns = new Set();
for (const [word, path] of Object.entries(allSolutions)) {
if (foundWords.has(word)) continue;
for (let i = 0; i < path.length - 1; i++) {
const a = cellKey(path[i][0], path[i][1]);
const b = cellKey(path[i + 1][0], path[i + 1][1]);
conns.add(a < b ? a + '-' + b : b + '-' + a);
}
}
return conns;
}
function getWordColor(word) {
let h = 0;
for (let i = 0; i < word.length; i++) h = word.charCodeAt(i) + ((h << 5) - h);
return PALETTE[Math.abs(h) % PALETTE.length];
}
// ===================== Persistence =====================
function save() {
if (puzzleNumber === null) return;
elapsed += Date.now() - lastSaveTime;
lastSaveTime = Date.now();
const state = { foundWords: Array.from(foundWords), elapsed, board, allSolutions, extraWords, wordDefs };
localStorage.setItem('puzzle_' + puzzleNumber, JSON.stringify(state));
}
function getSolvedNumbers() {
const result = [];
for (let i = 0; i < 1000; i++) {
if (localStorage.getItem('puzzle_' + i)) result.push(i);
}
return result;
}
function displayElapsed() {
return elapsed + (Date.now() - lastSaveTime);
}
// ===================== Animation =====================
function easeOutBack(t) {
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function tileScale(x, y, now) {
let s = 1;
if (entranceStart) {
const delay = (x + y) * ENTRANCE_STAGGER;
const t = (now - entranceStart - delay) / ENTRANCE_DUR;
if (t <= 0) s = 0;
else if (t < 1) s = easeOutBack(t);
}
const p = pulses[cellKey(x, y)];
if (p) {
const t = (now - p.start) / PULSE_DUR;
if (t > 0 && t < 1) s *= 1 + Math.sin(Math.PI * t) * 0.22;
}
return s;
}
function anyBoardAnim(now) {
let active = false;
if (entranceStart) {
if (now - entranceStart < ENTRANCE_DUR + ENTRANCE_STAGGER * (GRID * 2)) active = true;
else entranceStart = 0;
}
for (const k in pulses) {
if (now - pulses[k].start < PULSE_DUR) active = true;
else delete pulses[k];
}
return active;
}
function ensureAnim() { if (!animRAF) animRAF = requestAnimationFrame(animate); }
function animate() {
const now = performance.now();
draw(now);
if (anyBoardAnim(now)) animRAF = requestAnimationFrame(animate);
else { animRAF = null; draw(now); }
}
// ===================== Rendering =====================
function drawConnections() {
const p = PAL();
ctx.strokeStyle = p.line; ctx.lineWidth = GAP - 4; ctx.lineCap = 'round';
ctx.beginPath();
for (const key of getActiveConnections()) {
const [ka, kb] = key.split('-');
const [ax, ay] = ka.split(',').map(Number);
const [bx, by] = kb.split(',').map(Number);
const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
const t1 = CELL / (2 * PITCH), t2 = 1 - t1;
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
}
ctx.stroke();
}
function drawSelectionLine(color) {
if (selected.length < 2) return;
const t1 = CELL / (2 * PITCH), t2 = 1 - t1;
ctx.strokeStyle = color; ctx.lineWidth = GAP - 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath();
for (let i = 0; i < selected.length - 1; i++) {
const c1 = cellCenter(selected[i][0], selected[i][1]);
const c2 = cellCenter(selected[i + 1][0], selected[i + 1][1]);
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
}
ctx.stroke();
}
function drawTile(x, y, fill, textCol, scale) {
const p = PAL();
const c = cellCenter(x, y);
const half = (CELL * scale) / 2;
// offset shadow
ctx.fillStyle = p.shadow;
ctx.fillRect(c.x - half + SH, c.y - half + SH, half * 2, half * 2);
// tile
ctx.fillStyle = fill;
ctx.fillRect(c.x - half, c.y - half, half * 2, half * 2);
ctx.strokeStyle = p.border; ctx.lineWidth = 3;
ctx.strokeRect(c.x - half, c.y - half, half * 2, half * 2);
// letter
ctx.fillStyle = textCol;
ctx.font = 'bold ' + Math.round(30 * scale) + 'px ui-monospace, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(board[x][y].toUpperCase(), c.x, c.y);
}
function draw(now) {
if (!board) return;
if (now === undefined) now = performance.now();
const p = PAL();
ctx.clearRect(0, 0, canvas.width, canvas.height);
const activeCells = getActiveCells();
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
drawConnections();
drawSelectionLine(p.sel);
for (let x = 0; x < GRID; x++) {
for (let y = 0; y < GRID; y++) {
const key = cellKey(x, y);
const scale = tileScale(x, y, now);
if (scale <= 0.001) continue;
const isActive = activeCells.has(key);
const isSel = selSet.has(key);
const pulse = pulses[key];
let fill = p.tile, textCol = p.ink;
if (pulse) { fill = pulse.color; textCol = p.inkOn; }
else if (isSel) { fill = p.selTile; textCol = p.inkOn; }
ctx.globalAlpha = (isActive || isSel || pulse) ? 1 : 0.12;
drawTile(x, y, fill, textCol, scale);
ctx.globalAlpha = 1;
}
}
wordDisplay.textContent = selected.map(([x, y]) => board[x][y]).join('');
}
// ===================== Confetti =====================
let confetti = [];
let confettiRAF = null;
function sizeConfetti() { confettiCanvas.width = window.innerWidth; confettiCanvas.height = window.innerHeight; }
function burst(cx, cy, n) {
for (let i = 0; i < n; i++) {
const ang = Math.random() * Math.PI * 2;
const sp = 4 + Math.random() * 8;
confetti.push({
x: cx, y: cy,
vx: Math.cos(ang) * sp,
vy: Math.sin(ang) * sp - 7,
size: 7 + Math.random() * 9,
rot: Math.random() * 6.28,
vr: (Math.random() - 0.5) * 0.5,
col: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0],
});
}
if (!confettiRAF) confettiRAF = requestAnimationFrame(confettiTick);
}
function confettiTick() {
cctx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);
confetti = confetti.filter(pt => pt.y < confettiCanvas.height + 60);
for (const pt of confetti) {
pt.vy += 0.3; pt.vx *= 0.99;
pt.x += pt.vx; pt.y += pt.vy; pt.rot += pt.vr;
cctx.save();
cctx.translate(pt.x, pt.y); cctx.rotate(pt.rot);
cctx.fillStyle = pt.col; cctx.strokeStyle = '#14110c'; cctx.lineWidth = 2;
cctx.fillRect(-pt.size / 2, -pt.size / 2, pt.size, pt.size);
cctx.strokeRect(-pt.size / 2, -pt.size / 2, pt.size, pt.size);
cctx.restore();
}
if (confetti.length) confettiRAF = requestAnimationFrame(confettiTick);
else { confettiRAF = null; cctx.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height); }
}
function tileScreenPos(x, y) {
const r = canvas.getBoundingClientRect();
const c = cellCenter(x, y);
return { x: r.left + c.x * (r.width / canvas.width), y: r.top + c.y * (r.height / canvas.height) };
}
function celebrate() {
const w = window.innerWidth;
burst(w * 0.2, window.innerHeight * 0.25, 50);
burst(w * 0.5, window.innerHeight * 0.18, 60);
burst(w * 0.8, window.innerHeight * 0.25, 50);
}
// ===================== Found / status =====================
function renderFound() {
const p = PAL();
foundDiv.innerHTML = '';
const h = document.createElement('h2');
h.textContent = 'Найдено (' + foundWords.size + ')';
foundDiv.appendChild(h);
const grid = document.createElement('div');
grid.className = 'found-grid';
for (const word of foundWords) {
const color = getWordColor(word);
const def = wordDefs[word] || '';
const isExtra = word in extraWords && !(word in allSolutions);
const item = document.createElement('div');
item.className = 'word-item' + (isExtra ? ' bonus' : '');
item.innerHTML =
'<div class="word-item-inner">' +
'<span class="word-color" style="background:' + color + '"></span>' +
'<span class="word-text">' + word + '</span>' +
(isExtra ? '<span class="word-tag">бонус</span>' : '') +
'</div>' +
(def ? '<span class="word-def">— ' + def + '</span>' : '');
grid.appendChild(item);
}
foundDiv.appendChild(grid);
}
function updateStatus() {
const remaining = Object.keys(allSolutions).filter(w => !foundWords.has(w)).length;
statLeft.textContent = remaining;
}
function allWordsFound() {
const keys = Object.keys(allSolutions);
return keys.length > 0 && keys.every(w => foundWords.has(w));
}
function showMessage(text, color) {
msgEl.textContent = text;
msgEl.style.color = color || PAL().msgDefault;
clearTimeout(showMessage._t);
showMessage._t = setTimeout(() => { msgEl.textContent = ''; }, 2000);
}
// ===================== Game logic =====================
function acceptWord(word) {
foundWords.add(word);
const isBonus = word in extraWords && !(word in allSolutions);
const path = allSolutions[word] || extraWords[word];
const color = isBonus ? PAL().bonus : PAL().found;
const now = performance.now();
for (const c of path) pulses[cellKey(c[0], c[1])] = { start: now, color };
ensureAnim();
const last = path[path.length - 1];
const sp = tileScreenPos(last[0], last[1]);
burst(sp.x, sp.y, isBonus ? 14 : 24);
if (isBonus) showMessage('Бонус!', PAL().bonus);
renderFound(); updateStatus(); save();
const done = allWordsFound();
setTimeout(() => { selected = []; draw(); }, 480);
if (done) finishGame(true);
}
function checkWord() {
if (selected.length < 4) return;
const word = currentWord();
if (foundWords.has(word)) {
const allW = Object.keys(allSolutions).concat(Object.keys(extraWords));
const isPrefixOfUnfound = allW.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
if (isPrefixOfUnfound) return;
showMessage('Уже найдено!', PAL().msgDefault);
selected = []; draw(); return;
}
if ((word in allSolutions) || (word in extraWords)) acceptWord(word);
}
function submitSelection() {
if (selected.length === 0) return;
const word = currentWord();
if (foundWords.has(word)) {
const allW = Object.keys(allSolutions).concat(Object.keys(extraWords));
const isPrefixOfUnfound = allW.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
if (isPrefixOfUnfound) return;
showMessage('Уже найдено!', PAL().msgDefault);
selected = []; draw(); return;
}
if (selected.length < 4) {
showMessage('Мало букв — минимум 4', PAL().selTile);
selected = []; draw(); return;
}
if ((word in allSolutions) || (word in extraWords)) { acceptWord(word); return; }
showMessage('Слово не найдено!', PAL().selTile);
selected = []; draw();
}
function isAdjacentAndConnected(a, b) {
if (Math.abs(a[0] - b[0]) > 1 || Math.abs(a[1] - b[1]) > 1) return false;
if (a[0] === b[0] && a[1] === b[1]) return false;
const ka = cellKey(a[0], a[1]), kb = cellKey(b[0], b[1]);
const key = ka < kb ? ka + '-' + kb : kb + '-' + ka;
return getActiveConnections().has(key);
}
// ===================== Pointer (tap + swipe) =====================
function evCell(e) {
const r = canvas.getBoundingClientRect();
const mx = (e.clientX - r.left) * (canvas.width / r.width);
const my = (e.clientY - r.top) * (canvas.height / r.height);
const col = Math.floor((mx - OFFSET_X) / PITCH);
const row = Math.floor((my - OFFSET_Y) / PITCH);
if (col < 0 || col >= GRID || row < 0 || row >= GRID) return null;
return [col, row];
}
function applyTap(cell) {
const [col, row] = cell;
const idx = selected.findIndex(s => s[0] === col && s[1] === row);
if (idx >= 0) { selected = selected.slice(0, idx); draw(); return; }
if (selected.length > 0 && isAdjacentAndConnected(selected[selected.length - 1], [col, row])) {
selected.push([col, row]); draw(); checkWord(); return;
}
if (getActiveCells().has(cellKey(col, row))) { selected = [[col, row]]; draw(); }
}
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
const cell = evCell(e);
if (!cell) return;
dragging = true; dragExtended = false;
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
applyTap(cell);
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const cell = evCell(e);
if (!cell) return;
const [col, row] = cell;
const last = selected[selected.length - 1];
if (last && last[0] === col && last[1] === row) return;
if (selected.length >= 2) {
const prev = selected[selected.length - 2];
if (prev[0] === col && prev[1] === row) { selected.pop(); dragExtended = true; draw(); return; }
}
if (selected.length === 0) {
if (getActiveCells().has(cellKey(col, row))) { selected = [[col, row]]; dragExtended = true; draw(); }
return;
}
if (selected.findIndex(s => s[0] === col && s[1] === row) >= 0) return;
if (isAdjacentAndConnected(last, [col, row])) { selected.push([col, row]); dragExtended = true; draw(); }
});
function endDrag(e) {
if (!dragging) return;
dragging = false;
try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}
if (dragExtended) submitSelection();
}
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
// ===================== Keyboard =====================
function letterAt(x, y) { return board[x][y].toLowerCase(); }
function solAndExtraKeys() { return Object.keys(allSolutions).concat(Object.keys(extraWords)); }
// Trace a path over the active-connection graph that spells `buffer`,
// preferring to keep the current selection as a prefix so it doesn't jump.
function routeByTyping(buffer) {
buffer = buffer.toLowerCase();
if (buffer === '') return [];
const activeCells = getActiveCells();
const conns = getActiveConnections();
const connected = (a, b) => {
const ka = cellKey(a[0], a[1]), kb = cellKey(b[0], b[1]);
return conns.has(ka < kb ? ka + '-' + kb : kb + '-' + ka);
};
const visited = new Set();
function dfs(path) {
if (path.length === buffer.length) return path.slice();
const nextLetter = buffer[path.length];
const last = path.length ? path[path.length - 1] : null;
const cands = [];
if (!last) {
for (const key of activeCells) {
const [x, y] = key.split(',').map(Number);
if (letterAt(x, y) === nextLetter) cands.push([x, y]);
}
} else {
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) {
if (!dx && !dy) continue;
const x = last[0] + dx, y = last[1] + dy;
if (x < 0 || x >= GRID || y < 0 || y >= GRID) continue;
if (!activeCells.has(cellKey(x, y))) continue;
if (!connected(last, [x, y])) continue;
if (letterAt(x, y) === nextLetter) cands.push([x, y]);
}
}
const curCell = selected[path.length];
cands.sort((a, b) => {
const am = (curCell && a[0] === curCell[0] && a[1] === curCell[1]) ? 0 : 1;
const bm = (curCell && b[0] === curCell[0] && b[1] === curCell[1]) ? 0 : 1;
return am - bm;
});
for (const c of cands) {
const k = cellKey(c[0], c[1]);
if (visited.has(k)) continue;
visited.add(k); path.push(c);
const r = dfs(path);
if (r) return r;
path.pop(); visited.delete(k);
}
return null;
}
return dfs([]);
}
function onKeyDown(e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (numberSelectorEl.classList.contains('show')) {
if (e.key === 'Escape') numberSelectorEl.classList.remove('show');
return;
}
if (gameCompleteEl.classList.contains('show')) return;
if (!board) return;
const tag = (e.target && e.target.tagName) || '';
const key = e.key;
if (key === 'Enter') {
if (tag === 'BUTTON') return;
e.preventDefault();
submitSelection();
return;
}
if (key === 'Backspace') {
e.preventDefault();
if (selected.length) { selected.pop(); draw(); }
return;
}
if (key === 'Escape') {
if (selected.length) { selected = []; draw(); }
return;
}
if (key.length === 1 && /\S/.test(key) && !/[0-9]/.test(key)) {
const path = routeByTyping(currentWord() + key.toLowerCase());
if (!path) { showMessage('Нет такого хода', PAL().selTile); return; }
e.preventDefault();
selected = path; draw();
const buffer = currentWord();
const canExtend = solAndExtraKeys().some(
w => !foundWords.has(w) && w.length > buffer.length && w.startsWith(buffer),
);
if (((buffer in allSolutions) || (buffer in extraWords)) && !canExtend) submitSelection();
}
}
// ===================== Timer =====================
function startTimer() {
if (timerInterval) clearInterval(timerInterval);
statTime.textContent = formatTime(displayElapsed());
timerInterval = setInterval(() => { statTime.textContent = formatTime(displayElapsed()); }, 500);
}
function resetTimer() {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
elapsed = 0;
lastSaveTime = Date.now();
statTime.textContent = '00:00';
}
function finishGame(withConfetti) {
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
gameTimeEl.textContent = formatTime(displayElapsed());
gameCompleteEl.classList.add('show');
if (withConfetti) celebrate();
}
function startBoard() {
entranceStart = performance.now();
for (const k in pulses) delete pulses[k];
initBoard();
ensureAnim();
}
// ===================== Load puzzle =====================
async function loadPuzzle(number) {
statLeft.textContent = '…';
gameCompleteEl.classList.remove('show');
numberSelectorEl.classList.remove('show');
resetTimer();
let data;
if (number != null) {
const res = await fetch('/api/puzzles/daily?number=' + number);
if (res.status === 404) { statNumber.textContent = '404'; return; }
data = await res.json();
if (data.error) { statNumber.textContent = 'ERR'; return; }
} else {
const res = await fetch('/api/puzzles/daily');
data = await res.json();
if (data.error) { statNumber.textContent = 'ERR'; return; }
}
puzzleNumber = data.number;
statNumber.textContent = '#' + puzzleNumber;
const raw = localStorage.getItem('puzzle_' + data.number);
if (raw) {
const parsed = JSON.parse(raw);
foundWords = new Set(parsed.foundWords);
elapsed = parsed.elapsed || 0;
lastSaveTime = Date.now();
} else {
foundWords = new Set();
}
board = data.board;
allSolutions = data.solutions || {};
extraWords = data.extra || {};
wordDefs = data.word_defs || {};
selected = [];
startBoard(); renderFound(); updateStatus();
if (allWordsFound()) { finishGame(false); return; }
startTimer();
}
async function loadRandomPuzzle() {
gameCompleteEl.classList.remove('show');
statLeft.textContent = '…';
resetTimer();
puzzleNumber = null;
statNumber.textContent = '?';
const res = await fetch('/api/puzzles/random');
const data = await res.json();
if (data.error) { statNumber.textContent = 'ERR'; return; }
foundWords = new Set();
board = data.board;
allSolutions = data.solutions || {};
extraWords = data.extra || {};
wordDefs = data.word_defs || {};
selected = [];
startBoard(); renderFound(); updateStatus();
startTimer();
}
// ===================== Number selector =====================
async function showNumberSelector() {
numberGridEl.innerHTML = '';
const solved = getSolvedNumbers();
try {
const res = await fetch('/api/puzzles/daily');
const data = await res.json();
if (data.error) return;
const maxNum = data.number || 0;
for (let i = maxNum; i >= 1; i--) {
const btn = document.createElement('button');
btn.textContent = i;
if (solved.includes(i)) btn.classList.add('solved');
if (i === puzzleNumber) btn.classList.add('current');
btn.addEventListener('click', () => loadPuzzle(i));
numberGridEl.appendChild(btn);
}
} catch (e) {}
numberSelectorEl.classList.add('show');
}
// ===================== Copy result =====================
function copyResult() {
const text = 'Занграммы #' + puzzleNumber +
'\n🎉 Мне потребовалось всего лишь ' + formatTime(displayElapsed()) +
'\n\n' + window.location.href;
navigator.clipboard.writeText(text).then(
() => showMessage('Результат скопирован!', PAL().found),
() => showMessage('Не удалось скопировать', PAL().selTile),
);
}
// ===================== Theme =====================
function applyTheme(t) {
theme = t;
document.documentElement.setAttribute('data-theme', t);
themeBtn.textContent = t === 'dark' ? '☀' : '☾';
localStorage.setItem('theme', t);
draw();
}
// ===================== Events =====================
wordDisplay.addEventListener('click', submitSelection);
$('copyResultBtn').addEventListener('click', copyResult);
$('prevPuzzlesBtn').addEventListener('click', showNumberSelector);
$('randomPuzzleBtn').addEventListener('click', loadRandomPuzzle);
$('closeNumberBtn').addEventListener('click', () => numberSelectorEl.classList.remove('show'));
themeBtn.addEventListener('click', () => applyTheme(theme === 'dark' ? 'light' : 'dark'));
document.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', sizeConfetti);
window.addEventListener('beforeunload', save);
document.addEventListener('visibilitychange', () => { if (document.hidden) save(); });
// ===================== Init =====================
applyTheme(theme);
sizeConfetti();
loadPuzzle();
})();
</script>
</body>
</html>