Billiards

Drag back from the cue ball to aim & power, release to shoot.

In JS, get canvas, ctx, resize. Ball colors: Standard pool: 1: yellow, 2: blue, 3: red, 4: purple, 5: orange, 6: green, 7: maroon, 8: black, 9-15: stripes (yellow/white, etc.). For simplicity, we can use solids for 1-7, black 8, stripes 9-15. But drawing stripes is easy. Let's do it for authenticity. Ball class: constructor(number, x, y, r) { number (0 cue, 1-15), pos, vel, r, active, color, stripeColor } draw(ctx): draw base circle, then stripe if number >=9, then number circle, then highlight. Colors array: const BALL_COLORS = [ '#ffffff', // 0 cue '#f9d71c', // 1 yellow '#1c5bc9', // 2 blue '#c91c1c', // 3 red '#6a1cc9', // 4 purple '#f98f1c', // 5 orange '#1cc91c', // 6 green '#7a1c1c', // 7 maroon '#111111', // 8 black '#f9d71c', '#1c5bc9', '#c91c1c', '#6a1cc9', '#f98f1c', '#1cc91c' // 9-15 ]; For stripes: draw white background, then colored band. Number text color: white for dark balls (8 and maybe 4,7), black for light. Number rendering: draw white circle with number. For 8, use white number on black. For others, black number on white. Physics update: function update(dt) { let moving = false; const steps = 4; const sdt = dt/steps; for (let s=0; s stopThreshold) moving = true; isMoving = moving; if (!isMoving && cueBallPottedPending) { respawnCueBall(); cueBallPottedPending=false; } if (!isMoving && allObjectBallsPotted) { showWin(); resetGame(); } } Wait, cue ball potted: should respawn after motion stops. Use a flag. Pocket detection in substeps: if ball center distance to pocket < pocketRadius - ballRadius*0.25, pot. But if we pot during substep, we should still process other balls. Set active=false, vel=0, track. For cue ball, set flag. Rail detection: for each active ball: // left/right rails if (b.pos.x < b.r) { // check if in any pocket zone; if not, reflect if (!inPocketZone(b.pos)) { b.pos.x = b.r; b.vel.x = Math.abs(b.vel.x)*railRestitution; } } else if (b.pos.x > tableW - b.r) { if (!inPocketZone(b.pos)) { b.pos.x = tableW - b.r; b.vel.x = -Math.abs(b.vel.x)*railRestitution; } } // top/bottom rails if (b.pos.y < b.r) { if (!inPocketZone(b.pos)) { b.pos.y = b.r; b.vel.y = Math.abs(b.vel.y)*railRestitution; } } else if (b.pos.y > tableH - b.r) { if (!inPocketZone(b.pos)) { b.pos.y = tableH - b.r; b.vel.y = -Math.abs(b.vel.y)*railRestitution; } } But inPocketZone should be true if ball is within any pocket distance. However, if ball is just bouncing near rail but not in pocket, inPocketZone false. Good. But for side pockets on long rails, the cutout is a semicircle. Our simple clamp might reflect the ball before it fully enters if the ball's x is < r but y is near tableH/2 (side pocket). inPocketZone handles. But also, if ball approaches side pocket from inside, its y might be slightly less than r? The side pocket center is at (tableW/2, 0) and (tableW/2, tableH). A ball entering side pocket will have y < pocketRadius (or y > tableH - pocketRadius) and x near tableW/2. So inPocketZone true. Good. But what about the rail segments between pockets? The top rail has three segments: left corner to side, side to right corner. Our clamp on y < r works for the entire top rail except pocket zones. Good. Similarly bottom. Left/right rails: full segments except corners. Good. One issue: a ball entering corner pocket may have x < r and y < r; inPocketZone true. Good. Pocket radius: corner > side. Use cornerR = b.r * 1.9, sideR = b.r * 1.6. Let's ensure pocket opening is wide enough. inPocketZone(pos): for each pocket: if distance(pos, pocket) < pocket.r - b.r*0.3 return true return false Actually pocket.r should be larger than b.r. Let's compute pocket.r = b.r * 2.0 for corners, b.r * 1.8 for sides. Then detection threshold = pocket.r - b.r*0.2. Ball-ball collision: for i in 0..n-1: for j in i+1..n-1: if both active: resolve. Vector class or use plain objects? Use simple functions. Let's define vector methods as functions to avoid class overhead? I'll use a small Vec2 class for clarity. Inline in JS. class Vec2 { constructor(x,y){this.x=x;this.y=y;} add(v){this.x+=v.x;this.y+=v.y;} addScaled(v,s){this.x+=v.x*s;this.y+=v.y*s;} sub(v){this.x-=v.x;this.y-=v.y;} scale(s){this.x*=s;this.y*=s;} clone(){return new Vec2(this.x,this.y);} get length(){return Math.hypot(this.x,this.y);} } Ball class: class Ball { constructor(num, x, y, r) { this.num = num; this.pos = new Vec2(x,y); this.vel = new Vec2(0,0); this.r = r; this.active = true; this.color = COLORS[num]; } } Drawing balls: function drawBall(ctx, b) { ctx.save(); ctx.translate(b.pos.x, b.pos.y); // shadow ctx.beginPath(); ctx.arc(2,3,b.r,0,Math.PI*2); ctx.fillStyle='rgba(0,0,0,0.4)'; ctx.fill(); // base ctx.beginPath(); ctx.arc(0,0,b.r,0,Math.PI*2); if (b.num >= 9) { // stripe: white base, color band ctx.fillStyle='#f0f0f0'; ctx.fill(); ctx.beginPath(); ctx.rect(-b.r, -b.r*0.45, b.r*2, b.r*0.9); ctx.fillStyle = b.color; ctx.fill(); // clip circle ctx.beginPath(); ctx.arc(0,0,b.r,0,Math.PI*2); ctx.clip(); // re-clip? Actually after drawing band, need to ensure it's inside circle. Use clip before drawing band. } else { ctx.fillStyle = b.color; ctx.fill(); } // number circle if (b.num > 0) { ctx.beginPath(); ctx.arc(0,0,b.r*0.5,0,Math.PI*2); ctx.fillStyle = '#f0f0f0'; ctx.fill(); ctx.fillStyle = (b.num===8 || b.num===4 || b.num===7) ? '#fff' : '#111'; // Actually 8 ball: white circle on black? Standard 8 ball has white circle with black number? Wait, 8 ball is black with white circle and black number? No, the 8 ball is solid black with a white circle containing the number 8 in black? Let me recall: The 8 ball is black with a white circle and the number 8 printed in black inside the white circle. Yes. So for 8: base black, white number circle, black text. For 4 and 7 (purple and maroon), white text on dark might be okay, but standard is white circle with black number for all except maybe black ball? Actually all object balls have a white circle with the number in black? I think so. For 8 ball, the circle is white and number black. For others, white circle, black number. So use black text always. ctx.font = `bold ${b.r*0.55}px sans-serif`; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText(String(b.num), 0, 1); } // highlight ctx.beginPath(); ctx.arc(-b.r*0.3,-b.r*0.3,b.r*0.25,0,Math.PI*2); ctx.fillStyle='rgba(255,255,255,0.35)'; ctx.fill(); ctx.restore(); } Wait, stripe clipping: better to draw stripe with clip. Let's implement: ctx.beginPath(); ctx.arc(0,0,b.r,0,Math.PI*2); ctx.save(); ctx.clip(); ctx.fillStyle='#f0f0f0'; ctx.fill(); ctx.fillStyle=b.color; ctx.fillRect(-b.r, -b.r*0.45, b.r*2, b.r*0.9); ctx.restore(); Then number circle. For 8 ball, we don't want a white circle? Actually yes. But the base is black, so white circle shows. Good. Cue stick drawing: function drawCue(ctx) { if (isMoving || !aiming) return; // draw only while aiming const dir = aimDir; // normalized const start = new Vec2(cueBall.pos.x - dir.x * (cueBall.r + 5), cueBall.pos.y - dir.y * (cueBall.r + 5)); const len = 200 + power / maxPower * 100; const end = new Vec2(start.x - dir.x * len, start.y - dir.y * len); // Wait, if aimDir is shot direction, the cue stick should extend backward from cue ball opposite to aimDir. So tip at cue ball, butt away. start = cueBall.pos - dir * (r+2) (tip slightly behind center? Actually tip touches ball at point of contact, which is behind center along aim direction? No, if shooting in direction dir, the cue tip strikes the cue ball from behind, i.e., from -dir. The tip is at cueBall.pos - dir * r. The stick extends from tip backward along -dir. So start = cueBall.pos - dir * r, end = start - dir * len. // We'll draw a tapered line. const grad = ctx.createLinearGradient(start.x, start.y, end.x, end.y); grad.addColorStop(0, '#f5deb3'); grad.addColorStop(0.3, '#d2a679'); grad.addColorStop(1, '#5c3a1e'); ctx.strokeStyle = grad; ctx.lineWidth = 8; ctx.lineCap='round'; ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.stroke(); // cue tip ctx.strokeStyle='#222'; ctx.lineWidth=6; ctx.beginPath(); ctx.moveTo(start.x,start.y); ctx.lineTo(start.x + dir.x*8, start.y + dir.y*8); ctx.stroke(); } Wait, if aimDir is shot direction, the stick points in -aimDir? Actually the cue stick is positioned behind the cue ball and pushes it forward. So the stick points along aimDir (from butt to tip). The tip is at cue ball. So line from butt to tip is in direction aimDir. Butt = cueBall.pos - aimDir * len. Tip = cueBall.pos - aimDir * r. So draw from butt to tip. Good. Aim line / ghost: Draw a thin line from cue ball in aimDir to indicate trajectory. Power bar: Draw a bar near bottom or near cue ball. Input: function getMouse(e) { const rect = canvas.getBoundingClientRect(); return new Vec2(e.clientX - rect.left - offsetX, e.clientY - rect.top - offsetY); } offsetX = (canvas.width - tableW)/2, offsetY = (canvas.height - tableH)/2. Mouse down: if (isMoving) return; const mp = getMouse(e); if (distance(mp, cueBall.pos) < cueBall.r * 3) { aiming = true; dragStart = mp.clone(); } Actually if user clicks cue ball, start. Or if click anywhere and cue ball is near, start. Let's just start if click within cue ball radius * 2. Mouse move: if (aiming) { mousePos = getMouse(e); update aim. } Mouse up: if (aiming) { shoot(); aiming = false; } Touch events: preventDefault. Shoot: const pull = new Vec2(dragStart.x - mousePos.x, dragStart.y - mousePos.y); // vector from mouse to start? Wait standard: drag back from cue ball. Start at cue ball. Mouse moves away (backward). So vector from cue ball to mouse = mouse - cueBall. Shot direction = -(mouse - cueBall) = cueBall - mouse. Power = min(distance * scale, max). So: const dx = dragStart.x - mousePos.x; // dragStart is cue ball pos const dy = dragStart.y - mousePos.y; const dist = Math.hypot(dx,dy); if (dist > 5) { const power = Math.min(dist * powerScale, maxPower); cueBall.vel = new Vec2(dx/dist * power, dy/dist * power); } This means if mouse is left of cue ball (drag left), dx positive, shoot right. Good. Aim display: aimDir = normalize(cueBall.pos - mousePos) = (dx/dist, dy/dist). power = min(dist * powerScale, maxPower). To avoid immediate shot on tiny drag, require dist > 5. Good. Initial rack: function initBalls() { balls = []; const r = tableW / 80; const d = r * 2; const apexX = tableW * 0.75; const apexY = tableH / 2; // 15 balls triangle let count = 0; for (let row=0; row<5; row++) { for (let col=0; col<=row; col++) { const x = apexX + row * d * 0.866; const y = apexY + (col - row/2) * d; balls.push(new Ball(count+1, x, y, r)); count++; } } // cue ball cueBall = new Ball(0, tableW * 0.25, tableH/2, r); balls.push(cueBall); } Spacing: d * 0.866 is horizontal spacing for triangular lattice. Vertical spacing d. Use d * 0.98 to compress slightly. Actually for equilateral triangle, horizontal spacing = d * cos(30) = d * 0.866. Use 0.866 * d * 0.98 to ensure slight overlap that resolves quickly. Pockets definition: const pockets = [ {pos: new Vec2(0,0), r: cornerR}, {pos: new Vec2(tableW/2, 0), r: sideR}, {pos: new Vec2(tableW,0), r: cornerR}, {pos: new Vec2(0,tableH), r: cornerR}, {pos: new Vec2(tableW/2,tableH), r: sideR}, {pos: new Vec2(tableW,tableH), r: cornerR} ]; Resizing: On resize, recompute table dimensions, ball positions? If we resize during game, it's tricky. Better to just resize canvas and recompute table/balls from scratch or scale. Simpler: on resize, reset game to initial positions with new dimensions. But that interrupts. For a single-page demo, resetting on resize is acceptable. Or we can scale coordinates. Let's reset on resize. Use resize listener with debounce. Actually, to keep it robust, compute table dimensions once on load and don't reset on resize, just redraw. But canvas size changes. We can keep table size fixed relative to canvas? If window resized, table may no longer fit. Let's recompute table size and scale ball positions proportionally to new table size. That's more complex. I'll just reset on resize after a small delay. For a demo, fine. Alternatively, set canvas to a fixed logical size and use CSS to scale? But requirement says render visible content first frame. Fixed canvas with CSS scaling is okay. Let's do: canvas.width = 1200, canvas.height = 700; tableW=1000, tableH=500. Then CSS: canvas { width: 100%; height: 100%; object-fit: contain; } But object-fit for canvas works. However, mouse coordinates need to be mapped to canvas internal resolution. This is easier: table size fixed, rendering consistent, responsive via CSS. But on very small screens, internal resolution may be too high. We can set canvas.width = window.innerWidth, canvas.height = window.innerHeight and compute table. Then reset on resize. I think dynamic is better for crispness. Let's go with dynamic: on load, set canvas size to window, init. On resize, re-init. Use a debounce. Stop threshold: if velocity length < 5 (sim units per second), set to 0. Since dt ~0.016, speed after friction decays. Use threshold = 8. Max speed: 2500. With tableW ~1000, crossing table takes <0.5s. Reasonable. Power scale: 8. Max drag distance ~300 px -> max power 2400. Friction: use v *= (1 - 0.6 * sdt). With sdt ~0.004, factor 0.9976. Speed 2000 -> after 1s (250 steps) -> 2000*0.9976^250 ≈ 1100. Maybe too slow? Actually pool balls roll quite far. Let's tune: friction factor per second = 0.98? Use v *= Math.exp(-1.2 * dt). For dt=0.016, factor=0.981. Speed 2000 -> after 1s -> 2000*0.981^62 ≈ 700. After 2s -> 250. After 3s -> 90. Good. Substep dt = 0.016/4 = 0.004. factor per substep = exp(-1.2*0.004)=0.9952. Rail restitution 0.85, ball restitution 0.92. Position correction: overlap/2. Potted balls display: Draw small potted balls in a tray at top or bottom. Or just update status text. Let's draw potted balls along the bottom rail outside the table. Since canvas is full window, we have space. Draw potted balls sorted by number in a row below table. Status text: show whose turn / balls potted count. Also show "Balls moving - wait" or "Aim and shoot". Win condition: