← 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>