Skip to main content

Fair Roulette

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fortune Wheel</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Cormorant+Garamond:wght@300;500&display=swap" rel="stylesheet" />
<style>
  :root {
    --gold: #c9a84c;
    --gold-light: #f0d080;
    --gold-dim: #7a6020;
    --bg: #0a0a0f;
    --surface: #13131a;
    --surface2: #1c1c28;
    --text: #e8dfc8;
    --text-dim: #8a7e60;
    --red: #8b1a1a;
    --green: #1a4a2e;
    --navy: #1a2040;
    --purple: #3a1a4a;
    --teal: #0f3a3a;
    --crimson: #6b1020;
    --glow: rgba(201, 168, 76, 0.4);
  }
 
  * { box-sizing: border-box; margin: 0; padding: 0; }
 
  body {
    background: var(--bg);
    color: var(--text);
    font-family: 'Cormorant Garamond', Georgia, serif;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 24px 16px;
    overflow-x: hidden;
  }
 
  /* Subtle noise texture overlay */
  body::before {
    content: '';
    position: fixed;
    inset: 0;
    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
    pointer-events: none;
    z-index: 0;
    opacity: 0.5;
  }
 
  .wrapper {
    position: relative;
    z-index: 1;
    width: 100%;
    max-width: 560px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 28px;
  }
 
  header {
    text-align: center;
  }
 
  header h1 {
    font-family: 'Playfair Display', serif;
    font-size: clamp(2rem, 6vw, 3rem);
    font-weight: 900;
    letter-spacing: 0.08em;
    background: linear-gradient(135deg, var(--gold-light) 0%, var(--gold) 50%, var(--gold-dim) 100%);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    text-transform: uppercase;
  }
 
  header p {
    color: var(--text-dim);
    font-size: 0.9rem;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    margin-top: 4px;
  }
 
  /* Controls */
  .controls {
    background: var(--surface);
    border: 1px solid rgba(201,168,76,0.15);
    border-radius: 4px;
    padding: 20px 24px;
    width: 100%;
    display: flex;
    align-items: flex-end;
    gap: 16px;
    flex-wrap: wrap;
  }
 
  .field {
    display: flex;
    flex-direction: column;
    gap: 6px;
    flex: 1;
    min-width: 80px;
  }
 
  .field label {
    font-size: 0.7rem;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    color: var(--gold);
    font-family: 'Cormorant Garamond', serif;
    font-weight: 500;
  }
 
  .field input[type="number"] {
    background: var(--surface2);
    border: 1px solid rgba(201,168,76,0.2);
    color: var(--text);
    font-family: 'Playfair Display', serif;
    font-size: 1.3rem;
    padding: 8px 12px;
    border-radius: 3px;
    width: 100%;
    outline: none;
    transition: border-color 0.2s;
    -moz-appearance: textfield;
  }
  .field input::-webkit-outer-spin-button,
  .field input::-webkit-inner-spin-button { -webkit-appearance: none; }
  .field input:focus { border-color: var(--gold); }
 
  .range-sep {
    color: var(--text-dim);
    font-size: 1.4rem;
    padding-bottom: 10px;
    font-family: 'Playfair Display', serif;
  }
 
  .btn-build {
    background: transparent;
    border: 1px solid var(--gold);
    color: var(--gold);
    font-family: 'Cormorant Garamond', serif;
    font-size: 0.75rem;
    letter-spacing: 0.2em;
    text-transform: uppercase;
    padding: 10px 18px;
    border-radius: 3px;
    cursor: pointer;
    transition: background 0.2s, color 0.2s;
    white-space: nowrap;
    align-self: flex-end;
  }
  .btn-build:hover { background: var(--gold); color: var(--bg); }
 
  /* Wheel area */
  .wheel-area {
    position: relative;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 20px;
    width: 100%;
  }
 
  .wheel-container {
    position: relative;
    width: clamp(280px, 80vw, 420px);
    height: clamp(280px, 80vw, 420px);
  }
 
  /* Pointer / arrow */
  .pointer {
    position: absolute;
    top: -14px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 10;
    filter: drop-shadow(0 0 6px var(--gold));
  }
  .pointer svg { display: block; }
 
  /* Glow ring behind canvas */
  .wheel-glow {
    position: absolute;
    inset: -10px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(201,168,76,0.08) 60%, transparent 75%);
    pointer-events: none;
    transition: opacity 0.4s;
    opacity: 0;
  }
  .wheel-glow.active { opacity: 1; }
 
  canvas {
    border-radius: 50%;
    display: block;
    width: 100%;
    height: 100%;
  }
 
  /* Center hub */
  .hub {
    position: absolute;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    width: 40px; height: 40px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 35%, #e8d090, #8a6018);
    border: 3px solid #1a1410;
    box-shadow: 0 0 12px rgba(0,0,0,0.8), 0 0 6px rgba(201,168,76,0.3);
    z-index: 5;
  }
 
  /* Spin button */
  .btn-spin {
    position: relative;
    background: linear-gradient(135deg, #8a6018 0%, var(--gold) 50%, #8a6018 100%);
    border: none;
    color: #0a0a0f;
    font-family: 'Playfair Display', serif;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    padding: 14px 48px;
    border-radius: 3px;
    cursor: pointer;
    box-shadow: 0 4px 20px rgba(201,168,76,0.25);
    transition: transform 0.1s, box-shadow 0.2s, opacity 0.2s;
    overflow: hidden;
  }
  .btn-spin::before {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.2) 50%, transparent 70%);
    transform: translateX(-100%);
    transition: transform 0.5s;
  }
  .btn-spin:hover::before { transform: translateX(100%); }
  .btn-spin:hover { box-shadow: 0 6px 30px rgba(201,168,76,0.45); transform: translateY(-1px); }
  .btn-spin:active { transform: translateY(0); }
  .btn-spin:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
 
  /* Result display */
  .result-card {
    background: var(--surface);
    border: 1px solid rgba(201,168,76,0.2);
    border-radius: 4px;
    padding: 18px 32px;
    text-align: center;
    width: 100%;
    min-height: 78px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 4px;
    transition: border-color 0.4s;
  }
  .result-card.highlight { border-color: var(--gold); }
 
  .result-label {
    font-size: 0.65rem;
    letter-spacing: 0.25em;
    text-transform: uppercase;
    color: var(--text-dim);
  }
 
  .result-value {
    font-family: 'Playfair Display', serif;
    font-size: clamp(2.2rem, 8vw, 3.5rem);
    font-weight: 900;
    color: var(--gold-light);
    line-height: 1;
    text-shadow: 0 0 30px rgba(240,208,128,0.4);
    transition: opacity 0.3s;
  }
 
  .result-sub {
    font-size: 0.75rem;
    color: var(--text-dim);
    letter-spacing: 0.1em;
  }
 
  .placeholder-msg {
    color: var(--text-dim);
    font-size: 0.85rem;
    letter-spacing: 0.1em;
    font-style: italic;
  }
 
  /* Error */
  .error-msg {
    color: #e05050;
    font-size: 0.8rem;
    letter-spacing: 0.1em;
    text-align: center;
    min-height: 18px;
  }
 
  /* Note about 3 */
  .note {
    font-size: 0.72rem;
    color: var(--text-dim);
    letter-spacing: 0.08em;
    text-align: center;
    line-height: 1.6;
  }
  .note span { color: var(--gold); }
 
  /* Spinning animation on wheel */
  @keyframes pulseGold {
    0%, 100% { box-shadow: 0 0 18px rgba(201,168,76,0.2); }
    50% { box-shadow: 0 0 40px rgba(201,168,76,0.6); }
  }
  .result-card.highlight { animation: pulseGold 1.5s ease-in-out 3; }
 
  /* Divider */
  .divider {
    width: 100%;
    height: 1px;
    background: linear-gradient(90deg, transparent, rgba(201,168,76,0.2), transparent);
  }
</style>
</head>
<body>
<div class="wrapper">
  <header>
    <h1>Fortune Wheel</h1>
    <p>Spin &amp; Discover Your Number</p>
  </header>
 
  <div class="controls">
    <div class="field">
      <label>Lower Bound</label>
      <input type="number" id="lb" value="1" step="1" />
    </div>
    <div class="range-sep">—</div>
    <div class="field">
      <label>Upper Bound</label>
      <input type="number" id="ub" value="8" step="1" />
    </div>
    <button class="btn-build" onclick="buildWheel()">Build</button>
  </div>
 
  <div class="error-msg" id="errorMsg"></div>
 
  <div class="wheel-area">
    <div class="wheel-container" id="wheelContainer">
      <div class="wheel-glow" id="wheelGlow"></div>
      <!-- Pointer arrow at top -->
      <div class="pointer">
        <svg width="24" height="28" viewBox="0 0 24 28" fill="none">
          <path d="M12 28 L0 4 Q12 0 24 4 Z" fill="#c9a84c"/>
          <path d="M12 26 L2 6 Q12 2 22 6 Z" fill="#f0d080"/>
        </svg>
      </div>
      <canvas id="wheelCanvas"></canvas>
      <div class="hub"></div>
    </div>
 
    <button class="btn-spin" id="spinBtn" onclick="spin()" disabled>SPIN</button>
  </div>
 
  <div class="divider"></div>
 
  <div class="result-card" id="resultCard">
    <p class="placeholder-msg">Build your wheel &amp; spin to reveal</p>
  </div>
 
  <p class="note">
    Numbers from <span id="noteRange">—</span> each have a perfectly <span>equal chance</span>
    &nbsp;·&nbsp; 40% chance of a reverse <span>bounce</span> on each spin
  </p>
</div>
 
<script>
// ─── State ──────────────────────────────────────────────────────────────────
let wheelData = null;
let currentRotation = 0;
let isSpinning = false;
let animFrame = null;
 
const SEGMENT_COLORS = [
  ['#6b1010','#9b2020'],
  ['#1a3a1a','#2a5a2a'],
  ['#0f1f4a','#1a3070'],
  ['#2a0f4a','#441880'],
  ['#0f2f3a','#175060'],
  ['#3a1a0a','#6a3010'],
  ['#1a0a2a','#302050'],
  ['#1f1a0f','#40381a'],
];
 
// ─── Build Wheel ─────────────────────────────────────────────────────────────
function buildWheel() {
  const lbEl = document.getElementById('lb');
  const ubEl = document.getElementById('ub');
  const errEl = document.getElementById('errorMsg');
 
  const lb = parseInt(lbEl.value);
  const ub = parseInt(ubEl.value);
 
  errEl.textContent = '';
 
  if (isNaN(lb) || isNaN(ub)) { errEl.textContent = 'Please enter valid integers.'; return; }
  if (lb > ub) { errEl.textContent = 'Lower bound must be ≤ upper bound.'; return; }
  if (ub - lb > 49) { errEl.textContent = 'Range too large — please keep it within 50 numbers.'; return; }
 
  const numbers = [];
  for (let i = lb; i <= ub; i++) numbers.push(i);
  const N = numbers.length;
 
  // All equal probability
  const probs = numbers.map(() => 1 / N);
 
  // Build segments — equal angular slices
  const segAngle = (2 * Math.PI) / N;
  let angle = -Math.PI / 2;
  const segments = numbers.map((n, i) => {
    const start = angle;
    const end = angle + segAngle;
    const mid = angle + segAngle / 2;
    angle += segAngle;
    return { number: n, prob: 1 / N, startAngle: start, endAngle: end, midAngle: mid, colorIdx: i % SEGMENT_COLORS.length };
  });
 
  wheelData = { numbers, probs, segments };
  currentRotation = 0;
 
  document.getElementById('noteRange').textContent = `${lb} to ${ub}`;
  document.getElementById('spinBtn').disabled = false;
  document.getElementById('resultCard').className = 'result-card';
  document.getElementById('resultCard').innerHTML = '<p class="placeholder-msg">Ready — hit SPIN!</p>';
 
  drawWheel(0);
}
 
// ─── Draw ────────────────────────────────────────────────────────────────────
function drawWheel(rotation) {
  if (!wheelData) return;
 
  const canvas = document.getElementById('wheelCanvas');
  const container = document.getElementById('wheelContainer');
  const size = container.clientWidth;
  canvas.width = size * devicePixelRatio;
  canvas.height = size * devicePixelRatio;
  canvas.style.width = size + 'px';
  canvas.style.height = size + 'px';
 
  const ctx = canvas.getContext('2d');
  ctx.scale(devicePixelRatio, devicePixelRatio);
 
  const cx = size / 2;
  const cy = size / 2;
  const R = size / 2 - 8;
 
  ctx.clearRect(0, 0, size, size);
 
  // Outer gold ring
  ctx.beginPath();
  ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
  const goldRing = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
  goldRing.addColorStop(0, '#8a6018');
  goldRing.addColorStop(0.5, '#c9a84c');
  goldRing.addColorStop(1, '#f0d080');
  ctx.fillStyle = goldRing;
  ctx.fill();
 
  wheelData.segments.forEach((seg) => {
    const start = seg.startAngle + rotation;
    const end = seg.endAngle + rotation;
    const [dark, light] = SEGMENT_COLORS[seg.colorIdx];
 
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.arc(cx, cy, R, start, end);
    ctx.closePath();
 
    const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
    grad.addColorStop(0, light);
    grad.addColorStop(1, dark);
    ctx.fillStyle = grad;
    ctx.fill();
 
    ctx.strokeStyle = 'rgba(0,0,0,0.5)';
    ctx.lineWidth = 1.5;
    ctx.stroke();
 
    // Label
    const labelR = R * 0.68;
    const midAngle = (start + end) / 2;
    const lx = cx + labelR * Math.cos(midAngle);
    const ly = cy + labelR * Math.sin(midAngle);
 
    ctx.save();
    ctx.translate(lx, ly);
    ctx.rotate(midAngle + Math.PI / 2);
 
    const sweep = seg.endAngle - seg.startAngle;
    const fontSize = Math.max(9, Math.min(sweep * R * 0.38, R * 0.22));
    ctx.font = `bold ${fontSize}px 'Playfair Display', Georgia, serif`;
    ctx.fillStyle = '#ffffff';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.shadowColor = 'rgba(0,0,0,0.8)';
    ctx.shadowBlur = 4;
    ctx.fillText(String(seg.number), 0, 0);
    ctx.restore();
 
    // Tick mark
    const tickX = cx + (R - 4) * Math.cos(start);
    const tickY = cy + (R - 4) * Math.sin(start);
    ctx.beginPath();
    ctx.arc(tickX, tickY, 2, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(201,168,76,0.5)';
    ctx.fill();
  });
 
  // Inner circle
  ctx.beginPath();
  ctx.arc(cx, cy, R * 0.24, 0, Math.PI * 2);
  ctx.fillStyle = '#0d0d14';
  ctx.fill();
  ctx.strokeStyle = '#c9a84c';
  ctx.lineWidth = 2;
  ctx.stroke();
}
 
// ─── Find which segment is under the pointer ──────────────────────────────────
function segmentAtPointer(rotation) {
  // Pointer is at angle -π/2 in world space.
  // A point on the wheel at angle θ (in wheel space) appears at θ + rotation.
  // We want θ + rotation ≡ -π/2  →  θ ≡ -π/2 - rotation
  const pointerAngle = ((-Math.PI / 2 - rotation) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI);
  // Segment angles are stored from -π/2, convert to [0, 2π)
  return wheelData.segments.find(seg => {
    const s = ((seg.startAngle + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI));
    const e = ((seg.endAngle   + Math.PI / 2 + 2 * Math.PI) % (2 * Math.PI));
    if (s < e) return pointerAngle >= s && pointerAngle < e;
    return pointerAngle >= s || pointerAngle < e; // wraps around 0
  }) || wheelData.segments[0];
}
 
// ─── Spin ─────────────────────────────────────────────────────────────────────
function spin() {
  if (isSpinning || !wheelData) return;
  isSpinning = true;
 
  const spinBtn   = document.getElementById('spinBtn');
  const resultCard = document.getElementById('resultCard');
  const glow      = document.getElementById('wheelGlow');
 
  spinBtn.disabled = true;
  resultCard.className = 'result-card';
  resultCard.innerHTML = '<p class="placeholder-msg">Spinning…</p>';
 
  // Pick winner uniformly
  const winIdx = Math.floor(Math.random() * wheelData.segments.length);
  const winner = wheelData.segments[winIdx];
 
  // Land at a random offset within the winning segment (not exactly center)
  // Keep away from edges by 15% of segment width
  const sweep = winner.endAngle - winner.startAngle;
  const margin = sweep * 0.15;
  const randomOffset = margin + Math.random() * (sweep - 2 * margin);
  const targetAngleOnWheel = winner.startAngle + randomOffset; // where we want pointer to point
 
  // Compute how much we need to rotate so targetAngleOnWheel sits under the pointer (-π/2)
  const rawTarget = -Math.PI / 2 - targetAngleOnWheel - currentRotation;
  const normalised = ((rawTarget % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
  const EXTRA_SPINS = 6 + Math.floor(Math.random() * 4);
  const primarySpin = normalised + EXTRA_SPINS * 2 * Math.PI;
 
  // Decide if we do a reverse-bounce (40% chance)
  const doBounce = Math.random() < 0.40;
  // Bounce: overshoot by a small amount, then spring back
  const bounceOvershoot = doBounce ? (0.04 + Math.random() * 0.10) * 2 * Math.PI : 0; // 14°–36°
 
  const startRotation = currentRotation;
  glow.classList.add('active');
 
  // ── Phase 1: main deceleration spin ──────────────────────────────────────
  const phase1End = startRotation + primarySpin + bounceOvershoot;
  const phase1Duration = 4200 + Math.random() * 1400;
 
  // ── Phase 2: bounce back (if doBounce) ───────────────────────────────────
  const phase2Start = phase1End;
  const phase2End   = startRotation + primarySpin; // back to where it should land
  const phase2Duration = 500 + Math.random() * 300;
 
  const startTime = performance.now();
  let phase = 1;
 
  // Strong ease-out: fast start, very slow finish — feels like real friction
  function easeOutQuint(t) {
    return 1 - Math.pow(1 - t, 5);
  }
  // Ease-out then ease-in for bounce-back (like a spring)
  function easeInOutCubic(t) {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }
 
  function animate(now) {
    if (phase === 1) {
      const elapsed = now - startTime;
      const t = Math.min(elapsed / phase1Duration, 1);
      const eased = easeOutQuint(t);
      currentRotation = startRotation + (phase1End - startRotation) * eased;
      drawWheel(currentRotation);
 
      if (t < 1) {
        animFrame = requestAnimationFrame(animate);
      } else {
        currentRotation = phase1End;
        if (doBounce) {
          phase = 2;
          // store phase 2 start time
          animate._phase2Start = now;
          animFrame = requestAnimationFrame(animate);
        } else {
          finish(winner);
        }
      }
    } else {
      // Phase 2: spring back
      const elapsed = now - animate._phase2Start;
      const t = Math.min(elapsed / phase2Duration, 1);
      const eased = easeInOutCubic(t);
      currentRotation = phase2Start + (phase2End - phase2Start) * eased;
      drawWheel(currentRotation);
 
      if (t < 1) {
        animFrame = requestAnimationFrame(animate);
      } else {
        currentRotation = phase2End;
        finish(winner);
      }
    }
  }
 
  animFrame = requestAnimationFrame(animate);
}
 
function finish(winner) {
  const spinBtn    = document.getElementById('spinBtn');
  const glow       = document.getElementById('wheelGlow');
  const isSpinningRef = isSpinning;
 
  isSpinning = false;
  spinBtn.disabled = false;
  drawWheel(currentRotation);
 
  // Re-detect actual segment under pointer (accounts for random offset)
  const actual = segmentAtPointer(currentRotation);
  showResult(actual || winner);
  setTimeout(() => glow.classList.remove('active'), 3000);
}
 
// ─── Show Result ──────────────────────────────────────────────────────────────
function showResult(winner) {
  const resultCard = document.getElementById('resultCard');
  const pct = (winner.prob * 100).toFixed(1);
 
  resultCard.innerHTML = `
    <span class="result-label">The wheel has spoken</span>
    <span class="result-value">${winner.number}</span>
    <span class="result-sub">${pct}% probability</span>
  `;
  resultCard.className = 'result-card highlight';
}
 
// ─── Init ─────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
  const canvas = document.getElementById('wheelCanvas');
  const container = document.getElementById('wheelContainer');
  const size = container.clientWidth || 380;
  canvas.width = size * devicePixelRatio;
  canvas.height = size * devicePixelRatio;
  canvas.style.width = size + 'px';
  canvas.style.height = size + 'px';
  const ctx = canvas.getContext('2d');
  ctx.scale(devicePixelRatio, devicePixelRatio);
  const cx = size / 2, cy = size / 2, R = size / 2 - 8;
  ctx.beginPath();
  ctx.arc(cx, cy, R + 6, 0, Math.PI * 2);
  const gr = ctx.createRadialGradient(cx, cy, R, cx, cy, R + 6);
  gr.addColorStop(0, '#8a6018'); gr.addColorStop(0.5, '#c9a84c'); gr.addColorStop(1, '#f0d080');
  ctx.fillStyle = gr; ctx.fill();
  ctx.beginPath();
  ctx.arc(cx, cy, R, 0, Math.PI * 2);
  ctx.fillStyle = '#13131a'; ctx.fill();
  ctx.font = `italic ${Math.floor(R * 0.14)}px 'Cormorant Garamond', Georgia, serif`;
  ctx.fillStyle = 'rgba(201,168,76,0.4)';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText('Set bounds & build', cx, cy);
  buildWheel();
});
 
window.addEventListener('resize', () => {
  if (wheelData) drawWheel(currentRotation);
});
</script>
</body>
</html>