Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Snakes & Ladders Darts</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Luckiest+Guy&display=swap" rel="stylesheet"> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| min-height: 100vh; | |
| background: radial-gradient(circle at 50% 15%, #241a3d, #0d0a18); | |
| color: #f4f2ff; | |
| font-family: "Courier New", monospace; | |
| display: flex; flex-direction: column; align-items: center; | |
| padding: 14px; gap: 10px; | |
| -webkit-user-select: none; user-select: none; | |
| } | |
| h1 { letter-spacing: 5px; font-size: clamp(17px, 4vw, 26px); } | |
| h1 .s { color: #ff5b7b; } h1 .l { color: #5ce8a0; } | |
| #layout { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; justify-content: center; width: 100%; max-width: 1150px; } | |
| .panel { position: relative; } | |
| canvas { display: block; border-radius: 14px; touch-action: manipulation; } | |
| #sl { width: min(94vw, 640px); height: auto; background: #221a38; box-shadow: 0 14px 40px rgba(0,0,0,.55); border-radius: 14px; } | |
| #playerStrip { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; margin-top: 10px; font-weight: bold; font-size: 13px; letter-spacing: 1px; } | |
| #playerStrip span { padding: 3px 10px; border-radius: 7px; border: 1.5px solid transparent; } | |
| #playerStrip span.me { border-color: currentColor; background: rgba(255,255,255,.07); } | |
| #hint { margin-top: 7px; text-align: center; font-size: 11.5px; opacity: .6; } | |
| #bust { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; z-index: 5; pointer-events: none; } | |
| #bust.show { display: flex; } | |
| #bust span { | |
| font-size: 68px; font-weight: bold; color: #ff4a4a; letter-spacing: 8px; | |
| text-shadow: 0 0 26px rgba(255,74,74,.65), 4px 4px 0 #3a0505; | |
| animation: bustpop .35s ease-out; | |
| } | |
| @keyframes bustpop { from { transform: scale(.3) rotate(-6deg); opacity: 0; } to { transform: scale(1) rotate(0deg); opacity: 1; } } | |
| #winmsg { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; z-index: 150; pointer-events: none; background: rgba(10,6,20,.45); } | |
| #winmsg.show { display: flex; } | |
| #winmsg span { | |
| font-family: 'Luckiest Guy', 'Courier New', monospace; | |
| font-size: clamp(36px, 9vw, 80px); color: #ffd75e; letter-spacing: 4px; text-align: center; | |
| text-shadow: 0 0 30px rgba(255,215,94,.7), 4px 4px 0 #7a2020; | |
| animation: winpop .4s ease-out; | |
| } | |
| @keyframes winpop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } } | |
| #confetti { position: fixed; inset: 0; z-index: 200; pointer-events: none; } | |
| #dart { width: min(94vw, 420px); height: auto; background: radial-gradient(circle at 50% 45%, #23202e, #14111d); cursor: crosshair; box-shadow: 0 14px 40px rgba(0,0,0,.55); } | |
| #side { width: min(94vw, 420px); display: flex; flex-direction: column; gap: 8px; } | |
| #banner { | |
| background: linear-gradient(90deg, #2a2150, #1c1638); | |
| border: 2px solid #4a3d85; border-radius: 10px; | |
| padding: 9px 12px; font-size: 14px; font-weight: bold; letter-spacing: 1px; | |
| min-height: 42px; display: flex; align-items: center; gap: 10px; | |
| } | |
| #banner .dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #fff; flex: none; } | |
| #legend { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; font-size: 10.5px; text-align: center; } | |
| #legend div { background: rgba(255,255,255,.06); border-radius: 6px; padding: 4px 1px; line-height: 1.35; } | |
| #legend b { display: block; font-size: 15px; } | |
| #log { font-size: 12px; opacity: .75; line-height: 1.55; min-height: 76px; background: rgba(0,0,0,.28); border-radius: 8px; padding: 7px 10px; } | |
| #controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| button, label.toggle { | |
| font-family: inherit; font-weight: bold; font-size: 12px; letter-spacing: 1px; | |
| background: #2a2150; color: #f4f2ff; border: 2px solid #4a3d85; | |
| border-radius: 7px; padding: 7px 12px; cursor: pointer; | |
| } | |
| button:hover { background: #37296b; } | |
| label.toggle input { vertical-align: middle; margin-right: 5px; } | |
| #overlay { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center; background: rgba(6,4,14,.9); } | |
| #card { background: #1c1638; border: 3px solid #ffd75e; border-radius: 16px; padding: 26px; max-width: 500px; width: 93vw; text-align: center; max-height: 92vh; overflow-y: auto; } | |
| #card h2 { color: #ffd75e; letter-spacing: 3px; margin-bottom: 14px; } | |
| #card p { font-size: 13px; line-height: 1.65; margin-bottom: 12px; opacity: .9; } | |
| #countrow { display: flex; align-items: center; justify-content: center; gap: 16px; margin: 10px 0 14px; } | |
| #countrow b { font-size: 30px; color: #ffd75e; min-width: 44px; } | |
| #countrow button { font-size: 20px; padding: 4px 16px; } | |
| .nameinp { | |
| display: block; width: 78%; margin: 7px auto; padding: 8px 12px; | |
| background: #120e22; color: #fff; border: 2px solid #4a3d85; border-radius: 8px; | |
| font-family: inherit; font-size: 14px; text-align: center; | |
| } | |
| .nameinp:focus { outline: none; border-color: #ffd75e; } | |
| .bigbtn { | |
| margin-top: 12px; font-family: inherit; font-weight: bold; font-size: 15px; letter-spacing: 2px; | |
| background: #ffd75e; color: #1c1638; border: none; padding: 11px 30px; border-radius: 9px; cursor: pointer; | |
| } | |
| .bigbtn:hover { filter: brightness(1.08); } | |
| #podium { font-size: 15px; line-height: 2; margin-bottom: 8px; } | |
| </style> | |
| </head> | |
| <body> | |
| <h1><span class="s">SNAKES</span> & <span class="l">LADDERS</span> DARTS</h1> | |
| <div id="layout"> | |
| <div class="panel"> | |
| <canvas id="sl" width="640" height="552"></canvas> | |
| <div id="playerStrip"></div> | |
| <div id="hint">Tap the dartboard where each dart lands · UNDO takes back a misclick</div> | |
| <div id="bust"><span>BUST!</span></div> | |
| </div> | |
| <div id="side"> | |
| <div class="panel"><canvas id="dart" width="480" height="480"></canvas></div> | |
| <div id="banner"></div> | |
| <div id="legend"> | |
| <div style="color:#ff8080"><b>50</b>BULL=6</div> | |
| <div style="color:#ffb36b"><b>25</b>BULL=5</div> | |
| <div style="color:#e8d8ff"><b>IN</b>SINGLE=4</div> | |
| <div style="color:#ffd75e"><b>T</b>TREBLE=3</div> | |
| <div><b>OUT</b>SINGLE=2</div> | |
| <div style="color:#8fe8a0"><b>D</b>DOUBLE=1</div> | |
| <div style="opacity:.55"><b>✕</b>MISS=0</div> | |
| </div> | |
| <div id="log"></div> | |
| <div id="controls"> | |
| <button id="undo">UNDO DART</button> | |
| <button id="soundbtn">🔊</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="overlay"><div id="card"></div></div> | |
| <div id="winmsg"><span></span></div> | |
| <canvas id="confetti"></canvas> | |
| <script> | |
| ; | |
| const SECTORS = [20, 1, 18, 4, 13, 6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5]; | |
| const R = { bullIn: 6.35, bullOut: 15.9, triIn: 99, triOut: 107, dblIn: 162, dblOut: 170 }; | |
| const LADDERS = [[4, 14, ""], [13, 47, "STAIRWAY TO HEAVEN"], [21, 38, ""], [24, 40, ""], [41, 57, ""], [49, 63, ""]]; | |
| const SNAKES = [ | |
| [27, 9, ""], | |
| [33, 18, ""], | |
| [44, 20, "MONTY PYTHON"], | |
| [54, 28, "JAKE SNAKE"], | |
| [59, 52, ""], | |
| [61, 2, "BIG MAMBA", { bias: 30, amp: 13, waves: 3.2, phase: 0.9 }] | |
| ]; | |
| const TOKEN_COLORS = ["#ff5b7b", "#ffd75e", "#4ad6ff", "#9d8bff", "#5ce8a0", "#ff9df0"]; | |
| const slCvs = document.getElementById("sl"); | |
| const slCtx = slCvs.getContext("2d"); | |
| const dCvs = document.getElementById("dart"); | |
| const dCtx = dCvs.getContext("2d"); | |
| let players = [], turn = 0, phase = "setup", muted = false; | |
| let pins = [], lastThrow = null, rotToken = 0, busyAnim = false, throwGen = 0; | |
| let gameWinners = [], catchupQueue = []; | |
| const COLS = 8, ROWS = 8, WIN = 64; | |
| const X0 = 104, Y0 = 20, CW = 64, CH = 64; | |
| function sqXY(n) { | |
| if (n <= 0) return { x: 56, y: Y0 + 7 * CH + CH / 2 }; | |
| const idx = Math.min(WIN, n) - 1; | |
| const row = Math.floor(idx / COLS), cIn = idx % COLS; | |
| const col = row % 2 === 0 ? cIn : COLS - 1 - cIn; | |
| return { x: X0 + col * CW + CW / 2, y: Y0 + (7 - row) * CH + CH / 2 }; | |
| } | |
| function snakePathPoint(a, b, si, t, offs) { | |
| const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; | |
| const nx = -dy / len, ny = dx / len; | |
| let w; | |
| if (offs && offs.length >= 5) { | |
| const seg = Math.min(3, Math.floor(t * 4)); | |
| const f = t * 4 - seg; | |
| w = offs[seg] * (1 - f) + offs[seg + 1] * f; | |
| } else if (offs && offs.bias !== undefined) { | |
| w = (Math.sin(t * Math.PI * offs.waves + (offs.phase || 0)) * (offs.amp || 13) - offs.bias) * Math.sin(t * Math.PI); | |
| } else { | |
| w = Math.sin(t * Math.PI * 2.2 + si * 1.7) * 16 * Math.sin(t * Math.PI); | |
| } | |
| return { x: a.x + dx * t + nx * w, y: a.y + dy * t + ny * w }; | |
| } | |
| const tokenOverride = {}; | |
| function lerpPt(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; } | |
| const LABEL_FONT = "13px 'Luckiest Guy', 'Courier New', monospace"; | |
| function dirForSquare(n) { | |
| const idx = n - 1, row = Math.floor(idx / COLS), cIn = idx % COLS; | |
| const col = row % 2 === 0 ? cIn : COLS - 1 - cIn; | |
| if (row % 2 === 0) return col === COLS - 1 ? "up" : "right"; | |
| return col === 0 ? "up" : "left"; | |
| } | |
| function drawArrow(ctx, p, dir, col, alpha) { | |
| const ang = { right: 0, down: Math.PI / 2, left: Math.PI, up: -Math.PI / 2 }[dir]; | |
| ctx.save(); | |
| ctx.translate(p.x, p.y); ctx.rotate(ang); | |
| ctx.globalAlpha = alpha; | |
| ctx.beginPath(); | |
| ctx.moveTo(9, 0); ctx.lineTo(-6, -7); ctx.lineTo(-2.5, 0); ctx.lineTo(-6, 7); | |
| ctx.closePath(); | |
| ctx.fillStyle = col; ctx.fill(); | |
| ctx.globalAlpha = 1; | |
| ctx.lineWidth = 1.2; ctx.strokeStyle = "rgba(40,40,60,.75)"; ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| function drawSquareLabel(n, name, color) { | |
| const p = sqXY(n); | |
| const lines = []; | |
| let cur = ""; | |
| for (const w of name.split(" ")) { | |
| const test = cur ? cur + " " + w : w; | |
| if (test.length > 9 && cur) { lines.push(cur); cur = w; } else cur = test; | |
| } | |
| if (cur) lines.push(cur); | |
| const ctx = slCtx; | |
| ctx.save(); | |
| ctx.font = LABEL_FONT; | |
| ctx.textAlign = "center"; ctx.textBaseline = "middle"; | |
| ctx.fillStyle = color; | |
| const lh = 14; | |
| const y0 = p.y - (lines.length - 1) * lh / 2 + 3; | |
| lines.forEach((ln, i) => ctx.fillText(ln, p.x, y0 + i * lh)); | |
| ctx.restore(); | |
| } | |
| function renderSL() { | |
| const ctx = slCtx; | |
| ctx.clearRect(0, 0, 640, 552); | |
| ctx.fillStyle = "#221a38"; | |
| ctx.fillRect(0, 0, 640, 552); | |
| for (let r = 0; r < ROWS; r++) { | |
| for (let c = 0; c < COLS; c++) { | |
| const n = r % 2 === 0 ? r * COLS + c + 1 : r * COLS + (COLS - c); | |
| const x = X0 + c * CW, y = Y0 + (ROWS - 1 - r) * CH; | |
| ctx.fillStyle = (r + c) % 2 === 0 ? "#f9f4c8" : "#fffdf2"; | |
| ctx.fillRect(x + 1, y + 1, CW - 2, CH - 2); | |
| ctx.strokeStyle = "rgba(130,120,80,.3)"; ctx.lineWidth = 1; | |
| ctx.strokeRect(x + 1, y + 1, CW - 2, CH - 2); | |
| ctx.fillStyle = "#3c3a55"; | |
| ctx.font = "bold 12px 'Courier New', monospace"; | |
| ctx.textAlign = "left"; ctx.textBaseline = "top"; | |
| ctx.fillText(n, x + 6, y + 4); | |
| if (n === WIN) { | |
| ctx.font = "50px 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', sans-serif"; | |
| ctx.textAlign = "center"; ctx.textBaseline = "middle"; | |
| ctx.fillText("🏆", x + CW / 2, y + CH / 2 + 2); | |
| } | |
| } | |
| } | |
| ctx.save(); | |
| const sy = Y0 + (ROWS - 1) * CH; | |
| ctx.fillStyle = "#ffd75e"; | |
| ctx.beginPath(); | |
| const rx = 18, ry = sy + 4, rw = 76, rh = CH - 8, rr2 = 10; | |
| ctx.moveTo(rx + rr2, ry); | |
| ctx.arcTo(rx + rw, ry, rx + rw, ry + rh, rr2); | |
| ctx.arcTo(rx + rw, ry + rh, rx, ry + rh, rr2); | |
| ctx.arcTo(rx, ry + rh, rx, ry, rr2); | |
| ctx.arcTo(rx, ry, rx + rw, ry, rr2); | |
| ctx.closePath(); ctx.fill(); | |
| ctx.strokeStyle = "#c9a227"; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.fillStyle = "#5a4500"; | |
| ctx.font = "bold 13px 'Courier New', monospace"; | |
| ctx.textAlign = "center"; ctx.textBaseline = "middle"; | |
| ctx.fillText("START", rx + rw / 2, ry + rh / 2 - 9); | |
| ctx.fillText("HERE", rx + rw / 2, ry + rh / 2 + 9); | |
| ctx.restore(); | |
| LADDERS.forEach(([from, to, name], li) => { | |
| const a = sqXY(from), b = sqXY(to); | |
| const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1; | |
| const ux = -dy / len, uy = dx / len, off = 6; | |
| ctx.save(); | |
| ctx.strokeStyle = "#e05252"; ctx.lineWidth = 3.5; ctx.lineCap = "round"; | |
| for (const s of [-off, off]) { | |
| ctx.beginPath(); | |
| ctx.moveTo(a.x + ux * s, a.y + uy * s); | |
| ctx.lineTo(b.x + ux * s, b.y + uy * s); | |
| ctx.stroke(); | |
| } | |
| ctx.lineWidth = 2.5; | |
| const rungs = Math.max(2, Math.floor(len / 16)); | |
| for (let i = 1; i < rungs; i++) { | |
| const t = i / rungs, px = a.x + dx * t, py = a.y + dy * t; | |
| ctx.beginPath(); | |
| ctx.moveTo(px - ux * off, py - uy * off); | |
| ctx.lineTo(px + ux * off, py + uy * off); | |
| ctx.stroke(); | |
| } | |
| ctx.restore(); | |
| }); | |
| LADDERS.forEach(([from, , name]) => { if (name) drawSquareLabel(from, name, "#b23a3a"); }); | |
| SNAKES.forEach((s, si) => { | |
| const [from, to, name, offs] = s; | |
| const a = sqXY(from), b = sqXY(to); | |
| const steps = Math.max(10, Math.floor(Math.hypot(b.x - a.x, b.y - a.y) / 7)); | |
| ctx.save(); | |
| ctx.strokeStyle = "#2fb3a3"; ctx.lineWidth = 7; ctx.lineCap = "round"; ctx.lineJoin = "round"; | |
| ctx.beginPath(); | |
| ctx.moveTo(a.x, a.y); | |
| for (let i = 1; i <= steps; i++) { | |
| const p = snakePathPoint(a, b, si, i / steps, offs); | |
| ctx.lineTo(p.x, p.y); | |
| } | |
| ctx.stroke(); | |
| ctx.fillStyle = "#2fb3a3"; | |
| ctx.beginPath(); ctx.arc(b.x, b.y, 7.5, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = "#0e2a26"; | |
| ctx.beginPath(); ctx.arc(b.x - 2.4, b.y - 2, 1.7, 0, Math.PI * 2); ctx.fill(); | |
| ctx.beginPath(); ctx.arc(b.x + 2.4, b.y - 2, 1.7, 0, Math.PI * 2); ctx.fill(); | |
| ctx.restore(); | |
| }); | |
| SNAKES.forEach(([from, , name]) => { if (name) drawSquareLabel(from, name, "#0b6b5d"); }); | |
| if (phase === "play" && !busyAnim && players.length) { | |
| const me = players[turn]; | |
| const flash = 0.45 + 0.35 * Math.sin(performance.now() / 160); | |
| for (let k = 1; k <= 6; k++) { | |
| const n = me.pos + k; | |
| if (n > WIN) break; | |
| drawArrow(slCtx, sqXY(n), dirForSquare(n), TOKEN_COLORS[turn], flash); | |
| } | |
| } | |
| players.forEach((pl, i) => { | |
| if (phase === "setup") return; | |
| const over = tokenOverride[i]; | |
| const base = over || sqXY(pl.pos); | |
| const sameSquare = over ? [pl] : players.filter(o => o.pos === pl.pos); | |
| const k = sameSquare.indexOf(pl); | |
| const spread = (!over && sameSquare.length > 1) ? 9 : 0; | |
| const ang = (k / Math.max(1, sameSquare.length)) * Math.PI * 2; | |
| const ox = Math.cos(ang) * spread, oy = Math.sin(ang) * spread; | |
| ctx.save(); | |
| ctx.beginPath(); ctx.arc(base.x + ox, base.y + oy, 15, 0, Math.PI * 2); | |
| ctx.fillStyle = TOKEN_COLORS[i]; ctx.fill(); | |
| ctx.lineWidth = 2.5; ctx.strokeStyle = "#fff"; ctx.stroke(); | |
| if (i === turn && phase === "play") { | |
| ctx.beginPath(); ctx.arc(base.x + ox, base.y + oy, 20, 0, Math.PI * 2); | |
| ctx.strokeStyle = TOKEN_COLORS[i]; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); | |
| ctx.stroke(); ctx.setLineDash([]); | |
| } | |
| ctx.restore(); | |
| }); | |
| } | |
| function segAt(px, py) { | |
| const cx = 240, cy = 240, scale = 240 * 0.81 / 170; | |
| const r = Math.hypot(px - cx, py - cy) / scale; | |
| let deg = Math.atan2(py - cy, px - cx) * 180 / Math.PI + 90 + 9; | |
| deg = ((deg % 360) + 360) % 360; | |
| if (r <= R.bullIn) return { move: 6, label: "INNER BULL! +6", pinCol: "#ff4a4a" }; | |
| if (r <= R.bullOut) return { move: 5, label: "Outer bull +5", pinCol: "#c62828" }; | |
| if (r > R.dblOut) return null; | |
| const numStr = String(SECTORS[Math.floor(deg / 18) % 20]); | |
| if (r >= R.triIn && r <= R.triOut) return { move: 3, label: "TREBLE " + numStr + " +3", pinCol: "#ffd75e" }; | |
| if (r >= R.dblIn && r <= R.dblOut) return { move: 1, label: "DOUBLE " + numStr + " +1", pinCol: "#8fe8a0" }; | |
| if (r < R.triIn) return { move: 4, label: "Inner single " + numStr + " +4", pinCol: "#e8d8ff" }; | |
| return { move: 2, label: "Outer single " + numStr + " +2", pinCol: "#bdbdbd" }; | |
| } | |
| function drawDartboard() { | |
| const ctx = dCtx; | |
| ctx.clearRect(0, 0, 480, 480); | |
| const cx = 240, cy = 240, S = 240 * 0.81 / 170; | |
| const rr = mm => mm * S; | |
| ctx.save(); | |
| ctx.beginPath(); ctx.arc(cx, cy, rr(210), 0, Math.PI * 2); | |
| const felt = ctx.createRadialGradient(cx, cy, rr(60), cx, cy, rr(212)); | |
| felt.addColorStop(0, "#2b2f36"); felt.addColorStop(1, "#15181d"); | |
| ctx.fillStyle = felt; ctx.fill(); | |
| for (let i = 0; i < 20; i++) { | |
| const a0 = (-90 - 9 + i * 18) * Math.PI / 180, a1 = a0 + 18 * Math.PI / 180; | |
| const dark = i % 2 === 0; | |
| const single = dark ? "#101114" : "#54565e"; | |
| const ring = dark ? "#c62828" : "#8e1414"; | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, rr(R.triOut), a0, a1); ctx.arc(cx, cy, rr(R.bullOut), a1, a0, true); | |
| ctx.closePath(); ctx.fillStyle = single; ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, rr(R.triOut), a0, a1); ctx.arc(cx, cy, rr(R.triIn), a1, a0, true); | |
| ctx.closePath(); ctx.fillStyle = ring; ctx.fill(); | |
| ctx.beginPath(); | |
| ctx.arc(cx, cy, rr(R.dblOut), a0, a1); ctx.arc(cx, cy, rr(R.dblIn), a1, a0, true); | |
| ctx.closePath(); ctx.fillStyle = ring; ctx.fill(); | |
| } | |
| ctx.strokeStyle = "rgba(240,240,245,.7)"; ctx.lineWidth = 1.1; | |
| for (let i = 0; i < 20; i++) { | |
| const a = (-90 - 9 + i * 18) * Math.PI / 180; | |
| ctx.beginPath(); | |
| ctx.moveTo(cx + Math.cos(a) * rr(R.bullOut), cy + Math.sin(a) * rr(R.bullOut)); | |
| ctx.lineTo(cx + Math.cos(a) * rr(R.dblOut), cy + Math.sin(a) * rr(R.dblOut)); | |
| ctx.stroke(); | |
| } | |
| [R.triIn, R.triOut, R.dblIn, R.dblOut, R.bullIn, R.bullOut].forEach(rd => { | |
| ctx.beginPath(); ctx.arc(cx, cy, rr(rd), 0, Math.PI * 2); ctx.stroke(); | |
| }); | |
| ctx.beginPath(); ctx.arc(cx, cy, rr(R.bullOut), 0, Math.PI * 2); ctx.fillStyle = "#c62828"; ctx.fill(); | |
| ctx.beginPath(); ctx.arc(cx, cy, rr(R.bullIn), 0, Math.PI * 2); ctx.fillStyle = "#ff4a4a"; ctx.fill(); | |
| ctx.fillStyle = "#f4f4f6"; ctx.font = "bold 17px 'Courier New', monospace"; | |
| ctx.textAlign = "center"; ctx.textBaseline = "middle"; | |
| for (let i = 0; i < 20; i++) { | |
| const a = (-90 + i * 18) * Math.PI / 180; | |
| ctx.fillText(String(SECTORS[i]), cx + Math.cos(a) * rr(189), cy + Math.sin(a) * rr(189)); | |
| } | |
| const vig = ctx.createRadialGradient(cx, cy, rr(120), cx, cy, rr(212)); | |
| vig.addColorStop(0, "rgba(0,0,0,0)"); vig.addColorStop(1, "rgba(0,0,0,.38)"); | |
| ctx.beginPath(); ctx.arc(cx, cy, rr(210), 0, Math.PI * 2); ctx.fillStyle = vig; ctx.fill(); | |
| pins.forEach(p => { | |
| ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = p.col || "#eee"; ctx.fill(); | |
| ctx.lineWidth = 1.4; ctx.strokeStyle = "#444"; ctx.stroke(); | |
| }); | |
| ctx.restore(); | |
| } | |
| function banner(html) { document.getElementById("banner").innerHTML = html; } | |
| function logMsg(t) { | |
| const l = document.getElementById("log"); | |
| const line = document.createElement("div"); | |
| line.textContent = "› " + t; | |
| l.prepend(line); | |
| while (l.children.length > 5) l.lastChild.remove(); | |
| } | |
| function updateBanner(extra) { | |
| const pl = players[turn]; | |
| banner("<span class='dot' style='background:" + TOKEN_COLORS[turn] + "'></span>" + | |
| "<span>" + pl.name + " — dart " + (pl.dartsThisTurn + 1) + " of 3" + (extra ? " · " + extra : "") + "</span>"); | |
| renderPlayers(); | |
| } | |
| function renderPlayers() { | |
| const el = document.getElementById("playerStrip"); | |
| el.innerHTML = players.map((p, i) => { | |
| const act = i === turn && phase === "play"; | |
| return "<span class='" + (act ? "me" : "") + "' style='color:" + TOKEN_COLORS[i] + "'>" + | |
| (act ? "▶ " : "") + p.name + "</span>"; | |
| }).join(""); | |
| } | |
| async function animateMove(pIdx, from, to) { | |
| busyAnim = true; | |
| const step = to > from ? 1 : -1; | |
| for (let pos = from + step; ; pos += step) { | |
| players[pIdx].pos = pos; | |
| renderSL(); | |
| tone(step > 0 ? 380 + pos * 2 : 300, .05, "square", .04); | |
| await sleep(130); | |
| if (pos === to) break; | |
| } | |
| const land = players[pIdx].pos; | |
| const ladder = LADDERS.find(l => l[0] === land); | |
| const snakeIdx = SNAKES.findIndex(s => s[0] === land); | |
| if (ladder) { | |
| logMsg(players[pIdx].name + " climbs a ladder " + ladder[0] + " → " + ladder[1] + "!"); | |
| sfxClimb(); | |
| await glideAlong(pIdx, t => lerpPt(sqXY(land), sqXY(ladder[1]), t), 640); | |
| players[pIdx].pos = ladder[1]; | |
| } else if (snakeIdx >= 0) { | |
| const sn = SNAKES[snakeIdx]; | |
| logMsg(players[pIdx].name + " slides down a snake " + sn[0] + " → " + sn[1] + "... ouch."); | |
| sfxSlide(); | |
| await glideAlong(pIdx, t => snakePathPoint(sqXY(land), sqXY(sn[1]), snakeIdx, t, sn[3]), 760); | |
| players[pIdx].pos = sn[1]; | |
| } | |
| renderSL(); | |
| busyAnim = false; | |
| } | |
| function glideAlong(pIdx, getPt, ms) { | |
| return new Promise(res => { | |
| const t0 = performance.now(); | |
| function fr(now) { | |
| const t = Math.min(1, (now - t0) / ms); | |
| const e = t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; | |
| tokenOverride[pIdx] = getPt(e); | |
| renderSL(); | |
| if (t < 1) requestAnimationFrame(fr); | |
| else { delete tokenOverride[pIdx]; res(); } | |
| } | |
| requestAnimationFrame(fr); | |
| }); | |
| } | |
| async function resolveThrow(seg) { | |
| const pl = players[turn]; | |
| logMsg(pl.name + ": " + seg.label); | |
| const from = pl.pos; | |
| const dest = from + seg.move; | |
| if (seg.move > 0 && dest > WIN) return "bust"; | |
| await animateMove(turn, from, dest); | |
| renderSL(); | |
| if (pl.pos === WIN) { | |
| onWin(turn); | |
| return true; | |
| } | |
| return false; | |
| } | |
| async function bustSequence(pIdx) { | |
| busyAnim = true; | |
| rotToken++; | |
| logMsg(players[pIdx].name + " went BUST — stays on " + players[pIdx].pos + "!"); | |
| sfxBust(); | |
| const el = document.getElementById("bust"); | |
| el.classList.add("show"); | |
| await sleep(1500); | |
| el.classList.remove("show"); | |
| busyAnim = false; | |
| if (phase !== "play") return; | |
| endTurnCommon(); | |
| } | |
| dCvs.addEventListener("pointerdown", e => { | |
| if (phase !== "play" || busyAnim || e.button !== 0) return; | |
| initAudio(); | |
| sfxThock(); | |
| const r = dCvs.getBoundingClientRect(); | |
| registerDart((e.clientX - r.left) / r.width * 480, (e.clientY - r.top) / r.height * 480); | |
| }); | |
| async function registerDart(tx, ty) { | |
| const pl = players[turn]; | |
| const seg = segAt(tx, ty); | |
| pins.push({ x: tx, y: ty, col: TOKEN_COLORS[turn] }); | |
| drawDartboard(); | |
| pl.thrown++; | |
| pl.dartsThisTurn++; | |
| throwGen++; | |
| lastThrow = { turn, posBefore: pl.pos, gen: throwGen }; | |
| if (!seg) { | |
| logMsg(pl.name + ": scored zero — no move, keep throwing!"); | |
| sfxMiss(); | |
| } else { | |
| const r = await resolveThrow(seg); | |
| if (r === true) return; | |
| if (r === "bust") { await bustSequence(turn); return; } | |
| } | |
| if (phase !== "play") return; | |
| if (pl.dartsThisTurn >= 3) { | |
| const token = ++rotToken; | |
| setTimeout(() => { | |
| if (phase !== "play" || token !== rotToken || busyAnim) return; | |
| endTurnCommon(); | |
| }, 400); | |
| } else { | |
| updateBanner(); | |
| } | |
| } | |
| function endTurnCommon() { | |
| players[turn].turns++; | |
| pins = []; | |
| if (gameWinners.length) { afterWinFlash(); return; } | |
| turn = (turn + 1) % players.length; | |
| players[turn].dartsThisTurn = 0; | |
| updateBanner(); | |
| renderSL(); | |
| logMsg(players[turn].name + "'s turn."); | |
| } | |
| function afterWinFlash() { | |
| if (catchupQueue.length) { | |
| phase = "play"; | |
| turn = catchupQueue.shift(); | |
| players[turn].dartsThisTurn = 0; | |
| pins = []; | |
| updateBanner(); | |
| renderSL(); | |
| logMsg("CATCH-UP TURN: " + players[turn].name + " — reach 64 to tie!"); | |
| } else { | |
| showResultsCard(); | |
| } | |
| } | |
| function onWin(pIdx) { | |
| if (!gameWinners.length) { | |
| gameWinners.push(pIdx); | |
| const T = players[pIdx].turns; | |
| const order = []; | |
| for (let i = 1; i <= players.length; i++) { | |
| const j = (pIdx + i) % players.length; | |
| if (players[j].turns < T) order.push(j); | |
| } | |
| catchupQueue = order; | |
| } else if (!gameWinners.includes(pIdx)) { | |
| gameWinners.push(pIdx); | |
| catchupQueue = catchupQueue.filter(i => i !== pIdx); | |
| } | |
| phase = "celebrate"; | |
| winSequence(pIdx, afterWinFlash); | |
| } | |
| dCvs.addEventListener("contextmenu", e => e.preventDefault()); | |
| document.getElementById("undo").onclick = () => { | |
| if (phase !== "play" || !lastThrow || busyAnim) return; | |
| if (throwGen !== lastThrow.gen) { | |
| logMsg("Too late to undo — the next player has already thrown."); | |
| return; | |
| } | |
| rotToken++; | |
| const lt = lastThrow; | |
| const pl = players[lt.turn]; | |
| pl.pos = lt.posBefore; | |
| pl.thrown--; | |
| pl.dartsThisTurn = Math.max(0, pl.dartsThisTurn - 1); | |
| pins.pop(); | |
| lastThrow = null; | |
| turn = lt.turn; | |
| logMsg("Last dart taken back."); | |
| updateBanner(); | |
| renderSL(); | |
| }; | |
| document.getElementById("soundbtn").onclick = e => { | |
| muted = !muted; | |
| e.target.textContent = muted ? "🔇" : "🔊"; | |
| }; | |
| let AC = null; | |
| function initAudio() { | |
| if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (err) {} } | |
| if (AC && AC.state === "suspended") AC.resume(); | |
| } | |
| function tone(f, dur, type, vol, delay) { | |
| if (muted || !AC) return; | |
| try { | |
| const t0 = AC.currentTime + (delay || 0); | |
| const o = AC.createOscillator(), g = AC.createGain(); | |
| o.type = type || "square"; o.frequency.setValueAtTime(f, t0); | |
| g.gain.setValueAtTime(vol || .05, t0); | |
| g.gain.exponentialRampToValueAtTime(.0001, t0 + dur); | |
| o.connect(g); g.connect(AC.destination); | |
| o.start(t0); o.stop(t0 + dur + .02); | |
| } catch (err) {} | |
| } | |
| function sfxThock() { tone(120, .06, "square", .09); } | |
| function sfxMiss() { tone(55, .12, "triangle", .05); } | |
| function sfxClimb() { [330, 415, 494, 622, 740].forEach((f, i) => tone(f, .1, "square", .05, i * .07)); } | |
| function sfxSlide() { [600, 500, 410, 330, 260, 200].forEach((f, i) => tone(f, .12, "triangle", .05, i * .08)); } | |
| function sfxBust() { [320, 250, 190, 130, 90].forEach((f, i) => tone(f, .16, "sawtooth", .07, i * .1)); } | |
| function sfxConfirm() { tone(660, .06, "square", .05); } | |
| function sfxFanfare() { | |
| const lead = [[392, 0, .18], [523, .18, .18], [659, .36, .18], [784, .54, .32], [659, .92, .15], [784, 1.12, .15], [1047, 1.32, .5], [784, 1.95, .15], [1047, 2.15, .75]]; | |
| lead.forEach(([f, d, len]) => tone(f, len, "square", .06, d)); | |
| [[131, 0, .5], [196, .6, .5], [262, 1.3, .7], [196, 2.1, .85]].forEach(([f, d, len]) => tone(f, len, "triangle", .08, d)); | |
| [[262, 2.15, .75], [330, 2.15, .75]].forEach(([f, d, len]) => tone(f, len, "square", .035, d)); | |
| } | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| const cCvs = document.getElementById("confetti"); | |
| const cCtx = cCvs.getContext("2d"); | |
| let confettiRAF = 0, confettiLast = 0; | |
| function startConfetti(ms) { | |
| cCvs.width = innerWidth; cCvs.height = innerHeight; | |
| const COLORS = ["#ff5b7b", "#ffd75e", "#4ad6ff", "#5ce8a0", "#9d8bff", "#ff9df0", "#ffffff"]; | |
| const bits = []; | |
| for (let i = 0; i < 170; i++) { | |
| bits.push({ | |
| x: Math.random() * cCvs.width, y: -20 - Math.random() * cCvs.height * .6, | |
| w: 5 + Math.random() * 6, h: 8 + Math.random() * 8, | |
| vy: 110 + Math.random() * 150, vx: (Math.random() - .5) * 40, | |
| rot: Math.random() * Math.PI * 2, vr: (Math.random() - .5) * 7, | |
| col: COLORS[i % COLORS.length], sway: Math.random() * Math.PI * 2 | |
| }); | |
| } | |
| const t0 = performance.now(); | |
| confettiLast = t0; | |
| cancelAnimationFrame(confettiRAF); | |
| function fr(now) { | |
| const dt = Math.min(.05, (now - confettiLast) / 1000); | |
| confettiLast = now; | |
| cCtx.clearRect(0, 0, cCvs.width, cCvs.height); | |
| let alive = false; | |
| for (const p of bits) { | |
| p.y += p.vy * dt; | |
| p.sway += dt * 3; | |
| p.x += (p.vx + Math.sin(p.sway) * 34) * dt; | |
| p.rot += p.vr * dt; | |
| if (p.y < cCvs.height + 24) alive = true; | |
| cCtx.save(); | |
| cCtx.translate(p.x, p.y); cCtx.rotate(p.rot); | |
| cCtx.fillStyle = p.col; | |
| cCtx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); | |
| cCtx.restore(); | |
| } | |
| if (alive && now - t0 < ms) confettiRAF = requestAnimationFrame(fr); | |
| else cCtx.clearRect(0, 0, cCvs.width, cCvs.height); | |
| } | |
| confettiRAF = requestAnimationFrame(fr); | |
| } | |
| async function winSequence(w, cb) { | |
| sfxFanfare(); | |
| const el = document.getElementById("winmsg"); | |
| el.querySelector("span").textContent = players[w].name + " WINS!"; | |
| el.classList.add("show"); | |
| startConfetti(3000); | |
| await sleep(3000); | |
| el.classList.remove("show"); | |
| if (cb) cb(); | |
| } | |
| function setupCard() { | |
| phase = "setup"; | |
| const card = document.getElementById("card"); | |
| card.innerHTML = | |
| "<h2>SNAKES & LADDERS DARTS</h2>" + | |
| "<p>Your dart is the dice: double 1 · outer single 2 · treble 3 · inner single 4 · outer bull 5 · inner bull 6.<br>Ladders lift you, snakes drag you down. Land exactly on 64 — overshooting is a BUST: you stay put and your turn ends!</p>" + | |
| "<p>Play with real darts — after each throw, tap the spot on the board where it landed.</p>" + | |
| "<div id='countrow'><button id='minus'>−</button><b id='cnt'>2</b><button id='plus'>+</button></div>" + | |
| "<p style='font-size:11px'>PLAYERS (tap a name to edit)</p>" + | |
| "<div id='names'></div>" + | |
| "<button class='bigbtn' id='startbtn'>START GAME</button>"; | |
| document.getElementById("overlay").style.display = "flex"; | |
| let count = 2; | |
| const namesDiv = document.getElementById("names"); | |
| function buildNames() { | |
| namesDiv.innerHTML = ""; | |
| for (let i = 0; i < count; i++) { | |
| const inp = document.createElement("input"); | |
| inp.className = "nameinp"; | |
| inp.maxLength = 12; | |
| inp.placeholder = "PLAYER " + (i + 1); | |
| inp.style.borderTopColor = TOKEN_COLORS[i]; | |
| namesDiv.appendChild(inp); | |
| } | |
| } | |
| buildNames(); | |
| document.getElementById("minus").onclick = () => { if (count > 2) { count--; buildNamesKeep(namesDiv, count); document.getElementById("cnt").textContent = count; sfxConfirm(); } }; | |
| document.getElementById("plus").onclick = () => { if (count < 6) { count++; buildNamesKeep(namesDiv, count); document.getElementById("cnt").textContent = count; sfxConfirm(); } }; | |
| document.getElementById("startbtn").onclick = () => { | |
| initAudio(); sfxConfirm(); | |
| const names = [...namesDiv.querySelectorAll("input")].map((el, i) => el.value.trim() || ("PLAYER " + (i + 1))); | |
| startGame(names); | |
| }; | |
| } | |
| function buildNamesKeep(div, count) { | |
| const old = [...div.querySelectorAll("input")].map(el => el.value); | |
| div.innerHTML = ""; | |
| for (let i = 0; i < count; i++) { | |
| const inp = document.createElement("input"); | |
| inp.className = "nameinp"; inp.maxLength = 12; | |
| inp.placeholder = "PLAYER " + (i + 1); | |
| inp.value = old[i] || ""; | |
| div.appendChild(inp); | |
| } | |
| } | |
| function startGame(names) { | |
| players = names.map((n, i) => ({ name: n.toUpperCase(), pos: 0, thrown: 0, dartsThisTurn: 0, turns: 0 })); | |
| turn = 0; pins = []; lastThrow = null; phase = "play"; | |
| gameWinners = []; catchupQueue = []; | |
| document.getElementById("log").innerHTML = ""; | |
| hideOverlay(); | |
| updateBanner(); | |
| renderSL(); | |
| logMsg(players[0].name + " throws first — tap where each dart lands!"); | |
| } | |
| function showResultsCard() { | |
| phase = "over"; | |
| const tie = gameWinners.length > 1; | |
| const order = [...players].sort((a, b) => b.pos - a.pos); | |
| const card = document.getElementById("card"); | |
| const title = tie ? "IT'S A TIE!" : "🏆 " + players[gameWinners[0]].name + " WINS!"; | |
| card.innerHTML = | |
| "<h2>" + title + "</h2>" + | |
| (tie ? "<p>" + gameWinners.map(i => players[i].name).join(" & ") + " all reached square 64!</p>" : "") + | |
| "<div id='podium'>" + order.map((pl, i) => | |
| (i + 1) + ". " + pl.name + " — square " + pl.pos + (gameWinners.includes(i) ? " 🏆" : "")).join("<br>") + "</div>" + | |
| "<button class='bigbtn' id='rematch'>PLAY AGAIN</button>"; | |
| document.getElementById("overlay").style.display = "flex"; | |
| document.getElementById("rematch").onclick = () => { sfxConfirm(); setupCard(); }; | |
| } | |
| function hideOverlay() { document.getElementById("overlay").style.display = "none"; } | |
| renderSL(); | |
| drawDartboard(); | |
| setupCard(); | |
| if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => renderSL()); | |
| (function slLoop() { | |
| if (phase === "play" && !busyAnim) renderSL(); | |
| requestAnimationFrame(slLoop); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |