Last active
March 30, 2026 22:45
-
-
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.
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"> | |
| <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"> </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> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Play here: https://gisthost.github.io/?67bf20571e2ff97670bfdf75d396489d