Add PHP server version: leaderboard, identity, menu screens, lore
- php/: MySQL-backed (via config.php, gitignored) top-5 solutions per level, cookie identity with rename, server-side progress for "Продолжить" - Menu screens: Играть / Продолжить / Предыстория / Настройки, plus placeholder start/end screens for a future intro/outro - Lore screen: EXO:GRID backstory text (6 epochs, no gameplay changes yet) - levels.js/solver.js copied in as-is
This commit is contained in:
+373
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user