Add PHP server version: leaderboard, identity, menu screens, lore

- php/: MySQL-backed (via config.php, gitignored) top-5 solutions per
  level, cookie identity with rename, server-side progress for "Продолжить"
- Menu screens: Играть / Продолжить / Предыстория / Настройки, plus
  placeholder start/end screens for a future intro/outro
- Lore screen: EXO:GRID backstory text (6 epochs, no gameplay changes yet)
- levels.js/solver.js copied in as-is
This commit is contained in:
Alex Cube
2026-08-10 15:18:53 +03:00
parent 50623173bb
commit 6006ad0690
7 changed files with 2858 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
data/
+119
View File
@@ -0,0 +1,119 @@
<?php
// api.php — весь бэкенд одним файлом с роутингом по ?action=. Никакого
// фреймворка не нужно на 4 маленьких эндпоинта.
require __DIR__ . '/db.php';
header('Content-Type: application/json; charset=utf-8');
$pdo = get_db();
$action = $_GET['action'] ?? '';
function read_json_body(): array {
$data = json_decode(file_get_contents('php://input'), true);
return is_array($data) ? $data : [];
}
function top5(PDO $pdo, string $level): array {
$stmt = $pdo->prepare('
SELECT s.move_count, s.source, s.created_at, u.name
FROM solutions s JOIN users u ON u.id = s.user_id
WHERE s.level_name = ?
ORDER BY s.move_count ASC
LIMIT 5
');
$stmt->execute([$level]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
switch ($action) {
case 'whoami': {
$u = current_user($pdo);
echo json_encode($u ? ['name' => $u['name'], 'lastLevel' => (int)$u['last_level']] : ['name' => null, 'lastLevel' => 0]);
break;
}
case 'save_progress': {
$u = current_user($pdo);
if (!$u) { http_response_code(401); echo json_encode(['error' => 'no user']); break; }
$body = read_json_body();
$level = (int)($body['level'] ?? -1);
if ($level < 0) { http_response_code(400); echo json_encode(['error' => 'bad level']); break; }
$pdo->prepare('UPDATE users SET last_level = ? WHERE id = ?')->execute([$level, $u['id']]);
echo json_encode(['ok' => true]);
break;
}
case 'set_name': {
$body = read_json_body();
$name = trim(mb_substr((string)($body['name'] ?? ''), 0, 40));
if ($name === '') { http_response_code(400); echo json_encode(['error' => 'empty name']); break; }
$u = current_user($pdo);
if ($u) {
$pdo->prepare('UPDATE users SET name = ? WHERE id = ?')->execute([$name, $u['id']]);
} else {
$token = bin2hex(random_bytes(16));
$pdo->prepare('INSERT INTO users (token, name, created_at) VALUES (?, ?, ?)')
->execute([$token, $name, time()]);
setcookie('sokoban_token', $token, time() + 60*60*24*365, '/', '', false, true);
}
echo json_encode(['name' => $name]);
break;
}
case 'top': {
$level = (string)($_GET['level'] ?? '');
echo json_encode($level === '' ? [] : top5($pdo, $level));
break;
}
case 'best': {
$level = (string)($_GET['level'] ?? '');
$stmt = $pdo->prepare('SELECT moves FROM solutions WHERE level_name = ? ORDER BY move_count ASC LIMIT 1');
$stmt->execute([$level]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
echo json_encode($row ? ['moves' => json_decode($row['moves'])] : null);
break;
}
case 'submit': {
$u = current_user($pdo);
if (!$u) { http_response_code(401); echo json_encode(['error' => 'no user']); break; }
$body = read_json_body();
$level = (string)($body['level'] ?? '');
$moves = $body['moves'] ?? null;
$source = ($body['source'] ?? 'player') === 'solver' ? 'solver' : 'player';
if ($level === '' || !is_array($moves) || !count($moves) || count($moves) > 20000) {
http_response_code(400); echo json_encode(['error' => 'bad payload']); break;
}
// каждый ход — пара из {-1,0,1}, ничего больше сюда залететь не должно
foreach ($moves as $m) {
if (!is_array($m) || count($m) !== 2 || !in_array($m[0], [-1,0,1], true) || !in_array($m[1], [-1,0,1], true)) {
http_response_code(400); echo json_encode(['error' => 'bad move']); break 2;
}
}
$moveKey = md5(implode('|', array_map(function($m) { return $m[0] . ',' . $m[1]; }, $moves)));
try {
$pdo->prepare('INSERT INTO solutions (level_name, user_id, moves, move_count, move_key, source, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
->execute([$level, $u['id'], json_encode($moves), count($moves), $moveKey, $source, time()]);
} catch (PDOException $e) {
// UNIQUE(level_name, move_key) — та же последовательность уже есть, не ошибка
}
// держим только топ-5 по числу ходов на уровень
$pdo->prepare('
DELETE FROM solutions WHERE level_name = ? AND id NOT IN (
SELECT id FROM solutions WHERE level_name = ? ORDER BY move_count ASC LIMIT 5
)
')->execute([$level, $level]);
echo json_encode(top5($pdo, $level));
break;
}
default:
http_response_code(404);
echo json_encode(['error' => 'unknown action']);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
// db.php — MySQL, БД уже создана на сервере (панель Тенетты), не SQLite.
// Схема создаётся сама при первом обращении, руками ничего накатывать не
// нужно — залил файлы и работает.
function get_db(): PDO {
$cfg = require __DIR__ . '/config.php';
$dsn = "mysql:host={$cfg['host']};dbname={$cfg['db']};charset=utf8mb4";
$pdo = new PDO($dsn, $cfg['user'], $cfg['pass']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) UNIQUE NOT NULL,
name VARCHAR(40) NOT NULL,
last_level INT NOT NULL DEFAULT 0,
created_at INT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
// move_key хранится как md5-хеш последовательности ходов (фикс. длина
// 32 символа) — а не сырой склеенной строкой, у которой длина растёт
// с числом ходов и может упереться в лимит длины ключа InnoDB-индекса.
$pdo->exec('CREATE TABLE IF NOT EXISTS solutions (
id INT AUTO_INCREMENT PRIMARY KEY,
level_name VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
moves MEDIUMTEXT NOT NULL,
move_count INT NOT NULL,
move_key CHAR(32) NOT NULL,
source VARCHAR(16) NOT NULL DEFAULT "player",
created_at INT NOT NULL,
UNIQUE KEY uniq_level_movekey (level_name, move_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
// MySQL/MariaDB не везде понимают "CREATE INDEX IF NOT EXISTS" — просто
// глотаем "уже есть" при повторных запусках.
try { $pdo->exec('CREATE INDEX idx_level_count ON solutions(level_name, move_count)'); }
catch (PDOException $e) {}
return $pdo;
}
// Идентичность — долгий cookie (год) с токеном, не PHP-сессия: имя должно
// помниться неделями, а не до закрытия вкладки.
function current_user(PDO $pdo): ?array {
if (empty($_COOKIE['sokoban_token'])) return null;
$stmt = $pdo->prepare('SELECT * FROM users WHERE token = ?');
$stmt->execute([$_COOKIE['sokoban_token']]);
$u = $stmt->fetch(PDO::FETCH_ASSOC);
return $u ?: null;
}
+2178
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
// levels.js — парсер оригинального дампа Сокобан
const LEVELS = (function() {
const dataEl = document.getElementById('levels-data');
if (!dataEl) throw new Error('Данные уровней не найдены. Добавь <script id="levels-data">');
const raw = dataEl.textContent;
const levels = [];
// Разбиваем по разделителю *****
const blocks = raw.split(/\*\*+\n/);
let currentMaze = null;
let headerLines = [];
for (const block of blocks) {
const lines = block.split('\n');
// Ищем заголовок Maze
for (const line of lines) {
const mazeMatch = line.match(/^Maze:\s*(\d+)/);
if (mazeMatch) {
// Сохраняем предыдущий уровень
if (currentMaze && currentMaze.mapLines.length > 0) {
finalizeLevel(currentMaze, levels);
}
currentMaze = {
num: parseInt(mazeMatch[1]),
cols: 0,
rows: 0,
headerLines: [],
mapLines: []
};
}
if (currentMaze) {
const sizeXMatch = line.match(/Size X:\s*(\d+)/);
const sizeYMatch = line.match(/Size Y:\s*(\d+)/);
if (sizeXMatch) currentMaze.cols = parseInt(sizeXMatch[1]);
if (sizeYMatch) currentMaze.rows = parseInt(sizeYMatch[1]);
// Всё после "Length:" и до следующего "Maze:" или "***" — это карта
if (line.match(/^Length:/)) {
currentMaze.inMap = true;
continue;
}
if (currentMaze.inMap && !line.match(/^Maze:/) && !line.match(/^\*+/)) {
// Проверяем, что строка содержит символы карты
if (line.match(/^[ X.*@$&+\-]+$/) || line.trim() === '') {
currentMaze.mapLines.push(line);
}
}
}
}
}
// Последний уровень
if (currentMaze && currentMaze.mapLines.length > 0) {
finalizeLevel(currentMaze, levels);
}
function finalizeLevel(maze, levelsArr) {
// Убираем пустые строки в начале и конце
while (maze.mapLines.length > 0 && maze.mapLines[0].trim() === '') {
maze.mapLines.shift();
}
while (maze.mapLines.length > 0 && maze.mapLines[maze.mapLines.length-1].trim() === '') {
maze.mapLines.pop();
}
if (maze.mapLines.length === 0) {
console.warn(`Maze ${maze.num}: пустая карта`);
return;
}
// Дополняем строки до cols пробелами
const paddedLines = maze.mapLines.map(line => {
if (line.length < maze.cols) {
return line + ' '.repeat(maze.cols - line.length);
}
return line.substring(0, maze.cols);
});
// Если строк меньше rows — добавляем пустые
while (paddedLines.length < maze.rows) {
paddedLines.push(' '.repeat(maze.cols));
}
const map = [];
let player = null;
const boxes = [];
const targets = [];
for (let y = 0; y < maze.rows; y++) {
const row = [];
const line = paddedLines[y] || ' '.repeat(maze.cols);
for (let x = 0; x < maze.cols; x++) {
const ch = line[x] || ' ';
switch (ch) {
case 'X': row.push(1); break; // стена
case '.': row.push(2); targets.push({x, y}); break; // цель
case '*': row.push(0); boxes.push({x, y}); break; // ящик на полу
case '$': row.push(2); boxes.push({x, y}); targets.push({x, y}); break; // ящик на цели
case '@': row.push(0); player = {x, y}; break; // игрок на полу
case '+': row.push(2); player = {x, y}; targets.push({x, y}); break; // игрок на цели
case '&': row.push(0); break; // спец. пол
default: row.push(0); break; // пробел = пол
}
}
map.push(row);
}
if (!player) {
console.warn(`Maze ${maze.num}: игрок не найден`);
return;
}
boxes.sort((a, b) => a.y - b.y || a.x - b.x);
levelsArr.push({
name: `Maze ${maze.num}`,
cols: maze.cols,
rows: maze.rows,
map,
player: [player.x, player.y],
boxes: boxes.map(b => [b.x, b.y])
});
}
console.log(`✅ Загружено ${levels.length} уровней`);
return levels;
})();
+373
View File
@@ -0,0 +1,373 @@
// solver.js — автосолвер для Сокобана
//
// createSolver() делает всю разовую подготовку (deadlock-клетки, куча,
// стартовое состояние) и возвращает объект с .step(budgetMs) — прогоняет
// поиск максимум budgetMs миллисекунд и возвращает управление. Так UI может
// показывать таймер/крутилку и дать кнопку отмены, не подвешивая вкладку —
// без Web Worker, потому что игра открывается как file://, а в Chrome оттуда
// воркеры не грузятся (CORS).
//
// solveCurrentLevel() — старый синхронный вход, гоняет step() до конца сам;
// оставлен для обратной совместимости (тесты, консоль).
function createSolver(levelData, playerX, playerY) {
const { map, boxes: startBoxesArr } = levelData;
const cols = map[0].length;
const rows = map.length;
// Собираем цели
const targetSet = new Set();
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (map[y][x] === 2) targetSet.add(`${x},${y}`);
}
}
function isWall(x, y) {
if (x < 0 || x >= cols || y < 0 || y >= rows) return true;
return map[y][x] === 1;
}
// Статика: клетки, из которых ящик вообще может попасть на какую-то цель —
// считаем "оттягиванием" от каждой цели назад, игнорируя другие ящики.
// Ящик вне этого множества — гарантированный deadlock, отсекаем сразу.
// Это стандартный и намного более сильный фильтр, чем только угловой чек.
const liveCells = new Set();
for (const tkey of targetSet) {
const [tx, ty] = tkey.split(',').map(Number);
const q = [[tx, ty]];
liveCells.add(tkey);
while (q.length) {
const [x, y] = q.shift();
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const px = x - dx, py = y - dy; // откуда ящик мог прийти
const behind = x - 2*dx, py2 = y - 2*dy; // где стоял игрок, чтобы толкнуть
if (isWall(px, py) || isWall(behind, py2)) continue;
const key = `${px},${py}`;
if (liveCells.has(key)) continue;
liveCells.add(key);
q.push([px, py]);
}
}
}
// Freeze-deadlock: ящик неподвижен по оси, если ХОТЯ БЫ С ОДНОЙ стороны —
// стена, или ящик, который сам неподвижен по ОБЕИМ осям (значит не
// сдвинется никогда и не освободит клетку). Стена с одной стороны глушит
// толчок в обе стороны сразу: толкнуть в неё нельзя (упрётся), толкнуть
// от неё тоже нельзя (там некуда встать игроку). Ловит не только одиночный
// ящик в углу, но и несколько ящиков, взаимно блокирующих друг друга —
// ровно тот случай "два ящика застряли рядом в коридоре", который старая
// угловая проверка не видела, потому что каждый ящик по отдельности не в
// углу. Рекурсия с защитой от циклов (stack) — ящики, упирающиеся друг в
// друга по кругу и не имеющие независимого выхода, взаимно замороженные.
function isFrozenAxis(boxList, posToIdx, x, y, axis, stack, memo) {
const key = `${x},${y}|${axis}`;
if (memo.has(key)) return memo.get(key);
if (stack.has(key)) return true;
stack.add(key);
function sideBlocks(sx, sy) {
if (isWall(sx, sy)) return true;
const idx = posToIdx.get(`${sx},${sy}`);
if (idx === undefined) return false;
const nb = boxList[idx];
return isFrozenAxis(boxList, posToIdx, nb.x, nb.y, 'x', stack, memo) &&
isFrozenAxis(boxList, posToIdx, nb.x, nb.y, 'y', stack, memo);
}
const result = axis === 'x'
? sideBlocks(x-1, y) || sideBlocks(x+1, y)
: sideBlocks(x, y-1) || sideBlocks(x, y+1);
stack.delete(key);
memo.set(key, result);
return result;
}
function isDeadlock(boxList) {
const posToIdx = new Map();
boxList.forEach((b, i) => posToIdx.set(`${b.x},${b.y}`, i));
const memo = new Map();
for (const b of boxList) {
const key = `${b.x},${b.y}`;
if (targetSet.has(key)) continue;
if (!liveCells.has(key)) return true;
if (isFrozenAxis(boxList, posToIdx, b.x, b.y, 'x', new Set(), memo) &&
isFrozenAxis(boxList, posToIdx, b.x, b.y, 'y', new Set(), memo)) return true;
}
return false;
}
function getReachable(px, py, boxSet) {
const visited = new Set();
const queue = [{x: px, y: py}];
visited.add(`${px},${py}`);
const reachable = [];
while (queue.length) {
const {x, y} = queue.shift();
reachable.push({x, y});
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const nx = x+dx, ny = y+dy;
const key = `${nx},${ny}`;
if (!isWall(nx, ny) && !boxSet.has(key) && !visited.has(key)) {
visited.add(key);
queue.push({x: nx, y: ny});
}
}
}
return new Set(reachable.map(p => `${p.x},${p.y}`));
}
function stateKey(px, py, boxList) {
const sorted = boxList.map(b => `${b.x},${b.y}`).sort().join('|');
return `${px},${py}|${sorted}`;
}
// Туннели: клетка ровно с двумя проходимыми соседями по прямой (коридор
// шириной в одну клетку, без развилок). Толкая ящик туда, у игрока нет
// выбора — либо толкать дальше в ту же сторону, либо не толкать вовсе,
// так что каждую промежуточную клетку отдельным состоянием перебирать
// бессмысленно (доказано в теории Sokoban — оставить ящик посреди такого
// коридора никогда не выгоднее, чем протолкнуть его до конца). Считаем
// геометрию один раз на уровень.
const tunnelSet = new Set();
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (isWall(x, y)) continue;
const open = [[0,-1],[0,1],[-1,0],[1,0]].filter(([dx,dy]) => !isWall(x+dx, y+dy));
if (open.length === 2 && open[0][0] === -open[1][0] && open[0][1] === -open[1][1]) {
tunnelSet.add(`${x},${y}`);
}
}
}
// Толкаем ящик b в направлении (dx,dy). Молча едем дальше БЕЗ отдельной
// точки остановки только если клетка впереди — тоже чистое туннельное
// продолжение (не развилка, не стена/ящик, не цель). Останавливаться
// ПЕРЕД развилкой и заезжать НА развилку — разные вещи: встав на
// развилке, ящик перекрывает и боковой отвод тоже, а остановившись перед
// ней — нет. Схлопывать можно только строго внутренние клетки прямого
// туннеля, где по обе стороны — тот же туннель; последнюю клетку перед
// любым "особым местом" всегда фиксируем как отдельный кандидат, а вход
// в само особое место — это уже следующий, отдельный толчок обычного
// перебора. moveChain — полная цепочка одиночных толчков для
// проигрывания в UI, ничего там менять не пришлось.
function generatePushChain(b, dx, dy, boxSet) {
const results = [];
const moveChain = [];
let curX = b.x, curY = b.y;
while (true) {
const nx = curX + dx, ny = curY + dy;
if (isWall(nx, ny)) break;
if (boxSet.has(`${nx},${ny}`)) break;
moveChain.push({dir: [dx, dy], boxFrom: {x: curX, y: curY}});
curX = nx; curY = ny;
const isTarget = targetSet.has(`${curX},${curY}`);
const aheadX = curX + dx, aheadY = curY + dy;
const safeToContinueSilently = !isTarget && tunnelSet.has(`${curX},${curY}`) &&
!isWall(aheadX, aheadY) && !boxSet.has(`${aheadX},${aheadY}`) &&
tunnelSet.has(`${aheadX},${aheadY}`) && !targetSet.has(`${aheadX},${aheadY}`);
if (!safeToContinueSilently) {
results.push({finalX: curX, finalY: curY, moveChain: moveChain.slice()});
break;
}
}
return results;
}
// Эвристика: для каждой цели — расстояние ПО ПРОХОДИМЫМ КЛЕТКАМ (не по
// прямой), обратным BFS от цели, посчитано один раз на весь солвер. Прямая
// (Манхэттен) в лабиринте откровенно врёт: клетка через стену выглядит
// "рядом", хотя реально до неё идти в обход через весь коридор — именно
// это и сбивало поиск с толку на уровнях с длинными ходами. BFS игнорирует
// текущие позиции ящиков (это статическая оценка по геометрии уровня) —
// стандартное допущение, ящики двигаются, стены нет.
const targets = [...targetSet].map(s => { const [x,y] = s.split(',').map(Number); return {x,y}; });
const targetDistMaps = targets.map(t => {
const dist = Array.from({length: rows}, () => new Array(cols).fill(Infinity));
dist[t.y][t.x] = 0;
const q = [[t.x, t.y]];
while (q.length) {
const [x, y] = q.shift();
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const nx = x+dx, ny = y+dy;
if (nx < 0 || nx >= cols || ny < 0 || ny >= rows || isWall(nx, ny)) continue;
if (dist[ny][nx] > dist[y][x] + 1) {
dist[ny][nx] = dist[y][x] + 1;
q.push([nx, ny]);
}
}
}
return dist;
});
// ponytail: "ящик к ближайшей цели независимо" — не венгерский алгоритм,
// при нескольких ящиках рядом может занижать точность (толкаются в одну
// и ту же цель на бумаге). Разница на порядки дешевле полного
// паросочетания на каждый кандидат-пуш, см. предыдущую попытку в истории.
function heuristic(boxList) {
let sum = 0;
for (const b of boxList) {
let best = Infinity;
for (const dm of targetDistMaps) {
const d = dm[b.y]?.[b.x];
if (d !== undefined && d < best) best = d;
}
if (best !== Infinity) sum += best;
}
return sum;
}
// ponytail: простая бинарная куча вместо готовой либы — стандартной
// priority queue в JS нет, а это 15 строк.
class MinHeap {
constructor() { this.items = []; }
get size() { return this.items.length; }
push(item) {
const a = this.items;
a.push(item);
let i = a.length - 1;
while (i > 0) {
const p = (i-1)>>1;
if (a[p].f <= a[i].f) break;
[a[p], a[i]] = [a[i], a[p]];
i = p;
}
}
pop() {
const a = this.items;
const top = a[0];
const last = a.pop();
if (a.length) {
a[0] = last;
let i = 0;
while (true) {
const l = i*2+1, r = i*2+2;
let smallest = i;
if (l < a.length && a[l].f < a[smallest].f) smallest = l;
if (r < a.length && a[r].f < a[smallest].f) smallest = r;
if (smallest === i) break;
[a[smallest], a[i]] = [a[i], a[smallest]];
i = smallest;
}
}
return top;
}
}
const startBoxes = startBoxesArr.map(b => ({x: b[0], y: b[1]}));
const startKey = stateKey(playerX, playerY, startBoxes);
const visited = new Set();
visited.add(startKey);
const heap = new MinHeap();
heap.push({
px: playerX, py: playerY,
boxes: startBoxes,
moves: [],
g: 0,
f: heuristic(startBoxes)
});
// ponytail: без времени/кнопки отмены раньше это было единственной защитой
// от зависшей вкладки. Теперь отмена — дело кнопки в UI, а этот потолок —
// просто страховка от переполнения памяти на совсем патологических уровнях.
const MAX_STATES = 5000000;
let explored = 0;
return {
get explored() { return explored; },
// Прогоняет поиск максимум budgetMs миллисекунд и отдаёт управление —
// { done:false } значит "зови step() ещё раз", { done:true, solved, moves, explored } — готово.
step(budgetMs) {
const stepStart = Date.now();
while (heap.size && explored < MAX_STATES) {
if (Date.now() - stepStart > budgetMs) return { done: false };
const cur = heap.pop();
explored++;
if (cur.boxes.every(b => targetSet.has(`${b.x},${b.y}`))) {
return { done: true, solved: true, moves: cur.moves, explored };
}
const boxSet = new Set(cur.boxes.map(b => `${b.x},${b.y}`));
const reachable = getReachable(cur.px, cur.py, boxSet);
for (const b of cur.boxes) {
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const fromX = b.x - dx, fromY = b.y - dy;
if (!reachable.has(`${fromX},${fromY}`)) continue;
for (const {finalX, finalY, moveChain} of generatePushChain(b, dx, dy, boxSet)) {
const newBoxes = cur.boxes.map(b2 =>
b2.x === b.x && b2.y === b.y ? {x: finalX, y: finalY} : {...b2}
);
if (isDeadlock(newBoxes)) continue;
// после цепочки толчков игрок стоит там, где ящик был перед
// последним шагом — на клетку позади финальной по направлению толчка
const newPx = finalX - dx, newPy = finalY - dy;
const key = stateKey(newPx, newPy, newBoxes);
if (visited.has(key)) continue;
visited.add(key);
const g = cur.g + moveChain.length;
heap.push({
px: newPx, py: newPy,
boxes: newBoxes,
moves: [...cur.moves, ...moveChain],
g,
f: g + heuristic(newBoxes)
});
}
}
}
}
return { done: true, solved: false, explored };
}
};
}
// Старый синхронный вход — гоняет step() до конца сам. Для тестов/консоли,
// в игре теперь используется createSolver() напрямую (см. sokoban.html).
function solveCurrentLevel(levelData, playerX, playerY) {
const solver = createSolver(levelData, playerX, playerY);
let result;
do { result = solver.step(1000); } while (!result.done);
return result;
}
// Поиск пути для игрока
function findPath(sx, sy, ex, ey, level, boxes) {
const cols = level[0].length;
const rows = level.length;
function isWall(x, y) {
if (x < 0 || x >= cols || y < 0 || y >= rows) return true;
return level[y][x] === 1;
}
const visited = new Set();
const queue = [{x: sx, y: sy, path: []}];
visited.add(`${sx},${sy}`);
const boxSet = new Set(boxes.map(b => `${b.targetX},${b.targetY}`));
while (queue.length) {
const {x, y, path} = queue.shift();
if (x === ex && y === ey) return path;
for (const [dx, dy] of [[0,-1],[0,1],[-1,0],[1,0]]) {
const nx = x+dx, ny = y+dy;
const key = `${nx},${ny}`;
if (!isWall(nx, ny) && !boxSet.has(key) && !visited.has(key)) {
visited.add(key);
queue.push({x: nx, y: ny, path: [...path, [dx, dy]]});
}
}
}
return null;
}