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