129a1ea5f7
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>
1089 lines
44 KiB
HTML
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>
|