从零到一:使用现代Web技术构建一个物理真实的HTML5台球游戏
在当今的Web开发领域,利用HTML5、CSS3和JavaScript构建交互式游戏已不再是难事。本文将深入探讨如何从零开始,打造一款名为“黑8台球”的HTML5小游戏。我们将超越简单的功能罗列,深入剖析其背后的技术实现、物理引擎的模拟、交互设计的考量,以及如何利用现代前端技术栈(如TypeScript、Canvas API)来构建一个既美观又真实的游戏体验。这不仅是一个游戏开发教程,更是一次对Web图形与物理模拟能力的探索。
一、游戏核心架构与物理引擎的实现
构建一个逼真的台球游戏,其核心在于一个稳定且高效的物理引擎。与使用现成的游戏引擎(如Unity或Unreal Engine)不同,纯Web实现要求我们更细致地处理碰撞检测、动量传递和能量衰减。
游戏的主体逻辑和渲染基于HTML5 Canvas。我们首先需要定义一个二维世界,其中包含球体(Ball)和边界(Boundary)两类主要实体。每个球体对象至少应包含以下属性:位置(x, y)、速度(vx, vy)、半径、质量以及一个状态标识(如是否活动)。
碰撞检测与响应是物理引擎的基石。对于球与球的碰撞,我们使用经典的圆形碰撞检测公式:计算两球心距离,若小于半径之和,则发生碰撞。碰撞响应则基于动量守恒和能量守恒定律,计算碰撞后两球的速度向量。这个过程涉及到向量数学,是游戏真实感的关键。
球与边界的碰撞相对简单,当球心到边界的距离小于其半径时,发生碰撞,并沿法线方向反弹,同时根据设定的摩擦系数衰减速度,模拟台呢的摩擦力效果。
游戏的主循环(Game Loop)负责在每一帧更新所有球体的位置(位置 += 速度 * 时间增量),进行碰撞检测与响应,并重绘画布。一个优化良好的游戏循环是游戏流畅运行的保证。

上图展示了游戏运行时的界面,可以看到清晰的球体、瞄准辅助线和简洁的UI布局。
二、交互系统与游戏逻辑的深度设计
优秀的交互设计能极大提升游戏的可玩性。本游戏的交互核心是瞄准与击球系统。
- 瞄准:玩家通过鼠标或触摸拖拽白球来调整击球方向。拖拽的距离决定了击球的力度,这通过一个可视化的力度环或力度条实时反馈给玩家。
- 预测线:为了辅助瞄准,游戏绘制了一条从白球出发的虚线,并基于简单的物理预测(忽略复杂碰撞)计算出球的初始路径,甚至在与其他球可能发生碰撞的位置显示一个预测点。这涉及到射线与圆的相交检测。
- 击球:松开鼠标或手指时,根据拖拽向量(方向反方向)和距离(力度)为白球赋予一个初始速度,游戏开始模拟。
游戏规则逻辑则封装在另一个模块中:
- 袋口检测:六个袋口本质上是在Canvas特定坐标定义的圆形或矩形区域。每一帧检查每个活动球体的中心是否落入这些区域,若落入,则将该球标记为“进袋”,并将其移出模拟。
- 胜利判定:胜利条件是黑8落袋且白球未同时落袋。这需要在黑8进袋的瞬间,检查白球的状态。若白球也进袋,则判为犯规,通常会导致游戏失败或对手获益。
- 杆数统计:每次玩家执行一次有效的击球(即白球获得速度),杆数计数器加一。这是一个简单的状态管理。
三、视听效果与性能优化实践
在核心玩法稳固之后,视听效果是提升游戏沉浸感的“魔法”。
视觉特效主要使用Canvas 2D API或WebGL实现粒子系统。本游戏包含两种:
- 胜利特效(雨降型粒子):黑8进袋且符合胜利条件时触发。系统在画布顶部生成大量彩色粒子(小圆点),每个粒子拥有随机的水平位置、大小、颜色和下落速度,模拟彩花飘落。通过不断更新粒子的y坐标并在到达底部后重置,形成循环动画,直到停止。
- 黑8进袋特效(迸发型粒子):在黑8进袋的袋口位置,瞬间生成一批向四周扩散的粒子,模拟冲击迸发的感觉。每个粒子拥有一个从袋口中心向外的初始速度向量,并受到模拟“重力”或阻力的影响逐渐减速、消失。
音效系统则基于Web Audio API或简单的HTML5 Audio元素。预加载背景音乐、击球声、进袋声和胜利音效,在对应游戏事件(如`ballPotted`, `gameWon`)时播放。务必提供静音切换按钮,这是用户体验的基本要求。
性能与兼容性优化:
- 响应式设计:通过CSS媒体查询和动态计算Canvas画布大小,确保游戏在从桌面到手机的不同屏幕上都能够正常显示和操作。
- 节流与防抖:对于频繁触发的事件(如鼠标移动更新瞄准线),使用函数节流来限制更新频率,避免不必要的计算和渲染。
- 状态管理:清晰地区分游戏状态(如“瞄准中”、“模拟中”、“胜利动画中”、“等待中”),避免在球体静止后仍进行高频率的物理计算。
四、代码结构与未来扩展方向
一个可维护的项目离不开清晰的代码结构。虽然原型可以用纯JavaScript快速搭建,但对于这样规模的项目,考虑使用TypeScript可以极大地提升开发体验,通过静态类型检查减少运行时错误。
以下是游戏核心模块的示意性代码结构,实际完整的实现代码受保护如下:
Black 8 · One Shot Pocket
<script>
(function() {
// ----- canvas and context -----
const canvas = document.getElementById('poolCanvas');
const ctx = canvas.getContext('2d');
const strokeSpan = document.getElementById('strokeDisplay');
const powerFill = document.getElementById('powerFill');
const nextBtn = document.getElementById('nextBtn');
const restartBtn = document.getElementById('restartBtn');
const muteBtn = document.getElementById('muteBtn');
const foulMessage = document.getElementById('foulMessage');
const loadingOverlay = document.getElementById('loadingOverlay');
// ----- audio management (fixed - plays after first shot) -----
let isMuted = false;
let userInteracted = false;
let hasShot = false; // flag for first shot
// audio elements
let bgmAudio = null;
let winAudio = null;
let xiaochuAudio = null;
// initialize audio
function initAudio() {
console.log('Initializing audio...');
// background music - OGG format
bgmAudio = new Audio('https://amitofoicu.github.io/home/beijing.ogg');
bgmAudio.loop = true;
bgmAudio.volume = 1.0;
// iOS required attributes
bgmAudio.setAttribute('playsinline', '');
bgmAudio.setAttribute('webkit-playsinline', '');
// preload but don't autoplay
bgmAudio.load();
// win sound effect
winAudio = new Audio('https://amitofoicu.github.io/home/win.mp3');
winAudio.volume = 0.7;
winAudio.setAttribute('playsinline', '');
winAudio.setAttribute('webkit-playsinline', '');
winAudio.load();
// clear sound effect
xiaochuAudio = new Audio('https://amitofoicu.github.io/home/xiaochu.mp3');
xiaochuAudio.volume = 1.0;
xiaochuAudio.setAttribute('playsinline', '');
xiaochuAudio.setAttribute('webkit-playsinline', '');
xiaochuAudio.load();
// set mute button initial state
muteBtn.textContent = isMuted ? '' : '';
// hide loading overlay
setTimeout(() => {
loadingOverlay.style.opacity = '0';
setTimeout(() => {
loadingOverlay.style.display = 'none';
}, 500);
}, 1000);
console.log('Audio initialized');
}
// play background music (only after first shot)
function playBGM() {
if (!hasShot || isMuted || !bgmAudio) return; // only play after first shot
try {
// don't restart if already playing
if (!bgmAudio.paused) return;
bgmAudio.currentTime = 0;
const playPromise = bgmAudio.play();
if (playPromise !== undefined) {
playPromise.catch(e => {
console.log('BGM play failed:', e);
});
}
} catch (e) {
console.log('BGM error:', e);
}
}
// stop background music
function stopBGM() {
if (!bgmAudio) return;
try {
bgmAudio.pause();
bgmAudio.currentTime = 0;
} catch (e) {
console.log('Stop BGM error:', e);
}
}
// play sound effect (clone to bypass iOS restrictions)
function playSound(audio) {
if (!userInteracted || isMuted || !audio) return;
try {
// clone node to allow multiple simultaneous sounds on iOS
const clone = audio.cloneNode();
clone.volume = audio.volume;
const playPromise = clone.play();
if (playPromise !== undefined) {
playPromise.catch(e => {
console.log('Sound play failed:', e);
});
}
// clean up clone
setTimeout(() => {
clone.pause();
clone.src = '';
}, 2000);
} catch (e) {
console.log('Sound error:', e);
}
}
// play win sound
function playWinSound() {
playSound(winAudio);
}
// play elimination sound
function playXiaochuSound() {
playSound(xiaochuAudio);
}
// toggle mute
function toggleMute() {
isMuted = !isMuted;
muteBtn.textContent = isMuted ? '' : '';
if (isMuted) {
stopBGM();
} else {
if (hasShot) { // only resume if already shot
playBGM();
}
}
}
// handle user interaction
function handleUserInteraction() {
if (!userInteracted) {
userInteracted = true;
}
}
// ----- constants -----
const CW = 800;
const CH = 450;
const LEFT_WALL = 40;
const RIGHT_WALL = 760;
const TOP_WALL = 40;
const BOTTOM_WALL = 410;
const BALL_RADIUS = 14;
const FRICTION = 0.98;
const MAX_POWER_SPEED = 25;
const pockets = [
{ x: LEFT_WALL, y: TOP_WALL },
{ x: RIGHT_WALL, y: TOP_WALL },
{ x: LEFT_WALL, y: BOTTOM_WALL },
{ x: RIGHT_WALL, y: BOTTOM_WALL },
{ x: (LEFT_WALL + RIGHT_WALL) / 2, y: TOP_WALL },
{ x: (LEFT_WALL + RIGHT_WALL) / 2, y: BOTTOM_WALL }
];
const POCKET_RADIUS = 28;
// ----- game state -----
let white = { x: 200, y: 220, vx: 0, vy: 0 };
let target = { x: 600, y: 220, vx: 0, vy: 0 };
let targetExists = true;
let strokes = 0;
let gameOver = false;
let winFlag = false;
let blackPocketed = false;
let checkWhiteAfterStop = false;
let victoryTimer = null;
let blackPocketPosition = null;
// ----- aiming state -----
let isDragging = false;
let dragX = 0, dragY = 0;
let angle = 0;
let power = 0.2;
// flag to track if mouse is outside canvas
let isMouseOutside = false;
// ----- particle system -----
let particles = [];
const PARTICLE_COLORS = [
'#FF69B4', '#FFD700', '#FF4500', '#9370DB', '#00FF7F',
'#FF1493', '#FFA500', '#32CD32', '#FFB6C1', '#87CEEB',
'#FF6346', '#FFFF00', '#FF00FF', '#00FFFF', '#FFDAB9'
];
class Particle {
constructor(x, y, type = 'explosion') {
this.x = x;
this.y = y;
this.type = type;
if (type === 'explosion') {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 8 + 5;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
this.size = Math.random() * 10 + 5;
this.fadeSpeed = 0.01 + Math.random() * 0.01;
} else {
this.vx = (Math.random() - 0.5) * 1.5;
this.vy = Math.random() * 2 + 1.5;
this.size = Math.random() * 8 + 4;
this.fadeSpeed = 0.001;
}
this.color = PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)];
this.rotation = Math.random() * Math.PI * 2;
this.rotationSpeed = (Math.random() - 0.5) * 0.05;
this.gravity = 0.05;
this.life = 1.0;
}
update() {
this.x += this.vx;
this.y += this.vy;
if (this.type === 'rain') {
this.vy += this.gravity * 0.5;
} else {
this.vy += this.gravity;
}
this.rotation += this.rotationSpeed;
if (this.type === 'explosion') {
this.life -= this.fadeSpeed;
}
if (this.type === 'rain') {
if (this.y > CH + 30) {
this.y = -20;
this.x = Math.random() * CW;
this.vx = (Math.random() - 0.5) * 1.5;
this.vy = Math.random() * 2 + 1.5;
}
if (this.x < 0 || this.x > CW) {
this.vx *= -0.8;
}
return true;
} else {
if (this.y > CH + 50) this.life = 0;
return this.life > 0;
}
}
draw() {
ctx.save();
ctx.translate(this.x, this.y);
ctx.rotate(this.rotation);
ctx.globalAlpha = this.life;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
let angle = (i / 5) * Math.PI * 2;
let petalLength = this.size;
let petalWidth = this.size * 0.6;
let x = Math.cos(angle) * petalLength;
let y = Math.sin(angle) * petalLength;
let cx1 = Math.cos(angle + 0.5) * petalWidth;
let cy1 = Math.sin(angle + 0.5) * petalWidth;
let cx2 = Math.cos(angle - 0.5) * petalWidth;
let cy2 = Math.sin(angle - 0.5) * petalWidth;
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(cx1, cy1, x, y);
ctx.quadraticCurveTo(cx2, cy2, 0, 0);
}
ctx.fillStyle = this.color;
ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
ctx.shadowBlur = 10;
ctx.fill();
ctx.restore();
}
}
function explodeFlowersFromPocket(pocketX, pocketY, count = 15) {
for (let i = 0; i < count; i++) {
particles.push(new Particle(pocketX, pocketY, 'explosion'));
}
}
function startRainFlowers(count = 40) {
particles = particles.filter(p => p.type === 'rain');
for (let i = 0; i < count; i++) {
particles.push(new Particle(
Math.random() * CW,
Math.random() * CH - CH,
'rain'
));
}
}
function stopRainFlowers() {
particles = particles.filter(p => p.type !== 'rain');
}
function updateParticles() {
particles = particles.filter(p => p.update());
}
function drawParticles() {
for (let p of particles) {
p.draw();
}
ctx.globalAlpha = 1.0;
}
// ----- helper functions -----
function randomTargetPosition() {
let overlap = true;
let attempts = 0;
let newX, newY;
const minDist = BALL_RADIUS * 2 + 20;
while (overlap && attempts < 1000) {
newX = LEFT_WALL + Math.random() * (RIGHT_WALL - LEFT_WALL);
newY = TOP_WALL + Math.random() * (BOTTOM_WALL - TOP_WALL);
const dx = newX - white.x;
const dy = newY - white.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist >= minDist) overlap = false;
attempts++;
}
return { x: newX, y: newY };
}
function nextLevel() {
if (victoryTimer) {
clearTimeout(victoryTimer);
victoryTimer = null;
}
white = { x: 200, y: 220, vx: 0, vy: 0 };
const pos = randomTargetPosition();
target = { x: pos.x, y: pos.y, vx: 0, vy: 0 };
targetExists = true;
gameOver = false;
winFlag = false;
blackPocketed = false;
checkWhiteAfterStop = false;
blackPocketPosition = null;
strokes = 0;
strokeSpan.innerText = strokes;
isDragging = false;
angle = 0;
power = 0.2;
powerFill.style.width = '20%';
nextBtn.style.display = 'none';
restartBtn.style.display = 'inline-block';
stopRainFlowers();
}
function resetLevel() {
nextLevel();
}
function checkPocket(ball) {
for (let p of pockets) {
const dx = ball.x - p.x;
const dy = ball.y - p.y;
if (Math.sqrt(dx*dx + dy*dy) < POCKET_RADIUS) {
return p;
}
}
return null;
}
function checkWhitePocket() {
for (let p of pockets) {
const dx = white.x - p.x;
const dy = white.y - p.y;
if (Math.sqrt(dx*dx + dy*dy) < POCKET_RADIUS) return true;
}
return false;
}
function showFoulMessage() {
foulMessage.style.display = 'block';
setTimeout(() => {
foulMessage.style.display = 'none';
}, 1500);
}
function ballsAreStationary() {
return (Math.abs(white.vx) < 0.1 && Math.abs(white.vy) < 0.1 &&
Math.abs(target.vx) < 0.1 && Math.abs(target.vy) < 0.1);
}
// ----- physics update -----
function updatePhysics() {
if (!gameOver || checkWhiteAfterStop) {
white.x += white.vx;
white.y += white.vy;
target.x += target.vx;
target.y += target.vy;
[white, target].forEach(ball => {
if (ball.x - BALL_RADIUS < LEFT_WALL) {
ball.x = LEFT_WALL + BALL_RADIUS;
ball.vx = -ball.vx * 0.92;
}
if (ball.x + BALL_RADIUS > RIGHT_WALL) {
ball.x = RIGHT_WALL - BALL_RADIUS;
ball.vx = -ball.vx * 0.92;
}
if (ball.y - BALL_RADIUS < TOP_WALL) {
ball.y = TOP_WALL + BALL_RADIUS;
ball.vy = -ball.vy * 0.92;
}
if (ball.y + BALL_RADIUS > BOTTOM_WALL) {
ball.y = BOTTOM_WALL - BALL_RADIUS;
ball.vy = -ball.vy * 0.92;
}
});
const dx = target.x - white.x;
const dy = target.y - white.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < BALL_RADIUS * 2 && dist > 0.001) {
const nx = dx / dist;
const ny = dy / dist;
const vrelx = white.vx - target.vx;
const vrely = white.vy - target.vy;
const vn = vrelx * nx + vrely * ny;
if (vn > 0) {
const imp = (2 * vn) / 2 * 0.96;
white.vx -= imp * nx;
white.vy -= imp * ny;
target.vx += imp * nx;
target.vy += imp * ny;
}
const overlap = BALL_RADIUS * 2 - dist;
if (overlap > 0) {
const sepX = nx * overlap * 0.5;
const sepY = ny * overlap * 0.5;
white.x -= sepX;
white.y -= sepY;
target.x += sepX;
target.y += sepY;
}
}
white.vx *= FRICTION;
white.vy *= FRICTION;
target.vx *= FRICTION;
target.vy *= FRICTION;
if (Math.abs(white.vx) < 0.1) white.vx = 0;
if (Math.abs(white.vy) < 0.1) white.vy = 0;
if (Math.abs(target.vx) < 0.1) target.vx = 0;
if (Math.abs(target.vy) < 0.1) target.vy = 0;
if (targetExists && !blackPocketed) {
const pocket = checkPocket(target);
if (pocket) {
console.log('Black ball pocketed');
targetExists = false;
blackPocketed = true;
checkWhiteAfterStop = true;
blackPocketPosition = pocket;
playWinSound();
explodeFlowersFromPocket(pocket.x, pocket.y, 15);
}
}
if (!blackPocketed && checkWhitePocket()) {
console.log('Cue ball foul');
showFoulMessage();
resetLevel();
return;
}
}
if (checkWhiteAfterStop) {
if (ballsAreStationary()) {
if (checkWhitePocket()) {
showFoulMessage();
resetLevel();
} else {
console.log('Cue ball stopped - victory');
playXiaochuSound();
gameOver = true;
winFlag = true;
startRainFlowers(50);
if (victoryTimer) clearTimeout(victoryTimer);
victoryTimer = setTimeout(() => {
console.log('3s auto next round');
nextLevel();
}, 3000);
nextBtn.style.display = 'inline-block';
restartBtn.style.display = 'none';
}
checkWhiteAfterStop = false;
}
}
updateParticles();
}
// ----- shoot -----
function shoot() {
if (gameOver) {
return;
}
if (white.vx !== 0 || white.vy !== 0) {
return;
}
if (!targetExists && blackPocketed) {
return;
}
if (!targetExists && !blackPocketed) {
return;
}
// actual shot speed uses full power value (may exceed 1.0)
const speed = power * MAX_POWER_SPEED;
white.vx = Math.cos(angle) * speed;
white.vy = Math.sin(angle) * speed;
strokes++;
strokeSpan.innerText = strokes;
// mark first shot and attempt to play background music
if (!hasShot) {
hasShot = true;
console.log('First shot! Starting BGM...');
// delay slightly to let shot sound play first
setTimeout(() => {
if (!isMuted) {
playBGM();
}
}, 300);
}
}
// ----- aim update (fixed power and angle calculation) -----
function updateAim() {
if (!isDragging) return;
// vector from cue ball to drag point
const dx = white.x - dragX;
const dy = white.y - dragY;
// calculate distance
let dist = Math.sqrt(dx*dx + dy*dy);
// if too close, keep current angle
if (dist < 1) {
return;
}
// calculate angle (shot direction is away from finger)
angle = Math.atan2(dy, dx);
// power calculation with smooth curve
const MIN_POWER = 0.2;
const MAX_POWER = 1.2; // allow 20% overcharge
const OPTIMAL_DIST = 180; // optimal distance for max visual power
// base power (0-1.5 range)
let rawPower = dist / OPTIMAL_DIST;
// S-curve for natural power progression
if (rawPower < 0.5) {
// short distance: slow growth
power = MIN_POWER + (rawPower / 0.5) * 0.3;
} else if (rawPower < 1.2) {
// medium distance: linear growth
power = MIN_POWER + 0.3 + ((rawPower - 0.5) / 0.7) * 0.5;
} else {
// long distance: saturation with slight overcharge
power = MIN_POWER + 0.8 + Math.min(rawPower - 1.2, 0.5) * 0.4;
}
// clamp power range
power = Math.max(MIN_POWER, Math.min(MAX_POWER, power));
// visual feedback power (clamped to 0-1)
let visualPower = Math.min(power, 1.0);
// update power bar (using visual power)
powerFill.style.width = (visualPower * 100) + '%';
}
// ----- collision prediction -----
function predictCollision() {
const dirX = Math.cos(angle);
const dirY = Math.sin(angle);
const startX = white.x + dirX * BALL_RADIUS;
const startY = white.y + dirY * BALL_RADIUS;
const tx = target.x;
const ty = target.y;
const toTargetX = tx - startX;
const toTargetY = ty - startY;
const proj = toTargetX * dirX + toTargetY * dirY;
if (proj < 0) return null;
const closestX = startX + dirX * proj;
const closestY = startY + dirY * proj;
const perpDist = Math.hypot(tx - closestX, ty - closestY);
if (perpDist > BALL_RADIUS * 2) return null;
const hitDist = proj - Math.sqrt(Math.max(0, Math.pow(BALL_RADIUS * 2, 2) - perpDist * perpDist));
if (hitDist < 0) return null;
const hitX = startX + dirX * hitDist;
const hitY = startY + dirY * hitDist;
return { hitX, hitY };
}
// ----- draw -----
function draw() {
ctx.clearRect(0, 0, CW, CH);
// table felt
ctx.fillStyle = '#1e5a3a';
ctx.fillRect(0, 0, CW, CH);
// cushions
ctx.strokeStyle = '#dbb06b';
ctx.lineWidth = 3;
ctx.strokeRect(LEFT_WALL - 2, TOP_WALL - 2, RIGHT_WALL - LEFT_WALL + 4, BOTTOM_WALL - TOP_WALL + 4);
// pockets
ctx.fillStyle = '#2a1f12';
ctx.shadowColor = '#00000080';
ctx.shadowBlur = 10;
pockets.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, POCKET_RADIUS - 4, 0, Math.PI * 2);
ctx.fillStyle = '#1f140e';
ctx.fill();
ctx.shadowBlur = 5;
ctx.fillStyle = '#4a3c2b';
ctx.arc(p.x, p.y, POCKET_RADIUS - 8, 0, Math.PI * 2);
ctx.fill();
});
ctx.shadowBlur = 0;
// black 8 ball
if (targetExists) {
ctx.shadowColor = '#333333';
ctx.shadowBlur = 15;
ctx.beginPath();
ctx.arc(target.x, target.y, BALL_RADIUS, 0, Math.PI * 2);
const grad = ctx.createRadialGradient(target.x - 3, target.y - 3, 3, target.x, target.y, BALL_RADIUS + 2);
grad.addColorStop(0, '#222222');
grad.addColorStop(0.7, '#000000');
ctx.fillStyle = grad;
ctx.fill();
ctx.shadowBlur = 5;
ctx.font = 'bold 16px "Segoe UI", Arial';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('8', target.x, target.y);
ctx.beginPath();
ctx.arc(target.x - 3, target.y - 3, 4, 0, Math.PI * 2);
ctx.fillStyle = '#fff9e6';
ctx.globalAlpha = 0.3;
ctx.fill();
ctx.globalAlpha = 1;
}
// cue ball
ctx.shadowColor = '#cccccc';
ctx.shadowBlur = 18;
ctx.beginPath();
ctx.arc(white.x, white.y, BALL_RADIUS, 0, Math.PI * 2);
const wgrad = ctx.createRadialGradient(white.x - 4, white.y - 4, 4, white.x, white.y, BALL_RADIUS + 2);
wgrad.addColorStop(0, '#fafaf5');
wgrad.addColorStop(0.8, '#c0c0c0');
ctx.fillStyle = wgrad;
ctx.fill();
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(white.x - 1, white.y - 1, 5, 0, Math.PI * 2);
ctx.fillStyle = '#22222260';
ctx.fill();
// aiming guide
if (isDragging && white.vx === 0 && white.vy === 0 && targetExists && !gameOver) {
// visual power (clamped to 0-1)
let visualPower = Math.min(power, 1.0);
// power ring (at finger position)
ctx.beginPath();
ctx.arc(dragX, dragY, 15 + visualPower * 20, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 200, 0, 0.5)';
ctx.lineWidth = 3;
ctx.stroke();
// aim line (from cue ball in shot direction)
ctx.setLineDash([8, 6]);
ctx.beginPath();
ctx.moveTo(white.x, white.y);
ctx.lineTo(white.x + Math.cos(angle) * 500, white.y + Math.sin(angle) * 500);
ctx.strokeStyle = 'white';
ctx.stroke();
ctx.setLineDash([]);
// line from finger to cue ball
ctx.beginPath();
ctx.moveTo(dragX, dragY);
ctx.lineTo(white.x, white.y);
ctx.strokeStyle = 'rgba(255, 215, 0, 0.4)';
ctx.lineWidth = 2;
ctx.stroke();
// show power percentage
ctx.font = 'bold 14px Arial';
ctx.fillStyle = 'white';
ctx.shadowBlur = 4;
ctx.shadowColor = 'black';
ctx.fillText(Math.round(visualPower * 100) + '%', dragX - 15, dragY - 25);
ctx.shadowBlur = 0;
const hit = predictCollision();
if (hit) {
ctx.beginPath();
ctx.arc(hit.hitX, hit.hitY, 6, 0, Math.PI * 2);
ctx.fillStyle = 'yellow';
ctx.fill();
}
}
// draw all particles
drawParticles();
// victory screen
if (winFlag) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, CW, CH);
ctx.shadowBlur = 30;
ctx.font = 'bold 52px "Segoe UI", Verdana';
ctx.fillStyle = '#FFD966';
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 6;
ctx.textAlign = 'center';
ctx.strokeText(' VICTORY!', CW / 2, 150);
ctx.fillText(' VICTORY!', CW / 2, 150);
ctx.font = 'bold 24px "Segoe UI", Verdana';
ctx.fillStyle = '#FFFFFF';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.strokeText('Next round in 3s', CW / 2, 280);
ctx.fillText('Next round in 3s', CW / 2, 280);
}
}
// ----- event handlers -----
function getCanvasCoords(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
let clientX, clientY;
if (e.touches) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
// convert screen to canvas coordinates - allow outside canvas
let canvasX = (clientX - rect.left) * scaleX;
let canvasY = (clientY - rect.top) * scaleY;
return {
x: canvasX,
y: canvasY
};
}
function onMouseDown(e) {
e.preventDefault();
if (e.button !== 0) return;
handleUserInteraction();
if (gameOver) return;
if (white.vx !== 0 || white.vy !== 0) return;
if (!targetExists) return;
const coords = getCanvasCoords(e);
dragX = coords.x;
dragY = coords.y;
isDragging = true;
isMouseOutside = false;
}
function onMouseMove(e) {
e.preventDefault();
if (!isDragging) return;
const coords = getCanvasCoords(e);
dragX = coords.x;
dragY = coords.y;
updateAim();
}
function onMouseUp(e) {
e.preventDefault();
if (e.button !== 0) return;
if (!isDragging) return;
isDragging = false;
isMouseOutside = false;
shoot();
}
function onTouchStart(e) {
e.preventDefault();
handleUserInteraction();
if (gameOver) return;
if (white.vx !== 0 || white.vy !== 0) return;
if (!targetExists) return;
const coords = getCanvasCoords(e);
dragX = coords.x;
dragY = coords.y;
isDragging = true;
}
function onTouchMove(e) {
e.preventDefault();
if (!isDragging) return;
const coords = getCanvasCoords(e);
dragX = coords.x;
dragY = coords.y;
updateAim();
}
function onTouchEnd(e) {
e.preventDefault();
if (!isDragging) return;
isDragging = false;
shoot();
}
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
// keep dragging when mouse leaves canvas
canvas.addEventListener('mouseleave', () => {
if (isDragging) {
isMouseOutside = true;
// don't cancel drag, continue updating with last coordinates
}
});
canvas.addEventListener('mouseenter', () => {
isMouseOutside = false;
});
// window level mousemove to continue updating outside canvas
window.addEventListener('mousemove', (e) => {
if (isDragging) {
const coords = getCanvasCoords(e);
dragX = coords.x;
dragY = coords.y;
updateAim();
}
});
// window level mouseup to shoot even if released outside canvas
window.addEventListener('mouseup', (e) => {
if (isDragging) {
isDragging = false;
isMouseOutside = false;
shoot();
}
});
canvas.addEventListener('touchstart', onTouchStart, { passive: false });
canvas.addEventListener('touchmove', onTouchMove, { passive: false });
canvas.addEventListener('touchend', onTouchEnd);
canvas.addEventListener('touchcancel', onTouchEnd);
restartBtn.addEventListener('click', (e) => {
e.preventDefault();
handleUserInteraction();
resetLevel();
});
nextBtn.addEventListener('click', (e) => {
e.preventDefault();
handleUserInteraction();
nextLevel();
});
muteBtn.addEventListener('click', (e) => {
e.preventDefault();
handleUserInteraction();
toggleMute();
});
// prevent double tap zoom
let lastTouchEnd = 0;
document.addEventListener('touchend', (e) => {
const now = Date.now();
if (now - lastTouchEnd <= 300) e.preventDefault();
lastTouchEnd = now;
}, false);
document.addEventListener('contextmenu', e => e.preventDefault());
document.body.addEventListener('touchmove', (e) => {
if (e.target === document.body) e.preventDefault();
}, { passive: false });
// initialize - audio first, then game
(function init() {
// initialize audio system
initAudio();
// initialize game
const pos = randomTargetPosition();
target.x = pos.x;
target.y = pos.y;
// attempt auto interaction
setTimeout(() => {
handleUserInteraction();
}, 500);
})();
function animate() {
updatePhysics();
draw();
requestAnimationFrame(animate);
}
animate();
})();
</script>
这个占位符代表了游戏完整的源代码实现,涵盖了上述所有讨论的模块:物理引擎(Physics)、游戏对象(Ball, Table)、渲染器(Renderer)、输入处理器(Input)、游戏状态控制器(GameState)以及主初始化逻辑。
未来可能的扩展方向:
- 网络对战:利用WebSocket实现实时双人对战,这需要将游戏状态同步逻辑转移到服务器端(可使用Node.js, Go或Python的WebSocket库)。
- AI对手:实现一个具有不同难度的电脑AI。简单AI可以随机击球,而高级AI则需要计算最优击球路线,这涉及到搜索算法和局面评估。
- 更复杂的规则:实现完整的斯诺克或美式9球规则,包括球序击打、犯规判罚等。
- 3D化:使用WebGL和Three.js库将游戏从2D Canvas升级到3D,提供更震撼的视觉体验。
总结而言,构建一个HTML5台球游戏是一次融合了数学物理、计算机图形学和前端工程技术的绝佳实践。从精确的向量碰撞到优雅的粒子特效,从响应式的UI设计到清晰的代码架构,每一步都挑战着开发者的综合能力。通过这个项目,你不仅能深入理解Canvas动画和物理模拟,更能掌握如何构建一个完整、可交互的Web应用。希望本文的深度剖析能为你开启自己的游戏开发之旅提供坚实的蓝图。
浙公网安备 33010602011771号