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:
@@ -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;
|
||||
})();
|
||||
Reference in New Issue
Block a user