The pool break, built by Gemini 4 Argon
Hostedvia DaBenchOpenRouter
87tok/s
First token included
27Ktokens
5m 10s
$0.14
Show the code the model wroteHide the code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eight-Ball Break</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 42%, #1a222c 0%, #0d1117 65%, #07090c 100%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
}
canvas {
display: block;
max-width: 98vw;
max-height: 96vh;
width: auto;
height: auto;
filter: drop-shadow(0 28px 50px rgba(0, 0, 0, 0.78));
}
</style>
</head>
<body>
<canvas id="poolCanvas"></canvas>
<script>
(() => {
const canvas = document.getElementById('poolCanvas');
const ctx = canvas.getContext('2d');
// Logical dimensions for crisp broadcast aspect ratio
const LOGICAL_W = 1220;
const LOGICAL_H = 740;
function resizeCanvas() {
const dpr = Math.min(window.devicePixelRatio || 1, 2.5);
canvas.width = LOGICAL_W * dpr;
canvas.height = LOGICAL_H * dpr;
canvas.style.aspectRatio = `${LOGICAL_W} / ${LOGICAL_H}`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// ============================================================================
// TABLE GEOMETRY & CONSTANTS (Exact 2:1 Regulation Playing Surface)
// ============================================================================
const PLAY_W = 940;
const PLAY_H = 470;
const PLAY_LEFT = (LOGICAL_W - PLAY_W) / 2; // 140
const PLAY_RIGHT = PLAY_LEFT + PLAY_W; // 1080
const PLAY_TOP = 72;
const PLAY_BOTTOM = PLAY_TOP + PLAY_H; // 542
const PLAY_CENTER_X = (PLAY_LEFT + PLAY_RIGHT) / 2; // 610
const PLAY_CENTER_Y = (PLAY_TOP + PLAY_BOTTOM) / 2; // 307
const CUSHION_WIDTH = 23; // Rubber cushion nose to wood rail
const RAIL_WIDTH = 44; // Wood rail width outside cushion
const WOOD_LEFT = PLAY_LEFT - CUSHION_WIDTH;
const WOOD_RIGHT = PLAY_RIGHT + CUSHION_WIDTH;
const WOOD_TOP = PLAY_TOP - CUSHION_WIDTH;
const WOOD_BOTTOM = PLAY_BOTTOM + CUSHION_WIDTH;
const OUTER_LEFT = WOOD_LEFT - RAIL_WIDTH;
const OUTER_RIGHT = WOOD_RIGHT + RAIL_WIDTH;
const OUTER_TOP = WOOD_TOP - RAIL_WIDTH;
const OUTER_BOTTOM = WOOD_BOTTOM + RAIL_WIDTH;
const HEAD_STRING_X = PLAY_LEFT + PLAY_W * 0.25; // 375
const HEAD_SPOT_X = HEAD_STRING_X;
const FOOT_SPOT_X = PLAY_LEFT + PLAY_W * 0.75; // 845
const SPOT_Y = PLAY_CENTER_Y; // 307
const BALL_R = 13.0;
const BALL_D = BALL_R * 2;
// Pocket openings & centers
const CORNER_CUT = 28; // Nose cut distance from corner
const SIDE_CUT = 24; // Half-opening at side pocket nose
const POCKETS = [
// 4 Corner pockets
{ id: 0, x: PLAY_LEFT - 10, y: PLAY_TOP - 10, r: 24.5, captureR: 20, type: 'corner' },
{ id: 1, x: PLAY_RIGHT + 10, y: PLAY_TOP - 10, r: 24.5, captureR: 20, type: 'corner' },
{ id: 2, x: PLAY_LEFT - 10, y: PLAY_BOTTOM + 10, r: 24.5, captureR: 20, type: 'corner' },
{ id: 3, x: PLAY_RIGHT + 10, y: PLAY_BOTTOM + 10, r: 24.5, captureR: 20, type: 'corner' },
// 2 Side pockets
{ id: 4, x: PLAY_CENTER_X, y: PLAY_TOP - 16, r: 23.0, captureR: 19, type: 'side' },
{ id: 5, x: PLAY_CENTER_X, y: PLAY_BOTTOM + 16, r: 23.0, captureR: 19, type: 'side' }
];
// Six Cushion Polygons (each defined clockwise from wood-back-start, nose-start, nose-end, wood-back-end)
const CUSHIONS = [
// Top-Left Cushion
[
{ x: WOOD_LEFT + CORNER_CUT - 15, y: WOOD_TOP },
{ x: PLAY_LEFT + CORNER_CUT, y: PLAY_TOP },
{ x: PLAY_CENTER_X - SIDE_CUT, y: PLAY_TOP },
{ x: PLAY_CENTER_X - SIDE_CUT + 7, y: WOOD_TOP }
],
// Top-Right Cushion
[
{ x: PLAY_CENTER_X + SIDE_CUT - 7, y: WOOD_TOP },
{ x: PLAY_CENTER_X + SIDE_CUT, y: PLAY_TOP },
{ x: PLAY_RIGHT - CORNER_CUT, y: PLAY_TOP },
{ x: WOOD_RIGHT - CORNER_CUT + 15, y: WOOD_TOP }
],
// Bottom-Right Cushion
[
{ x: WOOD_RIGHT - CORNER_CUT + 15, y: WOOD_BOTTOM },
{ x: PLAY_RIGHT - CORNER_CUT, y: PLAY_BOTTOM },
{ x: PLAY_CENTER_X + SIDE_CUT, y: PLAY_BOTTOM },
{ x: PLAY_CENTER_X + SIDE_CUT - 7, y: WOOD_BOTTOM }
],
// Bottom-Left Cushion
[
{ x: PLAY_CENTER_X - SIDE_CUT + 7, y: WOOD_BOTTOM },
{ x: PLAY_CENTER_X - SIDE_CUT, y: PLAY_BOTTOM },
{ x: PLAY_LEFT + CORNER_CUT, y: PLAY_BOTTOM },
{ x: WOOD_LEFT + CORNER_CUT - 15, y: WOOD_BOTTOM }
],
// Left (Head) Cushion
[
{ x: WOOD_LEFT, y: WOOD_BOTTOM - CORNER_CUT + 15 },
{ x: PLAY_LEFT, y: PLAY_BOTTOM - CORNER_CUT },
{ x: PLAY_LEFT, y: PLAY_TOP + CORNER_CUT },
{ x: WOOD_LEFT, y: WOOD_TOP + CORNER_CUT - 15 }
],
// Right (Foot) Cushion
[
{ x: WOOD_RIGHT, y: WOOD_TOP + CORNER_CUT - 15 },
{ x: PLAY_RIGHT, y: PLAY_TOP + CORNER_CUT },
{ x: PLAY_RIGHT, y: PLAY_BOTTOM - CORNER_CUT },
{ x: WOOD_RIGHT, y: WOOD_BOTTOM - CORNER_CUT + 15 }
]
];
// Build collision segments from the 6 cushions (jaw bevels + main nose + back guard)
const COLLISION_SEGMENTS = [];
for (const poly of CUSHIONS) {
// Segments: (0->1) jaw1, (1->2) main nose, (2->3) jaw2, (3->0) wood back
for (let i = 0; i < 4; i++) {
const p1 = poly[i];
const p2 = poly[(i + 1) % 4];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
// Outward normal for clockwise polygon in screen coords (y down) is (dy/len, -dx/len)
// Let's verify with Top-Left main nose: p1=(168, 72) -> p2=(586, 72), dx>0, dy=0.
// We want normal pointing DOWN (0, +1), which is (-dy/len, dx/len)!
const nx = -dy / len;
const ny = dx / len;
COLLISION_SEGMENTS.push({
x1: p1.x, y1: p1.y,
x2: p2.x, y2: p2.y,
dx, dy, lenSq: len * len,
nx, ny,
isJaw: (i === 0 || i === 2)
});
}
}
// Official Pool Ball Colors
const BALL_DEFS = {
0: { color: '#F8F6EC', stripe: false, label: '' },
1: { color: '#F5C414', stripe: false, label: '1' }, // Yellow
2: { color: '#144BA8', stripe: false, label: '2' }, // Blue
3: { color: '#D92222', stripe: false, label: '3' }, // Red
4: { color: '#532484', stripe: false, label: '4' }, // Purple
5: { color: '#F26818', stripe: false, label: '5' }, // Orange
6: { color: '#117A3D', stripe: false, label: '6' }, // Green
7: { color: '#70191E', stripe: false, label: '7' }, // Maroon
8: { color: '#16181B', stripe: false, label: '8' }, // Black
9: { color: '#F5C414', stripe: true, label: '9' },
10: { color: '#144BA8', stripe: true, label: '10' },
11: { color: '#D92222', stripe: true, label: '11' },
12: { color: '#532484', stripe: true, label: '12' },
13: { color: '#F26818', stripe: true, label: '13' },
14: { color: '#117A3D', stripe: true, label: '14' },
15: { color: '#70191E', stripe: true, label: '15' }
};
// ============================================================================
// PRE-RENDER STATIC TABLE SURFACE FOR PERFORMANCE & BROADCAST DETAIL
// ============================================================================
const tableBuffer = document.createElement('canvas');
tableBuffer.width = LOGICAL_W * 2;
tableBuffer.height = LOGICAL_H * 2;
const tCtx = tableBuffer.getContext('2d');
tCtx.scale(2, 2);
function renderStaticTable(c) {
// 1. Outer Mahogany Frame with Rounded Corners
const frameRadius = 26;
c.save();
roundRectPath(c, OUTER_LEFT, OUTER_TOP, OUTER_RIGHT - OUTER_LEFT, OUTER_BOTTOM - OUTER_TOP, frameRadius);
const woodGrad = c.createLinearGradient(OUTER_LEFT, OUTER_TOP, OUTER_RIGHT, OUTER_BOTTOM);
woodGrad.addColorStop(0, '#4d2414');
woodGrad.addColorStop(0.2, '#5c2c18');
woodGrad.addColorStop(0.5, '#3f1c0f');
woodGrad.addColorStop(0.8, '#582916');
woodGrad.addColorStop(1, '#38170b');
c.fillStyle = woodGrad;
c.fill();
// Subtle wood grain lines on rails
c.clip();
c.strokeStyle = 'rgba(20, 6, 2, 0.22)';
c.lineWidth = 1.2;
for (let i = -200; i < 1500; i += 7) {
c.beginPath();
c.moveTo(i, OUTER_TOP);
c.bezierCurveTo(i + 40, PLAY_CENTER_Y - 100, i - 30, PLAY_CENTER_Y + 100, i + 20, OUTER_BOTTOM);
c.stroke();
}
// Outer metallic bevel trim
roundRectPath(c, OUTER_LEFT + 1.5, OUTER_TOP + 1.5, OUTER_RIGHT - OUTER_LEFT - 3, OUTER_BOTTOM - OUTER_TOP - 3, frameRadius - 1);
c.strokeStyle = 'rgba(255, 215, 175, 0.16)';
c.lineWidth = 2;
c.stroke();
// Inner rail lip bevel
c.strokeStyle = '#240d05';
c.lineWidth = 3;
c.strokeRect(WOOD_LEFT - 1.5, WOOD_TOP - 1.5, WOOD_RIGHT - WOOD_LEFT + 3, WOOD_BOTTOM - WOOD_TOP + 3);
c.restore();
// 2. Metallic Corner & Side Pocket Castings (Flush Broadcast Style)
drawPocketCastings(c);
// 3. Green Worsted Baize Cloth Bed
c.save();
c.beginPath();
c.rect(WOOD_LEFT, WOOD_TOP, WOOD_RIGHT - WOOD_LEFT, WOOD_BOTTOM - WOOD_TOP);
c.clip();
const clothGrad = c.createRadialGradient(
PLAY_CENTER_X, PLAY_CENTER_Y, 40,
PLAY_CENTER_X, PLAY_CENTER_Y, 640
);
clothGrad.addColorStop(0, '#178549');
clothGrad.addColorStop(0.55,'#12703d');
clothGrad.addColorStop(1, '#0b522b');
c.fillStyle = clothGrad;
c.fillRect(WOOD_LEFT, WOOD_TOP, WOOD_RIGHT - WOOD_LEFT, WOOD_BOTTOM - WOOD_TOP);
// Fine procedural cloth weave texture
c.fillStyle = 'rgba(255,255,255,0.015)';
for (let x = WOOD_LEFT; x < WOOD_RIGHT; x += 3) {
for (let y = WOOD_TOP; y < WOOD_BOTTOM; y += 3) {
if ((x + y) % 6 === 0) c.fillRect(x, y, 1.5, 1.5);
}
}
// 4. Faint Table Markings: Head String, Head Spot, Foot Spot, Rack Outline
c.strokeStyle = 'rgba(225, 255, 238, 0.17)';
c.lineWidth = 1.4;
c.beginPath();
c.moveTo(HEAD_STRING_X, PLAY_TOP);
c.lineTo(HEAD_STRING_X, PLAY_BOTTOM);
c.stroke();
// Faint Head Spot
c.fillStyle = 'rgba(225, 255, 238, 0.22)';
c.beginPath();
c.arc(HEAD_SPOT_X, SPOT_Y, 2.5, 0, Math.PI * 2);
c.fill();
// Faint Rack Triangle Line
const rackRowDx = BALL_D * Math.cos(Math.PI / 6);
c.strokeStyle = 'rgba(225, 255, 238, 0.11)';
c.lineWidth = 1.1;
c.beginPath();
c.moveTo(FOOT_SPOT_X - BALL_R, SPOT_Y);
c.lineTo(FOOT_SPOT_X + 4 * rackRowDx + BALL_R * 0.8, SPOT_Y - 2 * BALL_D - BALL_R);
c.lineTo(FOOT_SPOT_X + 4 * rackRowDx + BALL_R * 0.8, SPOT_Y + 2 * BALL_D + BALL_R);
c.closePath();
c.stroke();
// Foot Spot (dark cloth sticker with tiny pale center dot)
c.fillStyle = 'rgba(8, 36, 20, 0.65)';
c.beginPath();
c.arc(FOOT_SPOT_X, SPOT_Y, 5.2, 0, Math.PI * 2);
c.fill();
c.fillStyle = 'rgba(225, 255, 238, 0.45)';
c.beginPath();
c.arc(FOOT_SPOT_X, SPOT_Y, 1.5, 0, Math.PI * 2);
c.fill();
// 5. Cushion Shadows onto the Playing Surface
c.fillStyle = 'rgba(0, 18, 8, 0.34)';
c.fillRect(PLAY_LEFT, PLAY_TOP, PLAY_W, 7);
c.fillRect(PLAY_LEFT, PLAY_BOTTOM - 5, PLAY_W, 5);
c.fillRect(PLAY_LEFT, PLAY_TOP, 6, PLAY_H);
c.fillRect(PLAY_RIGHT - 6, PLAY_TOP, 6, PLAY_H);
// 6. Render the 6 Molded Cloth-Covered Rubber Cushions
for (let idx = 0; idx < CUSHIONS.length; idx++) {
const poly = CUSHIONS[idx];
c.beginPath();
c.moveTo(poly[0].x, poly[0].y);
c.lineTo(poly[1].x, poly[1].y);
c.lineTo(poly[2].x, poly[2].y);
c.lineTo(poly[3].x, poly[3].y);
c.closePath();
// Top/Left cushions catch slightly different overhead angle than Bottom/Right
let cGrad;
if (idx === 0 || idx === 1) { // Top cushions
cGrad = c.createLinearGradient(0, WOOD_TOP, 0, PLAY_TOP);
cGrad.addColorStop(0, '#0c4d29');
cGrad.addColorStop(0.75, '#167d44');
cGrad.addColorStop(1, '#1e9654');
} else if (idx === 2 || idx === 3) { // Bottom cushions
cGrad = c.createLinearGradient(0, WOOD_BOTTOM, 0, PLAY_BOTTOM);
cGrad.addColorStop(0, '#0a4223');
cGrad.addColorStop(0.75, '#136e3b');
cGrad.addColorStop(1, '#1b894d');
} else if (idx === 4) { // Left cushion
cGrad = c.createLinearGradient(WOOD_LEFT, 0, PLAY_LEFT, 0);
cGrad.addColorStop(0, '#0b4726');
cGrad.addColorStop(0.75, '#14753f');
cGrad.addColorStop(1, '#1c8e50');
} else { // Right cushion
cGrad = c.createLinearGradient(WOOD_RIGHT, 0, PLAY_RIGHT, 0);
cGrad.addColorStop(0, '#0b4726');
cGrad.addColorStop(0.75, '#14753f');
cGrad.addColorStop(1, '#1c8e50');
}
c.fillStyle = cGrad;
c.fill();
// Crisp highlight along the cushion nose edge
c.beginPath();
c.moveTo(poly[0].x, poly[0].y);
c.lineTo(poly[1].x, poly[1].y);
c.lineTo(poly[2].x, poly[2].y);
c.lineTo(poly[3].x, poly[3].y);
c.strokeStyle = 'rgba(165, 255, 200, 0.22)';
c.lineWidth = 1.2;
c.stroke();
}
c.restore();
// 7. Six Pockets (Deep dark well + leather rim)
for (const p of POCKETS) {
// Leather pocket liner back
c.save();
c.beginPath();
c.arc(p.x, p.y, p.r + 5, 0, Math.PI * 2);
c.fillStyle = '#181412';
c.fill();
c.strokeStyle = '#2d2521';
c.lineWidth = 2;
c.stroke();
// Deep pocket hole with subtle rim gradient
const pGrad = c.createRadialGradient(p.x + 2, p.y + 2, 2, p.x, p.y, p.r);
pGrad.addColorStop(0, '#020304');
pGrad.addColorStop(0.72, '#07090c');
pGrad.addColorStop(0.92, '#14181f');
pGrad.addColorStop(1, '#222830');
c.beginPath();
c.arc(p.x, p.y, p.r, 0, Math.PI * 2);
c.fillStyle = pGrad;
c.fill();
// Subtle inner pocket net/leather stitch arc
c.beginPath();
c.arc(p.x, p.y, p.r - 3, 0, Math.PI * 2);
c.strokeStyle = 'rgba(255,255,255,0.06)';
c.lineWidth = 1;
c.stroke();
c.restore();
}
// 8. Eighteen Diamond Markers (Sights) on the Wooden Rails
// Long rails: 8 segments (diamonds at 1/8, 2/8, 3/8, 5/8, 6/8, 7/8; 4/8 is the side pocket)
// Short rails: 4 segments (diamonds at 1/4, 2/4, 3/4)
const railTopY = WOOD_TOP - RAIL_WIDTH * 0.5;
const railBottomY = WOOD_BOTTOM + RAIL_WIDTH * 0.5;
const railLeftX = WOOD_LEFT - RAIL_WIDTH * 0.5;
const railRightX = WOOD_RIGHT + RAIL_WIDTH * 0.5;
for (let i = 1; i <= 7; i++) {
if (i === 4) continue; // Side pocket
const x = PLAY_LEFT + (PLAY_W / 8) * i;
drawDiamond(c, x, railTopY, true);
drawDiamond(c, x, railBottomY, true);
}
for (let j = 1; j <= 3; j++) {
const y = PLAY_TOP + (PLAY_H / 4) * j;
drawDiamond(c, railLeftX, y, false);
drawDiamond(c, railRightX, y, false);
}
// 9. Potted Balls Return Tray Below the Table
drawTrayBackground(c);
}
function roundRectPath(c, x, y, w, h, r) {
c.beginPath();
c.moveTo(x + r, y);
c.lineTo(x + w - r, y);
c.quadraticCurveTo(x + w, y, x + w, y + r);
c.lineTo(x + w, y + h - r);
c.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
c.lineTo(x + r, y + h);
c.quadraticCurveTo(x, y + h, x, y + h - r);
c.lineTo(x, y + r);
c.quadraticCurveTo(x, y, x + r, y);
c.closePath();
}
function drawPocketCastings(c) {
c.save();
roundRectPath(c, OUTER_LEFT, OUTER_TOP, OUTER_RIGHT - OUTER_LEFT, OUTER_BOTTOM - OUTER_TOP, 26);
c.clip();
const metalGrad = c.createLinearGradient(OUTER_LEFT, OUTER_TOP, OUTER_RIGHT, OUTER_BOTTOM);
metalGrad.addColorStop(0, '#2b3036');
metalGrad.addColorStop(0.3, '#434a54');
metalGrad.addColorStop(0.5, '#23272d');
metalGrad.addColorStop(0.8, '#3b424b');
metalGrad.addColorStop(1, '#1d2024');
c.fillStyle = metalGrad;
// 4 Corners
const cw = 72;
c.fillRect(OUTER_LEFT, OUTER_TOP, cw, cw);
c.fillRect(OUTER_RIGHT - cw, OUTER_TOP, cw, cw);
c.fillRect(OUTER_LEFT, OUTER_BOTTOM - cw, cw, cw);
c.fillRect(OUTER_RIGHT - cw, OUTER_BOTTOM - cw, cw, cw);
// 2 Side Castings
const sw = 68;
c.fillRect(PLAY_CENTER_X - sw / 2, OUTER_TOP, sw, RAIL_WIDTH + 6);
c.fillRect(PLAY_CENTER_X - sw / 2, WOOD_BOTTOM - 6, sw, RAIL_WIDTH + 6);
c.restore();
}
function drawDiamond(c, x, y, horizontalRail) {
const rx = horizontalRail ? 3.8 : 5.6;
const ry = horizontalRail ? 5.6 : 3.8;
c.save();
c.beginPath();
c.moveTo(x, y - ry);
c.lineTo(x + rx, y);
c.lineTo(x, y + ry);
c.lineTo(x - rx, y);
c.closePath();
const dGrad = c.createLinearGradient(x - rx, y - ry, x + rx, y + ry);
dGrad.addColorStop(0, '#fffdf5');
dGrad.addColorStop(0.5, '#e8dfc5');
dGrad.addColorStop(1, '#b8ab8a');
c.fillStyle = dGrad;
c.fill();
c.strokeStyle = 'rgba(20, 8, 2, 0.55)';
c.lineWidth = 0.8;
c.stroke();
c.restore();
}
// Tray coordinates
const TRAY_W = 540;
const TRAY_H = 52;
const TRAY_X = (LOGICAL_W - TRAY_W) / 2;
const TRAY_Y = OUTER_BOTTOM + 36;
function drawTrayBackground(c) {
c.save();
// Wooden outer housing
roundRectPath(c, TRAY_X - 8, TRAY_Y - 6, TRAY_W + 16, TRAY_H + 12, 26);
const trayWood = c.createLinearGradient(TRAY_X, TRAY_Y - 6, TRAY_X, TRAY_Y + TRAY_H + 6);
trayWood.addColorStop(0, '#4f2413');
trayWood.addColorStop(0.5, '#36160b');
trayWood.addColorStop(1, '#240d05');
c.fillStyle = trayWood;
c.fill();
c.strokeStyle = 'rgba(255, 210, 170, 0.14)';
c.lineWidth = 1.2;
c.stroke();
// Inner recessed dark felt trough
roundRectPath(c, TRAY_X, TRAY_Y, TRAY_W, TRAY_H, 20);
const troughGrad = c.createLinearGradient(TRAY_X, TRAY_Y, TRAY_X, TRAY_Y + TRAY_H);
troughGrad.addColorStop(0, '#071c10');
troughGrad.addColorStop(0.35, '#0c2e1b');
troughGrad.addColorStop(1, '#092113');
c.fillStyle = troughGrad;
c.fill();
// Inner shadow
c.strokeStyle = 'rgba(0,0,0,0.65)';
c.lineWidth = 2;
c.stroke();
// 15 subtle circular dimples in the tray
for (let i = 0; i < 15; i++) {
const sx = TRAY_X + 31 + i * 34;
const sy = TRAY_Y + TRAY_H / 2;
c.beginPath();
c.arc(sx, sy, BALL_R - 2, 0, Math.PI * 2);
c.fillStyle = 'rgba(0, 0, 0, 0.25)';
c.fill();
}
c.restore();
}
renderStaticTable(tCtx);
// ============================================================================
// BALL STATE & REFEREE EIGHT-BALL RACKING
// ============================================================================
let balls = [];
let pottedTrayBalls = [];
let breakCount = 0;
// State machine
const STATE_RACKED = 0; // Hold rack for 1.0s
const STATE_STRIKING = 1; // Cue stick draws back and drives cue ball into rack
const STATE_SIMULATING = 2; // Physics running until all balls stop
const STATE_REST_WAIT = 3; // Wait 2.0s after all balls stop
let simState = STATE_RACKED;
let stateTimer = 0;
// Cue stick animation state
let cueAimAngle = 0;
let cueOffset = 0;
let cueAlpha = 1;
let plannedCueSpeed = 1120;
let plannedTopSpinX = 0;
let plannedTopSpinY = 0;
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function setupRefereeRack() {
balls = [];
pottedTrayBalls = [];
// Cue ball on the head string (vary Y position each break for unique realistic breaks)
const ySpread = (breakCount === 0)
? -28
: (Math.random() - 0.5) * 150;
const cueBallY = SPOT_Y + ySpread;
const cueBallX = HEAD_STRING_X;
balls.push(createBall(0, cueBallX, cueBallY));
// Referee 8-Ball Rack Rules:
// - 15 balls in a 5-row triangle with apex ball centered on the Foot Spot.
// - 8-ball MUST be at the center of the triangle: row 2, col 1 (0-indexed).
// - Bottom two corners: row 4, col 0 and row 4, col 4 MUST be one solid and one stripe.
// - 1-ball placed at the apex (row 0, col 0), remaining solids & stripes mixed cleanly.
const remainingSolids = shuffleArray([2, 3, 4, 5, 6, 7]);
const remainingStripes = shuffleArray([9, 10, 11, 12, 13, 14, 15]);
const cornerSolid = remainingSolids.pop();
const cornerStripe = remainingStripes.pop();
const swapCorners = Math.random() < 0.5;
// Pool remaining 11 balls (5 solids, 6 stripes) and interleave/shuffle so suits are well mixed
const pool = shuffleArray([...remainingSolids, ...remainingStripes]);
const rowSpacingX = BALL_D * Math.cos(Math.PI / 6) + 0.04;
const colSpacingY = BALL_D + 0.04;
for (let row = 0; row < 5; row++) {
for (let col = 0; col <= row; col++) {
let id;
if (row === 0 && col === 0) {
id = 1; // Apex ball on foot spot
} else if (row === 2 && col === 1) {
id = 8; // 8-ball in the center of row 3
} else if (row === 4 && col === 0) {
id = swapCorners ? cornerSolid : cornerStripe;
} else if (row === 4 && col === 4) {
id = swapCorners ? cornerStripe : cornerSolid;
} else {
id = pool.pop();
}
// Base lattice coordinates + microscopic physical tolerance (~0.08px)
// so contact wave disperses authentically across all 5 rows
const bx = FOOT_SPOT_X + row * rowSpacingX + (Math.random() - 0.5) * 0.14;
const by = SPOT_Y + (col - row * 0.5) * colSpacingY + (Math.random() - 0.5) * 0.14;
balls.push(createBall(id, bx, by));
}
}
// Relax rack positions so no two balls overlap at all (strictly d >= 2R) while remaining tight
for (let iter = 0; iter < 40; iter++) {
for (let i = 1; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) {
const b1 = balls[i];
const b2 = balls[j];
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const dist = Math.hypot(dx, dy);
const minDist = BALL_D + 0.002;
if (dist < minDist && dist > 0.0001) {
const overlap = (minDist - dist) * 0.5;
const nx = dx / dist;
const ny = dy / dist;
// Keep apex ball anchored on the foot spot
if (i === 1) {
b2.x += nx * overlap * 2;
b2.y += ny * overlap * 2;
} else {
b1.x -= nx * overlap;
b1.y -= ny * overlap;
b2.x += nx * overlap;
b2.y += ny * overlap;
}
}
}
}
}
// Aim cue stick at the apex 1-ball with slight realistic cut/power variation
const apex = balls[1];
const targetYOffset = (Math.random() - 0.5) * 3.4;
const aimDx = apex.x - BALL_D * 0.88 - cueBallX;
const aimDy = (apex.y + targetYOffset) - cueBallY;
cueAimAngle = Math.atan2(aimDy, aimDx);
plannedCueSpeed = 1130 + Math.random() * 90;
// Slight topspin/follow + tiny side english like a real power break
const spinSpeed = 240 + Math.random() * 110;
const spinAngle = cueAimAngle + (Math.random() - 0.5) * 0.28;
plannedTopSpinX = Math.cos(spinAngle) * spinSpeed;
plannedTopSpinY = Math.sin(spinAngle) * spinSpeed;
cueOffset = 8;
cueAlpha = 1;
breakCount++;
}
function createBall(id, x, y) {
return {
id,
x,
y,
vx: 0,
vy: 0,
spinVx: 0,
spinVy: 0,
stripeAngle: (Math.random() - 0.5) * Math.PI,
rollPhaseX: (Math.random() - 0.5) * 1.4,
rollPhaseY: (Math.random() - 0.5) * 1.4,
potted: false,
potting: false,
potTimer: 0,
potPocket: null,
potStartX: 0,
potStartY: 0,
scale: 1
};
}
// ============================================================================
// 2D PHYSICS ENGINE (SUB-STEPPED, MOMENTUM CONSERVING, ZERO OVERLAP)
// ============================================================================
const SUBSTEPS = 28;
const RESTITUTION_BALL = 0.965;
const RESTITUTION_RAIL = 0.79;
const ROLLING_FRICTION = 44.0; // Constant deceleration on baize (px/s^2)
const SLIDING_DRAG = 0.26; // Velocity-proportional drag at high speed
function stepPhysics(dt) {
const subDt = dt / SUBSTEPS;
for (let step = 0; step < SUBSTEPS; step++) {
// 1. Integrate positions, rolling friction, and topspin follow
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potted || b.potting) continue;
// Advance position
b.x += b.vx * subDt;
b.y += b.vy * subDt;
// Transfer residual topspin into linear velocity on cloth contact
if (b.spinVx !== 0 || b.spinVy !== 0) {
const transfer = Math.min(1, subDt * 6.5);
b.vx += b.spinVx * transfer;
b.vy += b.spinVy * transfer;
b.spinVx *= (1 - transfer);
b.spinVy *= (1 - transfer);
if (Math.hypot(b.spinVx, b.spinVy) < 1) {
b.spinVx = 0;
b.spinVy = 0;
}
}
// Apply rolling resistance + high-speed cloth drag
const speed = Math.hypot(b.vx, b.vy);
if (speed > 0) {
// Visual rolling rotation update
const moveDist = speed * subDt;
const moveAngle = Math.atan2(b.vy, b.vx);
b.stripeAngle += Math.sin(moveAngle - b.stripeAngle) * (moveDist / BALL_R) * 0.45;
b.rollPhaseX = Math.max(-3.0, Math.min(3.0, b.rollPhaseX + (b.vx * subDt) / BALL_R * 0.35));
b.rollPhaseY = Math.max(-3.0, Math.min(3.0, b.rollPhaseY + (b.vy * subDt) / BALL_R * 0.35));
// Gently keep number circle near the top hemisphere while still rolling visibly
b.rollPhaseX *= 0.997;
b.rollPhaseY *= 0.997;
const decel = ROLLING_FRICTION + speed * SLIDING_DRAG;
const newSpeed = Math.max(0, speed - decel * subDt);
if (newSpeed < 1.2) {
b.vx = 0;
b.vy = 0;
} else {
const ratio = newSpeed / speed;
b.vx *= ratio;
b.vy *= ratio;
}
}
}
// 2. Check Pocket Gravity & Pocket Capture
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potted || b.potting) continue;
for (let pIdx = 0; pIdx < POCKETS.length; pIdx++) {
const p = POCKETS[pIdx];
const dx = p.x - b.x;
const dy = p.y - b.y;
const dist = Math.hypot(dx, dy);
// Pocket throat bevel pull when over the slate lip
if (dist < p.r + 6 && dist > 0.001) {
const pull = 360 * subDt;
b.vx += (dx / dist) * pull;
b.vy += (dy / dist) * pull;
}
// Captured by pocket hole
if (dist <= p.captureR) {
b.potting = true;
b.potTimer = 0;
b.potPocket = p;
b.potStartX = b.x;
b.potStartY = b.y;
b.vx = 0;
b.vy = 0;
b.spinVx = 0;
b.spinVy = 0;
break;
}
}
}
// 3. Cushion & Rail Collisions
solveCushionCollisions();
// 4. Multi-pass Ball-to-Ball Collisions & Strict Non-Overlap Enforcement
for (let iter = 0; iter < 3; iter++) {
solveBallCollisions();
}
// 5. Final Cushion Clamp so a ball pushed by another ball never penetrates a rail
solveCushionCollisions();
}
// Animate potting balls & potted tray balls
updatePottingAnimations(dt);
}
function solveCushionCollisions() {
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potted || b.potting) continue;
for (let s = 0; s < COLLISION_SEGMENTS.length; s++) {
const seg = COLLISION_SEGMENTS[s];
const px = b.x - seg.x1;
const py = b.y - seg.y1;
let t = (px * seg.dx + py * seg.dy) / seg.lenSq;
if (t < 0) t = 0;
else if (t > 1) t = 1;
const cx = seg.x1 + t * seg.dx;
const cy = seg.y1 + t * seg.dy;
const dx = b.x - cx;
const dy = b.y - cy;
const distSq = dx * dx + dy * dy;
if (distSq < BALL_R * BALL_R) {
const dist = Math.sqrt(distSq);
let nx, ny;
if (dist > 0.0001) {
nx = dx / dist;
ny = dy / dist;
} else {
nx = seg.nx;
ny = seg.ny;
}
// Push ball strictly outside cushion segment
const overlap = BALL_R - dist;
b.x += nx * overlap;
b.y += ny * overlap;
// Reflect velocity if moving into the cushion
const vn = b.vx * nx + b.vy * ny;
if (vn < 0) {
const tx = -ny;
const ty = nx;
const vt = b.vx * tx + b.vy * ty;
const e = seg.isJaw ? 0.68 : RESTITUTION_RAIL;
const newVn = -vn * e;
const newVt = vt * 0.96;
b.vx = nx * newVn + tx * newVt;
b.vy = ny * newVn + ty * newVt;
}
}
}
// Hard outer safety boundary (inside pocket leather backs)
if (b.x < WOOD_LEFT - 12) { b.x = WOOD_LEFT - 12; b.vx = Math.abs(b.vx) * 0.5; }
if (b.x > WOOD_RIGHT + 12) { b.x = WOOD_RIGHT + 12; b.vx = -Math.abs(b.vx) * 0.5; }
if (b.y < WOOD_TOP - 12) { b.y = WOOD_TOP - 12; b.vy = Math.abs(b.vy) * 0.5; }
if (b.y > WOOD_BOTTOM + 12){ b.y = WOOD_BOTTOM + 12;b.vy = -Math.abs(b.vy) * 0.5; }
}
}
function solveBallCollisions() {
const n = balls.length;
for (let i = 0; i < n; i++) {
const b1 = balls[i];
if (b1.potted || b1.potting) continue;
for (let j = i + 1; j < n; j++) {
const b2 = balls[j];
if (b2.potted || b2.potting) continue;
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const distSq = dx * dx + dy * dy;
if (distSq < BALL_D * BALL_D) {
const dist = Math.sqrt(distSq) || 0.0001;
const nx = dx / dist;
const ny = dy / dist;
// 1. Positional separation: strictly prevent overlap
const overlap = BALL_D - 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);
// 2. Momentum-conserving equal-mass elastic impulse
const rvx = b2.vx - b1.vx;
const rvy = b2.vy - b1.vy;
const velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal < 0) {
const impulse = -(1 + RESTITUTION_BALL) * velAlongNormal * 0.5;
const ix = impulse * nx;
const iy = impulse * ny;
b1.vx -= ix;
b1.vy -= iy;
b2.vx += ix;
b2.vy += iy;
// Subtle tangential throw friction between colliding phenolic balls
const tx = -ny;
const ty = nx;
const velAlongTangent = (rvx * tx + rvy * ty) * 0.015;
b1.vx += velAlongTangent * tx;
b1.vy += velAlongTangent * ty;
b2.vx -= velAlongTangent * tx;
b2.vy -= velAlongTangent * ty;
}
}
}
}
}
function updatePottingAnimations(dt) {
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potting) {
b.potTimer += dt;
const duration = 0.24;
const t = Math.min(1, b.potTimer / duration);
const ease = t * t;
b.x = b.potStartX + (b.potPocket.x - b.potStartX) * ease;
b.y = b.potStartY + (b.potPocket.y - b.potStartY) * ease;
b.scale = 1 - 0.48 * ease;
if (t >= 1) {
b.potting = false;
b.potted = true;
// Add to potted tray below the table (rolls in from right end of tray)
const slotIndex = pottedTrayBalls.length;
const targetX = TRAY_X + 31 + slotIndex * 34;
const startX = TRAY_X + TRAY_W - 22;
pottedTrayBalls.push({
id: b.id,
x: Math.max(targetX, startX),
y: TRAY_Y + TRAY_H / 2,
targetX,
vx: -340,
stripeAngle: b.stripeAngle,
rollPhaseX: 0,
rollPhaseY: 0,
settled: false
});
}
}
}
// Roll potted balls smoothly into their tray slots
for (let i = 0; i < pottedTrayBalls.length; i++) {
const tb = pottedTrayBalls[i];
if (!tb.settled) {
tb.x += tb.vx * dt;
tb.stripeAngle -= (tb.vx * dt) / BALL_R * 0.6;
if (tb.x <= tb.targetX) {
tb.x = tb.targetX;
tb.vx = 0;
tb.settled = true;
}
}
}
}
function areAllBallsStopped() {
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potting) return false;
if (!b.potted && (Math.abs(b.vx) > 0.05 || Math.abs(b.vy) > 0.05 || Math.abs(b.spinVx) > 0.05)) {
return false;
}
}
for (let i = 0; i < pottedTrayBalls.length; i++) {
if (!pottedTrayBalls[i].settled) return false;
}
return true;
}
// ============================================================================
// RENDERING BALLS, SHADOWS, CUE STICK & TRAY
// ============================================================================
function drawBall(c, x, y, r, id, stripeAngle, rollPhaseX, rollPhaseY, alpha = 1, inPocket = false) {
const def = BALL_DEFS[id];
c.save();
c.globalAlpha = alpha;
// 1. Base sphere (ivory white for cue ball and stripes, solid color for 1-8)
c.beginPath();
c.arc(x, y, r, 0, Math.PI * 2);
c.fillStyle = def.stripe ? '#F7F4E8' : def.color;
c.fill();
// Clip to ball circle for stripe, number circle, and 3D shading
c.clip();
// 2. Stripe band for balls 9-15
if (def.stripe) {
c.save();
c.translate(x, y);
c.rotate(stripeAngle);
const bandHalfWidth = r * 0.58;
const curveOffset = rollPhaseY * 0.7;
c.fillStyle = def.color;
c.beginPath();
c.moveTo(-r * 1.2, -bandHalfWidth + curveOffset);
c.quadraticCurveTo(0, -bandHalfWidth - curveOffset * 1.5, r * 1.2, -bandHalfWidth + curveOffset);
c.lineTo(r * 1.2, bandHalfWidth + curveOffset);
c.quadraticCurveTo(0, bandHalfWidth - curveOffset * 1.5, -r * 1.2, bandHalfWidth + curveOffset);
c.closePath();
c.fill();
c.restore();
}
// 3. Small White Number Circle + Crisp Number (for balls 1-15)
if (id > 0) {
const numX = x + rollPhaseX;
const numY = y + rollPhaseY;
const circleR = r * 0.45;
c.beginPath();
c.arc(numX, numY, circleR, 0, Math.PI * 2);
c.fillStyle = '#FAF8F0';
c.fill();
c.save();
c.translate(numX, numY);
c.rotate(stripeAngle * 0.18);
c.fillStyle = '#111317';
c.font = `800 ${id >= 10 ? 7.6 : 8.5}px system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`;
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(def.label, 0, 0.5);
c.restore();
}
// 4. 3D Spherical Rim Shading
const shadeGrad = c.createRadialGradient(
x - r * 0.28, y - r * 0.28, r * 0.15,
x, y, r
);
shadeGrad.addColorStop(0, 'rgba(0, 0, 0, 0)');
shadeGrad.addColorStop(0.62,'rgba(0, 0, 0, 0.08)');
shadeGrad.addColorStop(0.88,'rgba(0, 0, 0, 0.28)');
shadeGrad.addColorStop(1, 'rgba(0, 0, 0, 0.52)');
c.fillStyle = shadeGrad;
c.fillRect(x - r, y - r, r * 2, r * 2);
// If dropping into pocket, darken slightly as it descends
if (inPocket) {
c.fillStyle = `rgba(0, 0, 0, ${(1 - r / BALL_R) * 1.35})`;
c.fillRect(x - r, y - r, r * 2, r * 2);
}
// 5. Single Crisp Overhead Broadcast Highlight
c.save();
c.translate(x - r * 0.34, y - r * 0.34);
c.rotate(-Math.PI / 4);
c.beginPath();
c.ellipse(0, 0, r * 0.27, r * 0.16, 0, 0, Math.PI * 2);
c.fillStyle = 'rgba(255, 255, 255, 0.72)';
c.fill();
c.restore();
c.restore();
}
function drawBallShadow(c, x, y, r) {
// Directional shadow cast from overhead table light at center of table
const lightDx = (x - PLAY_CENTER_X) / 480;
const lightDy = (y - PLAY_CENTER_Y) / 360;
const sx = x + lightDx * 4.5 + 1.8;
const sy = y + lightDy * 4.5 + 2.8;
c.save();
// Soft outer cast shadow
const sGrad = c.createRadialGradient(sx, sy, r * 0.2, sx, sy, r * 1.35);
sGrad.addColorStop(0, 'rgba(0, 12, 5, 0.52)');
sGrad.addColorStop(0.6, 'rgba(0, 12, 5, 0.24)');
sGrad.addColorStop(1, 'rgba(0, 12, 5, 0)');
c.fillStyle = sGrad;
c.beginPath();
c.arc(sx, sy, r * 1.35, 0, Math.PI * 2);
c.fill();
// Tight ambient occlusion contact shadow right under ball
const aoGrad = c.createRadialGradient(x, y + 0.8, r * 0.1, x, y + 0.8, r * 0.98);
aoGrad.addColorStop(0, 'rgba(0, 0, 0, 0.55)');
aoGrad.addColorStop(0.75,'rgba(0, 0, 0, 0.22)');
aoGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
c.fillStyle = aoGrad;
c.beginPath();
c.arc(x, y + 0.8, r * 0.98, 0, Math.PI * 2);
c.fill();
c.restore();
}
function drawCueStick(c, cueBall, angle, distFromBall, alpha) {
if (alpha <= 0.01) return;
const stickLen = 430;
const tipRadius = 4.2;
const buttRadius = 9.5;
c.save();
c.globalAlpha = alpha;
c.translate(cueBall.x, cueBall.y);
c.rotate(angle + Math.PI); // Point backwards from cue ball along aim line
const x0 = BALL_R + distFromBall;
// Soft drop shadow of the cue stick on cloth & rail
c.save();
c.translate(3, 9);
c.beginPath();
c.moveTo(x0, -tipRadius);
c.lineTo(x0 + stickLen, -buttRadius);
c.lineTo(x0 + stickLen, buttRadius);
c.lineTo(x0, tipRadius);
c.closePath();
c.fillStyle = 'rgba(0, 0, 0, 0.36)';
c.fill();
c.restore();
// Main Cue Stick Tapered Clip Path
c.beginPath();
c.moveTo(x0, -tipRadius);
c.lineTo(x0 + stickLen, -buttRadius);
c.lineTo(x0 + stickLen, buttRadius);
c.lineTo(x0, tipRadius);
c.closePath();
c.save();
c.clip();
// 1. Blue chalked leather tip (0..5px)
c.fillStyle = '#2574b8';
c.fillRect(x0, -12, 5.5, 24);
// 2. White ivory ferrule (5.5..16px)
c.fillStyle = '#f5f2e6';
c.fillRect(x0 + 5.5, -12, 10.5, 24);
// 3. Hard rock maple shaft (16..215px)
const mapleGrad = c.createLinearGradient(0, -buttRadius, 0, buttRadius);
mapleGrad.addColorStop(0, '#d1b184');
mapleGrad.addColorStop(0.35,'#f3dcb7');
mapleGrad.addColorStop(0.7, '#cfa977');
mapleGrad.addColorStop(1, '#9e7849');
c.fillStyle = mapleGrad;
c.fillRect(x0 + 16, -12, 200, 24);
// 4. Brass joint collar (216..226px)
const brassGrad = c.createLinearGradient(0, -buttRadius, 0, buttRadius);
brassGrad.addColorStop(0, '#9c7a32');
brassGrad.addColorStop(0.4, '#fae08c');
brassGrad.addColorStop(1, '#755719');
c.fillStyle = brassGrad;
c.fillRect(x0 + 216, -12, 10, 24);
// 5. Ebony / Rosewood butt forearm & sleeve (226..422px)
const buttGrad = c.createLinearGradient(0, -buttRadius, 0, buttRadius);
buttGrad.addColorStop(0, '#2b140c');
buttGrad.addColorStop(0.35,'#4f2517');
buttGrad.addColorStop(0.7, '#261009');
buttGrad.addColorStop(1, '#140703');
c.fillStyle = buttGrad;
c.fillRect(x0 + 226, -12, 196, 24);
// Decorative splice points on forearm
c.fillStyle = '#e5cfa3';
for (let s = -1; s <= 1; s += 2) {
c.beginPath();
c.moveTo(x0 + 226, s * 2.2);
c.lineTo(x0 + 285, s * 4.5);
c.lineTo(x0 + 226, s * 6.8);
c.closePath();
c.fill();
}
// Black & white linen wrap section (300..395px)
c.fillStyle = '#1b1d20';
c.fillRect(x0 + 300, -12, 95, 24);
// Rubber bumper (422..430px)
c.fillStyle = '#111';
c.fillRect(x0 + 422, -12, 10, 24);
c.restore();
c.restore();
}
// ============================================================================
// MAIN ANIMATION LOOP & STATE MACHINE
// ============================================================================
setupRefereeRack();
let lastTimestamp = performance.now();
function tick(now) {
const rawDt = (now - lastTimestamp) / 1000;
lastTimestamp = now;
// Clamp dt so background tab switching never causes large jumps
const dt = Math.min(rawDt, 0.035);
stateTimer += dt;
if (simState === STATE_RACKED) {
// Hold freshly racked balls for 1.0 second on load / re-rack
cueOffset = 8;
cueAlpha = 1;
if (stateTimer >= 1.0) {
simState = STATE_STRIKING;
stateTimer = 0;
}
} else if (simState === STATE_STRIKING) {
// Cue stick draws back over 0.65s, holds briefly, then drives forward into cue ball
const drawbackDur = 0.62;
const pauseDur = 0.10;
const driveDur = 0.13;
const maxDrawback = 128;
if (stateTimer < drawbackDur) {
// Smooth ease-in-out drawback
const t = stateTimer / drawbackDur;
const ease = 0.5 - 0.5 * Math.cos(t * Math.PI);
cueOffset = 8 + ease * maxDrawback;
} else if (stateTimer < drawbackDur + pauseDur) {
cueOffset = 8 + maxDrawback;
} else if (stateTimer < drawbackDur + pauseDur + driveDur) {
// Explosive forward acceleration (cubic ease-in)
const t = (stateTimer - drawbackDur - pauseDur) / driveDur;
const ease = t * t * t;
cueOffset = (8 + maxDrawback) * (1 - ease);
} else {
// Impact! Launch cue ball into the rack
cueOffset = 0;
const cueBall = balls[0];
cueBall.vx = Math.cos(cueAimAngle) * plannedCueSpeed;
cueBall.vy = Math.sin(cueAimAngle) * plannedCueSpeed;
cueBall.spinVx = plannedTopSpinX;
cueBall.spinVy = plannedTopSpinY;
simState = STATE_SIMULATING;
stateTimer = 0;
}
} else if (simState === STATE_SIMULATING) {
// Follow-through & smooth retraction of the cue stick after impact
if (stateTimer < 0.08) {
cueOffset = -16 * (stateTimer / 0.08);
} else if (stateTimer < 0.42) {
const t = (stateTimer - 0.08) / 0.34;
cueOffset = -16 + t * 55;
cueAlpha = Math.max(0, 1 - t);
} else {
cueAlpha = 0;
}
stepPhysics(dt);
if (stateTimer > 0.5 && areAllBallsStopped()) {
simState = STATE_REST_WAIT;
stateTimer = 0;
}
} else if (simState === STATE_REST_WAIT) {
cueAlpha = 0;
// Wait 2.0 seconds after every ball has stopped, then re-rack and break again
if (stateTimer >= 2.0) {
setupRefereeRack();
simState = STATE_RACKED;
stateTimer = 0;
}
}
// ==========================================================================
// DRAW SCENE
// ==========================================================================
ctx.clearRect(0, 0, LOGICAL_W, LOGICAL_H);
// 1. Static Table + Tray Buffer
ctx.drawImage(tableBuffer, 0, 0, LOGICAL_W, LOGICAL_H);
// 2. Balls currently dropping into pockets (drawn inside pocket wells)
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.potting) {
drawBall(ctx, b.x, b.y, BALL_R * b.scale, b.id, b.stripeAngle, b.rollPhaseX, b.rollPhaseY, 1, true);
}
}
// 3. Soft Shadows for all active balls on the table
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (!b.potted && !b.potting) {
drawBallShadow(ctx, b.x, b.y, BALL_R);
}
}
// 4. Active Balls on the Table
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (!b.potted && !b.potting) {
drawBall(ctx, b.x, b.y, BALL_R, b.id, b.stripeAngle, b.rollPhaseX, b.rollPhaseY, 1, false);
}
}
// 5. Potted Balls in the Tray Below the Table
for (let i = 0; i < pottedTrayBalls.length; i++) {
const tb = pottedTrayBalls[i];
drawBallShadow(ctx, tb.x, tb.y, BALL_R);
drawBall(ctx, tb.x, tb.y, BALL_R, tb.id, tb.stripeAngle, tb.rollPhaseX, tb.rollPhaseY, 1, false);
}
// 6. Cue Stick
if (cueAlpha > 0 && !balls[0].potted) {
drawCueStick(ctx, balls[0], cueAimAngle, cueOffset, cueAlpha);
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();
</script>
</body>
</html>One email when a new model lands on the board or in the showcase. Unsubscribe anytime.