// 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; }