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:
@@ -0,0 +1 @@
|
||||
data/
|
||||
+119
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+134
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user