← back to Games Agentabrams

games/gems/NeonGems.aa

756 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 GEMS</title>
<style>
  :root{
    --bg:#0a0e14;
    --panel:#0f1622;
    --line:#1c2735;
    --ink:#e8f0ff;
    --muted:#7f93ad;
    --accent:#3df2ff;
  }
  *{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
  html,body{ margin:0; padding:0; height:100%; }
  body{
    background:
      radial-gradient(1200px 700px at 50% -10%, #12213a 0%, rgba(18,33,58,0) 60%),
      var(--bg);
    color:var(--ink);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    touch-action:none;
    overflow:hidden;
    display:flex;
    align-items:center;
    justify-content:center;
    min-height:100%;
    user-select:none;
    -webkit-user-select:none;
  }
  #wrap{
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:14px;
    padding:16px;
    width:100%;
    max-width:560px;
  }
  #title{
    margin:0;
    font-size:clamp(20px,6vw,34px);
    font-weight:800;
    letter-spacing:.42em;
    text-transform:uppercase;
    text-indent:.42em;
    background:linear-gradient(90deg,#3df2ff,#8a7dff 45%,#ff5db8);
    -webkit-background-clip:text;
    background-clip:text;
    color:transparent;
    filter:drop-shadow(0 0 12px rgba(61,242,255,.35));
    text-align:center;
  }
  #hud{
    width:100%;
    display:flex;
    gap:10px;
    justify-content:center;
  }
  .stat{
    flex:1;
    background:linear-gradient(180deg,var(--panel),#0b1220);
    border:1px solid var(--line);
    border-radius:12px;
    padding:8px 10px;
    text-align:center;
    box-shadow:inset 0 1px 0 rgba(255,255,255,.03);
  }
  .stat .lbl{
    font-size:10px;
    letter-spacing:.22em;
    text-transform:uppercase;
    color:var(--muted);
  }
  .stat .val{
    font-size:clamp(16px,5vw,24px);
    font-weight:800;
    margin-top:2px;
    font-variant-numeric:tabular-nums;
    color:var(--ink);
  }
  #boardWrap{
    position:relative;
    width:100%;
    border-radius:16px;
    background:linear-gradient(180deg,#0c1420,#080c13);
    border:1px solid var(--line);
    box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 60px rgba(61,242,255,.04);
    padding:10px;
    touch-action:none;
  }
  canvas{ display:block; width:100%; height:auto; border-radius:10px; touch-action:none; }
  #foot{
    display:flex;
    gap:10px;
    align-items:center;
    justify-content:center;
    width:100%;
  }
  button{
    font-family:inherit;
    font-weight:700;
    letter-spacing:.16em;
    text-transform:uppercase;
    font-size:11px;
    color:var(--ink);
    background:linear-gradient(180deg,#132033,#0c1524);
    border:1px solid var(--line);
    border-radius:10px;
    padding:9px 14px;
    cursor:pointer;
    transition:transform .08s ease, border-color .15s ease, box-shadow .15s ease;
  }
  button:hover{ border-color:var(--accent); box-shadow:0 0 14px rgba(61,242,255,.25); }
  button:active{ transform:translateY(1px); }
  #msg{
    min-height:16px;
    font-size:11px;
    letter-spacing:.16em;
    text-transform:uppercase;
    color:var(--muted);
    text-align:center;
  }
</style>
</head>
<body>
  <div id="wrap">
    <h1 id="title">Neon Gems</h1>
    <div id="hud">
      <div class="stat"><div class="lbl">Score</div><div class="val" id="scoreEl">0</div></div>
      <div class="stat"><div class="lbl">Moves</div><div class="val" id="movesEl">30</div></div>
      <div class="stat"><div class="lbl">Best</div><div class="val" id="bestEl">0</div></div>
    </div>
    <div id="boardWrap">
      <canvas id="game"></canvas>
    </div>
    <div id="msg">Swap gems to match 3 or more</div>
    <div id="foot">
      <button id="soundBtn" type="button">Sound: On</button>
      <button id="newBtn" type="button">New Game</button>
    </div>
  </div>

<script>
(function(){
  "use strict";

  function boot(){
    var canvas = document.getElementById('game');
    if(!canvas) return;
    var ctx = canvas.getContext('2d');
    if(!ctx) return;

    var scoreEl = document.getElementById('scoreEl');
    var movesEl = document.getElementById('movesEl');
    var bestEl  = document.getElementById('bestEl');
    var msgEl   = document.getElementById('msg');
    var soundBtn= document.getElementById('soundBtn');
    var newBtn  = document.getElementById('newBtn');
    var boardWrap = document.getElementById('boardWrap');

    var N = 8;              // grid size
    var START_MOVES = 30;
    var BEST_KEY = "abrams-gems-best";

    // Gem palette: distinct saturated neon hues
    var GEMS = [
      { core:"#ff3d6e", glow:"#ff3d6e" }, // pink/red
      { core:"#ffb43d", glow:"#ffb43d" }, // amber
      { core:"#ffe93d", glow:"#ffe93d" }, // yellow
      { core:"#4dff88", glow:"#4dff88" }, // green
      { core:"#3df2ff", glow:"#3df2ff" }, // cyan
      { core:"#7d8bff", glow:"#7d8bff" }, // indigo
      { core:"#c96dff", glow:"#c96dff" }  // violet
    ];
    var NC = GEMS.length;

    // DPR-aware sizing
    var cell = 48;          // logical px per cell (set in resize)
    var dpr = 1;

    // Board state: grid[r][c] = color index (0..NC-1) or -1 empty
    var grid = [];
    var score = 0, moves = START_MOVES, best = 0;
    var soundOn = true;

    // Per-cell render offset (for fall/swap animation)
    // offset[r][c] = {dx, dy} in logical pixels, animated to 0
    var offX = [], offY = [];
    // pop animation: pop[r][c] = scale factor while clearing (1 -> 0)
    var popScale = [];
    var spawnScale = []; // 0 -> 1 for newly spawned/fallen gems

    var sel = null;        // {r,c} currently selected
    var animating = false; // block input during resolve animations
    var gameOver = false;

    // ---------- storage ----------
    function loadBest(){
      try{
        var v = localStorage.getItem(BEST_KEY);
        best = v ? (parseInt(v,10)||0) : 0;
      }catch(e){ best = 0; }
    }
    function saveBest(){
      try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
    }

    // ---------- audio ----------
    var audioCtx = null;
    function ensureAudio(){
      if(audioCtx) return;
      try{
        var AC = window.AudioContext || window.webkitAudioContext;
        if(AC) audioCtx = new AC();
      }catch(e){ audioCtx = null; }
    }
    function blip(freq, dur, type, vol){
      if(!soundOn || !audioCtx) return;
      try{
        var t = audioCtx.currentTime;
        var o = audioCtx.createOscillator();
        var g = audioCtx.createGain();
        o.type = type || "sine";
        o.frequency.setValueAtTime(freq, t);
        g.gain.setValueAtTime(0.0001, t);
        g.gain.exponentialRampToValueAtTime(vol||0.14, t+0.01);
        g.gain.exponentialRampToValueAtTime(0.0001, t+(dur||0.12));
        o.connect(g); g.connect(audioCtx.destination);
        o.start(t);
        o.stop(t+(dur||0.12)+0.02);
      }catch(e){}
    }
    function sfxSwap(){ blip(420,0.09,"triangle",0.10); }
    function sfxBad(){ blip(160,0.14,"sawtooth",0.08); }
    function sfxMatch(chain){ blip(520 + chain*90, 0.14, "square", 0.12); }
    function sfxDrop(){ blip(300,0.06,"sine",0.06); }

    // ---------- grid helpers ----------
    function rndColor(){ return Math.floor(Math.random()*NC); }

    function inBounds(r,c){ return r>=0 && r<N && c>=0 && c<N; }

    // Build initial board with NO pre-existing match-3
    function fillInitial(){
      grid = [];
      for(var r=0;r<N;r++){
        grid[r] = [];
        for(var c=0;c<N;c++){
          var col;
          var guard = 0;
          do{
            col = rndColor();
            guard++;
            // reject if it completes a horizontal or vertical run of 3
          }while(guard<50 && (
            (c>=2 && grid[r][c-1]===col && grid[r][c-2]===col) ||
            (r>=2 && grid[r-1][c]===col && grid[r-2][c]===col)
          ));
          grid[r][c] = col;
        }
      }
      // guarantee at least one valid move
      if(!hasValidMove()){ shuffleBoard(); }
    }

    // find all matched cells (>=3 in row/col). returns 2D boolean.
    function findMatches(){
      var mark = [];
      for(var r=0;r<N;r++){ mark[r]=[]; for(var c=0;c<N;c++) mark[r][c]=false; }
      var r,c;
      // horizontal
      for(r=0;r<N;r++){
        var run=1;
        for(c=1;c<N;c++){
          if(grid[r][c]!==-1 && grid[r][c]===grid[r][c-1]) run++;
          else {
            if(run>=3){ for(var k=0;k<run;k++) mark[r][c-1-k]=true; }
            run=1;
          }
        }
        if(run>=3){ for(var k2=0;k2<run;k2++) mark[r][N-1-k2]=true; }
      }
      // vertical
      for(c=0;c<N;c++){
        var run2=1;
        for(r=1;r<N;r++){
          if(grid[r][c]!==-1 && grid[r][c]===grid[r-1][c]) run2++;
          else {
            if(run2>=3){ for(var k3=0;k3<run2;k3++) mark[r-1-k3][c]=true; }
            run2=1;
          }
        }
        if(run2>=3){ for(var k4=0;k4<run2;k4++) mark[N-1-k4][c]=true; }
      }
      return mark;
    }

    function countMarked(mark){
      var n=0;
      for(var r=0;r<N;r++) for(var c=0;c<N;c++) if(mark[r][c]) n++;
      return n;
    }

    // would swapping (r1,c1)<->(r2,c2) produce a match?
    function swapProducesMatch(r1,c1,r2,c2){
      var t = grid[r1][c1]; grid[r1][c1]=grid[r2][c2]; grid[r2][c2]=t;
      var m = findMatches();
      var ok = countMarked(m) > 0;
      var t2 = grid[r1][c1]; grid[r1][c1]=grid[r2][c2]; grid[r2][c2]=t2;
      return ok;
    }

    function hasValidMove(){
      for(var r=0;r<N;r++){
        for(var c=0;c<N;c++){
          if(c<N-1 && swapProducesMatch(r,c,r,c+1)) return true;
          if(r<N-1 && swapProducesMatch(r,c,r+1,c)) return true;
        }
      }
      return false;
    }

    function shuffleBoard(){
      var guard=0;
      do{
        // Fisher-Yates over flat colors
        var flat=[];
        for(var r=0;r<N;r++) for(var c=0;c<N;c++) flat.push(grid[r][c]);
        for(var i=flat.length-1;i>0;i--){
          var j=Math.floor(Math.random()*(i+1));
          var tmp=flat[i]; flat[i]=flat[j]; flat[j]=tmp;
        }
        var idx=0;
        for(var r2=0;r2<N;r2++) for(var c2=0;c2<N;c2++) grid[r2][c2]=flat[idx++];
        guard++;
      }while(guard<80 && (countMarked(findMatches())>0 || !hasValidMove()));
    }

    // ---------- animation arrays ----------
    function resetAnimArrays(){
      offX=[]; offY=[]; popScale=[]; spawnScale=[];
      for(var r=0;r<N;r++){
        offX[r]=[]; offY[r]=[]; popScale[r]=[]; spawnScale[r]=[];
        for(var c=0;c<N;c++){ offX[r][c]=0; offY[r][c]=0; popScale[r][c]=1; spawnScale[r][c]=1; }
      }
    }

    // ---------- new game ----------
    function newGame(){
      score=0; moves=START_MOVES; gameOver=false; sel=null; animating=false;
      fillInitial();
      resetAnimArrays();
      updateHUD();
      setMsg("Swap gems to match 3 or more");
      draw();
    }

    // ---------- HUD ----------
    function updateHUD(){
      scoreEl.textContent = String(score);
      movesEl.textContent = String(moves);
      bestEl.textContent  = String(best);
    }
    function setMsg(t){ msgEl.textContent = t; }

    // ---------- sizing ----------
    function resize(){
      // available width from wrapper (minus padding)
      var wrapW = boardWrap.clientWidth - 20; // 10px padding each side
      if(wrapW < 120) wrapW = 120;
      // keep board square, cap so it fits typical iframe heights too
      var maxByH = (window.innerHeight || 600) - 230;
      var size = Math.min(wrapW, maxByH > 200 ? maxByH : wrapW);
      size = Math.max(200, Math.min(size, 520));
      cell = size / N;
      dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 3));
      canvas.width = Math.round(size * dpr);
      canvas.height = Math.round(size * dpr);
      canvas.style.width = size + "px";
      canvas.style.height = size + "px";
      draw();
    }

    // ---------- drawing ----------
    function drawGem(cx, cy, rad, gi, alpha, scale){
      if(gi<0) return;
      var g = GEMS[gi];
      var R = rad*scale;
      if(R<=0.5) return;
      ctx.save();
      ctx.globalAlpha = alpha;
      // outer glow
      ctx.shadowColor = g.glow;
      ctx.shadowBlur = R*0.9;
      // body gradient
      var grad = ctx.createRadialGradient(cx - R*0.3, cy - R*0.35, R*0.15, cx, cy, R);
      grad.addColorStop(0, "#ffffff");
      grad.addColorStop(0.18, g.core);
      grad.addColorStop(1, shade(g.core, -0.45));
      ctx.fillStyle = grad;
      roundRectPath(cx-R, cy-R, R*2, R*2, R*0.42);
      ctx.fill();
      // inner rim
      ctx.shadowBlur = 0;
      ctx.lineWidth = Math.max(1, R*0.10);
      ctx.strokeStyle = shade(g.core, 0.35);
      ctx.globalAlpha = alpha*0.8;
      roundRectPath(cx-R*0.92, cy-R*0.92, R*1.84, R*1.84, R*0.36);
      ctx.stroke();
      // sparkle highlight
      ctx.globalAlpha = alpha*0.85;
      ctx.fillStyle = "rgba(255,255,255,0.85)";
      ctx.beginPath();
      ctx.ellipse(cx - R*0.32, cy - R*0.38, R*0.22, R*0.13, -0.6, 0, Math.PI*2);
      ctx.fill();
      ctx.restore();
    }

    function roundRectPath(x,y,w,h,r){
      if(r>w/2) r=w/2; if(r>h/2) r=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();
    }

    // shade a hex color by amt (-1..1)
    function shade(hex, amt){
      var h = hex.replace('#','');
      var r = parseInt(h.substring(0,2),16);
      var g = parseInt(h.substring(2,4),16);
      var b = parseInt(h.substring(4,6),16);
      if(amt<0){
        r = Math.round(r*(1+amt)); g = Math.round(g*(1+amt)); b = Math.round(b*(1+amt));
      } else {
        r = Math.round(r+(255-r)*amt); g = Math.round(g+(255-g)*amt); b = Math.round(b+(255-b)*amt);
      }
      r=Math.max(0,Math.min(255,r)); g=Math.max(0,Math.min(255,g)); b=Math.max(0,Math.min(255,b));
      return "rgb("+r+","+g+","+b+")";
    }

    function draw(){
      var W = canvas.width, H = canvas.height;
      ctx.setTransform(dpr,0,0,dpr,0,0);
      var size = W/dpr;
      // bg
      ctx.clearRect(0,0,size,size);
      var bg = ctx.createLinearGradient(0,0,0,size);
      bg.addColorStop(0,"#0b1220");
      bg.addColorStop(1,"#070b11");
      ctx.fillStyle = bg;
      ctx.fillRect(0,0,size,size);

      // grid cells backdrop
      for(var r=0;r<N;r++){
        for(var c=0;c<N;c++){
          var x = c*cell, y = r*cell;
          ctx.fillStyle = ((r+c)%2===0) ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.006)";
          ctx.fillRect(x,y,cell,cell);
        }
      }

      // selection highlight
      if(sel){
        var sx = sel.c*cell, sy = sel.r*cell;
        ctx.save();
        ctx.strokeStyle = "#3df2ff";
        ctx.shadowColor = "#3df2ff";
        ctx.shadowBlur = 14;
        ctx.lineWidth = 3;
        roundRectPath(sx+3, sy+3, cell-6, cell-6, cell*0.2);
        ctx.stroke();
        ctx.restore();
      }

      // gems
      var rad = cell*0.40;
      for(var r2=0;r2<N;r2++){
        for(var c2=0;c2<N;c2++){
          var gi = grid[r2][c2];
          if(gi<0) continue;
          var cx = c2*cell + cell/2 + offX[r2][c2];
          var cy = r2*cell + cell/2 + offY[r2][c2];
          var ps = popScale[r2][c2];
          var ss = spawnScale[r2][c2];
          var sc = ps * ss;
          if(sc<=0.02) continue;
          drawGem(cx, cy, rad, gi, Math.min(1, sc<1?sc:1)*(ps<1?ps:1), sc);
        }
      }
    }

    // ---------- animation loop ----------
    var rafId = null;
    function animateOffsets(cb){
      animating = true;
      var speed = 0.22; // easing factor per frame
      function step(){
        var moving = false;
        for(var r=0;r<N;r++){
          for(var c=0;c<N;c++){
            // ease offsets to 0
            if(Math.abs(offX[r][c])>0.5){ offX[r][c]*= (1-speed); moving=true; } else offX[r][c]=0;
            if(Math.abs(offY[r][c])>0.5){ offY[r][c]*= (1-speed); moving=true; } else offY[r][c]=0;
            // spawn scale ease to 1
            if(spawnScale[r][c] < 0.999){ spawnScale[r][c] += (1-spawnScale[r][c])*0.28; if(spawnScale[r][c]>0.995) spawnScale[r][c]=1; moving=true; }
          }
        }
        draw();
        if(moving){ rafId = requestAnimationFrame(step); }
        else { animating=false; cb && cb(); }
      }
      rafId = requestAnimationFrame(step);
    }

    function animatePops(marks, cb){
      animating = true;
      function step(){
        var moving=false;
        for(var r=0;r<N;r++){
          for(var c=0;c<N;c++){
            if(marks[r][c] && popScale[r][c]>0.02){
              popScale[r][c] -= 0.14;
              if(popScale[r][c]<0) popScale[r][c]=0;
              moving=true;
            }
          }
        }
        draw();
        if(moving) rafId=requestAnimationFrame(step);
        else { animating=false; cb && cb(); }
      }
      rafId=requestAnimationFrame(step);
    }

    // ---------- resolve cascades ----------
    function resolveBoard(chain){
      var marks = findMatches();
      var n = countMarked(marks);
      if(n===0){
        // done cascading
        animating=false;
        checkEndAndShuffle();
        draw();
        return;
      }
      sfxMatch(chain||0);
      // score: base 10 per gem, cascade multiplier
      var mult = 1 + (chain||0)*0.5;
      var gained = Math.round(n * 10 * mult);
      score += gained;
      if(score>best){ best=score; saveBest(); }
      updateHUD();
      if((chain||0)>0) setMsg("Combo x"+(chain+1)+"  +"+gained);
      else setMsg("+"+gained);

      // animate pop, then collapse + refill
      animatePops(marks, function(){
        // clear matched
        for(var r=0;r<N;r++) for(var c=0;c<N;c++){ if(marks[r][c]){ grid[r][c]=-1; popScale[r][c]=1; } }
        collapseAndRefill();
        // animate the falls then resolve again (cascade)
        animateOffsets(function(){
          resolveBoard((chain||0)+1);
        });
      });
    }

    // gravity: pull down, spawn new at top; set offY so they animate falling
    function collapseAndRefill(){
      for(var c=0;c<N;c++){
        // gather existing from bottom up
        var writeR = N-1;
        for(var r=N-1;r>=0;r--){
          if(grid[r][c]!==-1){
            if(writeR!==r){
              grid[writeR][c]=grid[r][c];
              grid[r][c]=-1;
              // offset so it visually starts where it was, then eases to 0
              offY[writeR][c] = (r-writeR)*cell;
              spawnScale[writeR][c]=1;
            }
            writeR--;
          }
        }
        // fill remaining top with new gems, dropping in from above
        var spawnIdx = 0;
        for(var rr=writeR; rr>=0; rr--){
          grid[rr][c]=rndColor();
          offY[rr][c] = -(cell*(spawnIdx+1)) - cell; // start above the board
          spawnScale[rr][c]=0.35;
          spawnIdx++;
        }
      }
      sfxDrop();
    }

    function checkEndAndShuffle(){
      if(!hasValidMove()){
        setMsg("No moves — reshuffling…");
        shuffleBoard();
        resetAnimArrays();
        draw();
        // after shuffle there should be no immediate matches (guarded)
      }
      if(moves<=0){
        gameOver=true;
        setMsg("Out of moves — Score "+score+". Tap New Game.");
      }
    }

    // ---------- input ----------
    function cellFromEvent(clientX, clientY){
      var rect = canvas.getBoundingClientRect();
      var x = clientX - rect.left;
      var y = clientY - rect.top;
      // canvas CSS size maps to N*cell logical (cell already logical)
      var px = x * (canvas.width/dpr) / rect.width;
      var py = y * (canvas.height/dpr) / rect.height;
      var c = Math.floor(px / cell);
      var r = Math.floor(py / cell);
      if(!inBounds(r,c)) return null;
      return {r:r,c:c};
    }

    function tryMove(a, b){
      // must be adjacent
      var dr = Math.abs(a.r-b.r), dc = Math.abs(a.c-b.c);
      if(dr+dc!==1) return false;
      if(gameOver) return false;
      sfxSwap();
      // do swap
      var t=grid[a.r][a.c]; grid[a.r][a.c]=grid[b.r][b.c]; grid[b.r][b.c]=t;
      // set offsets for swap animation (each moves toward its new home)
      offX[a.r][a.c] = (b.c-a.c)*cell; offY[a.r][a.c] = (b.r-a.r)*cell;
      offX[b.r][b.c] = (a.c-b.c)*cell; offY[b.r][b.c] = (a.r-b.r)*cell;

      var m = findMatches();
      if(countMarked(m)===0){
        // invalid: animate swap in, then swap back
        sfxBad();
        animateOffsets(function(){
          var t2=grid[a.r][a.c]; grid[a.r][a.c]=grid[b.r][b.c]; grid[b.r][b.c]=t2;
          offX[a.r][a.c]=(b.c-a.c)*cell; offY[a.r][a.c]=(b.r-a.r)*cell;
          offX[b.r][b.c]=(a.c-b.c)*cell; offY[b.r][b.c]=(a.r-b.r)*cell;
          animateOffsets(function(){ setMsg("No match there"); });
        });
        return true; // was a valid adjacent attempt (but no match)
      }
      // valid move
      moves--;
      updateHUD();
      animateOffsets(function(){
        resolveBoard(0);
      });
      return true;
    }

    var pointerDown = null; // {r,c,x,y}
    function onDown(clientX, clientY){
      ensureAudio();
      if(animating || gameOver) return;
      var cellPos = cellFromEvent(clientX, clientY);
      if(!cellPos) return;
      pointerDown = {r:cellPos.r, c:cellPos.c, x:clientX, y:clientY};
      if(sel){
        // second click: attempt swap or reselect
        if(sel.r===cellPos.r && sel.c===cellPos.c){
          sel=null; draw(); return;
        }
        var dr=Math.abs(sel.r-cellPos.r), dc=Math.abs(sel.c-cellPos.c);
        if(dr+dc===1){
          var a={r:sel.r,c:sel.c}; sel=null;
          tryMove(a, cellPos);
          draw();
        } else {
          sel={r:cellPos.r,c:cellPos.c}; draw();
        }
      } else {
        sel={r:cellPos.r,c:cellPos.c}; draw();
      }
    }

    function onUp(clientX, clientY){
      if(!pointerDown) return;
      if(animating || gameOver){ pointerDown=null; return; }
      var dx = clientX - pointerDown.x;
      var dy = clientY - pointerDown.y;
      var absx=Math.abs(dx), absy=Math.abs(dy);
      var threshold = cell*0.3;
      if(Math.max(absx,absy) > threshold){
        // treat as swipe from the down cell
        var a={r:pointerDown.r, c:pointerDown.c};
        var b;
        if(absx>absy) b={r:a.r, c:a.c + (dx>0?1:-1)};
        else b={r:a.r + (dy>0?1:-1), c:a.c};
        if(inBounds(b.r,b.c)){
          sel=null;
          tryMove(a,b);
          draw();
        }
      }
      pointerDown=null;
    }

    // mouse
    canvas.addEventListener('mousedown', function(e){ e.preventDefault(); onDown(e.clientX,e.clientY); });
    window.addEventListener('mouseup', function(e){ onUp(e.clientX,e.clientY); });
    // touch
    canvas.addEventListener('touchstart', function(e){
      if(e.touches.length){ e.preventDefault(); var t=e.touches[0]; onDown(t.clientX,t.clientY); }
    }, {passive:false});
    canvas.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
    canvas.addEventListener('touchend', function(e){
      e.preventDefault();
      var t = (e.changedTouches && e.changedTouches.length) ? e.changedTouches[0] : null;
      if(t) onUp(t.clientX,t.clientY);
    }, {passive:false});

    // buttons
    soundBtn.addEventListener('click', function(){
      soundOn = !soundOn;
      soundBtn.textContent = "Sound: " + (soundOn?"On":"Off");
      if(soundOn){ ensureAudio(); blip(660,0.08,"sine",0.1); }
    });
    newBtn.addEventListener('click', function(){ ensureAudio(); newGame(); });

    // resize handling
    var resizeTimer=null;
    window.addEventListener('resize', function(){
      clearTimeout(resizeTimer);
      resizeTimer=setTimeout(resize, 60);
    });

    // ---------- init ----------
    loadBest();
    newGame();
    resize();
  }

  if(document.readyState === "loading"){
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
})();
</script>
</body>
</html>