← back to Games Agentabrams

games/whack-a-mole/AbramsWhack.aa

729 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 Whack</title>
<style>
  :root{
    --bg0:#080012;
    --bg1:#12002a;
    --neon:#00f0ff;
    --neon2:#ff2bd6;
    --neon3:#b14bff;
    --good:#33ff9e;
    --bad:#ff3b5c;
    --gold:#ffd23f;
    --ink:#eafcff;
  }
  *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent;}
  html,body{height:100%;width:100%;overflow:hidden;}
  body{
    font-family:"Trebuchet MS","Segoe UI",system-ui,sans-serif;
    background:
      radial-gradient(circle at 20% 10%, rgba(177,75,255,.28), transparent 45%),
      radial-gradient(circle at 85% 90%, rgba(0,240,255,.24), transparent 45%),
      linear-gradient(160deg,var(--bg1),var(--bg0));
    color:var(--ink);
    user-select:none;
    display:flex;
    align-items:center;
    justify-content:center;
  }
  #app{
    position:relative;
    width:100%;
    height:100%;
    max-width:900px;
    max-height:900px;
    margin:auto;
    display:flex;
    flex-direction:column;
    padding:clamp(8px,2vmin,20px);
  }

  /* HUD */
  #hud{
    display:flex;
    align-items:center;
    justify-content:space-between;
    gap:clamp(6px,1.5vmin,14px);
    flex-wrap:wrap;
    padding:clamp(6px,1.4vmin,12px) clamp(8px,2vmin,16px);
    border-radius:16px;
    background:rgba(10,0,25,.45);
    border:1px solid rgba(0,240,255,.25);
    box-shadow:0 0 24px rgba(0,240,255,.12) inset;
    backdrop-filter:blur(4px);
  }
  .stat{
    display:flex;
    flex-direction:column;
    align-items:center;
    line-height:1.05;
    min-width:0;
  }
  .stat .label{
    font-size:clamp(8px,1.6vmin,11px);
    letter-spacing:.14em;
    text-transform:uppercase;
    opacity:.7;
  }
  .stat .val{
    font-size:clamp(16px,4vmin,30px);
    font-weight:800;
    font-variant-numeric:tabular-nums;
    text-shadow:0 0 10px currentColor;
  }
  #score .val{color:var(--neon);}
  #best .val{color:var(--gold);}
  #time .val{color:var(--good);}
  #combo .val{color:var(--neon2);}
  #time.low .val{color:var(--bad); animation:pulse .6s infinite;}
  @keyframes pulse{50%{opacity:.35;}}

  #lives{display:flex;gap:4px;font-size:clamp(14px,3vmin,22px);}

  #muteBtn{
    cursor:pointer;
    border:1px solid rgba(255,255,255,.3);
    background:rgba(255,255,255,.06);
    color:var(--ink);
    border-radius:10px;
    padding:clamp(4px,1vmin,8px) clamp(6px,1.4vmin,10px);
    font-size:clamp(12px,2.6vmin,18px);
    line-height:1;
    transition:.15s;
  }
  #muteBtn:hover{background:rgba(0,240,255,.15);border-color:var(--neon);}

  #title{
    text-align:center;
    margin:clamp(4px,1.2vmin,10px) 0;
    font-size:clamp(18px,5vmin,40px);
    font-weight:900;
    letter-spacing:.06em;
    background:linear-gradient(90deg,var(--neon),var(--neon2),var(--neon3),var(--neon));
    background-size:300% 100%;
    -webkit-background-clip:text;
    background-clip:text;
    color:transparent;
    animation:shift 6s linear infinite;
    filter:drop-shadow(0 0 12px rgba(255,43,214,.4));
  }
  @keyframes shift{to{background-position:300% 0;}}

  /* Board */
  #boardWrap{
    flex:1;
    display:flex;
    align-items:center;
    justify-content:center;
    min-height:0;
  }
  #board{
    display:grid;
    grid-template-columns:repeat(3,1fr);
    grid-template-rows:repeat(3,1fr);
    gap:clamp(6px,2vmin,18px);
    width:min(100%,min(72vh,560px));
    aspect-ratio:1/1;
  }
  .hole{
    position:relative;
    border-radius:50%;
    background:
      radial-gradient(circle at 50% 38%, #1a0033 0%, #0c0018 60%, #05000d 100%);
    box-shadow:
      0 0 0 2px rgba(0,240,255,.18),
      inset 0 10px 26px rgba(0,0,0,.9),
      inset 0 -6px 14px rgba(177,75,255,.18);
    overflow:hidden;
    cursor:pointer;
    display:flex;
    align-items:center;
    justify-content:center;
    transition:box-shadow .12s;
  }
  .hole::after{ /* rim glow */
    content:"";
    position:absolute;
    inset:0;
    border-radius:50%;
    box-shadow:inset 0 6px 18px rgba(0,240,255,.10);
    pointer-events:none;
  }
  .hole.hit{
    box-shadow:
      0 0 24px 6px rgba(51,255,158,.6),
      inset 0 10px 26px rgba(0,0,0,.9);
  }
  .hole.badhit{
    box-shadow:
      0 0 24px 6px rgba(255,59,92,.7),
      inset 0 10px 26px rgba(0,0,0,.9);
  }

  /* the pop target */
  .target{
    position:absolute;
    left:50%;
    bottom:-72%;
    transform:translateX(-50%);
    width:78%;
    height:78%;
    border-radius:50% 50% 46% 46%;
    display:flex;
    align-items:center;
    justify-content:center;
    font-size:clamp(22px,9vmin,64px);
    transition:bottom .13s cubic-bezier(.2,.9,.3,1.4), transform .13s;
    pointer-events:none;
    will-change:bottom;
  }
  .target.up{ bottom:8%; }
  .target.mole{
    background:radial-gradient(circle at 50% 35%, var(--neon3), #4a007a 70%, #22003a);
    box-shadow:0 0 18px rgba(177,75,255,.7), inset 0 -8px 12px rgba(0,0,0,.5), inset 0 6px 10px rgba(255,255,255,.15);
    border:2px solid rgba(0,240,255,.5);
  }
  .target.bomb{
    background:radial-gradient(circle at 50% 35%, #ff5a78, #b3001d 72%, #4a000c);
    box-shadow:0 0 20px rgba(255,59,92,.85), inset 0 -8px 12px rgba(0,0,0,.5);
    border:2px solid rgba(255,210,63,.7);
  }
  .target.gold{
    background:radial-gradient(circle at 50% 35%, #fff2a8, var(--gold) 68%, #b98900);
    box-shadow:0 0 26px rgba(255,210,63,.9), inset 0 -8px 12px rgba(0,0,0,.35);
    border:2px solid #fff;
  }
  .target.whacked{ transform:translateX(-50%) scale(.55) rotate(8deg); opacity:0; transition:transform .18s, opacity .18s; }

  /* floating score popup */
  .pop{
    position:absolute;
    left:50%;
    top:30%;
    transform:translate(-50%,-50%);
    font-weight:900;
    font-size:clamp(14px,4.5vmin,30px);
    pointer-events:none;
    text-shadow:0 0 8px currentColor;
    animation:floatUp .7s ease-out forwards;
    z-index:5;
  }
  @keyframes floatUp{
    0%{opacity:0; transform:translate(-50%,-30%) scale(.6);}
    20%{opacity:1;}
    100%{opacity:0; transform:translate(-50%,-160%) scale(1.1);}
  }

  /* ripple on whack */
  .ripple{
    position:absolute;
    inset:0;
    border-radius:50%;
    pointer-events:none;
    animation:rip .45s ease-out forwards;
  }
  @keyframes rip{
    from{box-shadow:0 0 0 0 rgba(255,255,255,.6);}
    to{box-shadow:0 0 0 40px rgba(255,255,255,0);}
  }

  /* Overlay */
  #overlay{
    position:absolute;
    inset:0;
    display:flex;
    align-items:center;
    justify-content:center;
    background:rgba(4,0,14,.82);
    backdrop-filter:blur(6px);
    z-index:20;
    text-align:center;
    padding:20px;
  }
  #overlay.hidden{display:none;}
  .panel{
    max-width:440px;
    width:100%;
    background:linear-gradient(160deg, rgba(28,0,54,.9), rgba(8,0,20,.9));
    border:1px solid rgba(0,240,255,.4);
    border-radius:22px;
    padding:clamp(20px,4vmin,36px);
    box-shadow:0 0 40px rgba(177,75,255,.35), inset 0 0 30px rgba(0,240,255,.08);
  }
  .panel h1{
    font-size:clamp(26px,7vmin,46px);
    font-weight:900;
    letter-spacing:.04em;
    background:linear-gradient(90deg,var(--neon),var(--neon2));
    -webkit-background-clip:text;background-clip:text;color:transparent;
    filter:drop-shadow(0 0 10px rgba(0,240,255,.5));
    margin-bottom:8px;
  }
  .panel p{opacity:.85; font-size:clamp(12px,2.6vmin,15px); line-height:1.5; margin-bottom:16px;}
  .legend{
    display:flex;justify-content:center;gap:16px;flex-wrap:wrap;
    margin-bottom:18px;font-size:clamp(11px,2.4vmin,14px);
  }
  .legend span{display:inline-flex;align-items:center;gap:6px;}
  .dot{width:16px;height:16px;border-radius:50%;display:inline-block;box-shadow:0 0 8px currentColor;}
  .dot.m{background:var(--neon3);color:var(--neon3);}
  .dot.b{background:var(--bad);color:var(--bad);}
  .dot.g{background:var(--gold);color:var(--gold);}
  #finalScore{
    font-size:clamp(28px,8vmin,52px);
    font-weight:900;
    color:var(--neon);
    text-shadow:0 0 16px var(--neon);
    margin:6px 0;
    font-variant-numeric:tabular-nums;
  }
  #finalBest{color:var(--gold);font-weight:700;margin-bottom:16px;}
  #newBest{
    color:var(--gold);font-weight:900;letter-spacing:.1em;
    animation:pulse .8s infinite; margin-bottom:8px; font-size:clamp(13px,3vmin,18px);
  }
  #newBest.hidden{display:none;}
  .btn{
    cursor:pointer;
    border:none;
    border-radius:14px;
    padding:clamp(12px,2.6vmin,16px) clamp(24px,5vmin,40px);
    font-size:clamp(15px,3.4vmin,22px);
    font-weight:800;
    letter-spacing:.05em;
    color:#08001a;
    background:linear-gradient(90deg,var(--neon),var(--neon2));
    box-shadow:0 6px 24px rgba(255,43,214,.4);
    transition:transform .1s, box-shadow .15s;
  }
  .btn:hover{transform:translateY(-2px); box-shadow:0 10px 30px rgba(0,240,255,.5);}
  .btn:active{transform:translateY(1px) scale(.98);}

  /* screen shake */
  @keyframes shake{
    0%,100%{transform:translate(0,0);}
    25%{transform:translate(-4px,2px);}
    50%{transform:translate(4px,-2px);}
    75%{transform:translate(-3px,-1px);}
  }
  #app.shake{animation:shake .28s;}
</style>
</head>
<body>
<div id="app">
  <div id="hud">
    <div id="score" class="stat"><span class="label">Score</span><span class="val">0</span></div>
    <div id="combo" class="stat"><span class="label">Combo</span><span class="val">x1</span></div>
    <div id="title" style="flex:1;">ABRAMS WHACK</div>
    <div id="time" class="stat"><span class="label">Time</span><span class="val">45</span></div>
    <div id="best" class="stat"><span class="label">Best</span><span class="val">0</span></div>
    <div id="livesWrap" class="stat"><span class="label">Lives</span><span id="lives" class="val"></span></div>
    <button id="muteBtn" title="Mute / unmute">🔊</button>
  </div>

  <div id="boardWrap">
    <div id="board"></div>
  </div>

  <div id="overlay">
    <div class="panel">
      <h1 id="ovTitle">ABRAMS WHACK</h1>
      <div id="newBest" class="hidden">★ NEW HIGH SCORE ★</div>
      <div id="finalScore" style="display:none;">0</div>
      <div id="finalBest" style="display:none;"></div>
      <p id="ovDesc">Whack the neon moles before they vanish. Hit fast for combo multipliers.
        Dodge the red bombs — they cost a life. Grab the gold ones for big points.</p>
      <div class="legend">
        <span><i class="dot m"></i>Mole +pts</span>
        <span><i class="dot g"></i>Gold jackpot</span>
        <span><i class="dot b"></i>Bomb −life</span>
      </div>
      <button id="startBtn" class="btn">START</button>
    </div>
  </div>
</div>

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

  // ---------- Config ----------
  const ROUND_TIME = 45;          // seconds
  const HOLES = 9;                 // 3x3
  const START_LIVES = 3;
  const BASE_POINTS = 10;
  const GOLD_POINTS = 50;
  const MISS_PENALTY = 5;          // missed mole
  const BOMB_PENALTY = 15;         // whacking a bomb
  const COMBO_WINDOW = 1400;       // ms to keep combo alive between hits
  const MAX_COMBO = 8;

  // ---------- State ----------
  let board, holes = [];
  let score = 0, best = 0, timeLeft = ROUND_TIME, lives = START_LIVES;
  let combo = 0, lastHitAt = 0;
  let running = false;
  let spawnTimer = null, tickTimer = null;
  let elapsed = 0;                 // seconds into round (for difficulty ramp)
  let muted = false;

  // per-hole occupancy: {kind, el, timeout, whacked}
  const occ = new Array(HOLES).fill(null);

  // ---------- DOM ----------
  const $ = s => document.querySelector(s);
  const app = $("#app");
  const scoreVal = $("#score .val");
  const comboVal = $("#combo .val");
  const timeStat = $("#time");
  const timeVal = $("#time .val");
  const bestVal = $("#best .val");
  const livesEl = $("#lives");
  const overlay = $("#overlay");
  const ovTitle = $("#ovTitle");
  const ovDesc = $("#ovDesc");
  const finalScore = $("#finalScore");
  const finalBest = $("#finalBest");
  const newBest = $("#newBest");
  const startBtn = $("#startBtn");
  const muteBtn = $("#muteBtn");

  // ---------- Persistence ----------
  const LS_KEY = "abramsWhack.best";
  try { best = parseInt(localStorage.getItem(LS_KEY) || "0", 10) || 0; } catch(e){ best = 0; }
  try { muted = localStorage.getItem("abramsWhack.muted") === "1"; } catch(e){}
  bestVal.textContent = best;
  muteBtn.textContent = muted ? "🔇" : "🔊";

  // ---------- Audio (procedural Web Audio) ----------
  let actx = null;
  function audio(){
    if (muted) return null;
    if (!actx){
      try { actx = new (window.AudioContext || window.webkitAudioContext)(); }
      catch(e){ return null; }
    }
    if (actx.state === "suspended") actx.resume();
    return actx;
  }
  function tone(freq, dur, type, gain, slideTo){
    const ac = audio(); if(!ac) return;
    const o = ac.createOscillator();
    const g = ac.createGain();
    o.type = type || "sine";
    o.frequency.setValueAtTime(freq, ac.currentTime);
    if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, ac.currentTime + dur);
    g.gain.setValueAtTime(0.0001, ac.currentTime);
    g.gain.exponentialRampToValueAtTime(gain || 0.2, ac.currentTime + 0.01);
    g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + dur);
    o.connect(g); g.connect(ac.destination);
    o.start(); o.stop(ac.currentTime + dur + 0.02);
  }
  function noiseBurst(dur, gain){
    const ac = audio(); if(!ac) return;
    const n = Math.floor(ac.sampleRate * dur);
    const buf = ac.createBuffer(1, n, ac.sampleRate);
    const data = buf.getChannelData(0);
    for (let i=0;i<n;i++) data[i] = (Math.random()*2-1) * (1 - i/n);
    const src = ac.createBufferSource(); src.buffer = buf;
    const g = ac.createGain(); g.gain.value = gain || 0.25;
    const f = ac.createBiquadFilter(); f.type="highpass"; f.frequency.value=800;
    src.connect(f); f.connect(g); g.connect(ac.destination);
    src.start();
  }
  const sfx = {
    whack(c){ tone(420 + Math.min(c,MAX_COMBO)*55, 0.12, "square", 0.22, 180); },
    gold(){ tone(660,0.1,"triangle",0.25,1320); setTimeout(()=>tone(990,0.14,"triangle",0.22,1760),70); },
    bomb(){ noiseBurst(0.28,0.35); tone(120,0.3,"sawtooth",0.3,40); },
    miss(){ tone(200,0.14,"sine",0.15,120); },
    pop(){ tone(300,0.06,"sine",0.08,500); },
    over(){ tone(440,0.2,"triangle",0.2,110); setTimeout(()=>tone(220,0.4,"triangle",0.2,80),160); },
    start(){ tone(330,0.1,"triangle",0.2,660); setTimeout(()=>tone(660,0.14,"triangle",0.2,990),100); }
  };

  // ---------- Build board ----------
  function buildBoard(){
    board = $("#board");
    board.innerHTML = "";
    holes = [];
    for (let i=0;i<HOLES;i++){
      const h = document.createElement("div");
      h.className = "hole";
      h.dataset.i = i;
      h.addEventListener("pointerdown", onHolePress, {passive:false});
      board.appendChild(h);
      holes.push(h);
    }
  }

  // ---------- Difficulty ramp ----------
  // returns {upTime, gap} in ms. As elapsed grows, moles stay up shorter & spawn faster.
  function difficulty(){
    const t = elapsed / ROUND_TIME;               // 0..1
    const upTime = Math.max(520, 1150 - t*760);   // 1150 -> 520 ms visible
    const gap = Math.max(340, 900 - t*620);       // spawn interval
    return { upTime, gap };
  }

  function chooseKind(){
    // gold rare, bomb scales up a bit with difficulty
    const t = elapsed / ROUND_TIME;
    const r = Math.random();
    if (r < 0.06) return "gold";
    if (r < 0.06 + 0.14 + t*0.12) return "bomb";  // 14% -> ~26%
    return "mole";
  }

  // ---------- Spawn ----------
  function spawn(){
    if (!running) return;
    // find a free hole
    const free = [];
    for (let i=0;i<HOLES;i++) if (!occ[i]) free.push(i);
    if (free.length){
      // sometimes spawn 2 at once late-game
      const t = elapsed / ROUND_TIME;
      const count = (t > 0.5 && Math.random() < 0.3 && free.length > 1) ? 2 : 1;
      for (let k=0;k<count && free.length;k++){
        const idx = free.splice(Math.floor(Math.random()*free.length),1)[0];
        placeTarget(idx);
      }
    }
    const { gap } = difficulty();
    spawnTimer = setTimeout(spawn, gap + Math.random()*120);
  }

  function placeTarget(i){
    const kind = chooseKind();
    const { upTime } = difficulty();
    const el = document.createElement("div");
    el.className = "target " + kind;
    el.textContent = kind === "bomb" ? "💣" : (kind === "gold" ? "⭐" : "🐹");
    holes[i].appendChild(el);
    sfx.pop();
    // force reflow then raise
    // eslint-disable-next-line no-unused-expressions
    el.offsetHeight;
    requestAnimationFrame(()=> el.classList.add("up"));

    const record = { kind, el, whacked:false, timeout:null };
    occ[i] = record;

    record.timeout = setTimeout(()=>{
      if (record.whacked) return;
      // duck back down
      el.classList.remove("up");
      // a mole that escaped un-whacked = miss penalty (bombs escaping are fine)
      if (kind === "mole" || kind === "gold"){
        onMissed();
      }
      setTimeout(()=>{ if (el.parentNode) el.parentNode.removeChild(el); }, 180);
      if (occ[i] === record) occ[i] = null;
    }, upTime);
  }

  // ---------- Interaction ----------
  function onHolePress(e){
    e.preventDefault();
    if (!running) return;
    const i = +this.dataset.i;
    const rec = occ[i];
    if (!rec || rec.whacked){
      return; // whacked empty hole -> nothing (no penalty for empties)
    }
    whack(i, rec, this);
  }

  function whack(i, rec, holeEl){
    rec.whacked = true;
    clearTimeout(rec.timeout);
    rec.el.classList.add("whacked");
    ripple(holeEl);
    setTimeout(()=>{ if (rec.el.parentNode) rec.el.parentNode.removeChild(rec.el); }, 200);
    if (occ[i] === rec) occ[i] = null;

    const now = performance.now();
    if (rec.kind === "bomb"){
      // penalty + life loss
      combo = 0; lastHitAt = 0;
      score = Math.max(0, score - BOMB_PENALTY);
      loseLife();
      holeEl.classList.add("badhit");
      setTimeout(()=>holeEl.classList.remove("badhit"),200);
      floatPop(holeEl, "−"+BOMB_PENALTY, "var(--bad)");
      sfx.bomb();
      shake();
      updateHUD();
      return;
    }

    // good hit -> combo
    if (now - lastHitAt < COMBO_WINDOW) combo = Math.min(MAX_COMBO, combo + 1);
    else combo = 1;
    lastHitAt = now;

    const mult = combo;
    let gained;
    if (rec.kind === "gold"){
      gained = GOLD_POINTS * mult;
      sfx.gold();
      floatPop(holeEl, "+"+gained, "var(--gold)");
    } else {
      gained = BASE_POINTS * mult;
      sfx.whack(combo);
      floatPop(holeEl, "+"+gained, "var(--good)");
    }
    score += gained;
    holeEl.classList.add("hit");
    setTimeout(()=>holeEl.classList.remove("hit"),160);
    updateHUD();
  }

  function onMissed(){
    combo = 0; lastHitAt = 0;
    score = Math.max(0, score - MISS_PENALTY);
    sfx.miss();
    updateHUD();
  }

  function loseLife(){
    lives = Math.max(0, lives - 1);
    renderLives();
    if (lives <= 0) endRound();
  }

  // ---------- FX ----------
  function ripple(holeEl){
    const r = document.createElement("div");
    r.className = "ripple";
    holeEl.appendChild(r);
    setTimeout(()=>{ if (r.parentNode) r.parentNode.removeChild(r); }, 460);
  }
  function floatPop(holeEl, text, color){
    const p = document.createElement("div");
    p.className = "pop";
    p.textContent = text;
    p.style.color = color;
    holeEl.appendChild(p);
    setTimeout(()=>{ if (p.parentNode) p.parentNode.removeChild(p); }, 720);
  }
  function shake(){
    app.classList.remove("shake");
    // reflow
    void app.offsetWidth;
    app.classList.add("shake");
  }

  // ---------- HUD ----------
  function updateHUD(){
    scoreVal.textContent = score;
    comboVal.textContent = "x" + Math.max(1, combo);
    comboVal.style.transform = combo > 1 ? "scale(1.15)" : "scale(1)";
  }
  function renderLives(){
    livesEl.textContent = "❤".repeat(lives) + "♡".repeat(Math.max(0,START_LIVES-lives));
  }
  function renderTime(){
    timeVal.textContent = timeLeft;
    if (timeLeft <= 10) timeStat.classList.add("low");
    else timeStat.classList.remove("low");
  }

  // ---------- Round control ----------
  function startRound(){
    // reset
    score = 0; combo = 0; lastHitAt = 0; lives = START_LIVES;
    timeLeft = ROUND_TIME; elapsed = 0; running = true;
    clearAll();
    updateHUD(); renderLives(); renderTime();
    bestVal.textContent = best;
    overlay.classList.add("hidden");
    sfx.start();

    // ticking clock
    tickTimer = setInterval(()=>{
      timeLeft--; elapsed++;
      renderTime();
      if (timeLeft <= 0) endRound();
    }, 1000);

    // start spawning
    spawn();
  }

  function endRound(){
    if (!running) return;
    running = false;
    clearTimeout(spawnTimer);
    clearInterval(tickTimer);
    clearAll();
    sfx.over();

    // high score
    const isNew = score > best;
    if (isNew){
      best = score;
      try { localStorage.setItem(LS_KEY, String(best)); } catch(e){}
    }
    bestVal.textContent = best;

    // show overlay in game-over mode
    ovTitle.textContent = "GAME OVER";
    ovDesc.style.display = "none";
    finalScore.style.display = "block";
    finalScore.textContent = score;
    finalBest.style.display = "block";
    finalBest.textContent = "Best: " + best;
    newBest.classList.toggle("hidden", !isNew);
    startBtn.textContent = "PLAY AGAIN";
    overlay.classList.remove("hidden");
  }

  function clearAll(){
    for (let i=0;i<HOLES;i++){
      const rec = occ[i];
      if (rec){ clearTimeout(rec.timeout); if (rec.el && rec.el.parentNode) rec.el.parentNode.removeChild(rec.el); }
      occ[i] = null;
    }
    holes.forEach(h=>{
      h.classList.remove("hit","badhit");
      // strip leftover pops/ripples
      Array.from(h.querySelectorAll(".pop,.ripple,.target")).forEach(n=>n.remove());
    });
  }

  // ---------- Mute ----------
  muteBtn.addEventListener("click", (e)=>{
    e.stopPropagation();
    muted = !muted;
    muteBtn.textContent = muted ? "🔇" : "🔊";
    try { localStorage.setItem("abramsWhack.muted", muted ? "1":"0"); } catch(e){}
    if (!muted) audio(); // wake context
  });

  // ---------- Start button ----------
  startBtn.addEventListener("click", ()=>{ audio(); startRound(); });

  // keyboard: space/enter to start when not running
  window.addEventListener("keydown", (e)=>{
    if (!running && (e.code === "Space" || e.code === "Enter")){
      e.preventDefault(); audio(); startRound();
    }
  });

  // prevent iOS double-tap zoom on board taps handled via pointerdown already
  document.addEventListener("gesturestart", e=>e.preventDefault());

  // ---------- Init ----------
  buildBoard();
  updateHUD(); renderLives(); renderTime();

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