← back to Games Agentabrams
games/sokoban/index.html
713 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>NEON SOKOBAN</title>
<style>
:root{
--bg:#0a0e14;
--panel:#0f1622;
--wall:#1b2a44;
--wall-glow:#2f6bff;
--floor:#0d131e;
--crate:#f0a500;
--crate-done:#2ee6a6;
--target:#ff3d81;
--player:#41d6ff;
--ink:#cfe3ff;
--ink-dim:#6f86a8;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{margin:0;padding:0;height:100%;}
body{
background:var(--bg);
color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;flex-direction:column;align-items:center;
min-height:100%;
touch-action:none;
overflow:hidden;
user-select:none;-webkit-user-select:none;
}
#app{
width:100%;max-width:640px;
display:flex;flex-direction:column;
height:100vh;height:100dvh;
padding:8px;
}
header{flex:0 0 auto;text-align:center;padding:2px 0 6px;}
h1{
margin:0;
font-size:clamp(16px,5vw,26px);
font-weight:800;
letter-spacing:0.42em;
text-transform:uppercase;
color:var(--player);
text-shadow:0 0 8px rgba(65,214,255,.55),0 0 22px rgba(65,214,255,.25);
padding-left:0.42em;
}
.bar{
display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:center;
flex:0 0 auto;margin:2px 0 6px;
}
select,button{
font-family:inherit;font-size:13px;
background:var(--panel);color:var(--ink);
border:1px solid #24344f;border-radius:8px;
padding:7px 10px;cursor:pointer;
transition:border-color .12s,background .12s,transform .05s;
}
button:hover,select:hover{border-color:var(--wall-glow);}
button:active{transform:translateY(1px);}
.stats{
display:flex;gap:14px;justify-content:center;flex-wrap:wrap;
font-size:12px;letter-spacing:.08em;color:var(--ink-dim);
text-transform:uppercase;flex:0 0 auto;margin-bottom:4px;
}
.stats b{color:var(--ink);font-weight:700;}
.stats .solved{color:var(--crate-done);}
#boardWrap{
flex:1 1 auto;
display:flex;align-items:center;justify-content:center;
min-height:0;position:relative;
}
canvas{
display:block;
image-rendering:auto;
border-radius:10px;
background:var(--floor);
box-shadow:0 0 0 1px #1a2740, 0 8px 30px rgba(0,0,0,.5);
touch-action:none;
}
#win{
position:absolute;inset:0;display:none;
align-items:center;justify-content:center;flex-direction:column;
background:rgba(6,10,16,.82);border-radius:10px;text-align:center;
gap:12px;padding:16px;
}
#win.show{display:flex;}
#win .big{
font-size:clamp(22px,7vw,40px);font-weight:800;letter-spacing:.3em;
color:var(--crate-done);text-shadow:0 0 16px rgba(46,230,166,.6);
text-transform:uppercase;padding-left:.3em;
}
#win .sub{font-size:13px;color:var(--ink);letter-spacing:.06em;}
#controls{
flex:0 0 auto;
display:flex;align-items:center;justify-content:space-between;
gap:10px;padding:8px 4px 12px;
}
#dpad{
display:grid;
grid-template-columns:repeat(3,54px);
grid-template-rows:repeat(3,54px);
gap:6px;
}
#dpad button{
font-size:22px;padding:0;border-radius:12px;
display:flex;align-items:center;justify-content:center;
color:var(--player);background:#101a2b;border-color:#22344f;
}
#dpad button:active{background:#16233a;box-shadow:0 0 12px rgba(65,214,255,.35) inset;}
.du{grid-column:2;grid-row:1;}
.dl{grid-column:1;grid-row:2;}
.dr{grid-column:3;grid-row:2;}
.dd{grid-column:2;grid-row:3;}
#dpad .ctr{grid-column:2;grid-row:2;visibility:hidden;}
#sideBtns{display:flex;flex-direction:column;gap:8px;}
#sideBtns button{min-width:88px;font-weight:700;letter-spacing:.08em;}
#btnUndo{color:var(--crate);border-color:#3a2f12;}
#btnReset{color:var(--target);border-color:#3a1424;}
.hint{font-size:10px;color:var(--ink-dim);letter-spacing:.06em;text-align:center;flex:0 0 auto;padding-bottom:4px;}
@media (max-height:620px){
#dpad{grid-template-columns:repeat(3,46px);grid-template-rows:repeat(3,46px);}
h1{font-size:16px;}
}
</style>
</head>
<body>
<div id="app">
<header><h1>Neon Sokoban</h1></header>
<div class="bar">
<select id="levelSelect" aria-label="Select level"></select>
<button id="btnPrev">◀ Prev</button>
<button id="btnNext">Next ▶</button>
</div>
<div class="stats">
<span>Level <b id="statLevel">1</b></span>
<span>Moves <b id="statMoves">0</b></span>
<span>Pushes <b id="statPushes">0</b></span>
<span>Best <b id="statBest">—</b></span>
<span class="solved" id="statSolved"></span>
</div>
<div id="boardWrap">
<canvas id="board"></canvas>
<div id="win">
<div class="big">Solved</div>
<div class="sub" id="winSub"></div>
<button id="btnWinNext">Next Level ▶</button>
</div>
</div>
<div id="controls">
<div id="dpad">
<button class="du" data-dir="up" aria-label="Up">▲</button>
<button class="dl" data-dir="left" aria-label="Left">◀</button>
<span class="ctr"></span>
<button class="dr" data-dir="right" aria-label="Right">▶</button>
<button class="dd" data-dir="down" aria-label="Down">▼</button>
</div>
<div id="sideBtns">
<button id="btnUndo">↶ UNDO</button>
<button id="btnReset">⟳ RESET</button>
</div>
</div>
<div class="hint">Arrows / WASD move · U undo · R reset</div>
</div>
<script>
(function(){
"use strict";
// ---- Level data (ASCII). # wall, space floor, . target, $ crate, * crate-on-target, @ player, + player-on-target ----
var LEVELS = [
{ name:"Warmup", grid:[
"#####",
"#@$.#",
"#####"
]},
{ name:"Corner", grid:[
"######",
"#. #",
"# $$ #",
"# $. #",
"#. @ #",
"######"
]},
{ name:"Twist", grid:[
" #####",
"### #",
"#.@$ #",
"### $.#",
"#.##$ #",
"# # . ##",
"#$ *$$.#",
"# . #",
"########"
]},
{ name:"Detour", grid:[
"########",
"# #",
"# .$. #",
"# $@$ #",
"# .$. #",
"# #",
"########"
]},
{ name:"Chamber", grid:[
"#######",
"# . #",
"# $#$ #",
"#. @ .#",
"# $#$ #",
"# . #",
"#######"
]},
{ name:"Zigzag", grid:[
"########",
"# #",
"# $ $ #",
"#.##.# #",
"# $ @ #",
"#.## # #",
"# #",
"########"
]},
{ name:"Lockbox", grid:[
"#########",
"# # #",
"# $ # . #",
"# $ #.. #",
"# @ $ #",
"# # #",
"#########"
]},
{ name:"Gauntlet", grid:[
"########",
"#. # #",
"#. $$@ #",
"#. $ $ #",
"#. #",
"# #",
"########"
]},
{ name:"Spiral", grid:[
"########",
"#. . .#",
"# $$$ #",
"# @ #",
"# $$$ #",
"#. . .#",
"########"
]},
{ name:"Vault", grid:[
"#########",
"# ... #",
"# $$$ #",
"# $@ #",
"# $$ #",
"# ... #",
"#########"
]}
];
var STORE_KEY = "abrams-sokoban";
// ---- Tile constants ----
var WALL=1, FLOOR=0, TARGET=2;
// ---- State ----
var state = null; // { statics, w, h, crates(Set of index), player, targets(Set), moves, pushes, history[] }
var current = 0;
var progress = loadProgress();
var canvas = document.getElementById("board");
var ctx = canvas.getContext("2d");
var cell = 32;
function loadProgress(){
try{
var raw = localStorage.getItem(STORE_KEY);
if(!raw) return {};
var p = JSON.parse(raw);
return (p && typeof p === "object") ? p : {};
}catch(e){ return {}; }
}
function saveProgress(){
try{ localStorage.setItem(STORE_KEY, JSON.stringify(progress)); }catch(e){}
}
function idx(x,y,w){ return y*w + x; }
// Parse a level into a fresh state object
function buildState(levelIndex){
var grid = LEVELS[levelIndex].grid;
var h = grid.length;
var w = 0;
for(var i=0;i<h;i++){ if(grid[i].length>w) w = grid[i].length; }
var statics = new Array(w*h); // WALL / FLOOR / TARGET
var crates = {}; // index -> true
var targets = {};
var player = 0;
for(var y=0;y<h;y++){
var row = grid[y];
for(var x=0;x<w;x++){
var ch = x < row.length ? row[x] : " ";
var index = idx(x,y,w);
var isTarget=false, isCrate=false, isPlayer=false, isWall=false;
switch(ch){
case "#": isWall=true; break;
case ".": isTarget=true; break;
case "$": isCrate=true; break;
case "*": isCrate=true; isTarget=true; break;
case "@": isPlayer=true; break;
case "+": isPlayer=true; isTarget=true; break;
default: break; // space => floor
}
statics[index] = isWall ? WALL : (isTarget ? TARGET : FLOOR);
if(isTarget) targets[index]=true;
if(isCrate) crates[index]=true;
if(isPlayer) player=index;
}
}
return {
w:w, h:h, statics:statics,
crates:crates, targets:targets,
player:player, moves:0, pushes:0,
history:[]
};
}
var DIRS = {
up: {dx:0, dy:-1},
down: {dx:0, dy:1},
left: {dx:-1,dy:0},
right:{dx:1, dy:0}
};
function isWall(s,index){ return s.statics[index] === WALL; }
function tryMove(dir){
if(!state) return;
var d = DIRS[dir];
if(!d) return;
var s = state;
var w = s.w;
var px = s.player % w, py = (s.player - (s.player%w))/w;
var nx = px + d.dx, ny = py + d.dy;
if(nx<0||ny<0||nx>=w||ny>=s.h) return;
var nIndex = idx(nx,ny,w);
if(isWall(s,nIndex)) return;
var pushed = false;
if(s.crates[nIndex]){
// crate ahead — check the cell beyond
var bx = nx + d.dx, by = ny + d.dy;
if(bx<0||by<0||bx>=w||by>=s.h) return;
var bIndex = idx(bx,by,w);
if(isWall(s,bIndex) || s.crates[bIndex]) return; // blocked
// move crate
delete s.crates[nIndex];
s.crates[bIndex] = true;
pushed = true;
}
// record history for undo
s.history.push({ from:s.player, to:nIndex, pushed:pushed });
s.player = nIndex;
s.moves++;
if(pushed) s.pushes++;
render();
checkWin();
}
function undo(){
if(!state || state.history.length===0) return;
var s = state;
var last = s.history.pop();
// player moves back
s.player = last.from;
if(s.moves>0) s.moves--;
if(last.pushed){
// crate was pushed from 'to' to beyond; revert it
var w = s.w;
var tx = last.to % w, ty = (last.to - (last.to%w))/w;
var dx = tx - (last.from % w);
var dy = ty - ((last.from - (last.from%w))/w);
var bIndex = idx(tx+dx, ty+dy, w);
delete s.crates[bIndex];
s.crates[last.to] = true;
if(s.pushes>0) s.pushes--;
}
hideWin();
render();
}
function reset(){
state = buildState(current);
hideWin();
layout();
render();
updateStats();
}
function isSolved(s){
for(var t in s.targets){
if(!s.crates[t]) return false;
}
return true;
}
function checkWin(){
updateStats();
if(state && isSolved(state)){
recordSolve();
showWin();
}
}
function recordSolve(){
var key = String(current);
var rec = progress[key] || {};
rec.solved = true;
if(typeof rec.best !== "number" || state.moves < rec.best){
rec.best = state.moves;
}
progress[key] = rec;
saveProgress();
buildLevelSelect();
updateStats();
}
// ---- Rendering ----
function layout(){
if(!state) return;
var wrap = document.getElementById("boardWrap");
var availW = wrap.clientWidth - 4;
var availH = wrap.clientHeight - 4;
if(availW<=0) availW = 300;
if(availH<=0) availH = 300;
var c = Math.floor(Math.min(availW/state.w, availH/state.h));
c = Math.max(14, Math.min(c, 64));
cell = c;
canvas.width = state.w * cell;
canvas.height = state.h * cell;
canvas.style.width = canvas.width + "px";
canvas.style.height = canvas.height + "px";
}
function render(){
if(!state) return;
var s = state, w=s.w, h=s.h;
ctx.clearRect(0,0,canvas.width,canvas.height);
// floor bg
ctx.fillStyle = "#0d131e";
ctx.fillRect(0,0,canvas.width,canvas.height);
for(var y=0;y<h;y++){
for(var x=0;x<w;x++){
var index = idx(x,y,w);
var st = s.statics[index];
var px = x*cell, py=y*cell;
if(st===WALL){
drawWall(px,py);
} else {
drawFloor(px,py);
if(st===TARGET){ drawTarget(px,py); }
}
}
}
// crates
for(var ci in s.crates){
var i = +ci;
var cx = (i % w)*cell, cy = ((i-(i%w))/w)*cell;
var onTarget = s.statics[i]===TARGET;
drawCrate(cx,cy,onTarget);
}
// player
var pi = s.player;
var ppx = (pi % w)*cell, ppy = ((pi-(pi%w))/w)*cell;
drawPlayer(ppx,ppy);
}
function drawFloor(px,py){
ctx.fillStyle = "#0d131e";
ctx.fillRect(px,py,cell,cell);
ctx.strokeStyle = "rgba(40,60,95,.28)";
ctx.lineWidth = 1;
ctx.strokeRect(px+0.5,py+0.5,cell-1,cell-1);
}
function drawWall(px,py){
var g = ctx.createLinearGradient(px,py,px,py+cell);
g.addColorStop(0,"#20335a");
g.addColorStop(1,"#152340");
ctx.fillStyle = g;
ctx.fillRect(px,py,cell,cell);
ctx.strokeStyle = "rgba(47,107,255,.55)";
ctx.lineWidth = Math.max(1, cell*0.06);
ctx.strokeRect(px+ctx.lineWidth/2, py+ctx.lineWidth/2, cell-ctx.lineWidth, cell-ctx.lineWidth);
}
function drawTarget(px,py){
var cx = px+cell/2, cy=py+cell/2;
var r = cell*0.18;
ctx.save();
ctx.shadowColor = "rgba(255,61,129,.85)";
ctx.shadowBlur = cell*0.4;
ctx.strokeStyle = "#ff3d81";
ctx.lineWidth = Math.max(1.5, cell*0.07);
ctx.beginPath();
ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.stroke();
ctx.restore();
}
function drawCrate(px,py,onTarget){
var pad = cell*0.12;
var x=px+pad, y=py+pad, sz=cell-pad*2;
var col = onTarget ? "#2ee6a6" : "#f0a500";
var glow = onTarget ? "rgba(46,230,166,.75)" : "rgba(240,165,0,.55)";
ctx.save();
ctx.shadowColor = glow;
ctx.shadowBlur = onTarget ? cell*0.5 : cell*0.28;
var g = ctx.createLinearGradient(x,y,x,y+sz);
g.addColorStop(0, onTarget ? "#3af0b4" : "#ffbe2e");
g.addColorStop(1, col);
ctx.fillStyle = g;
roundRect(x,y,sz,sz,cell*0.12);
ctx.fill();
ctx.restore();
// inner cross detail
ctx.strokeStyle = onTarget ? "rgba(6,30,22,.55)" : "rgba(60,40,0,.5)";
ctx.lineWidth = Math.max(1, cell*0.04);
ctx.beginPath();
ctx.moveTo(x,y); ctx.lineTo(x+sz,y+sz);
ctx.moveTo(x+sz,y); ctx.lineTo(x,y+sz);
ctx.stroke();
}
function drawPlayer(px,py){
var cx=px+cell/2, cy=py+cell/2;
var r = cell*0.32;
ctx.save();
ctx.shadowColor = "rgba(65,214,255,.85)";
ctx.shadowBlur = cell*0.45;
var g = ctx.createRadialGradient(cx,cy-r*0.3,r*0.2,cx,cy,r);
g.addColorStop(0,"#bff2ff");
g.addColorStop(1,"#41d6ff");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.fill();
ctx.restore();
ctx.strokeStyle = "rgba(10,40,55,.6)";
ctx.lineWidth = Math.max(1, cell*0.04);
ctx.beginPath();
ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.stroke();
}
function roundRect(x,y,w,h,r){
r = Math.min(r, w/2, h/2);
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r);
ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r);
ctx.arcTo(x,y,x+w,y,r);
ctx.closePath();
}
// ---- UI ----
function updateStats(){
document.getElementById("statLevel").textContent = String(current+1);
document.getElementById("statMoves").textContent = state ? String(state.moves) : "0";
document.getElementById("statPushes").textContent = state ? String(state.pushes) : "0";
var rec = progress[String(current)];
document.getElementById("statBest").textContent = (rec && typeof rec.best==="number") ? String(rec.best) : "—";
var solvedCount = 0;
for(var k in progress){ if(progress[k] && progress[k].solved) solvedCount++; }
document.getElementById("statSolved").textContent = solvedCount>0 ? ("✓ " + solvedCount + "/" + LEVELS.length + " solved") : "";
}
function buildLevelSelect(){
var sel = document.getElementById("levelSelect");
var prev = sel.value;
sel.innerHTML = "";
for(var i=0;i<LEVELS.length;i++){
var opt = document.createElement("option");
opt.value = String(i);
var rec = progress[String(i)];
var tag = (rec && rec.solved) ? " ✓" : "";
var best = (rec && typeof rec.best==="number") ? (" · " + rec.best) : "";
opt.textContent = (i+1) + ". " + LEVELS[i].name + tag + best;
sel.appendChild(opt);
}
sel.value = String(current);
if(prev==="" ){ /* noop */ }
}
function loadLevel(i){
if(i<0) i=0;
if(i>=LEVELS.length) i=LEVELS.length-1;
current = i;
document.getElementById("levelSelect").value = String(current);
reset();
}
function showWin(){
var el = document.getElementById("win");
var sub = document.getElementById("winSub");
var rec = progress[String(current)] || {};
var bestTxt = (typeof rec.best==="number") ? (" · Best " + rec.best) : "";
sub.textContent = state.moves + " moves · " + state.pushes + " pushes" + bestTxt;
var nextBtn = document.getElementById("btnWinNext");
nextBtn.style.display = (current < LEVELS.length-1) ? "" : "none";
el.classList.add("show");
}
function hideWin(){ document.getElementById("win").classList.remove("show"); }
// ---- Input ----
var KEYMAP = {
"ArrowUp":"up","ArrowDown":"down","ArrowLeft":"left","ArrowRight":"right",
"w":"up","s":"down","a":"left","d":"right",
"W":"up","S":"down","A":"left","D":"right"
};
window.addEventListener("keydown", function(e){
var k = e.key;
if(KEYMAP[k]){
e.preventDefault();
tryMove(KEYMAP[k]);
return;
}
if(k==="u"||k==="U"){ e.preventDefault(); undo(); return; }
if(k==="r"||k==="R"){ e.preventDefault(); reset(); return; }
if(k==="n"||k==="N"){ e.preventDefault(); loadLevel(current+1); return; }
}, {passive:false});
// Prevent arrow/space page scroll fallback
window.addEventListener("keydown", function(e){
if(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight"," "].indexOf(e.key) !== -1){
e.preventDefault();
}
}, {passive:false});
function bindTap(el, fn){
if(!el) return;
el.addEventListener("click", function(e){ e.preventDefault(); fn(); });
el.addEventListener("touchstart", function(e){ e.preventDefault(); fn(); }, {passive:false});
}
var dpadBtns = document.querySelectorAll("#dpad button[data-dir]");
for(var i=0;i<dpadBtns.length;i++){
(function(btn){
var dir = btn.getAttribute("data-dir");
btn.addEventListener("click", function(e){ e.preventDefault(); tryMove(dir); });
btn.addEventListener("touchstart", function(e){ e.preventDefault(); tryMove(dir); }, {passive:false});
})(dpadBtns[i]);
}
bindTap(document.getElementById("btnUndo"), undo);
bindTap(document.getElementById("btnReset"), reset);
bindTap(document.getElementById("btnPrev"), function(){ loadLevel(current-1); });
bindTap(document.getElementById("btnNext"), function(){ loadLevel(current+1); });
bindTap(document.getElementById("btnWinNext"), function(){ loadLevel(current+1); });
document.getElementById("levelSelect").addEventListener("change", function(e){
loadLevel(parseInt(e.target.value,10)||0);
});
// Swipe on canvas
var touchStart = null;
canvas.addEventListener("touchstart", function(e){
if(e.touches.length===1){
touchStart = {x:e.touches[0].clientX, y:e.touches[0].clientY};
}
}, {passive:true});
canvas.addEventListener("touchend", function(e){
if(!touchStart) return;
var t = e.changedTouches[0];
var dx = t.clientX - touchStart.x;
var dy = t.clientY - touchStart.y;
touchStart = null;
var ax = Math.abs(dx), ay = Math.abs(dy);
if(Math.max(ax,ay) < 22) return;
if(ax>ay){ tryMove(dx>0?"right":"left"); }
else{ tryMove(dy>0?"down":"up"); }
}, {passive:true});
var resizeTimer = null;
window.addEventListener("resize", function(){
if(resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function(){ layout(); render(); }, 60);
});
// ---- Init (guarded until DOM ready) ----
function init(){
buildLevelSelect();
// start at first unsolved level, else level 0
var start = 0;
for(var i=0;i<LEVELS.length;i++){
var rec = progress[String(i)];
if(!rec || !rec.solved){ start = i; break; }
}
current = start;
loadLevel(current);
// second layout pass after fonts/flex settle
setTimeout(function(){ layout(); render(); }, 50);
}
if(document.readyState === "loading"){
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
</body>
</html>