← back to Nineoh Guide

apps/web/public/games/roulette.html

1160 lines

<!DOCTYPE html>
<!--
  90210 ROULETTE — PLAN
  =====================
  American-style roulette (0, 00, 1–36) rendered on a <canvas> element.

  WHEEL:
  - 38 pockets (0, 00, 1–36) in authentic American layout order.
  - Drawn with requestAnimationFrame: animated spin, decelerating ball
    physics with bounce/scatter effect, ball settles in pocket.
  - Wheel spins one direction; ball counter-rotates then slows + spirals in.
  - Pockets colored: 0/00 = green, alternating red/black per standard layout.

  BETS:
  - Inside bets: Straight Up (single number), clicked on the number grid.
  - Outside bets: Red/Black, Odd/Even, 1–18/19–36, Dozens (1st/2nd/3rd 12),
    Columns (1/2/3).
  - Multiple bets per spin, shown as stacked chip icons on the grid.
  - Chip values: $5, $10, $25, $50, $100.
  - Payouts: Straight Up 35:1, Outside 1:1, Dozens/Columns 2:1.

  STATE MACHINE: betting → spinning → result → betting
  - SPIN button launches the wheel animation (~4.5s).
  - After the ball settles, winning pockets are highlighted and
    winning bets are paid out. Losing bets disappear.
  - In-page result banner (no alert).

  PERSISTENCE: bankroll + biggest single win in localStorage.
  SOUND: Web Audio — ball tick on pocket edge, wheel whirr (oscillator),
    win chime, lose low note, chip click.

  UI: Gold-on-marble. Same serif/sans system stack as Blackjack sibling.
  Canvas wheel centered above the number layout grid.
  Fully touch-compatible — tap to bet, SPIN button.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>90210 Roulette — Beverly Hills Casino</title>
<style>
:root {
  --gold:        #c9a961;
  --gold-light:  #e8d49a;
  --gold-dark:   #9a7a3a;
  --champagne:   #f0e6c8;
  --marble:      #f7f3ed;
  --felt:        #1c3d2a;
  --felt-dark:   #122a1c;
  --ink:         #1a1209;
  --cream:       #faf6ef;
  --red-pocket:  #b52a2a;
  --serif:       "Cormorant Garamond","Garamond","Times New Roman",Georgia,serif;
  --sans:        "Gill Sans","Optima","Trebuchet MS",system-ui,sans-serif;
}
*, *::before, *::after { box-sizing:border-box; margin:0; padding:0; }
html, body { height:100%; overflow-x:hidden; }

body {
  background:#0e1a14;
  font-family:var(--sans);
  color:var(--cream);
  min-height:100%;
  display:flex;
  flex-direction:column;
  align-items:center;
  user-select:none;
  -webkit-tap-highlight-color:transparent;
}

/* ── HEADER ── */
header {
  width:100%; text-align:center;
  padding:16px 20px 10px;
  border-bottom:1px solid rgba(201,169,97,0.2);
  background:linear-gradient(180deg,#0a110d,#0e1a14);
  position:relative;
}
.wordmark {
  font-family:var(--serif);
  font-size:clamp(20px,4vw,32px);
  letter-spacing:0.25em;
  color:var(--gold);
  text-transform:uppercase;
  font-weight:400;
}
.wordmark span { color:var(--champagne); }
.tagline {
  font-family:var(--serif);
  font-size:clamp(9px,1.5vw,12px);
  letter-spacing:0.4em;
  color:rgba(201,169,97,0.5);
  text-transform:uppercase;
  margin-top:2px;
}
#muteBtn {
  position:absolute; right:14px; top:50%; transform:translateY(-50%);
  background:rgba(201,169,97,0.1); border:1px solid rgba(201,169,97,0.3);
  color:var(--gold); border-radius:50%;
  width:34px; height:34px; font-size:15px; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  transition:background 0.2s;
}
#muteBtn:hover { background:rgba(201,169,97,0.2); }

/* ── BANKROLL BAR ── */
.bankroll-bar {
  display:flex; align-items:center; justify-content:center;
  gap:28px; padding:8px 20px;
  font-size:clamp(10px,1.8vw,12px); letter-spacing:0.12em;
  color:rgba(240,230,200,0.6);
  border-bottom:1px solid rgba(201,169,97,0.08);
}
.bankroll-bar .val {
  font-family:var(--serif);
  font-size:clamp(14px,2.5vw,19px);
  color:var(--gold); letter-spacing:0.05em;
}

/* ── MAIN LAYOUT ── */
.main-wrap {
  width:100%; max-width:960px;
  display:flex; flex-direction:column;
  align-items:center; gap:0; padding:12px 12px 24px;
}

/* ── WHEEL CANVAS ── */
#wheelCanvas {
  width:clamp(220px,50vw,300px);
  height:clamp(220px,50vw,300px);
  border-radius:50%;
  box-shadow: 0 0 0 4px var(--gold-dark),
              0 0 0 8px #0a1a10,
              0 0 0 10px rgba(201,169,97,0.3),
              0 20px 60px rgba(0,0,0,0.7);
  display:block; margin:0 auto 10px;
  cursor:default;
}

/* ── RESULT BANNER ── */
#resultBanner {
  background:rgba(10,18,13,0.95);
  border:1.5px solid var(--gold);
  border-radius:8px;
  padding:8px 20px;
  font-family:var(--serif);
  text-align:center;
  margin:4px 0 8px;
  min-width:220px;
  transition:opacity 0.4s;
  opacity:0;
  pointer-events:none;
}
#resultBanner.show { opacity:1; }
.rb-number {
  font-size:clamp(26px,5vw,40px);
  letter-spacing:0.05em;
  line-height:1;
}
.rb-number.green { color:#3db87a; }
.rb-number.red   { color:#e05050; }
.rb-number.black { color:var(--champagne); }
.rb-label {
  font-size:clamp(10px,1.8vw,13px);
  letter-spacing:0.3em;
  color:rgba(240,230,200,0.6);
  margin-top:2px;
}
.rb-payout {
  font-size:clamp(14px,2.5vw,20px);
  margin-top:4px;
  letter-spacing:0.05em;
}
.rb-payout.win  { color:#80d890; }
.rb-payout.lose { color:#e07070; }
.rb-payout.push { color:var(--champagne); }

/* ── STATUS ── */
#statusMsg {
  font-family:var(--serif);
  font-size:clamp(12px,2vw,15px);
  letter-spacing:0.15em;
  color:rgba(240,230,200,0.65);
  text-align:center; min-height:20px;
  margin-bottom:6px;
}

/* ── CHIP SELECTOR ── */
.chip-selector {
  display:flex; gap:7px; flex-wrap:wrap;
  justify-content:center; margin-bottom:10px;
}
.chip-sel {
  width:clamp(40px,7vw,50px); height:clamp(40px,7vw,50px);
  border-radius:50%;
  border:2.5px dashed rgba(255,255,255,0.3);
  display:flex; align-items:center; justify-content:center;
  font-family:var(--serif); font-size:clamp(10px,1.8vw,13px); font-weight:600;
  cursor:pointer;
  transition:transform 0.1s, box-shadow 0.15s, border-color 0.15s;
  box-shadow:0 2px 6px rgba(0,0,0,0.4);
}
.chip-sel:hover   { transform:scale(1.1); }
.chip-sel:active  { transform:scale(0.95); }
.chip-sel.active  { border-color:white; box-shadow:0 0 12px rgba(255,255,255,0.5); transform:scale(1.08); }
.chip-sel.c5   { background:radial-gradient(circle at 35% 35%,#e05050,#8b1a1a); color:#fde8e8; }
.chip-sel.c10  { background:radial-gradient(circle at 35% 35%,#5080e0,#1a3a8b); color:#e8eefe; }
.chip-sel.c25  { background:radial-gradient(circle at 35% 35%,#50b050,#1a5a1a); color:#e8fde8; }
.chip-sel.c50  { background:radial-gradient(circle at 35% 35%,#c9a961,#7a5a1a); color:#fdf0d0; }
.chip-sel.c100 { background:radial-gradient(circle at 35% 35%,#2a2a2a,#111); color:var(--gold); border-color:rgba(201,169,97,0.5); }

/* ── BETTING TABLE ── */
.table-outer {
  background:radial-gradient(ellipse 90% 80% at 50% 50%, #26563a 0%, #1c3d2a 55%, #122a1c 100%);
  border-radius:10px;
  border:2.5px solid var(--gold-dark);
  box-shadow:0 0 0 5px #0a1a10, 0 0 0 7px var(--gold-dark),
             inset 0 2px 8px rgba(0,0,0,0.4),
             0 12px 40px rgba(0,0,0,0.6);
  padding:10px;
  width:100%;
  position:relative;
}
.table-outer::before {
  content:''; position:absolute; inset:6px;
  border:1px solid rgba(201,169,97,0.12);
  border-radius:6px; pointer-events:none;
}

/* ── NUMBER GRID ── */
.number-grid {
  display:grid;
  grid-template-columns: 36px repeat(12,1fr);
  gap:2px;
  width:100%;
  margin-bottom:6px;
}

.cell {
  aspect-ratio:0.7;
  border-radius:3px;
  display:flex; align-items:center; justify-content:center;
  font-family:var(--serif); font-size:clamp(9px,1.6vw,13px);
  cursor:pointer;
  position:relative;
  transition:opacity 0.1s, transform 0.1s;
  border:1px solid transparent;
  min-height:28px;
}
.cell:hover   { opacity:0.85; transform:scale(1.05); border-color:rgba(255,255,255,0.4); z-index:2; }
.cell:active  { transform:scale(0.95); }
.cell.num-red   { background:var(--red-pocket); color:#fff; }
.cell.num-black { background:#1a1a1a; color:#fff; }
.cell.num-green { background:#1a6b32; color:#fff; font-size:clamp(7px,1.3vw,10px); }
.cell.zero-col  { grid-column:1; grid-row:1/4; border-radius:4px 4px 4px 4px;
                  display:flex; flex-direction:column; gap:4px;
                  background:transparent; cursor:default; border:none; }
.cell.zero-col:hover { transform:none; opacity:1; }
.zero-single {
  flex:1; border-radius:3px;
  display:flex; align-items:center; justify-content:center;
  font-family:var(--serif); font-size:clamp(8px,1.4vw,11px);
  cursor:pointer; transition:opacity 0.1s, transform 0.1s;
  border:1px solid transparent;
}
.zero-single:hover  { opacity:0.85; transform:scale(1.05); border-color:rgba(255,255,255,0.4); }
.zero-single:active { transform:scale(0.95); }
.zero-single.num-green { background:#1a6b32; color:#fff; }

/* winning highlight */
.cell.winner, .zero-single.winner {
  outline:2.5px solid var(--gold-light);
  box-shadow:0 0 12px rgba(201,169,97,0.8);
  z-index:5;
  animation:winPulse 0.6s ease 3;
}
@keyframes winPulse {
  0%,100% { box-shadow:0 0 12px rgba(201,169,97,0.8); }
  50%      { box-shadow:0 0 24px rgba(201,169,97,1); }
}

/* ── CHIP INDICATOR ON CELL ── */
.chip-dot {
  position:absolute; bottom:2px; right:2px;
  background:var(--gold); color:var(--ink);
  border-radius:50%; width:14px; height:14px;
  display:flex; align-items:center; justify-content:center;
  font-size:7px; font-weight:bold;
  box-shadow:0 1px 4px rgba(0,0,0,0.5);
  pointer-events:none;
}

/* ── OUTSIDE BETS ── */
.outside-grid {
  display:grid;
  grid-template-columns:36px repeat(6,1fr);
  gap:2px; width:100%; margin-bottom:4px;
}
.outside-grid .spacer { grid-column:1; }

.out-cell {
  border-radius:3px;
  padding:5px 4px;
  display:flex; align-items:center; justify-content:center;
  font-family:var(--serif); font-size:clamp(8px,1.5vw,12px);
  letter-spacing:0.05em;
  cursor:pointer; border:1px solid rgba(201,169,97,0.25);
  color:var(--champagne);
  background:rgba(0,0,0,0.3);
  transition:background 0.15s, transform 0.1s, border-color 0.15s;
  text-align:center; line-height:1.2;
  position:relative; min-height:32px;
}
.out-cell:hover   { background:rgba(201,169,97,0.12); border-color:rgba(201,169,97,0.5); }
.out-cell:active  { transform:scale(0.97); }
.out-cell.red-bg  { background:rgba(140,30,30,0.5); }
.out-cell.blk-bg  { background:rgba(20,20,20,0.7); }
.out-cell.winner  { border-color:var(--gold-light); box-shadow:0 0 10px rgba(201,169,97,0.7); animation:winPulse 0.6s ease 3; }

.dozen-grid {
  display:grid;
  grid-template-columns:36px repeat(3,1fr);
  gap:2px; width:100%; margin-bottom:2px;
}
.dozen-grid .spacer { grid-column:1; }

.col-grid {
  display:grid;
  grid-template-columns:36px repeat(3,1fr);
  gap:2px; width:100%;
}
.col-grid .spacer { grid-column:1; }

/* ── BOTTOM CONTROLS ── */
.controls-row {
  display:flex; gap:10px; justify-content:center;
  flex-wrap:wrap; margin-top:12px;
}
.btn {
  font-family:var(--serif);
  font-size:clamp(12px,2vw,15px);
  letter-spacing:0.18em; text-transform:uppercase;
  padding:clamp(9px,2vw,12px) clamp(20px,4vw,36px);
  border-radius:4px; border:none; cursor:pointer;
  transition:transform 0.1s, box-shadow 0.15s, opacity 0.2s;
}
.btn:active   { transform:scale(0.96); }
.btn:disabled { opacity:0.35; cursor:not-allowed; transform:none !important; }
.btn-spin {
  background:linear-gradient(135deg,var(--gold-light),var(--gold),var(--gold-dark));
  color:var(--ink);
  box-shadow:0 3px 14px rgba(201,169,97,0.4);
  min-width:120px;
}
.btn-spin:hover:not(:disabled) { box-shadow:0 5px 20px rgba(201,169,97,0.6); }
.btn-clear {
  background:rgba(201,169,97,0.1);
  color:var(--gold); border:1px solid rgba(201,169,97,0.35);
}
.btn-clear:hover:not(:disabled) { background:rgba(201,169,97,0.18); }
.btn-reset {
  background:rgba(160,50,50,0.12);
  color:#e08888; border:1px solid rgba(180,60,60,0.3);
}
.btn-reset:hover:not(:disabled) { background:rgba(160,50,50,0.22); }

/* ── FOOTER ── */
footer {
  width:100%; text-align:center;
  padding:10px 20px; font-size:10px;
  letter-spacing:0.25em;
  color:rgba(201,169,97,0.22); text-transform:uppercase;
  border-top:1px solid rgba(201,169,97,0.07);
}
</style>
</head>
<body>

<header>
  <div class="wordmark">90210 <span>Roulette</span></div>
  <div class="tagline">Beverly Hills Casino Royale</div>
  <button id="muteBtn" aria-label="Toggle sound" title="Toggle sound">🔊</button>
</header>

<div class="bankroll-bar">
  <div>
    <div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.45);margin-bottom:2px">BANKROLL</div>
    <div class="val" id="bankrollDisplay">$1,000</div>
  </div>
  <div>
    <div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.45);margin-bottom:2px">TOTAL BET</div>
    <div class="val" id="totalBetDisplay">—</div>
  </div>
  <div>
    <div style="font-size:0.85em;letter-spacing:0.12em;color:rgba(240,230,200,0.45);margin-bottom:2px">BEST WIN</div>
    <div class="val" id="bestWinDisplay">$0</div>
  </div>
</div>

<div class="main-wrap">

  <!-- WHEEL -->
  <canvas id="wheelCanvas" width="400" height="400" aria-label="Roulette wheel"></canvas>

  <!-- RESULT BANNER -->
  <div id="resultBanner">
    <div class="rb-number" id="rbNumber">0</div>
    <div class="rb-label" id="rbLabel">GREEN</div>
    <div class="rb-payout" id="rbPayout">+$0</div>
  </div>

  <div id="statusMsg">Select a chip, then click to place bets</div>

  <!-- CHIP SELECTOR -->
  <div class="chip-selector" id="chipSelector">
    <div class="chip-sel c5"   data-val="5"   role="button" tabindex="0" aria-label="$5 chip">$5</div>
    <div class="chip-sel c10"  data-val="10"  role="button" tabindex="0" aria-label="$10 chip">$10</div>
    <div class="chip-sel c25"  data-val="25"  role="button" tabindex="0" aria-label="$25 chip">$25</div>
    <div class="chip-sel c50"  data-val="50"  role="button" tabindex="0" aria-label="$50 chip">$50</div>
    <div class="chip-sel c100" data-val="100" role="button" tabindex="0" aria-label="$100 chip">$100</div>
  </div>

  <!-- BETTING TABLE -->
  <div class="table-outer">

    <!-- Zero column + number grid (3 rows of 12) -->
    <div class="number-grid" id="numberGrid">
      <!-- zero column injected by JS -->
    </div>

    <!-- Dozens -->
    <div class="dozen-grid" id="dozenGrid">
      <div class="spacer"></div>
      <div class="out-cell" data-bet="dozen1" aria-label="1st 12">1st 12</div>
      <div class="out-cell" data-bet="dozen2" aria-label="2nd 12">2nd 12</div>
      <div class="out-cell" data-bet="dozen3" aria-label="3rd 12">3rd 12</div>
    </div>

    <!-- Columns -->
    <div class="col-grid" id="colGrid">
      <div class="spacer"></div>
      <div class="out-cell" data-bet="col1" aria-label="Column 1 (3,6,9…)">Col 1</div>
      <div class="out-cell" data-bet="col2" aria-label="Column 2 (2,5,8…)">Col 2</div>
      <div class="out-cell" data-bet="col3" aria-label="Column 3 (1,4,7…)">Col 3</div>
    </div>

    <!-- Outside even-money bets -->
    <div class="outside-grid" id="outsideGrid" style="margin-top:4px">
      <div class="spacer"></div>
      <div class="out-cell" data-bet="low"   aria-label="1 to 18">1–18</div>
      <div class="out-cell" data-bet="even"  aria-label="Even">Even</div>
      <div class="out-cell red-bg" data-bet="red"   aria-label="Red">Red</div>
      <div class="out-cell blk-bg" data-bet="black" aria-label="Black">Black</div>
      <div class="out-cell" data-bet="odd"   aria-label="Odd">Odd</div>
      <div class="out-cell" data-bet="high"  aria-label="19 to 36">19–36</div>
    </div>

  </div>

  <!-- CONTROLS -->
  <div class="controls-row">
    <button class="btn btn-spin"  id="spinBtn"  disabled>Spin</button>
    <button class="btn btn-clear" id="clearBtn">Clear Bets</button>
    <button class="btn btn-reset" id="resetBtn">↺ Reset</button>
  </div>

</div>

<footer>90210 Roulette · American Wheel (0, 00) · Straight Up 35:1 · Beverly Hills Casino</footer>

<script>
// ════════════════════════════════════════════════════
//  CONSTANTS
// ════════════════════════════════════════════════════

// American roulette wheel order (clockwise from top, pocket index 0 = 0)
const WHEEL_ORDER = [
  0, 28, 9, 26, 30, 11, 7, 20, 32, 17, 5, 22, 34, 15, 3, 24, 36, 13, 1,
  '00', 27, 10, 25, 29, 12, 8, 19, 31, 18, 6, 21, 33, 16, 4, 23, 35, 14, 2
];
const NUM_POCKETS = WHEEL_ORDER.length; // 38

// Standard American roulette red numbers
const REDS = new Set([1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]);

function pocketColor(n) {
  if (n === 0 || n === '00') return 'green';
  return REDS.has(n) ? 'red' : 'black';
}

// Grid layout: numbers in the betting grid go top-to-bottom per column
// Row 1: 3,6,9,12,15,18,21,24,27,30,33,36
// Row 2: 2,5,8,11,14,17,20,23,26,29,32,35
// Row 3: 1,4,7,10,13,16,19,22,25,28,31,34
const GRID_ROWS = [
  [3,6,9,12,15,18,21,24,27,30,33,36],
  [2,5,8,11,14,17,20,23,26,29,32,35],
  [1,4,7,10,13,16,19,22,25,28,31,34]
];

// Payout multipliers (return factor, not including original bet)
const PAYOUT = {
  straight: 35,
  dozen:    2,
  col:      2,
  outside:  1
};

// ════════════════════════════════════════════════════
//  AUDIO ENGINE
// ════════════════════════════════════════════════════
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let actx = null;
let muted = false;

function getCtx() {
  if (!actx) actx = new AudioCtx();
  if (actx.state === 'suspended') actx.resume();
  return actx;
}

function playTone({ freq=440, type='sine', vol=0.2, dur=0.15, attack=0.01 }={}) {
  if (muted) return;
  try {
    const c = getCtx();
    const osc = c.createOscillator();
    const g   = c.createGain();
    osc.connect(g); g.connect(c.destination);
    osc.type = type; osc.frequency.value = freq;
    g.gain.setValueAtTime(0, c.currentTime);
    g.gain.linearRampToValueAtTime(vol, c.currentTime + attack);
    g.gain.exponentialRampToValueAtTime(0.001, c.currentTime + dur);
    osc.start(); osc.stop(c.currentTime + dur + 0.02);
  } catch(e){}
}

let wheelWhirr = null;
function startWhirr() {
  if (muted || !AudioCtx) return;
  try {
    const c = getCtx();
    wheelWhirr = c.createOscillator();
    const g = c.createGain();
    wheelWhirr.connect(g); g.connect(c.destination);
    wheelWhirr.type = 'sawtooth';
    wheelWhirr.frequency.setValueAtTime(80, c.currentTime);
    wheelWhirr.frequency.linearRampToValueAtTime(40, c.currentTime + 4);
    g.gain.setValueAtTime(0, c.currentTime);
    g.gain.linearRampToValueAtTime(0.06, c.currentTime + 0.3);
    g.gain.linearRampToValueAtTime(0.03, c.currentTime + 3.5);
    g.gain.linearRampToValueAtTime(0, c.currentTime + 4.5);
    wheelWhirr.start();
    wheelWhirr.stop(c.currentTime + 5);
  } catch(e){}
}

const sounds = {
  chip()   { playTone({ freq:1600, type:'triangle', vol:0.08, dur:0.07, attack:0.005 }); },
  tick()   { playTone({ freq:1200, type:'square',   vol:0.05, dur:0.04, attack:0.002 }); },
  settle() {
    playTone({ freq:400, type:'sine', vol:0.12, dur:0.25 });
    setTimeout(()=>playTone({ freq:500, type:'sine', vol:0.08, dur:0.2 }), 80);
  },
  win() {
    [0,100,220,380].forEach((ms,i)=>{
      setTimeout(()=>playTone({ freq:[523,659,784,1047][i], type:'triangle', vol:0.18-i*0.02, dur:0.28 }), ms);
    });
  },
  bigWin() {
    [0,80,180,300,450].forEach((ms,i)=>{
      setTimeout(()=>playTone({ freq:[523,659,784,1047,1319][i], type:'sine', vol:0.2-i*0.02, dur:0.35 }), ms);
    });
  },
  lose() {
    playTone({ freq:200, type:'sawtooth', vol:0.1, dur:0.4 });
    setTimeout(()=>playTone({ freq:160, type:'sawtooth', vol:0.07, dur:0.35 }), 200);
  },
  click() { playTone({ freq:700, type:'sine', vol:0.05, dur:0.05 }); }
};

// ════════════════════════════════════════════════════
//  STATE
// ════════════════════════════════════════════════════
let bankroll     = 1000;
let bestWin      = 0;
let selectedChip = 25;
let bets         = {}; // key → amount  (key = 'n_7', 'red', 'dozen1', 'col2', etc.)
let phase        = 'betting'; // 'betting' | 'spinning' | 'result'
let lastResult   = null;

function saveState() {
  localStorage.setItem('rl90210_bankroll', bankroll);
  localStorage.setItem('rl90210_bestWin',  bestWin);
}
function loadState() {
  bankroll = parseInt(localStorage.getItem('rl90210_bankroll') || '1000');
  bestWin  = parseInt(localStorage.getItem('rl90210_bestWin')  || '0');
  if (isNaN(bankroll) || bankroll < 1) bankroll = 1000;
  if (isNaN(bestWin)) bestWin = 0;
}

function totalBet() {
  return Object.values(bets).reduce((s, v) => s + v, 0);
}

// ════════════════════════════════════════════════════
//  WHEEL CANVAS RENDERER
// ════════════════════════════════════════════════════
const canvas = document.getElementById('wheelCanvas');
const ctx2   = canvas.getContext('2d');
const CX = canvas.width / 2;
const CY = canvas.height / 2;
const R_OUTER = CX - 6;
const R_INNER = CX * 0.68;
const R_BALL  = CX * 0.56;
const R_NUM   = CX * 0.82;
const R_HUB   = CX * 0.18;

let wheelAngle = 0;  // radians, current rotation of wheel
let ballAngle  = 0;  // radians, current position of ball on track
let ballR      = 0;  // current radius of ball (spirals inward)
let spinning   = false;
let wheelSpeed = 0;  // rad/frame wheel
let ballSpeed  = 0;  // rad/frame ball (counter-rotating)
let spinTarget = 0;  // total angle wheel will travel
let spinElapsed = 0;
let spinDuration = 0;
let resultPocketIdx = -1; // which pocket ball lands in
let rafId = null;
let ballSettled = false;
let settleTimer = 0;
let tickTimer   = 0;

// Pocket angle: pocket i is at angle (i / NUM_POCKETS) * 2π from top of wheel
// We rotate the wheel, so the pocket at physical position = wheelAngle + i*(2π/38)
function pocketAngle(i) {
  return (i / NUM_POCKETS) * Math.PI * 2;
}

function drawWheel(winPocket = -1) {
  ctx2.clearRect(0, 0, canvas.width, canvas.height);

  // Outer rim
  ctx2.save();
  ctx2.beginPath();
  ctx2.arc(CX, CY, R_OUTER, 0, Math.PI * 2);
  const rimGrad = ctx2.createRadialGradient(CX-20, CY-20, R_INNER, CX, CY, R_OUTER);
  rimGrad.addColorStop(0, '#d4a44c');
  rimGrad.addColorStop(0.5, '#c9a961');
  rimGrad.addColorStop(1, '#7a5a1a');
  ctx2.fillStyle = rimGrad;
  ctx2.fill();
  ctx2.restore();

  // Pockets
  ctx2.save();
  ctx2.translate(CX, CY);
  ctx2.rotate(wheelAngle - Math.PI / 2); // start from top
  const sliceAngle = (Math.PI * 2) / NUM_POCKETS;

  for (let i = 0; i < NUM_POCKETS; i++) {
    const start = i * sliceAngle - sliceAngle / 2;
    const end   = start + sliceAngle;
    const n     = WHEEL_ORDER[i];
    const col   = pocketColor(n);

    // Pocket background
    ctx2.beginPath();
    ctx2.moveTo(0, 0);
    ctx2.arc(0, 0, R_INNER, start, end);
    ctx2.closePath();

    if (winPocket === i) {
      ctx2.fillStyle = '#e8d49a';
    } else if (col === 'green') {
      ctx2.fillStyle = '#1a6b32';
    } else if (col === 'red') {
      ctx2.fillStyle = '#9e2222';
    } else {
      ctx2.fillStyle = '#111';
    }
    ctx2.fill();
    ctx2.strokeStyle = 'rgba(201,169,97,0.25)';
    ctx2.lineWidth = 0.5;
    ctx2.stroke();

    // Number label
    const midAngle = i * sliceAngle;
    const tx = Math.cos(midAngle) * R_NUM;
    const ty = Math.sin(midAngle) * R_NUM;
    ctx2.save();
    ctx2.translate(tx, ty);
    ctx2.rotate(midAngle + Math.PI / 2);
    ctx2.fillStyle = (winPocket === i) ? '#1a1209' : '#fff';
    ctx2.font = `bold ${Math.floor(CX * 0.065)}px serif`;
    ctx2.textAlign = 'center';
    ctx2.textBaseline = 'middle';
    ctx2.fillText(String(n), 0, 0);
    ctx2.restore();
  }
  ctx2.restore();

  // Center hub
  ctx2.save();
  ctx2.beginPath();
  ctx2.arc(CX, CY, R_HUB, 0, Math.PI * 2);
  const hubGrad = ctx2.createRadialGradient(CX - R_HUB*0.3, CY - R_HUB*0.3, 2, CX, CY, R_HUB);
  hubGrad.addColorStop(0, '#e8d49a');
  hubGrad.addColorStop(0.5, '#c9a961');
  hubGrad.addColorStop(1, '#7a5a1a');
  ctx2.fillStyle = hubGrad;
  ctx2.fill();
  ctx2.strokeStyle = '#5a3a0a';
  ctx2.lineWidth = 1.5;
  ctx2.stroke();
  ctx2.restore();

  // Ball
  if (spinning || ballSettled) {
    const bx = CX + Math.cos(ballAngle) * ballR;
    const by = CY + Math.sin(ballAngle) * ballR;

    // Ball shadow
    ctx2.save();
    ctx2.beginPath();
    ctx2.arc(bx + 2, by + 2, CX * 0.038, 0, Math.PI * 2);
    ctx2.fillStyle = 'rgba(0,0,0,0.35)';
    ctx2.fill();
    ctx2.restore();

    // Ball
    ctx2.save();
    ctx2.beginPath();
    ctx2.arc(bx, by, CX * 0.038, 0, Math.PI * 2);
    const ballGrad = ctx2.createRadialGradient(bx - CX*0.015, by - CX*0.015, 1, bx, by, CX*0.038);
    ballGrad.addColorStop(0, '#fff');
    ballGrad.addColorStop(0.4, '#e8e8e8');
    ballGrad.addColorStop(1, '#bbb');
    ctx2.fillStyle = ballGrad;
    ctx2.fill();
    ctx2.restore();
  }

  // Pointer/marker at top
  ctx2.save();
  ctx2.beginPath();
  ctx2.moveTo(CX, CY - R_OUTER + 2);
  ctx2.lineTo(CX - 6, CY - R_INNER - 8);
  ctx2.lineTo(CX + 6, CY - R_INNER - 8);
  ctx2.closePath();
  ctx2.fillStyle = var90210Gold;
  ctx2.fill();
  ctx2.restore();
}

const var90210Gold = '#c9a961';

// ════════════════════════════════════════════════════
//  SPIN ANIMATION
// ════════════════════════════════════════════════════
function startSpin(pocketIdx) {
  resultPocketIdx = pocketIdx;
  spinning      = true;
  ballSettled   = false;
  settleTimer   = 0;
  tickTimer     = 0;

  wheelSpeed    = 0.06; // fast initial wheel speed (rad/frame)
  ballSpeed     = -0.09; // counter-rotating ball
  ballR         = R_BALL;
  ballAngle     = Math.random() * Math.PI * 2; // random start position

  // We'll run for ~280 frames (~4.7s at 60fps)
  spinDuration  = 280;
  spinElapsed   = 0;

  startWhirr();
  rafId = requestAnimationFrame(spinFrame);
}

function spinFrame() {
  spinElapsed++;
  const t = spinElapsed / spinDuration; // 0→1
  const eased = t < 1 ? t : 1;

  // Ease out: speed decreases using cosine
  const factor = Math.max(0, Math.cos(eased * Math.PI * 0.5));

  wheelSpeed = 0.06 * factor;
  ballSpeed  = -0.09 * Math.max(0.1, factor * 1.2);

  wheelAngle += wheelSpeed;
  ballAngle  += ballSpeed;

  // Ball spirals inward as it slows
  const inwardProgress = Math.pow(Math.max(0, eased - 0.5) / 0.5, 1.8);
  ballR = R_BALL - (R_BALL - R_INNER * 0.85) * inwardProgress;

  // Tick sound every ~8 pockets
  tickTimer++;
  if (tickTimer >= Math.max(4, 12 - Math.floor(eased * 8))) {
    tickTimer = 0;
    sounds.tick();
  }

  drawWheel();

  if (spinElapsed >= spinDuration) {
    // Snap ball to correct pocket
    const targetPocketAngle = pocketAngle(resultPocketIdx) + wheelAngle - Math.PI / 2;
    ballAngle = targetPocketAngle;
    ballR     = R_INNER * 0.72; // settled in pocket
    ballSettled = true;
    spinning    = false;

    drawWheel(resultPocketIdx);
    sounds.settle();
    setTimeout(onSpinComplete, 400);
    return;
  }

  rafId = requestAnimationFrame(spinFrame);
}

// ════════════════════════════════════════════════════
//  BET RESOLUTION
// ════════════════════════════════════════════════════
function resolveNumber(n) {
  // n is the actual number (0, '00', or 1-36)
  const color = pocketColor(n);
  let netGain = 0;
  let hasAnyWin = false;

  for (const [key, amount] of Object.entries(bets)) {
    let win = false;
    let mult = 0;

    if (key.startsWith('n_')) {
      const betNum = key.slice(2);
      const betVal = betNum === '00' ? '00' : parseInt(betNum);
      win = (betVal === n);
      mult = PAYOUT.straight;
    } else if (key === 'red')    { win = color === 'red';   mult = PAYOUT.outside; }
    else if (key === 'black')  { win = color === 'black'; mult = PAYOUT.outside; }
    else if (key === 'even')   { win = typeof n === 'number' && n > 0 && n % 2 === 0; mult = PAYOUT.outside; }
    else if (key === 'odd')    { win = typeof n === 'number' && n > 0 && n % 2 === 1; mult = PAYOUT.outside; }
    else if (key === 'low')    { win = typeof n === 'number' && n >= 1  && n <= 18; mult = PAYOUT.outside; }
    else if (key === 'high')   { win = typeof n === 'number' && n >= 19 && n <= 36; mult = PAYOUT.outside; }
    else if (key === 'dozen1') { win = typeof n === 'number' && n >= 1  && n <= 12; mult = PAYOUT.dozen; }
    else if (key === 'dozen2') { win = typeof n === 'number' && n >= 13 && n <= 24; mult = PAYOUT.dozen; }
    else if (key === 'dozen3') { win = typeof n === 'number' && n >= 25 && n <= 36; mult = PAYOUT.dozen; }
    else if (key === 'col1')   { win = typeof n === 'number' && n > 0 && n % 3 === 0; mult = PAYOUT.col; }  // 3,6,9…
    else if (key === 'col2')   { win = typeof n === 'number' && n > 0 && n % 3 === 2; mult = PAYOUT.col; }  // 2,5,8…
    else if (key === 'col3')   { win = typeof n === 'number' && n > 0 && n % 3 === 1; mult = PAYOUT.col; }  // 1,4,7…

    if (win) {
      netGain += amount * mult;       // profit
      bankroll += amount * (mult + 1); // return bet + profit
      hasAnyWin = true;
    }
    // losing bets stay deducted (already removed from bankroll on placement)
  }

  return { netGain, hasAnyWin };
}

function onSpinComplete() {
  const n = lastResult; // the number
  const color = pocketColor(n);
  const { netGain, hasAnyWin } = resolveNumber(n);

  if (netGain > bestWin) bestWin = netGain;
  saveState();
  updateBankrollUI();

  // Show result banner
  const numEl    = document.getElementById('rbNumber');
  const labelEl  = document.getElementById('rbLabel');
  const payoutEl = document.getElementById('rbPayout');

  numEl.textContent = String(n);
  numEl.className   = 'rb-number ' + color;
  labelEl.textContent = color.toUpperCase();

  const tb = totalBet();
  if (hasAnyWin && netGain > 0) {
    payoutEl.textContent = `+${fmt(netGain)}`;
    payoutEl.className   = 'rb-payout win';
    netGain > 200 ? sounds.bigWin() : sounds.win();
  } else {
    payoutEl.textContent = `−${fmt(tb)}`;
    payoutEl.className   = 'rb-payout lose';
    sounds.lose();
  }

  document.getElementById('resultBanner').classList.add('show');
  highlightWinners(n);

  phase = 'result';
  setStatus('Click SPIN or place new bets for another round');

  // After a pause, re-enable betting
  setTimeout(() => {
    bets = {};
    clearBetUI();
    phase = 'betting';
    document.getElementById('spinBtn').disabled = true;
    setStatus('Place your bets');
    if (bankroll <= 0) {
      bankroll = 1000;
      saveState();
      updateBankrollUI();
      setStatus('Bankrupt — reset to $1,000');
    }
  }, 3500);
}

// ════════════════════════════════════════════════════
//  DOM BUILDING
// ════════════════════════════════════════════════════
function buildGrid() {
  const grid = document.getElementById('numberGrid');
  grid.innerHTML = '';

  // Zero column (two cells: 0 and 00, stacked)
  const zeroCol = document.createElement('div');
  zeroCol.className = 'cell zero-col';

  ['0', '00'].forEach(z => {
    const el = document.createElement('div');
    el.className = 'zero-single num-green';
    el.textContent = z;
    el.dataset.bet = 'n_' + z;
    el.setAttribute('role', 'button');
    el.setAttribute('tabindex', '0');
    el.setAttribute('aria-label', 'Bet on ' + z);
    zeroCol.appendChild(el);
  });
  grid.appendChild(zeroCol);

  // Number cells: 3 rows × 12 cols
  for (let row = 0; row < 3; row++) {
    for (let col = 0; col < 12; col++) {
      const n   = GRID_ROWS[row][col];
      const col_class = REDS.has(n) ? 'num-red' : 'num-black';
      const el  = document.createElement('div');
      el.className = `cell ${col_class}`;
      el.textContent = n;
      el.dataset.bet = 'n_' + n;
      el.style.gridColumn = col + 2;
      el.style.gridRow    = row + 1;
      el.setAttribute('role', 'button');
      el.setAttribute('tabindex', '0');
      el.setAttribute('aria-label', 'Bet on ' + n);
      grid.appendChild(el);
    }
  }
}

function attachBetListeners() {
  // Number cells + zero singles
  document.querySelectorAll('[data-bet]').forEach(el => {
    el.addEventListener('click', () => placeBet(el.dataset.bet, el));
    el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); placeBet(el.dataset.bet, el); } });
  });
}

function placeBet(key, el) {
  if (phase !== 'betting') return;
  if (selectedChip > bankroll - totalBet()) {
    setStatus('Not enough bankroll');
    return;
  }
  bets[key] = (bets[key] || 0) + selectedChip;
  sounds.chip();
  updateChipDot(key, el, bets[key]);
  document.getElementById('spinBtn').disabled = false;
  updateBankrollUI();
  setStatus('');
}

function updateChipDot(key, el, total) {
  // find or create chip indicator
  let dot = el.querySelector('.chip-dot');
  if (!dot) {
    dot = document.createElement('div');
    dot.className = 'chip-dot';
    el.appendChild(dot);
  }
  dot.textContent = total >= 1000 ? (total/1000).toFixed(1)+'k' : total;
}

function clearBetUI() {
  document.querySelectorAll('.chip-dot').forEach(d => d.remove());
  document.querySelectorAll('.winner').forEach(el => el.classList.remove('winner'));
  document.getElementById('resultBanner').classList.remove('show');
  updateBankrollUI();
}

function highlightWinners(n) {
  const betKey = 'n_' + (n === '00' ? '00' : n);
  const el = document.querySelector(`[data-bet="${betKey}"]`);
  if (el) el.classList.add('winner');

  // Outside bets
  const color = pocketColor(n);
  if (color === 'red')   highlightOutside('red');
  if (color === 'black') highlightOutside('black');
  if (typeof n === 'number' && n > 0) {
    if (n % 2 === 0)           highlightOutside('even');
    if (n % 2 === 1)           highlightOutside('odd');
    if (n >= 1  && n <= 18)    highlightOutside('low');
    if (n >= 19 && n <= 36)    highlightOutside('high');
    if (n >= 1  && n <= 12)    highlightOutside('dozen1');
    if (n >= 13 && n <= 24)    highlightOutside('dozen2');
    if (n >= 25 && n <= 36)    highlightOutside('dozen3');
    if (n % 3 === 0)           highlightOutside('col1');
    if (n % 3 === 2)           highlightOutside('col2');
    if (n % 3 === 1)           highlightOutside('col3');
  }
}

function highlightOutside(key) {
  const el = document.querySelector(`[data-bet="${key}"]`);
  if (el) el.classList.add('winner');
}

// ════════════════════════════════════════════════════
//  UI HELPERS
// ════════════════════════════════════════════════════
function fmt(n) { return '$' + n.toLocaleString(); }

function updateBankrollUI() {
  document.getElementById('bankrollDisplay').textContent  = fmt(bankroll);
  const tb = totalBet();
  document.getElementById('totalBetDisplay').textContent  = tb > 0 ? fmt(tb) : '—';
  document.getElementById('bestWinDisplay').textContent   = fmt(bestWin);
}

function setStatus(msg) { document.getElementById('statusMsg').textContent = msg; }

// ════════════════════════════════════════════════════
//  CHIP SELECTOR
// ════════════════════════════════════════════════════
document.querySelectorAll('.chip-sel').forEach(chip => {
  chip.addEventListener('click', () => {
    document.querySelectorAll('.chip-sel').forEach(c => c.classList.remove('active'));
    chip.classList.add('active');
    selectedChip = parseInt(chip.dataset.val);
    sounds.click();
  });
});
// Default active chip
document.querySelector('.chip-sel[data-val="25"]').classList.add('active');

// ════════════════════════════════════════════════════
//  SPIN
// ════════════════════════════════════════════════════
document.getElementById('spinBtn').addEventListener('click', () => {
  if (phase !== 'betting') return;
  if (Object.keys(bets).length === 0) { setStatus('Place a bet first'); return; }

  // Deduct all bets from bankroll now
  const tb = totalBet();
  if (tb > bankroll) { setStatus('Not enough bankroll'); return; }
  bankroll -= tb;
  saveState();
  updateBankrollUI();

  // Pick random pocket
  const pocketIdx = Math.floor(Math.random() * NUM_POCKETS);
  lastResult = WHEEL_ORDER[pocketIdx];

  phase = 'spinning';
  document.getElementById('spinBtn').disabled = true;
  setStatus('No more bets…');

  sounds.click();
  drawWheel(); // clear any winner highlights
  startSpin(pocketIdx);
});

// ════════════════════════════════════════════════════
//  CLEAR / RESET
// ════════════════════════════════════════════════════
document.getElementById('clearBtn').addEventListener('click', () => {
  if (phase !== 'betting') return;
  sounds.click();
  bets = {};
  clearBetUI();
  document.getElementById('spinBtn').disabled = true;
  setStatus('Bets cleared');
  updateBankrollUI();
});

document.getElementById('resetBtn').addEventListener('click', () => {
  bankroll = 1000;
  bestWin  = 0;
  bets     = {};
  phase    = 'betting';
  if (rafId) cancelAnimationFrame(rafId);
  spinning = false;
  ballSettled = false;
  clearBetUI();
  drawWheel();
  document.getElementById('spinBtn').disabled = true;
  setStatus('Bankroll reset to $1,000');
  saveState();
  updateBankrollUI();
  sounds.click();
});

// ════════════════════════════════════════════════════
//  MUTE
// ════════════════════════════════════════════════════
document.getElementById('muteBtn').addEventListener('click', () => {
  muted = !muted;
  document.getElementById('muteBtn').textContent = muted ? '🔇' : '🔊';
});

// ════════════════════════════════════════════════════
//  KEYBOARD SHORTCUTS
// ════════════════════════════════════════════════════
document.addEventListener('keydown', e => {
  if (e.repeat) return;
  const k = e.key.toLowerCase();
  if (k === ' ' || k === 'enter') {
    e.preventDefault();
    if (phase === 'betting') document.getElementById('spinBtn').click();
  }
  if (k === 'c') document.getElementById('clearBtn').click();
  if (k === 'm') document.getElementById('muteBtn').click();
});

// ════════════════════════════════════════════════════
//  IDLE WHEEL ANIMATION (slow rotation when not spinning)
// ════════════════════════════════════════════════════
let idleRaf = null;
function idleFrame() {
  if (!spinning) {
    wheelAngle += 0.003;
    drawWheel();
  }
  idleRaf = requestAnimationFrame(idleFrame);
}

// ════════════════════════════════════════════════════
//  INIT
// ════════════════════════════════════════════════════
loadState();
buildGrid();
attachBetListeners();
updateBankrollUI();
drawWheel();
idleFrame();
setStatus('Select a chip, then click to place bets');
</script>
</body>
</html>