import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { initializeApp } from 'firebase/app'; import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged, } from 'firebase/auth'; import { getFirestore, doc, setDoc, getDoc, updateDoc, deleteDoc, collection, onSnapshot, } from 'firebase/firestore'; import { Trophy, Users, Play, Target, Settings, Volume2, VolumeX, RotateCcw, Shield, Award, Zap, Copy, Check, ChevronLeft, ChevronRight, RefreshCw, User, Sparkles, Crosshair, Clock, AlertTriangle, Info, Cpu, Globe, Lock, Unlock, Sliders, HelpCircle, LogOut, Plus, Search, Undo2, Eye, Activity, Server, Crown, } from 'lucide-react'; // ============================================================================ // 1. FIREBASE INITIALIZATION (STRICT COMPLIANCE WITH RULES 1, 2, 3) // ============================================================================ const firebaseConfig = typeof __firebase_config !== 'undefined' && __firebase_config ? JSON.parse(__firebase_config) : { apiKey: 'demo-api-key', authDomain: 'demo.firebaseapp.com', projectId: 'demo-project', }; const app = initializeApp(firebaseConfig); const auth = getAuth(app); const db = getFirestore(app); const appId = typeof __app_id !== 'undefined' ? __app_id : 'cue-masters-online'; // ============================================================================ // 2. PROCEDURAL WEB AUDIO SYNTHESIZER (NO EXTERNAL ASSETS REQUIRED) // ============================================================================ class SoundEngine { constructor() { this.ctx = null; this.muted = false; this.volume = 0.75; this.lastBallHitTime = 0; } init() { if (!this.ctx && typeof window !== 'undefined') { const AudioCtx = window.AudioContext || window.webkitAudioContext; if (AudioCtx) { this.ctx = new AudioCtx(); } } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume().catch(() => {}); } } playCueStrike(powerRatio = 0.5) { if (this.muted) return; this.init(); if (!this.ctx) return; const now = this.ctx.currentTime; const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'triangle'; const baseFreq = 140 + powerRatio * 90; osc.frequency.setValueAtTime(baseFreq, now); osc.frequency.exponentialRampToValueAtTime(45, now + 0.085); const vol = Math.min(1, Math.max(0.1, powerRatio)) * this.volume * 0.7; gain.gain.setValueAtTime(vol, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.09); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.095); } playBallCollision(impactVelocity = 5) { if (this.muted) return; this.init(); if (!this.ctx) return; const nowMs = performance.now(); if (nowMs - this.lastBallHitTime < 18) return; // Rate limit rapid break hits this.lastBallHitTime = nowMs; const now = this.ctx.currentTime; const normVel = Math.min(1, Math.max(0.05, impactVelocity / 22)); // Crisp phenolic resin click const osc = this.ctx.createOscillator(); const osc2 = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; osc2.type = 'triangle'; osc.frequency.setValueAtTime(1850 + normVel * 600, now); osc.frequency.exponentialRampToValueAtTime(620, now + 0.035); osc2.frequency.setValueAtTime(920 + normVel * 300, now); osc2.frequency.exponentialRampToValueAtTime(310, now + 0.04); const vol = normVel * this.volume * 0.65; gain.gain.setValueAtTime(vol, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.042); osc.connect(gain); osc2.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc2.start(now); osc.stop(now + 0.045); osc2.stop(now + 0.045); } playCushionImpact(speed = 4) { if (this.muted) return; this.init(); if (!this.ctx) return; const now = this.ctx.currentTime; const norm = Math.min(1, Math.max(0.05, speed / 18)); const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(115 + norm * 40, now); osc.frequency.exponentialRampToValueAtTime(38, now + 0.075); gain.gain.setValueAtTime(norm * this.volume * 0.45, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.08); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(now); osc.stop(now + 0.085); } playPocketDrop() { if (this.muted) return; this.init(); if (!this.ctx) return; const now = this.ctx.currentTime; [190, 145, 260].forEach((freq, idx) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sine'; const start = now + idx * 0.055; osc.frequency.setValueAtTime(freq, start); osc.frequency.exponentialRampToValueAtTime(70, start + 0.09); gain.gain.setValueAtTime(this.volume * 0.35, start); gain.gain.exponentialRampToValueAtTime(0.001, start + 0.095); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(start); osc.stop(start + 0.1); }); } playFoul() { if (this.muted) return; this.init(); if (!this.ctx) return; const now = this.ctx.currentTime; [220, 165].forEach((freq, idx) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'sawtooth'; const start = now + idx * 0.14; osc.frequency.setValueAtTime(freq, start); gain.gain.setValueAtTime(this.volume * 0.25, start); gain.gain.exponentialRampToValueAtTime(0.001, start + 0.18); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(start); osc.stop(start + 0.19); }); } playWinFanfare() { if (this.muted) return; this.init(); if (!this.ctx) return; const now = this.ctx.currentTime; const notes = [261.63, 329.63, 392.0, 523.25]; notes.forEach((freq, idx) => { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = 'triangle'; const start = now + idx * 0.11; osc.frequency.setValueAtTime(freq, start); gain.gain.setValueAtTime(this.volume * 0.35, start); gain.gain.exponentialRampToValueAtTime(0.001, start + 0.35); osc.connect(gain); gain.connect(this.ctx.destination); osc.start(start); osc.stop(start + 0.36); }); } } const soundFX = new SoundEngine(); // ============================================================================ // 3. CONSTANTS, TABLE GEOMETRY, CUES, FELT THEMES & BALL DEFINITIONS // ============================================================================ const TABLE_WIDTH = 1000; // Logical coordinate width (2:1 standard championship ratio) const TABLE_HEIGHT = 500; const RAIL_SIZE = 44; const PLAY_LEFT = RAIL_SIZE; const PLAY_RIGHT = TABLE_WIDTH - RAIL_SIZE; const PLAY_TOP = RAIL_SIZE; const PLAY_BOTTOM = TABLE_HEIGHT - RAIL_SIZE; const PLAY_W = PLAY_RIGHT - PLAY_LEFT; const PLAY_H = PLAY_BOTTOM - PLAY_TOP; // Pockets (6 standard pockets: 4 corners + 2 middle) const POCKETS = [ { id: 0, x: PLAY_LEFT + 4, y: PLAY_TOP + 4, radius: 24, isCorner: true }, { id: 1, x: TABLE_WIDTH / 2, y: PLAY_TOP - 4, radius: 22, isCorner: false }, { id: 2, x: PLAY_RIGHT - 4, y: PLAY_TOP + 4, radius: 24, isCorner: true }, { id: 3, x: PLAY_LEFT + 4, y: PLAY_BOTTOM - 4, radius: 24, isCorner: true }, { id: 4, x: TABLE_WIDTH / 2, y: PLAY_BOTTOM + 4, radius: 22, isCorner: false }, { id: 5, x: PLAY_RIGHT - 4, y: PLAY_BOTTOM - 4, radius: 24, isCorner: true }, ]; // Baulk / Head String Line & Snooker D-Zone const HEAD_STRING_X = PLAY_LEFT + PLAY_W * 0.22; const SNOOKER_D_RADIUS = PLAY_H * 0.165; const FOOT_SPOT_X = PLAY_LEFT + PLAY_W * 0.75; const FOOT_SPOT_Y = TABLE_HEIGHT / 2; // Pool Ball Visual Palette (1-15) const POOL_BALL_COLORS = { 0: { color: '#F8FAFC', label: '', isStripe: false }, 1: { color: '#EAB308', label: '1', isStripe: false }, // Yellow 2: { color: '#2563EB', label: '2', isStripe: false }, // Blue 3: { color: '#DC2626', label: '3', isStripe: false }, // Red 4: { color: '#7C3AED', label: '4', isStripe: false }, // Purple 5: { color: '#EA580C', label: '5', isStripe: false }, // Orange 6: { color: '#16A34A', label: '6', isStripe: false }, // Green 7: { color: '#881337', label: '7', isStripe: false }, // Maroon 8: { color: '#0F172A', label: '8', isStripe: false }, // Black 8 9: { color: '#EAB308', label: '9', isStripe: true }, 10: { color: '#2563EB', label: '10', isStripe: true }, 11: { color: '#DC2626', label: '11', isStripe: true }, 12: { color: '#7C3AED', label: '12', isStripe: true }, 13: { color: '#EA580C', label: '13', isStripe: true }, 14: { color: '#16A34A', label: '14', isStripe: true }, 15: { color: '#881337', label: '15', isStripe: true }, }; // Snooker Color Spot Definitions const SNOOKER_COLORS_CONFIG = [ { id: 20, snookerType: 'color', name: 'Yellow', points: 2, color: '#FACC15', spotX: HEAD_STRING_X, spotY: TABLE_HEIGHT / 2 + SNOOKER_D_RADIUS, }, { id: 21, snookerType: 'color', name: 'Green', points: 3, color: '#16A34A', spotX: HEAD_STRING_X, spotY: TABLE_HEIGHT / 2 - SNOOKER_D_RADIUS, }, { id: 22, snookerType: 'color', name: 'Brown', points: 4, color: '#92400E', spotX: HEAD_STRING_X, spotY: TABLE_HEIGHT / 2, }, { id: 23, snookerType: 'color', name: 'Blue', points: 5, color: '#2563EB', spotX: TABLE_WIDTH / 2, spotY: TABLE_HEIGHT / 2, }, { id: 24, snookerType: 'color', name: 'Pink', points: 6, color: '#F472B6', spotX: PLAY_LEFT + PLAY_W * 0.72, spotY: TABLE_HEIGHT / 2, }, { id: 25, snookerType: 'color', name: 'Black', points: 7, color: '#0F172A', spotX: PLAY_LEFT + PLAY_W * 0.91, spotY: TABLE_HEIGHT / 2, }, ]; // Cosmetics — Cue Sticks (Purely Cosmetic for Fair Play) const CUE_CATALOG = [ { id: 'classic_maple', name: 'Classic Ash & Maple', tier: 'Standard', cost: 0, minLevel: 1, buttColor: '#451A03', shaftColor: '#FDE68A', wrapColor: '#1E293B', accentColor: '#D97706', trailColor: 'rgba(255,255,255,0.65)', }, { id: 'carbon_pro', name: 'Stealth Carbon Pro', tier: 'Pro', cost: 250, minLevel: 2, buttColor: '#0F172A', shaftColor: '#475569', wrapColor: '#1E293B', accentColor: '#38BDF8', trailColor: 'rgba(56,189,248,0.75)', }, { id: 'emerald_viper', name: 'Emerald Cruiser', tier: 'Elite', cost: 500, minLevel: 3, buttColor: '#064E3B', shaftColor: '#FEF3C7', wrapColor: '#022C22', accentColor: '#10B981', trailColor: 'rgba(16,185,129,0.75)', }, { id: 'royal_sovereign', name: 'Royal Sovereign Gold', tier: 'Master', cost: 900, minLevel: 4, buttColor: '#78350F', shaftColor: '#FEF08A', wrapColor: '#451A03', accentColor: '#FACC15', trailColor: 'rgba(250,204,21,0.85)', }, { id: 'celestial_neon', name: 'Celestial Cyber Cue', tier: 'Grandmaster', cost: 1500, minLevel: 5, buttColor: '#311042', shaftColor: '#E2E8F0', wrapColor: '#090D16', accentColor: '#E879F9', trailColor: 'rgba(232,121,249,0.85)', }, ]; // Cosmetics — Table Felt Themes const TABLE_THEMES = [ { id: 'championship_green', name: 'Crucible Championship Green', cost: 0, feltCenter: '#0D7A49', feltEdge: '#064E2E', railColor: '#451A03', trimColor: '#D97706', }, { id: 'tournament_blue', name: 'US Open Tournament Blue', cost: 200, feltCenter: '#1D5B9E', feltEdge: '#123A66', railColor: '#1E293B', trimColor: '#94A3B8', }, { id: 'royal_burgundy', name: 'Mayfair Club Burgundy', cost: 400, feltCenter: '#881337', feltEdge: '#4C0519', railColor: '#3B180C', trimColor: '#F59E0B', }, { id: 'midnight_slate', name: 'Midnight Obsidian Slate', cost: 650, feltCenter: '#1E293B', feltEdge: '#0F172A', railColor: '#090D16', trimColor: '#38BDF8', }, ]; const AVATAR_OPTIONS = ['🎱', '👑', '🦊', '🐯', '⚡', '🔥', '💎', '🎯', '🦁', '🐉']; // Helper to determine Rank Tier from Elo function getRankTier(elo = 1200) { if (elo >= 1800) return { name: 'Grandmaster', color: 'text-fuchsia-400', bg: 'bg-fuchsia-500/15 border-fuchsia-500/40' }; if (elo >= 1550) return { name: 'Expert', color: 'text-amber-400', bg: 'bg-amber-500/15 border-amber-500/40' }; if (elo >= 1350) return { name: 'Advanced', color: 'text-emerald-400', bg: 'bg-emerald-500/15 border-emerald-500/40' }; if (elo >= 1150) return { name: 'Intermediate', color: 'text-sky-400', bg: 'bg-sky-500/15 border-sky-500/40' }; return { name: 'Beginner', color: 'text-slate-300', bg: 'bg-slate-500/15 border-slate-500/40' }; } // ============================================================================ // 4. INITIAL RACK GENERATORS FOR 8-BALL, 9-BALL, SNOOKER & DRILLS // ============================================================================ function createInitialBalls(mode = '8ball', drillType = 'standard') { const isSnooker = mode === 'snooker'; const radius = isSnooker ? 10.5 : 13.2; const balls = []; // Cue ball (id: 0) balls.push({ id: 0, x: HEAD_STRING_X - 35, y: TABLE_HEIGHT / 2 + (isSnooker ? 25 : 0), vx: 0, vy: 0, spinX: 0, spinY: 0, radius, color: '#F8FAFC', label: '', isStripe: false, potted: false, snookerType: 'cue', points: 0, rollPhase: 0, }); if (mode === '8ball') { if (drillType === 'clearance_drill') { // Practice clearance drill const customSpots = [ { id: 1, x: 580, y: 180 }, { id: 2, x: 720, y: 140 }, { id: 3, x: 820, y: 310 }, { id: 9, x: 640, y: 360 }, { id: 10, x: 770, y: 240 }, { id: 8, x: 840, y: 250 }, ]; customSpots.forEach((s) => { const meta = POOL_BALL_COLORS[s.id]; balls.push({ id: s.id, x: s.x, y: s.y, vx: 0, vy: 0, spinX: 0, spinY: 0, radius, color: meta.color, label: meta.label, isStripe: meta.isStripe, potted: false, group: s.id === 8 ? 'eight' : s.id < 8 ? 'solids' : 'stripes', rollPhase: 0, }); }); return balls; } // Standard 15-ball triangle rack // Row layout: 5 rows (1, 2, 3, 4, 5) // Ensure 8-ball is in center of row 3 (index 4), and bottom corners are 1 solid & 1 stripe const rackOrder = [ 1, // Row 0 apex 9, 2, // Row 1 10, 8, 3, // Row 2 (8-ball in center!) 4, 14, 11, 5, // Row 3 12, 6, 13, 15, 7, // Row 4 (corners: 12 stripe & 7 solid) ]; const dx = radius * Math.sqrt(3) + 0.4; const dy = radius + 0.25; let idx = 0; for (let row = 0; row < 5; row++) { for (let col = 0; col <= row; col++) { const ballId = rackOrder[idx++]; const meta = POOL_BALL_COLORS[ballId]; const x = FOOT_SPOT_X + row * dx; const y = FOOT_SPOT_Y + (col - row / 2) * (dy * 2); balls.push({ id: ballId, x, y, vx: 0, vy: 0, spinX: 0, spinY: 0, radius, color: meta.color, label: meta.label, isStripe: meta.isStripe, potted: false, group: ballId === 8 ? 'eight' : ballId < 8 ? 'solids' : 'stripes', rollPhase: (ballId * 0.7) % 6.28, }); } } } else if (mode === '9ball') { // 9-Ball Diamond Rack: 1-ball at apex, 9-ball in center // Diamond rows have lengths: 1, 2, 3, 2, 1 const diamondRows = [ [1], [2, 3], [4, 9, 5], [6, 7], [8], ]; const dx = radius * Math.sqrt(3) + 0.4; const dy = radius + 0.25; for (let row = 0; row < diamondRows.length; row++) { const rowBalls = diamondRows[row]; for (let col = 0; col < rowBalls.length; col++) { const ballId = rowBalls[col]; const meta = POOL_BALL_COLORS[ballId]; const x = FOOT_SPOT_X + row * dx; const y = FOOT_SPOT_Y + (col - (rowBalls.length - 1) / 2) * (dy * 2); balls.push({ id: ballId, x, y, vx: 0, vy: 0, spinX: 0, spinY: 0, radius, color: meta.color, label: meta.label, isStripe: meta.isStripe, potted: false, group: 'nineball', rollPhase: (ballId * 0.8) % 6.28, }); } } } else if (mode === 'snooker') { // 6 Colored Balls on their official spots SNOOKER_COLORS_CONFIG.forEach((c) => { balls.push({ id: c.id, x: c.spotX, y: c.spotY, spotX: c.spotX, spotY: c.spotY, vx: 0, vy: 0, spinX: 0, spinY: 0, radius, color: c.color, label: String(c.points), name: c.name, isStripe: false, potted: false, snookerType: 'color', points: c.points, rollPhase: 0, }); }); // 15 Red Balls racked just to the right of the Pink ball (spotX = PLAY_LEFT + PLAY_W * 0.72) const pinkSpotX = PLAY_LEFT + PLAY_W * 0.72; const apexX = pinkSpotX + radius * 2.35; const dx = radius * Math.sqrt(3) + 0.35; const dy = radius + 0.2; let redId = 1; for (let row = 0; row < 5; row++) { for (let col = 0; col <= row; col++) { const x = apexX + row * dx; const y = FOOT_SPOT_Y + (col - row / 2) * (dy * 2); balls.push({ id: redId++, x, y, vx: 0, vy: 0, spinX: 0, spinY: 0, radius, color: '#DC2626', label: '', name: 'Red', isStripe: false, potted: false, snookerType: 'red', points: 1, rollPhase: 0, }); } } } return balls; } // ============================================================================ // 5. DETERMINISTIC PHYSICS STEP & RAYCAST AIMING ENGINE // ============================================================================ function isBallLegalTarget(ball, gameState) { if (!ball || ball.id === 0 || ball.potted) return false; const { mode, currentTurn, playerGroups, balls, snookerTarget } = gameState; if (mode === '8ball') { const group = playerGroups[currentTurn]; if (!group) { // Open table: any ball except 8-ball is a legal first contact return ball.id !== 8; } const remainingInGroup = balls.filter( (b) => !b.potted && b.id !== 0 && b.id !== 8 && b.group === group ).length; if (remainingInGroup > 0) { return ball.group === group; } return ball.id === 8; } if (mode === '9ball') { // Must contact the lowest-numbered ball on the table first const activeObjectBalls = balls.filter((b) => !b.potted && b.id > 0); if (activeObjectBalls.length === 0) return false; const lowestId = Math.min(...activeObjectBalls.map((b) => b.id)); return ball.id === lowestId; } if (mode === 'snooker') { if (snookerTarget === 'red') { return ball.snookerType === 'red'; } if (snookerTarget === 'color') { return ball.snookerType === 'color'; } if (typeof snookerTarget === 'number') { return ball.points === snookerTarget; } } return true; } // Compute Raycast for Aiming Line, Ghost Ball, and Deflection Vectors function computeAimRaycast(balls, aimAngle, spinX = 0, spinY = 0) { const cueBall = balls.find((b) => b.id === 0 && !b.potted); if (!cueBall) return null; const dx = Math.cos(aimAngle); const dy = Math.sin(aimAngle); const r = cueBall.radius; let closestDist = Infinity; let hitBall = null; // Check intersection with all active object balls (circle-circle sweep) for (let i = 0; i < balls.length; i++) { const b = balls[i]; if (b.id === 0 || b.potted) continue; const rx = b.x - cueBall.x; const ry = b.y - cueBall.y; const proj = rx * dx + ry * dy; if (proj <= 0) continue; // Behind cue ball movement direction const perpDistSq = rx * rx + ry * ry - proj * proj; const sumR = r + b.radius; if (perpDistSq < sumR * sumR) { const offset = Math.sqrt(sumR * sumR - perpDistSq); const dist = proj - offset; if (dist >= 0 && dist < closestDist) { closestDist = dist; hitBall = b; } } } // Check cushion boundaries if no ball hit or before ball hit let wallDist = Infinity; let wallNormal = { x: 0, y: 0 }; if (dx > 0.0001) { const d = (PLAY_RIGHT - r - cueBall.x) / dx; if (d < wallDist) { wallDist = d; wallNormal = { x: -1, y: 0 }; } } else if (dx < -0.0001) { const d = (PLAY_LEFT + r - cueBall.x) / dx; if (d < wallDist) { wallDist = d; wallNormal = { x: 1, y: 0 }; } } if (dy > 0.0001) { const d = (PLAY_BOTTOM - r - cueBall.y) / dy; if (d < wallDist) { wallDist = d; wallNormal = { x: 0, y: -1 }; } } else if (dy < -0.0001) { const d = (PLAY_TOP + r - cueBall.y) / dy; if (d < wallDist) { wallDist = d; wallNormal = { x: 0, y: 1 }; } } if (hitBall && closestDist <= wallDist) { const ghostX = cueBall.x + dx * closestDist; const ghostY = cueBall.y + dy * closestDist; // Normal from ghost ball center to hitBall center const nx = (hitBall.x - ghostX) / (r + hitBall.radius); const ny = (hitBall.y - ghostY) / (r + hitBall.radius); // Tangent direction for cue ball deflection const dot = dx * nx + dy * ny; let tangentX = dx - dot * nx; let tangentY = dy - dot * ny; // Incorporate topspin/backspin influence on post-impact cue ball path preview tangentX += dx * spinY * 0.45 - dy * spinX * 0.2; tangentY += dy * spinY * 0.45 + dx * spinX * 0.2; return { type: 'ball', ghostX, ghostY, hitBall, targetDirX: nx, targetDirY: ny, cueDeflectX: tangentX, cueDeflectY: tangentY, }; } // Hit cushion first const ghostX = cueBall.x + dx * wallDist; const ghostY = cueBall.y + dy * wallDist; // Reflected vector with sidespin influence let refX = wallNormal.x !== 0 ? -dx : dx; let refY = wallNormal.y !== 0 ? -dy : dy; refX += -wallNormal.y * spinX * 0.35; refY += wallNormal.x * spinX * 0.35; return { type: 'cushion', ghostX, ghostY, hitBall: null, cueDeflectX: refX, cueDeflectY: refY, }; } // Single deterministic physics step (runs multiple times per visual frame) function stepPhysicsSimulation(balls, shotTracker, playAudio = true) { const SUBSTEPS = 6; const FRICTION = 0.9918; const SPIN_DECAY = 0.986; const RESTITUTION_BALL = 0.945; const RESTITUTION_RAIL = 0.81; let anyMoving = false; for (let step = 0; step < SUBSTEPS; step++) { // 1. Advance positions & apply cloth friction + spin transfer for (let i = 0; i < balls.length; i++) { const b = balls[i]; if (b.potted) continue; const speed = Math.hypot(b.vx, b.vy); if (speed > 0.025 || Math.abs(b.spinY) > 0.04) { anyMoving = true; b.x += b.vx / SUBSTEPS; b.y += b.vy / SUBSTEPS; // Apply rolling/sliding friction per substep const stepFriction = Math.pow(FRICTION, 1 / SUBSTEPS); b.vx *= stepFriction; b.vy *= stepFriction; // Topspin / Backspin continuous cloth traction on cue ball if (b.id === 0 && (Math.abs(b.spinY) > 0.02 || Math.abs(b.spinX) > 0.02)) { b.spinY *= Math.pow(SPIN_DECAY, 1 / SUBSTEPS); b.spinX *= Math.pow(SPIN_DECAY, 1 / SUBSTEPS); } b.rollPhase = (b.rollPhase + speed * 0.03) % (Math.PI * 2); if (Math.hypot(b.vx, b.vy) < 0.028 && Math.abs(b.spinY) < 0.04) { b.vx = 0; b.vy = 0; b.spinX = 0; b.spinY = 0; } } else { b.vx = 0; b.vy = 0; } } // 2. Pocket detection for (let i = 0; i < balls.length; i++) { const b = balls[i]; if (b.potted) continue; for (let p = 0; p < POCKETS.length; p++) { const pocket = POCKETS[p]; const dist = Math.hypot(b.x - pocket.x, b.y - pocket.y); if (dist < pocket.radius) { b.potted = true; b.vx = 0; b.vy = 0; b.spinX = 0; b.spinY = 0; b.pocketAnim = { x: pocket.x, y: pocket.y, progress: 0 }; shotTracker.pottedBalls.push(b); if (playAudio) soundFX.playPocketDrop(); break; } } } // 3. Cushion collisions (with pocket mouth gaps) for (let i = 0; i < balls.length; i++) { const b = balls[i]; if (b.potted) continue; const r = b.radius; // Check if ball is inside a pocket throat (allow entry into pocket) const nearPocket = POCKETS.some( (pk) => Math.hypot(b.x - pk.x, b.y - pk.y) < pk.radius + 9 ); if (nearPocket) continue; let hitCushion = false; const preSpeed = Math.hypot(b.vx, b.vy); if (b.x - r < PLAY_LEFT) { b.x = PLAY_LEFT + r; b.vx = Math.abs(b.vx) * RESTITUTION_RAIL; if (b.id === 0) { b.vy += b.spinX * 1.4; b.spinX *= 0.5; } hitCushion = true; } else if (b.x + r > PLAY_RIGHT) { b.x = PLAY_RIGHT - r; b.vx = -Math.abs(b.vx) * RESTITUTION_RAIL; if (b.id === 0) { b.vy -= b.spinX * 1.4; b.spinX *= 0.5; } hitCushion = true; } if (b.y - r < PLAY_TOP) { b.y = PLAY_TOP + r; b.vy = Math.abs(b.vy) * RESTITUTION_RAIL; if (b.id === 0) { b.vx -= b.spinX * 1.4; b.spinX *= 0.5; } hitCushion = true; } else if (b.y + r > PLAY_BOTTOM) { b.y = PLAY_BOTTOM - r; b.vy = -Math.abs(b.vy) * RESTITUTION_RAIL; if (b.id === 0) { b.vx += b.spinX * 1.4; b.spinX *= 0.5; } hitCushion = true; } if (hitCushion) { if (shotTracker.firstContactBall) { shotTracker.cushionHitsAfterContact += 1; } shotTracker.totalCushionHits += 1; if (playAudio && preSpeed > 0.6) { soundFX.playCushionImpact(preSpeed); } } } // 4. Ball-to-Ball Elastic Collisions & Spin Action for (let i = 0; i < balls.length; i++) { const b1 = balls[i]; if (b1.potted) continue; for (let j = i + 1; j < balls.length; j++) { const b2 = balls[j]; if (b2.potted) continue; const dx = b2.x - b1.x; const dy = b2.y - b1.y; const dist = Math.hypot(dx, dy); const minDist = b1.radius + b2.radius; if (dist < minDist && dist > 0.0001) { // Record first object ball contacted by Cue Ball (id === 0) if ((b1.id === 0 || b2.id === 0) && !shotTracker.firstContactBall) { shotTracker.firstContactBall = b1.id === 0 ? b2 : b1; } // Resolve overlap immediately to prevent tunneling const overlap = minDist - dist; const nx = dx / dist; const ny = dy / dist; b1.x -= nx * (overlap * 0.5); b1.y -= ny * (overlap * 0.5); b2.x += nx * (overlap * 0.5); b2.y += ny * (overlap * 0.5); // Relative velocity along normal const rvx = b1.vx - b2.vx; const rvy = b1.vy - b2.vy; const velAlongNormal = rvx * nx + rvy * ny; if (velAlongNormal > 0) { const impulse = velAlongNormal * RESTITUTION_BALL; b1.vx -= nx * impulse; b1.vy -= ny * impulse; b2.vx += nx * impulse; b2.vy += ny * impulse; // Apply Cue Ball Topspin (follow) / Backspin (draw/screw) upon hitting an object ball! if (b1.id === 0 && (Math.abs(b1.spinY) > 0.05 || Math.abs(b1.spinX) > 0.05)) { const spinForce = impulse * 0.42; // spinY > 0 drives cue ball forward along original collision normal; spinY < 0 draws it back b1.vx += nx * b1.spinY * spinForce - ny * b1.spinX * spinForce * 0.35; b1.vy += ny * b1.spinY * spinForce + nx * b1.spinX * spinForce * 0.35; b1.spinY *= 0.25; b1.spinX *= 0.4; } else if (b2.id === 0 && (Math.abs(b2.spinY) > 0.05 || Math.abs(b2.spinX) > 0.05)) { const spinForce = impulse * 0.42; b2.vx -= nx * b2.spinY * spinForce - ny * b2.spinX * spinForce * 0.35; b2.vy -= ny * b2.spinY * spinForce + nx * b2.spinX * spinForce * 0.35; b2.spinY *= 0.25; b2.spinX *= 0.4; } if (playAudio && velAlongNormal > 0.35) { soundFX.playBallCollision(velAlongNormal); } } } } } } return anyMoving; } // ============================================================================ // 6. COMPLETE RULE EVALUATION ENGINE (8-BALL, 9-BALL & SNOOKER) // ============================================================================ function evaluateShotRules(prevState, ballsAfter, shotTracker) { const { mode, currentTurn, playerGroups, scores, currentBreak, snookerTarget, isBreakShot, } = prevState; const opponentTurn = currentTurn === 1 ? 2 : 1; const nextBalls = ballsAfter.map((b) => ({ ...b })); const cueBall = nextBalls.find((b) => b.id === 0); const cuePotted = shotTracker.pottedBalls.some((b) => b.id === 0); const objectPotted = shotTracker.pottedBalls.filter((b) => b.id !== 0); const firstHit = shotTracker.firstContactBall; let foulReason = null; let turnContinues = false; let winner = null; let winReason = ''; let nextGroups = { ...playerGroups }; let nextScores = { ...scores }; let nextBreak = currentBreak; let nextSnookerTarget = snookerTarget; let ballInHand = false; let bannerMessage = ''; // Helper to respot cue ball safely if scratched const respotCueBall = () => { if (!cueBall) return; cueBall.potted = false; cueBall.vx = 0; cueBall.vy = 0; cueBall.spinX = 0; cueBall.spinY = 0; let testX = HEAD_STRING_X - 35; let testY = TABLE_HEIGHT / 2; // Ensure no overlap with existing balls for (let offset = 0; offset < 120; offset += 15) { const candidateY = TABLE_HEIGHT / 2 + (offset % 2 === 0 ? offset : -offset); const conflict = nextBalls.some( (ob) => ob.id !== 0 && !ob.potted && Math.hypot(ob.x - testX, ob.y - candidateY) < ob.radius + cueBall.radius + 4 ); if (!conflict) { testY = candidateY; break; } } cueBall.x = testX; cueBall.y = testY; }; // -------------------------------------------------------------------------- // MODE A: 8-BALL POOL RULES // -------------------------------------------------------------------------- if (mode === '8ball') { const eightPotted = objectPotted.some((b) => b.id === 8); const activeGroup = nextGroups[currentTurn]; const remainingBeforeShot = prevState.balls.filter( (b) => !b.potted && b.id !== 0 && b.id !== 8 && (!activeGroup || b.group === activeGroup) ).length; // 1. Check 8-Ball Potted Conditions if (eightPotted) { if (isBreakShot) { // 8-ball on break: respot 8-ball unless scratched const eightBall = nextBalls.find((b) => b.id === 8); if (eightBall) { eightBall.potted = false; eightBall.x = FOOT_SPOT_X; eightBall.y = FOOT_SPOT_Y; } if (cuePotted) { foulReason = 'Scratch on Break! 8-Ball respotted.'; } else { turnContinues = true; bannerMessage = '8-Ball potted on break — respotted on foot spot!'; } } else if (!activeGroup || remainingBeforeShot > 0) { // Illegally potted 8-ball early -> Immediate Loss! winner = opponentTurn; winReason = `Player ${currentTurn} potted the 8-Ball before clearing their group!`; return { balls: nextBalls, currentTurn: opponentTurn, playerGroups: nextGroups, scores: nextScores, currentBreak: 0, snookerTarget: nextSnookerTarget, ballInHand: false, foulMessage: winReason, bannerMessage: winReason, winner, winReason, isBreakShot: false, }; } else { // Player was legally on the 8-ball! if (cuePotted) { winner = opponentTurn; winReason = `Player ${currentTurn} scratched while potting the 8-Ball!`; } else if (!firstHit || firstHit.id !== 8) { winner = opponentTurn; winReason = `Foul on the 8-Ball! Player ${ opponentTurn } wins.`; } else { winner = currentTurn; winReason = `Player ${currentTurn} legally pocketed the 8-Ball!`; nextScores[currentTurn] = (nextScores[currentTurn] || 0) + 1; } return { balls: nextBalls, currentTurn: winner, playerGroups: nextGroups, scores: nextScores, currentBreak: 0, snookerTarget: nextSnookerTarget, ballInHand: false, foulMessage: winner !== currentTurn ? winReason : null, bannerMessage: winReason, winner, winReason, isBreakShot: false, }; } } // 2. Standard 8-Ball Foul Detection if (cuePotted) { foulReason = 'Scratch! Cue ball potted.'; } else if (!firstHit) { foulReason = 'Foul! Cue ball failed to contact any ball.'; } else if (activeGroup) { // Must hit own group first (or 8-ball if group is already cleared) const mustHitEight = remainingBeforeShot === 0; if (mustHitEight && firstHit.id !== 8) { foulReason = `Foul! Must contact the 8-Ball first.`; } else if (!mustHitEight && firstHit.group !== activeGroup) { foulReason = `Foul! Must hit ${activeGroup.toUpperCase()} first.`; } } else if (!activeGroup && !isBreakShot && firstHit.id === 8) { foulReason = 'Foul! Cannot hit the 8-Ball first on an open table.'; } // Check cushion contact after hit if no ball was potted if (!foulReason && objectPotted.length === 0 && shotTracker.cushionHitsAfterContact === 0) { foulReason = 'Foul! No ball potted and no cushion contacted after impact.'; } if (foulReason) { if (cuePotted) respotCueBall(); ballInHand = true; bannerMessage = `${foulReason} Ball-in-Hand to Player ${opponentTurn}.`; } else { // Legal shot! Check group assignment or continuation const pottedSolids = objectPotted.filter((b) => b.group === 'solids'); const pottedStripes = objectPotted.filter((b) => b.group === 'stripes'); if (!activeGroup && !isBreakShot && (pottedSolids.length > 0 || pottedStripes.length > 0)) { const chosenGroup = pottedSolids.length >= pottedStripes.length ? 'solids' : 'stripes'; nextGroups[currentTurn] = chosenGroup; nextGroups[opponentTurn] = chosenGroup === 'solids' ? 'stripes' : 'solids'; turnContinues = true; bannerMessage = `Table Assigned! Player ${currentTurn} is ${chosenGroup.toUpperCase()}.`; } else if (!activeGroup && isBreakShot && objectPotted.length > 0) { turnContinues = true; bannerMessage = 'Legal Break Pot! Table remains Open.'; } else if (activeGroup) { const pottedOwnGroup = objectPotted.some((b) => b.group === activeGroup); if (pottedOwnGroup) { turnContinues = true; bannerMessage = `Nice shot! Player ${currentTurn} continues.`; } else { bannerMessage = `Turn passes to Player ${opponentTurn}.`; } } else { bannerMessage = `Turn passes to Player ${opponentTurn}.`; } // Update ball count score for HUD nextScores[1] = nextGroups[1] ? 7 - nextBalls.filter((b) => !b.potted && b.group === nextGroups[1]).length : 0; nextScores[2] = nextGroups[2] ? 7 - nextBalls.filter((b) => !b.potted && b.group === nextGroups[2]).length : 0; } } // -------------------------------------------------------------------------- // MODE B: 9-BALL POOL RULES // -------------------------------------------------------------------------- else if (mode === '9ball') { const activeBefore = prevState.balls.filter((b) => !b.potted && b.id > 0); const lowestId = activeBefore.length > 0 ? Math.min(...activeBefore.map((b) => b.id)) : 1; const ninePotted = objectPotted.some((b) => b.id === 9); if (cuePotted) { foulReason = 'Scratch! Cue ball potted.'; } else if (!firstHit) { foulReason = `Foul! Cue ball missed the ${lowestId}-Ball.`; } else if (firstHit.id !== lowestId) { foulReason = `Foul! Must strike the lowest ball (${lowestId}-Ball) first.`; } else if (objectPotted.length === 0 && shotTracker.cushionHitsAfterContact === 0) { foulReason = 'Foul! No ball potted and no rail contacted after impact.'; } if (foulReason) { if (cuePotted) respotCueBall(); // If 9-ball was potted on a foul, respot the 9-ball on the foot spot! if (ninePotted) { const nineBall = nextBalls.find((b) => b.id === 9); if (nineBall) { nineBall.potted = false; nineBall.x = FOOT_SPOT_X; nineBall.y = FOOT_SPOT_Y; } foulReason += ' 9-Ball respotted.'; } ballInHand = true; bannerMessage = `${foulReason} Ball-in-Hand to Player ${opponentTurn}.`; } else { // Legal shot! Did the 9-ball go in? if (ninePotted) { winner = currentTurn; winReason = `Player ${currentTurn} legally pocketed the 9-Ball to win the rack!`; nextScores[currentTurn] = (nextScores[currentTurn] || 0) + 1; return { balls: nextBalls, currentTurn, playerGroups: nextGroups, scores: nextScores, currentBreak: nextBreak + 1, snookerTarget: nextSnookerTarget, ballInHand: false, foulMessage: null, bannerMessage: winReason, winner, winReason, isBreakShot: false, }; } if (objectPotted.length > 0) { turnContinues = true; nextScores[currentTurn] = (nextScores[currentTurn] || 0) + objectPotted.length; bannerMessage = `Great pot! Player ${currentTurn} continues at the table.`; } else { bannerMessage = `Turn passes to Player ${opponentTurn}.`; } } } // -------------------------------------------------------------------------- // MODE C: SNOOKER RULES (15 REDS + 6 COLORS) // -------------------------------------------------------------------------- else if (mode === 'snooker') { const redsRemainingBefore = prevState.balls.filter( (b) => !b.potted && b.snookerType === 'red' ).length; const getExpectedPenalty = (ballVal = 0) => { const targetVal = typeof snookerTarget === 'number' ? snookerTarget : snookerTarget === 'red' ? 1 : 4; return Math.max(4, targetVal, ballVal); }; // Helper to respot any potted colored ball const respotColorBall = (colorBall) => { const targetBall = nextBalls.find((b) => b.id === colorBall.id); if (!targetBall) return; targetBall.potted = false; targetBall.vx = 0; targetBall.vy = 0; // Try its own spot first, then highest available spot const spotCandidates = [ { x: targetBall.spotX, y: targetBall.spotY }, ...SNOOKER_COLORS_CONFIG.slice() .reverse() .map((c) => ({ x: c.spotX, y: c.spotY })), ]; let placed = false; for (const sp of spotCandidates) { const occupied = nextBalls.some( (ob) => ob.id !== targetBall.id && !ob.potted && Math.hypot(ob.x - sp.x, ob.y - sp.y) < ob.radius + targetBall.radius + 2 ); if (!occupied) { targetBall.x = sp.x; targetBall.y = sp.y; placed = true; break; } } if (!placed) { targetBall.x = targetBall.spotX + 24; targetBall.y = targetBall.spotY; } }; let foulPenalty = 0; if (cuePotted) { const maxPottedVal = objectPotted.reduce((m, b) => Math.max(m, b.points || 4), 4); foulPenalty = getExpectedPenalty(maxPottedVal); foulReason = `In-Off (Scratch)! ${foulPenalty} foul points to Player ${opponentTurn}.`; } else if (!firstHit) { foulPenalty = getExpectedPenalty(4); foulReason = `Foul (Miss)! ${foulPenalty} penalty points to Player ${opponentTurn}.`; } else { // Check if first hit matched snookerTarget const legalFirst = isBallLegalTarget(firstHit, prevState); if (!legalFirst) { foulPenalty = getExpectedPenalty(firstHit.points || 4); foulReason = `Foul! Struck ${firstHit.name || 'wrong ball'} first (${foulPenalty} pts to opponent).`; } else if (objectPotted.length > 0) { // Verify all potted balls match target rule if (snookerTarget === 'red') { const pottedColor = objectPotted.find((b) => b.snookerType === 'color'); if (pottedColor) { foulPenalty = getExpectedPenalty(pottedColor.points); foulReason = `Foul! Potted ${pottedColor.name} while on Red (${foulPenalty} pts).`; } } else if (snookerTarget === 'color') { const pottedRed = objectPotted.find((b) => b.snookerType === 'red'); const uniqueColors = new Set(objectPotted.map((b) => b.id)); if (pottedRed || uniqueColors.size > 1) { const highest = Math.max(...objectPotted.map((b) => b.points || 4)); foulPenalty = getExpectedPenalty(highest); foulReason = `Foul! Illegal ball potted while on a Color (${foulPenalty} pts).`; } } else if (typeof snookerTarget === 'number') { if (objectPotted.length > 1 || objectPotted[0].points !== snookerTarget) { const highest = Math.max(...objectPotted.map((b) => b.points || 4)); foulPenalty = getExpectedPenalty(highest); foulReason = `Foul on the colors! ${foulPenalty} pts awarded to opponent.`; } } } } const redsRemainingNow = nextBalls.filter( (b) => !b.potted && b.snookerType === 'red' ).length; if (foulReason) { if (cuePotted) { respotCueBall(); ballInHand = true; // Place in the D-zone } // Respot any colored balls potted during a foul objectPotted .filter((b) => b.snookerType === 'color') .forEach((cb) => respotColorBall(cb)); nextScores[opponentTurn] = (nextScores[opponentTurn] || 0) + foulPenalty; nextBreak = 0; nextSnookerTarget = redsRemainingNow > 0 ? 'red' : getLowestRemainingSnookerColor(nextBalls); bannerMessage = foulReason; } else if (objectPotted.length > 0) { // Legal Snooker Pot! const ptsScored = objectPotted.reduce((sum, b) => sum + (b.points || 1), 0); nextScores[currentTurn] = (nextScores[currentTurn] || 0) + ptsScored; nextBreak = currentBreak + ptsScored; turnContinues = true; if (snookerTarget === 'red') { nextSnookerTarget = 'color'; bannerMessage = `Potted Red (+${ptsScored})! Break: ${nextBreak}. Nominate any Color.`; } else if (snookerTarget === 'color') { // Respot the potted color because this was the color after a red objectPotted .filter((b) => b.snookerType === 'color') .forEach((cb) => respotColorBall(cb)); nextSnookerTarget = redsRemainingNow > 0 ? 'red' : getLowestRemainingSnookerColor(nextBalls); bannerMessage = `Potted ${objectPotted[0].name} (+${ptsScored})! Break: ${nextBreak}.`; } else if (typeof snookerTarget === 'number') { // Final clearance of colors (do NOT respot!) const nextCol = getLowestRemainingSnookerColor(nextBalls); nextSnookerTarget = nextCol; bannerMessage = `Cleared ${objectPotted[0].name} (+${ptsScored})! Break: ${nextBreak}.`; } } else { // No pot, legal safety shot nextBreak = 0; nextSnookerTarget = redsRemainingNow > 0 ? 'red' : getLowestRemainingSnookerColor(nextBalls); bannerMessage = `End of break. Player ${opponentTurn} to play (${formatSnookerTarget(nextSnookerTarget)}).`; } // Check if frame is complete (all balls potted) const remainingObjectBalls = nextBalls.filter((b) => !b.potted && b.id !== 0); if (remainingObjectBalls.length === 0) { if (nextScores[1] === nextScores[2]) { // Respotted Black tie-breaker! const blackBall = nextBalls.find((b) => b.points === 7); if (blackBall) respotColorBall(blackBall); nextSnookerTarget = 7; bannerMessage = 'Tied Frame! Respotted Black shootout!'; } else { winner = nextScores[1] > nextScores[2] ? 1 : 2; winReason = `Frame Complete! Player ${winner} wins ${nextScores[1]} – ${nextScores[2]}!`; } } } return { balls: nextBalls, currentTurn: turnContinues ? currentTurn : opponentTurn, playerGroups: nextGroups, scores: nextScores, currentBreak: turnContinues ? nextBreak : 0, snookerTarget: nextSnookerTarget, ballInHand, foulMessage: foulReason, bannerMessage, winner, winReason, isBreakShot: false, }; } function getLowestRemainingSnookerColor(balls) { const activeColors = balls.filter((b) => !b.potted && b.snookerType === 'color'); if (activeColors.length === 0) return null; return Math.min(...activeColors.map((b) => b.points)); } function formatSnookerTarget(target) { if (target === 'red') return 'Red (1 pt)'; if (target === 'color') return 'Any Color (2–7 pts)'; const map = { 2: 'Yellow (2 pts)', 3: 'Green (3 pts)', 4: 'Brown (4 pts)', 5: 'Blue (5 pts)', 6: 'Pink (6 pts)', 7: 'Black (7 pts)', }; return map[target] || 'Clearance'; } function calculateSnookerPointsRemaining(balls, snookerTarget) { const reds = balls.filter((b) => !b.potted && b.snookerType === 'red').length; const colorsSum = balls .filter((b) => !b.potted && b.snookerType === 'color') .reduce((acc, b) => acc + b.points, 0); if (reds > 0) { return reds * 8 + 27 + (snookerTarget === 'color' ? 7 : 0); } return colorsSum + (snookerTarget === 'color' ? 7 : 0); } // ============================================================================ // 7. AI BOT SHOT CALCULATOR (FOR LABELED AI MATCHES) // ============================================================================ function calculateAIShot(gameState, difficulty = 'pro') { const { balls } = gameState; const cueBall = balls.find((b) => b.id === 0 && !b.potted); if (!cueBall) return { angle: 0, power: 10, spinX: 0, spinY: 0 }; // Find all legal target balls const legalBalls = balls.filter((b) => isBallLegalTarget(b, gameState)); const candidates = legalBalls.length > 0 ? legalBalls : balls.filter((b) => !b.potted && b.id !== 0); let bestShot = null; let bestScore = -Infinity; // Evaluate each candidate target ball + pocket pair candidates.forEach((target) => { POCKETS.forEach((pocket) => { const toPocketX = pocket.x - target.x; const toPocketY = pocket.y - target.y; const distToPocket = Math.hypot(toPocketX, toPocketY); const normPKX = toPocketX / distToPocket; const normPKY = toPocketY / distToPocket; // Required ghost ball position behind target ball const sumR = cueBall.radius + target.radius; const ghostX = target.x - normPKX * sumR; const ghostY = target.y - normPKY * sumR; const toGhostX = ghostX - cueBall.x; const toGhostY = ghostY - cueBall.y; const distToGhost = Math.hypot(toGhostX, toGhostY); const normGX = toGhostX / distToGhost; const normGY = toGhostY / distToGhost; // Cut angle cosine (1.0 = straight-in shot, <= 0 = impossible back-cut) const cutCos = normGX * normPKX + normGY * normPKY; if (cutCos <= 0.18) return; const angle = Math.atan2(toGhostY, toGhostX); // Verify raycast hits this target ball first! const ray = computeAimRaycast(balls, angle, 0, 0); if (!ray || ray.type !== 'ball' || ray.hitBall?.id !== target.id) return; const score = cutCos * 120 - distToPocket * 0.08 - distToGhost * 0.05; if (score > bestScore) { bestScore = score; const idealPower = Math.min(22, Math.max(7.5, (distToGhost + distToPocket) * 0.025 + 6)); bestShot = { angle, power: idealPower, spinX: 0, spinY: cutCos > 0.8 ? -0.35 : 0.15, }; } }); }); // Fallback direct hit on a legal ball if no clean pocket line exists if (!bestShot && candidates.length > 0) { const t = candidates[0]; bestShot = { angle: Math.atan2(t.y - cueBall.y, t.x - cueBall.x), power: 11.5, spinX: 0, spinY: 0, }; } if (!bestShot) { bestShot = { angle: 0, power: 12, spinX: 0, spinY: 0 }; } // Apply difficulty error margin const maxErrorRad = difficulty === 'grandmaster' ? 0.004 : difficulty === 'pro' ? 0.018 : 0.048; const jitter = (Math.random() - 0.5) * 2 * maxErrorRad; return { angle: bestShot.angle + jitter, power: bestShot.power, spinX: bestShot.spinX, spinY: bestShot.spinY, }; } // ============================================================================ // 8. MAIN APPLICATION COMPONENT // ============================================================================ export default function App() { // --- Auth & User Profile State --- const [user, setUser] = useState(null); const [authReady, setAuthReady] = useState(false); const [profile, setProfile] = useState({ uid: '', username: 'CueMaster', avatar: '🎱', isGuest: true, elo: 1200, xp: 0, level: 1, coins: 300, wins: 0, losses: 0, streak: 0, bestSnookerBreak: 0, modeStats: { '8ball': { wins: 0, losses: 0 }, '9ball': { wins: 0, losses: 0 }, snooker: { wins: 0, losses: 0 }, }, matchHistory: [], ownedCues: ['classic_maple'], ownedThemes: ['championship_green'], equippedCue: 'classic_maple', equippedTheme: 'championship_green', }); // --- Navigation & Modals --- const [activeTab, setActiveTab] = useState('lobby'); // 'lobby' | 'game' | 'leaderboards' | 'shop' | 'profile' const [showSystemModal, setShowSystemModal] = useState(false); const [showRulesModal, setShowRulesModal] = useState(false); const [showSpinModal, setShowSpinModal] = useState(false); const [editingProfile, setEditingProfile] = useState(false); const [tempUsername, setTempUsername] = useState(''); const [tempAvatar, setTempAvatar] = useState('🎱'); // --- Audio & Visual Preferences --- const [soundMuted, setSoundMuted] = useState(false); const [soundVolume, setSoundVolume] = useState(0.75); const [showTrajectoryGuide, setShowTrajectoryGuide] = useState(true); // --- Online Multiplayer & Matchmaking State --- const [allProfiles, setAllProfiles] = useState([]); const [publicRooms, setPublicRooms] = useState([]); const [activeRoomId, setActiveRoomId] = useState(null); const [roomData, setRoomData] = useState(null); const [joinCodeInput, setJoinCodeInput] = useState(''); const [copiedCode, setCopiedCode] = useState(false); const [matchmakingState, setMatchmakingState] = useState({ searching: false, mode: '8ball', ranked: true, elapsed: 0, }); // --- Local Match Configuration (Lobby Selection) --- const [selectedMode, setSelectedMode] = useState('8ball'); // '8ball' | '9ball' | 'snooker' const [selectedRanked, setSelectedRanked] = useState(true); const [selectedTurnTime, setSelectedTurnTime] = useState(45); const [selectedAiSkill, setSelectedAiSkill] = useState('pro'); const [practiceDrill, setPracticeDrill] = useState('standard'); // --- Active Gameplay State --- const [gameState, setGameState] = useState(() => ({ matchType: 'practice', // 'practice' | 'ai' | 'online' | 'pass_play' mode: '8ball', ranked: false, turnLimit: 45, turnTimeLeft: 45, balls: createInitialBalls('8ball'), currentTurn: 1, // 1 or 2 playerGroups: { 1: null, 2: null }, // 'solids' | 'stripes' scores: { 1: 0, 2: 0 }, currentBreak: 0, snookerTarget: 'red', // 'red' | 'color' | 2..7 ballInHand: true, isBreakShot: true, foulMessage: null, bannerMessage: 'Break Shot! Place the cue ball behind the head string or aim and shoot.', winner: null, winReason: '', shotSequenceId: 0, player1: { uid: 'local', name: 'Player 1', avatar: '🎱', elo: 1200 }, player2: { uid: 'practice', name: 'Solo Practice', avatar: '🎯', elo: 1200, isAI: false }, })); // --- Aiming, Power, Spin & Undo History Refs/State --- const [aimAngle, setAimAngle] = useState(0); const [shotPower, setShotPower] = useState(0); // 0 to 100 const [isPullingCue, setIsPullingCue] = useState(false); const [spinOffset, setSpinOffset] = useState({ x: 0, y: 0 }); // -1 to 1 const [isMoving, setIsMoving] = useState(false); const [isDraggingCueBall, setIsDraggingCueBall] = useState(false); const [practiceHistory, setPracticeHistory] = useState([]); const [toastMessage, setToastMessage] = useState(null); const canvasRef = useRef(null); const ballsRef = useRef(gameState.balls); const gameStateRef = useRef(gameState); const isMovingRef = useRef(false); const shotTrackerRef = useRef({ firstContactBall: null, pottedBalls: [], cushionHitsAfterContact: 0, totalCushionHits: 0, }); const pocketAnimationsRef = useRef([]); const lastProcessedSeqRef = useRef(0); useEffect(() => { gameStateRef.current = gameState; }, [gameState]); const showToast = useCallback((msg) => { setToastMessage(msg); setTimeout(() => { setToastMessage((prev) => (prev === msg ? null : prev)); }, 3600); }, []); // ========================================================================== // 9. FIREBASE AUTHENTICATION & REAL-TIME LISTENERS (RULES 1, 2, 3) // ========================================================================== useEffect(() => { const initAuth = async () => { try { if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) { await signInWithCustomToken(auth, __initial_auth_token); } else { await signInAnonymously(auth); } } catch (err) { console.error('Firebase auth init error:', err); } }; initAuth(); const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => { setUser(firebaseUser); setAuthReady(true); }); return () => unsubscribe(); }, []); // Load & sync Player Profile + Public Leaderboards + Public Rooms useEffect(() => { if (!user) return; const profileDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid); const unsubProfile = onSnapshot( profileDocRef, (snap) => { if (snap.exists()) { const data = snap.data(); setProfile((prev) => ({ ...prev, ...data, uid: user.uid })); } else { const randomSuffix = user.uid.slice(0, 4).toUpperCase(); const initialProfile = { uid: user.uid, username: `CuePlayer_${randomSuffix}`, avatar: AVATAR_OPTIONS[Math.floor(Math.random() * AVATAR_OPTIONS.length)], isGuest: true, elo: 1200, xp: 0, level: 1, coins: 350, wins: 0, losses: 0, streak: 0, bestSnookerBreak: 0, modeStats: { '8ball': { wins: 0, losses: 0 }, '9ball': { wins: 0, losses: 0 }, snooker: { wins: 0, losses: 0 }, }, matchHistory: [], ownedCues: ['classic_maple'], ownedThemes: ['championship_green'], equippedCue: 'classic_maple', equippedTheme: 'championship_green', lastSeen: Date.now(), }; setDoc(profileDocRef, initialProfile).catch(() => {}); setProfile(initialProfile); } }, (err) => console.error('Profile listener error:', err) ); // Fetch all profiles for Live Leaderboard (Sorted in JS memory per Rule 2) const profilesColRef = collection(db, 'artifacts', appId, 'public', 'data', 'profiles'); const unsubAllProfiles = onSnapshot( profilesColRef, (snap) => { const list = []; snap.forEach((d) => list.push(d.data())); setAllProfiles(list); }, (err) => console.error('All profiles listener error:', err) ); // Fetch all public rooms & matchmaking rooms const matchesColRef = collection(db, 'artifacts', appId, 'public', 'data', 'matches'); const unsubMatches = onSnapshot( matchesColRef, (snap) => { const rooms = []; snap.forEach((d) => { const data = d.data(); if (data && Date.now() - (data.updatedAt || 0) < 1000 * 60 * 45) { rooms.push(data); } }); setPublicRooms(rooms); }, (err) => console.error('Matches listener error:', err) ); return () => { unsubProfile(); unsubAllProfiles(); unsubMatches(); }; }, [user]); // Listen to Active Online Room Synchronization useEffect(() => { if (!user || !activeRoomId) return; const roomDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId); const unsubRoom = onSnapshot( roomDocRef, (snap) => { if (!snap.exists()) return; const data = snap.data(); setRoomData(data); // If match just transitioned to active and we are in lobby, open game view if (data.status === 'playing' && activeTab !== 'game') { setActiveTab('game'); } // Synchronize opponent shot execution or authoritative post-shot state if (data.gameStateJson) { try { const parsed = JSON.parse(data.gameStateJson); // If opponent just fired a live shot that we haven't simulated yet if ( data.lastShot && data.lastShot.seq > lastProcessedSeqRef.current && data.lastShot.shooterUid !== user.uid ) { lastProcessedSeqRef.current = data.lastShot.seq; // Set pre-shot positions and trigger deterministic animation on our screen if (data.lastShot.preBalls) { ballsRef.current = data.lastShot.preBalls; } const cue = ballsRef.current.find((b) => b.id === 0); if (cue) { const speed = (data.lastShot.power / 100) * 24.5; cue.vx = Math.cos(data.lastShot.angle) * speed; cue.vy = Math.sin(data.lastShot.angle) * speed; cue.spinX = data.lastShot.spinX || 0; cue.spinY = data.lastShot.spinY || 0; soundFX.playCueStrike(data.lastShot.power / 100); isMovingRef.current = true; setIsMoving(true); } } // Apply authoritative synced state when balls aren't actively moving if (!isMovingRef.current && parsed.shotSequenceId >= lastProcessedSeqRef.current) { lastProcessedSeqRef.current = parsed.shotSequenceId; ballsRef.current = parsed.balls; setGameState((prev) => ({ ...prev, ...parsed, matchType: 'online', player1: data.player1, player2: data.player2, })); } } catch (e) { console.error('Failed to parse synced gameStateJson:', e); } } }, (err) => console.error('Active room listener error:', err) ); return () => unsubRoom(); }, [user, activeRoomId, activeTab]); // ========================================================================== // 10. MATCHMAKING QUEUE & PRIVATE ROOM HANDLERS // ========================================================================== const startMatchmaking = async (mode, ranked) => { if (!user) return; setMatchmakingState({ searching: true, mode, ranked, elapsed: 0 }); // Look for an existing waiting public room matching mode & ranked const openMatch = publicRooms.find( (r) => r.status === 'waiting' && r.isPublicQueue && r.mode === mode && Boolean(r.ranked) === Boolean(ranked) && r.player1?.uid !== user.uid ); if (openMatch) { await joinOnlineRoom(openMatch.roomId); setMatchmakingState((prev) => ({ ...prev, searching: false })); } else { // Create a public waiting room in the matchmaking pool await createOnlineRoom(mode, ranked, 45, true); } }; const cancelMatchmaking = async () => { setMatchmakingState((prev) => ({ ...prev, searching: false, elapsed: 0 })); if (activeRoomId && user && roomData?.status === 'waiting') { try { await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId)); } catch (e) {} setActiveRoomId(null); setRoomData(null); } }; useEffect(() => { if (!matchmakingState.searching) return; const timer = setInterval(() => { setMatchmakingState((prev) => ({ ...prev, elapsed: prev.elapsed + 1 })); }, 1000); return () => clearInterval(timer); }, [matchmakingState.searching]); const createOnlineRoom = async ( mode = selectedMode, ranked = selectedRanked, turnLimit = selectedTurnTime, isPublicQueue = false ) => { if (!user) return; const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let roomId = ''; for (let i = 0; i < 6; i++) { roomId += chars[Math.floor(Math.random() * chars.length)]; } const initialBalls = createInitialBalls(mode); const p1 = { uid: user.uid, name: profile.username, avatar: profile.avatar, elo: profile.elo, cue: profile.equippedCue, }; const initialGameState = { matchType: 'online', mode, ranked, turnLimit, turnTimeLeft: turnLimit, balls: initialBalls, currentTurn: 1, playerGroups: { 1: null, 2: null }, scores: { 1: 0, 2: 0 }, currentBreak: 0, snookerTarget: 'red', ballInHand: true, isBreakShot: true, foulMessage: null, bannerMessage: `Match started! ${p1.name} to break.`, winner: null, winReason: '', shotSequenceId: 1, }; const roomPayload = { roomId, status: 'waiting', isPublicQueue, mode, ranked, turnLimit, player1: p1, player2: null, createdAt: Date.now(), updatedAt: Date.now(), gameStateJson: JSON.stringify(initialGameState), }; await setDoc( doc(db, 'artifacts', appId, 'public', 'data', 'matches', roomId), roomPayload ); lastProcessedSeqRef.current = 1; setActiveRoomId(roomId); setRoomData(roomPayload); if (!isPublicQueue) { showToast(`Room ${roomId} created! Share the code with your friend.`); } }; const joinOnlineRoom = async (codeToJoin) => { if (!user) return; const cleanCode = (codeToJoin || '').trim().toUpperCase(); if (!cleanCode) return; const roomRef = doc(db, 'artifacts', appId, 'public', 'data', 'matches', cleanCode); const snap = await getDoc(roomRef); if (!snap.exists()) { showToast(`Room ${cleanCode} not found.`); return; } const data = snap.data(); const p2 = { uid: user.uid, name: profile.username, avatar: profile.avatar, elo: profile.elo, cue: profile.equippedCue, }; // Allow reconnection if already Player 1 or Player 2 if (data.player1?.uid === user.uid || data.player2?.uid === user.uid) { setActiveRoomId(cleanCode); setRoomData(data); setActiveTab('game'); showToast(`Reconnected to Room ${cleanCode}!`); return; } if (data.status !== 'waiting' && data.player2) { showToast(`Room ${cleanCode} is already full.`); return; } const parsedState = JSON.parse(data.gameStateJson); parsedState.player1 = data.player1; parsedState.player2 = p2; await updateDoc(roomRef, { player2: p2, status: 'playing', updatedAt: Date.now(), gameStateJson: JSON.stringify(parsedState), }); lastProcessedSeqRef.current = parsedState.shotSequenceId || 1; ballsRef.current = parsedState.balls; setGameState({ ...parsedState, matchType: 'online', player1: data.player1, player2: p2, }); setActiveRoomId(cleanCode); setMatchmakingState((prev) => ({ ...prev, searching: false })); setActiveTab('game'); showToast(`Joined Room ${cleanCode} vs ${data.player1.name}!`); }; // ========================================================================== // 11. START PRACTICE, AI BOT MATCH, OR LOCAL PASS & PLAY // ========================================================================== const startLocalMatch = (matchType = 'practice', mode = selectedMode, drill = 'standard') => { if (matchmakingState.searching) cancelMatchmaking(); setActiveRoomId(null); setRoomData(null); const initialBalls = createInitialBalls(mode, drill); ballsRef.current = initialBalls.map((b) => ({ ...b })); isMovingRef.current = false; setIsMoving(false); setPracticeHistory([]); setSpinOffset({ x: 0, y: 0 }); setShotPower(0); const aiNames = { rookie: { name: 'Rookie Bot [AI]', elo: 1050, avatar: '🤖' }, pro: { name: 'Club Pro Bot [AI]', elo: 1420, avatar: '🦾' }, grandmaster: { name: 'Grandmaster AI [AI]', elo: 1850, avatar: '⚡' }, }; const chosenAI = aiNames[selectedAiSkill] || aiNames.pro; const p1 = { uid: user?.uid || 'local_p1', name: profile.username, avatar: profile.avatar, elo: profile.elo, }; const p2 = matchType === 'ai' ? { uid: 'ai_bot', name: chosenAI.name, avatar: chosenAI.avatar, elo: chosenAI.elo, isAI: true, aiSkill: selectedAiSkill, } : matchType === 'pass_play' ? { uid: 'local_p2', name: 'Guest Player 2', avatar: '🎯', elo: 1200, isAI: false, } : { uid: 'practice', name: 'Solo Practice Table', avatar: '🎯', elo: profile.elo, isAI: false, }; setGameState({ matchType, mode, ranked: matchType === 'ai' ? selectedRanked : false, turnLimit: matchType === 'practice' ? 0 : selectedTurnTime, turnTimeLeft: matchType === 'practice' ? 0 : selectedTurnTime, balls: initialBalls, currentTurn: 1, playerGroups: { 1: null, 2: null }, scores: { 1: 0, 2: 0 }, currentBreak: 0, snookerTarget: 'red', ballInHand: true, isBreakShot: drill === 'standard', foulMessage: null, bannerMessage: matchType === 'practice' ? 'Practice Mode Active: Drag cue ball anywhere, test spin & undo shots freely!' : `Break Shot! ${p1.name} to break.`, winner: null, winReason: '', shotSequenceId: 1, player1: p1, player2: p2, }); setActiveTab('game'); }; // ========================================================================== // 12. EXECUTE SHOT & AUTHORITATIVE POST-SHOT RESOLUTION // ========================================================================== const canLocalPlayerShoot = useMemo(() => { if (isMoving || gameState.winner) return false; if (gameState.matchType === 'practice' || gameState.matchType === 'pass_play') { return true; } if (gameState.matchType === 'ai') { return gameState.currentTurn === 1; } if (gameState.matchType === 'online') { const myTurnNum = gameState.player1?.uid === user?.uid ? 1 : gameState.player2?.uid === user?.uid ? 2 : 0; return gameState.currentTurn === myTurnNum; } return false; }, [isMoving, gameState, user]); const executeShot = useCallback( async (angleToUse, powerPercent, spinXVal, spinYVal) => { if (isMovingRef.current || gameStateRef.current.winner) return; const currentGS = gameStateRef.current; const currentBalls = ballsRef.current.map((b) => ({ ...b })); const cueBall = currentBalls.find((b) => b.id === 0 && !b.potted); if (!cueBall) return; // Save snapshot for Practice Mode Undo if (currentGS.matchType === 'practice') { setPracticeHistory((prev) => [ ...prev.slice(-9), { balls: currentBalls.map((b) => ({ ...b })), scores: { ...currentGS.scores }, currentBreak: currentGS.currentBreak, snookerTarget: currentGS.snookerTarget, playerGroups: { ...currentGS.playerGroups }, }, ]); } // Reset shot tracker shotTrackerRef.current = { firstContactBall: null, pottedBalls: [], cushionHitsAfterContact: 0, totalCushionHits: 0, }; const maxSpeed = 24.5; const speed = Math.max(1.2, (powerPercent / 100) * maxSpeed); const preBallsSnapshot = currentBalls.map((b) => ({ ...b })); cueBall.vx = Math.cos(angleToUse) * speed; cueBall.vy = Math.sin(angleToUse) * speed; cueBall.spinX = spinXVal; cueBall.spinY = spinYVal; ballsRef.current = currentBalls; isMovingRef.current = true; setIsMoving(true); setShotPower(0); setIsPullingCue(false); soundFX.playCueStrike(powerPercent / 100); // If Online Match, broadcast the live shot vector immediately so opponent sees real-time animation if (currentGS.matchType === 'online' && activeRoomId && user) { const nextSeq = (currentGS.shotSequenceId || 1) + 1; lastProcessedSeqRef.current = nextSeq; try { await updateDoc( doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId), { lastShot: { seq: nextSeq, shooterUid: user.uid, angle: angleToUse, power: powerPercent, spinX: spinXVal, spinY: spinYVal, preBalls: preBallsSnapshot, }, updatedAt: Date.now(), } ); } catch (err) { console.error('Failed to broadcast live shot:', err); } } }, [activeRoomId, user] ); // Trigger AI Bot Shot automatically when it is Player 2 (AI)'s turn useEffect(() => { if ( activeTab === 'game' && gameState.matchType === 'ai' && gameState.currentTurn === 2 && !isMoving && !gameState.winner ) { const timer = setTimeout(() => { if (isMovingRef.current || gameStateRef.current.winner) return; // If AI has ball-in-hand, place cue ball in an advantageous spot first if (gameStateRef.current.ballInHand) { const cue = ballsRef.current.find((b) => b.id === 0); if (cue) { cue.potted = false; cue.x = HEAD_STRING_X - 25; cue.y = TABLE_HEIGHT / 2 + (Math.random() - 0.5) * 70; } } const aiShot = calculateAIShot( { ...gameStateRef.current, balls: ballsRef.current }, gameStateRef.current.player2?.aiSkill || 'pro' ); setAimAngle(aiShot.angle); setSpinOffset({ x: aiShot.spinX, y: aiShot.spinY }); setTimeout(() => { executeShot( aiShot.angle, (aiShot.power / 24.5) * 100, aiShot.spinX, aiShot.spinY ); }, 650); }, 1100); return () => clearTimeout(timer); } }, [activeTab, gameState.matchType, gameState.currentTurn, isMoving, gameState.winner, executeShot]); // Turn Countdown Timer useEffect(() => { if ( activeTab !== 'game' || isMoving || gameState.winner || !gameState.turnLimit || gameState.matchType === 'practice' ) { return; } const interval = setInterval(() => { setGameState((prev) => { if (prev.turnTimeLeft <= 1) { // Turn Timeout Foul! soundFX.playFoul(); const nextTurn = prev.currentTurn === 1 ? 2 : 1; return { ...prev, currentTurn: nextTurn, turnTimeLeft: prev.turnLimit, ballInHand: true, foulMessage: `Shot Clock Expired! Ball-in-Hand awarded to Player ${nextTurn}.`, bannerMessage: `Time Foul! Player ${nextTurn} has Ball-in-Hand.`, }; } return { ...prev, turnTimeLeft: prev.turnTimeLeft - 1 }; }); }, 1000); return () => clearInterval(interval); }, [activeTab, isMoving, gameState.winner, gameState.turnLimit, gameState.matchType, gameState.currentTurn]); // ========================================================================== // 13. RECORD VERIFIED MATCH OUTCOME, ELO RATING & PROGRESSION // ========================================================================== const recordMatchOutcome = useCallback( async (finalState) => { if (!user || finalState.matchType === 'practice') return; const myPlayerNum = finalState.matchType === 'online' ? finalState.player1?.uid === user.uid ? 1 : 2 : 1; const didWin = finalState.winner === myPlayerNum; const opponent = myPlayerNum === 1 ? finalState.player2 : finalState.player1; const opponentElo = opponent?.elo || 1200; // Standard Elo Calculation (K = 32) const expectedScore = 1 / (1 + Math.pow(10, (opponentElo - profile.elo) / 400)); const eloDelta = finalState.ranked ? Math.round(32 * ((didWin ? 1 : 0) - expectedScore)) : 0; const newElo = Math.max(800, profile.elo + eloDelta); const xpGain = didWin ? 120 : 45; const coinGain = didWin ? 85 : 20; const newXp = (profile.xp || 0) + xpGain; const newLevel = Math.floor(newXp / 300) + 1; const modeKey = finalState.mode || '8ball'; const prevModeStat = profile.modeStats?.[modeKey] || { wins: 0, losses: 0 }; const historyEntry = { id: `${Date.now()}`, mode: finalState.mode, opponentName: opponent?.name || 'Opponent', result: didWin ? 'WIN' : 'LOSS', score: `${finalState.scores[myPlayerNum]} - ${finalState.scores[myPlayerNum === 1 ? 2 : 1]}`, eloDelta, timestamp: Date.now(), }; const updatedProfile = { ...profile, elo: newElo, xp: newXp, level: newLevel, coins: (profile.coins || 0) + coinGain, wins: (profile.wins || 0) + (didWin ? 1 : 0), losses: (profile.losses || 0) + (didWin ? 0 : 1), streak: didWin ? (profile.streak || 0) + 1 : 0, bestSnookerBreak: Math.max( profile.bestSnookerBreak || 0, finalState.mode === 'snooker' ? finalState.currentBreak || 0 : 0 ), modeStats: { ...(profile.modeStats || {}), [modeKey]: { wins: prevModeStat.wins + (didWin ? 1 : 0), losses: prevModeStat.losses + (didWin ? 0 : 1), }, }, matchHistory: [historyEntry, ...(profile.matchHistory || []).slice(0, 14)], lastSeen: Date.now(), }; setProfile(updatedProfile); try { await setDoc( doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updatedProfile ); } catch (err) { console.error('Error saving match stats:', err); } }, [user, profile] ); // ========================================================================== // 14. MAIN 60FPS CANVAS RENDERING & PHYSICS ANIMATION LOOP // ========================================================================== useEffect(() => { if (activeTab !== 'game') return; let animationFrameId; const renderLoop = () => { const canvas = canvasRef.current; if (canvas) { const ctx = canvas.getContext('2d'); // 1. Step Physics if balls are moving if (isMovingRef.current) { const stillMoving = stepPhysicsSimulation( ballsRef.current, shotTrackerRef.current, true ); if (!stillMoving) { isMovingRef.current = false; setIsMoving(false); // Evaluate rules after all balls come to rest const prevGS = gameStateRef.current; const postEval = evaluateShotRules( prevGS, ballsRef.current, shotTrackerRef.current ); // In Practice mode, never lock out turns or end the session unless all balls cleared if (prevGS.matchType === 'practice') { const cue = postEval.balls.find((b) => b.id === 0); if (cue && cue.potted) { cue.potted = false; cue.x = HEAD_STRING_X - 35; cue.y = TABLE_HEIGHT / 2; } ballsRef.current = postEval.balls; setGameState((prev) => ({ ...prev, balls: postEval.balls, scores: postEval.scores, currentBreak: postEval.currentBreak, snookerTarget: postEval.snookerTarget, foulMessage: postEval.foulMessage, bannerMessage: postEval.foulMessage || postEval.bannerMessage || 'Practice Mode — Take your next shot or drag the cue ball.', isBreakShot: false, })); } else { ballsRef.current = postEval.balls; const nextSeq = (prevGS.shotSequenceId || 1) + 1; const updatedGS = { ...prevGS, ...postEval, turnTimeLeft: prevGS.turnLimit, shotSequenceId: nextSeq, }; if (postEval.foulMessage) { soundFX.playFoul(); } if (postEval.winner) { soundFX.playWinFanfare(); recordMatchOutcome(updatedGS); } setGameState(updatedGS); // Sync authoritative post-shot state to Firestore if Online Match if (prevGS.matchType === 'online' && activeRoomId && user) { lastProcessedSeqRef.current = nextSeq; updateDoc( doc(db, 'artifacts', appId, 'public', 'data', 'matches', activeRoomId), { gameStateJson: JSON.stringify(updatedGS), status: postEval.winner ? 'completed' : 'playing', updatedAt: Date.now(), } ).catch((e) => console.error('Failed to sync post-shot state:', e)); } } } } // 2. Render Table, Cushions, Pockets, Markings, Balls, Aiming Guide & Cue Stick drawBilliardsTable(ctx); } animationFrameId = requestAnimationFrame(renderLoop); }; const drawBilliardsTable = (ctx) => { const theme = TABLE_THEMES.find((t) => t.id === profile.equippedTheme) || TABLE_THEMES[0]; const equippedCueObj = CUE_CATALOG.find((c) => c.id === profile.equippedCue) || CUE_CATALOG[0]; ctx.clearRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT); // A. Outer Hardwood Rails const railGrad = ctx.createLinearGradient(0, 0, TABLE_WIDTH, TABLE_HEIGHT); railGrad.addColorStop(0, theme.railColor); railGrad.addColorStop(0.5, '#271206'); railGrad.addColorStop(1, theme.railColor); ctx.fillStyle = railGrad; ctx.beginPath(); ctx.roundRect(0, 0, TABLE_WIDTH, TABLE_HEIGHT, 24); ctx.fill(); // Gold / Chrome Outer Trim ctx.strokeStyle = theme.trimColor; ctx.lineWidth = 3; ctx.strokeRect(6, 6, TABLE_WIDTH - 12, TABLE_HEIGHT - 12); // B. Felt Playing Surface with Radial Lighting const feltGrad = ctx.radialGradient ? ctx.createRadialGradient( TABLE_WIDTH / 2, TABLE_HEIGHT / 2, 40, TABLE_WIDTH / 2, TABLE_HEIGHT / 2, TABLE_WIDTH * 0.65 ) : null; if (feltGrad) { feltGrad.addColorStop(0, theme.feltCenter); feltGrad.addColorStop(1, theme.feltEdge); ctx.fillStyle = feltGrad; } else { ctx.fillStyle = theme.feltCenter; } ctx.fillRect(PLAY_LEFT - 8, PLAY_TOP - 8, PLAY_W + 16, PLAY_H + 16); // Cushion Rubber Bevels & Shadow ctx.strokeStyle = 'rgba(0,0,0,0.42)'; ctx.lineWidth = 8; ctx.strokeRect(PLAY_LEFT, PLAY_TOP, PLAY_W, PLAY_H); // Diamond Sights on Rails ctx.fillStyle = '#E2E8F0'; for (let i = 1; i <= 7; i++) { if (i === 4) continue; // Center pocket const dx = PLAY_LEFT + (PLAY_W / 8) * i; ctx.beginPath(); ctx.arc(dx, RAIL_SIZE / 2, 3, 0, Math.PI * 2); ctx.arc(dx, TABLE_HEIGHT - RAIL_SIZE / 2, 3, 0, Math.PI * 2); ctx.fill(); } for (let j = 1; j <= 3; j++) { const dy = PLAY_TOP + (PLAY_H / 4) * j; ctx.beginPath(); ctx.arc(RAIL_SIZE / 2, dy, 3, 0, Math.PI * 2); ctx.arc(TABLE_WIDTH - RAIL_SIZE / 2, dy, 3, 0, Math.PI * 2); ctx.fill(); } // C. Table Markings (Baulk Line, Snooker D-Zone, Foot Spot) ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(HEAD_STRING_X, PLAY_TOP); ctx.lineTo(HEAD_STRING_X, PLAY_BOTTOM); ctx.stroke(); if (gameStateRef.current.mode === 'snooker') { // Draw the Snooker "D" Semicircle ctx.beginPath(); ctx.arc( HEAD_STRING_X, TABLE_HEIGHT / 2, SNOOKER_D_RADIUS, Math.PI * 0.5, Math.PI * 1.5 ); ctx.stroke(); // Color Spot Crosses SNOOKER_COLORS_CONFIG.forEach((sc) => { ctx.fillStyle = 'rgba(255,255,255,0.28)'; ctx.beginPath(); ctx.arc(sc.spotX, sc.spotY, 2.5, 0, Math.PI * 2); ctx.fill(); }); } else { // Foot spot dot ctx.fillStyle = 'rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.arc(FOOT_SPOT_X, FOOT_SPOT_Y, 3, 0, Math.PI * 2); ctx.fill(); } // D. Render Pockets POCKETS.forEach((pk) => { // Brass/Chrome casting rim ctx.beginPath(); ctx.arc(pk.x, pk.y, pk.radius + 5, 0, Math.PI * 2); ctx.fillStyle = '#1E293B'; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = theme.trimColor; ctx.stroke(); // Deep pocket well const pkGrad = ctx.createRadialGradient( pk.x, pk.y, 2, pk.x, pk.y, pk.radius ); pkGrad.addColorStop(0, '#020617'); pkGrad.addColorStop(1, '#0F172A'); ctx.beginPath(); ctx.arc(pk.x, pk.y, pk.radius, 0, Math.PI * 2); ctx.fillStyle = pkGrad; ctx.fill(); }); // E. Render Balls const currentBalls = ballsRef.current; const gs = gameStateRef.current; // Highlight lowest ball in 9-Ball or legal group balls let lowest9BallId = null; if (gs.mode === '9ball') { const rem = currentBalls.filter((b) => !b.potted && b.id > 0); if (rem.length > 0) lowest9BallId = Math.min(...rem.map((b) => b.id)); } currentBalls.forEach((b) => { if (b.potted) return; // Subtle pulse ring around the required 9-ball lowest target if (gs.mode === '9ball' && b.id === lowest9BallId && !isMovingRef.current) { ctx.save(); ctx.strokeStyle = 'rgba(250, 204, 21, 0.75)'; ctx.lineWidth = 2; ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.arc(b.x, b.y, b.radius + 5, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } // Drop shadow under ball ctx.beginPath(); ctx.arc(b.x + 2.5, b.y + 3, b.radius, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0, 0, 0, 0.38)'; ctx.fill(); // Base Ball Sphere ctx.save(); ctx.beginPath(); ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2); ctx.clip(); if (b.isStripe) { // Ivory base with vibrant colored stripe band ctx.fillStyle = '#F8FAFC'; ctx.fillRect(b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); ctx.fillStyle = b.color; const bandOffset = Math.sin(b.rollPhase || 0) * 2.5; ctx.fillRect( b.x - b.radius, b.y - b.radius * 0.55 + bandOffset, b.radius * 2, b.radius * 1.1 ); } else { ctx.fillStyle = b.color; ctx.fillRect(b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); } // Number circle for pool balls if (b.label && gs.mode !== 'snooker') { ctx.fillStyle = 'rgba(255, 255, 255, 0.92)'; ctx.beginPath(); ctx.arc(b.x, b.y, b.radius * 0.46, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#0F172A'; ctx.font = `bold ${Math.round(b.radius * 0.62)}px Inter, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, b.x, b.y + 0.5); } // Red dot on cue ball to visualize spin & roll if (b.id === 0) { const dotX = b.x + Math.cos(b.rollPhase || 0) * (b.radius * 0.35); const dotY = b.y + Math.sin(b.rollPhase || 0) * (b.radius * 0.35); ctx.fillStyle = '#EF4444'; ctx.beginPath(); ctx.arc(dotX, dotY, 2.2, 0, Math.PI * 2); ctx.fill(); // Ball-in-hand placement halo if ((gs.ballInHand || gs.matchType === 'practice') && !isMovingRef.current) { ctx.strokeStyle = 'rgba(56, 189, 248, 0.8)'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.arc(b.x, b.y, b.radius - 1, 0, Math.PI * 2); ctx.stroke(); } } // 3D Glossy Specular Highlight const shineGrad = ctx.createRadialGradient( b.x - b.radius * 0.35, b.y - b.radius * 0.35, 1, b.x - b.radius * 0.2, b.y - b.radius * 0.2, b.radius ); shineGrad.addColorStop(0, 'rgba(255, 255, 255, 0.65)'); shineGrad.addColorStop(0.35, 'rgba(255, 255, 255, 0.12)'); shineGrad.addColorStop(1, 'rgba(0, 0, 0, 0.28)'); ctx.fillStyle = shineGrad; ctx.fillRect(b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2); ctx.restore(); }); // F. Render Aiming Raycast Line, Ghost Ball & Cue Stick when stationary const cueBall = currentBalls.find((b) => b.id === 0 && !b.potted); if (cueBall && !isMovingRef.current && !gs.winner && canLocalPlayerShoot) { const ray = computeAimRaycast( currentBalls, aimAngle, spinOffset.x, spinOffset.y ); if (ray && showTrajectoryGuide) { const isLegalHit = ray.type === 'ball' ? isBallLegalTarget(ray.hitBall, gs) : true; // Primary Aiming Line from Cue Ball to Ghost Ball ctx.save(); ctx.beginPath(); ctx.moveTo(cueBall.x, cueBall.y); ctx.lineTo(ray.ghostX, ray.ghostY); ctx.strokeStyle = isLegalHit ? equippedCueObj.trailColor : 'rgba(239, 68, 68, 0.85)'; ctx.lineWidth = 2; ctx.setLineDash([6, 5]); ctx.stroke(); // Ghost Ball Circle ctx.beginPath(); ctx.arc(ray.ghostX, ray.ghostY, cueBall.radius, 0, Math.PI * 2); ctx.strokeStyle = isLegalHit ? '#38BDF8' : '#EF4444'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.stroke(); ctx.fillStyle = isLegalHit ? 'rgba(56, 189, 248, 0.18)' : 'rgba(239, 68, 68, 0.25)'; ctx.fill(); // Object Ball Target Line & Cue Ball Deflection Tangent if (ray.type === 'ball' && ray.hitBall) { const guideLen = gs.matchType === 'practice' ? 160 : 85; ctx.beginPath(); ctx.moveTo(ray.hitBall.x, ray.hitBall.y); ctx.lineTo( ray.hitBall.x + ray.targetDirX * guideLen, ray.hitBall.y + ray.targetDirY * guideLen ); ctx.strokeStyle = isLegalHit ? 'rgba(34, 197, 94, 0.9)' : 'rgba(239, 68, 68, 0.9)'; ctx.lineWidth = 2.5; ctx.stroke(); // Cue Ball Post-Impact Tangent (affected by Topspin/Backspin!) ctx.beginPath(); ctx.moveTo(ray.ghostX, ray.ghostY); ctx.lineTo( ray.ghostX + ray.cueDeflectX * (guideLen * 0.65), ray.ghostY + ray.cueDeflectY * (guideLen * 0.65) ); ctx.strokeStyle = 'rgba(250, 204, 21, 0.75)'; ctx.lineWidth = 1.8; ctx.setLineDash([4, 4]); ctx.stroke(); } ctx.restore(); } // Render Animated Cue Stick behind the Cue Ball ctx.save(); ctx.translate(cueBall.x, cueBall.y); ctx.rotate(aimAngle + Math.PI); const pullBackDist = 18 + (shotPower / 100) * 68; const cueLength = 250; // Cue Shadow ctx.fillStyle = 'rgba(0,0,0,0.35)'; ctx.fillRect(pullBackDist, 3, cueLength, 6); // Brass Ferrule & Blue Chalk Tip ctx.fillStyle = '#38BDF8'; ctx.fillRect(pullBackDist, -2.5, 4, 5); ctx.fillStyle = '#FACC15'; ctx.fillRect(pullBackDist + 4, -2.5, 5, 5); // Shaft (Maple / Carbon) ctx.fillStyle = equippedCueObj.shaftColor; ctx.beginPath(); ctx.moveTo(pullBackDist + 9, -2.5); ctx.lineTo(pullBackDist + cueLength * 0.55, -4); ctx.lineTo(pullBackDist + cueLength * 0.55, 4); ctx.lineTo(pullBackDist + 9, 2.5); ctx.closePath(); ctx.fill(); // Butt & Decorative Wrap ctx.fillStyle = equippedCueObj.buttColor; ctx.beginPath(); ctx.moveTo(pullBackDist + cueLength * 0.55, -4); ctx.lineTo(pullBackDist + cueLength, -6); ctx.lineTo(pullBackDist + cueLength, 6); ctx.lineTo(pullBackDist + cueLength * 0.55, 4); ctx.closePath(); ctx.fill(); // Accent Rings ctx.fillStyle = equippedCueObj.accentColor; ctx.fillRect(pullBackDist + cueLength * 0.55, -4, 6, 8); ctx.fillRect(pullBackDist + cueLength - 10, -6, 6, 12); ctx.restore(); } }; animationFrameId = requestAnimationFrame(renderLoop); return () => cancelAnimationFrame(animationFrameId); }, [ activeTab, aimAngle, shotPower, spinOffset, showTrajectoryGuide, canLocalPlayerShoot, profile.equippedTheme, profile.equippedCue, activeRoomId, user, recordMatchOutcome, ]); // ========================================================================== // 15. CANVAS MOUSE / TOUCH INPUT (AIMING & BALL-IN-HAND PLACEMENT) // ========================================================================== const getCanvasLogicalCoords = (e) => { const canvas = canvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; const scaleX = TABLE_WIDTH / rect.width; const scaleY = TABLE_HEIGHT / rect.height; return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY, }; }; const handleCanvasPointerDown = (e) => { if (!canLocalPlayerShoot || isMovingRef.current) return; const pt = getCanvasLogicalCoords(e); const cueBall = ballsRef.current.find((b) => b.id === 0 && !b.potted); if (!cueBall) return; const distToCue = Math.hypot(pt.x - cueBall.x, pt.y - cueBall.y); const canMoveCueBall = gameState.ballInHand || gameState.matchType === 'practice'; if (canMoveCueBall && distToCue < cueBall.radius * 2.4) { setIsDraggingCueBall(true); return; } // Aim towards clicked/touched point const newAngle = Math.atan2(pt.y - cueBall.y, pt.x - cueBall.x); setAimAngle(newAngle); }; const handleCanvasPointerMove = (e) => { if (!canLocalPlayerShoot || isMovingRef.current) return; const pt = getCanvasLogicalCoords(e); const cueBall = ballsRef.current.find((b) => b.id === 0 && !b.potted); if (!cueBall) return; if (isDraggingCueBall) { const r = cueBall.radius; let nx = Math.max(PLAY_LEFT + r, Math.min(PLAY_RIGHT - r, pt.x)); let ny = Math.max(PLAY_TOP + r, Math.min(PLAY_BOTTOM - r, pt.y)); // Enforce behind head-string on break shot, or inside D-zone in Snooker if (gameState.matchType !== 'practice') { if (gameState.mode === 'snooker') { nx = Math.min(nx, HEAD_STRING_X); const distFromDCenter = Math.hypot(nx - HEAD_STRING_X, ny - TABLE_HEIGHT / 2); if (distFromDCenter > SNOOKER_D_RADIUS) { const ang = Math.atan2(ny - TABLE_HEIGHT / 2, nx - HEAD_STRING_X); nx = HEAD_STRING_X + Math.cos(ang) * SNOOKER_D_RADIUS; ny = TABLE_HEIGHT / 2 + Math.sin(ang) * SNOOKER_D_RADIUS; } } else if (gameState.isBreakShot) { nx = Math.min(nx, HEAD_STRING_X); } } // Check collision with other balls const overlapsOther = ballsRef.current.some( (ob) => ob.id !== 0 && !ob.potted && Math.hypot(ob.x - nx, ob.y - ny) < ob.radius + r + 2 ); if (!overlapsOther) { cueBall.x = nx; cueBall.y = ny; } return; } // Only update aim on drag if mouse button is pressed or touch event if (e.buttons === 1 || e.touches) { const newAngle = Math.atan2(pt.y - cueBall.y, pt.x - cueBall.x); setAimAngle(newAngle); } }; const handleCanvasPointerUp = () => { if (isDraggingCueBall) { setIsDraggingCueBall(false); } }; // Undo last shot in Practice Mode const handlePracticeUndo = () => { if (gameState.matchType !== 'practice' || practiceHistory.length === 0 || isMoving) return; const lastSnap = practiceHistory[practiceHistory.length - 1]; ballsRef.current = lastSnap.balls.map((b) => ({ ...b })); setPracticeHistory((prev) => prev.slice(0, -1)); setGameState((prev) => ({ ...prev, balls: lastSnap.balls, scores: lastSnap.scores, currentBreak: lastSnap.currentBreak, snookerTarget: lastSnap.snookerTarget, playerGroups: lastSnap.playerGroups, bannerMessage: 'Shot Undone! Ready to retry.', })); }; // Buy or Equip Cosmetic Item const handleCueAction = async (cueItem) => { const isOwned = profile.ownedCues?.includes(cueItem.id); if (isOwned) { const updated = { ...profile, equippedCue: cueItem.id }; setProfile(updated); if (user) { await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated); } showToast(`Equipped ${cueItem.name}!`); return; } if ((profile.coins || 0) < cueItem.cost) { showToast(`Not enough coins! Need ${cueItem.cost} coins.`); return; } const updated = { ...profile, coins: profile.coins - cueItem.cost, ownedCues: [...(profile.ownedCues || []), cueItem.id], equippedCue: cueItem.id, }; setProfile(updated); if (user) { await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated); } showToast(`Unlocked & Equipped ${cueItem.name}!`); }; const handleThemeAction = async (themeItem) => { const isOwned = profile.ownedThemes?.includes(themeItem.id); if (isOwned) { const updated = { ...profile, equippedTheme: themeItem.id }; setProfile(updated); if (user) { await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated); } showToast(`Applied ${themeItem.name}!`); return; } if ((profile.coins || 0) < themeItem.cost) { showToast(`Not enough coins! Need ${themeItem.cost} coins.`); return; } const updated = { ...profile, coins: profile.coins - themeItem.cost, ownedThemes: [...(profile.ownedThemes || []), themeItem.id], equippedTheme: themeItem.id, }; setProfile(updated); if (user) { await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated); } showToast(`Unlocked & Applied ${themeItem.name}!`); }; const handleSaveProfileIdentity = async () => { if (!tempUsername.trim() || !user) return; const updated = { ...profile, username: tempUsername.trim().slice(0, 18), avatar: tempAvatar, isGuest: false, }; setProfile(updated); setEditingProfile(false); await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'profiles', user.uid), updated); showToast('Player profile saved to cloud!'); }; const rankBadge = getRankTier(profile.elo); // ========================================================================== // 16. RENDER UI: TOP NAVIGATION, LOBBY, GAME HUD, SHOP, LEADERBOARDS // ========================================================================== return (
{/* Top Club Header Bar */}
setActiveTab('lobby')} className="flex items-center gap-2.5 cursor-pointer group" >
🎱

CUE MASTERS

Online Pool & Snooker Club

{/* Navigation Pills */}
{/* Right Controls: Player Badge, Coins, Audio, System Status */}
🪙 {profile.coins || 0} | {profile.elo} ELO
{/* Mobile Bottom/Sub Navigation Bar */}
{[ { id: 'lobby', label: 'Lobby', icon: Globe }, { id: 'game', label: 'Table', icon: Play }, { id: 'leaderboards', label: 'Ranks', icon: Trophy }, { id: 'shop', label: 'Cues', icon: Sparkles }, { id: 'profile', label: 'Stats', icon: User }, ].map((item) => { const Icon = item.icon; return ( ); })}
{/* Floating Toast Notification */} {toastMessage && (
{toastMessage}
)} {/* ==================================================================== */} {/* TAB 1: MULTIPLAYER LOBBY & MODE SELECTOR */} {/* ==================================================================== */} {activeTab === 'lobby' && (
{/* Active Room Waiting Banner */} {activeRoomId && roomData && roomData.status === 'waiting' && (
Waiting for Opponent Mode: {roomData.mode.toUpperCase()} • {roomData.ranked ? 'Ranked' : 'Casual'}

Room Code:{' '} {activeRoomId}

Share this code with a friend or wait for an online player to join from the lobby.

)} {/* Game Mode Selection Cards (8-Ball, 9-Ball, Snooker) */}
{[ { id: '8ball', title: '8-Ball Pool', subtitle: 'Solids vs Stripes Championship', badge: 'Most Popular', desc: 'Standard 15-ball rack. Call your group after the break, clear your balls, and legally sink the 8-Ball to win.', gradient: 'from-emerald-600/25 via-slate-900 to-slate-950', border: 'border-emerald-500/40', ballIcon: '8', ballColor: 'bg-slate-950 text-white border-2 border-white/80', }, { id: '9ball', title: '9-Ball Pool', subtitle: 'Fast-Paced Diamond Rack', badge: 'Pro Circuit', desc: 'Balls 1–9. Always strike the lowest-numbered ball on the table first. Sink the 9-Ball on any legal shot to triumph!', gradient: 'from-amber-600/25 via-slate-900 to-slate-950', border: 'border-amber-500/40', ballIcon: '9', ballColor: 'bg-amber-400 text-slate-950 border-2 border-white', }, { id: 'snooker', title: 'Championship Snooker', subtitle: '15 Reds & 6 Colors Frame', badge: '147 Max Break', desc: 'Full 22-ball Crucible rules. Alternate Red (1 pt) and Colors (2–7 pts), build massive breaks, and clear the colors in order.', gradient: 'from-rose-600/25 via-slate-900 to-slate-950', border: 'border-rose-500/40', ballIcon: '147', ballColor: 'bg-rose-600 text-white border-2 border-rose-300', }, ].map((m) => { const isSelected = selectedMode === m.id; return (
setSelectedMode(m.id)} className={`relative rounded-2xl p-5 cursor-pointer transition-all bg-gradient-to-br ${ m.gradient } border-2 ${ isSelected ? `${m.border} shadow-xl scale-[1.01]` : 'border-slate-800/80 opacity-80 hover:opacity-100' }`} >
{m.badge}

{m.title}

{m.subtitle}

{m.ballIcon}

{m.desc}

Your Record:{' '} {profile.modeStats?.[m.id]?.wins || 0}W - {profile.modeStats?.[m.id]?.losses || 0}L {isSelected ? '✓ Selected Mode' : 'Click to Select'}
); })}
{/* Matchmaking & Mode Launch Panel */}
{/* Left 7 Cols: Quick Match, AI Match, Practice & Private Room Creator */}

Play {selectedMode === '8ball' ? '8-Ball Pool' : selectedMode === '9ball' ? '9-Ball Pool' : 'Snooker'}

Choose real-time online multiplayer, private friend room, AI bot challenge, or solo practice.

{/* Match Settings Toggles */}
{/* Action Grid */}
{/* 1. Online Quick Match */}
Online Multiplayer

Quick Match Queue

Automatically pairs you with a waiting real player online in synchronized rooms.

{/* 2. Create or Join Private Friend Room */}
Play With Friends

Private Room

setJoinCodeInput(e.target.value.toUpperCase())} className="w-full bg-slate-950 border border-slate-700 rounded-lg px-2.5 py-1.5 text-xs font-mono uppercase text-white focus:outline-none focus:border-sky-500" />
{/* 3. Play vs AI Bot (Clearly Labeled as AI) */}
Single Player vs AI [BOT]

AI Championship Bot

{['rookie', 'pro', 'grandmaster'].map((lvl) => ( ))}
{/* 4. Practice Mode & Pass-and-Play */}
Training & Local 2P

Solo Practice & Local

Unlimited time, free cue-ball placement, extended trajectory lines & shot undo.

{/* Right 5 Cols: Live Public Rooms & Online Players Browser */}

Live Club Rooms & Lobby

Join an open table or spectate active matches in real time.

{Math.max(1, allProfiles.length)} Players Registered
{publicRooms.length === 0 ? (

No Open Public Tables Right Now

Click “Create Room & Get Code” or “Find Online Match” to host a live table!

) : (
{publicRooms.map((rm) => (
{rm.player1?.avatar || '🎱'}
{rm.player1?.name || 'Host'} #{rm.roomId}
{rm.mode?.toUpperCase()} • {rm.ranked ? 'Ranked' : 'Casual'} •{' '} {rm.status === 'waiting' ? 'Open to Join' : `vs ${rm.player2?.name || 'Player 2'}`}
{rm.status === 'waiting' && rm.player1?.uid !== user?.uid ? ( ) : rm.player1?.uid === user?.uid || rm.player2?.uid === user?.uid ? ( ) : ( In Progress )}
))}
)}
Equipped Cue: {CUE_CATALOG.find((c) => c.id === profile.equippedCue)?.name}
)} {/* ==================================================================== */} {/* TAB 2: ACTIVE GAME TABLE, HUD, SPIN WIDGET & POWER SLIDER */} {/* ==================================================================== */} {activeTab === 'game' && (
{/* Top Match HUD Bar (Players, Scores, Ball Groups / Snooker Break, Shot Clock) */}
{/* Player 1 Card */}
{gameState.player1?.avatar || '🎱'}
{gameState.player1?.name || 'Player 1'} {gameState.currentTurn === 1 && !gameState.winner && ( )}
{gameState.mode === '8ball' && ( {gameState.playerGroups[1] || 'Open Table'} )} {gameState.mode === 'snooker' && ( Frame Pts: {gameState.scores[1]} )} {gameState.mode === '9ball' && ( Balls Potted: {gameState.scores[1]} )}
{gameState.scores[1]}
{/* Center Match Status, Snooker Break / 9-Ball Target & Shot Timer */}
{gameState.mode === '8ball' ? '8-Ball Pool' : gameState.mode === '9ball' ? '9-Ball Pool' : 'Snooker'} {gameState.matchType === 'online' && activeRoomId && ( Room #{activeRoomId} )} {gameState.turnLimit > 0 && gameState.matchType !== 'practice' && !gameState.winner && ( {gameState.turnTimeLeft}s )}
{/* Mode-Specific Live Rule Target Indicator */}
{gameState.mode === 'snooker' ? ( Ball On:{' '} {formatSnookerTarget(gameState.snookerTarget)} {' '} • Break: {gameState.currentBreak} • Remaining:{' '} {calculateSnookerPointsRemaining(gameState.balls, gameState.snookerTarget)} ) : gameState.mode === '9ball' ? ( Lowest Ball On:{' '} # {Math.min( ...gameState.balls .filter((b) => !b.potted && b.id > 0) .map((b) => b.id), 9 )}{' '} Ball ) : ( {gameState.bannerMessage} )}
{/* Player 2 Card */}
{gameState.scores[2]}
{gameState.currentTurn === 2 && !gameState.winner && ( )} {gameState.player2?.name || 'Player 2'}
{gameState.mode === '8ball' && ( {gameState.playerGroups[2] || 'Open Table'} )} {gameState.mode === 'snooker' && ( Frame Pts: {gameState.scores[2]} )} {gameState.mode === '9ball' && ( Balls Potted: {gameState.scores[2]} )}
{gameState.player2?.avatar || '🎯'}
{/* Foul / Status Notification Banner */} {(gameState.foulMessage || gameState.ballInHand) && !gameState.winner && (
{gameState.foulMessage || 'Ball-in-Hand Active: Drag the cue ball directly on the table to position it.'}
{gameState.ballInHand ? 'Drag Cue Ball' : 'Rule Notice'}
)} {/* Middle Section: Interactive Table Canvas + Tactile Cue Power Slider */}
{/* Responsive Billiards Table Canvas Container */}
{/* Winner Overlay Modal inside Table */} {gameState.winner && (

{gameState.winner === 1 ? `${gameState.player1?.name} Wins!` : `${gameState.player2?.name} Wins!`}

{gameState.winReason}

)}
{/* Right-Side Tactile Cue Power Pull-Back & Shoot Control */}
{/* Cue Ball Spin (English) Selector Button */}
Spin
{/* Power Slider Bar */}
PWR {Math.round(shotPower)}%
{ setShotPower(Number(e.target.value)); setIsPullingCue(Number(e.target.value) > 0); }} onMouseUp={() => { if (shotPower >= 4 && canLocalPlayerShoot) { executeShot(aimAngle, shotPower, spinOffset.x, spinOffset.y); } else { setShotPower(0); } }} onTouchEnd={() => { if (shotPower >= 4 && canLocalPlayerShoot) { executeShot(aimAngle, shotPower, spinOffset.x, spinOffset.y); } else { setShotPower(0); } }} className="w-full lg:h-40 lg:w-6 accent-emerald-500 cursor-pointer" />
{/* Direct Shoot Button */}
{/* Bottom Precision Fine-Aim Controls, Practice Tools & Rack Controls */}
{/* Fine-Aim Micro Adjusters */}
Fine Aim:
{/* Remaining Object Balls Mini Tray */}
Potted: {gameState.balls.filter((b) => b.potted && b.id !== 0).length === 0 ? ( None yet ) : ( gameState.balls .filter((b) => b.potted && b.id !== 0) .slice(0, 15) .map((b) => ( {b.label} )) )}
{/* Right Controls: Aiming Guide Toggle, Practice Undo, Reset / Forfeit */}
{gameState.matchType === 'practice' && ( <> )}
)} {/* ==================================================================== */} {/* TAB 3: GLOBAL ELO LEADERBOARDS */} {/* ==================================================================== */} {activeTab === 'leaderboards' && (

Global Cue Masters Rankings

Real-time Elo leaderboard synced across all registered online players.

Your Rating: {profile.elo} ELO {rankBadge.name}
{allProfiles .slice() .sort((a, b) => (b.elo || 1200) - (a.elo || 1200)) .map((p, idx) => { const tier = getRankTier(p.elo || 1200); const isMe = p.uid === user?.uid; return ( ); })}
Rank Player Tier Elo Rating Wins / Losses High Snooker Break
{idx === 0 ? '🥇 1' : idx === 1 ? '🥈 2' : idx === 2 ? '🥉 3' : `#${idx + 1}`} {p.avatar || '🎱'} {p.username} {isMe && ( YOU )} {tier.name} {p.elo || 1200} {p.wins || 0}W – {p.losses || 0}L {p.bestSnookerBreak || 0} pts
)} {/* ==================================================================== */} {/* TAB 4: CUSTOMIZATION LOCKER (CUES & TABLE FELT THEMES) */} {/* ==================================================================== */} {activeTab === 'shop' && (

Cue & Table Customization Locker

All cosmetics are strictly visual — competitive physics remain 100% fair for every player.

🪙 Balance: {profile.coins || 0} Match Coins
{/* Cues Section */}

Handcrafted Cue Sticks

{CUE_CATALOG.map((cue) => { const owned = profile.ownedCues?.includes(cue.id); const equipped = profile.equippedCue === cue.id; return (
{cue.tier} {owned ? 'Owned' : `🪙 ${cue.cost}`}

{cue.name}

{/* Visual Preview of Cue Stick */}
); })}
{/* Table Felt Themes Section */}

Championship Table Felt Themes

{TABLE_THEMES.map((th) => { const owned = profile.ownedThemes?.includes(th.id); const equipped = profile.equippedTheme === th.id; return (

{th.name}

{owned ? 'Unlocked' : `🪙 ${th.cost} Coins`}

); })}
)} {/* ==================================================================== */} {/* TAB 5: PLAYER CAREER PROFILE & MATCH HISTORY */} {/* ==================================================================== */} {activeTab === 'profile' && (
{profile.avatar}

{profile.username}

{rankBadge.name}

Account ID: {user?.uid || 'Local'}

{/* Key Career Metrics */}
{[ { label: 'Elo Rating', value: profile.elo, color: 'text-emerald-400' }, { label: 'Win Rate', value: profile.wins + profile.losses > 0 ? `${Math.round((profile.wins / (profile.wins + profile.losses)) * 100)}%` : '0%', color: 'text-sky-400', }, { label: 'Total Wins', value: profile.wins || 0, color: 'text-amber-400' }, { label: 'Best Snooker Break', value: `${profile.bestSnookerBreak || 0} pts`, color: 'text-rose-400', }, ].map((stat, i) => (
{stat.label}
{stat.value}
))}
{/* Recent Match History */}

Verified Match History

{(!profile.matchHistory || profile.matchHistory.length === 0) ? (
No completed competitive matches recorded yet. Complete an Online or AI match to log your history!
) : (
{profile.matchHistory.map((mh) => (
{mh.result} {mh.mode} vs {mh.opponentName}
Score: {mh.score} {mh.eloDelta !== 0 && ( 0 ? 'text-emerald-400' : 'text-rose-400' }`} > {mh.eloDelta > 0 ? `+${mh.eloDelta}` : mh.eloDelta} ELO )}
))}
)}
)} {/* ==================================================================== */} {/* MODAL A: INTERACTIVE CUE BALL SPIN (ENGLISH) SELECTOR */} {/* ==================================================================== */} {showSpinModal && (

Cue Ball Spin (English)

Click or drag on the cue ball face to apply Topspin (Follow), Backspin (Draw/Screw), or Left/Right Side Spin.

{ const rect = e.currentTarget.getBoundingClientRect(); const cx = rect.width / 2; const cy = rect.height / 2; const dx = (e.clientX - rect.left - cx) / (cx * 0.85); const dy = -(e.clientY - rect.top - cy) / (cy * 0.85); const mag = Math.hypot(dx, dy); if (mag > 1) { setSpinOffset({ x: dx / mag, y: dy / mag }); } else { setSpinOffset({ x: dx, y: dy }); } }} className="relative w-44 h-44 mx-auto rounded-full bg-gradient-to-br from-white via-slate-100 to-slate-400 border-4 border-emerald-500 shadow-2xl cursor-pointer flex items-center justify-center" > {/* Crosshairs */}
FOLLOW DRAW L R {/* Tip Contact Indicator */}
)} {/* ==================================================================== */} {/* MODAL B: EDIT PROFILE & ACCOUNT IDENTITY */} {/* ==================================================================== */} {editingProfile && (

Player Account Profile

setTempUsername(e.target.value)} maxLength={18} className="w-full bg-slate-950 border border-slate-700 rounded-xl px-3.5 py-2 text-sm text-white focus:outline-none focus:border-emerald-500" />
{AVATAR_OPTIONS.map((av) => ( ))}
)} {/* ==================================================================== */} {/* MODAL C: OFFICIAL RULES & CONTROLS GUIDE */} {/* ==================================================================== */} {showRulesModal && (

Cue Masters — Rules & Controls Guide

🎯 Aiming, Shooting & Spin Controls

• Aiming: Click/touch and drag anywhere on the table to orient the aiming line and ghost ball preview. Use the Fine Aim buttons at the bottom for precision 0.2° micro-adjustments.
• Shooting: Drag the PWR Slider on the right and release to strike, or click the SHOOT button.
• Spin (English): Click the Cue Ball icon above the power slider to apply Topspin (follow), Backspin (draw), or Sidespin.
• Ball-in-Hand: When a foul occurs (or anytime in Practice Mode), drag the cue ball directly on the felt.

🎱 8-Ball, 9-Ball & Snooker Rules Enforced

• 8-Ball Pool: Table is Open after the break until a legal pot assigns Solids (1–7) or Stripes (9–15). Must hit your own group first and either pot a ball or drive a ball to a cushion. Legally sink the 8-ball after clearing your group to win.
• 9-Ball Pool: Always contact the lowest-numbered ball on the table first (highlighted with a gold ring). Legally potting the 9-ball at any point wins the rack.
• Snooker: Alternate potting a Red (1 pt) followed by any Color (2–7 pts). Colors respot while Reds remain. Once all Reds are cleared, pot the colors in ascending order: Yellow (2), Green (3), Brown (4), Blue (5), Pink (6), Black (7).

)} {/* ==================================================================== */} {/* MODAL D: SYSTEM STATUS, ARCHITECTURE & DEPLOYMENT REPORT */} {/* ==================================================================== */} {showSystemModal && (

System Architecture & Multiplayer Status Report

Network Transport
{authReady ? 'Connected (Firestore Live)' : 'Initializing...'}
Physics Engine
Deterministic 6x Substep
Audio Engine
Web Audio Synthesizer

1. Real-Time Multiplayer & Anti-Cheat Validation

Matches are synchronized in real time via persistent Firestore document listeners under{' '} /artifacts/{appId}/public/data/matches/{roomId}. Each shot increments a monotonic shotSequenceId, preventing out-of-turn or duplicate shot submissions. Both clients execute the identical fixed-substep impulse simulation and commit the verified post-shot state.

2. Standalone Node.js + Socket.IO + MySQL Deployment

To deploy with a dedicated Node.js WebSocket server and MySQL database outside the browser sandbox, set{' '} VITE_WS_SERVER_URL, DATABASE_URL, and{' '} JWT_SECRET in your environment and run the physics & rule evaluators (stepPhysicsSimulation and evaluateShotRules) directly inside the Node.js room worker.

)}
); }