Skip to content

Instantly share code, notes, and snippets.

@ericek111
Last active March 30, 2026 22:45
Show Gist options
  • Select an option

  • Save ericek111/67bf20571e2ff97670bfdf75d396489d to your computer and use it in GitHub Desktop.

Select an option

Save ericek111/67bf20571e2ff97670bfdf75d396489d to your computer and use it in GitHub Desktop.
A simple Minesweeper clone -- blatantly ripped off minesweeper.one and refactored by Claude.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Minesweeper</title>
<style>
:root {
--ms-bg: #C0C0C0;
--ms-link: #00C;
--ms-menu-font: Helvetica, sans-serif;
}
body {
font-family: Verdana, sans-serif;
text-align: center;
margin: 0;
padding: 0;
background: var(--ms-bg);
}
#container {
display: inline-block;
margin: 10px auto;
}
#game {
transform-origin: center top;
display: inline-block;
position: relative;
text-align: left;
}
#menu-bar {
display: flex;
margin: 5px 0;
padding-left: 5px;
.menu-slot { position: relative; }
.menu-title {
font: 12px var(--ms-menu-font);
padding-right: 5px;
cursor: default;
&:hover { color: var(--ms-link); text-decoration: underline; }
}
}
.menu-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 5;
background: var(--ms-bg);
border: 1px outset grey;
padding: 1px;
.menu-panel {
border: thin inset grey;
padding: 2px 4px;
}
hr {
margin: 2px 0;
border: none;
border-top: 1px solid grey;
}
}
.menu-item {
font: 12px var(--ms-menu-font);
white-space: nowrap;
cursor: default;
display: flex;
align-items: center;
gap: 4px;
padding: 1px 2px;
&:hover { color: var(--ms-link); text-decoration: underline; }
img { vertical-align: middle; }
.shortcut { margin-left: auto; padding-left: 16px; }
}
#board-wrap {
position: relative;
display: inline-block;
}
#board {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.overlay {
position: absolute;
left: 10px;
top: 46px;
width: var(--grid-w);
height: var(--grid-h);
z-index: 2;
text-align: center;
border: 1px solid;
box-sizing: border-box;
}
#pause-overlay {
color: #fff;
background: #000;
.pause-title { font-size: 18px; font-weight: bold; }
}
#disarm-overlay {
background: #DDAA77;
opacity: 0.9;
.countdown { font: bold 20px Courier, monospace; }
.small-btn { font-size: 80%; margin: 2px; }
}
.dialog {
text-align: left;
padding: 5px 10px;
font-size: 13px;
h2 { font-size: 16px; }
h3 { font-size: 14px; }
}
.pb-table {
td { padding: 4px; }
.num { text-align: right; }
}
#dlg-custom input[type="number"] { width: 60px; }
td { vertical-align: top; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<div id="container">
<div id="game">
<nav id="menu-bar"></nav>
<div id="board-wrap">
<canvas id="board"></canvas>
<div id="pause-overlay" class="overlay" hidden>
<p class="pause-title">PAUSED</p>
<p><button id="resume-btn">Resume</button></p>
</div>
<div id="disarm-overlay" class="overlay" hidden>
<p>Mine detonates in<br>
<span id="countdown" class="countdown">3.0</span></p>
<p>
<button class="small-btn" id="disarm-btn">Disarm the Mine</button><br>
<button class="small-btn" id="fate-btn">Accept Your Fate</button>
</p>
</div>
</div>
<div id="dlg-pb" class="dialog" hidden>
<h2>Your Personal Best Times</h2>
<table class="pb-table">
<tr><td><b>Beginner:</b></td><td class="num"><b><span id="pb-beginner">999</span></b></td></tr>
<tr><td><b>Intermediate:</b></td><td class="num"><b><span id="pb-intermediate">999</span></b></td></tr>
<tr><td><b>Expert:</b></td><td class="num"><b><span id="pb-expert">999</span></b></td></tr>
</table>
<p>
<button id="reset-scores-btn">Reset Scores</button>
<button class="close-dlg">Close</button>
</p>
</div>
<div id="dlg-custom" class="dialog" hidden>
<h2>Custom Settings</h2>
<table>
<tr><td>Game Height:</td><td><input type="number" min="8" max="24" id="input-h"></td></tr>
<tr><td>Game Width:</td><td><input type="number" min="8" max="32" id="input-w"></td></tr>
<tr><td>Number of Bombs:</td><td><input type="number" min="1" id="input-m"></td></tr>
</table>
<p>
<button id="custom-ok-btn">OK</button>
<button class="close-dlg">Cancel</button>
</p>
</div>
<div id="dlg-win" class="dialog" hidden>
<h2>YOU WIN!</h2>
<h3>Congratulations!</h3>
<p>Game Time: <span id="win-time"></span><br><br>
<span id="win-info"></span><br><br>
Number of clicks: <span id="win-moves"></span></p>
<h3 id="win-record">&nbsp;</h3>
<button class="close-dlg">Close</button>
</div>
<div id="dlg-about" class="dialog" hidden>
<h2>About Minesweeper</h2>
<p>A classic Minesweeper game.<br>Play offline straight from this HTML file.</p>
<p><b>Keyboard:</b> F2 = New Game, P = Pause, H = Hint</p>
<button class="close-dlg">Close</button>
</div>
<div id="dlg-inst" class="dialog" hidden>
<h2>Instructions and Rules</h2>
<ol>
<li>Click a cell to reveal it.</li>
<li>Right-click to place a flag (suspected mine).</li>
<li>Numbers show how many adjacent cells contain mines.</li>
<li>Use logic to find all mines.</li>
<li>Reveal all safe cells to win.</li>
</ol>
<p><b>Options:</b></p>
<ul>
<li><b>Safe Start:</b> First click opens a useful area.</li>
<li><b>Safe Neighborhood:</b> Click a satisfied number to auto-open neighbors.</li>
<li><b>Question Marks:</b> Right-click cycles: blank → flag → ? → blank.</li>
<li><b>Open Remaining:</b> When all mines flagged, click counter to open rest.</li>
<li><b>Disarm:</b> Chance to undo when hitting a mine.</li>
<li><b>Hints:</b> Press H while hovering to peek.</li>
</ul>
<button class="close-dlg">Close</button>
</div>
</div>
</div>
<script>
'use strict';
// ============================================================
// SPRITE DATA
// ============================================================
// Shared base64 prefixes — GIF sprites share common headers
const P1 = "data:image/gif;base64,R0lGODdh";
const P2 = "APEAAHt7e729vf///wAAACwAAAAA";
const P3 = `EAAQ${P2}EAAQAAAC`;
const P4 = "AAACwAAAAAEAAQAAAC";
const P5 = "DQAXAPEAAAAAAHsAAP8AAAAAACwAAAAADQAXAAAC";
const S = {
td: [
P1+P5+"OYSPKct8ApqDEcpqHNL6rfxtYQdSZGVSimkEgOtGCPzWT1vHaQKWa6ryoX5CD5CIuYxQDVlJEuQlCgA7",
P1+P5+"N4SPCbHN95g0ApoJRHVJ79ZpF3h4VrhhpTgqbMRlyKSCNHWG47N+fL9D5YAxog30Cj4+nJpCUQAAOw==",
P1+P5+"PISPKct8AZoTLzZrIEQuJ0sBmrJUZDhypfetkFSGIHxQMLWtyQbuotFB8IKenM7UE/4wkmVk5hjeZJ9PAQA7",
P1+P5+"OYSPKct8AZoTLzZrIEQuJ0sBmrJUZDhypfetkFSGIFzBlLyRXm50Yuv6qXBAlPDge12CtgjSJvt8CgA7",
P1+P5+"PISPCbHNJwybJgJh6bXYQlyBSBd65TmKZMqZX7V1oCfXB1PPt7JJDbtLrDQfzwPoEIp+GtGD8jBBfzxeAQA7",
P1+P5+"OYSPKct8ApoLZsUGaLWIapdoF9RlG1meoXklhghJFixzZh0dIsJ5ir1TmYIgFq9nFN4wSByqZnO5CgA7",
P1+P5+"OYSPKct8ApoLZsUGaLWIapdoF9RlG1meoXklhghJFixz0YiRNsKBT78bqXhA1NB3jOV0OAeqFnQhCgA7",
P1+P5+"OYSPKct8AZoTLzZrIEQuJ0sBmrJUZDhypfet27iFHoy84h2Z5HzqR5f6rXAqFNHQwaVcvFjtFvxICwA7",
P1+"DQAXAPAAAAAAAP8AACwAAAAADQAXAAACNISPGct8AZqDEcpqHNL6rfxtYQdSZGVSiok2kSeFb2yBiY2uqVr24q7zwWyXUWsCi92WhgIAOw==",
P1+P5+"OoSPKct8ApqDEcpqHNL6rfxtYQdSZGVSiok2kSeFQNBOB51g8y6qeG+g/Uq3nEa48tV0tJgFFstJDQUAOw=="
],
tn: P1+P5+"MoSPCbHN95g0rzkKba4VcY19ytdd2JgJ6ipo7Ju+rZiQtVae5pah4S2a4H4RzyWnQBQAADs=",
md: P1+"DQAXAPEAAAAAAACAgAD//wAAACwAAAAADQAXAAACOYSPKct8ApqDEcpqHNL6rfxtYQdSZGVSimkEgOtGCPzWT1vHaQKWa6ryoX5CD5CIuYxQDVlJEuQlCgA7",
co: [
P1+"EAAQAPAAAHt7e729vSwAAAAAEAAQAAACHYSPacHtD550cc5qYZZ4s+6Bm5iRlnl5lNqgHMsUADs=",
P1+"EAAQAPEAAAAA/3t7e729vQ"+P4+"KYyPacLtH550UQIgq7sYPt5RG5hZV/mdXho2WsOhDBi7Iy1O06vb/VMAADs=",
P1+"EAAQAPEAAAB7AHt7e729vQ"+P4+"LYyPacLtH550kYGLL7W5t4o5IVNNo1CKJ/p4G7d+MDCRc0fbAp7JtZT66YSNAgA7",
P1+"EAAQAPEAAHt7e729vf8AAA"+P4+"LISPacHtD550MYiLMWW5i1ZNGhNKY1Ba3eZ4rHiBExeTtFejtyvPUurTBR0FADs=",
P1+"EAAQAPEAAAAAe3t7e729vQ"+P4+"LoyPacLtH550sYErLqBOZ9xU3zZyjFeGp8aCQtW26oQy4lO/U2rv+b2bBU3DRgEAOw==",
P1+"EAAQAPEAAHsAAHt7e729vQ"+P4+"KYyPacLtH550UYCLc6u5g81gE2hdI8N5JqmK6OkK1RSnLXuTJ7VDvVMAADs=",
P1+"EAAQAPEAAAB7e3t7e729vQ"+P4+"LIyPacLtH550kYGLL7U5b4FNTRWKAtmVDJpqKwc4Kiu7J9yOTRvrJvQDBhsFADs=",
P1+"EAAQAPEAAAAAAHt7e729vQ"+P4+"KoyPacLtH550UYCLc6u5gz0xGBiOTCWZZ3lRk7qmrfvAsWMLaEjzvc8oAAA7",
P1+"EAAQAPAAAHt7e729vSwAAAAAEAAQAAACKYSPacHtD550kanV6j3ZYl8F2id+4UZlV1qGbfeqJ7hacsJOkqvDvVMAADs="
],
cs: [
P1+P3+"KpSPGckXYIKcVAIoqn541xt5E5iJVmdyoUmmasmi8Sq2Kchd+q7n/A8oAAA7",
P1+P3+"MpSPGckXYIKcVAIo6gP0xn6Bn3Vxk5eRpZieoTuqbLXCrWy75YvqOxnTADe74sqIvBQAADs=",
P1+P3+"NJSPGckXYIKcVAIo7N1XRw4CDzaK0zZGlSZeKsV52RnLbAeTdeVmYdwr/VKzVc73CwmTmwIAOw==",
P1+P3+"MpSPGckXYIKcVAIo3t3bYs1xWlR5wJiVIEqFJzu5Hll2F13ZnyveGX+ZpVQxCAioQ3IKADs=",
P1+P3+"NJSPGckXYIKcVAIo5gXvWqx52xd1nElm6HpF2zu2GQx3ZTXaKu7p1XeS/VigIaVlotWUzAIAOw==",
P1+P3+"MZSPGckXYIKcVAIo3t1cRw4CXrZV0/VdpoVpoTi6b7yWaFartxzGr0pbnSC8n8gYKgAAOw==",
P1+P3+"MpSPGckXYIKcVAIo7N1XRw562VZN19eVD7aC6Tm6JNxSM0uaHS3fURta/VS6WI8DPF4KADs=",
P1+P3+"MZSPGckXYIKcVAIo3t1cRw4CXlZZ11ia4hWVm9lWLyqfMEnN9KTvao5J1TKh4soYKgAAOw==",
P1+P3+"NJSPGckXYIKcVAIo7N1XRw4CDzaK0zZ+3dldUQl6GczJJYW6GcqKOh37AVfCSsU1DPmUygIAOw=="
],
bf: P1+"EAAPAPIAAAAAAHt7e729vf8AAP///wAAAAAAAAAAACwAAAAAEAAPAAADOUi63NJwiaCEvdgGSvIY2VZZ3xdyZAliYpeaLIrBV5sJwDliebzjgKDPFSwWNTKjEfm7MSebqFSaAAA7",
bd: "data:image/gif;base64,R0lGODlhEAAQAPIEAAAAAHsAAHt7e729vf///wAAAAAAAAAAACH5BAUKAAQALAAAAAAQABAAAAIrlI9pw+0vnnTxhTAruzc33knaAHoWJk6A+aypA8Qu1cj2PGi3TE+s3wM6CgA7",
br: P1+"EAAQAPEAAAAAAHt7e729vf///ywAAAAAEAAQAAACNIyPacLtHx4DUkRJ5RUUeM81G/d9zuYNQ5lZTbqWIsnW7lSDDJq3Y3560UC+SiuIqYwqjgIAOw==",
bm: P1+"EAAQAPIAAAAAAHt7e729vf8AAP///wAAAAAAAAAAACwAAAAAEAAQAAADRBi63CowyhkmBFbUMSSOnAaFQFkKoTgOZpmqAsARLhtVl8xyJ1S1OlYLZuIIP0SdEolbFTs30LEWRUFjUp/VIwk1M5MEADs=",
bx: P1+"EAAQAPEAAAAAAHt7e/8AAP///ywAAAAAEAAQAAACNIyPacLtHx4DUkRJ5RUUeM81G/d9zuYNQ5lZTbqWIsnW7lSDDJq3Y3560UC+SiuIqYwqjgIAOw==",
bq: P1+"EAAQAPEAAAAAAHt7e729vf///ywAAAAAEAAQAAACOZyPKcknYYScVAY4KNiA3ihtYWdhkyigj1ml5ApqL5zJ1VefM922eKUqxUY3Vm/yWV2WzKWyCQ0UAAA7",
bs: P1+P3+"NpSPGckXYIKcYEoAhb0bx/dgGOhplyieX4lWpSq0odHW4mq72WnPZijzcWS6j6VyhABzKWatAAA7",
bl: P1+P3+"KpSPGckXYIKcVAIoqn541xt5E5iJVmdyoUmmasmi8Sq2Kchd+q7n/A8oAAA7",
fd: P1+"GgAaAPIAAAAAAHt7e729vf//AP///wAAAAAAAAAAACwAAAAAGgAaAAADgBi63M4ikEmrvTNKzK/eQiiOJMlkZVqehAq8b8qW72Db8TiPwO3jugUq1Pv9AKKdoNgrDprAD4h4c+KqoZ0Tet1KUVbj8dtais9I7VmcFpbDsJqvrRhuj1iyiMmOKs1rT0luJGFzQXVlPHBIiFM0cTKEKpRZQlIPmQ2Ymp2cnZoJADs=",
fs: P1+"GgAaAPIAAAAAAHt7e729vf//AP///wAAAAAAAAAAACwAAAAAGgAaAAADfxi63M4ikEmrvTNKzK/eQiiOJMlkZVqehAq8b8qW72Db8TiPwO3jugUq1Pv9AKKdoGg8hpTMGi76FLaWPulAu/2AsM0mcscMZ72oshlIvqlxbvQVvD3G5aIyjIvE59ddVQpDRGZ9gl88ajlJVi57Mo4qk4gtD5eYXpibAZqcmAkAOw==",
fw: P1+"GgAaAPIAAAAAAHt7AHt7e729vf//AP///wAAAAAAACwAAAAAGgAaAAADhCi63M5DlEmrvTNKzK/eQyiOJMlkZVqehQq8b8qWL2Hb8TiPwO3jugUq1Pv9AKLdoGg8hpRFmLRGQH5AS5y0uq1eUb0wjFsLf1vLYoDKDWjPxKYcuWPKfXQhOos73qxQeF1AcCJ2TVaFhneJioaHOUl6LlIykyqYT0JXD50NnJ6hoKGeCQA7",
ft: P1+"GgAaAPIAAAAAAHt7e729vf//AP///wAAAAAAAAAAACwAAAAAGgAaAAADgxi63M4ikEmrvTNKzK/eQiiOJMlkZVqehNqk7BoMNK2YCyrONVAPgVFs9xv4fsHQMFI0NpNLntFHPQKVuRazR50iP6Bt0fodSrmAdNoGRp17zas5Tg9G6UVolkhT+2t6CjpidTt7QnhJWIJaQm83jowuEDKSKpeLEmAPnC+dn56gogEJADs=",
fo: P1+"GgAaAPIAAAAAAICAAMDAwP//AICAgP///wAAAAAAACwAAAAAGgAaAAADi0i63M6ClEmrvTNKzK/eQiiOJMlkZVqehQq8b8qW72Db8TiPwO3jugUq1Pv9AKKdoBgABG7N5wD5ARFxNWyRuisOolDnDdk1moHls5EsbC3XWR9bgfLamlPpuDq0e/0hSnZ4Ynt8bldjcVNJbSR2R0F0iCKLU1SNky4wmJJWKqB8VQ+kDaOlqKeopQkAOw==",
fp: P1+"GgAaAPIAAAAAAHt7e729vf//AP///4CAAAAAAAAAACwAAAAAGgAaAAADhxi63M4ikEmrvTNKzK/eQiiOJMlkZVqehAq8b8qW7zAAdjzOI27fv9tugQr5coWgMMQT+AqvpHLZ9MGsRwCT2HL+rrmjkGeFAqRTLTk6TX9A3nZbzTXK5+9iVpndKvRBV2JaeV12d0uFJGJ8Q3+GIjWBhCJNi1cydSqbfhJvD6ANn6Gko6ShCQA7",
ck: P1+"CgAKAPAAAAAAAL29vSwAAAAACgAKAAACEYyPqcsBDJ6RLh4bac2KL20UADs=",
nc: P1+"CgAKAPAAAL29vQAAACwAAAAACgAKAAACCISPqcvtD2MrADs=",
btl: P1+"CgAK"+P2+"CgAKAAACFJSPqRLtbd6LErIaaNWSNwB4AVgAADs=",
btb: P1+"BAAK"+P2+"BAAKAAACCZRvocvtC6KMBQA7",
btr: P1+"CgAK"+P2+"CgAKAAACFZR/gRgQDx9wEc5qKb646Tp9mbIBBQA7",
blr: P1+"CgAE"+P2+"CgAEAAACC5SCCWCpfFxYTa4CADs=",
bjl: P1+"CgAK"+P2+"CgAKAAACFJSCCWHpdlxqEtF6ZX4RdwVsAQAUADs=",
bjr: P1+"CgAK"+P2+"CgAKAAACFJSCCWCp4NyKj6JpMdVxwQ0dHVAAADs=",
bbl: P1+"CgAK"+P2+"CgAKAAACFZSCCWHpdlxqEtF6ZX4RBwCG4CeKBQA7",
bbr: P1+"CgAK"+P2+"CgAKAAACE5SCCWCp4NyKj6JpMdVx+Q+GSwEAOw=="
};
// ============================================================
// IMAGE PRELOADING
// ============================================================
const allImages = [];
function loadImage(src) {
const img = new Image();
img.src = src;
allImages.push(img);
return img;
}
const IMG = {
digit: S.td.map(loadImage),
negative: loadImage(S.tn),
dash: loadImage(S.md),
cell: S.co.map(loadImage),
hint: S.cs.map(loadImage),
flag: loadImage(S.bf),
disarmed: loadImage(S.bd),
mine: loadImage(S.br),
misflag: loadImage(S.bm),
exploded: loadImage(S.bx),
question: loadImage(S.bq),
hintMine: loadImage(S.bs),
blank: loadImage(S.bl),
face: {
dead: loadImage(S.fd),
smile: loadImage(S.fs),
win: loadImage(S.fw),
paused: loadImage(S.ft),
surprised: loadImage(S.fo),
partial: loadImage(S.fp),
},
check: loadImage(S.ck),
nocheck: loadImage(S.nc),
border: {
topLeft: loadImage(S.btl),
top: loadImage(S.btb),
topRight: loadImage(S.btr),
side: loadImage(S.blr),
jointLeft: loadImage(S.bjl),
jointRight: loadImage(S.bjr),
bottomLeft: loadImage(S.bbl),
bottomRight: loadImage(S.bbr),
},
};
// ============================================================
// SETTINGS
// ============================================================
const STORAGE_PREFIX = 'ms_';
const loadSetting = (key, fallback) =>
localStorage.getItem(STORAGE_PREFIX + key) ?? fallback;
const saveSetting = (key, value) =>
localStorage.setItem(STORAGE_PREFIX + key, value);
const PRESETS = Object.freeze({
Beginner: { w: 8, h: 8, m: 10 },
Intermediate: { w: 16, h: 16, m: 40 },
Expert: { w: 31, h: 16, m: 99 },
});
const options = {
questionMarks: loadSetting('questionMarks', 'true') !== 'false',
macroOpen: loadSetting('macroOpen', 'true') !== 'false',
safeStart: loadSetting('safeStart', 'true') !== 'false',
openRemaining: loadSetting('openRemaining', 'false') === 'true',
disarm: loadSetting('disarm', 'false') === 'true',
hints: loadSetting('hints', 'true') !== 'false',
nightMode: loadSetting('nightMode', 'false') === 'true',
zoom: (() => {
const z = Number(loadSetting('zoom', '4'));
return (z >= -2 && z <= 12) ? z : 4;
})(),
};
function saveOptions() {
for (const key of ['questionMarks', 'macroOpen', 'safeStart', 'openRemaining', 'disarm', 'hints', 'nightMode', 'zoom']) {
saveSetting(key, options[key]);
}
}
let difficulty = loadSetting('diff', 'Beginner');
let W, H, M;
function loadBoardSize() {
if (difficulty === 'Custom') {
W = parseInt(loadSetting('cW', '8'), 10);
H = parseInt(loadSetting('cH', '8'), 10);
M = parseInt(loadSetting('cM', '10'), 10);
const maxMines = Math.round(W * H / 3);
if (isNaN(W) || W < 8 || W > 32 || isNaN(H) || H < 8 || H > 24 || isNaN(M) || M < 1 || M > maxMines) {
difficulty = 'Beginner';
({ w: W, h: H, m: M } = PRESETS.Beginner);
}
} else {
if (!PRESETS[difficulty]) difficulty = 'Beginner';
({ w: W, h: H, m: M } = PRESETS[difficulty]);
}
}
loadBoardSize();
// ============================================================
// GAME STATE
// ============================================================
let cells;
let dead, won, paused;
let cellsOpen, flagCount, moves;
let clockRunning, clockValue, clockTimer;
let fuseLit, fuseRemaining, fuseTimer, fuseX, fuseY;
let disarmCount, hintCount, openRemainingUsed, firstClickDone;
let hoverX = -1, hoverY = -1;
let cellVis;
let faceVis;
let bombDigits;
let timerDigits;
const createCell = () => ({
mine: false, open: false, flag: false,
question: false, disarmed: false, neighbors: 0,
});
const cell = (x, y) => cells[y * W + x];
const inBounds = (x, y) => x >= 0 && x < W && y >= 0 && y < H;
function eachNeighbor(x, y, fn) {
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx, ny = y + dy;
if (inBounds(nx, ny)) fn(nx, ny);
}
}
}
// ============================================================
// LAYOUT & DOM
// ============================================================
const GRID_TOP = 46;
let canvasW, canvasH, faceMargin, faceX, timerX;
function computeLayout() {
canvasW = 20 + 16 * W;
canvasH = 56 + 16 * H;
faceMargin = (W * 16 - 104) / 2;
faceX = 49 + faceMargin;
timerX = faceX + 26 + faceMargin;
}
const gameEl = document.getElementById('game');
const containerEl = document.getElementById('container');
const boardWrap = document.getElementById('board-wrap');
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const pauseOverlay = document.getElementById('pause-overlay');
const disarmOverlay = document.getElementById('disarm-overlay');
const countdownEl = document.getElementById('countdown');
const zoomScale = () => 2 ** (options.zoom / 4);
function applyZoom() {
const scale = zoomScale();
gameEl.style.transform = `scale(${scale})`;
containerEl.style.width = `${canvasW * scale}px`;
containerEl.style.minHeight = `${canvasH * scale + 40}px`;
}
function resizeBoard() {
computeLayout();
canvas.width = canvasW;
canvas.height = canvasH;
boardWrap.style.setProperty('--grid-w', `${16 * W}px`);
boardWrap.style.setProperty('--grid-h', `${16 * H}px`);
applyZoom();
}
// ============================================================
// RENDERING
// ============================================================
function render() {
ctx.imageSmoothingEnabled = false;
const { topLeft, top, topRight, side, jointLeft, jointRight, bottomLeft, bottomRight } = IMG.border;
ctx.drawImage(topLeft, 0, 0, 10, 10);
for (let j = 0; j < W; j++) ctx.drawImage(top, 10 + j * 16, 0, 16, 10);
ctx.drawImage(topRight, 10 + W * 16, 0, 10, 10);
ctx.drawImage(side, 0, 10, 10, 26);
ctx.drawImage(bombDigits[0], 10, 10, 13, 23);
ctx.drawImage(bombDigits[1], 23, 10, 13, 23);
ctx.drawImage(bombDigits[2], 36, 10, 13, 23);
ctx.drawImage(faceVis, faceX, 10, 26, 26);
ctx.drawImage(timerDigits[0], timerX, 10, 13, 23);
ctx.drawImage(timerDigits[1], timerX + 13, 10, 13, 23);
ctx.drawImage(timerDigits[2], timerX + 26, 10, 13, 23);
ctx.drawImage(side, 10 + W * 16, 10, 10, 26);
ctx.drawImage(jointLeft, 0, 36, 10, 10);
for (let j = 0; j < W; j++) ctx.drawImage(top, 10 + j * 16, 36, 16, 10);
ctx.drawImage(jointRight, 10 + W * 16, 36, 10, 10);
for (let y = 0; y < H; y++) {
const py = GRID_TOP + y * 16;
ctx.drawImage(side, 0, py, 10, 16);
for (let x = 0; x < W; x++)
ctx.drawImage(cellVis[y * W + x], 10 + x * 16, py, 16, 16);
ctx.drawImage(side, 10 + W * 16, py, 10, 16);
}
const bottomY = GRID_TOP + H * 16;
ctx.drawImage(bottomLeft, 0, bottomY, 10, 10);
for (let j = 0; j < W; j++) ctx.drawImage(top, 10 + j * 16, bottomY, 16, 10);
ctx.drawImage(bottomRight, 10 + W * 16, bottomY, 10, 10);
if (options.nightMode) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvasW, canvasH);
}
}
// ============================================================
// MENUS — definitions with direct callbacks
// ============================================================
const MENUS = [
{
title: 'Game',
items: [
{ label: 'New Game', shortcut: 'F2', action() { handleFaceClick(); render(); } },
{ label: 'Pause', shortcut: 'P', action() { togglePause(); render(); } },
null,
{ label: 'Beginner', get checked() { return difficulty === 'Beginner'; }, action() { setDifficulty('Beginner'); } },
{ label: 'Intermediate', get checked() { return difficulty === 'Intermediate'; }, action() { setDifficulty('Intermediate'); } },
{ label: 'Expert', get checked() { return difficulty === 'Expert'; }, action() { setDifficulty('Expert'); } },
{ label: 'Custom', get checked() { return difficulty === 'Custom'; }, action() { openCustomDialog(); } },
null,
{ label: 'Personal Best', action() { openPersonalBest(); } },
],
},
{
title: 'Options',
items: [
{ label: 'Zoom In', action() { adjustZoom(1); } },
{ label: 'Zoom Out', action() { adjustZoom(-1); } },
{ label: 'Night Mode', get checked() { return options.nightMode; }, action() { toggleOption('nightMode'); applyNightMode(); render(); } },
null,
{ label: 'Safe Start', get checked() { return options.safeStart; }, action() { toggleOption('safeStart'); } },
{ label: 'Question Marks (?)', get checked() { return options.questionMarks; }, action() { toggleOption('questionMarks'); } },
{ label: 'Safe Neighborhood', get checked() { return options.macroOpen; }, action() { toggleOption('macroOpen'); } },
{ label: 'Open Remaining', get checked() { return options.openRemaining; }, action() { toggleOption('openRemaining'); updateBombDisplay(); render(); } },
{ label: 'Disarm (undo)', get checked() { return options.disarm; }, action() { toggleOption('disarm'); } },
{ label: 'Hints (press H)', get checked() { return options.hints; }, action() { toggleOption('hints'); } },
],
},
{
title: 'Help',
items: [
{ label: 'About Minesweeper', action() { showDialog('dlg-about'); } },
null,
{ label: 'Instructions and rules', action() { showDialog('dlg-inst'); } },
],
},
];
let activeDropdown = null;
const checkableItems = []; // { icon, item } for updating check marks
function buildMenus() {
const nav = document.getElementById('menu-bar');
for (const menuDef of MENUS) {
const slot = document.createElement('div');
slot.className = 'menu-slot';
const title = document.createElement('span');
title.className = 'menu-title';
title.textContent = menuDef.title;
slot.appendChild(title);
const dropdown = document.createElement('div');
dropdown.className = 'menu-dropdown';
dropdown.hidden = true;
const panel = document.createElement('div');
panel.className = 'menu-panel';
for (const item of menuDef.items) {
if (item === null) {
panel.appendChild(document.createElement('hr'));
continue;
}
const el = document.createElement('span');
el.className = 'menu-item';
const icon = document.createElement('img');
icon.src = IMG.nocheck.src;
icon.width = 10;
icon.height = 10;
el.appendChild(icon);
const labelSpan = document.createElement('span');
labelSpan.textContent = item.label;
el.appendChild(labelSpan);
if (item.shortcut) {
const shortcutSpan = document.createElement('span');
shortcutSpan.className = 'shortcut';
shortcutSpan.textContent = item.shortcut;
el.appendChild(shortcutSpan);
}
el.addEventListener('click', (e) => {
item.action();
closeMenus();
e.stopPropagation();
});
if ('checked' in item) {
checkableItems.push({ icon, item });
}
panel.appendChild(el);
}
dropdown.appendChild(panel);
slot.appendChild(dropdown);
nav.appendChild(slot);
title.addEventListener('click', (e) => {
if (activeDropdown === dropdown) {
closeMenus();
} else {
closeMenus();
updateMenuChecks();
dropdown.hidden = false;
activeDropdown = dropdown;
}
e.stopPropagation();
});
title.addEventListener('mouseenter', () => {
if (activeDropdown && activeDropdown !== dropdown) {
closeMenus();
updateMenuChecks();
dropdown.hidden = false;
activeDropdown = dropdown;
}
});
}
}
function updateMenuChecks() {
for (const { icon, item } of checkableItems) {
icon.src = item.checked ? IMG.check.src : IMG.nocheck.src;
}
}
function closeMenus() {
for (const d of gameEl.querySelectorAll('.menu-dropdown')) d.hidden = true;
activeDropdown = null;
}
// Menu action helpers
function setDifficulty(name) {
difficulty = name;
saveSetting('diff', name);
loadBoardSize();
resizeBoard();
newGame();
render();
}
function toggleOption(key) {
options[key] = !options[key];
saveOptions();
}
function applyNightMode() {
const on = options.nightMode;
document.documentElement.style.setProperty('--ms-bg', on ? '#1a1a1a' : '#C0C0C0');
document.documentElement.style.setProperty('--ms-link', on ? '#6af' : '#00C');
document.body.style.color = on ? '#ddd' : '';
}
function adjustZoom(delta) {
const next = options.zoom + delta;
if (next >= -2 && next <= 12) {
options.zoom = next;
applyZoom();
saveOptions();
}
}
function openCustomDialog() {
showDialog('dlg-custom');
document.getElementById('input-h').value = H;
document.getElementById('input-w').value = W;
document.getElementById('input-m').value = M;
}
function openPersonalBest() {
showDialog('dlg-pb');
refreshPersonalBest();
}
// ============================================================
// DIALOGS
// ============================================================
function closeAllDialogs() {
for (const d of gameEl.querySelectorAll('.dialog')) d.hidden = true;
}
function showDialog(id) {
const dlg = document.getElementById(id);
const wasOpen = !dlg.hidden;
closeAllDialogs();
if (!wasOpen) dlg.hidden = false;
}
// ============================================================
// CANVAS HIT TESTING
// ============================================================
function canvasXY(e) {
const rect = canvas.getBoundingClientRect();
return [
(e.clientX - rect.left) * (canvas.width / rect.width),
(e.clientY - rect.top) * (canvas.height / rect.height),
];
}
function cellFromPixel(px, py) {
if (px >= 10 && px < 10 + W * 16 && py >= GRID_TOP && py < GRID_TOP + H * 16) {
return { x: Math.floor((px - 10) / 16), y: Math.floor((py - GRID_TOP) / 16) };
}
return null;
}
const isFaceHit = (px, py) =>
px >= faceX && px < faceX + 26 && py >= 10 && py < 36;
const isBombCounterHit = (px, py) =>
px >= 10 && px < 49 && py >= 10 && py < 33;
// ============================================================
// EVENTS
// ============================================================
function setupEvents() {
canvas.addEventListener('mousedown', (e) => {
const [px, py] = canvasXY(e);
if (cellFromPixel(px, py) && !dead && !won && !paused) {
faceVis = IMG.face.surprised;
render();
}
});
canvas.addEventListener('mouseup', (e) => {
const [px, py] = canvasXY(e);
const hit = cellFromPixel(px, py);
if (hit) { handleCellClick(hit.x, hit.y, e.button); render(); return; }
if (isFaceHit(px, py)) { handleFaceClick(); render(); return; }
if (isBombCounterHit(px, py)) { handleBombCounterClick(); render(); }
});
canvas.addEventListener('mousemove', (e) => {
const [px, py] = canvasXY(e);
const hit = cellFromPixel(px, py);
if (hit) { hoverX = hit.x; hoverY = hit.y; }
else { hoverX = -1; hoverY = -1; }
});
canvas.addEventListener('mouseout', () => { hoverX = -1; hoverY = -1; });
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Close menus on outside click, close dialogs on close-button click
document.addEventListener('click', (e) => {
if (!e.target.closest('#menu-bar')) closeMenus();
if (e.target.closest('.close-dlg')) closeAllDialogs();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'F2') { handleFaceClick(); render(); e.preventDefault(); }
else if (e.key.toLowerCase() === 'p') { togglePause(); render(); }
else if (e.key.toLowerCase() === 'h') { revealHint(); render(); }
});
document.getElementById('resume-btn').onclick = unpause;
document.getElementById('disarm-btn').onclick = disarmMine;
document.getElementById('fate-btn').onclick = acceptFate;
document.getElementById('reset-scores-btn').onclick = resetScores;
document.getElementById('custom-ok-btn').onclick = applyCustomSettings;
}
// ============================================================
// GAME LOGIC
// ============================================================
function newGame() {
faceVis = IMG.face.smile;
hideDisarm();
hidePause();
closeAllDialogs();
dead = won = paused = false;
cellsOpen = flagCount = moves = 0;
disarmCount = hintCount = 0;
openRemainingUsed = false;
firstClickDone = false;
hoverX = hoverY = -1;
stopClock();
clockValue = -1;
updateClockDisplay();
cells = Array.from({ length: W * H }, createCell);
let placed = 0;
while (placed < M) {
const x = Math.floor(Math.random() * W);
const y = Math.floor(Math.random() * H);
if (!cell(x, y).mine) {
cell(x, y).mine = true;
eachNeighbor(x, y, (nx, ny) => cell(nx, ny).neighbors++);
placed++;
}
}
cellVis = new Array(W * H).fill(IMG.blank);
updateBombDisplay();
}
// Relocate mines away from first click for a safe opening
function firstClick(x, y) {
if (!options.safeStart) {
if (cell(x, y).mine) { removeMine(x, y); placeRandomMine(); }
} else {
// Protect 3x3 area — temporarily mark as open so placeRandomMine avoids them
const zone = [];
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = x + dx, ny = y + dy;
if (inBounds(nx, ny)) { cell(nx, ny).open = true; zone.push({ x: nx, y: ny }); }
}
}
for (const p of zone) if (cell(p.x, p.y).mine) { removeMine(p.x, p.y); placeRandomMine(); }
for (const p of zone) cell(p.x, p.y).open = false;
}
startClock();
firstClickDone = true;
}
function removeMine(x, y) {
cell(x, y).mine = false;
eachNeighbor(x, y, (nx, ny) => cell(nx, ny).neighbors--);
}
function placeRandomMine() {
for (;;) {
const x = Math.floor(Math.random() * W);
const y = Math.floor(Math.random() * H);
const c = cell(x, y);
if (!c.mine && !c.open) {
c.mine = true;
eachNeighbor(x, y, (nx, ny) => cell(nx, ny).neighbors++);
return;
}
}
}
function handleCellClick(x, y, button) {
closeMenus();
if (dead || won || paused) return;
faceVis = IMG.face.smile;
moves++;
if (button !== 2) {
if (!firstClickDone) firstClick(x, y);
const c = cell(x, y);
if (c.open) {
// Chord click: auto-reveal unflagged neighbors when flag count matches
if (options.macroOpen && countFlags(x, y) === c.neighbors) {
const queue = [];
eachNeighbor(x, y, (nx, ny) => {
if (!cell(nx, ny).open && !cell(nx, ny).flag) queue.push({ x: nx, y: ny });
});
revealCells(queue);
}
} else if (!c.flag) {
revealCells([{ x, y }]);
}
if (won) { flagCount = M; updateBombDisplay(); }
} else {
// Right click: cycle flag → question → blank
const c = cell(x, y);
if (c.open) return;
if (c.flag) {
c.flag = false;
flagCount--;
if (options.questionMarks) { c.question = true; cellVis[y * W + x] = IMG.question; }
else cellVis[y * W + x] = IMG.blank;
} else if (c.question) {
c.question = false;
cellVis[y * W + x] = IMG.blank;
} else {
c.flag = true;
flagCount++;
cellVis[y * W + x] = IMG.flag;
}
updateBombDisplay();
}
}
// Iterative flood fill: reveal cells, expanding through empty (0-neighbor) cells
function revealCells(queue) {
const visited = new Set();
while (queue.length > 0) {
if (dead || fuseLit || won) break;
const { x, y } = queue.pop();
const key = y * W + x;
if (visited.has(key)) continue;
visited.add(key);
const c = cell(x, y);
if (c.open || c.flag) continue;
if (c.mine) {
stopClock();
options.disarm ? showDisarm(x, y) : triggerDeath(x, y);
return;
}
c.open = true;
cellsOpen++;
cellVis[y * W + x] = IMG.cell[c.neighbors];
if (cellsOpen === W * H - M) {
stopClock();
won = true;
flagAllMines();
showWin();
return;
}
if (c.neighbors === 0) {
eachNeighbor(x, y, (nx, ny) => {
if (!cell(nx, ny).open && !cell(nx, ny).flag) queue.push({ x: nx, y: ny });
});
}
}
}
function countFlags(x, y) {
let n = 0;
eachNeighbor(x, y, (nx, ny) => { if (cell(nx, ny).flag) n++; });
return n;
}
function flagAllMines() {
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const c = cell(x, y);
if (!c.open && !c.flag) { c.flag = true; cellVis[y * W + x] = IMG.flag; }
}
}
function triggerDeath(x, y) {
cell(x, y).open = true;
cellVis[y * W + x] = IMG.exploded;
dead = true;
faceVis = IMG.face.dead;
updateBombDisplay();
for (let j = 0; j < H; j++)
for (let i = 0; i < W; i++) {
const c = cell(i, j);
if (c.open) continue;
if (c.mine && !c.flag) cellVis[j * W + i] = IMG.mine;
else if (!c.mine && c.flag) cellVis[j * W + i] = IMG.misflag;
}
}
function showWin() {
if (hintCount > 0 || disarmCount > 0) {
faceVis = IMG.face.partial;
return;
}
faceVis = IMG.face.win;
const gameTime = Math.max(0, Math.min(clockValue, 999));
document.getElementById('win-time').textContent = gameTime;
document.getElementById('win-moves').textContent = moves;
const bestKey = `best_${difficulty}`;
let best = parseInt(loadSetting(bestKey, '999'), 10);
if (isNaN(best)) best = 999;
const isNewRecord = gameTime < best;
if (isNewRecord) { best = gameTime; saveSetting(bestKey, best); }
document.getElementById('win-info').textContent = difficulty === 'Custom'
? `Your game parameters were: ${W}\u00d7${H} with ${M} bombs.`
: `Your best ${difficulty} time is: ${best}`;
document.getElementById('win-record').textContent =
isNewRecord && difficulty !== 'Custom' ? 'New Record!' : '';
showDialog('dlg-win');
}
// ============================================================
// CLOCK & DISPLAYS
// ============================================================
function startClock() {
clockRunning = true;
if (clockValue < 0) clockValue = 0;
if (clockTimer) clearInterval(clockTimer);
clockTimer = setInterval(() => {
if (clockRunning) {
clockValue++;
if (clockValue <= 999) { updateClockDisplay(); render(); }
}
}, 1000);
updateClockDisplay();
}
function stopClock() {
clockRunning = false;
if (clockTimer) { clearInterval(clockTimer); clockTimer = null; }
}
function updateClockDisplay() {
const t = Math.max(0, Math.min(clockValue, 999));
timerDigits = [
IMG.digit[Math.floor(t / 100)],
IMG.digit[Math.floor(t / 10) % 10],
IMG.digit[t % 10],
];
}
function updateBombDisplay() {
let remaining = M - flagCount;
if (!dead && !won && options.openRemaining && remaining === 0) {
bombDigits = [IMG.dash, IMG.dash, IMG.dash];
return;
}
remaining = Math.max(-99, remaining);
const abs = Math.abs(remaining);
bombDigits = [
remaining < 0 ? IMG.negative : IMG.digit[Math.floor(abs / 100) % 10],
IMG.digit[Math.floor(abs / 10) % 10],
IMG.digit[abs % 10],
];
}
// ============================================================
// FACE / PAUSE / DISARM / HINTS
// ============================================================
function handleFaceClick() {
if (fuseLit) return;
newGame();
document.getElementById('dlg-win').hidden = true;
}
function togglePause() {
if (!dead && !won && clockRunning && !paused) {
paused = true;
stopClock();
pauseOverlay.hidden = false;
faceVis = IMG.face.paused;
} else if (paused) {
unpause();
}
}
function unpause() {
if (dead || won || !paused) return;
faceVis = IMG.face.smile;
hidePause();
startClock();
}
function hidePause() {
pauseOverlay.hidden = true;
paused = false;
}
function showDisarm(x, y) {
fuseX = x;
fuseY = y;
fuseRemaining = 30;
fuseLit = true;
disarmOverlay.hidden = false;
faceVis = IMG.face.surprised;
tickFuse();
}
function tickFuse() {
if (!fuseLit) return;
if (fuseRemaining <= 0) {
if (!clockRunning) acceptFate();
} else {
countdownEl.textContent = `${Math.floor(fuseRemaining / 10)}.${fuseRemaining % 10}`;
fuseTimer = setTimeout(tickFuse, 100);
}
fuseRemaining--;
}
function disarmMine() {
hideDisarm();
fuseLit = false;
disarmCount++;
flagCount++;
updateBombDisplay();
const c = cell(fuseX, fuseY);
c.flag = true;
c.disarmed = true;
c.open = true;
cellVis[fuseY * W + fuseX] = IMG.disarmed;
startClock();
render();
}
function acceptFate() {
hideDisarm();
fuseLit = false;
triggerDeath(fuseX, fuseY);
render();
}
function hideDisarm() {
disarmOverlay.hidden = true;
if (fuseTimer) { clearTimeout(fuseTimer); fuseTimer = null; }
fuseLit = false;
if (!dead && !won) faceVis = IMG.face.smile;
}
function revealHint() {
if (!options.hints || dead || won || !clockRunning || hoverX < 0) return;
const c = cell(hoverX, hoverY);
if (c.open || c.flag || c.question) return;
if (c.mine) {
cellVis[hoverY * W + hoverX] = IMG.hintMine;
} else if (c.neighbors === 0) {
handleCellClick(hoverX, hoverY, 0);
return;
} else {
cellVis[hoverY * W + hoverX] = IMG.hint[c.neighbors];
}
hintCount++;
}
// ============================================================
// BOMB COUNTER CLICK / PERSONAL BEST / CUSTOM
// ============================================================
function handleBombCounterClick() {
closeMenus();
if (dead || won || !options.openRemaining || M !== flagCount) return;
stopClock();
moves++;
openRemainingUsed = true;
let allCorrect = true;
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const c = cell(x, y);
if (c.open) continue;
if (c.mine && !c.flag) { cellVis[y * W + x] = IMG.exploded; allCorrect = false; }
else if (!c.mine && c.flag) cellVis[y * W + x] = IMG.misflag;
else if (!c.mine) { cellVis[y * W + x] = IMG.cell[c.neighbors]; c.open = true; cellsOpen++; }
}
if (allCorrect) { won = true; showWin(); updateBombDisplay(); }
else { dead = true; updateBombDisplay(); faceVis = IMG.face.dead; }
}
function refreshPersonalBest() {
for (const [diff, id] of [['Beginner', 'pb-beginner'], ['Intermediate', 'pb-intermediate'], ['Expert', 'pb-expert']]) {
let best = parseInt(loadSetting(`best_${diff}`, '999'), 10);
if (isNaN(best)) best = 999;
document.getElementById(id).textContent = best;
}
}
function resetScores() {
for (const d of ['Beginner', 'Intermediate', 'Expert']) saveSetting(`best_${d}`, '999');
refreshPersonalBest();
}
function applyCustomSettings() {
const w = parseInt(document.getElementById('input-w').value, 10);
const h = parseInt(document.getElementById('input-h').value, 10);
const m = parseInt(document.getElementById('input-m').value, 10);
const maxMines = Math.round(w * h / 3);
if (isNaN(w) || w < 8 || w > 32 || isNaN(h) || h < 8 || h > 24 || isNaN(m) || m < 1 || m > maxMines) {
alert('Invalid dimensions:\n Width: 8-32\n Height: 8-24\n Bombs: 1 to 1/3 of squares');
return;
}
difficulty = 'Custom';
W = w; H = h; M = m;
saveSetting('diff', 'Custom');
saveSetting('cW', w);
saveSetting('cH', h);
saveSetting('cM', m);
resizeBoard();
newGame();
render();
}
// ============================================================
// INIT
// ============================================================
function init() {
buildMenus();
setupEvents();
applyNightMode();
resizeBoard();
newGame();
render();
}
const pending = allImages.filter(img => !img.complete);
if (pending.length === 0) {
init();
} else {
let remaining = pending.length;
const onReady = () => { if (--remaining === 0) init(); };
for (const img of pending) {
img.onload = onReady;
img.onerror = onReady;
}
}
</script>
</body>
</html>
@ericek111
Copy link
Copy Markdown
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment