← 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>