← back to Games Agentabrams

games/minesweeper/index.html

821 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 Minesweeper</title>
<style>
  :root{
    --bg:#080b14;
    --bg2:#0d1220;
    --panel:#111a2e;
    --panel2:#0b1120;
    --grid:#0a0f1c;
    --cell:#1a2540;
    --cell-hi:#243258;
    --cell-lo:#0f1830;
    --revealed:#0c1424;
    --line:#22304f;
    --neon:#00f0ff;
    --neon2:#ff2bd6;
    --neon3:#7cff5c;
    --amber:#ffb020;
    --danger:#ff3b5c;
    --text:#cfe3ff;
    --muted:#6b83ad;
    --n1:#4ea1ff;
    --n2:#5cffb0;
    --n3:#ff6b8a;
    --n4:#b48bff;
    --n5:#ffb020;
    --n6:#38e8ff;
    --n7:#ff8ad6;
    --n8:#c8d4ee;
  }
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
  html,body{margin:0;padding:0;height:100%;}
  body{
    font-family:"Segoe UI",Roboto,-apple-system,system-ui,sans-serif;
    background:
      radial-gradient(1200px 600px at 20% -10%, rgba(0,240,255,.10), transparent 60%),
      radial-gradient(900px 500px at 110% 10%, rgba(255,43,214,.10), transparent 55%),
      linear-gradient(160deg,var(--bg),var(--bg2));
    color:var(--text);
    min-height:100%;
    display:flex;
    flex-direction:column;
    align-items:center;
    justify-content:flex-start;
    overflow:hidden;
    user-select:none;
    -webkit-user-select:none;
  }
  #app{
    width:100%;
    max-width:920px;
    min-height:100%;
    display:flex;
    flex-direction:column;
    padding:10px clamp(8px,2vw,18px) 14px;
    gap:10px;
  }
  header{
    display:flex;
    align-items:center;
    justify-content:space-between;
    gap:10px;
    flex-wrap:wrap;
  }
  .title{
    display:flex;
    align-items:baseline;
    gap:8px;
    letter-spacing:.5px;
  }
  .title h1{
    margin:0;
    font-size:clamp(18px,3.4vw,26px);
    font-weight:800;
    background:linear-gradient(90deg,var(--neon),var(--neon2));
    -webkit-background-clip:text;background-clip:text;
    -webkit-text-fill-color:transparent;
    text-shadow:0 0 18px rgba(0,240,255,.25);
  }
  .title .sub{
    font-size:11px;
    color:var(--muted);
    text-transform:uppercase;
    letter-spacing:2px;
  }
  .controls{
    display:flex;
    align-items:center;
    gap:8px;
    flex-wrap:wrap;
  }
  select,button{
    font-family:inherit;
    font-size:13px;
    color:var(--text);
    background:linear-gradient(180deg,var(--cell-hi),var(--cell));
    border:1px solid var(--line);
    border-radius:9px;
    padding:8px 12px;
    cursor:pointer;
    transition:transform .06s ease,box-shadow .15s ease,border-color .15s ease,background .15s ease;
    outline:none;
  }
  select{padding-right:26px;}
  button:hover,select:hover{border-color:var(--neon);box-shadow:0 0 12px rgba(0,240,255,.25);}
  button:active{transform:translateY(1px) scale(.98);}
  button.primary{
    background:linear-gradient(180deg,rgba(0,240,255,.22),rgba(0,240,255,.06));
    border-color:rgba(0,240,255,.5);
    color:#eafcff;
    font-weight:700;
  }
  button.icon{padding:8px 11px;}
  button.on{border-color:var(--neon3);box-shadow:0 0 12px rgba(124,255,92,.35);color:#eaffee;}
  .status{
    display:flex;
    align-items:stretch;
    justify-content:space-between;
    gap:10px;
    background:linear-gradient(180deg,var(--panel),var(--panel2));
    border:1px solid var(--line);
    border-radius:14px;
    padding:10px 14px;
    box-shadow:inset 0 0 24px rgba(0,240,255,.05), 0 6px 20px rgba(0,0,0,.35);
  }
  .stat{
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:2px;
    min-width:74px;
  }
  .stat .label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px;}
  .stat .val{
    font-family:"Courier New",monospace;
    font-weight:800;
    font-size:clamp(20px,4.4vw,28px);
    letter-spacing:1px;
    line-height:1;
  }
  .stat.mines .val{color:var(--danger);text-shadow:0 0 10px rgba(255,59,92,.45);}
  .stat.time .val{color:var(--neon);text-shadow:0 0 10px rgba(0,240,255,.4);}
  .face{
    display:flex;align-items:center;justify-content:center;
    font-size:clamp(24px,6vw,34px);
    width:clamp(52px,11vw,64px);height:clamp(52px,11vw,64px);
    border-radius:14px;
    background:linear-gradient(180deg,var(--cell-hi),var(--cell-lo));
    border:1px solid var(--line);
    cursor:pointer;
    box-shadow:0 4px 14px rgba(0,0,0,.4);
    transition:transform .06s ease,box-shadow .15s ease;
  }
  .face:hover{box-shadow:0 0 16px rgba(0,240,255,.3);}
  .face:active{transform:translateY(1px) scale(.96);}
  .boardwrap{
    flex:1;
    display:flex;
    align-items:center;
    justify-content:center;
    min-height:0;
    position:relative;
  }
  .board{
    display:grid;
    gap:3px;
    padding:10px;
    background:linear-gradient(180deg,var(--grid),#060a14);
    border:1px solid var(--line);
    border-radius:16px;
    box-shadow:inset 0 0 40px rgba(0,240,255,.06), 0 10px 34px rgba(0,0,0,.5);
    touch-action:manipulation;
  }
  .cell{
    display:flex;align-items:center;justify-content:center;
    font-weight:800;
    line-height:1;
    border-radius:6px;
    background:linear-gradient(180deg,var(--cell-hi),var(--cell));
    border:1px solid rgba(255,255,255,.04);
    box-shadow:inset 0 1px 0 rgba(255,255,255,.06), 0 2px 4px rgba(0,0,0,.35);
    cursor:pointer;
    transition:background .08s ease,transform .05s ease,box-shadow .12s ease;
    color:var(--text);
    overflow:hidden;
  }
  .cell:hover:not(.open):not(.flag){background:linear-gradient(180deg,#2c3d68,#1e2c50);}
  .cell.open{
    background:var(--revealed);
    box-shadow:inset 0 0 0 1px rgba(0,240,255,.05);
    cursor:default;
    border-color:rgba(0,240,255,.06);
  }
  .cell.open.empty{background:#0a1120;}
  .cell.flag{background:linear-gradient(180deg,#2a1636,#1c0f26);}
  .cell.mine{background:radial-gradient(circle at 50% 45%,rgba(255,59,92,.5),#200712);}
  .cell.exploded{background:radial-gradient(circle at 50% 45%,#ff3b5c,#7a0018);box-shadow:0 0 18px rgba(255,59,92,.7);}
  .cell.wrong{background:linear-gradient(180deg,#2a2030,#221018);}
  .cell.hint{animation:pulse 1s ease infinite;}
  @keyframes pulse{0%,100%{box-shadow:inset 0 0 0 2px var(--neon);}50%{box-shadow:inset 0 0 12px 2px var(--neon);}}
  .n1{color:var(--n1);}.n2{color:var(--n2);}.n3{color:var(--n3);}.n4{color:var(--n4);}
  .n5{color:var(--n5);}.n6{color:var(--n6);}.n7{color:var(--n7);}.n8{color:var(--n8);}
  .flagbar{
    display:flex;align-items:center;justify-content:center;gap:10px;
    flex-wrap:wrap;
  }
  .flagtoggle{
    display:flex;align-items:center;gap:10px;
    padding:10px 18px;
    font-size:15px;font-weight:700;
    border-radius:12px;
    background:linear-gradient(180deg,var(--cell-hi),var(--cell));
    border:1px solid var(--line);
  }
  .flagtoggle.armed{
    border-color:var(--neon2);
    box-shadow:0 0 18px rgba(255,43,214,.4);
    background:linear-gradient(180deg,rgba(255,43,214,.25),rgba(255,43,214,.08));
    color:#ffeafb;
  }
  .flagtoggle .mode{font-size:11px;letter-spacing:1px;text-transform:uppercase;opacity:.85;}
  .hintbtn{font-size:13px;}
  .overlay{
    position:absolute;inset:0;
    display:none;
    align-items:center;justify-content:center;
    background:rgba(4,8,16,.72);
    backdrop-filter:blur(3px);
    border-radius:16px;
    z-index:5;
    padding:16px;
  }
  .overlay.show{display:flex;animation:fade .25s ease;}
  @keyframes fade{from{opacity:0}to{opacity:1}}
  .card{
    text-align:center;
    background:linear-gradient(180deg,var(--panel),var(--panel2));
    border:1px solid var(--line);
    border-radius:18px;
    padding:26px 30px;
    box-shadow:0 16px 50px rgba(0,0,0,.6);
    max-width:340px;
  }
  .card h2{
    margin:0 0 6px;
    font-size:30px;
    letter-spacing:1px;
  }
  .card.win h2{color:var(--neon3);text-shadow:0 0 20px rgba(124,255,92,.5);}
  .card.lose h2{color:var(--danger);text-shadow:0 0 20px rgba(255,59,92,.5);}
  .card p{margin:6px 0;color:var(--text);font-size:14px;}
  .card .best{color:var(--amber);font-weight:700;}
  .card .newbest{color:var(--neon3);font-weight:800;animation:pulse 1s infinite;}
  .card button{margin-top:16px;font-size:15px;padding:12px 26px;}
  footer{
    text-align:center;font-size:11px;color:var(--muted);letter-spacing:.5px;
  }
  footer .best-line{color:var(--amber);}
  @media (max-width:520px){
    .stat{min-width:58px;}
    .controls{width:100%;justify-content:center;}
  }
</style>
</head>
<body>
<div id="app">
  <header>
    <div class="title">
      <h1>ABRAMS MINESWEEPER</h1>
      <span class="sub">neon sweep</span>
    </div>
    <div class="controls">
      <select id="difficulty" aria-label="Difficulty">
        <option value="beginner">Beginner · 9×9 · 10</option>
        <option value="intermediate">Intermediate · 16×16 · 40</option>
        <option value="expert">Expert · 16×30 · 99</option>
      </select>
      <button id="newgame" class="primary">↻ New Game</button>
      <button id="mute" class="icon on" title="Toggle sound">🔊</button>
    </div>
  </header>

  <div class="status">
    <div class="stat mines">
      <span class="label">Mines</span>
      <span class="val" id="mineCount">010</span>
    </div>
    <div class="face" id="face" title="New game">🙂</div>
    <div class="stat time">
      <span class="label">Time</span>
      <span class="val" id="timer">000</span>
    </div>
  </div>

  <div class="flagbar">
    <div class="flagtoggle" id="flagToggle" role="button" tabindex="0" aria-pressed="false">
      <span id="flagIcon">⛏️</span>
      <span class="mode" id="flagMode">Dig mode</span>
    </div>
    <button class="hintbtn" id="hint">💡 Hint</button>
  </div>

  <div class="boardwrap">
    <div class="board" id="board"></div>
    <div class="overlay" id="overlay">
      <div class="card" id="card">
        <h2 id="ovTitle">You Win!</h2>
        <p id="ovMsg"></p>
        <p id="ovBest"></p>
        <button class="primary" id="ovBtn">Play Again</button>
      </div>
    </div>
  </div>

  <footer>
    <div class="best-line" id="footerBest"></div>
    <div>Left-click / tap to dig · Right-click or Flag toggle to mark · First click is always safe</div>
  </footer>
</div>

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

  const DIFFS = {
    beginner:     { rows:9,  cols:9,  mines:10, label:"Beginner" },
    intermediate: { rows:16, cols:16, mines:40, label:"Intermediate" },
    expert:       { rows:16, cols:30, mines:99, label:"Expert" }
  };

  const LS_KEY = "abrams_minesweeper_best_v1";
  const LS_MUTE = "abrams_minesweeper_mute_v1";

  // --- DOM refs
  const boardEl   = document.getElementById("board");
  const mineCountEl = document.getElementById("mineCount");
  const timerEl   = document.getElementById("timer");
  const faceEl    = document.getElementById("face");
  const diffSel   = document.getElementById("difficulty");
  const newBtn    = document.getElementById("newgame");
  const muteBtn   = document.getElementById("mute");
  const flagToggle= document.getElementById("flagToggle");
  const flagIcon  = document.getElementById("flagIcon");
  const flagMode  = document.getElementById("flagMode");
  const hintBtn   = document.getElementById("hint");
  const overlay   = document.getElementById("overlay");
  const card      = document.getElementById("card");
  const ovTitle   = document.getElementById("ovTitle");
  const ovMsg     = document.getElementById("ovMsg");
  const ovBest    = document.getElementById("ovBest");
  const ovBtn     = document.getElementById("ovBtn");
  const footerBest= document.getElementById("footerBest");

  // --- state
  let cfg, grid, cellEls, flagMode_on=false, firstClickDone, gameOver, won;
  let flagsPlaced=0, revealedCount=0, totalSafe=0;
  let timer=0, timerId=null, muted=false;

  // ---------- Web Audio (procedural, no assets) ----------
  let audioCtx=null;
  function ac(){
    if(muted) return null;
    if(!audioCtx){
      try{ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }
      catch(e){ return null; }
    }
    if(audioCtx.state==="suspended") audioCtx.resume();
    return audioCtx;
  }
  function blip(freq, dur, type, vol){
    const ctx=ac(); if(!ctx) return;
    const o=ctx.createOscillator(), g=ctx.createGain();
    o.type=type||"square"; o.frequency.value=freq;
    g.gain.value=0.0001;
    o.connect(g); g.connect(ctx.destination);
    const t=ctx.currentTime;
    g.gain.setValueAtTime(0.0001,t);
    g.gain.exponentialRampToValueAtTime(vol||0.12, t+0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
    o.start(t); o.stop(t+dur+0.02);
  }
  const S = {
    reveal(){ blip(520,0.05,"triangle",0.08); },
    flood(){ blip(680,0.07,"sine",0.06); },
    flag(){ blip(880,0.06,"square",0.10); },
    unflag(){ blip(300,0.06,"square",0.08); },
    win(){ [523,659,784,1047].forEach((f,i)=>setTimeout(()=>blip(f,0.18,"triangle",0.14),i*110)); },
    lose(){ blip(160,0.4,"sawtooth",0.16); setTimeout(()=>blip(90,0.5,"sawtooth",0.14),140); },
    click(){ blip(440,0.03,"square",0.05); }
  };

  // ---------- persistence ----------
  function loadBest(){
    try{ return JSON.parse(localStorage.getItem(LS_KEY)) || {}; }
    catch(e){ return {}; }
  }
  function saveBest(o){
    try{ localStorage.setItem(LS_KEY, JSON.stringify(o)); }catch(e){}
  }
  function getBest(key){ const b=loadBest(); return (typeof b[key]==="number")?b[key]:null; }
  function setBest(key,val){ const b=loadBest(); b[key]=val; saveBest(b); }

  function loadMute(){
    try{ return localStorage.getItem(LS_MUTE)==="1"; }catch(e){ return false; }
  }
  function saveMute(v){ try{ localStorage.setItem(LS_MUTE, v?"1":"0"); }catch(e){} }

  // ---------- helpers ----------
  function pad3(n){ n=Math.max(0,Math.min(999,n|0)); return String(n).padStart(3,"0"); }
  function idx(r,c){ return r*cfg.cols+c; }
  function inBounds(r,c){ return r>=0 && r<cfg.rows && c>=0 && c<cfg.cols; }
  function neighbors(r,c){
    const out=[];
    for(let dr=-1;dr<=1;dr++)for(let dc=-1;dc<=1;dc++){
      if(dr===0&&dc===0) continue;
      if(inBounds(r+dr,c+dc)) out.push([r+dr,c+dc]);
    }
    return out;
  }

  // ---------- board sizing ----------
  function sizeBoard(){
    // Compute cell size that fits the available viewport.
    const app = document.getElementById("app");
    const appW = app.clientWidth - (parseFloat(getComputedStyle(app).paddingLeft)+parseFloat(getComputedStyle(app).paddingRight));
    // available height: viewport minus other chrome (approx). Use boardwrap.
    const wrap = boardEl.parentElement;
    const wrapH = wrap.clientHeight || (window.innerHeight*0.55);
    const gap=3, pad=10;
    const availW = Math.max(120, appW) - pad*2 - gap*(cfg.cols-1);
    const availH = Math.max(120, wrapH) - pad*2 - gap*(cfg.rows-1) - 4;
    let size = Math.floor(Math.min(availW/cfg.cols, availH/cfg.rows));
    size = Math.max(16, Math.min(46, size));
    boardEl.style.gridTemplateColumns = `repeat(${cfg.cols}, ${size}px)`;
    boardEl.style.gridTemplateRows    = `repeat(${cfg.rows}, ${size}px)`;
    const fs = Math.max(10, Math.round(size*0.56));
    for(const el of cellEls){ el.style.fontSize = fs+"px"; }
  }

  // ---------- new game ----------
  function newGame(){
    const key = diffSel.value;
    cfg = DIFFS[key];
    grid = [];
    cellEls = [];
    firstClickDone=false; gameOver=false; won=false;
    flagsPlaced=0; revealedCount=0;
    totalSafe = cfg.rows*cfg.cols - cfg.mines;
    stopTimer(); timer=0; timerEl.textContent="000";
    faceEl.textContent="🙂";
    overlay.classList.remove("show");
    setFlagMode(false);

    for(let i=0;i<cfg.rows*cfg.cols;i++){
      grid.push({ mine:false, count:0, open:false, flag:false, r:Math.floor(i/cfg.cols), c:i%cfg.cols });
    }

    boardEl.innerHTML="";
    const frag=document.createDocumentFragment();
    for(let r=0;r<cfg.rows;r++){
      for(let c=0;c<cfg.cols;c++){
        const el=document.createElement("div");
        el.className="cell";
        el.dataset.r=r; el.dataset.c=c;
        frag.appendChild(el);
        cellEls.push(el);
      }
    }
    boardEl.appendChild(frag);
    mineCountEl.textContent = pad3(cfg.mines);
    sizeBoard();
    updateFooterBest();
  }

  // ---------- mine placement (first click safe) ----------
  function placeMines(safeR, safeC){
    // build forbidden set: the clicked cell + its neighbors (guarantees an opening)
    const forbidden = new Set();
    forbidden.add(idx(safeR,safeC));
    for(const [nr,nc] of neighbors(safeR,safeC)) forbidden.add(idx(nr,nc));

    // if board too small to keep all neighbors safe, at least keep the clicked cell safe
    let openable = cfg.rows*cfg.cols - forbidden.size;
    let localForbidden = forbidden;
    if(openable < cfg.mines){
      localForbidden = new Set([idx(safeR,safeC)]);
    }

    const candidates=[];
    for(let i=0;i<cfg.rows*cfg.cols;i++){ if(!localForbidden.has(i)) candidates.push(i); }
    // Fisher-Yates partial shuffle
    for(let i=candidates.length-1;i>0;i--){
      const j=Math.floor(Math.random()*(i+1));
      [candidates[i],candidates[j]]=[candidates[j],candidates[i]];
    }
    for(let i=0;i<cfg.mines;i++){ grid[candidates[i]].mine=true; }

    // counts
    for(let r=0;r<cfg.rows;r++)for(let c=0;c<cfg.cols;c++){
      const cell=grid[idx(r,c)];
      if(cell.mine){ cell.count=-1; continue; }
      let n=0;
      for(const [nr,nc] of neighbors(r,c)){ if(grid[idx(nr,nc)].mine) n++; }
      cell.count=n;
    }
  }

  // ---------- rendering a cell ----------
  function renderCell(i){
    const cell=grid[i], el=cellEls[i];
    el.className="cell";
    el.textContent="";
    if(cell.flag && !cell.open){
      el.classList.add("flag");
      el.textContent="🚩";
      return;
    }
    if(cell.open){
      el.classList.add("open");
      if(cell.mine){
        el.classList.add("mine");
        el.textContent="💣";
      } else if(cell.count>0){
        el.classList.add("n"+cell.count);
        el.textContent=String(cell.count);
      } else {
        el.classList.add("empty");
      }
    }
  }

  // ---------- reveal / flood ----------
  function reveal(r,c){
    if(gameOver) return;
    const start=grid[idx(r,c)];
    if(start.open || start.flag) return;

    if(!firstClickDone){
      placeMines(r,c);
      firstClickDone=true;
      startTimer();
    }

    if(start.mine){
      explode(idx(r,c));
      return;
    }

    // iterative flood fill
    const stack=[[r,c]];
    let flooded=0;
    while(stack.length){
      const [cr,cc]=stack.pop();
      const ci=idx(cr,cc);
      const cell=grid[ci];
      if(cell.open || cell.flag || cell.mine) continue;
      cell.open=true; revealedCount++; flooded++;
      renderCell(ci);
      if(cell.count===0){
        for(const [nr,nc] of neighbors(cr,cc)){
          const ni=idx(nr,nc);
          if(!grid[ni].open && !grid[ni].flag && !grid[ni].mine){
            stack.push([nr,nc]);
          }
        }
      }
    }
    if(flooded>1) S.flood(); else S.reveal();
    checkWin();
  }

  // chord: if an open numbered cell has exactly `count` flags around it, reveal the rest
  function chord(r,c){
    if(gameOver) return;
    const cell=grid[idx(r,c)];
    if(!cell.open || cell.count<=0) return;
    let flags=0; const toOpen=[];
    for(const [nr,nc] of neighbors(r,c)){
      const nb=grid[idx(nr,nc)];
      if(nb.flag) flags++;
      else if(!nb.open) toOpen.push([nr,nc]);
    }
    if(flags===cell.count){
      for(const [nr,nc] of toOpen){ reveal(nr,nc); if(gameOver) return; }
    }
  }

  function explode(i){
    grid[i].open=true;
    gameOver=true; won=false;
    stopTimer();
    // reveal all mines + mark wrong flags
    for(let k=0;k<grid.length;k++){
      const cell=grid[k];
      if(cell.mine){
        cell.open=true; renderCell(k);
        if(k===i) cellEls[k].classList.add("exploded");
      } else if(cell.flag){
        renderCell(k);
        cellEls[k].classList.add("wrong");
        cellEls[k].textContent="❌";
      }
    }
    cellEls[i].classList.add("exploded");
    faceEl.textContent="😵";
    S.lose();
    setTimeout(()=>showOverlay(false), 380);
  }

  function checkWin(){
    if(gameOver) return;
    if(revealedCount>=totalSafe){
      gameOver=true; won=true;
      stopTimer();
      // auto-flag remaining mines
      for(let k=0;k<grid.length;k++){
        if(grid[k].mine && !grid[k].flag){ grid[k].flag=true; renderCell(k); }
      }
      flagsPlaced=cfg.mines;
      mineCountEl.textContent="000";
      faceEl.textContent="😎";
      S.win();
      handleWinRecord();
      setTimeout(()=>showOverlay(true), 260);
    }
  }

  // ---------- flag ----------
  function toggleFlag(r,c){
    if(gameOver) return;
    const cell=grid[idx(r,c)];
    if(cell.open) return;
    if(!firstClickDone){ startTimer(); firstClickDone=false; } // don't place mines on flag
    cell.flag=!cell.flag;
    flagsPlaced += cell.flag?1:-1;
    renderCell(idx(r,c));
    mineCountEl.textContent = pad3(cfg.mines - flagsPlaced);
    if(cell.flag) S.flag(); else S.unflag();
  }

  // ---------- timer ----------
  function startTimer(){
    if(timerId) return;
    timerId=setInterval(()=>{
      timer++; if(timer>999) timer=999;
      timerEl.textContent=pad3(timer);
    },1000);
  }
  function stopTimer(){ if(timerId){ clearInterval(timerId); timerId=null; } }

  // ---------- win record ----------
  function handleWinRecord(){
    const key=diffSel.value;
    const prev=getBest(key);
    if(prev===null || timer<prev){
      setBest(key, timer);
      won_newBest=true;
    } else {
      won_newBest=false;
    }
    updateFooterBest();
  }
  let won_newBest=false;

  // ---------- overlay ----------
  function showOverlay(isWin){
    card.classList.remove("win","lose");
    if(isWin){
      card.classList.add("win");
      ovTitle.textContent="⚡ CLEARED ⚡";
      ovMsg.textContent = `${DIFFS[diffSel.value].label} swept in ${timer}s`;
      const best=getBest(diffSel.value);
      if(won_newBest){
        ovBest.innerHTML = `<span class="newbest">★ NEW BEST TIME ★</span>`;
      } else {
        ovBest.innerHTML = best!==null ? `<span class="best">Best: ${best}s</span>` : "";
      }
    } else {
      card.classList.add("lose");
      ovTitle.textContent="💥 BOOM 💥";
      ovMsg.textContent="You hit a mine.";
      const best=getBest(diffSel.value);
      ovBest.innerHTML = best!==null ? `<span class="best">Best: ${best}s</span>` : "";
    }
    overlay.classList.add("show");
  }

  function updateFooterBest(){
    const best=getBest(diffSel.value);
    footerBest.textContent = best!==null
      ? `Best ${DIFFS[diffSel.value].label}: ${best}s`
      : `No best time yet for ${DIFFS[diffSel.value].label}`;
  }

  // ---------- flag mode toggle ----------
  function setFlagMode(on){
    flagMode_on=on;
    flagToggle.classList.toggle("armed",on);
    flagToggle.setAttribute("aria-pressed", on?"true":"false");
    flagIcon.textContent = on?"🚩":"⛏️";
    flagMode.textContent = on?"Flag mode":"Dig mode";
  }

  // ---------- hint ----------
  function giveHint(){
    if(gameOver) return;
    if(!firstClickDone){
      // hint the center-ish safe first click
      const r=Math.floor(cfg.rows/2), c=Math.floor(cfg.cols/2);
      pulseHint(idx(r,c));
      return;
    }
    // find a safe unopened, unflagged cell
    const safe=[];
    for(let k=0;k<grid.length;k++){
      if(!grid[k].open && !grid[k].flag && !grid[k].mine) safe.push(k);
    }
    if(safe.length){
      pulseHint(safe[Math.floor(Math.random()*safe.length)]);
      S.click();
    }
  }
  function pulseHint(i){
    const el=cellEls[i];
    el.classList.add("hint");
    setTimeout(()=>el.classList.remove("hint"),1600);
  }

  // ---------- event handling ----------
  let touchTimer=null, touchMoved=false, longPressed=false;

  function cellFromEvent(e){
    const t = e.target.closest ? e.target.closest(".cell") : null;
    if(!t) return null;
    return { r:+t.dataset.r, c:+t.dataset.c };
  }

  boardEl.addEventListener("click", (e)=>{
    if(longPressed){ longPressed=false; return; }
    const pos=cellFromEvent(e); if(!pos) return;
    if(gameOver) return;
    ac(); // unlock audio on gesture
    const cell=grid[idx(pos.r,pos.c)];
    if(flagMode_on){
      toggleFlag(pos.r,pos.c);
    } else if(cell.open && cell.count>0){
      chord(pos.r,pos.c);
    } else {
      reveal(pos.r,pos.c);
    }
  });

  boardEl.addEventListener("contextmenu",(e)=>{
    e.preventDefault();
    const pos=cellFromEvent(e); if(!pos) return;
    if(gameOver) return;
    ac();
    const cell=grid[idx(pos.r,pos.c)];
    if(cell.open && cell.count>0) chord(pos.r,pos.c);
    else toggleFlag(pos.r,pos.c);
  });

  // touch: long-press = flag
  boardEl.addEventListener("touchstart",(e)=>{
    if(gameOver) return;
    const pos=cellFromEvent(e); if(!pos) return;
    touchMoved=false; longPressed=false;
    touchTimer=setTimeout(()=>{
      longPressed=true;
      ac();
      const cell=grid[idx(pos.r,pos.c)];
      if(cell.open && cell.count>0) chord(pos.r,pos.c);
      else toggleFlag(pos.r,pos.c);
    },420);
  },{passive:true});
  boardEl.addEventListener("touchmove",()=>{ touchMoved=true; if(touchTimer){clearTimeout(touchTimer);touchTimer=null;} },{passive:true});
  boardEl.addEventListener("touchend",()=>{ if(touchTimer){clearTimeout(touchTimer);touchTimer=null;} });

  // ---------- control wiring ----------
  newBtn.addEventListener("click", ()=>{ S.click(); newGame(); });
  faceEl.addEventListener("click", ()=>{ S.click(); newGame(); });
  ovBtn.addEventListener("click", ()=>{ newGame(); });
  diffSel.addEventListener("change", ()=>{ newGame(); });

  flagToggle.addEventListener("click", ()=>{ setFlagMode(!flagMode_on); S.click(); });
  flagToggle.addEventListener("keydown",(e)=>{ if(e.key===" "||e.key==="Enter"){ e.preventDefault(); setFlagMode(!flagMode_on); }});
  hintBtn.addEventListener("click", ()=>{ giveHint(); });

  muteBtn.addEventListener("click", ()=>{
    muted=!muted; saveMute(muted);
    muteBtn.textContent = muted?"🔇":"🔊";
    muteBtn.classList.toggle("on",!muted);
    if(!muted) S.click();
  });

  // keyboard: F toggles flag mode, N new game, H hint
  document.addEventListener("keydown",(e)=>{
    if(e.target && (e.target.tagName==="SELECT")) return;
    const k=e.key.toLowerCase();
    if(k==="f"){ setFlagMode(!flagMode_on); }
    else if(k==="n"){ newGame(); }
    else if(k==="h"){ giveHint(); }
  });

  window.addEventListener("resize", ()=>{ if(cfg) sizeBoard(); });
  window.addEventListener("orientationchange", ()=>{ setTimeout(()=>{ if(cfg) sizeBoard(); },200); });

  // ---------- init ----------
  muted=loadMute();
  muteBtn.textContent = muted?"🔇":"🔊";
  muteBtn.classList.toggle("on",!muted);
  newGame();
})();
</script>
</body>
</html>