Pool For Fun
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Single-Player Pool with Spin</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; height: 100vh; background: #333; }
canvas { border: 2px solid #fff; background: #2a6b2a; }
#instructions { position: absolute; bottom: 10px; left: 10px; color: white; font-family: Arial, sans-serif; font-size: 12px; }
</style>
</head>
<body>
<canvas id="canvas" width="800" height="400"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
class Ball {
constructor(x, y, r, color, number = null, isCue = false) {
this.x = x;
this.y = y;
this.r = r;
this.color = color;
this.number = number;
this.isCue = isCue;
this.vx = 0;
this.vy = 0;
this.angularVelocity = 0;
this.angle = 0;
this.active = true;
}
draw(opacity = 1) {
if (!this.active) return;
ctx.globalAlpha = opacity;
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.angle);
ctx.beginPath();
ctx.arc(0, 0, this.r, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.closePath();
if (!this.isCue) {
ctx.beginPath();
ctx.arc(0, 0, this.r * 0.7, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
ctx.closePath();
}
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(this.r * 0.8, 0);
ctx.strokeStyle = this.isCue ? 'black' : 'red';
ctx.lineWidth = 1;
ctx.stroke();
if (this.number || this.isCue) {
ctx.fillStyle = this.isCue ? 'white' : 'black';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this.isCue ? 'Cue' : this.number, 0, 0);
}
ctx.restore();
ctx.globalAlpha = 1;
}
update(dt) {
if (!this.active) return;
const prevX = this.x;
const prevY = this.y;
this.vx *= 0.99;
this.vy *= 0.99;
this.angularVelocity *= 0.98;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.angle += this.angularVelocity * dt;
const spinFriction = 0.5 * Math.abs(this.angularVelocity);
if (this.angularVelocity !== 0) {
const frictionAngle = Math.atan2(this.vy, this.vx) + (this.angularVelocity > 0 ? Math.PI / 2 : -Math.PI / 2);
this.vx -= spinFriction * Math.cos(frictionAngle) * dt;
this.vy -= spinFriction * Math.sin(frictionAngle) * dt;
}
if (this.x - this.r < 0) {
this.x = this.r;
this.vx = -this.vx * 0.8;
this.angularVelocity += this.vy * 0.05;
} else if (this.x + this.r > canvas.width) {
this.x = canvas.width - this.r;
this.vx = -this.vx * 0.8;
this.angularVelocity -= this.vy * 0.05;
}
if (this.y - this.r < 0) {
this.y = this.r;
this.vy = -this.vy * 0.8;
this.angularVelocity -= this.vx * 0.05;
} else if (this.y + this.r > canvas.height) {
this.y = canvas.height - this.r;
this.vy = -this.vy * 0.8;
this.angularVelocity += this.vx * 0.05;
}
if (Math.abs(this.vx) < 0.1 && Math.abs(this.vy) < 0.1) {
this.vx = 0;
this.vy = 0;
}
if (Math.abs(this.angularVelocity) < 0.01) {
this.angularVelocity = 0;
}
// Record trail for ghost balls
if (ghostBalls.includes(this) && (Math.abs(this.vx) > 0 || Math.abs(this.vy) > 0)) {
trails.push({
x1: prevX,
y1: prevY,
x2: this.x,
y2: this.y,
color: this.isCue ? 'red' : 'yellow'
});
}
}
}
function resetGame() {
return [
new Ball(200, 200, 10, 'white', null, true),
new Ball(500, 200, 10, 'yellow', 1),
new Ball(520, 190, 10, 'blue', 2),
new Ball(520, 210, 10, 'red', 3),
new Ball(540, 180, 10, 'purple', 4),
new Ball(540, 200, 10, 'orange', 5),
new Ball(540, 220, 10, 'green', 6),
new Ball(560, 170, 10, 'maroon', 7),
new Ball(560, 190, 10, 'black', 8),
new Ball(560, 210, 10, 'yellow', 9),
new Ball(560, 230, 10, 'blue', 10),
new Ball(580, 160, 10, 'red', 11),
new Ball(580, 180, 10, 'purple', 12),
new Ball(580, 200, 10, 'orange', 13),
new Ball(580, 220, 10, 'green', 14),
new Ball(580, 240, 10, 'maroon', 15)
];
}
let balls = resetGame();
const pockets = [
{ x: 10, y: 10, r: 15 },
{ x: 790, y: 10, r: 15 },
{ x: 400, y: 10, r: 15 },
{ x: 10, y: 390, r: 15 },
{ x: 790, y: 390, r: 15 },
{ x: 400, y: 390, r: 15 }
];
let game = {
ballsPotted: 0,
blackPotted: false,
won: false,
lost: false
};
let ghostBalls = [];
let trails = [];
let previewActive = false;
let mouseX = 0, mouseY = 0;
function handleCollisions(set) {
for (let i = 0; i < set.length; i++) {
for (let j = i + 1; j < set.length; j++) {
const b1 = set[i];
const b2 = set[j];
if (!b1.active || !b2.active) continue;
const dx = b2.x - b1.x;
const dy = b2.y - b1.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < b1.r + b2.r && dist > 0) {
const angle = Math.atan2(dy, dx);
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const vx1 = b1.vx * cos + b1.vy * sin;
const vy1 = b1.vy * cos - b1.vx * sin;
const vx2 = b2.vx * cos + b2.vy * sin;
const vy2 = b2.vy * cos - b2.vx * sin;
const fv1 = vx2;
const fv2 = vx1;
b1.vx = fv1 * cos - vy1 * sin;
b1.vy = vy1 * cos + fv1 * sin;
b2.vx = fv2 * cos - vy2 * sin;
b2.vy = vy2 * cos + fv2 * sin;
const relVx = b2.vx - b1.vx;
const relVy = b2.vy - b1.vy;
const nx = dx / dist;
const ny = dy / dist;
const tangentX = -ny;
const tangentY = nx;
const relVelTangent = relVx * tangentX + relVy * tangentY;
const impulse = relVelTangent / (2 / b1.r);
b1.angularVelocity += impulse / b1.r;
b2.angularVelocity -= impulse / b2.r;
const overlap = (b1.r + b2.r - dist) / 2;
b1.x -= overlap * nx;
b1.y -= overlap * ny;
b2.x += overlap * nx;
b2.y += overlap * ny;
}
}
}
}
function checkPockets(set) {
set.forEach(ball => {
if (!ball.active) return;
pockets.forEach(p => {
const dx = ball.x - p.x;
const dy = ball.y - p.y;
if (Math.sqrt(dx * dx + dy * dy) < p.r) {
ball.active = false;
if (set === balls && ball.isCue) {
ball.x = 200;
ball.y = 200;
ball.vx = 0;
ball.vy = 0;
ball.angularVelocity = 0;
ball.angle = 0;
ball.active = true;
game.lost = true;
} else if (set === balls) {
updateScore(ball);
}
}
});
});
}
function updateScore(ball) {
const n = ball.number;
if (n === 8) {
game.blackPotted = true;
if (game.ballsPotted === 14) {
game.won = true;
} else {
game.lost = true;
}
} else if (n >= 1 && n <= 15) {
game.ballsPotted++;
}
}
function areMoving(set) {
return set.some(b => b.active && (Math.abs(b.vx) > 0 || Math.abs(b.vy) > 0 || Math.abs(b.angularVelocity) > 0));
}
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
});
document.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'r' && (game.won || game.lost)) {
balls = resetGame();
game = { ballsPotted: 0, blackPotted: false, won: false, lost: false };
previewActive = false;
ghostBalls = [];
trails = [];
return;
}
if (game.won || game.lost) return;
if (e.key.toLowerCase() === 'e' && !areMoving(balls) && !previewActive) {
const cue = balls[0];
const dx = mouseX - cue.x;
const dy = mouseY - cue.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5) return;
const maxDist = 200;
const maxForce = 800;
const force = Math.min((dist / maxDist) * maxForce, maxForce);
const angle = Math.atan2(dy, dx);
cue.vx = -Math.cos(angle) * force;
cue.vy = -Math.sin(angle) * force;
const offsetX = dx / cue.r;
const offsetY = dy / cue.r;
const spinFactor = 0.5;
cue.angularVelocity = -spinFactor * (offsetY * cue.vx - offsetX * cue.vy) / (cue.r * force + 1e-6);
} else if (e.key.toLowerCase() === 'q' && !areMoving(balls) && !previewActive) {
previewActive = true;
ghostBalls = balls.map(b => new Ball(b.x, b.y, b.r, b.color, b.number, b.isCue));
trails = []; // Reset trails for new preview
const cue = ghostBalls[0];
const dx = mouseX - cue.x;
const dy = mouseY - cue.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5) {
previewActive = false;
ghostBalls = [];
trails = [];
return;
}
const maxDist = 200;
const maxForce = 800;
const force = Math.min((dist / maxDist) * maxForce, maxForce);
const angle = Math.atan2(dy, dx);
cue.vx = -Math.cos(angle) * force;
cue.vy = -Math.sin(angle) * force;
const offsetX = dx / cue.r;
const offsetY = dy / cue.r;
const spinFactor = 0.5;
cue.angularVelocity = -spinFactor * (offsetY * cue.vx - offsetX * cue.vy) / (cue.r * force + 1e-6);
}
});
function drawTrails() {
trails.forEach(trail => {
ctx.beginPath();
ctx.moveTo(trail.x1, trail.y1);
ctx.lineTo(trail.x2, trail.y2);
ctx.strokeStyle = trail.color;
ctx.lineWidth = 1;
ctx.stroke();
});
}
function drawPreviewLine() {
if (areMoving(balls) || previewActive) return;
const cue = balls[0];
const dx = mouseX - cue.x;
const dy = mouseY - cue.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5) return;
const maxDist = 200;
const maxForce = 800;
const force = Math.min((dist / maxDist) * maxForce, maxForce);
const angle = Math.atan2(dy, dx);
let vx = -Math.cos(angle) * force;
let vy = -Math.sin(angle) * force;
let x = cue.x;
let y = cue.y;
const maxLen = 200 * (force / maxForce);
const ratio = Math.min(dist / maxDist, 1);
let r, g, b;
if (ratio <= 0.5) {
r = Math.round(510 * ratio);
g = 255;
b = 0;
} else {
r = 255;
g = Math.round(255 * (1 - (ratio - 0.5) * 2));
b = 0;
}
ctx.beginPath();
ctx.moveTo(x, y);
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
ctx.lineWidth = 2;
let minT = maxLen / Math.sqrt(vx * vx + vy * vy);
let target = null;
let tvx = 0, tvy = 0;
let tx = 0, ty = 0;
for (let i = 1; i < balls.length; i++) {
if (!balls[i].active) continue;
const dx = balls[i].x - x;
const dy = balls[i].y - y;
const a = vx * vx + vy * vy;
const b = 2 * (dx * vx + dy * vy);
const c = dx * dx + dy * dy - (cue.r + balls[i].r) ** 2;
const disc = b * b - 4 * a * c;
if (disc >= 0) {
const t = (-b - Math.sqrt(disc)) / (2 * a);
if (t > 0 && t < minT) {
minT = t;
target = balls[i];
const cx = x + t * vx;
const cy = y + t * vy;
const angle = Math.atan2(cy - balls[i].y, cx - balls[i].x);
const sin = Math.sin(angle);
const cos = Math.cos(angle);
const vx1 = vx * cos + vy * sin;
const vy1 = vy * cos - vx * sin;
const vx2 = 0;
const vy2 = 0;
const fv2 = vx1;
tvx = fv2 * cos - vy2 * sin;
tvy = vy2 * cos + fv2 * sin;
tx = cx;
ty = cy;
}
}
}
const len = minT * Math.sqrt(vx * vx + vy * vy);
x += (len < maxLen ? len : maxLen) * Math.cos(angle);
y += (len < maxLen ? len : maxLen) * Math.sin(angle);
ctx.lineTo(x, y);
ctx.stroke();
if (target) {
ctx.beginPath();
ctx.moveTo(tx, ty);
const tLen = 100;
ctx.lineTo(tx + tLen * tvx / Math.sqrt(tvx * tvx + tvy * tvy), ty + tLen * tvy / Math.sqrt(tvx * tvx + tvy * tvy));
ctx.strokeStyle = 'rgba(255, 255, 0, 0.5)';
ctx.stroke();
}
}
function drawPockets() {
pockets.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = 'black';
ctx.fill();
ctx.closePath();
});
}
function drawGame() {
ctx.fillStyle = 'white';
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(`Balls Potted: ${game.ballsPotted}/14`, 10, 10);
if (game.won) {
ctx.fillText('You Win! Press R to restart', 10, 30);
} else if (game.lost) {
ctx.fillText('Game Over! Press R to restart', 10, 30);
}
}
let lastTime = 0;
function loop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.033);
lastTime = timestamp;
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawPockets();
drawTrails(); // Draw trails before ghost balls
balls.forEach(b => b.update(dt));
checkPockets(balls);
handleCollisions(balls);
balls.forEach(b => b.draw());
if (previewActive) {
ghostBalls.forEach(b => b.update(dt));
checkPockets(ghostBalls);
handleCollisions(ghostBalls);
ghostBalls.forEach(b => b.draw(0.5));
if (!areMoving(ghostBalls)) {
previewActive = false;
ghostBalls = [];
trails = []; // Clear trails when preview ends
}
} else {
drawPreviewLine();
}
drawGame();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>