<div id="game-container">
<canvas id="pinballCanvas"></canvas>
<div class="ui-layer">
<div class="score-display" id="score">0</div>
<div class="ball-display" id="balls">Ball 1 / 3</div>
</div>
<div class="game-over-overlay" id="gameOver">
<div class="game-over-text">GAME OVER</div>
<div class="final-score" id="finalScore">Score: 0</div>
<br>
<div style="color: #666; font-size: 0.9rem;">Reload to play again</div>
</div>
<div class="controls-hint">AUTO-PILOT ENABLED</div>
</div>
<script>
const canvas = document.getElementById('pinballCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('game-container');
const scoreEl = document.getElementById('score');
const ballsEl = document.getElementById('balls');
const gameOverEl = document.getElementById('gameOver');
const finalScoreEl = document.getElementById('finalScore');
// Physics Constants
const GRAVITY = 0.25; // How heavy the ball feels
const FRICTION = 0.99; // Air resistance
const WALL_BOUNCE = 0.6; // Energy loss on walls
const BUMPER_BOUNCE = 1.3; // Energy gain on bumpers
const FLIPPER_FORCE = 0.45; // Rotational speed force
const SUBSTEPS = 8; // Physics steps per frame for stability
// Game State
let width, height;
let score = 0;
let balls = 3;
let currentBall = 1;
let gameActive = true;
let shakeTimer = 0;
function resize() {
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
width = canvas.width;
height = canvas.height;
initTable();
}
window.addEventListener('resize', resize);
const Vec2 = {
add: (v1, v2) => ({ x: v1.x + v2.x, y: v1.y + v2.y }),
sub: (v1, v2) => ({ x: v1.x - v2.x, y: v1.y - v2.y }),
mult: (v, s) => ({ x: v.x * s, y: v.y * s }),
dot: (v1, v2) => v1.x * v2.x + v1.y * v2.y,
mag: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
norm: (v) => {
const m = Math.sqrt(v.x * v.x + v.y * v.y);
return m === 0 ? { x: 0, y: 0 } : { x: v.x / m, y: v.y / m };
},
dist: (v1, v2) => Math.sqrt((v1.x - v2.x) ** 2 + (v1.y - v2.y) ** 2),
rotate: (v, angle) => ({
x: v.x * Math.cos(angle) - v.y * Math.sin(angle),
y: v.x * Math.sin(angle) + v.y * Math.cos(angle)
})
};
class Ball {
constructor(x, y) {
this.pos = { x, y };
this.vel = { x: 0, y: 0 };
this.radius = 12; // Base size
this.mass = 1;
}
update() {
// Apply Gravity
this.vel.y += GRAVITY;
this.vel = Vec2.mult(this.vel, FRICTION);
// Update Position
this.pos = Vec2.add(this.pos, this.vel);
}
draw() {
// Shadow
ctx.beginPath();
ctx.arc(this.pos.x + 4, this.pos.y + 4, this.radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
// Chrome Effect
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
// Radial gradient for metallic sphere look
const grad = ctx.createRadialGradient(
this.pos.x - this.radius * 0.3,
this.pos.y - this.radius * 0.3,
this.radius * 0.1,
this.pos.x,
this.pos.y,
this.radius
);
grad.addColorStop(0, '#fff'); // Highlight
grad.addColorStop(0.2, '#eee'); // Light Metal
grad.addColorStop(0.5, '#889'); // Mid Metal
grad.addColorStop(1, '#222'); // Shadow Edge
ctx.fillStyle = grad;
ctx.fill();
// Specular highlight stroke
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
class Bumper {
constructor(x, y, radius, scoreVal, color) {
this.pos = { x, y };
this.radius = radius;
this.scoreVal = scoreVal;
this.color = color;
this.flashTime = 0;
}
draw() {
const isFlashing = this.flashTime > 0;
if (isFlashing) this.flashTime--;
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
// Glow effect
if (isFlashing) {
ctx.shadowColor = this.color;
ctx.shadowBlur = 30;
ctx.fillStyle = '#fff'; // Flash white center
} else {
ctx.shadowColor = this.color;
ctx.shadowBlur = 10;
ctx.fillStyle = this.color; // Normal color
}
ctx.fill();
ctx.shadowBlur = 0; // Reset
// Rim
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// Detail
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius * 0.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
}
}
class Flipper {
constructor(x, y, type, length, restAngle, activeAngle) {
this.pivot = { x, y };
this.type = type; // 'left' or 'right'
this.length = length;
this.restAngle = restAngle;
this.activeAngle = activeAngle;
this.currentAngle = restAngle;
this.active = false;
this.angularVelocity = 0;
this.width = 15;
}
update() {
const target = this.active ? this.activeAngle : this.restAngle;
const diff = target - this.currentAngle;
// Simple spring-like movement for angle
if (this.active) {
this.angularVelocity += diff * 0.2;
} else {
this.angularVelocity += diff * 0.1;
}
this.angularVelocity *= 0.6; // Damping
this.currentAngle += this.angularVelocity;
}
getTip() {
return {
x: this.pivot.x + Math.cos(this.currentAngle) * this.length,
y: this.pivot.y + Math.sin(this.currentAngle) * this.length
};
}
draw() {
const tip = this.getTip();
ctx.save();
ctx.lineCap = 'round';
ctx.lineWidth = this.width;
ctx.strokeStyle = this.active ? '#fff' : '#ddd';
// Shadow
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 5;
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
ctx.beginPath();
ctx.moveTo(this.pivot.x, this.pivot.y);
ctx.lineTo(tip.x, tip.y);
ctx.stroke();
// Pivot circle
ctx.fillStyle = '#444';
ctx.beginPath();
ctx.arc(this.pivot.x, this.pivot.y, this.width/2, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
}
let ball;
let bumpers = [];
let flippers = [];
let walls = []; // Array of line segments {p1, p2}
function initTable() {
if (!width || !height) return;
// Reset arrays
bumpers = [];
flippers = [];
walls = [];
// Define Table Geometry
// 1. Walls (Simple Box with angled bottom)
const margin = 20;
const drainGap = 100;
// Outer Frame path points
const tl = { x: margin, y: margin };
const tr = { x: width - margin, y: margin };
const bl = { x: margin, y: height - 120 };
const br = { x: width - margin, y: height - 120 };
// Lane entrances
const bl_in = { x: width/2 - drainGap/2 - 40, y: height - 120 };
const br_in = { x: width/2 + drainGap/2 + 40, y: height - 120 };
// Flipper pivots
const f_pivot_y = height - 100;
const f_pivot_left = { x: width/2 - drainGap/2 - 10, y: f_pivot_y };
const f_pivot_right = { x: width/2 + drainGap/2 + 10, y: f_pivot_y };
// Create Walls segments
walls.push({ p1: {x: margin, y: height}, p2: tl }); // Left
walls.push({ p1: tl, p2: tr }); // Top
walls.push({ p1: tr, p2: {x: width-margin, y: height} }); // Right
// Slanted walls to flippers (The "In-lanes")
walls.push({ p1: {x: margin, y: height*0.7}, p2: f_pivot_left });
walls.push({ p1: {x: width-margin, y: height*0.7}, p2: f_pivot_right });
// Top Arch (Approximated with segments for simplicity in collision)
// Creating a "Corner" cut at top
walls.push({ p1: tl, p2: {x: margin + 100, y: margin + 100} }); // Top Left Corner
walls.push({ p1: tr, p2: {x: width - margin - 100, y: margin + 100} }); // Top Right Corner
// Add Bumpers
bumpers.push(new Bumper(width * 0.5, height * 0.3, 25, 100, '#0ff')); // Top Center
bumpers.push(new Bumper(width * 0.3, height * 0.4, 20, 50, '#f0f')); // Left
bumpers.push(new Bumper(width * 0.7, height * 0.4, 20, 50, '#f0f')); // Right
bumpers.push(new Bumper(width * 0.5, height * 0.55, 15, 250, '#f80')); // Center Small
// Add Flippers
const flipperLen = 80;
// Left Flipper: rests at ~30 deg, active at ~-30 deg
flippers.push(new Flipper(f_pivot_left.x, f_pivot_left.y, 'left', flipperLen, Math.PI * 0.15, -Math.PI * 0.2));
// Right Flipper: rests at ~150 deg, active at ~210 deg
flippers.push(new Flipper(f_pivot_right.x, f_pivot_right.y, 'right', flipperLen, Math.PI * 0.85, Math.PI * 1.2));
// Reset Ball
resetBall();
}
function resetBall() {
if (balls <= 0) {
endGame();
return;
}
// Launch from plunger lane (simulated)
ball = new Ball(width - 40, height - 100);
// Shoot up and left
ball.vel = { x: -5 - Math.random() * 5, y: -20 - Math.random() * 5 };
// Update UI
ballsEl.innerText = `Ball ${currentBall} / 3`;
}
function endGame() {
gameActive = false;
finalScoreEl.innerText = `Score: ${Math.floor(score)}`;
gameOverEl.classList.add('visible');
}
function triggerShake() {
container.classList.remove('shake');
void container.offsetWidth; // trigger reflow
container.classList.add('shake');
}
// Point to Line Segment Distance/Collision
function checkLineCollision(ball, p1, p2) {
const vLine = Vec2.sub(p2, p1);
const vBall = Vec2.sub(ball.pos, p1);
const lineLen = Vec2.mag(vLine);
const lineUnit = Vec2.norm(vLine);
// Project ball onto line
const proj = Vec2.dot(vBall, lineUnit);
// Closest point on segment
let closest;
if (proj < 0) closest = p1;
else if (proj > lineLen) closest = p2;
else closest = Vec2.add(p1, Vec2.mult(lineUnit, proj));
const distVec = Vec2.sub(ball.pos, closest);
const dist = Vec2.mag(distVec);
if (dist < ball.radius) {
// Collision!
const normal = Vec2.norm(distVec);
// Push out
const overlap = ball.radius - dist;
ball.pos = Vec2.add(ball.pos, Vec2.mult(normal, overlap));
// Reflect velocity
const dot = Vec2.dot(ball.vel, normal);
// Only bounce if moving into the wall
if (dot < 0) {
const reflect = Vec2.sub(ball.vel, Vec2.mult(normal, 2 * dot));
ball.vel = Vec2.mult(reflect, WALL_BOUNCE);
// Add a tiny bit of random noise to prevent infinite loops
ball.vel.x += (Math.random() - 0.5);
}
return true;
}
return false;
}
// Ball to Circle (Bumper) Collision
function checkCircleCollision(ball, circle) {
const dist = Vec2.dist(ball.pos, circle.pos);
const minDist = ball.radius + circle.radius;
if (dist < minDist) {
const normal = Vec2.norm(Vec2.sub(ball.pos, circle.pos));
// Push out
ball.pos = Vec2.add(circle.pos, Vec2.mult(normal, minDist));
// Reflect
const dot = Vec2.dot(ball.vel, normal);
if (dot < 0) {
const reflect = Vec2.sub(ball.vel, Vec2.mult(normal, 2 * dot));
ball.vel = Vec2.mult(reflect, BUMPER_BOUNCE);
}
// Gameplay effects
score += circle.scoreVal;
scoreEl.innerText = Math.floor(score);
circle.flashTime = 5; // Frames to flash
// Shake if hit hard
if (Vec2.mag(ball.vel) > 10) triggerShake();
return true;
}
return false;
}
// Ball to Flipper Collision
function checkFlipperCollision(ball, flipper) {
// Treat flipper as a thick line segment
const tip = flipper.getTip();
const p1 = flipper.pivot;
const p2 = tip;
// Standard line check first
const vLine = Vec2.sub(p2, p1);
const vBall = Vec2.sub(ball.pos, p1);
const lineLen = Vec2.mag(vLine);
const lineUnit = Vec2.norm(vLine);
const proj = Vec2.dot(vBall, lineUnit);
let closest;
if (proj < 0) closest = p1;
else if (proj > lineLen) closest = p2;
else closest = Vec2.add(p1, Vec2.mult(lineUnit, proj));
const distVec = Vec2.sub(ball.pos, closest);
const dist = Vec2.mag(distVec);
const thickness = flipper.width / 2;
const minDist = ball.radius + thickness;
if (dist < minDist) {
const normal = Vec2.norm(distVec);
// Push out
ball.pos = Vec2.add(closest, Vec2.mult(normal, minDist));
// Velocity Reflection
const dot = Vec2.dot(ball.vel, normal);
if (dot < 0) {
let reflect = Vec2.sub(ball.vel, Vec2.mult(normal, 2 * dot));
ball.vel = Vec2.mult(reflect, 0.4); // Less bounce on flipper surface normally
// Add Flipper Motion Energy
// If flipper is moving towards ball, add huge boost
if (flipper.active) {
// Calculate tangent velocity of flipper at impact point
const distFromPivot = Vec2.dist(closest, flipper.pivot);
const tipSpeed = 20 * FLIPPER_FORCE * (distFromPivot / flipper.length);
// Normal of flipper face
// Depending on Left or Right flipper, the normal points up/in
// Simplified: add velocity in the direction of the normal
ball.vel = Vec2.add(ball.vel, Vec2.mult(normal, tipSpeed));
triggerShake();
}
}
return true;
}
return false;
}
function updateAI() {
if (!ball) return;
// Reset flippers
flippers.forEach(f => f.active = false);
// Only care if ball is in lower half and moving down
if (ball.pos.y > height * 0.6 && ball.vel.y > -2) {
// Left Flipper Logic
if (ball.pos.x < width * 0.5) {
const f = flippers[0];
// Prediction: will it cross the flipper line?
// Simple distance check for now
if (Vec2.dist(ball.pos, f.pivot) < f.length + 30) {
f.active = true;
}
}
// Right Flipper Logic
else {
const f = flippers[1];
if (Vec2.dist(ball.pos, f.pivot) < f.length + 30) {
f.active = true;
}
}
}
}
function loop() {
if (!gameActive) return;
// Clear Canvas
ctx.clearRect(0, 0, width, height);
// Draw Background Elements (Lanes, Deco)
ctx.strokeStyle = '#333';
ctx.lineWidth = 4;
// Draw lane lines
ctx.beginPath();
ctx.moveTo(width*0.1, 0); ctx.lineTo(width*0.1, height);
ctx.moveTo(width*0.9, 0); ctx.lineTo(width*0.9, height);
ctx.stroke();
// Run Physics Substeps
const dt = 1;
for (let i = 0; i < SUBSTEPS; i++) {
if(ball) {
ball.update();
// Floor (Drain) Check
if (ball.pos.y > height + 50) {
balls--;
if (balls > 0) {
currentBall++;
resetBall();
} else {
ball = null;
endGame();
}
break;
}
// Wall Collisions
walls.forEach(w => checkLineCollision(ball, w.p1, w.p2));
// Bumper Collisions
bumpers.forEach(b => checkCircleCollision(ball, b));
// Flipper Collisions
flippers.forEach(f => checkFlipperCollision(ball, f));
}
}
// Update AI
updateAI();
// Update Flippers (Visuals/Physics state)
flippers.forEach(f => f.update());
// Draw Entities
// Bumpers
bumpers.forEach(b => b.draw());
// Walls (Visual)
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 3;
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 5;
walls.forEach(w => {
ctx.beginPath();
ctx.moveTo(w.p1.x, w.p1.y);
ctx.lineTo(w.p2.x, w.p2.y);
ctx.stroke();
});
ctx.shadowBlur = 0;
// Flippers
flippers.forEach(f => f.draw());
// Ball
if (ball) ball.draw();
requestAnimationFrame(loop);
}
// Init
resize(); // Sets up canvas and table
// Start Loop
requestAnimationFrame(loop);
</script>
<div id="game-container">
<canvas id="pinballCanvas"></canvas>
<div class="ui-layer">
<div class="score-display" id="score">0</div>
<div class="ball-display" id="balls">Ball 1 / 3</div>
</div>
<div class="game-over-overlay" id="gameOver">
<div class="game-over-text">GAME OVER</div>
<div class="final-score" id="finalScore">Score: 0</div>
<br>
<div style="color: #666; font-size: 0.9rem;">Reload to play again</div>
</div>
<div class="controls-hint">AUTO-PILOT ENABLED</div>
</div>
<script>
const canvas = document.getElementById('pinballCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('game-container');
const scoreEl = document.getElementById('score');
const ballsEl = document.getElementById('balls');
const gameOverEl = document.getElementById('gameOver');
const finalScoreEl = document.getElementById('finalScore');
// Physics Constants
const GRAVITY = 0.25; // How heavy the ball feels
const FRICTION = 0.99; // Air resistance
const WALL_BOUNCE = 0.6; // Energy loss on walls
const BUMPER_BOUNCE = 1.3; // Energy gain on bumpers
const FLIPPER_FORCE = 0.45; // Rotational speed force
const SUBSTEPS = 8; // Physics steps per frame for stability
// Game State
let width, height;
let score = 0;
let balls = 3;
let currentBall = 1;
let gameActive = true;
let shakeTimer = 0;
function resize() {
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
width = canvas.width;
height = canvas.height;
initTable();
}
window.addEventListener('resize', resize);
const Vec2 = {
add: (v1, v2) => ({ x: v1.x + v2.x, y: v1.y + v2.y }),
sub: (v1, v2) => ({ x: v1.x - v2.x, y: v1.y - v2.y }),
mult: (v, s) => ({ x: v.x * s, y: v.y * s }),
dot: (v1, v2) => v1.x * v2.x + v1.y * v2.y,
mag: (v) => Math.sqrt(v.x * v.x + v.y * v.y),
norm: (v) => {
const m = Math.sqrt(v.x * v.x + v.y * v.y);
return m === 0 ? { x: 0, y: 0 } : { x: v.x / m, y: v.y / m };
},
dist: (v1, v2) => Math.sqrt((v1.x - v2.x) ** 2 + (v1.y - v2.y) ** 2),
rotate: (v, angle) => ({
x: v.x * Math.cos(angle) - v.y * Math.sin(angle),
y: v.x * Math.sin(angle) + v.y * Math.cos(angle)
})
};
class Ball {
constructor(x, y) {
this.pos = { x, y };
this.vel = { x: 0, y: 0 };
this.radius = 12; // Base size
this.mass = 1;
}
update() {
// Apply Gravity
this.vel.y += GRAVITY;
this.vel = Vec2.mult(this.vel, FRICTION);
// Update Position
this.pos = Vec2.add(this.pos, this.vel);
}
draw() {
// Shadow
ctx.beginPath();
ctx.arc(this.pos.x + 4, this.pos.y + 4, this.radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
// Chrome Effect
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
// Radial gradient for metallic sphere look
const grad = ctx.createRadialGradient(
this.pos.x - this.radius * 0.3,
this.pos.y - this.radius * 0.3,
this.radius * 0.1,
this.pos.x,
this.pos.y,
this.radius
);
grad.addColorStop(0, '#fff'); // Highlight
grad.addColorStop(0.2, '#eee'); // Light Metal
grad.addColorStop(0.5, '#889'); // Mid Metal
grad.addColorStop(1, '#222'); // Shadow Edge
ctx.fillStyle = grad;
ctx.fill();
// Specular highlight stroke
ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
class Bumper {
constructor(x, y, radius, scoreVal, color) {
this.pos = { x, y };
this.radius = radius;
this.scoreVal = scoreVal;
this.color = color;
this.flashTime = 0;
}
draw() {
const isFlashing = this.flashTime > 0;
if (isFlashing) this.flashTime--;
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
// Glow effect
if (isFlashing) {
ctx.shadowColor = this.color;
ctx.shadowBlur = 30;
ctx.fillStyle = '#fff'; // Flash white center
} else {
ctx.shadowColor = this.color;
ctx.shadowBlur = 10;
ctx.fillStyle = this.color; // Normal color
}
ctx.fill();
ctx.shadowBlur = 0; // Reset
// Rim
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// Detail
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius * 0.6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.fill();
}
}
class Flipper {
constructor(x, y, type, length, restAngle, activeAngle) {
this.pivot = { x, y };
this.type = type; // 'left' or 'right'
this.length = length;
this.restAngle = restAngle;
this.activeAngle = activeAngle;
this.currentAngle = restAngle;
this.active = false;
this.angularVelocity = 0;
this.width = 15;
}
update() {
const target = this.active ? this.activeAngle : this.restAngle;
const diff = target - this.currentAngle;
// Simple spring-like movement for angle
if (this.active) {
this.angularVelocity += diff * 0.2;
} else {
this.angularVelocity += diff * 0.1;
}
this.angularVelocity *= 0.6; // Damping
this.currentAngle += this.angularVelocity;
}
getTip() {
return {
x: this.pivot.x + Math.cos(this.currentAngle) * this.length,
y: this.pivot.y + Math.sin(this.currentAngle) * this.length
};
}
draw() {
const tip = this.getTip();
ctx.save();
ctx.lineCap = 'round';
ctx.lineWidth = this.width;
ctx.strokeStyle = this.active ? '#fff' : '#ddd';
// Shadow
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 5;
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
ctx.beginPath();
ctx.moveTo(this.pivot.x, this.pivot.y);
ctx.lineTo(tip.x, tip.y);
ctx.stroke();
// Pivot circle
ctx.fillStyle = '#444';
ctx.beginPath();
ctx.arc(this.pivot.x, this.pivot.y, this.width/2, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
}
let ball;
let bumpers = [];
let flippers = [];
let walls = []; // Array of line segments {p1, p2}
function initTable() {
if (!width || !height) return;
// Reset arrays
bumpers = [];
flippers = [];
walls = [];
// Define Table Geometry
// 1. Walls (Simple Box with angled bottom)
const margin = 20;
const drainGap = 100;
// Outer Frame path points
const tl = { x: margin, y: margin };
const tr = { x: width - margin, y: margin };
const bl = { x: margin, y: height - 120 };
const br = { x: width - margin, y: height - 120 };
// Lane entrances
const bl_in = { x: width/2 - drainGap/2 - 40, y: height - 120 };
const br_in = { x: width/2 + drainGap/2 + 40, y: height - 120 };
// Flipper pivots
const f_pivot_y = height - 100;
const f_pivot_left = { x: width/2 - drainGap/2 - 10, y: f_pivot_y };
const f_pivot_right = { x: width/2 + drainGap/2 + 10, y: f_pivot_y };
// Create Walls segments
walls.push({ p1: {x: margin, y: height}, p2: tl }); // Left
walls.push({ p1: tl, p2: tr }); // Top
walls.push({ p1: tr, p2: {x: width-margin, y: height} }); // Right
// Slanted walls to flippers (The "In-lanes")
walls.push({ p1: {x: margin, y: height*0.7}, p2: f_pivot_left });
walls.push({ p1: {x: width-margin, y: height*0.7}, p2: f_pivot_right });
// Top Arch (Approximated with segments for simplicity in collision)
// Creating a "Corner" cut at top
walls.push({ p1: tl, p2: {x: margin + 100, y: margin + 100} }); // Top Left Corner
walls.push({ p1: tr, p2: {x: width - margin - 100, y: margin + 100} }); // Top Right Corner
// Add Bumpers
bumpers.push(new Bumper(width * 0.5, height * 0.3, 25, 100, '#0ff')); // Top Center
bumpers.push(new Bumper(width * 0.3, height * 0.4, 20, 50, '#f0f')); // Left
bumpers.push(new Bumper(width * 0.7, height * 0.4, 20, 50, '#f0f')); // Right
bumpers.push(new Bumper(width * 0.5, height * 0.55, 15, 250, '#f80')); // Center Small
// Add Flippers
const flipperLen = 80;
// Left Flipper: rests at ~30 deg, active at ~-30 deg
flippers.push(new Flipper(f_pivot_left.x, f_pivot_left.y, 'left', flipperLen, Math.PI * 0.15, -Math.PI * 0.2));
// Right Flipper: rests at ~150 deg, active at ~210 deg
flippers.push(new Flipper(f_pivot_right.x, f_pivot_right.y, 'right', flipperLen, Math.PI * 0.85, Math.PI * 1.2));
// Reset Ball
resetBall();
}
function resetBall() {
if (balls <= 0) {
endGame();
return;
}
// Launch from plunger lane (simulated)
ball = new Ball(width - 40, height - 100);
// Shoot up and left
ball.vel = { x: -5 - Math.random() * 5, y: -20 - Math.random() * 5 };
// Update UI
ballsEl.innerText = `Ball ${currentBall} / 3`;
}
function endGame() {
gameActive = false;
finalScoreEl.innerText = `Score: ${Math.floor(score)}`;
gameOverEl.classList.add('visible');
}
function triggerShake() {
container.classList.remove('shake');
void container.offsetWidth; // trigger reflow
container.classList.add('shake');
}
// Point to Line Segment Distance/Collision
function checkLineCollision(ball, p1, p2) {
const vLine = Vec2.sub(p2, p1);
const vBall = Vec2.sub(ball.pos, p1);
const lineLen = Vec2.mag(vLine);
const lineUnit = Vec2.norm(vLine);
// Project ball onto line
const proj = Vec2.dot(vBall, lineUnit);
// Closest point on segment
let closest;
if (proj < 0) closest = p1;
else if (proj > lineLen) closest = p2;
else closest = Vec2.add(p1, Vec2.mult(lineUnit, proj));
const distVec = Vec2.sub(ball.pos, closest);
const dist = Vec2.mag(distVec);
if (dist < ball.radius) {
// Collision!
const normal = Vec2.norm(distVec);
// Push out
const overlap = ball.radius - dist;
ball.pos = Vec2.add(ball.pos, Vec2.mult(normal, overlap));
// Reflect velocity
const dot = Vec2.dot(ball.vel, normal);
// Only bounce if moving into the wall
if (dot < 0) {
const reflect = Vec2.sub(ball.vel, Vec2.mult(normal, 2 * dot));
ball.vel = Vec2.mult(reflect, WALL_BOUNCE);
// Add a tiny bit of random noise to prevent infinite loops
ball.vel.x += (Math.random() - 0.5);
}
return true;
}
return false;
}
// Ball to Circle (Bumper) Collision
function checkCircleCollision(ball, circle) {
const dist = Vec2.dist(ball.pos, circle.pos);
const minDist = ball.radius + circle.radius;
if (dist < minDist) {
const normal = Vec2.norm(Vec2.sub(ball.pos, circle.pos));
// Push out
ball.pos = Vec2.add(circle.pos, Vec2.mult(normal, minDist));
// Reflect
const dot = Vec2.dot(ball.vel, normal);
if (dot < 0) {
const reflect = Vec2.sub(ball.vel, Vec2.mult(normal, 2 * dot));
ball.vel = Vec2.mult(reflect, BUMPER_BOUNCE);
}
// Gameplay effects
score += circle.scoreVal;
scoreEl.innerText = Math.floor(score);
circle.flashTime = 5; // Frames to flash
// Shake if hit hard
if (Vec2.mag(ball.vel) > 10) triggerShake();
return true;
}
return false;
}
// Ball to Flipper Collision
function checkFlipperCollision(ball, flipper) {
// Treat flipper as a thick line segment
const tip = flipper.getTip();
const p1 = flipper.pivot;
const p2 = tip;
// Standard line check first
const vLine = Vec2.sub(p2, p1);
const vBall = Vec2.sub(ball.pos, p1);
const lineLen = Vec2.mag(vLine);
const lineUnit = Vec2.norm(vLine);
const proj = Vec2.dot(vBall, lineUnit);
let closest;
if (proj < 0) closest = p1;
else if (proj > lineLen) closest = p2;
else closest = Vec2.add(p1, Vec2.mult(lineUnit, proj));
const distVec = Vec2.sub(ball.pos, closest);
const dist = Vec2.mag(distVec);
const thickness = flipper.width / 2;
const minDist = ball.radius + thickness;
if (dist < minDist) {
const normal = Vec2.norm(distVec);
// Push out
ball.pos = Vec2.add(closest, Vec2.mult(normal, minDist));
// Velocity Reflection
const dot = Vec2.dot(ball.vel, normal);
if (dot < 0) {
let reflect = Vec2.sub(ball.vel, Vec2.mult(normal, 2 * dot));
ball.vel = Vec2.mult(reflect, 0.4); // Less bounce on flipper surface normally
// Add Flipper Motion Energy
// If flipper is moving towards ball, add huge boost
if (flipper.active) {
// Calculate tangent velocity of flipper at impact point
const distFromPivot = Vec2.dist(closest, flipper.pivot);
const tipSpeed = 20 * FLIPPER_FORCE * (distFromPivot / flipper.length);
// Normal of flipper face
// Depending on Left or Right flipper, the normal points up/in
// Simplified: add velocity in the direction of the normal
ball.vel = Vec2.add(ball.vel, Vec2.mult(normal, tipSpeed));
triggerShake();
}
}
return true;
}
return false;
}
function updateAI() {
if (!ball) return;
// Reset flippers
flippers.forEach(f => f.active = false);
// Only care if ball is in lower half and moving down
if (ball.pos.y > height * 0.6 && ball.vel.y > -2) {
// Left Flipper Logic
if (ball.pos.x < width * 0.5) {
const f = flippers[0];
// Prediction: will it cross the flipper line?
// Simple distance check for now
if (Vec2.dist(ball.pos, f.pivot) < f.length + 30) {
f.active = true;
}
}
// Right Flipper Logic
else {
const f = flippers[1];
if (Vec2.dist(ball.pos, f.pivot) < f.length + 30) {
f.active = true;
}
}
}
}
function loop() {
if (!gameActive) return;
// Clear Canvas
ctx.clearRect(0, 0, width, height);
// Draw Background Elements (Lanes, Deco)
ctx.strokeStyle = '#333';
ctx.lineWidth = 4;
// Draw lane lines
ctx.beginPath();
ctx.moveTo(width*0.1, 0); ctx.lineTo(width*0.1, height);
ctx.moveTo(width*0.9, 0); ctx.lineTo(width*0.9, height);
ctx.stroke();
// Run Physics Substeps
const dt = 1;
for (let i = 0; i < SUBSTEPS; i++) {
if(ball) {
ball.update();
// Floor (Drain) Check
if (ball.pos.y > height + 50) {
balls--;
if (balls > 0) {
currentBall++;
resetBall();
} else {
ball = null;
endGame();
}
break;
}
// Wall Collisions
walls.forEach(w => checkLineCollision(ball, w.p1, w.p2));
// Bumper Collisions
bumpers.forEach(b => checkCircleCollision(ball, b));
// Flipper Collisions
flippers.forEach(f => checkFlipperCollision(ball, f));
}
}
// Update AI
updateAI();
// Update Flippers (Visuals/Physics state)
flippers.forEach(f => f.update());
// Draw Entities
// Bumpers
bumpers.forEach(b => b.draw());
// Walls (Visual)
ctx.strokeStyle = '#0ff';
ctx.lineWidth = 3;
ctx.shadowColor = '#0ff';
ctx.shadowBlur = 5;
walls.forEach(w => {
ctx.beginPath();
ctx.moveTo(w.p1.x, w.p1.y);
ctx.lineTo(w.p2.x, w.p2.y);
ctx.stroke();
});
ctx.shadowBlur = 0;
// Flippers
flippers.forEach(f => f.draw());
// Ball
if (ball) ball.draw();
requestAnimationFrame(loop);
}
// Init
resize(); // Sets up canvas and table
// Start Loop
requestAnimationFrame(loop);
</script>