1
Sokoban: 12 Levels
tap arrows or WASD · U undo · R restart
idle
354 lines · vanilla
view source
// Sokoban — 12 hand-built levels. Push boxes onto targets; can't pull.
// Tap arrows / WASD to move. U undo a step. R restart. ← / → switch level.
// Standard Sokoban notation:
// # wall ' ' floor . target $ box * box-on-target @ player + player-on-target
const LEVELS = [
// L1 — trivial: one push, straight line. (~5s)
[
"#####",
"#@$.#",
"#####",
],
// L2 — corner the box around an L. (~10s)
[
"#######",
"#. #",
"# $ #",
"# @ #",
"#######",
],
// L3 — two boxes, two targets, simple lane. (~15s)
[
"########",
"#. . #",
"# $$ #",
"# @ #",
"########",
],
// L4 — wrap around a stub to deliver to the corner target. (~20s)
[
"########",
"#. #",
"# # #",
"# $ #",
"# @ #",
"########",
],
// L5 — two boxes, must avoid corner-traps. (~20s)
[
"#######",
"#. #",
"# $$ #",
"# @ #",
"# .#",
"#######",
],
// L6 — push up around a stub to the upper target. (~25s)
[
"#########",
"# . #",
"## ## #",
"# #",
"# $ #",
"# @ #",
"#########",
],
// L7 — three boxes side by side. (~30s)
[
"#########",
"#. . . #",
"# #",
"# $$$ #",
"# @ #",
"#########",
],
// L8 — pocket: target inside a recess. (~30s)
[
"########",
"### ###",
"# . #",
"# $ @ #",
"# #",
"########",
],
// L9 — forced order: must place far box first. (~40s)
[
"##########",
"#. . #",
"# $$ #",
"# #",
"# @ #",
"##########",
],
// L10 — two pockets on opposite sides. (~45s)
[
"##########",
"# . #",
"# $ #",
"## ### ###",
"# $ #",
"# . @ #",
"##########",
],
// L11 — three boxes, three pockets in a row. (~50s)
[
"##########",
"# . . .#",
"# #",
"# $ $ #",
"# $ #",
"# @ #",
"##########",
],
// L12 — tight room, must thread through. (~60s)
[
"##########",
"#. #",
"# $## $ #",
"# # ## #",
"# $ . #",
"# @ . #",
"##########",
],
];
let levelIdx = 0;
let grid = null; // 2D: '#', ' ', '.' (statics)
let boxes = null; // Set of "r,c" positions
let targets = null; // Set of "r,c" positions
let player = { r: 0, c: 0 };
let history = []; // {pr,pc, boxFrom?, boxTo?}
let cols = 0, rows = 0;
let cellPx = 32;
let originX = 0, originY = 0;
let won = false;
let lastKeyTime = 0;
let prevTouch = false; // edge-detect on-screen tap
let buttons = []; // {x,y,w,h,action,label}
let flashWin = 0;
function parseLevel(idx) {
const src = LEVELS[idx];
rows = src.length;
cols = Math.max(...src.map(r => r.length));
grid = [];
boxes = new Set();
targets = new Set();
player = { r: 0, c: 0 };
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols; c++) {
const ch = src[r][c] || " ";
let cell = " ";
if (ch === "#") cell = "#";
else if (ch === "." || ch === "*" || ch === "+") {
cell = ".";
targets.add(r + "," + c);
}
if (ch === "$" || ch === "*") boxes.add(r + "," + c);
if (ch === "@" || ch === "+") { player.r = r; player.c = c; }
row.push(cell);
}
grid.push(row);
}
history = [];
won = false;
flashWin = 0;
}
function checkWin() {
if (boxes.size !== targets.size) return false;
for (const k of boxes) if (!targets.has(k)) return false;
return true;
}
function tryMove(dr, dc) {
if (won) return;
const nr = player.r + dr, nc = player.c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) return;
if (grid[nr][nc] === "#") return;
const targetKey = nr + "," + nc;
if (boxes.has(targetKey)) {
const br = nr + dr, bc = nc + dc;
if (br < 0 || br >= rows || bc < 0 || bc >= cols) return;
if (grid[br][bc] === "#") return;
const boxToKey = br + "," + bc;
if (boxes.has(boxToKey)) return;
// valid push
history.push({ pr: player.r, pc: player.c, boxFrom: targetKey, boxTo: boxToKey });
boxes.delete(targetKey);
boxes.add(boxToKey);
player.r = nr; player.c = nc;
} else {
history.push({ pr: player.r, pc: player.c });
player.r = nr; player.c = nc;
}
if (checkWin()) { won = true; flashWin = 1; }
}
function undo() {
if (won) return;
const h = history.pop();
if (!h) return;
player.r = h.pr; player.c = h.pc;
if (h.boxTo) {
boxes.delete(h.boxTo);
boxes.add(h.boxFrom);
}
}
function changeLevel(delta) {
levelIdx = (levelIdx + delta + LEVELS.length) % LEVELS.length;
parseLevel(levelIdx);
}
function layout(width, height) {
// Reserve ~140px at bottom for D-pad + nav. On tiny canvases, scale down.
const bottomBar = Math.min(160, Math.max(120, Math.floor(height * 0.32)));
const topBar = 32;
const playW = width - 16;
const playH = height - bottomBar - topBar - 8;
cellPx = Math.max(12, Math.floor(Math.min(playW / cols, playH / rows)));
const gridW = cellPx * cols, gridH = cellPx * rows;
originX = Math.floor((width - gridW) / 2);
originY = topBar + Math.floor((playH - gridH) / 2);
// Buttons in bottom bar
buttons = [];
const cx = Math.floor(width / 2);
const by = height - bottomBar + 8; // top of bottom bar
const btn = Math.max(44, Math.min(64, Math.floor(bottomBar / 2.6)));
const gap = 6;
// D-pad cross
buttons.push({ x: cx - btn / 2, y: by, w: btn, h: btn, action: "up", label: "↑" });
buttons.push({ x: cx - btn / 2, y: by + btn + gap * 2, w: btn, h: btn, action: "down", label: "↓" });
buttons.push({ x: cx - btn / 2 - btn - gap, y: by + btn / 2 + gap, w: btn, h: btn, action: "left", label: "←" });
buttons.push({ x: cx + btn / 2 + gap, y: by + btn / 2 + gap, w: btn, h: btn, action: "right", label: "→" });
// Side controls: undo on left, restart on right of d-pad
const sideBtnH = Math.max(36, Math.floor(btn * 0.8));
const sideBtnW = Math.max(72, Math.floor(width * 0.18));
buttons.push({
x: 8, y: by + Math.floor(btn * 0.6),
w: sideBtnW, h: sideBtnH, action: "undo", label: "Undo",
});
buttons.push({
x: width - 8 - sideBtnW, y: by + Math.floor(btn * 0.6),
w: sideBtnW, h: sideBtnH, action: "restart", label: "Restart",
});
// Level nav arrows at top
const navW = 44, navH = 28;
buttons.push({ x: 8, y: 4, w: navW, h: navH, action: "prev", label: "←" });
buttons.push({ x: width - 8 - navW, y: 4, w: navW, h: navH, action: "next", label: "→" });
}
function pointInBtn(x, y, b) {
return x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
}
function init({ width, height }) {
parseLevel(0);
layout(width, height);
}
function tick({ ctx, dt, width, height, input }) {
// Re-layout if size changed (rare in this runtime, but cheap)
layout(width, height);
// ---- Keyboard input (edge-detected) ----
if (input.justPressed("ArrowUp") || input.justPressed("w") || input.justPressed("W")) tryMove(-1, 0);
if (input.justPressed("ArrowDown") || input.justPressed("s") || input.justPressed("S")) tryMove(1, 0);
if (input.justPressed("ArrowLeft") || input.justPressed("a") || input.justPressed("A")) tryMove(0, -1);
if (input.justPressed("ArrowRight") || input.justPressed("d") || input.justPressed("D")) tryMove(0, 1);
if (input.justPressed("u") || input.justPressed("U") || input.justPressed("z") || input.justPressed("Z")) undo();
if (input.justPressed("r") || input.justPressed("R")) parseLevel(levelIdx);
if (input.justPressed("[") || input.justPressed(",")) changeLevel(-1);
if (input.justPressed("]") || input.justPressed(".")) changeLevel(1);
if (won && (input.justPressed("Enter") || input.justPressed(" "))) changeLevel(1);
// ---- Tap/click input on buttons (mobile + desktop) ----
const clicks = input.consumeClicks();
for (const ck of clicks) {
// If win overlay is showing, any tap on the board advances
if (won) {
// tap inside grid area to advance, otherwise buttons still work
const inBtn = buttons.some(b => pointInBtn(ck.x, ck.y, b));
if (!inBtn) { changeLevel(1); continue; }
}
for (const b of buttons) {
if (!pointInBtn(ck.x, ck.y, b)) continue;
if (b.action === "up") tryMove(-1, 0);
else if (b.action === "down") tryMove(1, 0);
else if (b.action === "left") tryMove(0, -1);
else if (b.action === "right") tryMove(0, 1);
else if (b.action === "undo") undo();
else if (b.action === "restart") parseLevel(levelIdx);
else if (b.action === "prev") changeLevel(-1);
else if (b.action === "next") changeLevel(1);
break;
}
}
if (flashWin > 0) flashWin = Math.max(0, flashWin - dt);
draw(ctx, width, height, input);
}
function draw(ctx, width, height, input) {
// Background
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, width, height);
// Top bar: level label
ctx.fillStyle = "#9aa0b4";
ctx.font = "13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`Level ${levelIdx + 1} / ${LEVELS.length}`, width / 2, 18);
// Step count
ctx.font = "11px system-ui, sans-serif";
ctx.textAlign = "right";
ctx.fillStyle = "#6b7185";
ctx.fillText(`steps ${history.length}`, width - 60, 18);
ctx.textAlign = "left";
// Grid cells
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = originX + c * cellPx;
const y = originY + r * cellPx;
const ch = grid[r][c];
if (ch === "#") {
ctx.fillStyle = "#2a3045";
ctx.fillRect(x, y, cellPx, cellPx);
// brick highlight
ctx.fillStyle = "#3a4262";
ctx.fillRect(x + 1, y + 1, cellPx - 2, 2);
} else {
// floor
ctx.fillStyle = (r + c) % 2 === 0 ? "#1a1f2e" : "#161b28";
ctx.fillRect(x, y, cellPx, cellPx);
if (ch === ".") {
// target marker
ctx.fillStyle = "#34c759";
const cx = x + cellPx / 2, cy = y + cellPx / 2;
ctx.beginPath();
ctx.arc(cx, cy, Math.max(3, cellPx * 0.14), 0, Math.PI * 2);
ctx.fill();
}
}
}
}
// Boxes
for (const k of boxes) {
const [r, c] = k.split(",").map(Number);
const x = originX + c * cellPx;
const y = originY + r * cellPx;
const onTarget = targets.has(k);
const pad = Math.max(2, Math.floor(cellPx * 0.1));
ctx.fillStyle = onTarget ? "#34c759" : "#c08040";
ctx.fillRect(x + pad, y + pad, cellPx - pad * 2, cellPx - pad * 2);
ctx.strokeStyle = onTarget ? "#1f7a3a" : "#7a4a20";
ctx.lineWidth = 2;
ctx.strokeRect(x + pad + 1, y + pad + 1, cellPx - pad * 2 - 2, cellPx - pad * 2 - 2);
// cross detail
ctx.beginPath();
ctx.moveTo(x + pad, y + pad); ctx.lineTo(x + cellPx - pad, y + cellPx - pad);
ctx.moveTo(x + cellPx - pad, y + pad); ctx.lineTo(x + pad, y + cellPx - pad);
ctx.stroke();
}
// Player
{
const x = originX + player.c * cellPx;
const y = originY + player.r * cellPx;
const cx = x + cellPx / 2, cy = y + cellPx / 2;
ctx.fillStyle = "#5ac8fa";
ctx.beginPath();
ctx.arc(cx, cy, cellPx * 0.32, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#0a1822";
ctx.beginPath();
ctx.arc(cx - cellPx * 0.1, cy - cellPx * 0.06, Math.max(1.2, cellPx * 0.045), 0, Math.PI * 2);
ctx.arc(cx + cellPx * 0.1, cy - cellPx * 0.06, Math.max(1.2, cellPx * 0.045), 0, Math.PI * 2);
ctx.fill();
}
// Buttons
for (const b of buttons) {
drawButton(ctx, b, input);
}
// Win overlay
if (won) {
ctx.fillStyle = "rgba(10,15,25,0.78)";
const gridW = cellPx * cols, gridH = cellPx * rows;
ctx.fillRect(originX - 8, originY - 8, gridW + 16, gridH + 16);
ctx.fillStyle = "#34c759";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold 22px system-ui, sans-serif";
ctx.fillText("Solved!", originX + gridW / 2, originY + gridH / 2 - 12);
ctx.fillStyle = "#e8e8f0";
ctx.font = "12px system-ui, sans-serif";
const msg = levelIdx + 1 < LEVELS.length
? "tap to continue · → next"
: "all 12 cleared — tap to loop";
ctx.fillText(msg, originX + gridW / 2, originY + gridH / 2 + 12);
}
}
function drawButton(ctx, b, input) {
const hot = input.mouseDown
&& input.mouseX >= b.x && input.mouseX <= b.x + b.w
&& input.mouseY >= b.y && input.mouseY <= b.y + b.h;
ctx.fillStyle = hot ? "#3a4566" : "#222a3e";
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = "#3a4566";
ctx.lineWidth = 1;
ctx.strokeRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1);
ctx.fillStyle = "#e8e8f0";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const isArrow = b.label === "↑" || b.label === "↓" || b.label === "←" || b.label === "→";
ctx.font = isArrow ? "bold 22px system-ui, sans-serif" : "13px system-ui, sans-serif";
ctx.fillText(b.label, b.x + b.w / 2, b.y + b.h / 2 + 1);
}
Comments (0)
Log in to comment.