← back to Games Agentabrams

games/tower-stack/AbramsStack.aa

515 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Abrams Stack</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a18; }
  #wrap {
    position: fixed; inset: 0;
    display: flex; align-items: center; justify-content: center;
    background: radial-gradient(ellipse at 50% 0%, #1a1240 0%, #0a0a18 60%, #050510 100%);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    user-select: none;
  }
  canvas { display: block; touch-action: none; }

  /* HUD */
  #hud {
    position: absolute; top: 0; left: 0; right: 0;
    display: flex; justify-content: space-between; align-items: flex-start;
    padding: 18px 20px; pointer-events: none; z-index: 5;
  }
  .stat { color: #fff; text-shadow: 0 2px 12px rgba(0,0,0,.6); }
  .stat .label { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; opacity: .55; }
  .stat .val { font-size: 34px; font-weight: 800; line-height: 1; margin-top: 2px;
    background: linear-gradient(180deg,#fff,#b9c4ff); -webkit-background-clip: text; background-clip: text; color: transparent; }
  .stat.right { text-align: right; }
  #combo {
    position:absolute; top: 78px; left: 50%; transform: translateX(-50%);
    font-size: 15px; font-weight: 700; letter-spacing: 1px; color: #ffe27a;
    text-shadow: 0 0 18px rgba(255,210,80,.7); opacity: 0; transition: opacity .2s; pointer-events:none; z-index:5;
  }

  /* Mute */
  #mute {
    position: absolute; top: 16px; right: 50%; transform: translateX(50%);
    width: 42px; height: 42px; border-radius: 12px;
    background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.14);
    color: #fff; font-size: 19px; cursor: pointer; z-index: 6;
    display:flex; align-items:center; justify-content:center; backdrop-filter: blur(6px);
    transition: background .15s, transform .1s;
  }
  #mute:hover { background: rgba(255,255,255,.14); }
  #mute:active { transform: translateX(50%) scale(.9); }

  /* Overlay */
  #overlay {
    position: absolute; inset: 0; z-index: 10;
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    text-align: center; padding: 24px;
    background: radial-gradient(ellipse at 50% 40%, rgba(30,20,70,.55), rgba(5,5,16,.9));
    backdrop-filter: blur(3px);
    transition: opacity .35s;
  }
  #overlay.hidden { opacity: 0; pointer-events: none; }
  #title {
    font-size: clamp(42px, 12vw, 92px); font-weight: 900; letter-spacing: -1px; line-height: .95;
    background: linear-gradient(120deg,#7af0ff,#a97bff 45%,#ff7ac6 90%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    filter: drop-shadow(0 4px 26px rgba(160,110,255,.45));
  }
  #subtitle { margin-top: 14px; font-size: 15px; letter-spacing: 3px; text-transform: uppercase; color: rgba(255,255,255,.6); }
  #big {
    margin: 26px 0 6px; font-size: clamp(34px,9vw,60px); font-weight: 900; color:#fff;
    text-shadow: 0 0 30px rgba(140,110,255,.6);
  }
  #ovBest { font-size: 14px; letter-spacing: 2px; color:#8fe0ff; text-transform: uppercase; }
  #hint {
    margin-top: 34px; font-size: 15px; color: rgba(255,255,255,.82);
    padding: 14px 26px; border: 1px solid rgba(255,255,255,.16); border-radius: 50px;
    background: rgba(255,255,255,.05); animation: pulse 1.8s ease-in-out infinite;
  }
  @keyframes pulse { 0%,100%{ transform: scale(1); opacity:.85 } 50%{ transform: scale(1.05); opacity:1 } }
  #credit { position:absolute; bottom: 16px; font-size: 11px; letter-spacing: 2px; color: rgba(255,255,255,.28); text-transform: uppercase; }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="c"></canvas>

  <div id="hud">
    <div class="stat">
      <div class="label">Height</div>
      <div class="val" id="score">0</div>
    </div>
    <div class="stat right">
      <div class="label">Best</div>
      <div class="val" id="best">0</div>
    </div>
  </div>
  <div id="combo"></div>
  <button id="mute" aria-label="Toggle sound">🔊</button>

  <div id="overlay">
    <div id="title">ABRAMS<br>STACK</div>
    <div id="subtitle">stack it high</div>
    <div id="big" style="display:none"></div>
    <div id="ovBest"></div>
    <div id="hint">Tap / Click / Space to drop</div>
  </div>
  <div id="credit">Pure Canvas 2D · Zero Dependencies</div>
</div>

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

  var canvas = document.getElementById("c");
  var ctx = canvas.getContext("2d");
  var scoreEl = document.getElementById("score");
  var bestEl = document.getElementById("best");
  var comboEl = document.getElementById("combo");
  var overlay = document.getElementById("overlay");
  var titleEl = document.getElementById("title");
  var subtitleEl = document.getElementById("subtitle");
  var bigEl = document.getElementById("big");
  var ovBestEl = document.getElementById("ovBest");
  var hintEl = document.getElementById("hint");
  var muteBtn = document.getElementById("mute");

  // ---------- Sizing (DPR aware, iframe responsive) ----------
  // Cap to a centered ~2:3 portrait column on desktop (letterboxed); full-width on phones.
  var W = 0, H = 0, DPR = 1;
  function resize() {
    DPR = Math.min(window.devicePixelRatio || 1, 2);
    H = window.innerHeight;
    W = Math.min(window.innerWidth, Math.round(H * 0.66));
    canvas.width = Math.floor(W * DPR);
    canvas.height = Math.floor(H * DPR);
    canvas.style.width = W + "px";
    canvas.style.height = H + "px";
    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
  }
  window.addEventListener("resize", resize);
  resize();

  // ---------- Persistent best ----------
  var best = 0;
  try { best = parseInt(localStorage.getItem("abramsStackBest") || "0", 10) || 0; } catch (e) {}
  bestEl.textContent = best;

  // ---------- Audio (procedural, optional) ----------
  var muted = false;
  try { muted = localStorage.getItem("abramsStackMuted") === "1"; } catch (e) {}
  muteBtn.textContent = muted ? "🔇" : "🔊";
  var actx = null;
  function ensureAudio() {
    if (muted) return;
    if (!actx) {
      try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { actx = null; }
    }
    if (actx && actx.state === "suspended") actx.resume();
  }
  function tone(freq, dur, type, vol, glideTo) {
    if (muted || !actx) return;
    var t = actx.currentTime;
    var o = actx.createOscillator();
    var g = actx.createGain();
    o.type = type || "sine";
    o.frequency.setValueAtTime(freq, t);
    if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t + dur);
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(vol || 0.2, t + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(actx.destination);
    o.start(t); o.stop(t + dur + 0.02);
  }
  function sndDrop() { tone(220, 0.14, "sine", 0.22, 150); }
  function sndSlice() { tone(140, 0.12, "sawtooth", 0.14, 90); }
  function sndPerfect(n) {
    if (muted || !actx) return;
    var base = 520 + Math.min(n, 8) * 40;
    tone(base, 0.14, "triangle", 0.22);
    setTimeout(function () { tone(base * 1.5, 0.16, "triangle", 0.18); }, 70);
  }
  function sndOver() {
    tone(300, 0.5, "sawtooth", 0.2, 70);
    setTimeout(function () { tone(160, 0.5, "sine", 0.15, 60); }, 90);
  }
  muteBtn.addEventListener("click", function (e) {
    e.stopPropagation();
    muted = !muted;
    muteBtn.textContent = muted ? "🔇" : "🔊";
    try { localStorage.setItem("abramsStackMuted", muted ? "1" : "0"); } catch (er) {}
    if (!muted) ensureAudio();
  });

  // ---------- Game state ----------
  var STATE_START = 0, STATE_PLAY = 1, STATE_OVER = 2;
  var state = STATE_START;

  var BLOCK_H = 46;        // logical pixel height of each block
  var baseWidth = 0;       // starting block width
  var blocks = [];         // placed blocks {x, w, hue}
  var current = null;      // moving block {x, w, dir, hue}
  var speed = 0;
  var camY = 0;            // camera vertical offset (target)
  var camYCur = 0;         // smoothed
  var score = 0;
  var combo = 0;
  var particles = [];
  var slices = [];         // falling sliced-off pieces
  var flash = 0;           // perfect flash intensity

  function hueForLevel(n) { return (200 + n * 9) % 360; }

  function groundY() { return H - 90; } // y of top of base row in world space (before camera)

  function reset() {
    blocks = [];
    slices = [];
    particles = [];
    score = 0;
    combo = 0;
    camY = 0; camYCur = 0;
    flash = 0;
    baseWidth = Math.min(W * 0.5, 300);
    speed = Math.max(2.6, W / 300);
    var bx = (W - baseWidth) / 2;
    blocks.push({ x: bx, w: baseWidth, hue: hueForLevel(0) });
    spawnCurrent();
  }

  function spawnCurrent() {
    var prev = blocks[blocks.length - 1];
    var fromLeft = blocks.length % 2 === 0;
    var startX = fromLeft ? -prev.w : W;
    current = {
      x: fromLeft ? -prev.w * 0.6 : W - prev.w * 0.4,
      w: prev.w,
      dir: fromLeft ? 1 : -1,
      hue: hueForLevel(blocks.length)
    };
    // speed scales with height
    speed = Math.max(2.6, W / 300) * (1 + blocks.length * 0.035);
  }

  // world Y (bottom-up) for the top of a block at index i (i=0 is base)
  function blockWorldY(i) { return groundY() - i * BLOCK_H; }

  function showCombo(txt) {
    comboEl.textContent = txt;
    comboEl.style.opacity = "1";
    clearTimeout(showCombo._t);
    showCombo._t = setTimeout(function () { comboEl.style.opacity = "0"; }, 800);
  }

  function drop() {
    if (state !== STATE_PLAY) return;
    var prev = blocks[blocks.length - 1];
    var cx = current.x, cw = current.w;
    var overlapL = Math.max(cx, prev.x);
    var overlapR = Math.min(cx + cw, prev.x + prev.w);
    var overlap = overlapR - overlapL;

    if (overlap <= 0) {
      // total miss -> game over. add the whole falling block as a slice
      slices.push({ x: cx, w: cw, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: current.dir * 1.5, rot: 0, vr: current.dir * 0.05 });
      gameOver();
      return;
    }

    // perfect detection
    var diff = Math.abs(cx - prev.x);
    var perfect = diff < 4;

    if (perfect) {
      combo++;
      flash = 1;
      sndPerfect(combo);
      // snap to perfect alignment (no shrink), small bonus width back on combo
      cx = prev.x;
      showCombo(combo > 1 ? "PERFECT ×" + combo + "  +" + (combo) : "PERFECT!");
      spawnParticles(cx + cw / 2, blockWorldY(blocks.length - 1) - BLOCK_H / 2, current.hue, 22, true);
    } else {
      combo = 0;
      sndDrop();
      // slice off the overhang
      if (cx < prev.x) {
        // overhang on the left
        var offW = prev.x - cx;
        slices.push({ x: cx, w: offW, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: -1.4, rot: 0, vr: -0.06 });
      } else {
        var offW2 = (cx + cw) - (prev.x + prev.w);
        if (offW2 > 0) slices.push({ x: prev.x + prev.w, w: offW2, y: blockWorldY(blocks.length - 1) - BLOCK_H, hue: current.hue, vy: 0, vx: 1.4, rot: 0, vr: 0.06 });
      }
      sndSlice();
      cx = overlapL;
      cw = overlap;
      spawnParticles(overlapL + overlap / 2, blockWorldY(blocks.length - 1) - BLOCK_H / 2, current.hue, 8, false);
    }

    blocks.push({ x: cx, w: cw, hue: current.hue });
    score++;
    scoreEl.textContent = score;

    // camera pans up as tower grows (keep top few visible)
    var visibleRows = Math.max(4, Math.floor((H - 200) / BLOCK_H));
    if (blocks.length > visibleRows) {
      camY = (blocks.length - visibleRows) * BLOCK_H;
    }

    spawnCurrent();
  }

  function spawnParticles(x, worldY, hue, count, sparkle) {
    for (var i = 0; i < count; i++) {
      var a = Math.random() * Math.PI * 2;
      var sp = (sparkle ? 2 + Math.random() * 4 : 1 + Math.random() * 2.5);
      particles.push({
        x: x, wy: worldY,
        vx: Math.cos(a) * sp,
        vy: Math.sin(a) * sp - (sparkle ? 1.5 : 0.5),
        life: 1, hue: hue, size: 2 + Math.random() * (sparkle ? 4 : 2)
      });
    }
  }

  function gameOver() {
    state = STATE_OVER;
    sndOver();
    if (score > best) {
      best = score;
      try { localStorage.setItem("abramsStackBest", String(best)); } catch (e) {}
      bestEl.textContent = best;
    }
    titleEl.style.display = "none";
    subtitleEl.style.display = "none";
    bigEl.style.display = "block";
    bigEl.textContent = "Height  " + score;
    ovBestEl.textContent = (score >= best && score > 0 ? "★ NEW BEST ★" : "Best  " + best);
    hintEl.textContent = "Tap / Click / Space to retry";
    overlay.classList.remove("hidden");
  }

  function startGame() {
    ensureAudio();
    overlay.classList.add("hidden");
    titleEl.style.display = "";
    subtitleEl.style.display = "";
    bigEl.style.display = "none";
    reset();
    state = STATE_PLAY;
  }

  // ---------- Input ----------
  function onAction(e) {
    if (e && e.target === muteBtn) return;
    if (e) e.preventDefault();
    ensureAudio();
    if (state === STATE_PLAY) {
      drop();
    } else {
      startGame();
    }
  }
  window.addEventListener("pointerdown", onAction, { passive: false });
  window.addEventListener("keydown", function (e) {
    if (e.code === "Space" || e.key === " " || e.code === "Enter") {
      e.preventDefault();
      onAction();
    }
  });

  // ---------- Render helpers ----------
  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();
  }

  function drawBlock(x, screenY, w, hue, glow) {
    var top = "hsl(" + hue + ",78%,64%)";
    var bot = "hsl(" + hue + ",70%,44%)";
    var grad = ctx.createLinearGradient(0, screenY, 0, screenY + BLOCK_H);
    grad.addColorStop(0, top);
    grad.addColorStop(1, bot);
    if (glow) {
      ctx.save();
      ctx.shadowColor = "hsla(" + hue + ",90%,65%,0.9)";
      ctx.shadowBlur = 26;
    }
    ctx.fillStyle = grad;
    roundRect(x, screenY, w, BLOCK_H - 3, 7);
    ctx.fill();
    if (glow) ctx.restore();
    // top highlight
    ctx.fillStyle = "hsla(" + hue + ",90%,80%,0.5)";
    roundRect(x + 3, screenY + 2, w - 6, 5, 3);
    ctx.fill();
  }

  // ---------- Loop ----------
  var lastT = performance.now();
  function frame(now) {
    var dt = Math.min((now - lastT) / 16.6667, 3); // normalized to 60fps
    lastT = now;

    // smooth camera
    camYCur += (camY - camYCur) * Math.min(1, 0.12 * dt);
    if (flash > 0) flash = Math.max(0, flash - 0.05 * dt);

    // update moving block
    if (state === STATE_PLAY && current) {
      current.x += current.dir * speed * dt;
      var prev = blocks[blocks.length - 1];
      // bounce within a range slightly beyond the tower so it fully crosses
      var leftLimit = -current.w * 0.9;
      var rightLimit = W - current.w * 0.1;
      if (current.x < leftLimit) { current.x = leftLimit; current.dir = 1; }
      if (current.x > rightLimit) { current.x = rightLimit; current.dir = -1; }
    }

    // update particles
    for (var i = particles.length - 1; i >= 0; i--) {
      var p = particles[i];
      p.x += p.vx * dt;
      p.wy += p.vy * dt;
      p.vy += 0.18 * dt;
      p.life -= 0.02 * dt;
      if (p.life <= 0) particles.splice(i, 1);
    }
    // update slices (falling debris)
    for (var s = slices.length - 1; s >= 0; s--) {
      var sl = slices[s];
      sl.vy += 0.55 * dt;
      sl.y += sl.vy * dt;
      sl.x += sl.vx * dt;
      sl.rot += sl.vr * dt;
      if (sl.y - camYCur > H + 200) slices.splice(s, 1);
    }

    draw();
    requestAnimationFrame(frame);
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);

    // subtle drifting bg glow tied to height
    var topHue = hueForLevel(blocks.length);
    var g = ctx.createRadialGradient(W / 2, H * 0.2, 40, W / 2, H * 0.2, Math.max(W, H));
    g.addColorStop(0, "hsla(" + topHue + ",55%,22%,0.55)");
    g.addColorStop(1, "rgba(5,5,16,0)");
    ctx.fillStyle = g;
    ctx.fillRect(0, 0, W, H);

    function worldToScreen(wy) { return wy - camYCur; }

    // placed blocks
    for (var i = 0; i < blocks.length; i++) {
      var b = blocks[i];
      var wy = blockWorldY(i);            // world y of top of this block
      var sy = worldToScreen(wy) - BLOCK_H;
      if (sy > H + BLOCK_H || sy < -BLOCK_H) continue;
      drawBlock(b.x, sy, b.w, b.hue, i === blocks.length - 1 && state === STATE_PLAY ? false : false);
    }

    // moving block
    if (state === STATE_PLAY && current) {
      var wyc = blockWorldY(blocks.length);
      var syc = worldToScreen(wyc) - BLOCK_H;
      drawBlock(current.x, syc, current.w, current.hue, true);
    }

    // slices (debris)
    for (var s = 0; s < slices.length; s++) {
      var sl = slices[s];
      var sy2 = worldToScreen(sl.y);
      ctx.save();
      ctx.translate(sl.x + sl.w / 2, sy2 + BLOCK_H / 2);
      ctx.rotate(sl.rot);
      ctx.globalAlpha = 0.9;
      var gr = ctx.createLinearGradient(0, -BLOCK_H / 2, 0, BLOCK_H / 2);
      gr.addColorStop(0, "hsl(" + sl.hue + ",78%,60%)");
      gr.addColorStop(1, "hsl(" + sl.hue + ",70%,42%)");
      ctx.fillStyle = gr;
      roundRect(-sl.w / 2, -BLOCK_H / 2, sl.w, BLOCK_H - 3, 6);
      ctx.fill();
      ctx.restore();
    }

    // particles
    ctx.globalAlpha = 1;
    for (var p = 0; p < particles.length; p++) {
      var pa = particles[p];
      ctx.globalAlpha = Math.max(0, pa.life);
      ctx.fillStyle = "hsl(" + pa.hue + ",90%,68%)";
      var py = pa.wy - camYCur;
      ctx.beginPath();
      ctx.arc(pa.x, py, pa.size, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    // perfect flash overlay
    if (flash > 0) {
      ctx.fillStyle = "rgba(255,255,255," + (flash * 0.18) + ")";
      ctx.fillRect(0, 0, W, H);
    }
  }

  requestAnimationFrame(frame);
})();
</script>
</body>
</html>