← back to Games Agentabrams
games/minesweeper/AbramsMines.aa
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>