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(); });