1const LEVELS = [
2 {
3 name: "First Push",
4 map: [
5 "#######",
6 "# #",
7 "# @ #",
8 "# $ #",
9 "# . #",
10 "# #",
11 "#######"
12 ]
13 },
14 {
15 name: "Two Crates",
16 map: [
17 "########",
18 "# #",
− "# .$. #",
19 "# $. #",
20 "# @ #",
21 "# $ #",
22 "# . #",
23 "########"
24 ]
25 },
26 {
27 name: "Corner Store",
28 map: [
29 "#########",
30 "# #",
31 "# . $ . #",
32 "# $ #",
33 "# @ #",
34 "# #",
35 "#########"
36 ]
37 },
38 {
39 name: "Hall Switch",
40 map: [
41 "#########",
42 "# # #",
43 "# . # . #",
44 "# $ $ #",
45 "# @ #",
46 "# #",
47 "#########"
48 ]
49 },
50 {
51 name: "Loading Bay",
52 map: [
53 "##########",
54 "# #",
55 "# .. #",
56 "# $$ #",
57 "# # #",
58 "# @ $ .#",
59 "# #",
60 "##########"
61 ]
62 },
63 {
64 name: "Side Door",
65 map: [
66 "##########",
67 "# #",
68 "# . . #",
69 "# $ $ #",
70 "# # #",
71 "# @ $. #",
72 "# #",
73 "##########"
74 ]
75 },
76 {
77 name: "Warehouse",
78 map: [
79 "###########",
80 "# #",
81 "# . . . #",
82 "# $$$ #",
83 "# # #",
84 "# @ #",
85 "# #",
86 "###########"
87 ]
88 },
89 {
90 name: "Final Shift",
91 map: [
92 "############",
93 "# # #",
94 "# . . # . #",
95 "# $ $ $ #",
96 "# # #",
97 "# @ #",
98 "# #",
99 "############"
100 ]
101 }
102];
103
104const STORAGE_KEY = "pixel-sokoban-completed";
105const boardEl = document.getElementById("board");
106const stripEl = document.getElementById("levelStrip");
107const levelLabelEl = document.getElementById("levelLabel");
108const moveCountEl = document.getElementById("moveCount");
109const pushCountEl = document.getElementById("pushCount");
110const overlayEl = document.getElementById("overlay");
111const overlayTextEl = document.getElementById("overlayText");
112
113const DIRS = {
114 ArrowUp: [-1, 0],
115 ArrowDown: [1, 0],
116 ArrowLeft: [0, -1],
117 ArrowRight: [0, 1]
118};
119
120let levelIndex = 0;
121let base = [];
122let boxes = new Set();
123let player = { r: 0, c: 0 };
124let moves = 0;
125let pushes = 0;
126let history = [];
127let locked = false;
128let completed = loadCompleted();
129let advanceTimer = null;
130
131function loadCompleted() {
132 try {
133 const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
134 return Array.from({ length: LEVELS.length }, (_, i) => Boolean(saved[i]));
135 } catch {
136 return Array(LEVELS.length).fill(false);
137 }
138}
139
140function saveCompleted() {
141 try {
142 localStorage.setItem(STORAGE_KEY, JSON.stringify(completed));
143 } catch {
144
145 }
146}
147
148function key(r, c) {
149 return `${r},${c}`;
150}
151
152function cloneBoxes(set = boxes) {
153 return new Set([...set]);
154}
155
156function parseLevel(index) {
157 const rows = LEVELS[index].map;
158 base = rows.map(row => row.split(""));
159 boxes = new Set();
160
161 for (let r = 0; r < base.length; r++) {
162 for (let c = 0; c < base[r].length; c++) {
163 const ch = base[r][c];
164 if (ch === "@" || ch === "+") {
165 player = { r, c };
166 base[r][c] = ch === "+" ? "." : " ";
167 } else if (ch === "$" || ch === "*") {
168 boxes.add(key(r, c));
169 base[r][c] = ch === "*" ? "." : " ";
170 }
171 }
172 }
173
174 moves = 0;
175 pushes = 0;
176 history = [];
177 locked = false;
178 hideOverlay();
179}
180
181function renderLevelStrip() {
182 stripEl.innerHTML = "";
183 LEVELS.forEach((level, i) => {
184 const btn = document.createElement("button");
185 btn.className = "level-btn";
186 btn.type = "button";
187 btn.textContent = i + 1;
188 btn.title = `Level ${i + 1}: ${level.name}`;
189 btn.setAttribute("aria-label", btn.title);
190 btn.classList.toggle("active", i === levelIndex);
191 btn.classList.toggle("done", completed[i]);
192 btn.addEventListener("click", () => loadLevel(i));
193 stripEl.appendChild(btn);
194 });
195}
196
197function render() {
198 const rows = base.length;
199 const cols = base[0].length;
200 boardEl.style.setProperty("--rows", rows);
201 boardEl.style.setProperty("--cols", cols);
202 boardEl.innerHTML = "";
203
204 for (let r = 0; r < rows; r++) {
205 for (let c = 0; c < cols; c++) {
206 const tile = document.createElement("div");
207 const isWall = base[r][c] === "#";
208 const isGoal = base[r][c] === ".";
209 const hasBox = boxes.has(key(r, c));
210 const hasPlayer = player.r === r && player.c === c;
211 tile.className = "tile " + (isWall ? "wall" : "floor");
212 if (isGoal) tile.classList.add("goal");
213 if (hasBox) tile.classList.add("box");
214 if (hasPlayer) tile.classList.add("player");
215 tile.setAttribute("role", "gridcell");
216 boardEl.appendChild(tile);
217 }
218 }
219
220 levelLabelEl.textContent = `${levelIndex + 1}/8`;
221 moveCountEl.textContent = moves;
222 pushCountEl.textContent = pushes;
223 renderLevelStrip();
224}
225
226function isWall(r, c) {
227 return r < 0 || c < 0 || r >= base.length || c >= base[0].length || base[r][c] === "#";
228}
229
230function canStand(r, c) {
231 return !isWall(r, c) && !boxes.has(key(r, c));
232}
233
234function saveHistory() {
235 history.push({
236 player: { ...player },
237 boxes: cloneBoxes(),
238 moves,
239 pushes
240 });
241}
242
243function move(dr, dc) {
244 if (locked) return;
245
246 const nr = player.r + dr;
247 const nc = player.c + dc;
248 const targetKey = key(nr, nc);
249
250 if (isWall(nr, nc)) return;
251
252 if (boxes.has(targetKey)) {
253 const br = nr + dr;
254 const bc = nc + dc;
255 const boxKey = key(br, bc);
256 if (!canStand(br, bc)) return;
257
258 saveHistory();
259 boxes.delete(targetKey);
260 boxes.add(boxKey);
261 player = { r: nr, c: nc };
262 moves++;
263 pushes++;
264 } else {
265 saveHistory();
266 player = { r: nr, c: nc };
267 moves++;
268 }
269
270 render();
271 if (isWon()) completeLevel();
272}
273
274function undo() {
275 if (locked || history.length === 0) return;
276 const previous = history.pop();
277 player = previous.player;
278 boxes = previous.boxes;
279 moves = previous.moves;
280 pushes = previous.pushes;
281 render();
282}
283
284function restart() {
285 loadLevel(levelIndex);
286}
287
288function isWon() {
289 return [...boxes].every(pos => {
290 const [r, c] = pos.split(",").map(Number);
291 return base[r][c] === ".";
292 });
293}
294
295function completeLevel() {
296 locked = true;
297 completed[levelIndex] = true;
298 saveCompleted();
299 renderLevelStrip();
300
301 const last = levelIndex === LEVELS.length - 1;
302 overlayTextEl.textContent = last ? "You solved every level!" : "Advancing to the next level…";
303 overlayEl.classList.add("show");
304 overlayEl.setAttribute("aria-hidden", "false");
305
306 if (!last) {
307 advanceTimer = window.setTimeout(() => loadLevel(levelIndex + 1), 1400);
308 }
309}
310
311function hideOverlay() {
312 overlayEl.classList.remove("show");
313 overlayEl.setAttribute("aria-hidden", "true");
314}
315
316function loadLevel(index) {
317 if (advanceTimer) {
318 window.clearTimeout(advanceTimer);
319 advanceTimer = null;
320 }
321 levelIndex = index;
322 parseLevel(levelIndex);
323 render();
324}
325
326document.addEventListener("keydown", event => {
327 if (event.key in DIRS) {
328 event.preventDefault();
329 const [dr, dc] = DIRS[event.key];
330 move(dr, dc);
331 } else if (event.key === "u" || event.key === "U") {
332 event.preventDefault();
333 undo();
334 } else if (event.key === "r" || event.key === "R") {
335 event.preventDefault();
336 restart();
337 }
338});
339
340loadLevel(0);
341
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.