1
2
3
4
5
6(function () {
7 "use strict";
8
9 const LEVELS = [
10
11 [
12 "#####",
13 "# #",
14 "# $ #",
15 "# . #",
16 "# @ #",
17 "#####",
18 ],
19
20 [
21 "######",
22 "# #",
23 "# $$ #",
24 "# .. #",
25 "# @ #",
26 "######",
27 ],
28
29 [
30 "#######",
31 "# #",
32 "# $ $ #",
33 "# @ #",
34 "# . . #",
35 "#######",
36 ],
37
38 [
39 " ####",
40 "### #",
41 "# $ #",
42 "# @$.#",
43 "### .#",
44 " ####",
45 ],
46
47 [
48 "########",
49 "# #",
50 "# $$* #",
51 "# @$ #",
52 "# .. #",
53 "########",
54 ],
55
56 [
57 " #####",
58 "### #",
59 "# $ $ #",
60 "# * ##",
61 "# $ @ #",
62 "# . . #",
63 "########",
64 ],
65
66 [
67 "########",
68 "# . #",
69 "# $$ # #",
70 "# # #",
71 "## # $##",
72 "# @ . #",
73 "# .$ #",
74 "########",
75 ],
76
77 [
78 " ######",
79 "### #",
80 "# $ $$ #",
81 "# @ #",
82 "## ##$##",
83 "# ... #",
84 "# . #",
85 "########",
86 ],
87 ];
88
89
90
91 let levelIndex = 0;
92 let width = 0;
93 let height = 0;
94 let walls = null;
95 let goals = null;
96 let boxes = null;
97 let player = { x: 0, y: 0 };
98 let moves = 0;
99 let pushes = 0;
100 let history = [];
101 let completed = new Set();
102 let won = false;
103 let advanceTimer = null;
104
105
106
107 const boardEl = document.getElementById("board");
108 const levelNumEl = document.getElementById("level-num");
109 const moveCountEl = document.getElementById("move-count");
110 const pushCountEl = document.getElementById("push-count");
111 const levelStripEl = document.getElementById("level-strip");
112 const overlayEl = document.getElementById("overlay");
113 const overlayTitleEl = document.getElementById("overlay-title");
114 const overlayMsgEl = document.getElementById("overlay-msg");
115 const overlayStatsEl = document.getElementById("overlay-stats");
116 const overlayHintEl = document.getElementById("overlay-hint");
117
118
119
120 function key(x, y) {
121 return x + "," + y;
122 }
123
124 function cloneBoxes(set) {
125 return new Set(set);
126 }
127
128 function snapshot() {
129 return {
130 boxes: cloneBoxes(boxes),
131 player: { x: player.x, y: player.y },
132 moves: moves,
133 pushes: pushes,
134 };
135 }
136
137 function restore(snap) {
138 boxes = cloneBoxes(snap.boxes);
139 player = { x: snap.player.x, y: snap.player.y };
140 moves = snap.moves;
141 pushes = snap.pushes;
142 }
143
144 function parseLevel(rows) {
145 height = rows.length;
146 width = Math.max.apply(
147 null,
148 rows.map(function (r) {
149 return r.length;
150 })
151 );
152 walls = new Set();
153 goals = new Set();
154 boxes = new Set();
155 player = { x: 0, y: 0 };
156
157 for (let y = 0; y < height; y++) {
158 const row = rows[y];
159 for (let x = 0; x < width; x++) {
160 const ch = x < row.length ? row[x] : " ";
161 switch (ch) {
162 case "#":
163 walls.add(key(x, y));
164 break;
165 case ".":
166 goals.add(key(x, y));
167 break;
168 case "$":
169 boxes.add(key(x, y));
170 break;
171 case "*":
172 goals.add(key(x, y));
173 boxes.add(key(x, y));
174 break;
175 case "@":
176 player = { x: x, y: y };
177 break;
178 case "+":
179 goals.add(key(x, y));
180 player = { x: x, y: y };
181 break;
182 default:
183 break;
184 }
185 }
186 }
187 }
188
189 function isWall(x, y) {
190 return walls.has(key(x, y));
191 }
192
193 function hasBox(x, y) {
194 return boxes.has(key(x, y));
195 }
196
197 function inBounds(x, y) {
198 return x >= 0 && y >= 0 && x < width && y < height;
199 }
200
201 function isPlayableCell(x, y) {
202
203 return inBounds(x, y) && !isWall(x, y);
204 }
205
206 function checkWin() {
207 let all = true;
208 goals.forEach(function (g) {
209 if (!boxes.has(g)) all = false;
210 });
211
212 if (boxes.size !== goals.size) all = false;
213 return all;
214 }
215
216
217
218 function tryMove(dx, dy) {
219 if (won) return;
220
221 const nx = player.x + dx;
222 const ny = player.y + dy;
223
224 if (!isPlayableCell(nx, ny)) return;
225
226 const targetHasBox = hasBox(nx, ny);
227
228 if (targetHasBox) {
229 const bx = nx + dx;
230 const by = ny + dy;
231 if (!isPlayableCell(bx, by) || hasBox(bx, by)) return;
232
233 history.push(snapshot());
234 boxes.delete(key(nx, ny));
235 boxes.add(key(bx, by));
236 player.x = nx;
237 player.y = ny;
238 moves += 1;
239 pushes += 1;
240 } else {
241 history.push(snapshot());
242 player.x = nx;
243 player.y = ny;
244 moves += 1;
245 }
246
247 updateHUD();
248 renderBoard();
249
250 if (checkWin()) {
251 onWin();
252 }
253 }
254
255 function undo() {
256 if (won || history.length === 0) return;
257 const snap = history.pop();
258 restore(snap);
259 updateHUD();
260 renderBoard();
261 }
262
263 function restartLevel() {
264 clearAdvanceTimer();
265 hideOverlay();
266 won = false;
267 loadLevel(levelIndex, false);
268 }
269
270
271
272 function clearAdvanceTimer() {
273 if (advanceTimer !== null) {
274 clearTimeout(advanceTimer);
275 advanceTimer = null;
276 }
277 }
278
279 function hideOverlay() {
280 overlayEl.classList.add("hidden");
281 }
282
283 function showOverlay(isLast) {
284 overlayTitleEl.textContent = isLast ? "All Levels Clear!" : "Level Complete!";
285 overlayMsgEl.textContent = isLast
286 ? "You finished every puzzle. Master pusher!"
287 : "Nice work packing those crates.";
288 overlayStatsEl.textContent =
289 "Moves: " + moves + " · Pushes: " + pushes;
290 overlayHintEl.textContent = isLast
291 ? "Pick a level above to play again."
292 : "Next level in a moment…";
293 overlayEl.classList.remove("hidden");
294 }
295
296 function onWin() {
297 won = true;
298 completed.add(levelIndex);
299 renderLevelStrip();
300 const isLast = levelIndex >= LEVELS.length - 1;
301 showOverlay(isLast);
302
303 clearAdvanceTimer();
304 if (!isLast) {
305 advanceTimer = setTimeout(function () {
306 advanceTimer = null;
307 hideOverlay();
308 loadLevel(levelIndex + 1, true);
309 }, 1600);
310 }
311 }
312
313
314
315 function updateHUD() {
316 levelNumEl.textContent = String(levelIndex + 1);
317 moveCountEl.textContent = String(moves);
318 pushCountEl.textContent = String(pushes);
319 }
320
321 function renderLevelStrip() {
322 levelStripEl.innerHTML = "";
323 for (let i = 0; i < LEVELS.length; i++) {
324 const btn = document.createElement("button");
325 btn.type = "button";
326 btn.className = "level-btn";
327 btn.textContent = String(i + 1);
328 btn.setAttribute("aria-label", "Level " + (i + 1));
329 if (i === levelIndex) btn.classList.add("current");
330 if (completed.has(i)) btn.classList.add("completed");
331 btn.addEventListener("click", function () {
332 clearAdvanceTimer();
333 hideOverlay();
334 loadLevel(i, true);
335 });
336 levelStripEl.appendChild(btn);
337 }
338 }
339
340 function cellIsInside(x, y) {
341
342
343
344
345
346
347
348
349
350 if (isWall(x, y)) return "wall";
351
352
353 const k = key(x, y);
354 if (goals.has(k) || boxes.has(k) || (player.x === x && player.y === y)) {
355 return "floor";
356 }
357
358
359
360 const dirs = [
361 [0, 1],
362 [0, -1],
363 [1, 0],
364 [-1, 0],
365 ];
366 let nearSomething = false;
367 for (let i = 0; i < dirs.length; i++) {
368 const ax = x + dirs[i][0];
369 const ay = y + dirs[i][1];
370 if (!inBounds(ax, ay)) continue;
371 const ak = key(ax, ay);
372 if (
373 walls.has(ak) ||
374 goals.has(ak) ||
375 boxes.has(ak) ||
376 (player.x === ax && player.y === ay)
377 ) {
378 nearSomething = true;
379 break;
380 }
381 }
382
383
384
385
386
387 if (!nearSomething) {
388
389
390 const row = LEVELS[levelIndex][y];
391 if (!row || x >= row.length) return "empty";
392 if (row[x] === " " && !nearSomething) {
393
394
395
396 return "empty";
397 }
398 }
399
400 return "floor";
401 }
402
403 function renderBoard() {
404 boardEl.style.gridTemplateColumns = "repeat(" + width + ", var(--tile))";
405 boardEl.innerHTML = "";
406
407 for (let y = 0; y < height; y++) {
408 for (let x = 0; x < width; x++) {
409 const cell = document.createElement("div");
410 const kind = cellIsInside(x, y);
411 cell.className = "tile " + kind;
412
413 if (kind === "floor" || kind === "wall") {
414
415 }
416 if (goals.has(key(x, y)) && kind !== "wall") {
417 cell.classList.add("goal");
418 if (kind === "empty") {
419 cell.classList.remove("empty");
420 cell.classList.add("floor");
421 }
422 }
423
424 if (boxes.has(key(x, y))) {
425 const box = document.createElement("div");
426 box.className = "entity box";
427 if (goals.has(key(x, y))) box.classList.add("on-goal");
428 cell.appendChild(box);
429
430 if (cell.classList.contains("empty")) {
431 cell.classList.remove("empty");
432 cell.classList.add("floor");
433 }
434 }
435
436 if (player.x === x && player.y === y) {
437 const p = document.createElement("div");
438 p.className = "entity player";
439 cell.appendChild(p);
440 if (cell.classList.contains("empty")) {
441 cell.classList.remove("empty");
442 cell.classList.add("floor");
443 }
444 }
445
446 boardEl.appendChild(cell);
447 }
448 }
449 }
450
451
452
453 function loadLevel(index, resetHistory) {
454 levelIndex = index;
455 won = false;
456 parseLevel(LEVELS[index]);
457 moves = 0;
458 pushes = 0;
459 if (resetHistory !== false) history = [];
460 else history = [];
461 updateHUD();
462 renderLevelStrip();
463 renderBoard();
464 }
465
466
467
468 const KEY_DIR = {
469 ArrowUp: [0, -1],
470 ArrowDown: [0, 1],
471 ArrowLeft: [-1, 0],
472 ArrowRight: [1, 0],
473 };
474
475 document.addEventListener("keydown", function (e) {
476 if (e.key in KEY_DIR) {
477 e.preventDefault();
478 const d = KEY_DIR[e.key];
479 tryMove(d[0], d[1]);
480 return;
481 }
482
483 const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
484 if (k === "u") {
485 e.preventDefault();
486 undo();
487 } else if (k === "r") {
488 e.preventDefault();
489 restartLevel();
490 }
491 });
492
493
494
495 loadLevel(0, true);
496})();
497
Discussion
No comments yet. Start the discussion. Recorded by @patrick-toulme.