added support for daily puzzles.
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
This commit is contained in:
@@ -12,6 +12,7 @@ env:
|
|||||||
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
|
ZANGRAMRU_DB_HOST: cnpg-cluster-rw.cnpg-cluster.svc.cluster.local
|
||||||
ZANGRAMRU_DB_USER: zangram
|
ZANGRAMRU_DB_USER: zangram
|
||||||
ZANGRAMRU_DB_BASE: zangram
|
ZANGRAMRU_DB_BASE: zangram
|
||||||
|
ZANGRAMRU_START_DATE: "2026-07-01"
|
||||||
|
|
||||||
probes:
|
probes:
|
||||||
liveness:
|
liveness:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from piccolo.apps.migrations.auto.migration_manager import MigrationManager
|
||||||
|
from piccolo.columns.column_types import Integer
|
||||||
|
from piccolo.columns.indexes import IndexMethod
|
||||||
|
|
||||||
|
ID = "2026-07-01T21:56:16:112157"
|
||||||
|
VERSION = "1.34.0"
|
||||||
|
DESCRIPTION = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def forwards():
|
||||||
|
manager = MigrationManager(
|
||||||
|
migration_id=ID, app_name="zangramru_db", description=DESCRIPTION
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.drop_column(
|
||||||
|
table_class_name="Puzzle",
|
||||||
|
tablename="puzzle",
|
||||||
|
column_name="date",
|
||||||
|
db_column_name="date",
|
||||||
|
schema=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.add_column(
|
||||||
|
table_class_name="Puzzle",
|
||||||
|
tablename="puzzle",
|
||||||
|
column_name="number",
|
||||||
|
db_column_name="number",
|
||||||
|
column_class_name="Integer",
|
||||||
|
column_class=Integer,
|
||||||
|
params={
|
||||||
|
"default": 0,
|
||||||
|
"null": False,
|
||||||
|
"primary_key": False,
|
||||||
|
"unique": False,
|
||||||
|
"index": False,
|
||||||
|
"index_method": IndexMethod.btree,
|
||||||
|
"choices": None,
|
||||||
|
"db_column_name": None,
|
||||||
|
"secret": False,
|
||||||
|
},
|
||||||
|
schema=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return manager
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
from piccolo.columns import JSON, UUID, Date, Varchar
|
import uuid
|
||||||
|
|
||||||
|
from piccolo.columns import JSON, UUID, Integer, Varchar
|
||||||
from piccolo.table import Table
|
from piccolo.table import Table
|
||||||
|
|
||||||
|
|
||||||
class Puzzle(Table):
|
class Puzzle(Table):
|
||||||
"""Table for puzzles."""
|
"""Table for puzzles."""
|
||||||
|
|
||||||
id = UUID(primary_key=True)
|
id = UUID(primary_key=True, default=uuid.uuid4)
|
||||||
board = Varchar(length=16)
|
board = Varchar(length=16)
|
||||||
solutions = JSON()
|
solutions = JSON()
|
||||||
extra = JSON()
|
extra = JSON()
|
||||||
definitions = JSON()
|
definitions = JSON()
|
||||||
date = Date()
|
number = Integer()
|
||||||
|
|||||||
+17242
-17469
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
|||||||
import csv
|
import csv
|
||||||
|
import datetime
|
||||||
import secrets
|
import secrets
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -107,7 +109,7 @@ class PuzzleGenerator:
|
|||||||
with dict_path.open("r") as f:
|
with dict_path.open("r") as f:
|
||||||
rows = csv.DictReader(f, delimiter="|")
|
rows = csv.DictReader(f, delimiter="|")
|
||||||
for row in rows:
|
for row in rows:
|
||||||
word = row["word"]
|
word = row["word"].lower().replace("ё", "е") # noqa: RUF001
|
||||||
self.words[word] = row["def"]
|
self.words[word] = row["def"]
|
||||||
self.trie.add(word)
|
self.trie.add(word)
|
||||||
|
|
||||||
@@ -139,7 +141,10 @@ class PuzzleGenerator:
|
|||||||
while paths:
|
while paths:
|
||||||
path, trie_node = paths.pop()
|
path, trie_node = paths.pop()
|
||||||
if trie_node.valid_word and len(trie_node.value) >= 4:
|
if trie_node.valid_word and len(trie_node.value) >= 4:
|
||||||
solutions[trie_node.value] = path
|
word = trie_node.value
|
||||||
|
if word in solutions:
|
||||||
|
return {}
|
||||||
|
solutions[word] = path
|
||||||
for dx, dy in (
|
for dx, dy in (
|
||||||
(1, 1),
|
(1, 1),
|
||||||
(-1, -1),
|
(-1, -1),
|
||||||
@@ -263,20 +268,24 @@ class PuzzleGenerator:
|
|||||||
|
|
||||||
return used_cells
|
return used_cells
|
||||||
|
|
||||||
def generate(self, target: int, max_attempts: int) -> Puzzle | None:
|
def generate(self, target: int, timeout: int = 10) -> Puzzle:
|
||||||
"""
|
"""
|
||||||
Generate a valid puzzle that has solutions.
|
Generate a valid puzzle that has solutions.
|
||||||
|
|
||||||
:param target: minimum number of words required to solve the puzzle.
|
:param target: minimum number of words required to solve the puzzle.
|
||||||
:param max_attempts: Since sometimes we might fail to generate a correct
|
:param timeout: since puzzles can fail to generate sometimes,
|
||||||
puzzle. We try it multiple times, this parameters limits how
|
we use timeout to not let the puzzle generator hung forever!
|
||||||
many attempts we should have before giving up.
|
|
||||||
:return: puzzle.
|
:return: puzzle.
|
||||||
"""
|
"""
|
||||||
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
|
board: list[list[str]] = [["" for _ in range(4)] for _ in range(4)]
|
||||||
valid_points: set[tuple[int, int]] = set()
|
valid_points: set[tuple[int, int]] = set()
|
||||||
single_board_attempt = 0
|
single_board_attempt = 0
|
||||||
for _ in range(max_attempts):
|
started = datetime.datetime.now()
|
||||||
|
while True:
|
||||||
|
if datetime.datetime.now() - started > timedelta(seconds=timeout):
|
||||||
|
raise ValueError(
|
||||||
|
f"Coud not find a valid solution in {timeout} seconds!"
|
||||||
|
)
|
||||||
if single_board_attempt > 50:
|
if single_board_attempt > 50:
|
||||||
valid_points = set()
|
valid_points = set()
|
||||||
|
|
||||||
@@ -301,8 +310,6 @@ class PuzzleGenerator:
|
|||||||
word_defs=word_defs,
|
word_defs=word_defs,
|
||||||
)
|
)
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def setup_generator(app: FastAPI) -> None:
|
def setup_generator(app: FastAPI) -> None:
|
||||||
"""Create puzzle-generator."""
|
"""Create puzzle-generator."""
|
||||||
@@ -319,7 +326,7 @@ PuzzleGeneratorDep = Annotated[PuzzleGenerator, Depends(get_generator)]
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
generator = PuzzleGenerator(CWD / "dict.csv")
|
generator = PuzzleGenerator(CWD / "dict.csv")
|
||||||
res = generator.generate(target=10, max_attempts=200)
|
res = generator.generate(target=10)
|
||||||
if res is None:
|
if res is None:
|
||||||
print("No solution found!") # noqa: T201
|
print("No solution found!") # noqa: T201
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import enum
|
import enum
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import gettempdir
|
from tempfile import gettempdir
|
||||||
|
|
||||||
@@ -46,6 +47,9 @@ class Settings(BaseSettings):
|
|||||||
db_base: str = "zangramru"
|
db_base: str = "zangramru"
|
||||||
db_echo: bool = False
|
db_echo: bool = False
|
||||||
|
|
||||||
|
# When first daily puzzle was generated.
|
||||||
|
start_date: date = date(2026, 7, 1)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db_url(self) -> URL:
|
def db_url(self) -> URL:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+310
-353
@@ -48,9 +48,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
#wordDisplay:hover {
|
#wordDisplay:hover { background: rgba(255,255,255,0.05); }
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
}
|
|
||||||
#message {
|
#message {
|
||||||
min-height: 1.5rem;
|
min-height: 1.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -65,11 +63,7 @@
|
|||||||
touch-action: none;
|
touch-action: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
#found {
|
#found { margin-top: 1.5rem; max-width: 500px; width: 100%; }
|
||||||
margin-top: 1.5rem;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
#found h2 { margin-bottom: 0.5rem; font-size: 1.1rem; }
|
#found h2 { margin-bottom: 0.5rem; font-size: 1.1rem; }
|
||||||
.word-item {
|
.word-item {
|
||||||
background: #16213e;
|
background: #16213e;
|
||||||
@@ -80,28 +74,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
.word-item-inner {
|
.word-item-inner { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
display: flex;
|
.word-color { width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; }
|
||||||
align-items: center;
|
.word-text { font-weight: 700; font-size: 1.1rem; color: #fff; }
|
||||||
gap: 0.5rem;
|
.word-def { color: #888; font-size: 0.9rem; padding-left: 1.5rem; }
|
||||||
}
|
.modal {
|
||||||
.word-color {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.word-text {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.word-def {
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
#gameComplete {
|
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -110,66 +87,81 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
#gameComplete.show {
|
.modal.show { display: flex; }
|
||||||
display: flex;
|
.modal .popup {
|
||||||
}
|
|
||||||
#gameComplete .popup {
|
|
||||||
background: #16213e;
|
background: #16213e;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 2rem 3rem;
|
padding: 2rem 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
max-width: 400px;
|
||||||
}
|
}
|
||||||
#gameComplete .popup h2 {
|
.modal .popup h2 { color: #fff; font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
color: #fff;
|
.modal .popup .time { color: #2ed573; font-size: 2rem; font-weight: bold; margin: 1rem 0; }
|
||||||
font-size: 1.5rem;
|
.modal .popup .buttons { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 1.5rem; }
|
||||||
margin-bottom: 0.5rem;
|
.modal .popup .buttons button { width: 100%; margin-top: 0; }
|
||||||
}
|
#numberSelector { background: rgba(0,0,0,0.5); z-index: 101; }
|
||||||
#gameComplete .popup .time {
|
#numberSelector .popup { padding: 1.5rem 2rem; max-height: 70vh; overflow-y: auto; }
|
||||||
color: #2ed573;
|
#numberSelector .popup h3 { color: #fff; margin-bottom: 1rem; text-align: center; }
|
||||||
font-size: 2rem;
|
#numberSelector .number-grid {
|
||||||
font-weight: bold;
|
display: grid;
|
||||||
margin: 1rem 0;
|
grid-template-columns: repeat(5, 1fr);
|
||||||
}
|
gap: 0.5rem;
|
||||||
#gameComplete .popup button {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.7rem 2rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
}
|
||||||
|
#numberSelector .number-grid button { padding: 0.5rem; font-size: 0.9rem; background: #0f3460; }
|
||||||
|
#numberSelector .number-grid button:hover { background: #1a4a80; }
|
||||||
|
#numberSelector .number-grid button.solved { background: #2ed573; color: #000; }
|
||||||
|
#numberSelector .number-grid button.current { border: 2px solid #feca57; }
|
||||||
|
#numberSelector .popup .close-btn { margin-top: 1rem; width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Занграммы</h1>
|
<h1>Занграммы</h1>
|
||||||
<div class="controls">
|
<div class="controls"><span id="status">Загрузка...</span></div>
|
||||||
<span id="status">Загрузка...</span>
|
|
||||||
</div>
|
|
||||||
<div id="wordDisplay"></div>
|
<div id="wordDisplay"></div>
|
||||||
<div id="message"></div>
|
<div id="message"></div>
|
||||||
<canvas id="board"></canvas>
|
<canvas id="board"></canvas>
|
||||||
<div id="found"></div>
|
<div id="found"></div>
|
||||||
<div id="gameComplete">
|
|
||||||
|
<!-- Game complete modal -->
|
||||||
|
<div id="gameComplete" class="modal">
|
||||||
<div class="popup">
|
<div class="popup">
|
||||||
<h2>Вы нашли все слова! Ваше время:</h2>
|
<h2>Вы нашли все слова! Ваше время:</h2>
|
||||||
<div class="time" id="gameTime"></div>
|
<div class="time" id="gameTime"></div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button id="copyResultBtn">Скопировать результат</button>
|
||||||
|
<button id="prevPuzzlesBtn">Предыдущие занграммы</button>
|
||||||
|
<button id="randomPuzzleBtn">Случайная занграмма</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const canvas = document.getElementById('board');
|
(() => {
|
||||||
|
// ===================== DOM =====================
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const canvas = $('board');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = $('status');
|
||||||
const wordDisplay = document.getElementById('wordDisplay');
|
const wordDisplay = $('wordDisplay');
|
||||||
const msgEl = document.getElementById('message');
|
const msgEl = $('message');
|
||||||
const foundDiv = document.getElementById('found');
|
const foundDiv = $('found');
|
||||||
const gameCompleteEl = document.getElementById('gameComplete');
|
const gameCompleteEl = $('gameComplete');
|
||||||
const gameTimeEl = document.getElementById('gameTime');
|
const gameTimeEl = $('gameTime');
|
||||||
|
const numberSelectorEl = $('numberSelector');
|
||||||
const CELL = 70;
|
const numberGridEl = $('numberGrid');
|
||||||
const GAP = 20;
|
|
||||||
const GRID = 4;
|
|
||||||
const CELL_PITCH = CELL + GAP;
|
|
||||||
let OFFSET_X, OFFSET_Y;
|
|
||||||
|
|
||||||
|
// ===================== Constants =====================
|
||||||
|
const CELL = 70, GAP = 20, GRID = 4, CELL_PITCH = CELL + GAP;
|
||||||
const PALETTE = [
|
const PALETTE = [
|
||||||
'#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff',
|
'#ff6b6b','#feca57','#48dbfb','#ff9ff3','#54a0ff',
|
||||||
'#5f27cd','#01a3a4','#f368e0','#ff6348','#7bed9f',
|
'#5f27cd','#01a3a4','#f368e0','#ff6348','#7bed9f',
|
||||||
@@ -177,42 +169,30 @@
|
|||||||
'#eccc68','#a4b0be','#ff7979','#badc58','#dff9fb'
|
'#eccc68','#a4b0be','#ff7979','#badc58','#dff9fb'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ===================== State =====================
|
||||||
let board = null;
|
let board = null;
|
||||||
let allSolutions = {};
|
let allSolutions = {};
|
||||||
let extraWords = {};
|
let extraWords = {};
|
||||||
|
let wordDefs = {};
|
||||||
let foundWords = new Set();
|
let foundWords = new Set();
|
||||||
let foundPaths = {};
|
|
||||||
let word_defs = {};
|
|
||||||
let selected = [];
|
let selected = [];
|
||||||
let flashTimer = null;
|
let flashTimer = null;
|
||||||
let startTime = null;
|
let puzzleNumber = null;
|
||||||
|
let elapsed = 0;
|
||||||
|
let lastSaveTime = 0;
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
|
let OFFSET_X, OFFSET_Y;
|
||||||
|
|
||||||
|
// ===================== Helpers =====================
|
||||||
function formatTime(ms) {
|
function formatTime(ms) {
|
||||||
const totalSec = Math.floor(ms / 1000);
|
const s = Math.floor(ms / 1000);
|
||||||
const min = Math.floor(totalSec / 60).toString().padStart(2, '0');
|
return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
|
||||||
const sec = (totalSec % 60).toString().padStart(2, '0');
|
|
||||||
return min + ':' + sec;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTimer() {
|
function cellKey(x, y) { return x + ',' + y; }
|
||||||
startTime = Date.now();
|
|
||||||
if (timerInterval) clearInterval(timerInterval);
|
|
||||||
timerInterval = setInterval(() => {
|
|
||||||
const remaining = Object.keys(allSolutions).filter(w => !foundWords.has(w)).length;
|
|
||||||
if (remaining === 0) {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
const elapsed = Date.now() - startTime;
|
|
||||||
gameTimeEl.textContent = formatTime(elapsed);
|
|
||||||
gameCompleteEl.classList.add('show');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTimer() {
|
function cellCenter(x, y) {
|
||||||
if (timerInterval) clearInterval(timerInterval);
|
return { x: OFFSET_X + x * CELL_PITCH + CELL / 2, y: OFFSET_Y + y * CELL_PITCH + CELL / 2 };
|
||||||
timerInterval = null;
|
|
||||||
startTime = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initBoard() {
|
function initBoard() {
|
||||||
@@ -222,46 +202,11 @@
|
|||||||
OFFSET_Y = 40;
|
OFFSET_Y = 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellCenter(x, y) {
|
|
||||||
return {
|
|
||||||
x: OFFSET_X + x * CELL_PITCH + CELL / 2,
|
|
||||||
y: OFFSET_Y + y * CELL_PITCH + CELL / 2
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function cellKey(x, y) {
|
|
||||||
return x + ',' + y;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSolutionConnections() {
|
|
||||||
const conns = new Set();
|
|
||||||
for (const [word, path] of Object.entries(allSolutions)) {
|
|
||||||
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 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]);
|
|
||||||
const kb = cellKey(b[0], b[1]);
|
|
||||||
const key = ka < kb ? ka + '-' + kb : kb + '-' + ka;
|
|
||||||
const conns = getSolutionConnections();
|
|
||||||
return conns.has(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveCells() {
|
function getActiveCells() {
|
||||||
const cells = new Set();
|
const cells = new Set();
|
||||||
for (const [word, path] of Object.entries(allSolutions)) {
|
for (const [word, path] of Object.entries(allSolutions)) {
|
||||||
if (foundWords.has(word)) continue;
|
if (foundWords.has(word)) continue;
|
||||||
for (const cell of path) {
|
for (const c of path) cells.add(cellKey(c[0], c[1]));
|
||||||
cells.add(cellKey(cell[0], cell[1]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return cells;
|
return cells;
|
||||||
}
|
}
|
||||||
@@ -281,96 +226,118 @@
|
|||||||
|
|
||||||
function drawPath(path, color, lineWidth, alpha) {
|
function drawPath(path, color, lineWidth, alpha) {
|
||||||
if (path.length < 2) return;
|
if (path.length < 2) return;
|
||||||
const t1 = CELL / (2 * CELL_PITCH);
|
const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
|
||||||
const t2 = 1 - CELL / (2 * CELL_PITCH);
|
ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.lineCap = 'round'; ctx.globalAlpha = alpha;
|
||||||
ctx.strokeStyle = color;
|
|
||||||
ctx.lineWidth = lineWidth;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.globalAlpha = alpha;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (let i = 0; i < path.length - 1; i++) {
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
const c1 = cellCenter(path[i][0], path[i][1]);
|
const c1 = cellCenter(path[i][0], path[i][1]);
|
||||||
const c2 = cellCenter(path[i + 1][0], path[i + 1][1]);
|
const c2 = cellCenter(path[i + 1][0], path[i + 1][1]);
|
||||||
const x1 = c1.x + (c2.x - c1.x) * t1;
|
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
|
||||||
const y1 = c1.y + (c2.y - c1.y) * t1;
|
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
|
||||||
const x2 = c1.x + (c2.x - c1.x) * t2;
|
|
||||||
const y2 = c1.y + (c2.y - c1.y) * t2;
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shadeColor(color, percent) {
|
||||||
|
let R = parseInt(color.substring(1, 3), 16);
|
||||||
|
let G = parseInt(color.substring(3, 5), 16);
|
||||||
|
let B = parseInt(color.substring(5, 7), 16);
|
||||||
|
R = Math.min(255, Math.max(0, Math.round(R * (100 + percent) / 100)));
|
||||||
|
G = Math.min(255, Math.max(0, Math.round(G * (100 + percent) / 100)));
|
||||||
|
B = Math.min(255, Math.max(0, Math.round(B * (100 + percent) / 100)));
|
||||||
|
return '#' + R.toString(16).padStart(2, '0') + G.toString(16).padStart(2, '0') + B.toString(16).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardsEqual(a, b) {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].length !== b[i].length) return false;
|
||||||
|
for (let j = 0; j < a[i].length; j++) {
|
||||||
|
if (a[i][j] !== b[i][j]) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 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 load() {
|
||||||
|
if (puzzleNumber === null) return null;
|
||||||
|
const raw = localStorage.getItem('puzzle_' + puzzleNumber);
|
||||||
|
if (!raw) return null;
|
||||||
|
const state = JSON.parse(raw);
|
||||||
|
lastSaveTime = Date.now();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (puzzleNumber === null) return;
|
||||||
|
localStorage.removeItem('puzzle_' + puzzleNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSolvedNumbers() {
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
if (localStorage.getItem('puzzle_' + i)) result.push(i);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Rendering =====================
|
||||||
function draw() {
|
function draw() {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
|
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
|
||||||
const activeCells = getActiveCells();
|
const activeCells = getActiveCells();
|
||||||
const activeConns = getActiveConnections();
|
const activeConns = getActiveConnections();
|
||||||
|
|
||||||
// Draw connections between active cells
|
ctx.strokeStyle = '#2a4a7f'; ctx.lineWidth = GAP - 6; ctx.lineCap = 'round'; ctx.globalAlpha = 0.6;
|
||||||
ctx.strokeStyle = '#2a4a7f';
|
|
||||||
ctx.lineWidth = GAP - 6;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.globalAlpha = 0.6;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (const key of activeConns) {
|
for (const key of activeConns) {
|
||||||
const [ka, kb] = key.split('-');
|
const [ka, kb] = key.split('-');
|
||||||
const [ax, ay] = ka.split(',').map(Number);
|
const [ax, ay] = ka.split(',').map(Number);
|
||||||
const [bx, by] = kb.split(',').map(Number);
|
const [bx, by] = kb.split(',').map(Number);
|
||||||
const c1 = cellCenter(ax, ay);
|
const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
|
||||||
const c2 = cellCenter(bx, by);
|
const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
|
||||||
const t1 = CELL / (2 * CELL_PITCH);
|
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
|
||||||
const t2 = 1 - CELL / (2 * CELL_PITCH);
|
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
|
||||||
const x1 = c1.x + (c2.x - c1.x) * t1;
|
|
||||||
const y1 = c1.y + (c2.y - c1.y) * t1;
|
|
||||||
const x2 = c1.x + (c2.x - c1.x) * t2;
|
|
||||||
const y2 = c1.y + (c2.y - c1.y) * t2;
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke(); ctx.globalAlpha = 1;
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
|
|
||||||
// Draw selected path
|
if (selected.length > 1) drawPath(selected, '#fff', GAP - 2, 0.8);
|
||||||
if (selected.length > 1) {
|
|
||||||
drawPath(selected, '#fff', GAP - 2, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw cells
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
|
|
||||||
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||||
for (let x = 0; x < GRID; x++) {
|
for (let x = 0; x < GRID; x++) {
|
||||||
for (let y = 0; y < GRID; y++) {
|
for (let y = 0; y < GRID; y++) {
|
||||||
const cx = OFFSET_X + x * CELL_PITCH;
|
const cx = OFFSET_X + x * CELL_PITCH, cy = OFFSET_Y + y * CELL_PITCH;
|
||||||
const cy = OFFSET_Y + y * CELL_PITCH;
|
|
||||||
const key = cellKey(x, y);
|
const key = cellKey(x, y);
|
||||||
const isVisible = activeCells.has(key);
|
const isVisible = activeCells.has(key), isSel = selSet.has(key);
|
||||||
const isSel = selSet.has(key);
|
|
||||||
|
|
||||||
ctx.globalAlpha = isVisible ? 1 : 0.08;
|
ctx.globalAlpha = isVisible ? 1 : 0.08;
|
||||||
ctx.fillStyle = isSel ? '#16213e' : '#0f3460';
|
ctx.fillStyle = isSel ? '#16213e' : '#0f3460';
|
||||||
ctx.beginPath();
|
ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2); ctx.fill();
|
||||||
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
if (isSel && isVisible) {
|
if (isSel && isVisible) {
|
||||||
ctx.strokeStyle = '#fff';
|
ctx.strokeStyle = '#fff'; ctx.lineWidth = 3;
|
||||||
ctx.lineWidth = 3;
|
ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2); ctx.stroke();
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
ctx.fillStyle = '#fff'; ctx.font = 'bold 30px sans-serif';
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.font = 'bold 30px sans-serif';
|
|
||||||
ctx.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
|
ctx.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
wordDisplay.textContent = selected.map(([x, y]) => board[x][y]).join('');
|
wordDisplay.textContent = selected.map(([x, y]) => board[x][y]).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,110 +345,51 @@
|
|||||||
if (flashTimer) clearTimeout(flashTimer);
|
if (flashTimer) clearTimeout(flashTimer);
|
||||||
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
|
const selSet = new Set(selected.map(([x, y]) => cellKey(x, y)));
|
||||||
const flashSet = new Set((path || selected).map(([x, y]) => cellKey(x, y)));
|
const flashSet = new Set((path || selected).map(([x, y]) => cellKey(x, y)));
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const activeCells = getActiveCells();
|
const activeCells = getActiveCells();
|
||||||
const activeConns = getActiveConnections();
|
const activeConns = getActiveConnections();
|
||||||
|
ctx.strokeStyle = '#2a4a7f'; ctx.lineWidth = GAP - 6; ctx.lineCap = 'round'; ctx.globalAlpha = 0.6;
|
||||||
// Draw connections
|
|
||||||
ctx.strokeStyle = '#2a4a7f';
|
|
||||||
ctx.lineWidth = GAP - 6;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.globalAlpha = 0.6;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
for (const key of activeConns) {
|
for (const key of activeConns) {
|
||||||
const [ka, kb] = key.split('-');
|
const [ka, kb] = key.split('-');
|
||||||
const [ax, ay] = ka.split(',').map(Number);
|
const [ax, ay] = ka.split(',').map(Number);
|
||||||
const [bx, by] = kb.split(',').map(Number);
|
const [bx, by] = kb.split(',').map(Number);
|
||||||
const c1 = cellCenter(ax, ay);
|
const c1 = cellCenter(ax, ay), c2 = cellCenter(bx, by);
|
||||||
const c2 = cellCenter(bx, by);
|
const t1 = CELL / (2 * CELL_PITCH), t2 = 1 - t1;
|
||||||
const t1 = CELL / (2 * CELL_PITCH);
|
ctx.moveTo(c1.x + (c2.x - c1.x) * t1, c1.y + (c2.y - c1.y) * t1);
|
||||||
const t2 = 1 - CELL / (2 * CELL_PITCH);
|
ctx.lineTo(c1.x + (c2.x - c1.x) * t2, c1.y + (c2.y - c1.y) * t2);
|
||||||
const x1 = c1.x + (c2.x - c1.x) * t1;
|
|
||||||
const y1 = c1.y + (c2.y - c1.y) * t1;
|
|
||||||
const x2 = c1.x + (c2.x - c1.x) * t2;
|
|
||||||
const y2 = c1.y + (c2.y - c1.y) * t2;
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke(); ctx.globalAlpha = 1;
|
||||||
ctx.globalAlpha = 1;
|
if (selected.length > 1) drawPath(selected, color, GAP - 2, 0.9);
|
||||||
|
|
||||||
// Draw selected path
|
|
||||||
if (selected.length > 1) {
|
|
||||||
drawPath(selected, color, GAP - 2, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw cells with flash tint
|
|
||||||
for (let x = 0; x < GRID; x++) {
|
for (let x = 0; x < GRID; x++) {
|
||||||
for (let y = 0; y < GRID; y++) {
|
for (let y = 0; y < GRID; y++) {
|
||||||
const cx = OFFSET_X + x * CELL_PITCH;
|
const cx = OFFSET_X + x * CELL_PITCH, cy = OFFSET_Y + y * CELL_PITCH;
|
||||||
const cy = OFFSET_Y + y * CELL_PITCH;
|
|
||||||
const key = cellKey(x, y);
|
const key = cellKey(x, y);
|
||||||
const isSel = selSet.has(key);
|
const isSel = selSet.has(key);
|
||||||
const isActive = activeCells.has(key);
|
const isActive = activeCells.has(key);
|
||||||
|
|
||||||
ctx.globalAlpha = flashSet.has(key) ? 1 : (isActive ? 1 : 0.08);
|
ctx.globalAlpha = flashSet.has(key) ? 1 : (isActive ? 1 : 0.08);
|
||||||
ctx.fillStyle = flashSet.has(key) ? shadeColor(color, -40) : '#0f3460';
|
ctx.fillStyle = flashSet.has(key) ? shadeColor(color, -40) : '#0f3460';
|
||||||
ctx.beginPath();
|
ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2); ctx.fill();
|
||||||
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
if (flashSet.has(key)) {
|
if (flashSet.has(key)) {
|
||||||
ctx.strokeStyle = color;
|
ctx.strokeStyle = color; ctx.lineWidth = 4;
|
||||||
ctx.lineWidth = 4;
|
ctx.beginPath(); ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2); ctx.stroke();
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx + CELL / 2, cy + CELL / 2, CELL / 2 + 2, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
ctx.fillStyle = '#fff'; ctx.font = 'bold 30px sans-serif';
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.font = 'bold 30px sans-serif';
|
|
||||||
ctx.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
|
ctx.fillText(board[x][y].toUpperCase(), cx + CELL / 2, cy + CELL / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
flashTimer = setTimeout(() => { selected = []; draw(); }, 500);
|
||||||
flashTimer = setTimeout(() => {
|
|
||||||
selected = [];
|
|
||||||
draw();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function shadeColor(color, percent) {
|
|
||||||
let R = parseInt(color.substring(1, 3), 16);
|
|
||||||
let G = parseInt(color.substring(3, 5), 16);
|
|
||||||
let B = parseInt(color.substring(5, 7), 16);
|
|
||||||
R = parseInt(R * (100 + percent) / 100);
|
|
||||||
G = parseInt(G * (100 + percent) / 100);
|
|
||||||
B = parseInt(B * (100 + percent) / 100);
|
|
||||||
R = (R < 255) ? R : 255;
|
|
||||||
G = (G < 255) ? G : 255;
|
|
||||||
B = (B < 255) ? B : 255;
|
|
||||||
R = Math.round(R < 0 ? 0 : R);
|
|
||||||
G = Math.round(G < 0 ? 0 : G);
|
|
||||||
B = Math.round(B < 0 ? 0 : B);
|
|
||||||
return '#' + (R.toString(16).padStart(2, '0')) + (G.toString(16).padStart(2, '0')) + (B.toString(16).padStart(2, '0'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWordColor(word) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < word.length; i++) {
|
|
||||||
hash = word.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFound() {
|
function renderFound() {
|
||||||
foundDiv.innerHTML = '<h2>Найдено (' + foundWords.size + ')</h2>';
|
foundDiv.innerHTML = '<h2>Найдено (' + foundWords.size + ')</h2>';
|
||||||
for (const word of foundWords) {
|
for (const word of foundWords) {
|
||||||
const color = getWordColor(word);
|
const color = getWordColor(word);
|
||||||
const def = word_defs[word] || '';
|
const def = wordDefs[word] || '';
|
||||||
const isExtra = word in extraWords;
|
const isExtra = word in extraWords;
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'word-item';
|
item.className = 'word-item'; item.style.opacity = isExtra ? '0.6' : '1';
|
||||||
item.style.opacity = isExtra ? '0.6' : '1';
|
|
||||||
item.innerHTML =
|
item.innerHTML =
|
||||||
'<div class="word-item-inner"><span class="word-color" style="background:' + color + '"></span><span class="word-text">' + word + '</span></div>' +
|
'<div class="word-item-inner"><span class="word-color" style="background:' + color + '"></span><span class="word-text">' + word + '</span></div>' +
|
||||||
(def ? '<span class="word-def">— ' + def + '</span>' : '');
|
(def ? '<span class="word-def">— ' + def + '</span>' : '');
|
||||||
@@ -495,159 +403,208 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(text, color) {
|
function showMessage(text, color) {
|
||||||
msgEl.textContent = text;
|
msgEl.textContent = text; msgEl.style.color = color || '#feca57';
|
||||||
msgEl.style.color = color || '#feca57';
|
|
||||||
setTimeout(() => { msgEl.textContent = ''; }, 2000);
|
setTimeout(() => { msgEl.textContent = ''; }, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== Game Logic =====================
|
||||||
function checkWord() {
|
function checkWord() {
|
||||||
if (selected.length < 4) return;
|
if (selected.length < 4) return;
|
||||||
|
|
||||||
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
|
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
|
||||||
|
|
||||||
// Already found
|
|
||||||
if (foundWords.has(word)) {
|
if (foundWords.has(word)) {
|
||||||
// Check if it's a prefix of an unfound word
|
|
||||||
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
|
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
|
||||||
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
|
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
|
||||||
if (isPrefixOfUnfound) return;
|
if (isPrefixOfUnfound) return;
|
||||||
showMessage('Уже найдено!', '#feca57');
|
showMessage('Уже найдено!', '#feca57');
|
||||||
selected = [];
|
selected = []; draw(); return;
|
||||||
draw();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid solution word
|
|
||||||
if (word in allSolutions) {
|
if (word in allSolutions) {
|
||||||
foundWords.add(word);
|
foundWords.add(word);
|
||||||
foundPaths[word] = allSolutions[word];
|
|
||||||
flashPath(allSolutions[word], '#2ed573');
|
flashPath(allSolutions[word], '#2ed573');
|
||||||
renderFound();
|
renderFound(); updateStatus(); save(); return;
|
||||||
updateStatus();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valid extra word
|
|
||||||
if (word in extraWords) {
|
if (word in extraWords) {
|
||||||
foundWords.add(word);
|
foundWords.add(word);
|
||||||
foundPaths[word] = extraWords[word];
|
|
||||||
flashPath(extraWords[word], '#feca57');
|
flashPath(extraWords[word], '#feca57');
|
||||||
renderFound();
|
renderFound(); showMessage('Бонус!', '#feca57'); save(); return;
|
||||||
showMessage('Бонус!', '#feca57');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not a valid word, silently ignore (user still building)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateWord() {
|
function validateWord() {
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
|
const word = selected.map(([x, y]) => board[x][y]).join('').toLowerCase();
|
||||||
|
|
||||||
// Already found
|
|
||||||
if (foundWords.has(word)) {
|
if (foundWords.has(word)) {
|
||||||
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
|
const allWords = Object.keys(allSolutions).concat(Object.keys(extraWords));
|
||||||
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
|
const isPrefixOfUnfound = allWords.some(w => !foundWords.has(w) && w.length > word.length && w.startsWith(word));
|
||||||
if (isPrefixOfUnfound) return;
|
if (isPrefixOfUnfound) return;
|
||||||
showMessage('Уже найдено!', '#feca57');
|
showMessage('Уже найдено!', '#feca57');
|
||||||
selected = [];
|
selected = []; draw(); return;
|
||||||
draw();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (selected.length < 4) { showMessage('Мало букв — нужно минимум 4', '#ff6b6b'); selected = []; draw(); return; }
|
||||||
// Too short
|
if (!(word in allSolutions) && !(word in extraWords)) { showMessage('Слово не найдено!', '#ff6b6b'); selected = []; draw(); return; }
|
||||||
if (selected.length < 4) {
|
|
||||||
showMessage('Мало букв — нужно минимум 4', '#ff6b6b');
|
|
||||||
selected = [];
|
|
||||||
draw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found
|
|
||||||
if (!(word in allSolutions) && !(word in extraWords)) {
|
|
||||||
showMessage('Слово не найдено!', '#ff6b6b');
|
|
||||||
selected = [];
|
|
||||||
draw();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showMessage('Верно!', '#2ed573');
|
showMessage('Верно!', '#2ed573');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
function handleCellClick(mx, my) {
|
function handleCellClick(mx, my) {
|
||||||
const col = Math.floor((mx - OFFSET_X) / CELL_PITCH);
|
const col = Math.floor((mx - OFFSET_X) / CELL_PITCH);
|
||||||
const row = Math.floor((my - OFFSET_Y) / CELL_PITCH);
|
const row = Math.floor((my - OFFSET_Y) / CELL_PITCH);
|
||||||
|
|
||||||
if (col < 0 || col >= GRID || row < 0 || row >= GRID) return false;
|
if (col < 0 || col >= GRID || row < 0 || row >= GRID) return false;
|
||||||
|
|
||||||
// Deselect if clicking already selected tile
|
|
||||||
const idx = selected.findIndex(s => s[0] === col && s[1] === row);
|
const idx = selected.findIndex(s => s[0] === col && s[1] === row);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) { selected = selected.slice(0, idx); draw(); return true; }
|
||||||
selected = selected.slice(0, idx);
|
|
||||||
draw();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check connection
|
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
if (!isAdjacentAndConnected(selected[selected.length - 1], [col, row])) return false;
|
if (!isAdjacentAndConnected(selected[selected.length - 1], [col, row])) return false;
|
||||||
|
} else {
|
||||||
|
if (!getActiveCells().has(cellKey(col, row))) return false;
|
||||||
}
|
}
|
||||||
selected.push([col, row]);
|
selected.push([col, row]);
|
||||||
|
draw(); checkWord(); return true;
|
||||||
draw();
|
|
||||||
checkWord();
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _pointerDown = false;
|
// ===================== Timer =====================
|
||||||
|
function startTimer() {
|
||||||
|
if (timerInterval) clearInterval(timerInterval);
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
const remaining = Object.keys(allSolutions).filter(w => !foundWords.has(w)).length;
|
||||||
|
if (remaining === 0) {
|
||||||
|
clearInterval(timerInterval); timerInterval = null;
|
||||||
|
gameTimeEl.textContent = formatTime(elapsed);
|
||||||
|
gameCompleteEl.classList.add('show');
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTimer() {
|
||||||
|
if (timerInterval) clearInterval(timerInterval); timerInterval = null;
|
||||||
|
elapsed = 0;
|
||||||
|
lastSaveTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Load Puzzle =====================
|
||||||
|
async function loadPuzzle(number) {
|
||||||
|
statusEl.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) { statusEl.textContent = 'Занграмма не найдена'; return; }
|
||||||
|
data = await res.json();
|
||||||
|
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
|
||||||
|
puzzleNumber = data.number;
|
||||||
|
} else {
|
||||||
|
const res = await fetch('/api/puzzles/daily');
|
||||||
|
data = await res.json();
|
||||||
|
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
|
||||||
|
puzzleNumber = data.number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
statusEl.textContent = 'Возобновление...';
|
||||||
|
} else {
|
||||||
|
foundWords = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
board = data.board;
|
||||||
|
allSolutions = data.solutions || {};
|
||||||
|
extraWords = data.extra || {};
|
||||||
|
wordDefs = data.word_defs || {};
|
||||||
|
selected = [];
|
||||||
|
initBoard(); draw(); renderFound(); updateStatus();
|
||||||
|
|
||||||
|
if (foundWords.size > 0 && foundWords.size === Object.keys(allSolutions).length) {
|
||||||
|
gameTimeEl.textContent = formatTime(elapsed);
|
||||||
|
gameCompleteEl.classList.add('show');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Random Puzzle =====================
|
||||||
|
async function loadRandomPuzzle() {
|
||||||
|
gameCompleteEl.classList.remove('show');
|
||||||
|
statusEl.textContent = 'Генерация...';
|
||||||
|
resetTimer();
|
||||||
|
puzzleNumber = null;
|
||||||
|
const res = await fetch('/api/puzzles/random');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
|
||||||
|
foundWords = new Set();
|
||||||
|
board = data.board;
|
||||||
|
allSolutions = data.solutions || {};
|
||||||
|
extraWords = data.extra || {};
|
||||||
|
wordDefs = data.word_defs || {};
|
||||||
|
selected = [];
|
||||||
|
initBoard(); draw(); 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\ud83c\udf89 Мне потребовалось всего лишь ' + formatTime(elapsed) + '\n\n' + window.location.href;
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => showMessage('Результат скопирован!', '#2ed573'),
|
||||||
|
() => showMessage('Не удалось скопировать', '#ff6b6b')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Events =====================
|
||||||
|
wordDisplay.addEventListener('click', validateWord);
|
||||||
|
$('copyResultBtn').addEventListener('click', copyResult);
|
||||||
|
$('prevPuzzlesBtn').addEventListener('click', showNumberSelector);
|
||||||
|
$('randomPuzzleBtn').addEventListener('click', loadRandomPuzzle);
|
||||||
|
$('closeNumberBtn').addEventListener('click', () => numberSelectorEl.classList.remove('show'));
|
||||||
|
|
||||||
canvas.addEventListener('pointerdown', (e) => {
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_pointerDown = true;
|
|
||||||
const r = canvas.getBoundingClientRect();
|
const r = canvas.getBoundingClientRect();
|
||||||
handleCellClick(e.clientX - r.left, e.clientY - r.top);
|
handleCellClick(e.clientX - r.left, e.clientY - r.top);
|
||||||
});
|
});
|
||||||
|
canvas.addEventListener('pointerup', () => {});
|
||||||
canvas.addEventListener('pointerup', (e) => {
|
|
||||||
_pointerDown = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.addEventListener('pointercancel', (e) => {
|
canvas.addEventListener('pointercancel', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
_pointerDown = false;
|
selected = []; draw();
|
||||||
selected = [];
|
|
||||||
draw();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPuzzle() {
|
// ===================== Init =====================
|
||||||
statusEl.textContent = 'Генерация...';
|
|
||||||
gameCompleteEl.classList.remove('show');
|
|
||||||
resetTimer();
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/puzzles/random');
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.error) { statusEl.textContent = 'Ошибка: ' + data.error; return; }
|
|
||||||
board = data.board;
|
|
||||||
allSolutions = data.solutions;
|
|
||||||
extraWords = data.extra || {};
|
|
||||||
word_defs = data.word_defs || {};
|
|
||||||
selected = [];
|
|
||||||
foundWords = new Set();
|
|
||||||
foundPaths = {};
|
|
||||||
initBoard();
|
|
||||||
draw();
|
|
||||||
renderFound();
|
|
||||||
updateStatus();
|
|
||||||
startTimer();
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.textContent = 'Не удалось загрузить головоломку';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wordDisplay.addEventListener('click', validateWord);
|
|
||||||
|
|
||||||
loadPuzzle();
|
loadPuzzle();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,3 +8,9 @@ class PuzzleOutputDTO(BaseModel):
|
|||||||
solutions: dict[str, list[tuple[int, int]]]
|
solutions: dict[str, list[tuple[int, int]]]
|
||||||
extra: dict[str, list[tuple[int, int]]]
|
extra: dict[str, list[tuple[int, int]]]
|
||||||
word_defs: dict[str, str]
|
word_defs: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class DailyPuzzleDTO(PuzzleOutputDTO):
|
||||||
|
"""Daily puzzles DTO."""
|
||||||
|
|
||||||
|
number: int
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
from fastapi import APIRouter
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import PositiveInt
|
||||||
|
from starlette import status
|
||||||
|
|
||||||
|
from zangramru.db.models.puzzle import Puzzle
|
||||||
from zangramru.services.puzzles import PuzzleGeneratorDep
|
from zangramru.services.puzzles import PuzzleGeneratorDep
|
||||||
|
from zangramru.settings import settings
|
||||||
|
|
||||||
from .schema import PuzzleOutputDTO
|
from .schema import DailyPuzzleDTO, PuzzleOutputDTO
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -10,5 +17,55 @@ router = APIRouter()
|
|||||||
@router.get("/random")
|
@router.get("/random")
|
||||||
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
|
def random_puzzle(generator: PuzzleGeneratorDep) -> PuzzleOutputDTO:
|
||||||
"""Generate a random puzzle."""
|
"""Generate a random puzzle."""
|
||||||
puzzle = generator.generate(target=10, max_attempts=500)
|
puzzle = generator.generate(target=10)
|
||||||
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
|
return PuzzleOutputDTO.model_validate(puzzle, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily")
|
||||||
|
async def daily_puzzle(
|
||||||
|
generator: PuzzleGeneratorDep,
|
||||||
|
number: PositiveInt | None = None,
|
||||||
|
) -> DailyPuzzleDTO:
|
||||||
|
"""Generate a random puzzle."""
|
||||||
|
# Because they start at one, so we need to subtract one.
|
||||||
|
target_date = date.today()
|
||||||
|
if number is not None:
|
||||||
|
target_date = settings.start_date + timedelta(days=number - 1)
|
||||||
|
|
||||||
|
if target_date > date.today():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Doesn't exist",
|
||||||
|
)
|
||||||
|
|
||||||
|
number = (target_date - settings.start_date).days
|
||||||
|
|
||||||
|
daily_puzzle = await Puzzle.objects().where(Puzzle.number == number).first()
|
||||||
|
|
||||||
|
if daily_puzzle is not None:
|
||||||
|
board = [list(daily_puzzle.board[i * 4 : i * 4 + 4]) for i in range(4)]
|
||||||
|
return DailyPuzzleDTO(
|
||||||
|
number=(date.today() - settings.start_date).days,
|
||||||
|
board=board,
|
||||||
|
solutions=json.loads(daily_puzzle.solutions),
|
||||||
|
extra=json.loads(daily_puzzle.extra),
|
||||||
|
word_defs=json.loads(daily_puzzle.definitions),
|
||||||
|
)
|
||||||
|
puzzle = generator.generate(target=10)
|
||||||
|
compressedboard = "".join("".join(row) for row in puzzle.board)
|
||||||
|
await Puzzle(
|
||||||
|
{
|
||||||
|
Puzzle.board: compressedboard,
|
||||||
|
Puzzle.number: number,
|
||||||
|
Puzzle.solutions: json.dumps(puzzle.solutions),
|
||||||
|
Puzzle.definitions: json.dumps(puzzle.word_defs),
|
||||||
|
Puzzle.extra: json.dumps(puzzle.extra),
|
||||||
|
}
|
||||||
|
).save()
|
||||||
|
return DailyPuzzleDTO(
|
||||||
|
number=number,
|
||||||
|
board=puzzle.board,
|
||||||
|
solutions=puzzle.solutions,
|
||||||
|
extra=puzzle.extra,
|
||||||
|
word_defs=puzzle.word_defs,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user