Minesweeper.html

HTML/XML Detected Guest 1 Views Size: 99.18 KB Posted on: Sep 21, 26 @ 12:15 PM
  1. 1<!DOCTYPE html>
  2. 2<html lang="id">
  3. 3<head>
  4. 4<meta charset="utf-8">
  5. 5<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  6. 6<title>Sapu Ranjau</title>
  7. 7<link rel="preconnect" href="https://fonts.googleapis.com">
  8. 8<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. 9<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,800&family=Figtree:wght@400;500;600;700;800&display=swap" rel="stylesheet">
  10. 10<style>
  11. 11:root{
  12. 12 box-sizing:border-box;
  13. 13 padding-top:env(safe-area-inset-top,0px);
  14. 14 padding-bottom:env(safe-area-inset-bottom,0px);
  15. 15 --h:160;
  16. 16 --bg:hsl(var(--h) 38% 95.5%); --bg2:hsl(var(--h) 55% 90%);
  17. 17 --surface:#ffffff; --surface2:hsl(var(--h) 32% 94.5%);
  18. 18 --text:hsl(var(--h) 55% 11%); --muted:hsl(var(--h) 16% 38%); --line:hsl(var(--h) 28% 87%);
  19. 19 --accent:hsl(var(--h) 84% 38%); --accent-d:hsl(var(--h) 88% 27%); --accent-soft:hsl(var(--h) 70% 89%); --on-accent:hsl(var(--h) 90% 7%);
  20. 20 --tile:hsl(var(--h) 50% 82%); --tile-hi:hsl(var(--h) 60% 89%); --tile-lo:hsl(var(--h) 40% 70%);
  21. 21 --rev:hsl(var(--h) 45% 98.5%); --board:hsl(var(--h) 38% 90%);
  22. 22 --flag:#f43f5e; --danger:#ef4444; --danger-soft:#fde2e2; --mine-ink:#1e2a35;
  23. 23 --warn:#f59e0b;
  24. 24 --n1:#2563eb; --n2:#15803d; --n3:#dc2626; --n4:#7c3aed; --n5:#c2410c; --n6:#0e7490; --n7:#1f2937; --n8:#6b7280;
  25. 25 --shadow:0 1px 2px rgba(13,42,36,.06),0 10px 28px rgba(13,42,36,.08);
  26. 26 --font-display:'Bricolage Grotesque','Figtree',system-ui,-apple-system,'Segoe UI',sans-serif;
  27. 27 --font-body:'Figtree',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
  28. 28}
  29. 29@media (prefers-color-scheme:dark){
  30. 30 :root:not([data-theme="light"]){
  31. 31 --bg:hsl(var(--h) 45% 5.5%); --bg2:hsl(var(--h) 45% 9.5%);
  32. 32 --surface:hsl(var(--h) 38% 9%); --surface2:hsl(var(--h) 34% 12.5%);
  33. 33 --text:hsl(var(--h) 45% 94%); --muted:hsl(var(--h) 20% 62%); --line:hsl(var(--h) 32% 17%);
  34. 34 --accent:hsl(var(--h) 68% 55%); --accent-d:hsl(var(--h) 80% 38%); --accent-soft:hsl(var(--h) 55% 15%); --on-accent:hsl(var(--h) 90% 8%);
  35. 35 --tile:hsl(var(--h) 30% 26%); --tile-hi:hsl(var(--h) 30% 34%); --tile-lo:hsl(var(--h) 32% 20%);
  36. 36 --rev:hsl(var(--h) 45% 6.5%); --board:hsl(var(--h) 45% 7.5%);
  37. 37 --flag:#fb7185; --danger:#f87171; --danger-soft:#3d1a1a; --mine-ink:#e5e7eb;
  38. 38 --n1:#60a5fa; --n2:#4ade80; --n3:#f87171; --n4:#a78bfa; --n5:#fb923c; --n6:#22d3ee; --n7:#e5e7eb; --n8:#9ca3af;
  39. 39 --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px rgba(0,0,0,.35);
  40. 40 }
  41. 41}
  42. 42:root[data-theme="dark"]{
  43. 43 --bg:hsl(var(--h) 45% 5.5%); --bg2:hsl(var(--h) 45% 9.5%);
  44. 44 --surface:hsl(var(--h) 38% 9%); --surface2:hsl(var(--h) 34% 12.5%);
  45. 45 --text:hsl(var(--h) 45% 94%); --muted:hsl(var(--h) 20% 62%); --line:hsl(var(--h) 32% 17%);
  46. 46 --accent:hsl(var(--h) 68% 55%); --accent-d:hsl(var(--h) 80% 38%); --accent-soft:hsl(var(--h) 55% 15%); --on-accent:hsl(var(--h) 90% 8%);
  47. 47 --tile:hsl(var(--h) 30% 26%); --tile-hi:hsl(var(--h) 30% 34%); --tile-lo:hsl(var(--h) 32% 20%);
  48. 48 --rev:hsl(var(--h) 45% 6.5%); --board:hsl(var(--h) 45% 7.5%);
  49. 49 --flag:#fb7185; --danger:#f87171; --danger-soft:#3d1a1a; --mine-ink:#e5e7eb;
  50. 50 --n1:#60a5fa; --n2:#4ade80; --n3:#f87171; --n4:#a78bfa; --n5:#fb923c; --n6:#22d3ee; --n7:#e5e7eb; --n8:#9ca3af;
  51. 51 --shadow:0 1px 2px rgba(0,0,0,.4),0 10px 28px rgba(0,0,0,.35);
  52. 52}
  53. 53:root[data-skin="laut"]{--h:210}
  54. 54:root[data-skin="anggur"]{--h:268}
  55. 55:root[data-skin="mangga"]{--h:36}
  56. 56html{scroll-padding-top:env(safe-area-inset-top,0px);background:var(--bg)}
  57. 57*,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
  58. 58body{
  59. 59 margin:0;min-height:100vh;color:var(--text);font-family:var(--font-body);font-size:16px;line-height:1.45;
  60. 60 background:radial-gradient(900px 420px at 85% -8%,var(--bg2),transparent 70%),var(--bg);
  61. 61}
  62. 62button{font:inherit;color:inherit;cursor:pointer;border:0;background:none}
  63. 63button:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
  64. 64.hidden{display:none!important}
  65. 65.app{max-width:1120px;margin:0 auto;padding:14px 14px 28px}
  66. 66
  67. 67/* header */
  68. 68.top{display:flex;align-items:center;justify-content:space-between;padding:4px 2px 14px}
  69. 69.brand{display:flex;align-items:center;gap:10px;font-family:var(--font-display);font-weight:800;font-size:1.25rem;letter-spacing:-.02em}
  70. 70.logo{width:34px;height:34px;border-radius:11px;background:var(--accent);display:grid;place-items:center;box-shadow:0 3px 0 var(--accent-d)}
  71. 71.logo svg{width:20px;height:20px}
  72. 72.top-actions{display:flex;gap:8px}
  73. 73.icon-btn{width:42px;height:42px;border-radius:14px;background:var(--surface);border:1.5px solid var(--line);display:grid;place-items:center;transition:transform .1s}
  74. 74.icon-btn:active{transform:scale(.94)}
  75. 75.icon-btn svg{width:20px;height:20px}
  76. 76.icon-btn[aria-pressed="false"]{opacity:.55}
  77. 77
  78. 78.screen{display:none}
  79. 79.screen.active{display:block}
  80. 80
  81. 81/* tabs */
  82. 82.tabs{display:grid;grid-template-columns:1fr 1fr;gap:6px;background:var(--surface2);padding:5px;border-radius:16px;margin-bottom:14px;max-width:360px}
  83. 83.tabs button{padding:9px 10px;border-radius:12px;font-weight:700;transition:background .15s}
  84. 84.tabs button[aria-pressed="true"]{background:var(--surface);box-shadow:0 1px 3px rgba(0,0,0,.12)}
  85. 85
  86. 86/* menu */
  87. 87.menu-grid{display:grid;gap:14px}
  88. 88.hero{padding:6px 2px 4px}
  89. 89.hero h1{font-family:var(--font-display);font-weight:800;font-size:clamp(2rem,7vw,3.2rem);line-height:1.02;letter-spacing:-.035em;margin:0 0 10px;max-width:14ch}
  90. 90.hero p{margin:0;color:var(--muted);max-width:46ch}
  91. 91.card{background:var(--surface);border:1.5px solid var(--line);border-radius:22px;padding:16px;box-shadow:var(--shadow)}
  92. 92.card h2{font-family:var(--font-display);font-size:1.05rem;font-weight:700;margin:0 0 12px;letter-spacing:-.01em}
  93. 93.card h2 small{font-family:var(--font-body);font-weight:600;color:var(--muted);font-size:.85rem;margin-left:6px}
  94. 94.hint{margin:10px 2px 0;color:var(--muted);font-size:.9rem}
  95. 95.center{text-align:center}
  96. 96.seg{display:grid;grid-template-columns:1fr 1fr;gap:8px}
  97. 97.seg.three{grid-template-columns:repeat(3,1fr)}
  98. 98.seg button{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:14px;border:1.5px solid var(--line);background:var(--surface2);font-weight:700;text-align:left;transition:background .15s,border-color .15s}
  99. 99.seg button svg{width:22px;height:22px;flex:none}
  100. 100.seg button[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
  101. 101.chips{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
  102. 102.chips.four{grid-template-columns:repeat(2,1fr)}
  103. 103.chip{padding:12px 14px;border-radius:14px;border:1.5px solid var(--line);background:var(--surface2);text-align:left;transition:background .15s,border-color .15s}
  104. 104.chip b{display:block;font-weight:700}
  105. 105.chip span{display:block;color:var(--muted);font-size:.85rem}
  106. 106.chip[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
  107. 107.dim{opacity:.45;pointer-events:none}
  108. 108.custom{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:12px}
  109. 109.custom label{display:block;font-size:.85rem;color:var(--muted);font-weight:600}
  110. 110.field,.custom input{width:100%;margin-top:4px;padding:10px 12px;border-radius:12px;border:1.5px solid var(--line);background:var(--surface2);color:var(--text);font:inherit;font-weight:700}
  111. 111.rowf{display:flex;gap:8px;align-items:center}
  112. 112.rowf .field{margin-top:0;text-transform:uppercase}
  113. 113.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 18px;border-radius:14px;font-weight:700;border:1.5px solid var(--line);background:var(--surface);transition:transform .08s}
  114. 114.btn:active{transform:translateY(1px)}
  115. 115.btn.primary{background:var(--accent);color:var(--on-accent);border-color:transparent;box-shadow:0 4px 0 var(--accent-d)}
  116. 116.btn.primary:active{transform:translateY(3px);box-shadow:0 1px 0 var(--accent-d)}
  117. 117.btn.big{width:100%;padding:16px 20px;font-size:1.1rem;border-radius:18px;font-family:var(--font-display);letter-spacing:-.01em}
  118. 118.btn.ghost{background:transparent}
  119. 119.btn.small{padding:8px 12px;font-size:.88rem;border-radius:12px;flex:none}
  120. 120.btn.danger-t{color:var(--danger)}
  121. 121.resume{display:flex;flex-direction:column;gap:12px;border-color:var(--accent)}
  122. 122.resume .meta{color:var(--muted);font-size:.92rem}
  123. 123.resume .row{display:flex;gap:8px}
  124. 124.resume .row .btn{flex:1}
  125. 125.daily{display:flex;align-items:center;justify-content:space-between;gap:14px}
  126. 126.daily h2{margin-bottom:4px}
  127. 127.daily p{margin:0;color:var(--muted);font-size:.92rem}
  128. 128.daily .btn{flex:none}
  129. 129.stats{width:100%;border-collapse:collapse;font-size:.95rem}
  130. 130.stats th{font-weight:600;color:var(--muted);text-align:left;padding:6px 4px;font-size:.85rem}
  131. 131.stats td{padding:8px 4px;border-top:1px solid var(--line)}
  132. 132.stats td:last-child,.stats th:last-child{text-align:right}
  133. 133.vs-line{display:flex;gap:8px;margin-top:12px}
  134. 134.vs-line div{flex:1;background:var(--surface2);border-radius:14px;padding:10px 12px}
  135. 135.vs-line b{display:block;font-family:var(--font-display);font-size:1.5rem;line-height:1.1}
  136. 136.vs-line span{color:var(--muted);font-size:.85rem}
  137. 137
  138. 138/* profil */
  139. 139.prof{display:flex;gap:14px;align-items:center}
  140. 140.avatar{width:64px;height:64px;border-radius:20px;background:var(--accent);color:var(--on-accent);display:grid;place-items:center;font-family:var(--font-display);font-weight:800;font-size:1.8rem;flex:none;box-shadow:0 4px 0 var(--accent-d)}
  141. 141.prof .info{flex:1;min-width:0}
  142. 142.prof .field{margin:0 0 6px}
  143. 143.lvl{display:flex;justify-content:space-between;font-weight:700;font-size:.92rem}
  144. 144.lvl span{color:var(--muted);font-weight:600}
  145. 145.xpbar{height:10px;border-radius:99px;background:var(--surface2);overflow:hidden;margin-top:6px}
  146. 146.xpbar i{display:block;height:100%;background:var(--accent);border-radius:99px;transition:width .3s}
  147. 147.ach-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}
  148. 148.ach{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border-radius:14px;background:var(--surface2);opacity:.5;filter:grayscale(1)}
  149. 149.ach.on{opacity:1;filter:none;background:var(--accent-soft)}
  150. 150.ach .aic{font-size:1.5rem;line-height:1.1}
  151. 151.ach b{display:block;font-size:.95rem}
  152. 152.ach span{display:block;color:var(--muted);font-size:.8rem;line-height:1.3}
  153. 153.hrow{display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--line)}
  154. 154.hrow:first-child{border-top:0}
  155. 155.hic{font-size:1.4rem;width:34px;text-align:center;flex:none}
  156. 156.hmain{flex:1;min-width:0}
  157. 157.hmain b{display:block}
  158. 158.hmain span{display:block;color:var(--muted);font-size:.82rem}
  159. 159.skins{display:flex;gap:10px;flex-wrap:wrap}
  160. 160.skin{width:56px;height:56px;border-radius:16px;border:3px solid transparent;display:grid;place-items:center;font-size:.72rem;font-weight:800;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.4)}
  161. 161.skin[aria-pressed="true"]{border-color:var(--text)}
  162. 162.sub-h{font-weight:700;margin:16px 0 8px;font-size:.95rem}
  163. 163
  164. 164/* game bar */
  165. 165.gbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}
  166. 166.hud{display:flex;align-items:center;gap:8px}
  167. 167.pill{display:flex;align-items:center;gap:7px;padding:8px 12px;background:var(--surface);border:1.5px solid var(--line);border-radius:14px;min-width:78px;justify-content:center}
  168. 168.pill svg{width:18px;height:18px;flex:none}
  169. 169.pill b{font-family:var(--font-display);font-size:1.15rem;font-variant-numeric:tabular-nums}
  170. 170.face{width:48px;height:48px;border-radius:16px;background:var(--surface);border:1.5px solid var(--line);font-size:1.6rem;line-height:1;display:grid;place-items:center;box-shadow:0 3px 0 var(--line);transition:transform .08s}
  171. 171.face:active{transform:translateY(2px);box-shadow:0 1px 0 var(--line)}
  172. 172.inforow{display:flex;flex-wrap:wrap;gap:6px;margin:-2px 0 12px}
  173. 173.ipill{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:99px;background:var(--surface);border:1.5px solid var(--line);font-size:.82rem;font-weight:700}
  174. 174.ipill.accent{background:var(--accent-soft);border-color:var(--accent)}
  175. 175.ipill.code{cursor:pointer;letter-spacing:.02em}
  176. 176.ipill svg{width:14px;height:14px}
  177. 177.hearts i{font-style:normal;color:var(--flag);font-size:1rem;line-height:1}
  178. 178.hearts i.off{color:var(--line)}
  179. 179
  180. 180.duel{display:grid;gap:8px;margin-bottom:12px;background:var(--surface);border:1.5px solid var(--line);border-radius:18px;padding:12px 14px}
  181. 181.d-row{display:grid;grid-template-columns:64px 1fr 44px;align-items:center;gap:10px;font-weight:700;font-size:.92rem}
  182. 182.d-row b{text-align:right;font-variant-numeric:tabular-nums}
  183. 183.bar{height:10px;border-radius:99px;background:var(--surface2);overflow:hidden}
  184. 184.bar i{display:block;height:100%;width:0;background:var(--accent);border-radius:99px;transition:width .25s}
  185. 185.bar.bot i{background:var(--warn)}
  186. 186
  187. 187.stage{position:relative;display:grid;gap:12px}
  188. 188.board-card{padding:10px}
  189. 189.bc-head{display:flex;justify-content:space-between;align-items:center;padding:2px 6px 8px;font-weight:700}
  190. 190.bc-head small{color:var(--muted);font-weight:600}
  191. 191.board-wrap{overflow-x:auto;overflow-y:hidden;border-radius:16px;touch-action:manipulation;-webkit-overflow-scrolling:touch}
  192. 192.board{--gap:2px;--s:32px;display:grid;grid-template-columns:repeat(var(--c),var(--s));grid-auto-rows:var(--s);gap:var(--gap);padding:6px;width:max-content;margin:0 auto;background:var(--board);border-radius:16px;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;transition:box-shadow .3s}
  193. 193.board.won{box-shadow:0 0 0 3px var(--accent)}
  194. 194.board.shake{animation:shake .4s}
  195. 195.cell{width:var(--s);height:var(--s);border-radius:calc(var(--s)*.24);display:grid;place-items:center;font-family:var(--font-display);font-weight:800;font-size:calc(var(--s)*.58);line-height:1;background:var(--tile);box-shadow:inset 0 -3px 0 var(--tile-lo),inset 0 2px 0 var(--tile-hi);cursor:pointer;position:relative}
  196. 196@media (hover:hover){.cell:not(.r):not(.f):hover{background:var(--tile-hi)}}
  197. 197.cell:not(.r):active{transform:translateY(1px);box-shadow:inset 0 -1px 0 var(--tile-lo),inset 0 2px 0 var(--tile-hi)}
  198. 198.cell.r{background:var(--rev);box-shadow:none;cursor:default;animation:pop .2s ease-out}
  199. 199.board.static .cell{animation:none}
  200. 200.cell svg{width:62%;height:62%;pointer-events:none}
  201. 201.cell .mine-ico{color:var(--mine-ink)}
  202. 202.cell.x{background:var(--danger)}
  203. 203.cell.x .mine-ico{color:#fff}
  204. 204.cell.m{background:var(--rev);box-shadow:none}
  205. 205.cell.w{background:var(--danger-soft)}
  206. 206.cell.w::after{content:"";position:absolute;left:14%;right:14%;top:50%;height:3px;background:var(--danger);border-radius:2px;transform:rotate(-40deg)}
  207. 207.cell.hint{outline:3px solid var(--warn);outline-offset:-1px;animation:pulse 1s ease-in-out infinite;z-index:1}
  208. 208.n1{color:var(--n1)}.n2{color:var(--n2)}.n3{color:var(--n3)}.n4{color:var(--n4)}.n5{color:var(--n5)}.n6{color:var(--n6)}.n7{color:var(--n7)}.n8{color:var(--n8)}
  209. 209@keyframes pop{from{transform:scale(.72);opacity:.5}to{transform:scale(1);opacity:1}}
  210. 210@keyframes pulse{50%{transform:scale(1.14)}}
  211. 211@keyframes shake{20%{transform:translateX(-7px)}40%{transform:translateX(6px)}60%{transform:translateX(-4px)}80%{transform:translateX(3px)}}
  212. 212.board.mini{--gap:1px;padding:4px;border-radius:12px}
  213. 213.board.mini .cell{border-radius:2px;box-shadow:none;font-size:0;cursor:default;animation:none}
  214. 214.board.mini .cell.r{background:var(--rev)}
  215. 215.board.mini .cell.r[class*=" n"]{background:var(--accent-soft)}
  216. 216.board.mini .cell.f{background:var(--flag)}
  217. 217.board.mini .cell.m{background:var(--mine-ink)}
  218. 218.board.mini .cell.x{background:var(--danger)}
  219. 219.board.mini .cell.w::after{display:none}
  220. 220.board.mini .cell.last{outline:2px solid var(--warn);outline-offset:0;z-index:1}
  221. 221
  222. 222.dock{position:sticky;bottom:calc(8px + env(safe-area-inset-bottom,0px));display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:4px;margin-top:14px;padding:8px;background:color-mix(in srgb,var(--surface) 88%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1.5px solid var(--line);border-radius:20px;box-shadow:var(--shadow);z-index:5}
  223. 223.tool{display:flex;flex-direction:column;align-items:center;gap:3px;padding:8px 2px;border-radius:14px;font-weight:700;font-size:.76rem;transition:background .15s}
  224. 224.tool svg{width:22px;height:22px}
  225. 225.tool[aria-pressed="true"]{background:var(--accent-soft)}
  226. 226.tool:active{background:var(--surface2)}
  227. 227.rbar{margin-top:14px;padding:12px;border-radius:20px;background:var(--surface);border:1.5px solid var(--line);box-shadow:var(--shadow)}
  228. 228.rbar .prog{height:8px;border-radius:99px;background:var(--surface2);overflow:hidden;margin-bottom:10px}
  229. 229.rbar .prog i{display:block;height:100%;width:0;background:var(--accent)}
  230. 230.rbar .btns4{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
  231. 231.rbar .btn{padding:10px 6px;font-size:.9rem}
  232. 232
  233. 233.overlay{position:absolute;inset:0;z-index:4;display:grid;place-items:center;border-radius:22px;background:color-mix(in srgb,var(--bg) 55%,transparent);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);text-align:center;padding:20px}
  234. 234.overlay .count{font-family:var(--font-display);font-weight:800;font-size:clamp(4rem,18vw,8rem);line-height:1;color:var(--accent);animation:pop .35s ease-out}
  235. 235.overlay h3{font-family:var(--font-display);font-size:1.6rem;margin:0 0 6px}
  236. 236.overlay p{margin:0 0 16px;color:var(--muted)}
  237. 237.overlay .btn{min-width:180px}
  238. 238
  239. 239/* modal + toast */
  240. 240.modal-wrap{position:fixed;inset:0;z-index:20;display:grid;place-items:center;padding:20px;background:rgba(6,20,15,.5);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px)}
  241. 241.modal{width:min(420px,100%);max-height:94vh;overflow-y:auto;background:var(--surface);border-radius:26px;padding:24px 20px 18px;text-align:center;box-shadow:var(--shadow);border:1.5px solid var(--line);animation:pop .25s ease-out}
  242. 242.modal .big-ic{font-size:3rem;line-height:1}
  243. 243.modal h3{font-family:var(--font-display);font-size:1.7rem;letter-spacing:-.02em;margin:8px 0 4px}
  244. 244.modal p{margin:0 0 14px;color:var(--muted)}
  245. 245.res{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:14px}
  246. 246.res div{background:var(--surface2);border-radius:14px;padding:10px}
  247. 247.res b{display:block;font-family:var(--font-display);font-size:1.3rem}
  248. 248.res span{font-size:.82rem;color:var(--muted)}
  249. 249.xp-line{margin:0 0 10px;font-weight:700;color:var(--accent-d);background:var(--accent-soft);border-radius:12px;padding:8px 10px;font-size:.92rem}
  250. 250:root[data-theme="dark"] .xp-line{color:var(--accent)}
  251. 251@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .xp-line{color:var(--accent)}}
  252. 252.ach-line{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:0 0 14px}
  253. 253.ach-line span{background:var(--surface2);border:1.5px solid var(--accent);border-radius:99px;padding:5px 11px;font-size:.85rem;font-weight:700}
  254. 254.modal .btns{display:grid;gap:8px}
  255. 255.share-box{width:min(480px,100%)}
  256. 256.share-box h3{margin:0 0 12px}
  257. 257.sh-opts{display:grid;gap:8px;margin-bottom:12px}
  258. 258.mini-seg button{justify-content:center;text-align:center;padding:9px 8px;font-size:.9rem}
  259. 259.mini-seg.three{grid-template-columns:repeat(3,1fr)}
  260. 260.share-prev{border-radius:16px;padding:0;line-height:0}
  261. 261.share-prev img{max-width:100%;max-height:48vh;border-radius:14px;display:block;margin:0 auto;-webkit-touch-callout:default;user-select:auto;-webkit-user-select:auto}
  262. 262.share-prev.clear{padding:10px;background:conic-gradient(#22332d 25%,#2c423a 0 50%,#22332d 0 75%,#2c423a 0) 0 0/22px 22px}
  263. 263.share-box .hint{margin:10px 0 14px}
  264. 264#fx{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:40}
  265. 265#toast{position:fixed;left:50%;bottom:calc(28px + env(safe-area-inset-bottom,0px));transform:translate(-50%,20px);background:var(--text);color:var(--bg);padding:11px 18px;border-radius:14px;font-weight:600;font-size:.92rem;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:50;max-width:calc(100vw - 32px);text-align:center}
  266. 266#toast.show{opacity:1;transform:translate(-50%,0)}
  267. 267
  268. 268@media (min-width:720px){
  269. 269 .menu-grid{grid-template-columns:1fr 1fr;align-items:start}
  270. 270 .menu-grid .hero,.menu-grid .full{grid-column:1/-1}
  271. 271 .chips.four{grid-template-columns:repeat(4,1fr)}
  272. 272}
  273. 273@media (min-width:900px){
  274. 274 .stage.bot{grid-template-columns:minmax(0,1fr) 320px;align-items:start}
  275. 275}
  276. 276@media (prefers-reduced-motion:reduce){
  277. 277 *,*::before,*::after{animation:none!important;transition:none!important}
  278. 278}
  279. 279</style>
  280. 280</head>
  281. 281<body>
  282. 282<div class="app">
  283. 283 <header class="top">
  284. 284 <div class="brand">
  285. 285 <div class="logo" aria-hidden="true">
  286. 286 <svg viewBox="0 0 24 24" fill="none"><g stroke="#fff" stroke-width="2.2" stroke-linecap="round"><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1"/></g><circle cx="12" cy="12" r="5.2" fill="#fff"/></svg>
  287. 287 </div>
  288. 288 Sapu Ranjau
  289. 289 </div>
  290. 290 <div class="top-actions">
  291. 291 <button class="icon-btn" id="soundBtn" aria-label="Suara" aria-pressed="true">
  292. 292 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9.5v5h4l5 4v-13l-5 4z"/><path d="M16.5 9a4 4 0 0 1 0 6M19 6.5a8 8 0 0 1 0 11"/></svg>
  293. 293 </button>
  294. 294 <button class="icon-btn" id="themeBtn" aria-label="Ganti tema terang atau gelap">
  295. 295 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
  296. 296 </button>
  297. 297 </div>
  298. 298 </header>
  299. 299
  300. 300 <main>
  301. 301 <!-- MENU -->
  302. 302 <section id="menu" class="screen active">
  303. 303 <div class="tabs" id="tabs">
  304. 304 <button data-tab="main" aria-pressed="true">Main</button>
  305. 305 <button data-tab="pro" aria-pressed="false">Profil</button>
  306. 306 </div>
  307. 307
  308. 308 <div id="paneMain" class="menu-grid">
  309. 309 <div class="hero">
  310. 310 <h1>Temukan semua ranjau.</h1>
  311. 311 <p>Main santai, ikut tantangan harian, atau adu cepat melawan bot yang bermain seperti manusia. Semua progres tersimpan di perangkat ini.</p>
  312. 312 </div>
  313. 313
  314. 314 <div id="resumeCard" class="card resume full hidden">
  315. 315 <div>
  316. 316 <h2 style="margin-bottom:4px">Ada permainan tersimpan</h2>
  317. 317 <div class="meta" id="resumeMeta"></div>
  318. 318 </div>
  319. 319 <div class="row">
  320. 320 <button class="btn primary" id="resumeBtn">Lanjutkan</button>
  321. 321 <button class="btn ghost danger-t" id="discardBtn">Hapus</button>
  322. 322 </div>
  323. 323 </div>
  324. 324
  325. 325 <div class="card daily full">
  326. 326 <div>
  327. 327 <h2>Tantangan harian</h2>
  328. 328 <p id="dailyTxt"></p>
  329. 329 </div>
  330. 330 <button class="btn primary" id="dailyBtn">Main</button>
  331. 331 </div>
  332. 332
  333. 333 <div class="card">
  334. 334 <h2>Mode</h2>
  335. 335 <div class="seg" id="modeSeg">
  336. 336 <button data-mode="solo" aria-pressed="true">
  337. 337 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-7 8-7s8 2.6 8 7"/></svg>
  338. 338 Solo
  339. 339 </button>
  340. 340 <button data-mode="bot" aria-pressed="false">
  341. 341 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="4"/><path d="M12 4v4"/><circle cx="9" cy="14" r="1" fill="currentColor"/><circle cx="15" cy="14" r="1" fill="currentColor"/></svg>
  342. 342 Lawan bot
  343. 343 </button>
  344. 344 </div>
  345. 345 <p class="hint" id="modeHint"></p>
  346. 346 <div id="livesBox">
  347. 347 <div class="sub-h">Aturan</div>
  348. 348 <div class="seg" id="livesSeg">
  349. 349 <button data-lives="0" aria-pressed="true">Klasik</button>
  350. 350 <button data-lives="1" aria-pressed="false">3 nyawa</button>
  351. 351 </div>
  352. 352 <p class="hint">Mode 3 nyawa: kena ranjau tidak langsung kalah, ranjau otomatis diberi bendera. Rekor waktu hanya untuk mode klasik.</p>
  353. 353 </div>
  354. 354 </div>
  355. 355
  356. 356 <div class="card">
  357. 357 <h2>Tingkat kesulitan</h2>
  358. 358 <div class="chips" id="diffChips"></div>
  359. 359 <div class="custom hidden" id="customBox">
  360. 360 <label>Baris<input id="cR" type="number" inputmode="numeric" min="5" max="30"></label>
  361. 361 <label>Kolom<input id="cC" type="number" inputmode="numeric" min="5" max="30"></label>
  362. 362 <label>Ranjau<input id="cM" type="number" inputmode="numeric" min="1"></label>
  363. 363 </div>
  364. 364 <p class="hint hidden" id="customHint"></p>
  365. 365 </div>
  366. 366
  367. 367 <div class="card full">
  368. 368 <h2>Kode papan <small>opsional</small></h2>
  369. 369 <div class="rowf">
  370. 370 <input class="field" id="codeIn" maxlength="24" placeholder="Contoh: S-K7Q2M" autocomplete="off" spellcheck="false">
  371. 371 <button class="btn" id="codeRand" aria-label="Buat kode acak">Acak</button>
  372. 372 </div>
  373. 373 <p class="hint" id="codeHint">Kosongkan untuk papan acak. Kode yang sama menghasilkan papan yang sama, jadi bisa dipakai untuk menantang teman.</p>
  374. 374 </div>
  375. 375
  376. 376 <div class="card full hidden" id="botCfg">
  377. 377 <h2>Kecepatan bot</h2>
  378. 378 <div class="chips four" id="botChips"></div>
  379. 379 <p class="hint">Kalian mulai dari papan dan bukaan awal yang sama. Bot bermain seperti orang: kadang ragu, berhenti sebentar, menebak, atau salah klik. Siapa yang lebih dulu membuka semua kotak aman, dia menang.</p>
  380. 380 </div>
  381. 381
  382. 382 <div class="full"><button class="btn primary big" id="startBtn">Mulai main</button></div>
  383. 383 </div>
  384. 384
  385. 385 <div id="panePro" class="menu-grid hidden">
  386. 386 <div class="card full">
  387. 387 <div class="prof">
  388. 388 <div class="avatar" id="avatar">P</div>
  389. 389 <div class="info">
  390. 390 <input class="field" id="nameIn" maxlength="16" placeholder="Nama pemain" autocomplete="off">
  391. 391 <div class="lvl"><b id="lvlTxt"></b><span id="xpTxt"></span></div>
  392. 392 <div class="xpbar"><i id="xpBar"></i></div>
  393. 393 </div>
  394. 394 </div>
  395. 395 </div>
  396. 396
  397. 397 <div class="card">
  398. 398 <h2>Rekor</h2>
  399. 399 <div id="statsBox"></div>
  400. 400 </div>
  401. 401
  402. 402 <div class="card">
  403. 403 <h2>Tampilan dan suara</h2>
  404. 404 <div class="sub-h" style="margin-top:0">Warna</div>
  405. 405 <div class="skins" id="skins"></div>
  406. 406 <div class="sub-h">Tema</div>
  407. 407 <div class="seg three mini-seg" id="themeSeg">
  408. 408 <button data-t="auto" aria-pressed="true">Otomatis</button>
  409. 409 <button data-t="light" aria-pressed="false">Terang</button>
  410. 410 <button data-t="dark" aria-pressed="false">Gelap</button>
  411. 411 </div>
  412. 412 <div class="sub-h">Suara</div>
  413. 413 <div class="seg mini-seg" id="soundSeg">
  414. 414 <button data-s="1" aria-pressed="true">Hidup</button>
  415. 415 <button data-s="0" aria-pressed="false">Mati</button>
  416. 416 </div>
  417. 417 <div class="sub-h">Pintasan keyboard</div>
  418. 418 <p class="hint" style="margin-top:0">R ulangi, F mode bendera, H petunjuk, P jeda, Esc tutup.</p>
  419. 419 </div>
  420. 420
  421. 421 <div class="card full">
  422. 422 <h2>Pencapaian <small id="achCount"></small></h2>
  423. 423 <div class="ach-grid" id="achGrid"></div>
  424. 424 </div>
  425. 425
  426. 426 <div class="card full">
  427. 427 <h2>Riwayat <small>15 game terakhir</small></h2>
  428. 428 <div id="histList"></div>
  429. 429 </div>
  430. 430
  431. 431 <div class="full center"><button class="btn ghost danger-t" id="wipeBtn">Hapus semua data</button></div>
  432. 432 </div>
  433. 433 </section>
  434. 434
  435. 435 <!-- GAME -->
  436. 436 <section id="game" class="screen">
  437. 437 <div class="gbar">
  438. 438 <button class="icon-btn" id="backBtn" aria-label="Kembali ke menu">
  439. 439 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
  440. 440 </button>
  441. 441 <div class="hud">
  442. 442 <div class="pill" title="Sisa ranjau">
  443. 443 <svg viewBox="0 0 24 24"><path d="M6 21V3" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" fill="none"/><path d="M6 4h12l-3 4.5 3 4.5H6z" fill="#f43f5e"/></svg>
  444. 444 <b id="minesLeft">0</b>
  445. 445 </div>
  446. 446 <button class="face" id="faceBtn" aria-label="Ulangi permainan">🙂</button>
  447. 447 <div class="pill" title="Waktu">
  448. 448 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9.5 2.5h5"/></svg>
  449. 449 <b id="timer">0:00</b>
  450. 450 </div>
  451. 451 </div>
  452. 452 <button class="icon-btn" id="pauseBtn" aria-label="Jeda">
  453. 453 <svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1.2"/><rect x="14" y="5" width="4" height="14" rx="1.2"/></svg>
  454. 454 </button>
  455. 455 </div>
  456. 456
  457. 457 <div class="inforow hidden" id="infoRow"></div>
  458. 458
  459. 459 <div class="duel hidden" id="duel">
  460. 460 <div class="d-row"><span>Kamu</span><div class="bar"><i id="barMe"></i></div><b id="pctMe">0%</b></div>
  461. 461 <div class="d-row"><span>Bot</span><div class="bar bot"><i id="barBot"></i></div><b id="pctBot">0%</b></div>
  462. 462 </div>
  463. 463
  464. 464 <div class="stage" id="stage">
  465. 465 <div class="card board-card">
  466. 466 <div class="bc-head" id="meHead"><span>Papanmu</span><small id="meInfo"></small></div>
  467. 467 <div class="board-wrap" id="meWrap"><div class="board" id="meBoard" aria-label="Papan permainan"></div></div>
  468. 468 </div>
  469. 469 <div class="card board-card hidden" id="botCard">
  470. 470 <div class="bc-head"><span id="botTitle">Bot</span><small id="botInfo"></small></div>
  471. 471 <div class="board-wrap" id="botWrap"><div class="board mini" id="botBoard" aria-label="Papan bot"></div></div>
  472. 472 </div>
  473. 473 <div class="overlay hidden" id="overlay"></div>
  474. 474 </div>
  475. 475
  476. 476 <div class="dock" id="dock">
  477. 477 <button class="tool" id="flagBtn" aria-pressed="false">
  478. 478 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 21V3"/><path d="M6 4h12l-3 4.5 3 4.5H6z"/></svg>
  479. 479 Bendera
  480. 480 </button>
  481. 481 <button class="tool" id="hintBtn">
  482. 482 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3z"/></svg>
  483. 483 Petunjuk
  484. 484 </button>
  485. 485 <button class="tool" id="saveBtn">
  486. 486 <svg id="icSave" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h11l4 4v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M8 3v6h8V3M8 21v-7h8v7"/></svg>
  487. 487 <svg id="icShare" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3M7.5 7.5L12 3l4.5 4.5M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7"/></svg>
  488. 488 <span id="saveLbl">Simpan</span>
  489. 489 </button>
  490. 490 <button class="tool" id="newBtn">
  491. 491 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4v5h-5"/></svg>
  492. 492 Ulangi
  493. 493 </button>
  494. 494 <button class="tool" id="menuBtn">
  495. 495 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l8-8 8 8M6 10v10h12V10"/></svg>
  496. 496 Menu
  497. 497 </button>
  498. 498 </div>
  499. 499
  500. 500 <div class="rbar hidden" id="replayBar">
  501. 501 <div class="prog"><i id="rpBar"></i></div>
  502. 502 <div class="btns4">
  503. 503 <button class="btn" id="rpPlay">Jeda</button>
  504. 504 <button class="btn" id="rpSpeed"></button>
  505. 505 <button class="btn" id="rpAgain">Ulang</button>
  506. 506 <button class="btn" id="rpClose">Tutup</button>
  507. 507 </div>
  508. 508 </div>
  509. 509 <p class="hint center" id="helpText"></p>
  510. 510 </section>
  511. 511 </main>
  512. 512</div>
  513. 513
  514. 514<canvas id="fx" class="hidden"></canvas>
  515. 515<div class="modal-wrap hidden" id="modal"><div class="modal" id="modalBox"></div></div>
  516. 516<div class="modal-wrap hidden" id="share">
  517. 517 <div class="modal share-box">
  518. 518 <h3>Bagikan hasil</h3>
  519. 519 <div class="sh-opts">
  520. 520 <div class="seg mini-seg" id="shFmt">
  521. 521 <button data-f="feed" aria-pressed="true">Feed 4:5</button>
  522. 522 <button data-f="story" aria-pressed="false">Story 9:16</button>
  523. 523 </div>
  524. 524 <div class="seg mini-seg three" id="shStyle">
  525. 525 <button data-s="dark" aria-pressed="false">Gelap</button>
  526. 526 <button data-s="light" aria-pressed="false">Terang</button>
  527. 527 <button data-s="clear" aria-pressed="false">Transparan</button>
  528. 528 </div>
  529. 529 </div>
  530. 530 <div class="share-prev" id="sharePrev"><img id="shImg" alt="Pratinjau kartu hasil"></div>
  531. 531 <p class="hint center" id="shHint">Tekan Unduh, atau tahan gambar untuk menyimpannya.</p>
  532. 532 <div class="btns">
  533. 533 <button class="btn primary" id="shDl">Unduh PNG</button>
  534. 534 <button class="btn hidden" id="shShare">Bagikan ke aplikasi lain</button>
  535. 535 <button class="btn ghost" id="shClose">Tutup</button>
  536. 536 </div>
  537. 537 </div>
  538. 538</div>
  539. 539<div id="toast" role="status" aria-live="polite"></div>
  540. 540
  541. 541<script>
  542. 542(() => {
  543. 543'use strict';
  544. 544const $ = s => document.querySelector(s);
  545. 545
  546. 546/* ===================== KONFIGURASI ===================== */
  547. 547const DIFFS = {
  548. 548 easy: { name:'Mudah', r:9, c:9, m:10 },
  549. 549 medium: { name:'Sedang', r:16, c:16, m:40 },
  550. 550 hard: { name:'Sulit', r:16, c:30, m:99 },
  551. 551 custom: { name:'Kustom' }
  552. 552};
  553. 553const BOTS = {
  554. 554 chill: { name:'Santai', ms:1300, sub:'Sering ragu, sesekali salah klik', subset:0.35, mis:0.012, flag:0.55, overlook:0.20 },
  555. 555 normal: { name:'Normal', ms:800, sub:'Seimbang seperti pemain biasa', subset:0.65, mis:0.008, flag:0.40, overlook:0.10 },
  556. 556 fast: { name:'Cepat', ms:450, sub:'Cepat dan cukup teliti', subset:0.9, mis:0.010, flag:0.25, overlook:0.05 },
  557. 557 blitz: { name:'Kilat', ms:240, sub:'Ngebut, rawan salah klik', subset:1, mis:0.018, flag:0.10, overlook:0.04 }
  558. 558};
  559. 559const SKINS = { mint:{n:'Mint',h:160}, laut:{n:'Laut',h:210}, anggur:{n:'Anggur',h:268}, mangga:{n:'Mangga',h:36} };
  560. 560const CODE_D = { M:'easy', S:'medium', H:'hard' };
  561. 561const ACH = [
  562. 562 {id:'first_win', ic:'🏁', n:'Langkah Pertama', d:'Menangkan game pertamamu.'},
  563. 563 {id:'easy_fast', ic:'⚡', n:'Kilat Pemula', d:'Menang Mudah di bawah 20 detik tanpa petunjuk.'},
  564. 564 {id:'medium_win', ic:'🧭', n:'Naik Kelas', d:'Menang di tingkat Sedang.'},
  565. 565 {id:'hard_win', ic:'🏔️', n:'Sang Penjinak', d:'Menang di tingkat Sulit.'},
  566. 566 {id:'no_flag', ic:'🕊️', n:'Tanpa Bendera', d:'Menang tanpa satu bendera pun di papan 25 ranjau atau lebih.'},
  567. 567 {id:'bot_win', ic:'🤖', n:'Kalahkan Bot', d:'Menang duel melawan bot.'},
  568. 568 {id:'bot_5', ic:'🎯', n:'Pemburu Bot', d:'Menang 5 kali melawan bot.'},
  569. 569 {id:'bot_blitz', ic:'🚀', n:'Lebih Cepat dari Kilat',d:'Kalahkan bot level Kilat.'},
  570. 570 {id:'lives_1', ic:'❤️', n:'Selamat Tipis', d:'Menang mode 3 nyawa dengan sisa satu nyawa.'},
  571. 571 {id:'seed_win', ic:'🎲', n:'Tantangan Teman', d:'Menang di papan dari kode teman.'},
  572. 572 {id:'daily_3', ic:'🔥', n:'Rajin Harian', d:'Selesaikan tantangan harian 3 hari beruntun.'},
  573. 573 {id:'daily_7', ic:'🗓️', n:'Sepekan Penuh', d:'Selesaikan tantangan harian 7 hari beruntun.'},
  574. 574 {id:'win_10', ic:'🏆', n:'Sepuluh Kemenangan', d:'Menang 10 kali di mode solo.'},
  575. 575 {id:'lose_10', ic:'💥', n:'Jatuh Bangun', d:'Kalah 10 kali dan tetap lanjut main.'}
  576. 576];
  577. 577const TITLES = ['Pemula','Penjelajah','Penyapu','Penjinak','Ahli','Suhu','Legenda'];
  578. 578const K_SET='sapuranjau:settings', K_SAVE='sapuranjau:save', K_STAT='sapuranjau:stats', K_HIST='sapuranjau:hist';
  579. 579
  580. 580/* ===================== PENYIMPANAN LOKAL ===================== */
  581. 581const store = {
  582. 582 get(k, d){ try{ const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; }catch(e){ return d; } },
  583. 583 set(k, v){ try{ localStorage.setItem(k, JSON.stringify(v)); return true; }catch(e){ return false; } },
  584. 584 del(k){ try{ localStorage.removeItem(k); }catch(e){} }
  585. 585};
  586. 586const defSet = () => ({ mode:'solo', diff:'easy', bot:'normal', custom:{r:12,c:12,m:25}, theme:'auto', skin:'mint', sound:true, name:'Pemain', lives:false, code:'' });
  587. 587const defStats = () => ({ best:{easy:null,medium:null,hard:null}, top:{easy:[],medium:[],hard:[]}, played:0, won:0, lost:0, vs:{w:0,l:0,d:0}, xp:0, ach:{}, daily:{streak:0,last:'',done:{}} });
  588. 588let S = Object.assign(defSet(), store.get(K_SET, {}));
  589. 589S.custom = Object.assign({r:12,c:12,m:25}, S.custom || {});
  590. 590if(!SKINS[S.skin]) S.skin='mint';
  591. 591function loadStats(){
  592. 592 const d=defStats(), o=store.get(K_STAT,{}) || {};
  593. 593 const st=Object.assign(d,o);
  594. 594 st.best=Object.assign(defStats().best,o.best||{});
  595. 595 st.top=Object.assign(defStats().top,o.top||{});
  596. 596 st.vs=Object.assign({w:0,l:0,d:0},o.vs||{});
  597. 597 st.daily=Object.assign({streak:0,last:'',done:{}},o.daily||{});
  598. 598 st.ach=o.ach||{};
  599. 599 return st;
  600. 600}
  601. 601let ST = loadStats();
  602. 602let HIST = store.get(K_HIST, []); if(!Array.isArray(HIST)) HIST=[];
  603. 603const saveSettings = () => store.set(K_SET, S);
  604. 604const saveStats = () => store.set(K_STAT, ST);
  605. 605function saveHist(){
  606. 606 if(!store.set(K_HIST, HIST)){
  607. 607 HIST.forEach((h,i)=>{ if(i>2) delete h.ev; });
  608. 608 store.set(K_HIST, HIST);
  609. 609 }
  610. 610}
  611. 611
  612. 612/* ===================== UTIL ===================== */
  613. 613const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
  614. 614const fmt = ms => { const s = Math.floor(ms/1000); return Math.floor(s/60) + ':' + String(s%60).padStart(2,'0'); };
  615. 615const fmtP = ms => ms < 60000 ? (ms/1000).toFixed(1).replace('.',',') + ' dtk' : fmt(ms);
  616. 616const coarse = matchMedia('(pointer:coarse)').matches;
  617. 617const rnd = a => a[Math.floor(Math.random()*a.length)];
  618. 618const dayKey = (d=new Date()) => d.getFullYear()+String(d.getMonth()+1).padStart(2,'0')+String(d.getDate()).padStart(2,'0');
  619. 619const yesterdayKey = () => dayKey(new Date(Date.now()-864e5));
  620. 620const dayLabel = () => new Date().toLocaleDateString('id-ID',{day:'numeric',month:'long'});
  621. 621let toastT;
  622. 622function toast(msg){ const t=$('#toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(toastT); toastT=setTimeout(()=>t.classList.remove('show'),2600); }
  623. 623const vib = p => { try{ if(S.sound && navigator.vibrate) navigator.vibrate(p); }catch(e){} };
  624. 624
  625. 625/* RNG dengan seed, agar papan bisa dibagikan lewat kode */
  626. 626function hashSeed(str){
  627. 627 let h=1779033703^str.length;
  628. 628 for(let i=0;i<str.length;i++){ h=Math.imul(h^str.charCodeAt(i),3432918353); h=(h<<13)|(h>>>19); }
  629. 629 h=Math.imul(h^(h>>>16),2246822507); h=Math.imul(h^(h>>>13),3266489909);
  630. 630 return (h^(h>>>16))>>>0;
  631. 631}
  632. 632function rng(seed){
  633. 633 let a=hashSeed(String(seed));
  634. 634 return () => { a|=0; a=(a+0x6D2B79F5)|0; let t=Math.imul(a^(a>>>15),1|a); t=(t+Math.imul(t^(t>>>7),61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; };
  635. 635}
  636. 636function parseCode(raw){
  637. 637 raw=(raw||'').trim().toUpperCase();
  638. 638 let m=/^([MSH])-([A-Z0-9]{3,12})$/.exec(raw);
  639. 639 if(m) return { diff:CODE_D[m[1]], seed:m[2], code:raw };
  640. 640 m=/^C(\d{1,2})X(\d{1,2})X(\d{1,3})-([A-Z0-9]{3,12})$/.exec(raw);
  641. 641 if(m){
  642. 642 const r=+m[1], c=+m[2], mi=+m[3];
  643. 643 if(r>=5&&r<=30&&c>=5&&c<=30&&mi>=1&&mi<=r*c-9) return { diff:'custom', custom:{r,c,m:mi}, seed:m[4], code:raw };
  644. 644 }
  645. 645 return null;
  646. 646}
  647. 647function makeCode(){
  648. 648 const abc='ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let s='';
  649. 649 for(let i=0;i<5;i++) s+=abc[Math.floor(Math.random()*abc.length)];
  650. 650 const dp = S.diff==='easy'?'M':S.diff==='medium'?'S':S.diff==='hard'?'H':null;
  651. 651 return dp ? dp+'-'+s : 'C'+S.custom.r+'X'+S.custom.c+'X'+S.custom.m+'-'+s;
  652. 652}
  653. 653
  654. 654/* ===================== SUARA, KONFETI ===================== */
  655. 655let AC=null;
  656. 656function tone(f,d,type,vol,when,slideTo){
  657. 657 if(!S.sound) return;
  658. 658 try{
  659. 659 AC = AC || new (window.AudioContext||window.webkitAudioContext)();
  660. 660 if(AC.state==='suspended') AC.resume();
  661. 661 const t=AC.currentTime+(when||0), o=AC.createOscillator(), g=AC.createGain();
  662. 662 o.type=type||'sine'; o.frequency.setValueAtTime(f,t);
  663. 663 if(slideTo) o.frequency.exponentialRampToValueAtTime(slideTo,t+d);
  664. 664 g.gain.setValueAtTime(vol||.05,t); g.gain.exponentialRampToValueAtTime(.0001,t+d);
  665. 665 o.connect(g); g.connect(AC.destination); o.start(t); o.stop(t+d+.02);
  666. 666 }catch(e){}
  667. 667}
  668. 668const sfx = {
  669. 669 open(n){ tone(420+Math.min(n,20)*14,.07,'triangle',.05); },
  670. 670 flag(){ tone(760,.06,'square',.03); tone(980,.05,'square',.025,.06); },
  671. 671 boom(){ tone(160,.45,'sawtooth',.09,0,40); tone(90,.5,'square',.06,0,30); },
  672. 672 win(){ [523,659,784,1047].forEach((f,k)=>tone(f,.22,'triangle',.06,k*.11)); },
  673. 673 lose(){ [330,262,196].forEach((f,k)=>tone(f,.28,'sawtooth',.05,k*.16)); },
  674. 674 hint(){ tone(880,.09,'sine',.05); tone(1320,.12,'sine',.04,.09); },
  675. 675 ach(){ tone(988,.1,'triangle',.05); tone(1319,.18,'triangle',.05,.1); }
  676. 676};
  677. 677function confetti(){
  678. 678 if(matchMedia('(prefers-reduced-motion:reduce)').matches) return;
  679. 679 const cv=$('#fx'), ctx=cv.getContext('2d');
  680. 680 cv.width=innerWidth; cv.height=innerHeight; cv.classList.remove('hidden');
  681. 681 const cols=['#34d399','#fbbf24','#fb7185','#60a5fa','#a78bfa','#f97316'], P=[];
  682. 682 for(let i=0;i<150;i++) P.push({ x:innerWidth/2+(Math.random()-.5)*innerWidth*.4, y:innerHeight*.35, vx:(Math.random()-.5)*14, vy:-Math.random()*14-4, g:.32+Math.random()*.15, s:6+Math.random()*7, r:Math.random()*6, vr:(Math.random()-.5)*.4, c:cols[i%cols.length] });
  683. 683 const t0=performance.now();
  684. 684 (function f(){
  685. 685 const el=performance.now()-t0;
  686. 686 ctx.clearRect(0,0,cv.width,cv.height);
  687. 687 for(const p of P){
  688. 688 p.vy+=p.g; p.x+=p.vx; p.y+=p.vy; p.vx*=.995; p.r+=p.vr;
  689. 689 ctx.save(); ctx.translate(p.x,p.y); ctx.rotate(p.r); ctx.fillStyle=p.c; ctx.globalAlpha=Math.max(0,1-el/3200);
  690. 690 ctx.fillRect(-p.s/2,-p.s/3,p.s,p.s*.6); ctx.restore();
  691. 691 }
  692. 692 if(el<3200) requestAnimationFrame(f); else { ctx.clearRect(0,0,cv.width,cv.height); cv.classList.add('hidden'); }
  693. 693 })();
  694. 694}
  695. 695function shake(){ if(!meView) return; const el=meView.el; el.classList.remove('shake'); void el.offsetWidth; el.classList.add('shake'); }
  696. 696
  697. 697/* ikon sel */
  698. 698const ICON_FLAG = '<svg viewBox="0 0 24 24"><path d="M7 21V3" stroke="#64748b" stroke-width="2.4" stroke-linecap="round" fill="none"/><path d="M7 3.5h12l-3.2 5 3.2 5H7z" fill="#f43f5e"/></svg>';
  699. 699const ICON_MINE = '<svg class="mine-ico" viewBox="0 0 24 24"><g stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 2.5v3.5M12 18v3.5M2.5 12H6M18 12h3.5M5.3 5.3l2.5 2.5M16.2 16.2l2.5 2.5M18.7 5.3l-2.5 2.5M7.8 16.2l-2.5 2.5"/></g><circle cx="12" cy="12" r="5.6" fill="currentColor"/><circle cx="10.2" cy="10.2" r="1.5" fill="#fff" opacity=".75"/></svg>';
  700. 700
  701. 701/* ===================== STATE PERMAINAN ===================== */
  702. 702let G = null, meView = null, botView = null, newBest=false;
  703. 703
  704. 704function buildNb(r,c){
  705. 705 const nb = new Array(r*c);
  706. 706 for(let y=0;y<r;y++) for(let x=0;x<c;x++){
  707. 707 const a=[];
  708. 708 for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
  709. 709 if(!dy && !dx) continue;
  710. 710 const yy=y+dy, xx=x+dx;
  711. 711 if(yy>=0 && yy<r && xx>=0 && xx<c) a.push(yy*c+xx);
  712. 712 }
  713. 713 nb[y*c+x]=a;
  714. 714 }
  715. 715 return nb;
  716. 716}
  717. 717function makeBoard(n, mines){ return { rev:new Uint8Array(n), flag:new Uint8Array(n), left:n-mines, flags:0, state:'play', boom:-1 }; }
  718. 718function computeCounts(){
  719. 719 const cnt = new Uint8Array(G.n);
  720. 720 for(let i=0;i<G.n;i++){
  721. 721 if(G.mm[i]) continue;
  722. 722 let k=0; for(const j of G.nb[i]) if(G.mm[j]) k++;
  723. 723 cnt[i]=k;
  724. 724 }
  725. 725 G.cnt = cnt;
  726. 726}
  727. 727function placeMines(avoid, rand){
  728. 728 rand = rand || Math.random;
  729. 729 const cand=[]; for(let i=0;i<G.n;i++) if(!avoid.has(i)) cand.push(i);
  730. 730 const mm = new Uint8Array(G.n);
  731. 731 const m = Math.min(G.mines, cand.length);
  732. 732 for(let k=0;k<m;k++){
  733. 733 const j = k + Math.floor(rand()*(cand.length-k));
  734. 734 const t=cand[k]; cand[k]=cand[j]; cand[j]=t;
  735. 735 mm[cand[k]]=1;
  736. 736 }
  737. 737 G.mm = mm; computeCounts();
  738. 738}
  739. 739
  740. 740/* aturan main */
  741. 741function reveal(b, i){
  742. 742 const ch=[];
  743. 743 if(b.rev[i] || b.flag[i] || b.state!=='play') return ch;
  744. 744 if(G.mm[i]){ b.rev[i]=1; b.state='lost'; b.boom=i; ch.push(i); return ch; }
  745. 745 b.rev[i]=1; b.left--; ch.push(i);
  746. 746 if(G.cnt[i]===0){
  747. 747 const st=[i];
  748. 748 while(st.length){
  749. 749 const u=st.pop();
  750. 750 for(const v of G.nb[u]){
  751. 751 if(b.rev[v] || b.flag[v]) continue;
  752. 752 b.rev[v]=1; b.left--; ch.push(v);
  753. 753 if(G.cnt[v]===0) st.push(v);
  754. 754 }
  755. 755 }
  756. 756 }
  757. 757 if(b.left===0) b.state='won';
  758. 758 return ch;
  759. 759}
  760. 760function toggleFlag(b, i){
  761. 761 if(b.rev[i]) return false;
  762. 762 if(b.flag[i]){ b.flag[i]=0; b.flags--; } else { b.flag[i]=1; b.flags++; }
  763. 763 return true;
  764. 764}
  765. 765function chord(b, i){
  766. 766 const ch=[];
  767. 767 if(!b.rev[i] || !G.cnt[i]) return ch;
  768. 768 let f=0; for(const j of G.nb[i]) if(b.flag[j]) f++;
  769. 769 if(f!==G.cnt[i]) return ch;
  770. 770 for(const j of G.nb[i]){
  771. 771 if(b.rev[j] || b.flag[j]) continue;
  772. 772 ch.push(...reveal(b,j));
  773. 773 if(b.state!=='play') break;
  774. 774 }
  775. 775 return ch;
  776. 776}
  777. 777function finalizeWin(b){
  778. 778 const ch=[];
  779. 779 for(let i=0;i<G.n;i++) if(G.mm[i] && !b.flag[i]){ b.flag[i]=1; b.flags++; ch.push(i); }
  780. 780 return ch;
  781. 781}
  782. 782/* mode nyawa: kena ranjau mengurangi nyawa, ranjau otomatis diberi bendera */
  783. 783function resolveLives(b, ch){
  784. 784 if(G.lives==null || b.state!=='lost') return false;
  785. 785 if(G.lives>1){
  786. 786 const bi=b.boom;
  787. 787 b.rev[bi]=0; b.state='play'; b.boom=-1; b.flag[bi]=1; b.flags++; G.lives--;
  788. 788 if(!ch.includes(bi)) ch.push(bi);
  789. 789 return true;
  790. 790 }
  791. 791 G.lives=0; return false;
  792. 792}
  793. 793
  794. 794/* ===================== TAMPILAN PAPAN ===================== */
  795. 795function createView(el, mini){
  796. 796 el.innerHTML='';
  797. 797 el.style.setProperty('--c', G.c);
  798. 798 const v = { el, mini, cells:[], keys:[] };
  799. 799 const frag = document.createDocumentFragment();
  800. 800 for(let i=0;i<G.n;i++){
  801. 801 const d=document.createElement('div'); d.className='cell'; d.dataset.i=i;
  802. 802 frag.appendChild(d); v.cells.push(d); v.keys.push('');
  803. 803 }
  804. 804 el.appendChild(frag);
  805. 805 el.classList.remove('won','shake');
  806. 806 return v;
  807. 807}
  808. 808function paint(v, b, i){
  809. 809 let cls='cell', html='';
  810. 810 const rev=b.rev[i], fl=b.flag[i], mine=G.mm && G.mm[i];
  811. 811 if(rev){
  812. 812 if(mine){ cls+=' r x'; html=ICON_MINE; }
  813. 813 else { cls+=' r'; const c=G.cnt[i]; if(c){ cls+=' n'+c; html=v.mini?'':c; } }
  814. 814 } else if(fl){
  815. 815 if(b.state==='lost' && !mine){ cls+=' f w'; html=v.mini?'':ICON_FLAG; }
  816. 816 else { cls+=' f'; html=v.mini?'':ICON_FLAG; }
  817. 817 } else if(b.state==='lost' && mine){ cls+=' m'; html=v.mini?'':ICON_MINE; }
  818. 818 const key=cls+'|'+html;
  819. 819 if(v.keys[i]===key) return;
  820. 820 v.keys[i]=key; const el=v.cells[i]; el.className=cls; el.innerHTML=html;
  821. 821}
  822. 822function paintList(v,b,list){ for(const i of list) paint(v,b,i); }
  823. 823function paintAll(v,b){
  824. 824 v.el.classList.add('static');
  825. 825 for(let i=0;i<G.n;i++) paint(v,b,i);
  826. 826 requestAnimationFrame(()=>v.el.classList.remove('static'));
  827. 827}
  828. 828function fit(){
  829. 829 if(!G || !meView) return;
  830. 830 const W=$('#meWrap').clientWidth;
  831. 831 if(W>0){
  832. 832 let s=Math.floor((W-12-(G.c-1)*2)/G.c);
  833. 833 meView.el.style.setProperty('--s', clamp(s,20,38)+'px');
  834. 834 }
  835. 835 if(G.vsBot && botView){
  836. 836 const w=$('#botWrap').clientWidth;
  837. 837 if(w>0){
  838. 838 let m=Math.floor((w-8-(G.c-1))/G.c);
  839. 839 botView.el.style.setProperty('--s', clamp(m,5,14)+'px');
  840. 840 }
  841. 841 }
  842. 842}
  843. 843
  844. 844/* ===================== HUD ===================== */
  845. 845let lastTimerTxt='';
  846. 846function updateTimer(){
  847. 847 const t=fmt(G.elapsed);
  848. 848 if(t!==lastTimerTxt){ $('#timer').textContent=t; lastTimerTxt=t; }
  849. 849}
  850. 850const pct = b => Math.round((G.n-G.mines-b.left)/(G.n-G.mines)*100);
  851. 851function updateInfo(){
  852. 852 const el=$('#infoRow'); let h='';
  853. 853 if(G.replay) h+='<span class="ipill">Putar ulang</span>';
  854. 854 if(G.daily) h+='<span class="ipill accent">Tantangan harian</span>';
  855. 855 else if(G.code) h+='<button class="ipill code" id="copyCode" title="Salin kode">Kode '+G.code+'</button>';
  856. 856 if(G.livesMax){
  857. 857 let hs=''; for(let k=0;k<G.livesMax;k++) hs+='<i class="'+(k<G.lives?'':'off')+'">♥</i>';
  858. 858 h+='<span class="ipill hearts" aria-label="Nyawa '+G.lives+'">'+hs+'</span>';
  859. 859 }
  860. 860 if(!G.vsBot && !G.replay) h+='<span class="ipill">Petunjuk '+G.hints+'/3</span>';
  861. 861 el.innerHTML=h; el.classList.toggle('hidden',!h);
  862. 862}
  863. 863function updateHud(){
  864. 864 if(!G) return;
  865. 865 $('#minesLeft').textContent = G.mines - G.me.flags;
  866. 866 let face='🙂';
  867. 867 if(G.replay) face='🎬';
  868. 868 else if(G.status==='over') face = G.result==='win' ? '😎' : '😵';
  869. 869 $('#faceBtn').textContent = face;
  870. 870 $('#meInfo').textContent = pct(G.me)+'% terbuka';
  871. 871 const over = G.status==='over';
  872. 872 $('#saveLbl').textContent = over ? 'Bagikan' : 'Simpan';
  873. 873 $('#icSave').classList.toggle('hidden', over);
  874. 874 $('#icShare').classList.toggle('hidden', !over);
  875. 875 updateTimer(); updateDuel(); updateInfo();
  876. 876}
  877. 877function updateDuel(){
  878. 878 if(!G || !G.vsBot) return;
  879. 879 const pm=pct(G.me), pb=pct(G.bot);
  880. 880 $('#barMe').style.width=pm+'%'; $('#pctMe').textContent=pm+'%';
  881. 881 $('#barBot').style.width=pb+'%'; $('#pctBot').textContent=pb+'%';
  882. 882 let note='Fokus';
  883. 883 if(G.bot.state==='lost') note='Meledak';
  884. 884 else if(G.bot.state==='won') note='Selesai';
  885. 885 else if(G.botNote) note=G.botNote;
  886. 886 $('#botInfo').textContent=note;
  887. 887}
  888. 888
  889. 889/* overlay */
  890. 890function showOverlay(html){ const o=$('#overlay'); o.innerHTML=html; o.classList.remove('hidden'); }
  891. 891function hideOverlay(){ $('#overlay').classList.add('hidden'); }
  892. 892function showPause(title, text){
  893. 893 showOverlay('<div><h3>'+title+'</h3><p>'+text+'</p><button class="btn primary" id="ovResume">Lanjutkan</button></div>');
  894. 894}
  895. 895function setPaused(p){
  896. 896 if(!G || G.status!=='play') return;
  897. 897 G.paused=p;
  898. 898 if(p) showPause('Permainan dijeda','Papan disembunyikan selama jeda.'); else hideOverlay();
  899. 899}
  900. 900function pauseToggle(){
  901. 901 if(!G) return;
  902. 902 if(G.replay){ replayToggle(); return; }
  903. 903 if(G.status==='play') setPaused(!G.paused);
  904. 904}
  905. 905
  906. 906/* ===================== MEMULAI PERMAINAN ===================== */
  907. 907function cfgFromSettings(){
  908. 908 const raw=(S.code||'').trim();
  909. 909 const pc = raw ? parseCode(raw) : null;
  910. 910 if(raw && !pc){ toast('Kode papan tidak valid. Contoh: S-K7Q2M'); return null; }
  911. 911 let diff=S.diff, custom=S.custom;
  912. 912 if(pc){ diff=pc.diff; if(pc.custom) custom=pc.custom; }
  913. 913 const d = diff==='custom' ? custom : DIFFS[diff];
  914. 914 let r=d.r, c=d.c;
  915. 915 if(!pc && innerWidth<700 && c>r){ const t=r; r=c; c=t; }
  916. 916 return { r, c, m:d.m, diff, vsBot:S.mode==='bot', botKey:S.bot, seed:pc?pc.seed:null, code:pc?pc.code:'', daily:null, lives:(S.mode==='solo'&&S.lives)?3:null };
  917. 917}
  918. 918function dailyCfg(){
  919. 919 return { r:16, c:16, m:40, diff:'medium', vsBot:false, botKey:S.bot, seed:'DAILY'+dayKey(), code:'', daily:dayKey(), lives:null };
  920. 920}
  921. 921function newGame(cfg){
  922. 922 if(G) clearTimeout(G.cd);
  923. 923 const n=cfg.r*cfg.c;
  924. 924 G = {
  925. 925 cfg, r:cfg.r, c:cfg.c, n, mines:cfg.m, diff:cfg.diff, vsBot:cfg.vsBot, botKey:cfg.botKey,
  926. 926 botDelay:(BOTS[cfg.botKey]||BOTS.normal).ms,
  927. 927 seed:cfg.seed||null, code:cfg.code||'', daily:cfg.daily||null,
  928. 928 lives:cfg.lives||null, livesMax:cfg.lives||0,
  929. 929 nb:buildNb(cfg.r,cfg.c), mm:null, cnt:null,
  930. 930 me:makeBoard(n,cfg.m), bot:cfg.vsBot?makeBoard(n,cfg.m):null,
  931. 931 status:'ready', elapsed:0, botAcc:0, paused:false, flagMode:false, result:null, botQ:[], botNote:'',
  932. 932 bk:new Uint8Array(n), botLast:-1, botCur:-1, botForce:false, botWait:(BOTS[cfg.botKey]||BOTS.normal).ms*1.6,
  933. 933 clicks:0, path:[], botPath:[], ev:[], hints:0, assisted:false, flagActs:0, endedAt:0, o:-1, info:null, replay:false
  934. 934 };
  935. 935 if(G.vsBot || G.seed) openFixed(G.seed ? rng(G.seed) : Math.random);
  936. 936 if(G.vsBot) G.status='countdown';
  937. 937 showScreen('game');
  938. 938 mount();
  939. 939 if(G.vsBot) runCountdown();
  940. 940}
  941. 941/* papan tetap: satu bukaan awal yang sama (duel bot, tantangan harian, kode papan) */
  942. 942function openFixed(rand){
  943. 943 let pick=-1;
  944. 944 for(let attempt=0; attempt<30; attempt++){
  945. 945 placeMines(new Set(), rand);
  946. 946 const zeros=[]; for(let i=0;i<G.n;i++) if(!G.mm[i] && G.cnt[i]===0) zeros.push(i);
  947. 947 const pool = zeros.length ? zeros : [...Array(G.n).keys()].filter(i=>!G.mm[i]);
  948. 948 pick = pool[Math.floor(rand()*pool.length)];
  949. 949 const tmp = makeBoard(G.n,G.mines); reveal(tmp,pick);
  950. 950 if(tmp.left>=Math.min(6,G.n-G.mines-1)) break;
  951. 951 }
  952. 952 G.o=pick;
  953. 953 reveal(G.me,pick); if(G.bot) reveal(G.bot,pick);
  954. 954}
  955. 955function runCountdown(){
  956. 956 let k=3;
  957. 957 showOverlay('<div class="count" id="cnt">3</div>');
  958. 958 const step=()=>{
  959. 959 k--;
  960. 960 const el=$('#cnt'); if(!el) return;
  961. 961 if(k>0){ el.textContent=k; el.style.animation='none'; void el.offsetWidth; el.style.animation=''; G.cd=setTimeout(step,750); }
  962. 962 else { el.textContent='Mulai!'; G.cd=setTimeout(()=>{ hideOverlay(); G.status='play'; },600); }
  963. 963 };
  964. 964 G.cd=setTimeout(step,750);
  965. 965}
  966. 966function mount(){
  967. 967 const bot=G.vsBot;
  968. 968 $('#duel').classList.toggle('hidden',!bot);
  969. 969 $('#botCard').classList.toggle('hidden',!bot);
  970. 970 $('#meHead').classList.toggle('hidden',!bot);
  971. 971 $('#stage').classList.toggle('bot',bot);
  972. 972 $('#dock').classList.toggle('hidden',G.replay);
  973. 973 $('#replayBar').classList.toggle('hidden',!G.replay);
  974. 974 $('#hintBtn').classList.toggle('hidden',bot);
  975. 975 $('#botTitle').textContent='Bot ('+(BOTS[G.botKey]||BOTS.normal).name+')';
  976. 976 meView=createView($('#meBoard'),false);
  977. 977 botView=bot?createView($('#botBoard'),true):null;
  978. 978 paintAll(meView,G.me);
  979. 979 if(botView) paintAll(botView,G.bot);
  980. 980 G.flagMode=false; setFlagMode(false);
  981. 981 if(G.status!=='countdown') hideOverlay();
  982. 982 lastTimerTxt='';
  983. 983 $('#helpText').textContent = G.replay ? 'Ini rekaman kliknya. Atur kecepatan atau jeda lewat tombol di atas.' : (coarse
  984. 984 ? 'Ketuk untuk membuka, tahan untuk memasang bendera. Ketuk angka untuk membuka kotak di sekitarnya.'
  985. 985 : 'Klik kiri untuk membuka, klik kanan untuk bendera. Klik angka untuk membuka kotak di sekitarnya.');
  986. 986 updateHud();
  987. 987 requestAnimationFrame(fit);
  988. 988}
  989. 989function setFlagMode(on){
  990. 990 G.flagMode=on;
  991. 991 $('#flagBtn').setAttribute('aria-pressed', on?'true':'false');
  992. 992}
  993. 993
  994. 994/* ===================== AKSI PEMAIN ===================== */
  995. 995function recEv(code,i){ if(G.ev.length<4500) G.ev.push(Math.round(G.elapsed),code,i); }
  996. 996function act(i, kind){
  997. 997 if(!G || G.replay || G.paused || G.status==='countdown' || G.status==='over') return;
  998. 998 const b=G.me; if(b.state!=='play') return;
  999. 999 let ch=[], clicked=false, code=0;
  1000. 1000 if(kind==='flag'){
  1001. 1001 if(b.rev[i]){ ch=chord(b,i); clicked=ch.length>0; code=2; }
  1002. 1002 else { toggleFlag(b,i); ch=[i]; code=1; if(b.flag[i]) G.flagActs++; }
  1003. 1003 } else {
  1004. 1004 if(b.rev[i]){ ch=chord(b,i); clicked=ch.length>0; code=2; }
  1005. 1005 else {
  1006. 1006 if(b.flag[i]) return;
  1007. 1007 if(G.status==='ready' && !G.mm){
  1008. 1008 const avoid=new Set([i]);
  1009. 1009 if(G.n-9>=G.mines) G.nb[i].forEach(j=>avoid.add(j));
  1010. 1010 placeMines(avoid);
  1011. 1011 }
  1012. 1012 ch=reveal(b,i); clicked=true; code=0;
  1013. 1013 }
  1014. 1014 }
  1015. 1015 if(clicked && G.status==='ready') G.status='play';
  1016. 1016 if(clicked){ G.clicks++; if(G.path.length<3000) G.path.push(i); }
  1017. 1017 if(clicked || code===1) recEv(code,i);
  1018. 1018 let saved=false;
  1019. 1019 if(b.state==='lost') saved=resolveLives(b,ch);
  1020. 1020 if(b.state==='won') ch=ch.concat(finalizeWin(b));
  1021. 1021 paintList(meView,b,ch);
  1022. 1022 if(b.state==='lost') paintAll(meView,b);
  1023. 1023 if(b.state==='won') meView.el.classList.add('won');
  1024. 1024 // suara dan efek
  1025. 1025 if(code===1) sfx.flag();
  1026. 1026 else if(saved){ sfx.boom(); shake(); vib([60,40,120]); toast('Kena ranjau! Sisa nyawa: '+G.lives); }
  1027. 1027 else if(b.state==='lost'){ sfx.boom(); shake(); vib([80,40,160]); }
  1028. 1028 else if(b.state==='play' && clicked) sfx.open(ch.length);
  1029. 1029 updateHud();
  1030. 1030 checkEnd();
  1031. 1031}
  1032. 1032
  1033. 1033/* ===================== PENYELESAI (untuk bot dan petunjuk) ===================== */
  1034. 1034function solveCore(b, bk, useSubset){
  1035. 1035 const { nb, cnt, n } = G;
  1036. 1036 const isMine = j => b.flag[j] || bk[j];
  1037. 1037 const safe=new Set();
  1038. 1038 let cons=[], progress=true, guard=0;
  1039. 1039 while(progress && guard++<60){
  1040. 1040 progress=false; cons=[];
  1041. 1041 for(let i=0;i<n;i++){
  1042. 1042 if(!b.rev[i] || G.mm[i]) continue;
  1043. 1043 const cn=cnt[i]; if(!cn) continue;
  1044. 1044 let f=0; const unk=[];
  1045. 1045 for(const j of nb[i]){ if(isMine(j)) f++; else if(!b.rev[j] && !safe.has(j)) unk.push(j); }
  1046. 1046 if(!unk.length) continue;
  1047. 1047 const need=cn-f;
  1048. 1048 if(need===0){ for(const j of unk) safe.add(j); progress=true; }
  1049. 1049 else if(need===unk.length){ for(const j of unk) bk[j]=1; progress=true; }
  1050. 1050 else if(need>0) cons.push({unk,need});
  1051. 1051 }
  1052. 1052 if(progress) continue;
  1053. 1053 if(!useSubset) break;
  1054. 1054 const byCell=new Map();
  1055. 1055 cons.forEach((k,idx)=>{ for(const j of k.unk){ if(!byCell.has(j)) byCell.set(j,[]); byCell.get(j).push(idx); } });
  1056. 1056 for(let a=0;a<cons.length && !progress;a++){
  1057. 1057 const A=cons[a], cand=new Set();
  1058. 1058 for(const j of A.unk) for(const idx of byCell.get(j)) if(idx!==a) cand.add(idx);
  1059. 1059 const setA=new Set(A.unk);
  1060. 1060 for(const bi of cand){
  1061. 1061 const B=cons[bi];
  1062. 1062 if(B.unk.length<=A.unk.length) continue;
  1063. 1063 const setB=new Set(B.unk);
  1064. 1064 if(!A.unk.every(j=>setB.has(j))) continue;
  1065. 1065 const diff=B.unk.filter(j=>!setA.has(j)), nd=B.need-A.need;
  1066. 1066 if(nd===0){ diff.forEach(j=>safe.add(j)); progress=true; }
  1067. 1067 else if(nd===diff.length){ diff.forEach(j=>{ bk[j]=1; }); progress=true; }
  1068. 1068 if(progress) break;
  1069. 1069 }
  1070. 1070 }
  1071. 1071 }
  1072. 1072 return { safe, cons, isMine };
  1073. 1073}
  1074. 1074function pickGuess(b, cons, isMine, noise){
  1075. 1075 const n=G.n, U=[]; let known=0;
  1076. 1076 for(let i=0;i<n;i++){ if(isMine(i)) known++; if(!b.rev[i] && !isMine(i)) U.push(i); }
  1077. 1077 if(!U.length) return -1;
  1078. 1078 const prob=new Map();
  1079. 1079 for(const k of cons){ const p=k.need/k.unk.length; for(const j of k.unk) prob.set(j, Math.max(prob.get(j)||0, p)); }
  1080. 1080 const dens=Math.max(0,G.mines-known)/U.length;
  1081. 1081 let bestJ=-1, bestP=9;
  1082. 1082 for(const [j,p] of prob){ const q=p+(Math.random()-.5)*noise; if(q<bestP){ bestP=q; bestJ=j; } }
  1083. 1083 const others=U.filter(j=>!prob.has(j));
  1084. 1084 if(others.length && (bestJ<0 || dens+(Math.random()-.5)*noise<bestP)) return rnd(others);
  1085. 1085 return bestJ>=0 ? bestJ : rnd(U);
  1086. 1086}
  1087. 1087
  1088. 1088/* ===================== PETUNJUK ===================== */
  1089. 1089function doHint(){
  1090. 1090 if(!G || G.replay) return;
  1091. 1091 if(G.vsBot){ toast('Petunjuk tidak tersedia saat lawan bot.'); return; }
  1092. 1092 if(G.status!=='play' || G.paused || G.me.state!=='play'){ toast('Buka satu kotak dulu, baru petunjuk bisa dipakai.'); return; }
  1093. 1093 if(G.hints>=3){ toast('Petunjuk habis. Maksimal 3 per game.'); return; }
  1094. 1094 const b=G.me, n=G.n;
  1095. 1095 const pseudo={ rev:b.rev, flag:new Uint8Array(n) }; // bendera pemain diabaikan karena bisa salah
  1096. 1096 const bk=new Uint8Array(n);
  1097. 1097 const core=solveCore(pseudo,bk,true);
  1098. 1098 let target=-1, msg;
  1099. 1099 if(core.safe.size){ const arr=[...core.safe]; target=rnd(arr); msg='Kotak yang berkedip pasti aman. Waktu +10 dtk.'; }
  1100. 1100 else { target=pickGuess(pseudo,core.cons,core.isMine,0); msg='Tidak ada langkah pasti. Kotak berkedip adalah tebakan paling aman. Waktu +10 dtk.'; }
  1101. 1101 if(target<0) return;
  1102. 1102 G.hints++; G.assisted=true; G.elapsed+=10000;
  1103. 1103 const el=meView.cells[target]; el.classList.add('hint');
  1104. 1104 setTimeout(()=>el.classList.remove('hint'),4500);
  1105. 1105 sfx.hint(); toast(msg); updateHud();
  1106. 1106}
  1107. 1107
  1108. 1108/* ===================== BOT (mirip manusia) ===================== */
  1109. 1109const GUESS_LINES=['Hmm, nebak dulu ya…','Bismillah…','Coba yang ini deh','Feeling aja nih'];
  1110. 1110const IDLE_LINES=['Hmm…','Lihat dulu…','Bentar, mikir…'];
  1111. 1111function pickNear(list){
  1112. 1112 if(list.length===1) return list[0];
  1113. 1113 if(G.botLast<0 || Math.random()<0.22) return rnd(list);
  1114. 1114 const ly=Math.floor(G.botLast/G.c), lx=G.botLast%G.c;
  1115. 1115 let best=list[0], bd=1e9;
  1116. 1116 for(const j of list){
  1117. 1117 const dy=Math.floor(j/G.c)-ly, dx=(j%G.c)-lx;
  1118. 1118 const d=dy*dy+dx*dx+Math.random()*6;
  1119. 1119 if(d<bd){ bd=d; best=j; }
  1120. 1120 }
  1121. 1121 return best;
  1122. 1122}
  1123. 1123function botWaitFor(kind, n){
  1124. 1124 let f=0.55+Math.random()*0.9;
  1125. 1125 if(kind==='guess') f+=1+Math.random()*1.4;
  1126. 1126 else if(kind==='flag') f*=0.5;
  1127. 1127 else if(kind==='chain') f*=0.8;
  1128. 1128 else if(kind==='oops') f+=0.8+Math.random();
  1129. 1129 if(n>8) f+=Math.min(1.4,n/35);
  1130. 1130 if(Math.random()<0.07) f+=1.5+Math.random()*2;
  1131. 1131 return G.botDelay*f;
  1132. 1132}
  1133. 1133function moveCursor(i){
  1134. 1134 if(!botView) return;
  1135. 1135 if(G.botCur>=0 && botView.cells[G.botCur]) botView.cells[G.botCur].classList.remove('last');
  1136. 1136 G.botCur=i;
  1137. 1137 if(i>=0) botView.cells[i].classList.add('last');
  1138. 1138}
  1139. 1139function botSolve(b, sk){
  1140. 1140 const useSubset = G.botForce || Math.random() < sk.subset;
  1141. 1141 G.botForce=false;
  1142. 1142 const core=solveCore(b,G.bk,useSubset);
  1143. 1143 if(core.safe.size){
  1144. 1144 let list=[...core.safe];
  1145. 1145 if(list.length>1) list=list.filter(()=>Math.random()>=sk.overlook); // manusia kadang terlewat
  1146. 1146 return { safe:list, guess:-1 };
  1147. 1147 }
  1148. 1148 if(!useSubset) return { safe:[], guess:-1, retry:true }; // lihat lagi sebelum menebak
  1149. 1149 return { safe:[], guess:pickGuess(b,core.cons,core.isMine,0.07) };
  1150. 1150}
  1151. 1151function botStep(){
  1152. 1152 const b=G.bot;
  1153. 1153 if(b.state!=='play') return;
  1154. 1154 const sk=BOTS[G.botKey]||BOTS.normal;
  1155. 1155 G.botWait=G.botDelay; G.botNote='';
  1156. 1156 const pend=[];
  1157. 1157 for(let i=0;i<G.n;i++) if(G.bk[i] && !b.flag[i] && !b.rev[i]) pend.push(i);
  1158. 1158 if(pend.length && Math.random()<sk.flag){
  1159. 1159 const j=pickNear(pend);
  1160. 1160 b.flag[j]=1; b.flags++;
  1161. 1161 paint(botView,b,j); moveCursor(j);
  1162. 1162 G.botLast=j; G.botNote='Pasang bendera'; G.botWait=botWaitFor('flag',0);
  1163. 1163 updateDuel(); return;
  1164. 1164 }
  1165. 1165 G.botQ=G.botQ.filter(c=>!b.rev[c] && !b.flag[c]);
  1166. 1166 const queued=G.botQ.length>0;
  1167. 1167 let target=-1, kind=queued?'chain':'open';
  1168. 1168 if(!queued){
  1169. 1169 const res=botSolve(b,sk);
  1170. 1170 if(res.safe.length) G.botQ=res.safe.slice();
  1171. 1171 else if(res.guess>=0){ target=res.guess; kind='guess'; G.botNote=rnd(GUESS_LINES); }
  1172. 1172 else {
  1173. 1173 if(res.retry) G.botForce=true;
  1174. 1174 G.botNote=rnd(IDLE_LINES); G.botWait=botWaitFor('think',0)*(res.retry?1.1:1); updateDuel(); return;
  1175. 1175 }
  1176. 1176 }
  1177. 1177 if(target<0){ target=pickNear(G.botQ); G.botQ.splice(G.botQ.indexOf(target),1); }
  1178. 1178 if(kind!=='guess' && Math.random()<sk.mis){
  1179. 1179 const opts=G.nb[target].filter(j=>!b.rev[j] && !b.flag[j]);
  1180. 1180 if(opts.length){ target=rnd(opts); kind='oops'; G.botNote='Ups, salah klik!'; }
  1181. 1181 }
  1182. 1182 let ch=reveal(b,target);
  1183. 1183 if(b.state==='won') ch=ch.concat(finalizeWin(b));
  1184. 1184 if(G.botPath.length<3000) G.botPath.push(target);
  1185. 1185 paintList(botView,b,ch);
  1186. 1186 G.botLast=target; moveCursor(target);
  1187. 1187 G.botWait=botWaitFor(kind,ch.length);
  1188. 1188 if(b.state==='lost'){
  1189. 1189 paintAll(botView,b);
  1190. 1190 G.botNote='Meledak';
  1191. 1191 toast('Bot menginjak ranjau! Selesaikan papanmu untuk menang.');
  1192. 1192 }
  1193. 1193 if(b.state==='won') botView.el.classList.add('won');
  1194. 1194 updateDuel();
  1195. 1195 checkEnd();
  1196. 1196}
  1197. 1197
  1198. 1198/* ===================== AKHIR PERMAINAN ===================== */
  1199. 1199const levelOf = xp => Math.floor(Math.sqrt(xp/40))+1;
  1200. 1200const xpAt = l => 40*(l-1)*(l-1);
  1201. 1201const titleOf = l => TITLES[Math.min(TITLES.length-1, Math.floor((l-1)/2))];
  1202. 1202function curStreak(){ const d=ST.daily; return (d.last===dayKey()||d.last===yesterdayKey()) ? d.streak : 0; }
  1203. 1203
  1204. 1204function checkEnd(){
  1205. 1205 if(!G || G.status==='over') return;
  1206. 1206 const me=G.me, bot=G.bot;
  1207. 1207 if(me.state==='won') end('win', bot && bot.state==='lost' ? 'botboom' : 'race');
  1208. 1208 else if(me.state==='lost') end(bot && bot.state==='lost' ? 'draw' : 'lose', 'boom');
  1209. 1209 else if(bot && bot.state==='won') end('lose','bot');
  1210. 1210}
  1211. 1211function end(res, why){
  1212. 1212 G.status='over'; G.result=res; G.paused=false; G.endedAt=Date.now();
  1213. 1213 store.del(K_SAVE);
  1214. 1214 const info={ xp:0, lvlUp:false, ach:[], daily:false };
  1215. 1215 G.info=info; newBest=false;
  1216. 1216 const lvBefore=levelOf(ST.xp), win=res==='win';
  1217. 1217 const unlock=id=>{ if(!ST.ach[id]){ ST.ach[id]=Date.now(); info.ach.push(ACH.find(a=>a.id===id)); } };
  1218. 1218
  1219. 1219 if(!G.vsBot){
  1220. 1220 ST.played++;
  1221. 1221 if(win){
  1222. 1222 ST.won++;
  1223. 1223 const classic = !G.assisted && !G.seed && G.lives==null && G.diff!=='custom';
  1224. 1224 if(classic){
  1225. 1225 const cur=ST.best[G.diff], t=Math.round(G.elapsed);
  1226. 1226 if(cur==null || t<cur){ ST.best[G.diff]=t; newBest=true; }
  1227. 1227 ST.top[G.diff]=ST.top[G.diff].concat(t).sort((a,b)=>a-b).slice(0,3);
  1228. 1228 }
  1229. 1229 }
  1230. 1230 } else {
  1231. 1231 if(win) ST.vs.w++; else if(res==='lose') ST.vs.l++; else ST.vs.d++;
  1232. 1232 }
  1233. 1233 if(!win) ST.lost++;
  1234. 1234
  1235. 1235 if(win && G.daily){
  1236. 1236 const dl=ST.daily;
  1237. 1237 if(!dl.done[G.daily]){
  1238. 1238 dl.streak = (dl.last===yesterdayKey()) ? dl.streak+1 : 1;
  1239. 1239 dl.last=G.daily; info.daily=true;
  1240. 1240 }
  1241. 1241 const t=Math.round(G.elapsed);
  1242. 1242 if(dl.done[G.daily]==null || t<dl.done[G.daily]) dl.done[G.daily]=t;
  1243. 1243 const ks=Object.keys(dl.done).sort(); while(ks.length>60) delete dl.done[ks.shift()];
  1244. 1244 }
  1245. 1245
  1246. 1246 // XP
  1247. 1247 const base={easy:20,medium:50,hard:120,custom:30}[G.diff]||30;
  1248. 1248 let xp = win ? base : 8;
  1249. 1249 if(G.vsBot && win) xp=Math.round(xp*1.5);
  1250. 1250 if(G.daily && win) xp+=30;
  1251. 1251 if(G.assisted) xp=Math.round(xp*0.5);
  1252. 1252 if(G.lives!=null) xp=Math.round(xp*0.7);
  1253. 1253 ST.xp+=xp; info.xp=xp; info.lvlUp = levelOf(ST.xp)>lvBefore;
  1254. 1254
  1255. 1255 // pencapaian
  1256. 1256 if(win){
  1257. 1257 unlock('first_win');
  1258. 1258 if(G.diff==='easy' && !G.assisted && !G.vsBot && G.lives==null && G.elapsed<20000) unlock('easy_fast');
  1259. 1259 if(G.diff==='medium') unlock('medium_win');
  1260. 1260 if(G.diff==='hard') unlock('hard_win');
  1261. 1261 if(G.flagActs===0 && G.mines>=25) unlock('no_flag');
  1262. 1262 if(G.vsBot){ unlock('bot_win'); if(G.botKey==='blitz') unlock('bot_blitz'); if(ST.vs.w>=5) unlock('bot_5'); }
  1263. 1263 if(G.livesMax && G.lives===1) unlock('lives_1');
  1264. 1264 if(G.seed && !G.daily) unlock('seed_win');
  1265. 1265 if(ST.daily.streak>=3 && G.daily) unlock('daily_3');
  1266. 1266 if(ST.daily.streak>=7 && G.daily) unlock('daily_7');
  1267. 1267 if(ST.won>=10) unlock('win_10');
  1268. 1268 } else if(ST.lost>=10) unlock('lose_10');
  1269. 1269 saveStats();
  1270. 1270
  1271. 1271 // riwayat + replay
  1272. 1272 HIST.unshift({
  1273. 1273 ts:G.endedAt, mode:G.vsBot?'bot':'solo', diff:G.diff, r:G.r, c:G.c, m:G.mines, res,
  1274. 1274 ms:Math.round(G.elapsed), clicks:G.clicks, pct:pct(G.me), bpct:G.vsBot?pct(G.bot):null, botKey:G.botKey,
  1275. 1275 code:G.code||'', daily:G.daily?1:0, lives:G.livesMax||0, assisted:G.assisted?1:0,
  1276. 1276 mm:Array.from(G.mm).join(''), o:G.o, ev:G.ev
  1277. 1277 });
  1278. 1278 if(HIST.length>15) HIST.length=15;
  1279. 1279 saveHist();
  1280. 1280
  1281. 1281 // efek
  1282. 1282 if(win){ sfx.win(); confetti(); vib([40,30,40,30,90]); }
  1283. 1283 else if(why==='bot'){ sfx.lose(); }
  1284. 1284 if(info.ach.length) setTimeout(()=>sfx.ach(),900);
  1285. 1285 updateHud();
  1286. 1286 const g=G;
  1287. 1287 setTimeout(()=>{ if(G===g) showResult(res, why); }, 750);
  1288. 1288}
  1289. 1289function showResult(res, why){
  1290. 1290 let icon, title, msg;
  1291. 1291 if(res==='win'){
  1292. 1292 icon='🎉'; title='Kamu menang!';
  1293. 1293 msg = G.vsBot ? (G.bot.state==='lost' ? 'Bot menginjak ranjau, dan kamu menuntaskan papan.' : 'Kamu membuka semua kotak aman lebih cepat dari bot.') : 'Semua kotak aman berhasil dibuka.';
  1294. 1294 } else if(res==='draw'){ icon='💥'; title='Sama-sama meledak'; msg='Kamu dan bot sama-sama menginjak ranjau.'; }
  1295. 1295 else if(why==='bot'){ icon='🤖'; title='Bot lebih cepat'; msg='Bot sudah membuka semua kotak aman lebih dulu.'; }
  1296. 1296 else { icon='💥'; title='Kena ranjau'; msg = G.seed ? 'Papan ini bisa dicoba lagi. Tekan Main lagi untuk mengulang papan yang sama.' : 'Kamu menginjak ranjau. Coba lagi, kamu pasti bisa.'; }
  1297. 1297 let rows='<div><b>'+fmtP(G.elapsed)+'</b><span>Waktu</span></div><div><b>'+pct(G.me)+'%</b><span>Papan terbuka</span></div>';
  1298. 1298 if(!G.vsBot && res==='win' && G.diff!=='custom' && ST.best[G.diff]!=null && !G.seed && G.lives==null){
  1299. 1299 rows+='<div><b>'+fmtP(ST.best[G.diff])+'</b><span>Rekor '+DIFFS[G.diff].name+'</span></div>';
  1300. 1300 if(newBest) rows+='<div><b>Baru!</b><span>Rekor terbaikmu</span></div>';
  1301. 1301 }
  1302. 1302 if(G.vsBot) rows+='<div><b>'+pct(G.bot)+'%</b><span>Papan bot</span></div>';
  1303. 1303 const info=G.info; let extra='';
  1304. 1304 if(info.xp){
  1305. 1305 const L=levelOf(ST.xp);
  1306. 1306 extra+='<div class="xp-line">+'+info.xp+' XP'+(info.lvlUp?', naik ke level '+L+' ('+titleOf(L)+')':'')+'</div>';
  1307. 1307 }
  1308. 1308 if(info.daily) extra+='<div class="xp-line">Tantangan harian selesai. Runtutan '+ST.daily.streak+' hari.</div>';
  1309. 1309 if(info.ach.length) extra+='<div class="ach-line">'+info.ach.map(a=>'<span>'+a.ic+' '+a.n+'</span>').join('')+'</div>';
  1310. 1310 $('#modalBox').innerHTML='<div class="big-ic">'+icon+'</div><h3>'+title+'</h3><p>'+msg+'</p><div class="res">'+rows+'</div>'+extra+
  1311. 1311 '<div class="btns"><button class="btn primary" id="mAgain">Main lagi</button><button class="btn" id="mShare">Bagikan sebagai gambar</button><button class="btn" id="mMenu">Kembali ke menu</button><button class="btn ghost" id="mView">Lihat papan</button></div>';
  1312. 1312 $('#modal').classList.remove('hidden');
  1313. 1313}
  1314. 1314function closeModal(){ $('#modal').classList.add('hidden'); }
  1315. 1315
  1316. 1316/* ===================== REPLAY ===================== */
  1317. 1317function startReplay(idx){
  1318. 1318 const h=HIST[idx]; if(!h || !h.mm || !h.ev){ toast('Rekaman tidak tersedia untuk game ini.'); return; }
  1319. 1319 if(G) clearTimeout(G.cd);
  1320. 1320 const n=h.r*h.c, mm=Uint8Array.from(h.mm,ch=>+ch);
  1321. 1321 G = {
  1322. 1322 cfg:null, r:h.r, c:h.c, n, mines:h.m, diff:h.diff, vsBot:false, botKey:'normal', botDelay:800,
  1323. 1323 seed:null, code:h.code||'', daily:null, lives:h.lives||null, livesMax:h.lives||0,
  1324. 1324 nb:buildNb(h.r,h.c), mm, cnt:null, me:makeBoard(n,h.m), bot:null,
  1325. 1325 status:'replay', elapsed:0, botAcc:0, paused:false, flagMode:false, result:h.res, botQ:[], botNote:'',
  1326. 1326 bk:new Uint8Array(n), botLast:-1, botCur:-1, botForce:false, botWait:800,
  1327. 1327 clicks:0, path:[], botPath:[], ev:[], hints:0, assisted:false, flagActs:0, endedAt:0, o:-1, info:null,
  1328. 1328 replay:true, rev:h.ev, rt:0, rpos:0, rspeed:2, rpaused:false, rdone:false, ridx:idx
  1329. 1329 };
  1330. 1330 computeCounts();
  1331. 1331 if(h.o>=0) reveal(G.me,h.o);
  1332. 1332 showScreen('game');
  1333. 1333 mount();
  1334. 1334 $('#rpSpeed').textContent='2×'; $('#rpPlay').textContent='Jeda'; $('#rpBar').style.width='0%';
  1335. 1335}
  1336. 1336function applyEv(t, code, i){
  1337. 1337 const b=G.me; let ch=[];
  1338. 1338 if(code===1){ toggleFlag(b,i); ch=[i]; }
  1339. 1339 else if(code===2) ch=chord(b,i);
  1340. 1340 else ch=reveal(b,i);
  1341. 1341 if(b.state==='lost') resolveLives(b,ch);
  1342. 1342 if(b.state==='won') ch=ch.concat(finalizeWin(b));
  1343. 1343 paintList(meView,b,ch);
  1344. 1344 if(b.state==='lost') paintAll(meView,b);
  1345. 1345 if(b.state==='won') meView.el.classList.add('won');
  1346. 1346 updateHud();
  1347. 1347}
  1348. 1348function replayTick(dt){
  1349. 1349 const ev=G.rev;
  1350. 1350 G.rt+=dt*G.rspeed;
  1351. 1351 while(G.rpos<ev.length && ev[G.rpos]<=G.rt){ applyEv(ev[G.rpos],ev[G.rpos+1],ev[G.rpos+2]); G.rpos+=3; }
  1352. 1352 if(G.rpos<ev.length){ const lim=1200*G.rspeed; if(ev[G.rpos]-G.rt>lim) G.rt=ev[G.rpos]-lim; }
  1353. 1353 G.elapsed=G.rt; updateTimer();
  1354. 1354 $('#rpBar').style.width=(ev.length?Math.min(100,G.rpos/ev.length*100):100)+'%';
  1355. 1355 if(G.rpos>=ev.length && !G.rdone){ G.rdone=true; G.rpaused=true; $('#rpPlay').textContent='Ulangi'; toast('Putar ulang selesai.'); }
  1356. 1356}
  1357. 1357function replayToggle(){
  1358. 1358 if(!G || !G.replay) return;
  1359. 1359 if(G.rdone){ startReplay(G.ridx); return; }
  1360. 1360 G.rpaused=!G.rpaused; $('#rpPlay').textContent=G.rpaused?'Lanjut':'Jeda';
  1361. 1361}
  1362. 1362
  1363. 1363/* ===================== SIMPAN DAN MUAT ===================== */
  1364. 1364const ser = a => Array.from(a).join('');
  1365. 1365const de = s => Uint8Array.from(s, ch => +ch);
  1366. 1366function serialize(){
  1367. 1367 return {
  1368. 1368 v:2, r:G.r, c:G.c, mines:G.mines, diff:G.diff, vsBot:G.vsBot, botKey:G.botKey,
  1369. 1369 seed:G.seed, code:G.code, daily:G.daily, lives:G.lives, livesMax:G.livesMax, hints:G.hints, assisted:G.assisted, flagActs:G.flagActs,
  1370. 1370 mm:ser(G.mm), elapsed:G.elapsed, ts:Date.now(), path:G.path, clicks:G.clicks, ev:G.ev, o:G.o, bpath:G.botPath,
  1371. 1371 me:{ rev:ser(G.me.rev), flag:ser(G.me.flag), state:G.me.state, boom:G.me.boom },
  1372. 1372 bot:G.bot?{ rev:ser(G.bot.rev), flag:ser(G.bot.flag), state:G.bot.state, boom:G.bot.boom }:null
  1373. 1373 };
  1374. 1374}
  1375. 1375function saveGame(silent){
  1376. 1376 if(!G || G.replay || G.status!=='play' || !G.mm){
  1377. 1377 if(!silent) toast(G && G.status==='over' ? 'Permainan sudah selesai.' : 'Buka satu kotak dulu, baru bisa disimpan.');
  1378. 1378 return false;
  1379. 1379 }
  1380. 1380 const ok=store.set(K_SAVE, serialize());
  1381. 1381 if(!silent) toast(ok ? 'Tersimpan di perangkat ini.' : 'Gagal menyimpan. Penyimpanan browser mungkin nonaktif.');
  1382. 1382 return ok;
  1383. 1383}
  1384. 1384function restoreBoard(s, n, mines, mm){
  1385. 1385 const b=makeBoard(n,mines);
  1386. 1386 b.rev=de(s.rev); b.flag=de(s.flag); b.state=s.state; b.boom=s.boom;
  1387. 1387 let rv=0, fl=0;
  1388. 1388 for(let i=0;i<n;i++){ if(b.rev[i] && !mm[i]) rv++; if(b.flag[i]) fl++; }
  1389. 1389 b.left=n-mines-rv; b.flags=fl;
  1390. 1390 return b;
  1391. 1391}
  1392. 1392function loadGame(){
  1393. 1393 const o=store.get(K_SAVE,null);
  1394. 1394 const n=o ? o.r*o.c : 0;
  1395. 1395 if(!o || !(o.v===1||o.v===2) || !o.mm || o.mm.length!==n || !o.me || o.me.rev.length!==n){
  1396. 1396 store.del(K_SAVE); renderMenu(); toast('Data simpanan tidak valid.'); return;
  1397. 1397 }
  1398. 1398 if(G) clearTimeout(G.cd);
  1399. 1399 const mm=de(o.mm), bm=BOTS[o.botKey]||BOTS.normal;
  1400. 1400 const cfg={ r:o.r, c:o.c, m:o.mines, diff:o.diff, vsBot:!!o.vsBot, botKey:o.botKey, seed:o.seed||null, code:o.code||'', daily:o.daily||null, lives:o.livesMax||null };
  1401. 1401 G = {
  1402. 1402 cfg, r:o.r, c:o.c, n, mines:o.mines, diff:o.diff, vsBot:!!o.vsBot, botKey:o.botKey, botDelay:bm.ms,
  1403. 1403 seed:o.seed||null, code:o.code||'', daily:o.daily||null,
  1404. 1404 lives:o.livesMax?(o.lives==null?o.livesMax:o.lives):null, livesMax:o.livesMax||0,
  1405. 1405 nb:buildNb(o.r,o.c), mm, cnt:null, me:restoreBoard(o.me,n,o.mines,mm), bot:null,
  1406. 1406 status:'play', elapsed:o.elapsed||0, botAcc:0, paused:true, flagMode:false, result:null, botQ:[], botNote:'',
  1407. 1407 bk:new Uint8Array(n), botLast:-1, botCur:-1, botForce:false, botWait:bm.ms,
  1408. 1408 clicks:o.clicks||0, path:Array.isArray(o.path)?o.path.slice(0,3000):[], botPath:Array.isArray(o.bpath)?o.bpath.slice(0,3000):[],
  1409. 1409 ev:Array.isArray(o.ev)?o.ev.slice(0,4500):[], hints:o.hints||0, assisted:!!o.assisted, flagActs:o.flagActs||0,
  1410. 1410 endedAt:0, o:(typeof o.o==='number'?o.o:-1), info:null, replay:false
  1411. 1411 };
  1412. 1412 if(o.vsBot && o.bot) G.bot=restoreBoard(o.bot,n,o.mines,mm); else G.vsBot=false;
  1413. 1413 computeCounts();
  1414. 1414 showScreen('game');
  1415. 1415 mount();
  1416. 1416 showPause('Permainan dimuat','Siap lanjut dari posisi terakhir?');
  1417. 1417}
  1418. 1418
  1419. 1419/* ===================== NAVIGASI ===================== */
  1420. 1420function showScreen(name){
  1421. 1421 document.querySelectorAll('.screen').forEach(s=>s.classList.toggle('active', s.id===name));
  1422. 1422 window.scrollTo({top:0});
  1423. 1423}
  1424. 1424function leaveGame(autosave){
  1425. 1425 if(G){
  1426. 1426 if(autosave && G.status==='play' && G.mm && !G.replay){ if(saveGame(true)) toast('Tersimpan otomatis. Lanjutkan dari menu.'); }
  1427. 1427 clearTimeout(G.cd);
  1428. 1428 }
  1429. 1429 G=null; meView=null; botView=null;
  1430. 1430 closeModal(); closeShare(); hideOverlay();
  1431. 1431 renderMenu(); showScreen('menu');
  1432. 1432}
  1433. 1433function restart(){
  1434. 1434 if(!G) return;
  1435. 1435 closeModal(); closeShare();
  1436. 1436 if(G.replay){ startReplay(G.ridx); return; }
  1437. 1437 newGame(G.cfg);
  1438. 1438}
  1439. 1439
  1440. 1440/* ===================== MENU ===================== */
  1441. 1441function buildChips(){
  1442. 1442 $('#diffChips').innerHTML = Object.keys(DIFFS).map(k=>{
  1443. 1443 const d=DIFFS[k];
  1444. 1444 const sub = k==='custom' ? 'Atur sendiri' : d.r+'×'+d.c+', '+d.m+' ranjau';
  1445. 1445 return '<button class="chip" data-diff="'+k+'"><b>'+d.name+'</b><span>'+sub+'</span></button>';
  1446. 1446 }).join('');
  1447. 1447 $('#botChips').innerHTML = Object.keys(BOTS).map(k=>{
  1448. 1448 const b=BOTS[k];
  1449. 1449 return '<button class="chip" data-bot="'+k+'"><b>'+b.name+'</b><span>'+b.sub+'</span></button>';
  1450. 1450 }).join('');
  1451. 1451 $('#skins').innerHTML = Object.keys(SKINS).map(k=>'<button class="skin" data-skin="'+k+'" aria-pressed="false" aria-label="Warna '+SKINS[k].n+'" style="background:hsl('+SKINS[k].h+',70%,45%)">'+SKINS[k].n+'</button>').join('');
  1452. 1452}
  1453. 1453function renderDaily(){
  1454. 1454 const key=dayKey(), done=ST.daily.done[key], st=curStreak();
  1455. 1455 $('#dailyTxt').textContent = (done!=null
  1456. 1456 ? 'Hari ini selesai dalam '+fmtP(done)+'. '
  1457. 1457 : dayLabel()+': papan yang sama untuk semua pemain, 16×16 dengan 40 ranjau. ') + (st?'Runtutan '+st+' hari.':'Menangkan untuk mulai runtutan.');
  1458. 1458 $('#dailyBtn').textContent = done!=null ? 'Main lagi' : 'Main sekarang';
  1459. 1459}
  1460. 1460function renderStats(){
  1461. 1461 const rows=['easy','medium','hard'].map(k=>{
  1462. 1462 const t=ST.top[k];
  1463. 1463 return '<tr><td>'+DIFFS[k].name+'</td><td>'+(t.length?t.map(fmtP).join(', '):'-')+'</td></tr>';
  1464. 1464 }).join('');
  1465. 1465 $('#statsBox').innerHTML =
  1466. 1466 '<table class="stats"><tr><th>Solo klasik</th><th>3 waktu terbaik</th></tr>'+rows+'</table>'+
  1467. 1467 '<p class="hint">Solo: '+ST.won+' menang dari '+ST.played+' main.</p>'+
  1468. 1468 '<div class="vs-line"><div><b>'+ST.vs.w+'</b><span>Menang lawan bot</span></div><div><b>'+ST.vs.l+'</b><span>Kalah</span></div><div><b>'+ST.vs.d+'</b><span>Seri</span></div></div>';
  1469. 1469}
  1470. 1470function renderProfile(){
  1471. 1471 const L=levelOf(ST.xp), cur=xpAt(L), next=xpAt(L+1);
  1472. 1472 $('#avatar').textContent=(S.name||'P').trim().charAt(0).toUpperCase()||'P';
  1473. 1473 if(document.activeElement!==$('#nameIn')) $('#nameIn').value=S.name;
  1474. 1474 $('#lvlTxt').textContent='Level '+L+', '+titleOf(L);
  1475. 1475 $('#xpTxt').textContent=ST.xp+' XP, '+(next-ST.xp)+' lagi ke level '+(L+1);
  1476. 1476 $('#xpBar').style.width=Math.round((ST.xp-cur)/(next-cur)*100)+'%';
  1477. 1477 const got=ACH.filter(a=>ST.ach[a.id]).length;
  1478. 1478 $('#achCount').textContent=got+'/'+ACH.length;
  1479. 1479 $('#achGrid').innerHTML=ACH.map(a=>'<div class="ach'+(ST.ach[a.id]?' on':'')+'"><div class="aic">'+a.ic+'</div><div><b>'+a.n+'</b><span>'+a.d+'</span></div></div>').join('');
  1480. 1480 document.querySelectorAll('#skins .skin').forEach(b=>b.setAttribute('aria-pressed',b.dataset.skin===S.skin?'true':'false'));
  1481. 1481 document.querySelectorAll('#themeSeg button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.t===S.theme?'true':'false'));
  1482. 1482 document.querySelectorAll('#soundSeg button').forEach(b=>b.setAttribute('aria-pressed',(b.dataset.s==='1')===!!S.sound?'true':'false'));
  1483. 1483 $('#soundBtn').setAttribute('aria-pressed',S.sound?'true':'false');
  1484. 1484 renderStats();
  1485. 1485 renderHist();
  1486. 1486}
  1487. 1487function renderHist(){
  1488. 1488 const el=$('#histList');
  1489. 1489 if(!HIST.length){ el.innerHTML='<p class="hint" style="margin:0">Belum ada game. Selesaikan satu game dan riwayatnya muncul di sini, lengkap dengan rekaman untuk diputar ulang.</p>'; return; }
  1490. 1490 el.innerHTML=HIST.map((h,i)=>{
  1491. 1491 const ic=h.res==='win'?'🏆':h.res==='draw'?'🤝':'💥';
  1492. 1492 const dn=h.diff==='custom'?'Kustom':(DIFFS[h.diff]||{name:'Kustom'}).name;
  1493. 1493 const t=(h.mode==='bot'?'Duel Bot '+(BOTS[h.botKey]||BOTS.normal).name:'Solo')+' '+dn;
  1494. 1494 const when=new Date(h.ts).toLocaleString('id-ID',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'});
  1495. 1495 const tags=[h.daily?'Harian':'', (h.code&&!h.daily)?'Kode '+h.code:'', h.lives?h.lives+' nyawa':'', h.assisted?'Dibantu':''].filter(Boolean).join(', ');
  1496. 1496 return '<div class="hrow"><div class="hic">'+ic+'</div><div class="hmain"><b>'+t+'</b><span>'+fmtP(h.ms)+', '+h.clicks+' klik, '+h.pct+'% terbuka</span><span>'+when+(tags?', '+tags:'')+'</span></div>'+
  1497. 1497 (h.mm&&h.ev?'<button class="btn small" data-replay="'+i+'">Putar ulang</button>':'')+'</div>';
  1498. 1498 }).join('');
  1499. 1499}
  1500. 1500function renderMenu(){
  1501. 1501 document.querySelectorAll('#modeSeg button').forEach(b=>b.setAttribute('aria-pressed', b.dataset.mode===S.mode?'true':'false'));
  1502. 1502 $('#modeHint').textContent = S.mode==='solo'
  1503. 1503 ? 'Main sendiri tanpa batas waktu. Klik pertama selalu aman.'
  1504. 1504 : 'Adu cepat di papan yang sama. Ada hitung mundur dan bukaan awal yang sama untuk kalian berdua.';
  1505. 1505 $('#livesBox').classList.toggle('hidden', S.mode!=='solo');
  1506. 1506 document.querySelectorAll('#livesSeg button').forEach(b=>b.setAttribute('aria-pressed', (b.dataset.lives==='1')===!!S.lives?'true':'false'));
  1507. 1507 const raw=(S.code||'').trim(), pc=raw?parseCode(raw):null;
  1508. 1508 document.querySelectorAll('#diffChips .chip').forEach(b=>b.setAttribute('aria-pressed', b.dataset.diff===(pc?pc.diff:S.diff)?'true':'false'));
  1509. 1509 $('#diffChips').classList.toggle('dim',!!pc);
  1510. 1510 const isCustom=(pc?pc.diff:S.diff)==='custom';
  1511. 1511 $('#customBox').classList.toggle('hidden',!isCustom || !!pc);
  1512. 1512 $('#customHint').classList.toggle('hidden',!isCustom || !!pc);
  1513. 1513 document.querySelectorAll('#botChips .chip').forEach(b=>b.setAttribute('aria-pressed', b.dataset.bot===S.bot?'true':'false'));
  1514. 1514 $('#cR').value=S.custom.r; $('#cC').value=S.custom.c; $('#cM').value=S.custom.m;
  1515. 1515 $('#customHint').textContent='Baris dan kolom 5 sampai 30. Ranjau maksimal '+(S.custom.r*S.custom.c-9)+'.';
  1516. 1516 $('#botCfg').classList.toggle('hidden', S.mode!=='bot');
  1517. 1517 $('#startBtn').textContent = S.mode==='bot' ? 'Mulai lawan bot' : 'Mulai main';
  1518. 1518 if(document.activeElement!==$('#codeIn')) $('#codeIn').value=S.code||'';
  1519. 1519 $('#codeHint').textContent = !raw ? 'Kosongkan untuk papan acak. Kode yang sama menghasilkan papan yang sama, jadi bisa dipakai untuk menantang teman.'
  1520. 1520 : (pc ? 'Kode valid. Tingkat kesulitan mengikuti kode.' : 'Format kode belum valid. Contoh: S-K7Q2M atau H-9XAB2.');
  1521. 1521
  1522. 1522 const o=store.get(K_SAVE,null), rc=$('#resumeCard');
  1523. 1523 if(o && (o.v===1||o.v===2) && o.mm && o.me){
  1524. 1524 const n=o.r*o.c; let rv=0;
  1525. 1525 for(let i=0;i<n;i++) if(o.me.rev[i]==='1' && o.mm[i]==='0') rv++;
  1526. 1526 const p=Math.round(rv/(n-o.mines)*100);
  1527. 1527 const dn = o.diff==='custom' ? 'Kustom' : (DIFFS[o.diff]||{name:'Kustom'}).name;
  1528. 1528 const when = new Date(o.ts).toLocaleString('id-ID',{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'});
  1529. 1529 $('#resumeMeta').textContent = (o.vsBot?'Lawan bot':(o.daily?'Harian':'Solo'))+', '+dn+' '+o.r+'×'+o.c+'. Terbuka '+p+'%, waktu '+fmt(o.elapsed||0)+'. Disimpan '+when+'.';
  1530. 1530 rc.classList.remove('hidden');
  1531. 1531 } else rc.classList.add('hidden');
  1532. 1532 renderDaily();
  1533. 1533 renderProfile();
  1534. 1534}
  1535. 1535function showTab(t){
  1536. 1536 document.querySelectorAll('#tabs button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.tab===t?'true':'false'));
  1537. 1537 $('#paneMain').classList.toggle('hidden',t!=='main');
  1538. 1538 $('#panePro').classList.toggle('hidden',t!=='pro');
  1539. 1539}
  1540. 1540function readCustom(){
  1541. 1541 const r=clamp(parseInt($('#cR').value)||9,5,30);
  1542. 1542 const c=clamp(parseInt($('#cC').value)||9,5,30);
  1543. 1543 const m=clamp(parseInt($('#cM').value)||10,1,r*c-9);
  1544. 1544 S.custom={r,c,m}; saveSettings(); renderMenu();
  1545. 1545}
  1546. 1546
  1547. 1547/* ===================== TEMA DAN WARNA ===================== */
  1548. 1548function applyLook(){
  1549. 1549 const de=document.documentElement;
  1550. 1550 if(S.theme==='light'||S.theme==='dark') de.setAttribute('data-theme',S.theme); else de.removeAttribute('data-theme');
  1551. 1551 if(S.skin && S.skin!=='mint') de.setAttribute('data-skin',S.skin); else de.removeAttribute('data-skin');
  1552. 1552}
  1553. 1553
  1554. 1554/* ===================== BAGIKAN HASIL SEBAGAI GAMBAR ===================== */
  1555. 1555function makePal(style){
  1556. 1556 const h=SKINS[S.skin].h;
  1557. 1557 if(style==='dark') return { bg:`hsl(${h},45%,5.5%)`, panel:`hsl(${h},38%,9%)`, text:`hsl(${h},45%,94%)`, muted:`hsl(${h},20%,62%)`, tile:`hsl(${h},30%,26%)`, rev:`hsl(${h},45%,6.5%)`, line:`hsl(${h},32%,17%)`, accent:`hsl(${h},68%,55%)`, danger:'#f87171', flag:'#fb7185', mine:'#e5e7eb', route:'#ff6a2b', bot:'#60a5fa',
  1558. 1558 n:['','#60a5fa','#4ade80','#f87171','#a78bfa','#fb923c','#22d3ee','#e5e7eb','#9ca3af'] };
  1559. 1559 if(style==='light') return { bg:`hsl(${h},38%,95.5%)`, panel:'#ffffff', text:`hsl(${h},55%,11%)`, muted:`hsl(${h},16%,38%)`, tile:`hsl(${h},50%,82%)`, rev:`hsl(${h},35%,94%)`, line:`hsl(${h},28%,87%)`, accent:`hsl(${h},84%,38%)`, danger:'#ef4444', flag:'#f43f5e', mine:'#1e2a35', route:'#ff5a1f', bot:'#2563eb',
  1560. 1560 n:['','#2563eb','#15803d','#dc2626','#7c3aed','#c2410c','#0e7490','#1f2937','#6b7280'] };
  1561. 1561 return { bg:null, panel:null, text:'#ffffff', muted:'rgba(255,255,255,.85)', tile:'rgba(255,255,255,.32)', rev:'rgba(0,0,0,.36)', line:'rgba(255,255,255,.35)', accent:`hsl(${h},75%,58%)`, danger:'#fb7185', flag:'#fb7185', mine:'#ffffff', route:'#ff6a2b', bot:'#7dd3fc',
  1562. 1562 n:['','#bfdbfe','#bbf7d0','#fecaca','#ddd6fe','#fed7aa','#a5f3fc','#f3f4f6','#e5e7eb'] };
  1563. 1563}
  1564. 1564let SH = { fmt:'feed', style:null, tok:0, blob:null };
  1565. 1565let DL = null;
  1566. 1566try{ if(window.claude && window.claude.use) window.claude.use('downloads').then(d=>{ DL=d; }).catch(()=>{}); }catch(e){}
  1567. 1567
  1568. 1568function rr(ctx,x,y,w,h,r){
  1569. 1569 r=Math.min(r,w/2,h/2);
  1570. 1570 ctx.beginPath(); ctx.moveTo(x+r,y);
  1571. 1571 ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
  1572. 1572 ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r);
  1573. 1573 ctx.closePath();
  1574. 1574}
  1575. 1575async function loadFonts(){
  1576. 1576 try{ await Promise.all(['800 40px "Bricolage Grotesque"','500 30px "Figtree"','700 30px "Figtree"'].map(f=>document.fonts.load(f))); }catch(e){}
  1577. 1577}
  1578. 1578function drawShare(cv, fmtKey, style){
  1579. 1579 const W=1080, H=fmtKey==='story'?1920:1350, m=64;
  1580. 1580 cv.width=W; cv.height=H;
  1581. 1581 const ctx=cv.getContext('2d'), P=makePal(style), clear=style==='clear';
  1582. 1582 const F1='"Bricolage Grotesque","Figtree",system-ui,sans-serif', F2='"Figtree",system-ui,sans-serif';
  1583. 1583 ctx.clearRect(0,0,W,H);
  1584. 1584 if(P.bg){ ctx.fillStyle=P.bg; ctx.fillRect(0,0,W,H); }
  1585. 1585 const txt=(s,x,y,font,color,align)=>{
  1586. 1586 ctx.font=font; ctx.fillStyle=color; ctx.textAlign=align||'left'; ctx.textBaseline='alphabetic';
  1587. 1587 if(clear){ ctx.shadowColor='rgba(0,0,0,.6)'; ctx.shadowBlur=16; ctx.shadowOffsetY=2; }
  1588. 1588 ctx.fillText(s,x,y);
  1589. 1589 ctx.shadowColor='transparent'; ctx.shadowBlur=0; ctx.shadowOffsetY=0;
  1590. 1590 };
  1591. 1591 const chip=(s,rx,cy,bg,fg)=>{
  1592. 1592 ctx.font='700 30px '+F2;
  1593. 1593 const w=ctx.measureText(s).width+52, h=54;
  1594. 1594 ctx.fillStyle=bg; rr(ctx,rx-w,cy-h/2,w,h,h/2); ctx.fill();
  1595. 1595 ctx.fillStyle=fg; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(s,rx-w/2,cy+1);
  1596. 1596 };
  1597. 1597 const info=G.info||{};
  1598. 1598
  1599. 1599 // header
  1600. 1600 ctx.fillStyle=P.accent; rr(ctx,m,64,56,56,16); ctx.fill();
  1601. 1601 const lx=m+28, ly=92; ctx.strokeStyle='#fff'; ctx.lineWidth=4; ctx.lineCap='round';
  1602. 1602 for(let k=0;k<8;k++){ const a=k*Math.PI/4; ctx.beginPath(); ctx.moveTo(lx+Math.cos(a)*14,ly+Math.sin(a)*14); ctx.lineTo(lx+Math.cos(a)*20,ly+Math.sin(a)*20); ctx.stroke(); }
  1603. 1603 ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(lx,ly,10,0,Math.PI*2); ctx.fill();
  1604. 1604 txt('Sapu Ranjau', m+74, 104, '800 38px '+F1, P.text);
  1605. 1605 const res=G.result;
  1606. 1606 if(res==='win') chip('Menang',W-m,92,'#12b981','#03241a');
  1607. 1607 else if(res==='draw') chip('Seri',W-m,92,'#f59e0b','#241500');
  1608. 1608 else chip('Kalah',W-m,92,'#ef4444','#ffffff');
  1609. 1609
  1610. 1610 // judul
  1611. 1611 const dn = G.diff==='custom' ? 'Kustom' : DIFFS[G.diff].name;
  1612. 1612 const bn = (BOTS[G.botKey]||BOTS.normal).name;
  1613. 1613 const ttl = G.daily ? 'Tantangan harian' : (G.vsBot ? 'Duel lawan Bot '+bn : 'Solo '+dn);
  1614. 1614 txt(ttl, m, 214, '800 60px '+F1, P.text);
  1615. 1615 txt(G.r+'×'+G.c+' dengan '+G.mines+' ranjau'+(G.vsBot?', level '+dn:'')+(G.livesMax?', mode 3 nyawa':''), m, 266, '500 32px '+F2, P.muted);
  1616. 1616 let by2='oleh '+(S.name||'Pemain');
  1617. 1617 if(G.daily) by2+=', '+dayLabel(); else if(G.code) by2+=', kode '+G.code;
  1618. 1618 if(G.assisted) by2+=', pakai petunjuk';
  1619. 1619 txt(by2, m, 314, '600 30px '+F2, P.muted);
  1620. 1620
  1621. 1621 // waktu
  1622. 1622 txt('Waktu', m, 400, '600 30px '+F2, P.muted);
  1623. 1623 txt(fmt(G.elapsed), m-6, 566, '800 210px '+F1, P.text);
  1624. 1624 if(!G.vsBot && res==='win' && newBest) chip('Rekor baru',W-m,510,P.route,'#ffffff');
  1625. 1625
  1626. 1626 // peta papan
  1627. 1627 const mapY=610, mapH=H-300-mapY, pad=26;
  1628. 1628 if(P.panel){ ctx.fillStyle=P.panel; rr(ctx,m,mapY,W-2*m,mapH,36); ctx.fill(); }
  1629. 1629 const bx=m+pad, byy=mapY+pad, bw=W-2*m-2*pad, bh=mapH-2*pad;
  1630. 1630 const b=G.me, r=G.r, c=G.c;
  1631. 1631 const s=Math.floor(Math.min(bw/c,bh/r));
  1632. 1632 const g=Math.max(1,Math.round(s*0.09)), cs=s-g;
  1633. 1633 const ox=bx+(bw-c*s+g)/2, oy=byy+(bh-r*s+g)/2;
  1634. 1634 for(let i=0;i<G.n;i++){
  1635. 1635 const x=ox+(i%c)*s, y=oy+Math.floor(i/c)*s;
  1636. 1636 const rev=b.rev[i], fl=b.flag[i], mine=G.mm[i];
  1637. 1637 let fill=P.tile, kind='';
  1638. 1638 if(rev){ if(mine){ fill=P.danger; kind='mine'; } else { fill=P.rev; if(G.cnt[i]) kind='num'; } }
  1639. 1639 else if(fl){ kind='flag'; if(b.state==='lost' && !mine) fill=P.danger; }
  1640. 1640 else if(b.state==='lost' && mine){ fill=P.rev; kind='mine'; }
  1641. 1641 ctx.fillStyle=fill; rr(ctx,x,y,cs,cs,cs*0.24); ctx.fill();
  1642. 1642 if(kind==='num' && cs>=15){
  1643. 1643 ctx.font='800 '+Math.floor(cs*0.62)+'px '+F1; ctx.fillStyle=P.n[G.cnt[i]]; ctx.textAlign='center'; ctx.textBaseline='middle';
  1644. 1644 ctx.fillText(String(G.cnt[i]), x+cs/2, y+cs/2+cs*0.04);
  1645. 1645 } else if(kind==='flag'){
  1646. 1646 const px=x+cs*0.36, top=y+cs*0.2;
  1647. 1647 ctx.strokeStyle=(fill===P.danger)?'#fff':P.muted; ctx.lineWidth=Math.max(1.5,cs*0.08); ctx.lineCap='round';
  1648. 1648 ctx.beginPath(); ctx.moveTo(px,top); ctx.lineTo(px,y+cs*0.8); ctx.stroke();
  1649. 1649 ctx.fillStyle=(fill===P.danger)?'#fff':P.flag;
  1650. 1650 ctx.beginPath(); ctx.moveTo(px,top); ctx.lineTo(px+cs*0.36,top+cs*0.17); ctx.lineTo(px,top+cs*0.34); ctx.closePath(); ctx.fill();
  1651. 1651 } else if(kind==='mine'){
  1652. 1652 ctx.fillStyle=(fill===P.danger)?'#fff':P.mine;
  1653. 1653 ctx.beginPath(); ctx.arc(x+cs/2,y+cs/2,cs*0.26,0,Math.PI*2); ctx.fill();
  1654. 1654 }
  1655. 1655 }
  1656. 1656 const toPts = list => list.map(i=>[ox+(i%c)*s+cs/2, oy+Math.floor(i/c)*s+cs/2]);
  1657. 1657 const trace = pts => { ctx.beginPath(); pts.forEach((p,k)=>k?ctx.lineTo(p[0],p[1]):ctx.moveTo(p[0],p[1])); ctx.stroke(); };
  1658. 1658 ctx.lineJoin='round'; ctx.lineCap='round';
  1659. 1659 // jejak bot (putus-putus, biru)
  1660. 1660 if(G.vsBot && G.botPath.length>1){
  1661. 1661 const bp=toPts(G.botPath);
  1662. 1662 ctx.setLineDash([Math.max(6,s*0.5),Math.max(5,s*0.4)]);
  1663. 1663 ctx.strokeStyle=P.bot; ctx.lineWidth=Math.max(2,s*0.11); ctx.globalAlpha=.85; trace(bp); ctx.globalAlpha=1;
  1664. 1664 ctx.setLineDash([]);
  1665. 1665 }
  1666. 1666 // jejak klik kamu (oranye), seperti rute lari
  1667. 1667 const pts=toPts(G.path);
  1668. 1668 if(pts.length>1){
  1669. 1669 const lw=Math.max(2.5,s*0.17);
  1670. 1670 ctx.strokeStyle='rgba(0,0,0,.35)'; ctx.lineWidth=lw+4; trace(pts);
  1671. 1671 ctx.strokeStyle=P.route; ctx.lineWidth=lw; ctx.globalAlpha=.92; trace(pts); ctx.globalAlpha=1;
  1672. 1672 }
  1673. 1673 if(pts.length){
  1674. 1674 const dot=(p,col)=>{ ctx.fillStyle=col; ctx.strokeStyle='#fff'; ctx.lineWidth=3; ctx.beginPath(); ctx.arc(p[0],p[1],Math.max(6,s*0.3),0,Math.PI*2); ctx.fill(); ctx.stroke(); };
  1675. 1675 dot(pts[0],P.accent);
  1676. 1676 if(pts.length>1) dot(pts[pts.length-1],P.route);
  1677. 1677 }
  1678. 1678
  1679. 1679 // statistik
  1680. 1680 ctx.strokeStyle=P.line; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(m,H-270); ctx.lineTo(W-m,H-270); ctx.stroke();
  1681. 1681 const pace = G.elapsed>=3000 ? (G.clicks/(G.elapsed/60000)).toFixed(1).replace('.',',') : '-';
  1682. 1682 const stats = G.vsBot
  1683. 1683 ? [['Klik',G.clicks],['Klik per menit',pace],['Papanmu',pct(G.me)+'%'],['Papan bot',pct(G.bot)+'%']]
  1684. 1684 : [['Klik',G.clicks],['Klik per menit',pace],['Terbuka',pct(G.me)+'%'],[G.livesMax?'Nyawa sisa':'Bendera',G.livesMax?G.lives:G.me.flags]];
  1685. 1685 const colW=(W-2*m)/4;
  1686. 1686 stats.forEach((st,k)=>{
  1687. 1687 const x=m+k*colW;
  1688. 1688 txt(String(st[1]), x, H-190, '800 62px '+F1, P.text);
  1689. 1689 txt(st[0], x, H-146, '500 26px '+F2, P.muted);
  1690. 1690 });
  1691. 1691 const when=new Date(G.endedAt||Date.now()).toLocaleString('id-ID',{day:'numeric',month:'long',year:'numeric',hour:'2-digit',minute:'2-digit'});
  1692. 1692 txt(when, m, H-72, '500 28px '+F2, P.muted);
  1693. 1693 if(G.vsBot) txt('Oranye kamu, biru bot', W-m, H-72, '600 28px '+F2, P.muted, 'right');
  1694. 1694 else if(info.xp) txt('+'+info.xp+' XP', W-m, H-72, '700 28px '+F2, P.muted, 'right');
  1695. 1695}
  1696. 1696function shareName(){
  1697. 1697 const d=new Date(G.endedAt||Date.now()), z=n=>String(n).padStart(2,'0');
  1698. 1698 return 'sapu-ranjau-'+G.diff+'-'+d.getFullYear()+z(d.getMonth()+1)+z(d.getDate())+'-'+z(d.getHours())+z(d.getMinutes())+'.png';
  1699. 1699}
  1700. 1700async function refreshShare(){
  1701. 1701 const tok=++SH.tok;
  1702. 1702 await loadFonts();
  1703. 1703 if(tok!==SH.tok || !G) return;
  1704. 1704 const cv=document.createElement('canvas');
  1705. 1705 drawShare(cv,SH.fmt,SH.style);
  1706. 1706 const blob=await new Promise(res=>cv.toBlob(res,'image/png'));
  1707. 1707 if(tok!==SH.tok) return;
  1708. 1708 SH.blob=blob;
  1709. 1709 $('#shImg').src=cv.toDataURL('image/png');
  1710. 1710 $('#sharePrev').classList.toggle('clear',SH.style==='clear');
  1711. 1711}
  1712. 1712function syncShareOpts(){
  1713. 1713 document.querySelectorAll('#shFmt button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.f===SH.fmt?'true':'false'));
  1714. 1714 document.querySelectorAll('#shStyle button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.s===SH.style?'true':'false'));
  1715. 1715}
  1716. 1716function openShare(){
  1717. 1717 if(!G || G.status!=='over') return;
  1718. 1718 closeModal();
  1719. 1719 if(!SH.style){
  1720. 1720 const dark = S.theme==='dark' || (S.theme!=='light' && matchMedia('(prefers-color-scheme:dark)').matches);
  1721. 1721 SH.style = dark ? 'dark' : 'light';
  1722. 1722 }
  1723. 1723 syncShareOpts();
  1724. 1724 $('#shShare').classList.toggle('hidden', typeof navigator.canShare!=='function');
  1725. 1725 $('#share').classList.remove('hidden');
  1726. 1726 refreshShare();
  1727. 1727}
  1728. 1728function closeShare(){ $('#share').classList.add('hidden'); }
  1729. 1729async function doDownload(){
  1730. 1730 if(!SH.blob){ toast('Gambar belum siap, tunggu sebentar.'); return; }
  1731. 1731 const name=shareName();
  1732. 1732 if(DL){
  1733. 1733 try{ await DL.save({ filename:name, data:SH.blob }); toast('Gambar disimpan.'); }
  1734. 1734 catch(e){ if(!e || e.code!=='declined') toast('Gagal menyimpan. Coba tahan gambarnya.'); }
  1735. 1735 return;
  1736. 1736 }
  1737. 1737 const url=URL.createObjectURL(SH.blob);
  1738. 1738 const a=document.createElement('a'); a.href=url; a.download=name;
  1739. 1739 document.body.appendChild(a); a.click(); a.remove();
  1740. 1740 setTimeout(()=>URL.revokeObjectURL(url),4000);
  1741. 1741 toast('Jika tidak terunduh, tahan gambar untuk menyimpan.');
  1742. 1742}
  1743. 1743async function doShare(){
  1744. 1744 if(!SH.blob) return;
  1745. 1745 try{
  1746. 1746 const file=new File([SH.blob], shareName(), { type:'image/png' });
  1747. 1747 if(navigator.canShare && navigator.canShare({files:[file]})) await navigator.share({ files:[file], title:'Sapu Ranjau' });
  1748. 1748 else toast('Berbagi langsung tidak didukung di sini. Pakai Unduh.');
  1749. 1749 }catch(e){ /* dibatalkan */ }
  1750. 1750}
  1751. 1751
  1752. 1752/* ===================== EVENT ===================== */
  1753. 1753$('#tabs').addEventListener('click',e=>{ const b=e.target.closest('button'); if(b) showTab(b.dataset.tab); });
  1754. 1754$('#modeSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; S.mode=b.dataset.mode; saveSettings(); renderMenu(); });
  1755. 1755$('#livesSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; S.lives=b.dataset.lives==='1'; saveSettings(); renderMenu(); });
  1756. 1756$('#diffChips').addEventListener('click',e=>{ const b=e.target.closest('.chip'); if(!b) return; S.diff=b.dataset.diff; saveSettings(); renderMenu(); });
  1757. 1757$('#botChips').addEventListener('click',e=>{ const b=e.target.closest('.chip'); if(!b) return; S.bot=b.dataset.bot; saveSettings(); renderMenu(); });
  1758. 1758['#cR','#cC','#cM'].forEach(id=>$(id).addEventListener('change',readCustom));
  1759. 1759$('#codeIn').addEventListener('input',e=>{ S.code=e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g,''); saveSettings(); renderMenu(); });
  1760. 1760$('#codeRand').addEventListener('click',()=>{ S.code=makeCode(); saveSettings(); renderMenu(); toast('Kode dibuat. Bagikan ke temanmu.'); });
  1761. 1761$('#startBtn').addEventListener('click',()=>{ if(S.diff==='custom') readCustom(); const cfg=cfgFromSettings(); if(cfg) newGame(cfg); });
  1762. 1762$('#dailyBtn').addEventListener('click',()=>newGame(dailyCfg()));
  1763. 1763$('#resumeBtn').addEventListener('click',loadGame);
  1764. 1764$('#discardBtn').addEventListener('click',()=>{ store.del(K_SAVE); renderMenu(); toast('Simpanan dihapus.'); });
  1765. 1765
  1766. 1766$('#nameIn').addEventListener('change',e=>{ S.name=(e.target.value.trim().slice(0,16))||'Pemain'; saveSettings(); renderProfile(); });
  1767. 1767$('#skins').addEventListener('click',e=>{ const b=e.target.closest('.skin'); if(!b) return; S.skin=b.dataset.skin; saveSettings(); applyLook(); renderProfile(); });
  1768. 1768$('#themeSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; S.theme=b.dataset.t; saveSettings(); applyLook(); renderProfile(); });
  1769. 1769$('#soundSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; S.sound=b.dataset.s==='1'; saveSettings(); renderProfile(); if(S.sound) sfx.ach(); });
  1770. 1770$('#soundBtn').addEventListener('click',()=>{ S.sound=!S.sound; saveSettings(); renderProfile(); toast(S.sound?'Suara hidup':'Suara mati'); if(S.sound) sfx.ach(); });
  1771. 1771$('#themeBtn').addEventListener('click',()=>{
  1772. 1772 const cur=document.documentElement.getAttribute('data-theme') || (matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light');
  1773. 1773 S.theme = cur==='dark' ? 'light' : 'dark';
  1774. 1774 saveSettings(); applyLook(); renderProfile();
  1775. 1775});
  1776. 1776$('#histList').addEventListener('click',e=>{ const b=e.target.closest('[data-replay]'); if(b) startReplay(+b.dataset.replay); });
  1777. 1777let wipeT=null;
  1778. 1778$('#wipeBtn').addEventListener('click',()=>{
  1779. 1779 const btn=$('#wipeBtn');
  1780. 1780 if(!wipeT){ btn.textContent='Ketuk lagi untuk menghapus semuanya'; wipeT=setTimeout(()=>{ wipeT=null; btn.textContent='Hapus semua data'; },3500); return; }
  1781. 1781 clearTimeout(wipeT); wipeT=null; btn.textContent='Hapus semua data';
  1782. 1782 ST=defStats(); HIST=[]; store.del(K_SAVE); saveStats(); saveHist(); renderMenu(); toast('Statistik, riwayat, dan simpanan dihapus.');
  1783. 1783});
  1784. 1784
  1785. 1785$('#backBtn').addEventListener('click',()=>leaveGame(true));
  1786. 1786$('#menuBtn').addEventListener('click',()=>leaveGame(true));
  1787. 1787$('#saveBtn').addEventListener('click',()=>{ if(G && G.status==='over') openShare(); else saveGame(false); });
  1788. 1788$('#hintBtn').addEventListener('click',doHint);
  1789. 1789$('#pauseBtn').addEventListener('click',pauseToggle);
  1790. 1790$('#flagBtn').addEventListener('click',()=>{ if(G) setFlagMode(!G.flagMode); });
  1791. 1791$('#newBtn').addEventListener('click',restart);
  1792. 1792$('#faceBtn').addEventListener('click',restart);
  1793. 1793$('#overlay').addEventListener('click',e=>{ if(e.target.closest('#ovResume')) setPaused(false); });
  1794. 1794$('#rpPlay').addEventListener('click',replayToggle);
  1795. 1795$('#rpSpeed').addEventListener('click',()=>{ if(!G||!G.replay) return; const sp=[1,2,4,8]; G.rspeed=sp[(sp.indexOf(G.rspeed)+1)%sp.length]; $('#rpSpeed').textContent=G.rspeed+'×'; });
  1796. 1796$('#rpAgain').addEventListener('click',()=>{ if(G&&G.replay) startReplay(G.ridx); });
  1797. 1797$('#rpClose').addEventListener('click',()=>leaveGame(false));
  1798. 1798$('#infoRow').addEventListener('click',e=>{
  1799. 1799 if(!e.target.closest('#copyCode') || !G) return;
  1800. 1800 try{ navigator.clipboard.writeText(G.code).then(()=>toast('Kode disalin: '+G.code)).catch(()=>toast('Kode papan: '+G.code)); }
  1801. 1801 catch(err){ toast('Kode papan: '+G.code); }
  1802. 1802});
  1803. 1803$('#modal').addEventListener('click',e=>{
  1804. 1804 if(e.target.closest('#mAgain')) restart();
  1805. 1805 else if(e.target.closest('#mShare')) openShare();
  1806. 1806 else if(e.target.closest('#mMenu')) leaveGame(false);
  1807. 1807 else if(e.target.closest('#mView') || e.target.id==='modal') closeModal();
  1808. 1808});
  1809. 1809$('#shFmt').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; SH.fmt=b.dataset.f; syncShareOpts(); refreshShare(); });
  1810. 1810$('#shStyle').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b) return; SH.style=b.dataset.s; syncShareOpts(); refreshShare(); });
  1811. 1811$('#shDl').addEventListener('click',doDownload);
  1812. 1812$('#shShare').addEventListener('click',doShare);
  1813. 1813$('#shClose').addEventListener('click',closeShare);
  1814. 1814$('#share').addEventListener('click',e=>{ if(e.target.id==='share') closeShare(); });
  1815. 1815
  1816. 1816/* input papan: klik, klik kanan, tahan (sentuh) */
  1817. 1817(function(){
  1818. 1818 const el=$('#meBoard');
  1819. 1819 let lp=null, fired=false, lastPT='mouse', sx=0, sy=0;
  1820. 1820 const cellOf = e => { const t=e.target.closest('.cell'); return t ? +t.dataset.i : -1; };
  1821. 1821 el.addEventListener('pointerdown',e=>{
  1822. 1822 lastPT=e.pointerType; fired=false; clearTimeout(lp);
  1823. 1823 if(e.pointerType==='mouse') return;
  1824. 1824 const i=cellOf(e); if(i<0) return;
  1825. 1825 sx=e.clientX; sy=e.clientY;
  1826. 1826 lp=setTimeout(()=>{ fired=true; vib(18); act(i,'flag'); },380);
  1827. 1827 });
  1828. 1828 el.addEventListener('pointermove',e=>{ if(lp && Math.hypot(e.clientX-sx,e.clientY-sy)>10){ clearTimeout(lp); lp=null; } });
  1829. 1829 ['pointercancel','pointerleave'].forEach(t=>el.addEventListener(t,()=>{ clearTimeout(lp); lp=null; }));
  1830. 1830 el.addEventListener('click',e=>{
  1831. 1831 clearTimeout(lp); lp=null;
  1832. 1832 if(fired){ fired=false; return; }
  1833. 1833 const i=cellOf(e); if(i<0) return;
  1834. 1834 act(i, G && G.flagMode ? 'flag' : 'open');
  1835. 1835 });
  1836. 1836 el.addEventListener('contextmenu',e=>{
  1837. 1837 e.preventDefault();
  1838. 1838 if(lastPT==='touch') return;
  1839. 1839 const i=cellOf(e); if(i>=0) act(i,'flag');
  1840. 1840 });
  1841. 1841})();
  1842. 1842
  1843. 1843/* pintasan keyboard */
  1844. 1844document.addEventListener('keydown',e=>{
  1845. 1845 const tg=e.target;
  1846. 1846 if(tg && tg.matches && tg.matches('input,textarea')) return;
  1847. 1847 if(e.key==='Escape'){ closeModal(); closeShare(); return; }
  1848. 1848 if(!G || !$('#game').classList.contains('active')) return;
  1849. 1849 if(!$('#modal').classList.contains('hidden') || !$('#share').classList.contains('hidden')) return;
  1850. 1850 if(e.ctrlKey||e.metaKey||e.altKey) return;
  1851. 1851 const k=e.key.toLowerCase();
  1852. 1852 if(k==='r') restart();
  1853. 1853 else if(k==='f' && !G.replay) setFlagMode(!G.flagMode);
  1854. 1854 else if(k==='p') pauseToggle();
  1855. 1855 else if(k==='h') doHint();
  1856. 1856});
  1857. 1857
  1858. 1858/* loop waktu, bot, dan replay */
  1859. 1859let lastTick=performance.now();
  1860. 1860setInterval(()=>{
  1861. 1861 const now=performance.now();
  1862. 1862 let dt=Math.min(now-lastTick,250); lastTick=now;
  1863. 1863 if(!G) return;
  1864. 1864 if(G.replay){ if(!G.rpaused && G.status==='replay') replayTick(dt); return; }
  1865. 1865 if(G.status!=='play' || G.paused) return;
  1866. 1866 G.elapsed+=dt;
  1867. 1867 updateTimer();
  1868. 1868 if(G.vsBot && G.bot.state==='play'){
  1869. 1869 G.botAcc+=dt;
  1870. 1870 while(G.botAcc>=G.botWait && G.bot.state==='play' && G.status==='play'){
  1871. 1871 G.botAcc-=G.botWait;
  1872. 1872 botStep();
  1873. 1873 }
  1874. 1874 }
  1875. 1875},50);
  1876. 1876
  1877. 1877/* simpan otomatis saat tab disembunyikan */
  1878. 1878document.addEventListener('visibilitychange',()=>{
  1879. 1879 if(document.hidden && G && G.status==='play' && !G.replay){ saveGame(true); if(!G.paused) setPaused(true); }
  1880. 1880});
  1881. 1881window.addEventListener('pagehide',()=>{ if(G && G.status==='play' && !G.replay) saveGame(true); });
  1882. 1882window.addEventListener('resize',fit);
  1883. 1883
  1884. 1884/* mulai */
  1885. 1885applyLook();
  1886. 1886buildChips();
  1887. 1887renderMenu();
  1888. 1888})();
  1889. 1889</script>
  1890. 1890</body>
  1891. 1891</html>

Raw Paste

Comments 0
Login to post a comment.
  • No comments yet. Be the first.
Login to post a comment. Login or Register
We use cookies. To comply with GDPR in the EU and the UK we have to show you these.

We use cookies and similar technologies to keep this website functional (including spam protection via Google reCAPTCHA or Cloudflare Turnstile), and — with your consent — to measure usage and show ads. See Privacy.