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();
129
130function loadCompleted() {
131 try {
132 const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
133 return Array.from({ length: LEVELS.length }, (_, i) => Boolean(saved[i]));
134 } catch {
135 return Array(LEVELS.length).fill(false);
136 }
137}
138
139function saveCompleted() {
140 localStorage.setItem(STORAGE_KEY, JSON.stringify(completed));
141}
142
143function key(r, c) {
144 return `${r},${c}`;
145}
146
147function cloneBoxes(set = boxes) {
148 return new Set([...set]);
149}
150
151function parseLevel(index) {
152 const rows = LEVELS[index].map;
153 base = rows.map(row => row.split(""));
154 boxes = new Set();
155
156 for (let r = 0; r < base.length; r++) {
157 for (let c = 0; c < base[r].length; c++) {
158 const ch = base[r][c];
159 if (ch === "@" || ch === "+") {
160 player = { r, c };
161 base[r][c] = ch === "+" ? "." : " ";
162 } else if (ch === "$" || ch === "*") {
163 boxes.add(key(r, c));
164 base[r][c] = ch === "*" ? "." : " ";
165 }
166 }
167 }
168
169 moves = 0;
170 pushes = 0;
171 history = [];
172 locked = false;
173 hideOverlay();
174}
175
176function renderLevelStrip() {
177 stripEl.innerHTML = "";
178 LEVELS.forEach((level, i) => {
179 const btn = document.createElement("button");
180 btn.className = "level-btn";
181 btn.type = "button";
182 btn.textContent = i + 1;
183 btn.title = `Level ${i + 1}: ${level.name}`;
184 btn.setAttribute("aria-label", btn.title);
185 btn.classList.toggle("active", i === levelIndex);
186 btn.classList.toggle("done", completed[i]);
187 btn.addEventListener("click", () => loadLevel(i));
188 stripEl.appendChild(btn);
189 });
190}
191
192function render() {
193 const rows = base.length;
194 const cols = base[0].length;
195 boardEl.style.setProperty("--rows", rows);
196 boardEl.style.setProperty("--cols", cols);
197 boardEl.innerHTML = "";
198
199 for (let r = 0; r < rows; r++) {
200 for (let c = 0; c < cols; c++) {
201 const tile = document.createElement("div");
202 const isWall = base[r][c] === "#";
203 const isGoal = base[r][c] === ".";
204 const hasBox = boxes.has(key(r, c));
205 const hasPlayer = player.r === r && player.c === c;
206 tile.className = "tile " + (isWall ? "wall" : "floor");
207 if (isGoal) tile.classList.add("goal");
208 if (hasBox) tile.classList.add("box");
209 if (hasPlayer) tile.classList.add("player");
210 tile.setAttribute("role", "gridcell");
211 boardEl.appendChild(tile);
212 }
213 }
214
215 levelLabelEl.textContent = `${levelIndex + 1}/8`;
216 moveCountEl.textContent = moves;
217 pushCountEl.textContent = pushes;
218 renderLevelStrip();
219}
220
221function isWall(r, c) {
222 return r < 0 || c < 0 || r >= base.length || c >= base[0].length || base[r][c] === "#";
223}
224
225function canStand(r, c) {
226 return !isWall(r, c) && !boxes.has(key(r, c));
227}
228
229function saveHistory() {
230 history.push({
231 player: { ...player },
232 boxes: cloneBoxes(),
233 moves,
234 pushes
235 });
236}
237
238function move(dr, dc) {
239 if (locked) return;
240
241 const nr = player.r + dr;
242 const nc = player.c + dc;
243 const targetKey = key(nr, nc);
244
245 if (isWall(nr, nc)) return;
246
247 if (boxes.has(targetKey)) {
248 const br = nr + dr;
249 const bc = nc + dc;
250 const boxKey = key(br, bc);
251 if (!canStand(br, bc)) return;
252
253 saveHistory();
254 boxes.delete(targetKey);
255 boxes.add(boxKey);
256 player = { r: nr, c: nc };
257 moves++;
258 pushes++;
259 } else {
260 saveHistory();
261 player = { r: nr, c: nc };
262 moves++;
263 }
264
265 render();
266 if (isWon()) completeLevel();
267}
268
269function undo() {
270 if (locked || history.length === 0) return;
271 const previous = history.pop();
272 player = previous.player;
273 boxes = previous.boxes;
274 moves = previous.moves;
275 pushes = previous.pushes;
276 render();
277}
278
279function restart() {
280 loadLevel(levelIndex);
281}
282
283function isWon() {
284 return [...boxes].every(pos => {
285 const [r, c] = pos.split(",").map(Number);
286 return base[r][c] === ".";
287 });
288}
289
290function completeLevel() {
291 locked = true;
292 completed[levelIndex] = true;
293 saveCompleted();
294 renderLevelStrip();
295
296 const last = levelIndex === LEVELS.length - 1;
297 overlayTextEl.textContent = last ? "You solved every level!" : "Advancing to the next level…";
298 overlayEl.classList.add("show");
299 overlayEl.setAttribute("aria-hidden", "false");
300
301 if (!last) {
302 window.setTimeout(() => loadLevel(levelIndex + 1), 1400);
303 }
304}
305
306function hideOverlay() {
307 overlayEl.classList.remove("show");
308 overlayEl.setAttribute("aria-hidden", "true");
309}
310
311function loadLevel(index) {
312 levelIndex = index;
313 parseLevel(levelIndex);
314 render();
315}
316
317document.addEventListener("keydown", event => {
318 if (event.key in DIRS) {
319 event.preventDefault();
320 const [dr, dc] = DIRS[event.key];
321 move(dr, dc);
322 } else if (event.key === "u" || event.key === "U") {
323 event.preventDefault();
324 undo();
325 } else if (event.key === "r" || event.key === "R") {
326 event.preventDefault();
327 restart();
328 }
329});
330
331loadLevel(0);
332
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.