Files
socoban/solver.js
T
Alex Cube 50623173bb Initial commit: Sokoban 60-level game with A* solver
- sokoban.html: canvas game, entrance/exit door mechanic (level completes
  by walking to the exit after all boxes are placed, not an alert popup),
  solution search UI (modal with timer, cancel, "Решение найдено!" button)
- solver.js: A* with binary heap, true-distance heuristic, static
  deadlock cells, freeze-deadlock detection, tunnel macro-moves,
  chunked step() API for non-blocking search in the UI
- localStorage-backed solution leaderboard per level (fewest moves wins),
  fed by both solver finds and manual player completions; G replays the
  best recorded solution
- BACKLOG.md: known follow-ups (dead files, room/corral pruning, undo, etc.)
2026-08-10 14:19:16 +03:00

373 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}