← back to Games Agentabrams

games/hopper/AbramsHopper.aa

794 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>HOPPER</title>
<style>
  html,body{margin:0;padding:0;height:100%;background:#0a0e14;overflow:hidden;}
  body{
    touch-action:none;
    -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
    font-family:'Trebuchet MS','Segoe UI',system-ui,-apple-system,sans-serif;
    display:flex;align-items:center;justify-content:center;
    color:#cfe;
  }
  #wrap{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;}
  canvas{display:block;background:#0a0e14;touch-action:none;image-rendering:optimizeQuality;}
</style>
</head>
<body>
<div id="wrap"><canvas id="c"></canvas></div>
<script>
(function(){
  "use strict";

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

    // ---------- Config ----------
    var COLS = 13;          // grid columns
    var ROWS = 15;          // grid rows (0 = home row, 14 = start row)
    var BEST_KEY = "abrams-hopper-best";
    var DPR = 1;
    var cell = 40;          // px per cell (computed on resize)
    var W = COLS*cell, H = ROWS*cell;
    var offX = 0, offY = 0; // canvas draw offsets (letterbox)

    // ---------- Audio (lazy) ----------
    var actx = null;
    function ensureAudio(){
      if(actx) return;
      try{
        var AC = window.AudioContext || window.webkitAudioContext;
        if(AC){ actx = new AC(); }
      }catch(e){ actx = null; }
    }
    function blip(freq, dur, type, vol){
      if(!actx) return;
      try{
        if(actx.state === "suspended"){ actx.resume(); }
        var o = actx.createOscillator();
        var g = actx.createGain();
        o.type = type || "square";
        o.frequency.value = freq;
        g.gain.value = 0.0001;
        o.connect(g); g.connect(actx.destination);
        var t = actx.currentTime;
        var v = (vol==null?0.12:vol);
        g.gain.setValueAtTime(0.0001, t);
        g.gain.exponentialRampToValueAtTime(v, t+0.01);
        g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
        o.start(t);
        o.stop(t+dur+0.02);
      }catch(e){}
    }
    function sHop(){ blip(660,0.08,"square",0.10); }
    function sSplat(){ blip(120,0.28,"sawtooth",0.16); setTimeout(function(){blip(80,0.22,"sawtooth",0.14);},40); }
    function sHome(){ blip(880,0.10,"triangle",0.14); setTimeout(function(){blip(1320,0.14,"triangle",0.12);},90); }
    function sLevel(){ blip(523,0.10,"triangle",0.12); setTimeout(function(){blip(659,0.10,"triangle",0.12);},110); setTimeout(function(){blip(784,0.16,"triangle",0.12);},220); }

    // ---------- Layout ----------
    // Rows (top to bottom):
    // 0        : HOME row (goal slots)
    // 1..5     : RIVER lanes (logs/turtles)
    // 6        : SAFE median
    // 7..11    : ROAD lanes (cars)
    // 12       : SAFE start-adjacent
    // 13..14   : start area (grass)
    var HOME_ROW = 0;
    var RIVER_ROWS = [1,2,3,4,5];
    var MEDIAN_ROW = 6;
    var ROAD_ROWS = [7,8,9,10,11];
    var SAFE_ROW = 12;
    var START_ROW = 13;

    // Home slots: positions (in columns) that must be filled
    var HOME_SLOTS = []; // {col, filled}
    function setupHomeSlots(){
      HOME_SLOTS = [];
      var cols = [1,4,6,8,11]; // 5 slots spread across
      for(var i=0;i<cols.length;i++){
        HOME_SLOTS.push({col:cols[i], filled:false});
      }
    }

    // ---------- Entities ----------
    var lanes = []; // active moving lanes: {row, type:'car'|'log', dir, speed, gap, len, color, items:[{x}]}
    function buildLanes(level){
      lanes = [];
      var baseSpeed = 1.4 + (level-1)*0.28; // cells/sec baseline
      // Road lanes (cars)
      var carColors = ["#ff3b6b","#ffd23b","#3bff9d","#5b8cff","#ff8f3b"];
      for(var r=0;r<ROAD_ROWS.length;r++){
        var row = ROAD_ROWS[r];
        var dir = (r%2===0)? 1 : -1;
        var speed = baseSpeed*(0.75 + r*0.16) * (1 + (level-1)*0.05);
        var len = 1 + (r%2); // 1 or 2 cells long
        var gap = 3.2 + (r%3)*0.9;
        lanes.push(makeLane(row,'car',dir,speed,gap,len,carColors[r%carColors.length]));
      }
      // River lanes (logs & turtles)
      for(var rr=0;rr<RIVER_ROWS.length;rr++){
        var rw = RIVER_ROWS[rr];
        var d = (rr%2===0)? -1 : 1;
        var sp = baseSpeed*(0.6 + rr*0.14) * (1 + (level-1)*0.05);
        var isTurtle = (rr%2===1);
        var llen = isTurtle ? (2 + (rr%2)) : (2 + (rr%3)); // 2..4
        var lgap = 2.6 + (rr%2)*0.8;
        var lane = makeLane(rw, 'log', d, sp, lgap, llen, isTurtle?"#2ee6c8":"#c8975a");
        lane.turtle = isTurtle;
        lanes.push(lane);
      }
    }
    function makeLane(row,type,dir,speed,gap,len,color){
      var items = [];
      var span = len + gap;
      // fill across a wider range so wrap looks continuous
      var total = Math.ceil((COLS + span*2)/span);
      var startX = -span;
      for(var i=0;i<total;i++){
        items.push({ x: startX + i*span + (Math.random()*0.4-0.2) });
      }
      return {row:row,type:type,dir:dir,speed:speed,gap:gap,len:len,color:color,items:items,span:span};
    }

    // ---------- Player ----------
    var player = {col:6, row:START_ROW, px:6, py:START_ROW, riding:null, alive:true};
    function resetPlayerPos(){
      player.col = 6; player.row = START_ROW;
      player.px = 6; player.py = START_ROW;
      player.riding = null; player.alive = true;
    }

    // ---------- Game State ----------
    var state = "title"; // title | play | dead | over | win
    var level = 1;
    var lives = 3;
    var score = 0;
    var best = 0;
    try{ var b = localStorage.getItem(BEST_KEY); if(b){ best = parseInt(b,10)||0; } }catch(e){}
    var timeMax = 30;      // seconds per crossing
    var timeLeft = timeMax;
    var deadTimer = 0;
    var farthestRow = START_ROW;

    function startGame(){
      level = 1; lives = 3; score = 0;
      setupHomeSlots();
      buildLanes(level);
      resetPlayerPos();
      timeMax = 30; timeLeft = timeMax;
      farthestRow = START_ROW;
      state = "play";
    }
    function nextLevel(){
      level++;
      setupHomeSlots();
      buildLanes(level);
      resetPlayerPos();
      timeMax = Math.max(18, 30 - (level-1)*2);
      timeLeft = timeMax;
      farthestRow = START_ROW;
      score += 500; // level bonus
      state = "play";
      sLevel();
    }
    function loseLife(){
      lives--;
      sSplat();
      if(lives<=0){
        commitBest();
        state = "over";
      }else{
        state = "dead";
        deadTimer = 0.9;
      }
    }
    function commitBest(){
      if(score>best){
        best = score;
        try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
      }
    }

    // ---------- Movement ----------
    function tryMove(dc,dr){
      if(state!=="play" || !player.alive) return;
      var nc = player.col + dc;
      var nr = player.row + dr;
      if(nc<0||nc>=COLS||nr<0||nr>ROWS-1) return;
      // Landing on home row: must hit an empty slot
      if(nr===HOME_ROW){
        var slot = null;
        for(var i=0;i<HOME_SLOTS.length;i++){
          if(!HOME_SLOTS[i].filled && Math.abs(HOME_SLOTS[i].col - nc) <= 0){
            slot = HOME_SLOTS[i]; break;
          }
        }
        // allow snapping to nearest empty slot within 1 col
        if(!slot){
          for(var j=0;j<HOME_SLOTS.length;j++){
            if(!HOME_SLOTS[j].filled && Math.abs(HOME_SLOTS[j].col - nc) === 1){
              slot = HOME_SLOTS[j]; break;
            }
          }
        }
        if(slot){
          slot.filled = true;
          score += 100 + Math.round(timeLeft*10);
          sHome();
          // Check all filled
          var allFilled = true;
          for(var k=0;k<HOME_SLOTS.length;k++){ if(!HOME_SLOTS[k].filled){ allFilled=false; break; } }
          if(allFilled){
            nextLevel();
          }else{
            resetPlayerPos();
            timeLeft = timeMax;
            farthestRow = START_ROW;
          }
          return;
        }else{
          // hit the wall between slots => death
          player.col = nc; player.row = nr;
          player.px = nc; player.py = nr;
          player.alive = false;
          loseLife();
          return;
        }
      }
      player.col = nc; player.row = nr;
      player.px = nc; player.py = nr;
      player.riding = null;
      // forward progress score
      if(nr < farthestRow){
        farthestRow = nr;
        score += 10;
      }
      sHop();
    }

    // ---------- Update ----------
    function update(dt){
      if(state==="dead"){
        deadTimer -= dt;
        if(deadTimer<=0){
          resetPlayerPos();
          timeLeft = timeMax;
          farthestRow = START_ROW;
          state = "play";
        }
        return;
      }
      if(state!=="play") return;

      // move lanes
      for(var i=0;i<lanes.length;i++){
        var ln = lanes[i];
        var d = ln.dir * ln.speed * dt;
        for(var m=0;m<ln.items.length;m++){
          ln.items[m].x += d;
          // wrap
          if(ln.dir>0 && ln.items[m].x > COLS+1){
            ln.items[m].x -= ln.span * ln.items.length;
          }else if(ln.dir<0 && ln.items[m].x < -ln.len-1){
            ln.items[m].x += ln.span * ln.items.length;
          }
        }
      }

      // timer
      timeLeft -= dt;
      if(timeLeft<=0){
        timeLeft = 0;
        player.alive = false;
        loseLife();
        return;
      }

      // River carry / drown logic
      var row = player.row;
      var inRiver = (RIVER_ROWS.indexOf(row) !== -1);
      if(inRiver){
        var onLog = null;
        for(var L=0;L<lanes.length;L++){
          if(lanes[L].row!==row) continue;
          var lane = lanes[L];
          for(var it=0; it<lane.items.length; it++){
            var x0 = lane.items[it].x;
            var x1 = x0 + lane.len;
            // player center on this log?
            if(player.px+0.5 >= x0-0.05 && player.px+0.5 <= x1+0.05){
              onLog = {lane:lane, item:lane.items[it]};
              break;
            }
          }
          if(onLog) break;
        }
        if(onLog){
          // carried by log
          player.px += onLog.lane.dir * onLog.lane.speed * dt;
          player.riding = onLog;
          // carried off screen => drown
          if(player.px < -0.6 || player.px > COLS-0.4){
            player.alive = false;
            loseLife();
            return;
          }
          player.col = Math.round(player.px);
        }else{
          // in water, no log => drown
          player.alive = false;
          loseLife();
          return;
        }
      }else{
        player.riding = null;
        player.px = player.col;
      }

      // Road collision
      if(ROAD_ROWS.indexOf(row) !== -1){
        for(var c=0;c<lanes.length;c++){
          if(lanes[c].row!==row) continue;
          var cl = lanes[c];
          for(var ci=0; ci<cl.items.length; ci++){
            var cx0 = cl.items[ci].x;
            var cx1 = cx0 + cl.len;
            var pcx = player.col + 0.5;
            if(pcx > cx0 && pcx < cx1){
              player.alive = false;
              loseLife();
              return;
            }
          }
        }
      }
    }

    // ---------- Render ----------
    function drawRoundRect(x,y,w,h,r){
      var rr = Math.min(r, w/2, h/2);
      ctx.beginPath();
      ctx.moveTo(x+rr,y);
      ctx.arcTo(x+w,y,x+w,y+h,rr);
      ctx.arcTo(x+w,y+h,x,y+h,rr);
      ctx.arcTo(x,y+h,x,y,rr);
      ctx.arcTo(x,y,x+w,y,rr);
      ctx.closePath();
    }

    function render(){
      ctx.save();
      ctx.setTransform(DPR,0,0,DPR,0,0);
      ctx.clearRect(0,0,canvas.width,canvas.height);
      // full bg
      ctx.fillStyle = "#0a0e14";
      ctx.fillRect(0,0,canvas.width/DPR,canvas.height/DPR);
      ctx.translate(offX, offY);
      // clip all field content to the grid rect so lane items that spawn
      // off-screen (x<0 or x>COLS) don't bleed into the letterbox area
      ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip();

      // playfield bg
      ctx.fillStyle = "#080b11";
      ctx.fillRect(0,0,W,H);

      // ----- row backgrounds -----
      // home band
      ctx.fillStyle = "#0d1a14";
      ctx.fillRect(0, HOME_ROW*cell, W, cell);
      // river band
      ctx.fillStyle = "#071726";
      ctx.fillRect(0, RIVER_ROWS[0]*cell, W, RIVER_ROWS.length*cell);
      // subtle water lines
      ctx.strokeStyle = "rgba(60,140,220,0.12)";
      ctx.lineWidth = 1;
      for(var rl=0; rl<RIVER_ROWS.length; rl++){
        var ry = (RIVER_ROWS[rl])*cell + cell*0.5;
        ctx.beginPath(); ctx.moveTo(0,ry); ctx.lineTo(W,ry); ctx.stroke();
      }
      // median
      ctx.fillStyle = "#12241a";
      ctx.fillRect(0, MEDIAN_ROW*cell, W, cell);
      // road band
      ctx.fillStyle = "#0c0f16";
      ctx.fillRect(0, ROAD_ROWS[0]*cell, W, ROAD_ROWS.length*cell);
      // lane dashes
      ctx.strokeStyle = "rgba(255,255,255,0.10)";
      ctx.setLineDash([cell*0.35, cell*0.3]);
      ctx.lineWidth = 2;
      for(var rd=0; rd<ROAD_ROWS.length; rd++){
        var yy = (ROAD_ROWS[rd])*cell + cell;
        if(rd<ROAD_ROWS.length){
          ctx.beginPath(); ctx.moveTo(0,yy); ctx.lineTo(W,yy); ctx.stroke();
        }
      }
      ctx.setLineDash([]);
      // safe & start grass
      ctx.fillStyle = "#0e1f16";
      ctx.fillRect(0, SAFE_ROW*cell, W, (ROWS-SAFE_ROW)*cell);

      // neon band edges
      ctx.strokeStyle = "rgba(46,230,200,0.25)";
      ctx.lineWidth = 2;
      ctx.strokeRect(1,1,W-2,H-2);

      // ----- home slots -----
      for(var hs=0; hs<HOME_SLOTS.length; hs++){
        var slot = HOME_SLOTS[hs];
        var sx = slot.col*cell;
        var sy = HOME_ROW*cell;
        ctx.save();
        if(slot.filled){
          ctx.shadowColor = "#2ee6c8"; ctx.shadowBlur = 14;
          ctx.fillStyle = "#2ee6c8";
          drawRoundRect(sx+cell*0.16, sy+cell*0.16, cell*0.68, cell*0.68, 8);
          ctx.fill();
          ctx.shadowBlur = 0;
          ctx.fillStyle = "#04120e";
          ctx.fillRect(sx+cell*0.42, sy+cell*0.28, cell*0.16, cell*0.34);
        }else{
          ctx.strokeStyle = "rgba(120,255,220,0.55)";
          ctx.lineWidth = 2;
          drawRoundRect(sx+cell*0.14, sy+cell*0.14, cell*0.72, cell*0.72, 8);
          ctx.stroke();
        }
        ctx.restore();
      }

      // ----- lanes -----
      for(var i=0;i<lanes.length;i++){
        var ln = lanes[i];
        var y = ln.row*cell;
        for(var m=0;m<ln.items.length;m++){
          var ix = ln.items[m].x*cell;
          if(ix > W+cell || ix < -ln.len*cell-cell) continue;
          if(ln.type==='car'){
            ctx.save();
            ctx.shadowColor = ln.color; ctx.shadowBlur = 12;
            ctx.fillStyle = ln.color;
            drawRoundRect(ix+3, y+cell*0.18, ln.len*cell-6, cell*0.64, 7);
            ctx.fill();
            ctx.shadowBlur = 0;
            // windows
            ctx.fillStyle = "rgba(10,14,20,0.6)";
            ctx.fillRect(ix+ (ln.dir>0? ln.len*cell-16 : 8), y+cell*0.28, 8, cell*0.44);
            ctx.restore();
          }else{
            // log or turtle
            ctx.save();
            if(ln.turtle){
              ctx.shadowColor = ln.color; ctx.shadowBlur = 10;
              ctx.fillStyle = ln.color;
              for(var t=0;t<ln.len;t++){
                ctx.beginPath();
                ctx.ellipse(ix + t*cell + cell*0.5, y+cell*0.5, cell*0.34, cell*0.30, 0, 0, Math.PI*2);
                ctx.fill();
              }
              ctx.shadowBlur=0;
              ctx.fillStyle="rgba(4,30,26,0.5)";
              for(var t2=0;t2<ln.len;t2++){
                ctx.beginPath();
                ctx.ellipse(ix + t2*cell + cell*0.5, y+cell*0.5, cell*0.16, cell*0.14, 0, 0, Math.PI*2);
                ctx.fill();
              }
            }else{
              ctx.shadowColor = "rgba(200,150,90,0.5)"; ctx.shadowBlur = 8;
              ctx.fillStyle = ln.color;
              drawRoundRect(ix+2, y+cell*0.20, ln.len*cell-4, cell*0.60, 9);
              ctx.fill();
              ctx.shadowBlur=0;
              ctx.strokeStyle="rgba(80,50,20,0.5)";
              ctx.lineWidth=1;
              for(var g=1;g<ln.len;g++){
                ctx.beginPath();
                ctx.moveTo(ix+g*cell, y+cell*0.22);
                ctx.lineTo(ix+g*cell, y+cell*0.78);
                ctx.stroke();
              }
            }
            ctx.restore();
          }
        }
      }

      // ----- player -----
      if(state!=="over" && state!=="title"){
        var pxPix = player.px*cell;
        var pyPix = player.row*cell;
        ctx.save();
        var glow = player.alive ? "#7dffb0" : "#ff5b6b";
        ctx.shadowColor = glow; ctx.shadowBlur = 18;
        ctx.fillStyle = glow;
        // body
        drawRoundRect(pxPix+cell*0.18, pyPix+cell*0.18, cell*0.64, cell*0.64, 10);
        ctx.fill();
        ctx.shadowBlur = 0;
        // eyes
        ctx.fillStyle = "#04120e";
        ctx.beginPath();
        ctx.arc(pxPix+cell*0.38, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
        ctx.arc(pxPix+cell*0.62, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
        ctx.fill();
        ctx.restore();
      }

      // ----- HUD -----
      drawHUD();

      // overlays
      if(state==="title") drawTitle();
      else if(state==="over") drawOver();

      ctx.restore();
    }

    function drawHUD(){
      var pad = 6;
      ctx.save();
      ctx.textBaseline = "top";
      // score
      var fs = Math.max(11, Math.round(cell*0.32));
      ctx.font = "bold "+fs+"px 'Trebuchet MS',sans-serif";
      ctx.fillStyle = "#8ff";
      ctx.textAlign = "left";
      ctx.fillText("SCORE "+score, pad+2, pad);
      ctx.fillStyle = "#8fa";
      ctx.textAlign = "center";
      ctx.fillText("BEST "+best, W/2, pad);
      // lives
      ctx.textAlign = "right";
      ctx.fillStyle = "#ff7d9c";
      var lifeStr = "";
      for(var i=0;i<lives;i++){ lifeStr += "♥"; }
      ctx.fillText("LV"+level+"  "+lifeStr, W-pad-2, pad);

      // timer bar
      if(state==="play" || state==="dead"){
        var barY = HOME_ROW*cell + cell + 3;
        var barH = 5;
        var frac = Math.max(0, timeLeft/timeMax);
        ctx.fillStyle = "rgba(255,255,255,0.08)";
        ctx.fillRect(6, barY, W-12, barH);
        var col = frac>0.5? "#2ee6c8" : (frac>0.25? "#ffd23b" : "#ff3b6b");
        ctx.shadowColor = col; ctx.shadowBlur = 8;
        ctx.fillStyle = col;
        ctx.fillRect(6, barY, (W-12)*frac, barH);
        ctx.shadowBlur = 0;
      }
      ctx.restore();
    }

    function neonTitleText(txt, cx, cy, size, color){
      ctx.save();
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.font = "bold "+size+"px 'Trebuchet MS',sans-serif";
      // letter-spacing manual
      var ls = size*0.16;
      var chars = txt.split("");
      var total = 0;
      for(var i=0;i<chars.length;i++){ total += ctx.measureText(chars[i]).width + ls; }
      total -= ls;
      var x = cx - total/2;
      ctx.shadowColor = color; ctx.shadowBlur = 22;
      ctx.fillStyle = color;
      for(var j=0;j<chars.length;j++){
        var w = ctx.measureText(chars[j]).width;
        ctx.fillText(chars[j], x + w/2, cy);
        x += w + ls;
      }
      ctx.restore();
    }

    function drawTitle(){
      ctx.save();
      ctx.fillStyle = "rgba(4,7,12,0.82)";
      ctx.fillRect(0,0,W,H);
      neonTitleText("HOPPER", W/2, H*0.30, Math.round(cell*1.05), "#2ee6c8");
      ctx.textAlign = "center";
      ctx.fillStyle = "#bfe";
      var s = Math.max(12, Math.round(cell*0.34));
      ctx.font = s+"px 'Trebuchet MS',sans-serif";
      ctx.fillText("Cross the road & river", W/2, H*0.44);
      ctx.fillText("Fill all home slots", W/2, H*0.50);
      ctx.fillStyle = "#8fa";
      ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
      ctx.fillText("ARROWS / WASD to hop", W/2, H*0.60);
      // pulsing prompt
      var a = 0.55 + 0.45*Math.sin(Date.now()/300);
      ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
      ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
      ctx.fillText("TAP / PRESS TO START", W/2, H*0.72);
      ctx.restore();
    }

    function drawOver(){
      ctx.save();
      ctx.fillStyle = "rgba(12,4,8,0.85)";
      ctx.fillRect(0,0,W,H);
      neonTitleText("GAME OVER", W/2, H*0.34, Math.round(cell*0.82), "#ff3b6b");
      ctx.textAlign="center";
      var s = Math.max(12, Math.round(cell*0.4));
      ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
      ctx.fillStyle = "#8ff";
      ctx.fillText("SCORE "+score, W/2, H*0.48);
      ctx.fillStyle = "#8fa";
      ctx.fillText("BEST "+best, W/2, H*0.55);
      var a = 0.55 + 0.45*Math.sin(Date.now()/300);
      ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
      ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
      ctx.fillText("TAP / PRESS TO RETRY", W/2, H*0.68);
      ctx.restore();
    }

    // ---------- D-pad (touch) ----------
    var dpad = null; // {cx,cy,r} in canvas coords
    function computeDpad(){
      // place bottom-left area over start grass
      var r = cell*1.1;
      dpad = { cx: r*1.15, cy: H - r*1.15, r: r };
    }
    function drawDpad(){
      if(!dpad) return;
      if(state!=="play" && state!=="dead") return;
      ctx.save();
      ctx.translate(offX, offY);
      ctx.globalAlpha = 0.5;
      var d = dpad;
      var arm = d.r*0.62, w = d.r*0.42;
      ctx.fillStyle = "rgba(46,230,200,0.14)";
      ctx.strokeStyle = "rgba(46,230,200,0.55)";
      ctx.lineWidth = 2;
      function armRect(ang){
        ctx.save();
        ctx.translate(d.cx,d.cy);
        ctx.rotate(ang);
        drawRoundRect(w*0.2, -w/2, arm, w, 6);
        ctx.fill(); ctx.stroke();
        // arrow tip
        ctx.fillStyle="rgba(180,255,235,0.8)";
        ctx.beginPath();
        ctx.moveTo(arm+w*0.2, 0);
        ctx.lineTo(arm-w*0.05, -w*0.28);
        ctx.lineTo(arm-w*0.05, w*0.28);
        ctx.closePath();
        ctx.fill();
        ctx.fillStyle="rgba(46,230,200,0.14)";
        ctx.restore();
      }
      armRect(-Math.PI/2); // up
      armRect(Math.PI/2);  // down
      armRect(Math.PI);    // left
      armRect(0);          // right
      ctx.restore();
    }
    function dpadHit(x,y){
      if(!dpad) return null;
      var dx = x - dpad.cx, dy = y - dpad.cy;
      var dist = Math.sqrt(dx*dx+dy*dy);
      if(dist > dpad.r*1.35) return null;
      if(dist < dpad.r*0.18) return null;
      if(Math.abs(dx) > Math.abs(dy)){
        return dx>0 ? "right" : "left";
      }else{
        return dy>0 ? "down" : "up";
      }
    }

    // ---------- Input ----------
    function handleDir(dir){
      if(dir==="up") tryMove(0,-1);
      else if(dir==="down") tryMove(0,1);
      else if(dir==="left") tryMove(-1,0);
      else if(dir==="right") tryMove(1,0);
    }

    function onKey(e){
      var k = e.key;
      var used = false;
      if(k==="ArrowUp"||k==="w"||k==="W"){ used=true; act("up"); }
      else if(k==="ArrowDown"||k==="s"||k==="S"){ used=true; act("down"); }
      else if(k==="ArrowLeft"||k==="a"||k==="A"){ used=true; act("left"); }
      else if(k==="ArrowRight"||k==="d"||k==="D"){ used=true; act("right"); }
      else if(k===" "||k==="Enter"){ used=true; act("start"); }
      if(used){ e.preventDefault(); }
    }
    function act(dir){
      ensureAudio();
      if(state==="title"){ startGame(); return; }
      if(state==="over"){ startGame(); return; }
      handleDir(dir);
    }

    window.addEventListener("keydown", onKey, {passive:false});

    // touch / pointer
    function canvasPoint(e){
      var rect = canvas.getBoundingClientRect();
      var cx = (e.clientX - rect.left) - offX;
      var cy = (e.clientY - rect.top) - offY;
      return {x:cx, y:cy};
    }
    function onPointerDown(e){
      e.preventDefault();
      ensureAudio();
      if(state==="title"||state==="over"){ startGame(); return; }
      var p = canvasPoint(e);
      var hit = dpadHit(p.x, p.y);
      if(hit){ handleDir(hit); return; }
      // swipe-region fallback: tap relative to player => hop toward tap
      var pcx = (player.px+0.5)*cell;
      var pcy = (player.row+0.5)*cell;
      var dx = p.x - pcx, dy = p.y - pcy;
      if(Math.abs(dx)<cell*0.4 && Math.abs(dy)<cell*0.4){ tryMove(0,-1); return; }
      if(Math.abs(dx) > Math.abs(dy)){ handleDir(dx>0?"right":"left"); }
      else{ handleDir(dy>0?"down":"up"); }
    }
    canvas.addEventListener("pointerdown", onPointerDown, {passive:false});
    canvas.addEventListener("touchstart", function(e){ e.preventDefault(); }, {passive:false});

    // ---------- Resize ----------
    function resize(){
      var wrap = document.getElementById("wrap");
      var availW = wrap.clientWidth || window.innerWidth;
      var availH = wrap.clientHeight || window.innerHeight;
      if(availW<=0) availW = window.innerWidth;
      if(availH<=0) availH = window.innerHeight;
      DPR = Math.min(window.devicePixelRatio||1, 2);

      // choose cell to fit both dims
      var cw = Math.floor(availW / COLS);
      var ch = Math.floor(availH / ROWS);
      cell = Math.max(12, Math.min(cw, ch));
      W = COLS*cell; H = ROWS*cell;

      canvas.width = Math.floor(availW*DPR);
      canvas.height = Math.floor(availH*DPR);
      canvas.style.width = availW+"px";
      canvas.style.height = availH+"px";

      offX = Math.floor((availW - W)/2);
      offY = Math.floor((availH - H)/2);

      computeDpad();

      // rebuild lane item x extents not needed (grid-relative), but ensure lanes exist
      if(lanes.length===0 && state!=="title"){ buildLanes(level); }
    }
    window.addEventListener("resize", resize);

    // ---------- Loop ----------
    var last = 0;
    function frame(ts){
      if(!last) last = ts;
      var dt = (ts - last)/1000;
      last = ts;
      if(dt>0.1) dt = 0.1; // clamp
      update(dt);
      render();
      drawDpad();
      requestAnimationFrame(frame);
    }

    // ---------- Init ----------
    setupHomeSlots();
    resize();
    requestAnimationFrame(frame);
  }

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