snakesAndLaddersDarts / index.html
garrec2's picture
Upload index.html
88848a4 verified
Raw
History Blame Contribute Delete
32 kB
<!DOCTYPE html>
<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> &amp; <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>
"use strict";
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 &amp; 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>