Created
August 8, 2025 07:34
-
-
Save senko/5c0fa6f262bf88cbe521c7c9a897e0dd to your computer and use it in GitHub Desktop.
GPT-5 (full) zero-shotting Minesweeper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>Minesweeper โ Pure HTML/CSS/JS</title> | |
<style> | |
:root{ | |
--bg: #0f1226; | |
--surface: #171a37; | |
--surface-2: #1e2249; | |
--border: rgba(255,255,255,0.08); | |
--text: #e8ebff; | |
--muted: #9aa4d6; | |
--accent: #ff5f87; | |
--good: #73e2a7; | |
--bad: #ff6b6b; | |
--warn: #ffc260; | |
--shadow: 0 10px 30px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.06); | |
--glow: 0 0 0 0 rgba(255,95,135,0); | |
--cell-size: 36px; | |
--cell-radius: 10px; | |
--gap: 8px; | |
} | |
*{ box-sizing: border-box; } | |
html, body { height: 100%; } | |
body{ | |
margin:0; | |
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; | |
background: radial-gradient(1200px 800px at 75% -10%, #233 0%, transparent 60%), radial-gradient(1000px 700px at -20% 110%, #27314f 0%, transparent 60%), var(--bg); | |
color: var(--text); | |
display:flex; | |
align-items:center; | |
justify-content:center; | |
padding: 24px; | |
} | |
.app{ | |
width: min(1000px, 96vw); | |
} | |
.panel{ | |
background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(0,0,0,0.15)), var(--surface); | |
border: 1px solid var(--border); | |
border-radius: 16px; | |
box-shadow: var(--shadow); | |
backdrop-filter: blur(6px); | |
} | |
.topbar{ | |
display:flex; | |
align-items:center; | |
justify-content:space-between; | |
gap:12px; | |
padding: 14px 16px; | |
margin-bottom: 14px; | |
} | |
.left-group, .right-group{ | |
display:flex; | |
align-items:center; | |
gap:10px; | |
} | |
.brand{ | |
font-weight:800; | |
letter-spacing:0.5px; | |
background: linear-gradient(90deg, #c6d0ff, #a7b2ff 60%, #e8abff); | |
-webkit-background-clip: text; | |
background-clip: text; | |
color: transparent; | |
text-shadow: 0 2px 18px rgba(167,178,255,0.2); | |
} | |
.seg{ | |
display:inline-flex; | |
background: var(--surface-2); | |
border: 1px solid var(--border); | |
border-radius: 999px; | |
overflow:hidden; | |
} | |
.seg button{ | |
all:unset; | |
cursor:pointer; | |
padding:8px 12px; | |
color: var(--muted); | |
transition: 200ms ease; | |
position:relative; | |
} | |
.seg button:hover{ color: var(--text); } | |
.seg button.active{ | |
color:#0f1230; | |
} | |
.seg button.active::before{ | |
content:""; | |
position:absolute; inset:2px; | |
border-radius:999px; | |
background: linear-gradient(180deg, #e1e6ff, #c8d0ff); | |
box-shadow: 0 8px 20px rgba(180,190,255,0.35), inset 0 1px 0 rgba(255,255,255,0.6); | |
z-index:-1; | |
} | |
.pill{ | |
display:flex; align-items:center; gap:8px; | |
padding:8px 12px; | |
background: var(--surface-2); | |
border:1px solid var(--border); | |
border-radius: 999px; | |
color: var(--muted); | |
font-variant-numeric: tabular-nums; | |
} | |
.pill strong{ color: var(--text); } | |
.face{ | |
all:unset; | |
cursor:pointer; | |
font-size: 24px; | |
width: 40px; height: 40px; | |
display:grid; place-items:center; | |
border-radius: 12px; | |
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.01)); | |
border:1px solid var(--border); | |
box-shadow: var(--shadow), var(--glow); | |
transition: transform 120ms ease, box-shadow 240ms ease; | |
} | |
.face:active{ transform: translateY(1px); } | |
.flag-toggle{ | |
all:unset; cursor:pointer; | |
padding:8px 12px; | |
border-radius: 999px; | |
background: var(--surface-2); | |
border:1px solid var(--border); | |
color: var(--muted); | |
display:flex; align-items:center; gap:8px; | |
transition: 200ms ease; | |
} | |
.flag-toggle.active{ | |
box-shadow: 0 0 0 8px rgba(255,95,135,0.08), 0 8px 24px rgba(255,95,135,0.25); | |
color: var(--text); | |
border-color: rgba(255,95,135,0.6); | |
} | |
.board-wrap{ | |
padding: 14px; | |
} | |
#board{ | |
display:grid; | |
grid-template-columns: repeat(var(--cols), var(--cell-size)); | |
gap: var(--gap); | |
touch-action: manipulation; | |
user-select: none; | |
} | |
.cell{ | |
width: var(--cell-size); | |
height: var(--cell-size); | |
border-radius: var(--cell-radius); | |
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(0,0,0,0.15)), #151836; | |
border:1px solid rgba(255,255,255,0.06); | |
box-shadow: 0 6px 14px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.08); | |
display:grid; place-items:center; | |
font-weight:800; | |
color: transparent; | |
position:relative; | |
cursor: pointer; | |
transition: transform 120ms ease, background 200ms ease, box-shadow 200ms ease, border-color 200ms ease; | |
will-change: transform; | |
} | |
.cell:active{ transform: translateY(1px) scale(0.98); } | |
.cell.revealed{ | |
background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(0,0,0,0.2)), #10122b; | |
color: var(--text); | |
border-color: rgba(255,255,255,0.05); | |
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05), inset 0 -20px 40px rgba(0,0,0,0.35); | |
cursor: default; | |
transform: none; | |
} | |
.cell.revealed.zero{ | |
color: transparent; | |
} | |
.cell.number-1{ color: #71a5ff; } | |
.cell.number-2{ color: #73e2a7; } | |
.cell.number-3{ color: #ff8a8a; } | |
.cell.number-4{ color: #c2a4ff; } | |
.cell.number-5{ color: #ffc260; } | |
.cell.number-6{ color: #7be7ff; } | |
.cell.number-7{ color: #b3a089; } | |
.cell.number-8{ color: #99a7bc; } | |
.cell.flagged::before, | |
.cell.flagged::after{ | |
content:""; | |
position:absolute; | |
transition: 160ms ease; | |
} | |
.cell.flagged::before{ /* pole */ | |
width: 10%; | |
height: 62%; | |
background: #e7eaf7; | |
left: 40%; | |
top: 18%; | |
border-radius: 2px; | |
box-shadow: inset 0 -1px 0 rgba(0,0,0,0.15); | |
} | |
.cell.flagged::after{ /* flag */ | |
left: 41%; | |
top: 20%; | |
width: 0; height: 0; | |
border-top: calc(var(--cell-size) * 0.18) solid var(--accent); | |
border-right: calc(var(--cell-size) * 0.22) solid transparent; | |
filter: drop-shadow(0 1px 0 rgba(0,0,0,0.25)); | |
} | |
.cell.mine.revealed{ | |
background: radial-gradient(circle at 50% 45%, #ff708f 0%, #ff416d 18%, #7a1c38 32%, #170912 60%), #0d0f22; | |
box-shadow: inset 0 0 30px rgba(255,33,84,0.5), 0 0 24px rgba(255,33,84,0.35); | |
border-color: rgba(255,33,84,0.5); | |
color: #fff; | |
} | |
.cell.mine.revealed::before{ | |
content:""; | |
position:absolute; | |
width: 34%; | |
height: 34%; | |
border-radius: 50%; | |
background: radial-gradient(circle at 35% 35%, #fff 0 4%, #000 12% 100%); | |
box-shadow: | |
0 0 0 2px #000, | |
0 0 14px rgba(0,0,0,0.4); | |
} | |
.cell.mine.revealed::after{ | |
content:""; | |
position:absolute; | |
width: 64%; | |
height: 64%; | |
background: conic-gradient(from 0, #000 0 10%, transparent 10% 20%); | |
mask: radial-gradient(circle at center, transparent 0 20%, #000 22% 100%); | |
opacity: 0.8; | |
} | |
.cell.bad-flag{ | |
animation: pulseBad 450ms ease; | |
} | |
@keyframes pulseBad{ | |
0%{ box-shadow: 0 0 0 0 rgba(255,107,107,0.0); } | |
50%{ box-shadow: 0 0 0 10px rgba(255,107,107,0.15); } | |
100%{ box-shadow: 0 0 0 0 rgba(255,107,107,0.0); } | |
} | |
.reveal-anim{ | |
animation: popIn 160ms ease-out both; | |
} | |
@keyframes popIn{ | |
from{ transform: translateY(2px) scale(0.96); opacity: 0.6; } | |
to{ transform: none; opacity: 1; } | |
} | |
.footer{ | |
display:flex; justify-content:space-between; align-items:center; | |
color: var(--muted); | |
margin-top: 12px; | |
font-size: 12px; | |
padding: 0 2px; | |
} | |
.footer a{ color: #b6c0ff; text-decoration: none; } | |
.footer a:hover{ text-decoration: underline; } | |
.inline-inputs{ | |
display:flex; align-items:center; gap:8px; | |
padding: 6px 8px; | |
border-radius: 999px; | |
background: var(--surface-2); | |
border:1px solid var(--border); | |
} | |
.inline-inputs input{ | |
width: 60px; | |
appearance: none; | |
-moz-appearance: textfield; | |
outline:none; | |
border:none; | |
padding:6px 8px; | |
border-radius: 8px; | |
background: rgba(255,255,255,0.05); | |
color: var(--text); | |
font-variant-numeric: tabular-nums; | |
} | |
.inline-inputs input::-webkit-outer-spin-button, | |
.inline-inputs input::-webkit-inner-spin-button{ | |
-webkit-appearance: none; | |
margin:0; | |
} | |
.inline-inputs button{ | |
all:unset; | |
cursor:pointer; | |
padding:6px 10px; | |
border-radius: 8px; | |
background: linear-gradient(180deg, #e1e6ff, #c8d0ff); | |
color:#0f1230; | |
font-weight:700; | |
border:1px solid rgba(255,255,255,0.55); | |
box-shadow: 0 8px 20px rgba(180,190,255,0.35); | |
} | |
.counter.bad strong{ color: var(--bad); } | |
.sr-only{ | |
position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; | |
} | |
@media (max-width: 680px){ | |
:root{ --gap: 6px; --cell-radius: 8px; } | |
.topbar{ flex-wrap: wrap; } | |
.brand{ font-size: 18px; } | |
} | |
</style> | |
</head> | |
<body> | |
<div class="app"> | |
<div class="panel topbar"> | |
<div class="left-group"> | |
<div class="brand">Minesweeper</div> | |
<div class="seg" id="difficulty"> | |
<button data-preset="beginner" class="active">Beginner</button> | |
<button data-preset="intermediate">Intermediate</button> | |
<button data-preset="expert">Expert</button> | |
</div> | |
<div class="inline-inputs" title="Custom size"> | |
<label class="sr-only" for="rows">Rows</label> | |
<input id="rows" type="number" min="5" max="40" value="9"> | |
<label class="sr-only" for="cols">Cols</label> | |
<input id="cols" type="number" min="5" max="50" value="9"> | |
<label class="sr-only" for="mines">Mines</label> | |
<input id="mines" type="number" min="1" value="10"> | |
<button id="applyCustom">Go</button> | |
</div> | |
</div> | |
<div class="right-group"> | |
<div class="pill counter" id="mineCounter" aria-live="polite" aria-atomic="true"> | |
๐ฃ <strong>010</strong> | |
</div> | |
<button class="face" id="faceBtn" title="New game">๐</button> | |
<div class="pill timer" id="timer" aria-live="polite" aria-atomic="true"> | |
โฑ๏ธ <strong>000</strong> | |
</div> | |
<button id="flagMode" class="flag-toggle" title="Toggle Flag mode (mobile friendly)">๐ฉ Flag</button> | |
</div> | |
</div> | |
<div class="panel board-wrap"> | |
<div id="board" aria-label="Minesweeper board" role="grid"></div> | |
</div> | |
<div class="footer"> | |
<div id="status" aria-live="polite">Good luck!</div> | |
<div id="bestTimes"></div> | |
</div> | |
</div> | |
<script> | |
(function(){ | |
const boardEl = document.getElementById('board'); | |
const mineCounterEl = document.querySelector('#mineCounter strong'); | |
const mineCounterPill = document.querySelector('#mineCounter'); | |
const timerEl = document.querySelector('#timer strong'); | |
const faceBtn = document.getElementById('faceBtn'); | |
const flagModeBtn = document.getElementById('flagMode'); | |
const statusEl = document.getElementById('status'); | |
const bestTimesEl = document.getElementById('bestTimes'); | |
const diffSeg = document.getElementById('difficulty'); | |
const rowsInput = document.getElementById('rows'); | |
const colsInput = document.getElementById('cols'); | |
const minesInput = document.getElementById('mines'); | |
const applyCustomBtn = document.getElementById('applyCustom'); | |
const PRESETS = { | |
beginner: { w: 9, h: 9, m: 10, label: 'Beginner' }, | |
intermediate: { w: 16, h: 16, m: 40, label: 'Intermediate' }, | |
expert: { w: 30, h: 16, m: 99, label: 'Expert' } | |
}; | |
let presetName = 'beginner'; | |
let width = PRESETS[presetName].w; | |
let height = PRESETS[presetName].h; | |
let mineCount = PRESETS[presetName].m; | |
let started = false; | |
let gameOver = false; | |
let elapsed = 0; | |
let timerId = null; | |
let flagsPlaced = 0; | |
let revealedCount = 0; | |
let cells = []; // array of cell objects | |
let safeStartNeighbors = true; // friendlier start | |
let longPressTimer = null; | |
let longPressDidFlag = false; | |
// Responsive cell sizing | |
function updateCellSize(){ | |
const wrap = boardEl.parentElement; | |
const available = wrap.clientWidth - 2 * 14 - (width - 1) * getGapPx(); // padding & gaps | |
const size = Math.max(22, Math.min(44, Math.floor(available / width))); | |
document.documentElement.style.setProperty('--cell-size', size + 'px'); | |
boardEl.style.setProperty('--cols', width); | |
} | |
function getGapPx(){ | |
const gap = getComputedStyle(document.documentElement).getPropertyValue('--gap').trim(); | |
return Number(gap.replace('px','')) || 8; | |
} | |
window.addEventListener('resize', updateCellSize); | |
// Utils | |
function idx(r, c){ return r * width + c; } | |
function inBounds(r, c){ return r >= 0 && r < height && c >= 0 && c < width; } | |
function neighbors(r, c){ | |
const res = []; | |
for(let dr=-1; dr<=1; dr++){ | |
for(let dc=-1; dc<=1; dc++){ | |
if(dr === 0 && dc === 0) continue; | |
const nr = r + dr, nc = c + dc; | |
if(inBounds(nr, nc)) res.push(idx(nr, nc)); | |
} | |
} | |
return res; | |
} | |
function format3(n){ return String(n).padStart(3, '0').slice(-3); } | |
function setStatus(msg){ statusEl.textContent = msg; } | |
function setFace(ch){ faceBtn.textContent = ch; } | |
function setGlow(on){ | |
document.documentElement.style.setProperty('--glow', on ? '0 0 0 10px rgba(115,226,167,0.15)' : '0 0 0 0 rgba(0,0,0,0)'); | |
} | |
function updateMineCounter(){ | |
const remaining = mineCount - flagsPlaced; | |
mineCounterEl.textContent = format3(Math.max(-99, Math.min(999, remaining))); | |
mineCounterPill.classList.toggle('bad', remaining < 0); | |
} | |
function resetTimer(){ | |
clearInterval(timerId); | |
elapsed = 0; | |
timerEl.textContent = format3(0); | |
timerId = null; | |
} | |
function startTimer(){ | |
if(timerId) return; | |
timerId = setInterval(()=>{ | |
elapsed++; | |
timerEl.textContent = format3(elapsed); | |
}, 1000); | |
} | |
function stopTimer(){ | |
if(timerId){ clearInterval(timerId); timerId = null; } | |
} | |
function newGame(w, h, m, preset){ | |
width = w; height = h; mineCount = m; | |
presetName = preset || 'custom'; | |
started = false; | |
gameOver = false; | |
flagsPlaced = 0; | |
revealedCount = 0; | |
longPressDidFlag = false; | |
setFace('๐'); | |
setGlow(false); | |
setStatus('Good luck! Tap to reveal, right-click or long-press to flag.'); | |
updateMineCounter(); | |
resetTimer(); | |
buildBoard(); | |
updateCellSize(); | |
saveInputsFromCurrent(); | |
renderBestTimes(); | |
} | |
function buildBoard(){ | |
boardEl.innerHTML = ''; | |
boardEl.setAttribute('role','grid'); | |
boardEl.setAttribute('aria-rowcount', height); | |
boardEl.setAttribute('aria-colcount', width); | |
boardEl.style.setProperty('--cols', width); | |
cells = new Array(width*height); | |
const frag = document.createDocumentFragment(); | |
for(let r=0; r<height; r++){ | |
for(let c=0; c<width; c++){ | |
const i = idx(r,c); | |
const div = document.createElement('button'); | |
div.className = 'cell'; | |
div.setAttribute('role','gridcell'); | |
div.setAttribute('aria-label', 'Hidden'); | |
div.dataset.index = i; | |
div.dataset.r = r; | |
div.dataset.c = c; | |
div.tabIndex = -1; | |
cells[i] = { | |
r, c, mine:false, adj:0, revealed:false, flagged:false, el:div | |
}; | |
frag.appendChild(div); | |
} | |
} | |
boardEl.appendChild(frag); | |
} | |
function placeMines(firstIndex){ | |
const forbid = new Set([firstIndex]); | |
if(safeStartNeighbors){ | |
const { r, c } = cells[firstIndex]; | |
neighbors(r, c).forEach(n => forbid.add(n)); | |
} | |
const total = width*height; | |
let available = []; | |
for(let i=0; i<total; i++){ | |
if(!forbid.has(i)) available.push(i); | |
} | |
// shuffle and pick | |
for(let i=available.length-1; i>0; i--){ | |
const j = Math.floor(Math.random()*(i+1)); | |
[available[i], available[j]] = [available[j], available[i]]; | |
} | |
const minesToPlace = Math.min(mineCount, available.length); | |
for(let i=0; i<minesToPlace; i++){ | |
const id = available[i]; | |
cells[id].mine = true; | |
} | |
// compute adjacency | |
for(let i=0; i<cells.length; i++){ | |
const cell = cells[i]; | |
if(cell.mine){ cell.adj = 0; continue; } | |
let count = 0; | |
neighbors(cell.r, cell.c).forEach(n => { if(cells[n].mine) count++; }); | |
cell.adj = count; | |
} | |
} | |
function revealCell(i, viaChord=false){ | |
if(gameOver) return; | |
const cell = cells[i]; | |
if(cell.revealed || cell.flagged) return; | |
if(!started){ | |
placeMines(i); | |
started = true; | |
startTimer(); | |
} | |
if(cell.mine){ | |
// hit a mine | |
showMine(i); | |
endGame(false); | |
return; | |
} | |
doReveal(i); | |
if(cell.adj === 0){ | |
floodReveal(i); | |
} | |
if(viaChord){ pulseNeighbors(i); } | |
checkWin(); | |
} | |
function doReveal(i){ | |
const cell = cells[i]; | |
if(cell.revealed) return; | |
cell.revealed = true; | |
revealedCount++; | |
const el = cell.el; | |
el.classList.add('revealed','reveal-anim'); | |
el.setAttribute('aria-label', cell.adj === 0 ? 'Empty' : (cell.adj + ' adjacent')); | |
if(cell.adj > 0){ | |
el.textContent = cell.adj; | |
el.classList.add('number-' + cell.adj); | |
}else{ | |
el.textContent = ''; | |
el.classList.add('zero'); | |
} | |
} | |
function floodReveal(startIndex){ | |
// BFS | |
const q = [startIndex]; | |
const seen = new Set([startIndex]); | |
while(q.length){ | |
const i = q.shift(); | |
const cell = cells[i]; | |
if(cell.adj !== 0) continue; | |
neighbors(cell.r, cell.c).forEach(n=>{ | |
if(seen.has(n)) return; | |
const nc = cells[n]; | |
if(!nc.revealed && !nc.flagged && !nc.mine){ | |
doReveal(n); | |
seen.add(n); | |
if(nc.adj === 0) q.push(n); | |
} | |
}); | |
} | |
} | |
function showMine(explodedIndex){ | |
cells.forEach((cell, i)=>{ | |
if(cell.mine){ | |
const el = cell.el; | |
if(!cell.revealed){ | |
el.classList.add('mine','revealed'); | |
el.setAttribute('aria-label', 'Mine'); | |
} | |
} | |
if(cell.flagged && !cell.mine){ | |
cell.el.classList.add('bad-flag'); | |
} | |
}); | |
cells[explodedIndex].el.style.outline = '2px solid rgba(255,33,84,0.7)'; | |
} | |
function endGame(won){ | |
gameOver = true; | |
stopTimer(); | |
if(won){ | |
setFace('๐'); | |
setGlow(true); | |
setStatus('You win! Time: ' + elapsed + 's'); | |
maybeRecordBest(); | |
// Auto-flag remaining | |
cells.forEach(cell=>{ | |
if(cell.mine && !cell.flagged){ | |
cell.flagged = true; | |
flagsPlaced++; | |
cell.el.classList.add('flagged'); | |
} | |
}); | |
updateMineCounter(); | |
}else{ | |
setFace('๐ต'); | |
setGlow(false); | |
setStatus('Boom! You hit a mine.'); | |
} | |
} | |
function checkWin(){ | |
if(revealedCount === width*height - mineCount){ | |
endGame(true); | |
} | |
} | |
function toggleFlag(i){ | |
if(gameOver) return; | |
const cell = cells[i]; | |
if(cell.revealed) return; | |
if(!started){ | |
// Allow flagging before start; timer starts on first reveal only | |
} | |
cell.flagged = !cell.flagged; | |
const el = cell.el; | |
if(cell.flagged){ | |
el.classList.add('flagged'); | |
el.setAttribute('aria-label','Flagged'); | |
flagsPlaced++; | |
}else{ | |
el.classList.remove('flagged'); | |
el.setAttribute('aria-label','Hidden'); | |
flagsPlaced--; | |
} | |
updateMineCounter(); | |
} | |
function chordReveal(i){ | |
const cell = cells[i]; | |
if(!cell.revealed || cell.adj === 0) return; | |
const neigh = neighbors(cell.r, cell.c); | |
const flagged = neigh.filter(n=>cells[n].flagged).length; | |
if(flagged !== cell.adj){ | |
// suggest with small shake? | |
return; | |
} | |
// reveal all un-flagged neighbors | |
for(const n of neigh){ | |
if(!cells[n].flagged && !cells[n].revealed){ | |
if(cells[n].mine){ | |
// wrong flags -> lose | |
showMine(n); | |
endGame(false); | |
return; | |
} | |
} | |
} | |
for(const n of neigh){ | |
if(!cells[n].flagged && !cells[n].revealed){ | |
revealCell(n, true); | |
} | |
} | |
} | |
function pulseNeighbors(i){ | |
const { r, c } = cells[i]; | |
neighbors(r,c).forEach(n=>{ | |
const el = cells[n].el; | |
el.classList.add('reveal-anim'); | |
setTimeout(()=>el.classList.remove('reveal-anim'), 180); | |
}); | |
} | |
// Events | |
boardEl.addEventListener('contextmenu', (e)=>{ | |
e.preventDefault(); | |
if(gameOver) return; | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
const i = Number(cellEl.dataset.index); | |
toggleFlag(i); | |
}); | |
boardEl.addEventListener('click', (e)=>{ | |
if(gameOver) return; | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
if(cellEl.dataset.ignoreClick === '1'){ | |
cellEl.dataset.ignoreClick = '0'; | |
return; | |
} | |
const i = Number(cellEl.dataset.index); | |
const cell = cells[i]; | |
// If clicking revealed numbered cell, try chord | |
if(cell.revealed && cell.adj > 0){ | |
chordReveal(i); | |
return; | |
} | |
revealCell(i); | |
}); | |
// Touch long-press to flag | |
boardEl.addEventListener('pointerdown', (e)=>{ | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
const i = Number(cellEl.dataset.index); | |
const cell = cells[i]; | |
if(e.pointerType === 'mouse'){ | |
// visual face press on left mouse | |
if(e.button === 0 && !cell.revealed && !cell.flagged && !gameOver){ | |
setFace('๐ฎ'); | |
} | |
return; | |
} | |
if(e.pointerType === 'touch'){ | |
// if flag mode toggle is active, let click handler handle reveal, | |
// and we will toggle flag on tap when flag mode is active by intercepting click | |
if(flagModeBtn.classList.contains('active')){ | |
// Do nothing here | |
return; | |
} | |
// schedule long press | |
longPressDidFlag = false; | |
clearTimeout(longPressTimer); | |
longPressTimer = setTimeout(()=>{ | |
longPressDidFlag = true; | |
toggleFlag(i); | |
cellEl.dataset.ignoreClick = '1'; // prevent following click | |
}, 350); | |
} | |
}); | |
boardEl.addEventListener('pointerup', (e)=>{ | |
setFace(gameOver ? (revealedCount === width*height - mineCount ? '๐' : '๐ต') : '๐'); | |
if(e.pointerType === 'touch'){ | |
clearTimeout(longPressTimer); | |
} | |
}); | |
boardEl.addEventListener('pointercancel', ()=>{ clearTimeout(longPressTimer); }); | |
boardEl.addEventListener('pointermove', ()=>{ /* could cancel long press on move if needed */ }); | |
// Flag mode (for touch) | |
flagModeBtn.addEventListener('click', ()=>{ | |
const active = flagModeBtn.classList.toggle('active'); | |
setStatus(active ? 'Flag mode: tap to place/remove flags.' : 'Reveal mode: tap to open cells.'); | |
}); | |
// Intercept click when flag mode active: toggle flag instead of reveal | |
boardEl.addEventListener('mousedown', (e)=>{ | |
if(e.button === 0){ | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
if(flagModeBtn.classList.contains('active')){ | |
e.preventDefault(); | |
} | |
} | |
}, true); | |
boardEl.addEventListener('click', (e)=>{ | |
if(flagModeBtn.classList.contains('active')){ | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
const i = Number(cellEl.dataset.index); | |
toggleFlag(i); | |
e.stopImmediatePropagation(); | |
e.preventDefault(); | |
} | |
}, true); | |
// Face/new game | |
faceBtn.addEventListener('click', ()=>{ | |
newGame(width, height, mineCount, presetName); | |
}); | |
// Difficulty buttons | |
diffSeg.addEventListener('click', (e)=>{ | |
const btn = e.target.closest('button[data-preset]'); | |
if(!btn) return; | |
diffSeg.querySelectorAll('button').forEach(b=>b.classList.remove('active')); | |
btn.classList.add('active'); | |
const key = btn.dataset.preset; | |
const p = PRESETS[key]; | |
rowsInput.value = p.h; | |
colsInput.value = p.w; | |
minesInput.value = p.m; | |
newGame(p.w, p.h, p.m, key); | |
}); | |
// Apply custom | |
applyCustomBtn.addEventListener('click', ()=>{ | |
const h = clamp(parseInt(rowsInput.value||9,10), 5, 40); | |
const w = clamp(parseInt(colsInput.value||9,10), 5, 50); | |
const maxMines = Math.max(1, w*h - 1); | |
const m = clamp(parseInt(minesInput.value||10,10), 1, maxMines); | |
rowsInput.value = h; colsInput.value = w; minesInput.value = m; | |
diffSeg.querySelectorAll('button').forEach(b=>b.classList.remove('active')); | |
newGame(w, h, m, 'custom'); | |
}); | |
function clamp(v, min, max){ return Math.max(min, Math.min(max, v)); } | |
// Keyboard: Space toggles flag mode, R restarts | |
document.addEventListener('keydown', (e)=>{ | |
if(e.key.toLowerCase() === 'r'){ | |
newGame(width, height, mineCount, presetName); | |
}else if(e.key === ' '){ | |
e.preventDefault(); | |
flagModeBtn.click(); | |
} | |
}); | |
// Save/retrieve best times | |
function bestKey(){ | |
// Use dimension based key to support custom; presets use label keys too | |
return `ms-best-${width}x${height}x${mineCount}`; | |
} | |
function maybeRecordBest(){ | |
const key = bestKey(); | |
const prev = parseInt(localStorage.getItem(key)||'0',10); | |
if(prev === 0 || elapsed < prev){ | |
localStorage.setItem(key, String(elapsed)); | |
} | |
if(PRESETS[presetName]){ | |
const pkey = `ms-best-${presetName}`; | |
const pprev = parseInt(localStorage.getItem(pkey)||'0',10); | |
if(pprev === 0 || elapsed < pprev){ | |
localStorage.setItem(pkey, String(elapsed)); | |
} | |
} | |
renderBestTimes(); | |
} | |
function renderBestTimes(){ | |
const parts = []; | |
['beginner', 'intermediate', 'expert'].forEach(k=>{ | |
const t = localStorage.getItem(`ms-best-${k}`); | |
parts.push(`${PRESETS[k].label}: ${t ? t + 's' : 'โ'}`); | |
}); | |
bestTimesEl.textContent = 'Best โ ' + parts.join(' ยท '); | |
} | |
// Accessibility helpers | |
boardEl.addEventListener('mouseover', (e)=>{ | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
const i = Number(cellEl.dataset.index); | |
const cell = cells[i]; | |
if(cell.revealed && cell.adj > 0){ | |
cellEl.style.outline = '1px solid rgba(255,255,255,0.06)'; | |
} | |
}); | |
boardEl.addEventListener('mouseout', (e)=>{ | |
const cellEl = e.target.closest('.cell'); | |
if(!cellEl) return; | |
cellEl.style.outline = ''; | |
}); | |
// Prevent page context menu on long press for mobile within board | |
boardEl.addEventListener('touchstart', ()=>{}, {passive:true}); | |
// Initialize from default preset | |
function saveInputsFromCurrent(){ | |
rowsInput.value = height; | |
colsInput.value = width; | |
minesInput.value = mineCount; | |
} | |
// Start | |
newGame(width, height, mineCount, presetName); | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment