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.)
This commit is contained in:
Alex Cube
2026-08-10 14:19:16 +03:00
commit 50623173bb
7 changed files with 4015 additions and 0 deletions
+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;
}