PREMIUM
FLIP A Coin
Studio-grade lighting with perfect physical settlement.
Height:
Press Space to Flip
0
Flips
0
Heads
0
Tails
0
Streak
HISTORY
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
import * as THREE from 'three';
// ── Audio Engine ─────────────────────────────────────────────────────────────
const AudioCtx = window.AudioContext || window.webkitAudioContext;
let audioCtx = null;
function ensureAudio() { if (!audioCtx) audioCtx = new AudioCtx(); if (audioCtx.state === 'suspended') audioCtx.resume(); }
function noiseBuffer(sec) {
const n = Math.ceil(audioCtx.sampleRate * sec);
const b = audioCtx.createBuffer(1, n, audioCtx.sampleRate);
const d = b.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = Math.random() * 2 - 1;
return b;
}
// Finger-flick launch sound — short noise burst
function playLaunch() {
ensureAudio(); const t = audioCtx.currentTime;
const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer(0.07);
const hp = audioCtx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1800;
const g = audioCtx.createGain();
g.gain.setValueAtTime(0.18, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.065);
src.connect(hp); hp.connect(g); g.connect(audioCtx.destination); src.start(t);
}
// Metallic coin impact — noise thud + ring overtones
function playLand(vol = 0.45, pitch = 1) {
ensureAudio(); const t = audioCtx.currentTime;
// Noise impact burst
const ns = audioCtx.createBufferSource(); ns.buffer = noiseBuffer(0.1);
const bp = audioCtx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 2800 * pitch; bp.Q.value = 2.5;
const ng = audioCtx.createGain();
ng.gain.setValueAtTime(vol, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
ns.connect(bp); bp.connect(ng); ng.connect(audioCtx.destination); ns.start(t);
// Metallic ring overtones
[3800, 6400, 9800].forEach((f, i) => {
const o = audioCtx.createOscillator(); const og = audioCtx.createGain();
o.frequency.setValueAtTime(f * pitch, t);
o.frequency.exponentialRampToValueAtTime(f * 0.76 * pitch, t + 0.16);
og.gain.setValueAtTime(vol * 0.16 / (i + 1), t);
og.gain.exponentialRampToValueAtTime(0.001, t + 0.20 / (i * 0.6 + 1));
o.connect(og); og.connect(audioCtx.destination); o.start(t); o.stop(t + 0.28);
});
}
// Wobble edge-tap — very short metallic tick
function playEdgeTap(vol) {
if (vol < 0.015) return;
ensureAudio(); const t = audioCtx.currentTime;
const ns = audioCtx.createBufferSource(); ns.buffer = noiseBuffer(0.022);
const bp = audioCtx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 5200; bp.Q.value = 7;
const g = audioCtx.createGain();
g.gain.setValueAtTime(Math.min(vol, 0.22), t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.022);
ns.connect(bp); bp.connect(g); g.connect(audioCtx.destination); ns.start(t);
}
// ── Scene Setup ───────────────────────────────────────────────────────────────
const wrap = document.getElementById('canvas-wrap');
const W = wrap.clientWidth, H = wrap.clientHeight;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(W, H);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.5;
wrap.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0f);
const camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 120);
camera.position.set(0, 1.5, 12);
function setupLighting() {
// Warm casino env map for metallic coin reflections
const pmrem = new THREE.PMREMGenerator(renderer);
const skyScene = new THREE.Scene();
const skySphere = new THREE.Mesh(new THREE.SphereGeometry(10, 32, 32),
new THREE.MeshBasicMaterial({ color: 0x120508, side: THREE.BackSide }));
skyScene.add(skySphere);
const ep1 = new THREE.PointLight(0xffd080, 90); ep1.position.set(3, 7, 2); skyScene.add(ep1);
const ep2 = new THREE.PointLight(0x203060, 35); ep2.position.set(-4, 5, -3); skyScene.add(ep2);
scene.environment = pmrem.fromScene(skyScene).texture;
// Very dim ambient — casino rooms are dramatically lit, not evenly lit
scene.add(new THREE.AmbientLight(0x0b0508, 2.5));
// Main overhead pendant spotlight — primary shadow caster, aimed at table
const main = new THREE.SpotLight(0xffe8a0, 100, 50, Math.PI / 8, 0.45, 1.5);
main.position.set(0, 13, -1.5); main.target.position.set(0, -3, 0);
main.castShadow = true; main.shadow.mapSize.set(1024, 1024);
scene.add(main); scene.add(main.target);
// Close-range fill — PointLight just above the table, illuminates coin face directly
const tableFill = new THREE.PointLight(0xfff0d0, 18, 8);
tableFill.position.set(0, 1.5, 0);
scene.add(tableFill);
// Second pendant slightly offset for softer fill
const fill = new THREE.SpotLight(0xffdc88, 35, 40, Math.PI / 7, 0.6, 1.4);
fill.position.set(1.5, 12, -3.5); fill.target.position.set(0, -3, 0);
scene.add(fill); scene.add(fill.target);
// Warm side accent — illuminates dealer from the right
const side = new THREE.PointLight(0xffb050, 20, 22);
side.position.set(8, 7, -5); scene.add(side);
// Cool blue accent from left — cinema contrast
const cool = new THREE.PointLight(0x1830a0, 12, 20);
cool.position.set(-8, 6, -4); scene.add(cool);
// Pendant fixture geometry above table (visual only)
const fixtureGeo = new THREE.CylinderGeometry(0.25, 0.6, 0.5, 12);
const fixtureMat = new THREE.MeshStandardMaterial({ color: 0x4a3800, metalness: 0.8, roughness: 0.3 });
[[-0, 13.2, -1.5], [1.5, 12.3, -3.5]].forEach(([x, y, z]) => {
const f = new THREE.Mesh(fixtureGeo, fixtureMat); f.position.set(x, y, z); scene.add(f);
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 8),
new THREE.MeshBasicMaterial({ color: 0xfff0a0 }));
bulb.position.set(x, y - 0.3, z); scene.add(bulb);
});
}
setupLighting();
// ── Physics constants (defined early — needed by casino table/env geometry) ────
const RADIUS = 0.65, THICK = 0.10, FLOOR_Y = -3, GRAVITY = 25;
// ── Table ─────────────────────────────────────────────────────────────────────
(function buildTable() {
// Casino felt — deep green with grain and concentric ring pattern
const fc = document.createElement('canvas'); fc.width = fc.height = 512;
const fx = fc.getContext('2d');
fx.fillStyle = '#0f3018'; fx.fillRect(0, 0, 512, 512);
const fg = fx.createRadialGradient(256, 256, 0, 256, 256, 300);
fg.addColorStop(0, 'rgba(28,72,40,0.8)'); fg.addColorStop(1, 'rgba(0,0,0,0.7)');
fx.fillStyle = fg; fx.fillRect(0, 0, 512, 512);
for (let i = 0; i < 18000; i++) {
fx.fillStyle = `rgba(255,255,255,${Math.random() * 0.025})`;
fx.fillRect(Math.random() * 512, Math.random() * 512, 1, 1);
}
fx.strokeStyle = 'rgba(255,255,255,0.04)'; fx.lineWidth = 1;
for (let r = 30; r < 260; r += 38) { fx.beginPath(); fx.arc(256, 256, r, 0, Math.PI * 2); fx.stroke(); }
const feltTex = new THREE.CanvasTexture(fc);
// Dark mahogany wood grain
const wc = document.createElement('canvas'); wc.width = 256; wc.height = 256;
const wx = wc.getContext('2d');
wx.fillStyle = '#1e0a04'; wx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 40; i++) {
const x = Math.random() * 256;
wx.fillStyle = `rgba(${60+Math.random()*30},${12+Math.random()*15},0,${0.18+Math.random()*0.22})`;
wx.fillRect(x, 0, 1 + Math.random() * 5, 256);
}
const woodTex = new THREE.CanvasTexture(wc);
woodTex.wrapS = woodTex.wrapT = THREE.RepeatWrapping; woodTex.repeat.set(3, 1);
const woodMat = new THREE.MeshStandardMaterial({ map: woodTex, roughness: 0.6, metalness: 0.08 });
// Table top — radius 9 (coin radius 1.3 = 14% of table radius — coin looks like a coin)
const topGeo = new THREE.CylinderGeometry(9, 9, 0.4, 72);
const tableTop = new THREE.Mesh(topGeo, [
woodMat,
new THREE.MeshStandardMaterial({ map: feltTex, roughness: 0.92, metalness: 0 }),
woodMat,
]);
tableTop.position.y = FLOOR_Y - 0.2; // top face at FLOOR_Y
tableTop.receiveShadow = true;
scene.add(tableTop);
// Padded cushion rail (dark leather-look torus, radius 8.6, tube 0.45)
const cushion = new THREE.Mesh(
new THREE.TorusGeometry(8.6, 0.45, 12, 72),
new THREE.MeshStandardMaterial({ color: 0x1c0808, roughness: 0.85, metalness: 0.05 })
);
cushion.rotation.x = Math.PI / 2; cushion.position.y = FLOOR_Y - 0.05;
scene.add(cushion);
// Decorative mahogany rim (torus, radius 9, tube 0.22)
const rim = new THREE.Mesh(new THREE.TorusGeometry(9, 0.22, 10, 72), woodMat);
rim.rotation.x = Math.PI / 2; rim.position.y = FLOOR_Y - 0.3;
scene.add(rim);
// Pedestal — top radius 1.2, bottom 1.8, height 4.3 (table y=-3.4 → ground y=-7.7, center -5.55)
const pedestal = new THREE.Mesh(new THREE.CylinderGeometry(1.2, 1.8, 4.3, 20), woodMat);
pedestal.position.y = -5.55;
scene.add(pedestal);
// Base disc at ground (y=-7.85, radius 3.8)
const base = new THREE.Mesh(new THREE.CylinderGeometry(3.8, 3.8, 0.3, 32), woodMat);
base.position.y = -7.85;
scene.add(base);
// Casino chip stacks — decorative near dealer's side (z≈-5)
const chipCols = [0xcc1111, 0x111111, 0x1144cc, 0xcc1111, 0x111111];
[[-1.8, -5.2], [0.6, -5.5]].forEach(([xOff, zOff]) => {
chipCols.forEach((color, i) => {
const chip = new THREE.Mesh(
new THREE.CylinderGeometry(0.38, 0.38, 0.09, 20),
new THREE.MeshStandardMaterial({ color, roughness: 0.55, metalness: 0.2 })
);
chip.position.set(xOff, FLOOR_Y + 0.045 + i * 0.09, zOff);
scene.add(chip);
});
});
})();
// ── Casino Environment ─────────────────────────────────────────────────────────
(function buildCasinoEnv() {
scene.fog = new THREE.FogExp2(0x06030a, 0.016);
scene.background = new THREE.Color(0x06030a);
// Casino carpet — dark burgundy with diamond pattern
const cc = document.createElement('canvas'); cc.width = cc.height = 512;
const ccx = cc.getContext('2d');
ccx.fillStyle = '#180508'; ccx.fillRect(0, 0, 512, 512);
ccx.strokeStyle = 'rgba(110,22,32,0.18)'; ccx.lineWidth = 1.5;
for (let cy = 0; cy < 512; cy += 28) {
for (let cx = 0; cx < 512; cx += 28) {
ccx.beginPath();
ccx.moveTo(cx + 14, cy); ccx.lineTo(cx + 28, cy + 14);
ccx.lineTo(cx + 14, cy + 28); ccx.lineTo(cx, cy + 14);
ccx.closePath(); ccx.stroke();
}
}
const carpetTex = new THREE.CanvasTexture(cc);
carpetTex.wrapS = carpetTex.wrapT = THREE.RepeatWrapping; carpetTex.repeat.set(8, 8);
// Ground plane at y=-8 (table surface at y=-3 → 5-unit tall table, realistic proportion)
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(80, 80),
new THREE.MeshStandardMaterial({ map: carpetTex, roughness: 0.97, metalness: 0 })
);
floor.rotation.x = -Math.PI / 2; floor.position.y = -8; floor.receiveShadow = true;
scene.add(floor);
// Dark ring apron around table base
const apron = new THREE.Mesh(
new THREE.RingGeometry(9.3, 20, 72),
new THREE.MeshStandardMaterial({ color: 0x100408, roughness: 0.98 })
);
apron.rotation.x = -Math.PI / 2; apron.position.y = -7.98;
scene.add(apron);
// Casino room walls — dark burgundy cylinder
const walls = new THREE.Mesh(
new THREE.CylinderGeometry(30, 30, 26, 32, 1, true),
new THREE.MeshStandardMaterial({ color: 0x110305, roughness: 0.9, side: THREE.BackSide })
);
walls.position.y = 5; scene.add(walls);
// Ceiling
const ceiling = new THREE.Mesh(
new THREE.CircleGeometry(30, 32),
new THREE.MeshStandardMaterial({ color: 0x0a0306, roughness: 1 })
);
ceiling.rotation.x = Math.PI / 2; ceiling.position.y = 18; scene.add(ceiling);
// 4 decorative columns with gold caps at room perimeter (radius 17)
const colMat = new THREE.MeshStandardMaterial({ color: 0x2a1808, roughness: 0.5, metalness: 0.15 });
const colCapMat = new THREE.MeshStandardMaterial({ color: 0x5a3c10, roughness: 0.35, metalness: 0.3 });
[0, 90, 180, 270].forEach(deg => {
const a = deg * Math.PI / 180;
const cx = Math.cos(a) * 17, cz = Math.sin(a) * 17;
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.7, 20, 12), colMat);
col.position.set(cx, 2, cz); scene.add(col);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(1.05, 0.7, 0.65, 12), colCapMat);
cap.position.set(cx, 12.3, cz); scene.add(cap);
const colBase = new THREE.Mesh(new THREE.CylinderGeometry(1.05, 1.05, 0.45, 12), colCapMat);
colBase.position.set(cx, -7.8, cz); scene.add(colBase);
// Warm wall sconce at each column
const sconce = new THREE.PointLight(0xff8030, 5, 14);
sconce.position.set(cx * 0.88, 2, cz * 0.88); scene.add(sconce);
});
// Background ghost tables — dark silhouettes visible through fog
const ghostMat = new THREE.MeshStandardMaterial({ color: 0x0c0308, roughness: 1 });
[[-18, -12], [20, -16], [-6, -22]].forEach(([gx, gz]) => {
const gt = new THREE.Mesh(new THREE.CylinderGeometry(5, 5, 0.3, 20), ghostMat);
gt.position.set(gx, -4.5, gz); scene.add(gt);
const gp = new THREE.Mesh(new THREE.CylinderGeometry(0.6, 0.9, 3.5, 12), ghostMat);
gp.position.set(gx, -6.3, gz); scene.add(gp);
});
})();
// Coin
const loader = new THREE.TextureLoader();
const hTex = loader.load('assets/heads.png');
const tTex = loader.load('assets/tails.png');
const coinGeo = new THREE.CylinderGeometry(RADIUS, RADIUS, THICK, 64);
const mSides = new THREE.MeshPhysicalMaterial({ color: 0xffd700, metalness: 1, roughness: 0.18, envMapIntensity: 3.5 });
const mHeads = new THREE.MeshPhysicalMaterial({ map: hTex, metalness: 0.9, roughness: 0.12, envMapIntensity: 5 });
const mTails = new THREE.MeshPhysicalMaterial({ map: tTex, metalness: 0.9, roughness: 0.12, envMapIntensity: 5 });
const coin = new THREE.Mesh(coinGeo, [mSides, mHeads, mTails]);
coin.castShadow = true; coin.receiveShadow = true;
scene.add(coin);
// ── Casino Dealer (Croupier) ───────────────────────────────────────────────────
// Ground y=-8. Dealer height 12 units → head at world y=4. Coin diameter 2.6 = 22% of dealer
// height — looks like an actual coin relative to a person.
const skinMat = new THREE.MeshStandardMaterial({ color: 0xd4956a, roughness: 0.7 });
const shirtMat = new THREE.MeshStandardMaterial({ color: 0xe8e4dc, roughness: 0.85 }); // white shirt
const vestMat = new THREE.MeshStandardMaterial({ color: 0x0c0c0e, roughness: 0.55, metalness: 0.1 });
const pantsMat = new THREE.MeshStandardMaterial({ color: 0x0a0a0c, roughness: 0.85 });
const shoeMat = new THREE.MeshStandardMaterial({ color: 0x060608, roughness: 0.5 });
const visorMat = new THREE.MeshStandardMaterial({ color: 0x1a5c28, roughness: 0.7 }); // classic green visor
const refGroup = new THREE.Group();
// Feet at world y=-8 (ground), facing camera (+z). rotation.y=π → local -z = world +z
refGroup.position.set(0, -8, -7);
refGroup.rotation.y = Math.PI;
scene.add(refGroup);
// Shoes (world y ≈ -8)
[-0.62, 0.62].forEach(x => {
const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.65, 0.22, 0.9), shoeMat);
shoe.position.set(x, 0.11, 0.15); refGroup.add(shoe);
});
// Legs — black trousers, radius 0.44→0.40, height 4.0, center at local y=2.0
[-0.62, 0.62].forEach(x => {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.44, 0.40, 4.0, 8), pantsMat);
leg.position.set(x, 2.0, 0); refGroup.add(leg);
});
// Hip connector
const hip = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.45, 0.9), pantsMat);
hip.position.set(0, 4.3, 0); refGroup.add(hip);
// White dress shirt (torso base), 2.0×4.2×1.0, center y=6.5
const shirt = new THREE.Mesh(new THREE.BoxGeometry(2.0, 4.2, 1.0), shirtMat);
shirt.position.set(0, 6.5, 0); refGroup.add(shirt);
// Black vest over shirt (slightly narrower/shallower), 1.9×3.8×0.65
const vest = new THREE.Mesh(new THREE.BoxGeometry(1.9, 3.8, 0.65), vestMat);
vest.position.set(0, 6.6, 0); refGroup.add(vest);
// Neck
const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.35, 0.55, 8), skinMat);
neck.position.set(0, 8.8, 0); refGroup.add(neck);
// Bow tie — small dark box at collar, pushed slightly forward
const bowTie = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.22, 0.08), vestMat);
bowTie.position.set(0, 8.62, 0.44); refGroup.add(bowTie);
// Head (sphere radius 0.65), center y=9.6
const head = new THREE.Mesh(new THREE.SphereGeometry(0.65, 16, 12), skinMat);
head.position.set(0, 9.6, 0); refGroup.add(head);
// Casino dealer visor — classic green eyeshade
const visorBody = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 0.62, 0.26, 16), vestMat);
visorBody.position.set(0, 10.05, 0); refGroup.add(visorBody);
const visorBrim = new THREE.Mesh(new THREE.CylinderGeometry(0.95, 0.92, 0.05, 32), visorMat);
visorBrim.position.set(0, 9.88, 0.1); refGroup.add(visorBrim);
// Left arm — resting forward on table edge
// Shoulder local (1.2, 8.5, 0) → world (-1.2, 0.5, -7). Arm pitches forward+down to table.
const lShoulderPivot = new THREE.Group();
lShoulderPivot.position.set(1.2, 8.5, 0);
lShoulderPivot.rotation.x = 0.72; lShoulderPivot.rotation.z = -0.15;
refGroup.add(lShoulderPivot);
const lUpperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.26, 0.23, 2.8, 8), shirtMat);
lUpperArm.position.y = -1.4; lShoulderPivot.add(lUpperArm);
const lElbowPivot = new THREE.Group();
lElbowPivot.position.y = -2.8; lElbowPivot.rotation.x = -0.35;
lShoulderPivot.add(lElbowPivot);
const lForearm = new THREE.Mesh(new THREE.CylinderGeometry(0.21, 0.19, 2.4, 8), skinMat);
lForearm.position.y = -1.2; lElbowPivot.add(lForearm);
const lHand = new THREE.Mesh(new THREE.SphereGeometry(0.52, 10, 8), skinMat);
lHand.position.y = -2.55; lElbowPivot.add(lHand);
// Right arm — the tossing arm, hierarchical for animation
// Shoulder local (-1.2, 8.5, 0) → world (1.2, 0.5, -7). Upper arm 2.8 + forearm 2.4 + thumb.
// With rx=0.95: arm pitches forward-down, thumb tip reaches ≈ world (1.2, -2.6, -2.0) —
// just above felt surface y=-3, within table radius 9.
const REF_ARM_REST_RX = 0.95;
const rShoulderPivot = new THREE.Group();
rShoulderPivot.position.set(-1.2, 8.5, 0);
rShoulderPivot.rotation.x = REF_ARM_REST_RX;
rShoulderPivot.rotation.z = 0.20;
refGroup.add(rShoulderPivot);
const rUpperArm = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.25, 2.8, 8), shirtMat);
rUpperArm.position.y = -1.4; rShoulderPivot.add(rUpperArm);
const rElbowPivot = new THREE.Group();
rElbowPivot.position.y = -2.8;
rElbowPivot.rotation.x = -0.42;
rShoulderPivot.add(rElbowPivot);
const rForearm = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.20, 2.4, 8), skinMat);
rForearm.position.y = -1.2; rElbowPivot.add(rForearm);
// Hand — sized to hold coin (coin radius 0.65, hand radius ~0.55 feels natural)
const rHand = new THREE.Mesh(new THREE.SphereGeometry(0.55, 10, 8), skinMat);
rHand.position.y = -2.55; rElbowPivot.add(rHand);
// Thumb pivot — flicks upward at toss moment
const thumbPivot = new THREE.Group();
thumbPivot.position.y = -2.65; rElbowPivot.add(thumbPivot);
// Thumb sized to match coin (coin radius 0.65 → thumb radius ~0.20, length 0.7)
const rThumb = new THREE.Mesh(new THREE.CylinderGeometry(0.20, 0.18, 0.70, 8), skinMat);
rThumb.position.y = 0.35; thumbPivot.add(rThumb);
// Invisible marker at thumb tip — world position = coin launch/rest point
const coinRestMarker = new THREE.Object3D();
coinRestMarker.position.y = 0.75;
thumbPivot.add(coinRestMarker);
// Dealer animation state
const refAnim = { progress: 0, duration: 0.22 };
// ── Physics ───────────────────────────────────────────────────────────────────
// phase: idle | fly | bounce | wobble
const state = {
active: false, phase: 'idle', isHeads: true,
x: 0, z: 0, vx: 0, vz: 0, // horizontal
y: FLOOR_Y + THICK / 2, vy: 0,
rx: 0, rz: 0, rvx: 0,
theta: 0, phi: 0, omega: 0, baseRX: 0
};
let tossVy = 13;
let landedT = 0; // seconds remaining in post-result zoom; 0 = back to thumb view
document.querySelectorAll('[data-vy]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-vy]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
tossVy = +btn.dataset.vy;
});
});
function toss() {
if (state.active) return;
landedT = 0; // cancel result zoom, return to action
ensureAudio(); playLaunch();
state.active = true; state.phase = 'fly'; state.isHeads = Math.random() < 0.5;
document.getElementById('result').classList.remove('show');
// Get launch position from thumb tip
scene.updateMatrixWorld(true);
const launchPos = new THREE.Vector3();
coinRestMarker.getWorldPosition(launchPos);
state.x = launchPos.x;
state.y = launchPos.y;
state.z = launchPos.z;
state.vy = tossVy + Math.random() * 3;
// Horizontal drift to land roughly at table centre
const tFlight = (state.vy * 2) / GRAVITY;
state.vx = (-state.x / tFlight) * 0.88;
state.vz = (-state.z / tFlight) * 0.88;
state.rvx = (20 + Math.random() * 8) * (Math.random() > 0.4 ? 1 : -1);
state.rx = coin.rotation.x;
state.rz = 0;
// Trigger arm-snap animation
refAnim.progress = refAnim.duration;
}
// Persistent camera state — always lerped, never snapped
const camPos = new THREE.Vector3(0, 1.5, 12);
const camLook = new THREE.Vector3(1.2, -1.0, -3); // frames dealer's hand + table
const CAM_LERP = 1.2; // consistent pan speed across all phases
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const time = performance.now() * 0.001;
// ── Dealer arm animation (runs every frame) ───────────────────────────────
if (refAnim.progress > 0) {
refAnim.progress = Math.max(0, refAnim.progress - dt);
const t = 1 - refAnim.progress / refAnim.duration; // 0→1
const snap = Math.sin(t * Math.PI); // 0→1→0 arc
rShoulderPivot.rotation.x = REF_ARM_REST_RX - snap * 0.75;
thumbPivot.rotation.x = -snap * 1.6; // thumb pops up hard
} else {
// Gentle idle breathing sway
rShoulderPivot.rotation.x = REF_ARM_REST_RX + Math.sin(time * 0.9) * 0.025;
thumbPivot.rotation.x = 0;
}
// ── Physics phases ────────────────────────────────────────────────────────
if (state.phase === 'fly' || state.phase === 'bounce') {
const isBounce = state.phase === 'bounce';
state.vy -= GRAVITY * dt;
state.y += state.vy * dt;
state.x += state.vx * dt;
state.z += state.vz * dt;
// Very slight air resistance on horizontal
state.vx *= 1 - dt * 0.25;
state.vz *= 1 - dt * 0.25;
state.rx += state.rvx * dt;
state.rz *= Math.max(0, 1 - dt * 6);
coin.position.set(state.x, state.y, state.z);
coin.rotation.set(state.rx, 0, state.rz);
// Camera tracks coin — pulled back to fit new larger scene
const cx = state.x * 0.25;
const cy = state.y * 0.35 + 2.5;
const cz = state.z * 0.15 + (isBounce ? 9.5 : 11.5);
camPos.lerp(new THREE.Vector3(cx, cy, cz), dt * CAM_LERP);
camLook.lerp(coin.position, dt * CAM_LERP * 2.2);
// Tilt-aware floor contact
const contactY = FLOOR_Y + THICK / 2 * Math.abs(Math.cos(state.rx)) + RADIUS * Math.abs(Math.sin(state.rx));
if (state.y <= contactY && state.vy < 0) {
state.y = contactY;
coin.position.y = state.y;
if (!isBounce) {
playLand(0.45, 1.0);
state.vy = Math.abs(state.vy) * 0.28;
state.rvx *= 0.55;
state.vx *= 0.5;
state.vz *= 0.5;
state.phase = 'bounce';
} else {
playLand(0.28, 0.88);
const targetFacing = state.isHeads ? 0 : Math.PI;
state.baseRX = Math.round((state.rx - targetFacing) / (Math.PI * 2)) * (Math.PI * 2) + targetFacing;
state.theta = 0.58 + Math.random() * 0.12;
state.phi = 0;
state.omega = 10 + Math.random() * 4;
state.phase = 'wobble';
}
}
} else if (state.phase === 'wobble') {
state.theta *= (1 - dt * 1.35);
state.omega += dt * 58;
state.phi += state.omega * dt;
const prevPhi = state.phi - state.omega * dt;
if (Math.floor(state.phi / (Math.PI / 1.4)) > Math.floor(prevPhi / (Math.PI / 1.4))) {
playEdgeTap(state.theta * 0.55);
}
const q1 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), state.theta * Math.sin(state.phi));
const q2 = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), state.theta * Math.cos(state.phi));
const qBase = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), state.baseRX);
coin.quaternion.copy(qBase.clone().multiply(q1).multiply(q2));
// Keep coin at landing x,z (wherever it landed on the table)
coin.position.set(state.x, FLOOR_Y + THICK / 2 * Math.cos(state.theta) + RADIUS * Math.sin(state.theta), state.z);
// Pull camera in to watch the wobble — pulled back for larger scene
camPos.lerp(new THREE.Vector3(state.x * 0.3, 0.8, 8.5), dt * CAM_LERP);
camLook.lerp(new THREE.Vector3(state.x, FLOOR_Y + 0.3, state.z), dt * CAM_LERP);
if (state.theta < 0.005) {
state.phase = 'idle'; state.active = false;
coin.rotation.set(state.baseRX, 0, 0);
coin.position.set(state.x, FLOOR_Y + THICK / 2, state.z);
landedT = 5.5; // zoom in on the settled coin for 5.5 seconds
showResult();
}
} else {
const tipPos = new THREE.Vector3();
coinRestMarker.getWorldPosition(tipPos);
if (landedT > 0) {
// Post-result: zoom in close on settled coin so heads/tails is clearly readable
landedT -= dt;
const cp = coin.position;
// Camera hovers 1.8 units above coin, 3.2 units in front, slight x offset
camPos.lerp(new THREE.Vector3(cp.x + 0.3, cp.y + 1.8, cp.z + 3.2), dt * CAM_LERP * 1.4);
camLook.lerp(new THREE.Vector3(cp.x, cp.y + 0.05, cp.z), dt * CAM_LERP * 2);
} else {
// Idle — coin rests on dealer's thumb, wide shot framing dealer + table
coin.position.copy(tipPos);
coin.rotation.set(0, time * 0.55, 0); // slow spin on thumb
camPos.lerp(new THREE.Vector3(0, 2.0, 12), dt * CAM_LERP);
camLook.lerp(tipPos, dt * CAM_LERP * 1.5);
}
}
camera.position.copy(camPos);
camera.lookAt(camLook);
renderer.render(scene, camera);
}
animate();
let heads=0, tails=0, streak=0, lastRes='', history=[];
function showResult() {
const r = state.isHeads ? 'H' : 'T'; if(state.isHeads) heads++; else tails++;
streak = (r===lastRes) ? streak+1 : 1; lastRes=r; history.push(r);
const el = document.getElementById('result');
el.textContent = state.isHeads ? 'HEADS' : 'TAILS';
el.style.color = state.isHeads ? '#ffd700' : '#fff';
el.classList.add('show');
document.getElementById('val-total').textContent = heads+tails;
document.getElementById('val-heads').textContent = heads;
document.getElementById('val-tails').textContent = tails;
document.getElementById('val-streak').textContent = streak;
document.getElementById('history').innerHTML = history.slice(-20).map(x => `<div class="history-item ${x==='H'?'h':'t'}">${x}</div>`).join('');
}
document.getElementById('flip-btn').addEventListener('click', toss);
wrap.addEventListener('click', toss);
document.addEventListener('keydown', e => { if (e.code==='Space') { e.preventDefault(); toss(); } });
document.getElementById('reset-btn').addEventListener('click', () => {
if(state.active) return; heads=tails=streak=0; lastRes=''; history=[];
document.getElementById('result').classList.remove('show');
['val-total','val-heads','val-tails','val-streak'].forEach(i => document.getElementById(i).textContent='0');
document.getElementById('history').innerHTML = '';
});
window.addEventListener('resize', () => { renderer.setSize(wrap.clientWidth, wrap.clientHeight); camera.aspect = wrap.clientWidth / wrap.clientHeight; camera.updateProjectionMatrix(); });