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
+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;
})();