很多学完HTML、CSS、JavaScript基础的朋友都会卡在同一个槛上:语法都认识、小Demo都能跑,真让自己独立写一个“有点交互”的项目,反而不知道从哪里下手。
打气球游戏恰好就是这么个临界点——它不算大,却把前端最常用的几块东西全串起来了:事件绑定、Canvas绘图、动画循环、随机逻辑、状态切换、计分反馈。整个项目纯用原生HTML+CSS+JavaScript完成,不依赖任何框架,一张网页打开就能玩。这篇文章我会把整个项目拆开讲清楚:为什么这么设计、每个模块怎么分工、完整代码长什么样,以及实际运行中容易踩的坑和后续能往哪些方向扩展。
有基础但缺项目经验的初学者,打算给学生上“网页小游戏”课的人,或者纯粹想找点事练手的前端爱好者,都可以跟着这份分析报告从头到尾把逻辑捋一遍,然后直接照抄代码跑一版自己的打气球游戏。
1. 为什么打气球这种小游戏适合做纯前端的练手项目:需求拆解与方案选型
1.1 打气球玩法背后的核心交互循环
打气球游戏看起来简单,本质上是“限时点击得分”的典型互动闭环。拆开看,一局游戏里有四个必然发生的环节:
- 气球从画面底部随机出现,一边上升一边保持可点击状态;
- 玩家视角追踪目标,在气球飞出屏幕前点击它;
- 点击命中触发反馈:气球消失、分数增加;
- 时间走完,游戏结束,展示结果,允许重新开始。
这四个环节刚好对应前端交互的四个基本功:定时生成对象、高频画面更新、坐标判定、状态管理和界面刷新。而且每个环节的难度都是可以控制的,气球大小、速度、生成频率、游戏时长都可以当参数调,这让项目天然适合用来做“参数化设计”的讲解。
以前我在线下带过一段前端小组练习,发现让零基础的人一上来就做商城、博客这类页面,他们很容易陷入CSS调样式出不来;但做这种带即时反馈的小游戏,大多数人很快就能理解“程序是状态的流动”这件事。原因在于打气球游戏的反馈非常直接——你写对了,屏幕上的分数立刻变;写错了,气球直接消失或不动,出问题一眼就能看到,调试成本低。
1.2 纯DOM页面方案与Canvas绘图方案怎么选
实现打气球有一个路线选择:用DOM元素做气球,CSS动画移动,还是用Canvas统一绘制。
我刚开始做这个项目时先用DOM方案试过:每生成一个气球就document.createElement('div'),加上绝对定位,再用setInterval或requestAnimationFrame修改top值让气球往上飘。这种方式在最简单时是可行的,甚至兼容性更好,但一旦气球数量上到二三十个,DOM节点增多,布局需要反复计算,页面会开始有可见卡顿。而且做“点击消失”的特效,还得处理removeChild之后动画残留、z-index层级等问题,代码会很啰嗦。
换成Canvas之后结构清晰很多:整个游戏画面是画布上一个持续重绘的画面,气球只是存储在数组里的数据对象,每帧把所有对象画一遍就行。点击判定不需要遍历DOM,直接拿坐标做数学计算。两种方案的核心差异我整理了一张表:
| 对比维度 | DOM + CSS 方案 | Canvas 绘图方案 |
|---|---|---|
| 实现成本 | 简单场景低,复杂场景代码琐碎 | 需要理解重绘机制,前期成本略高 |
| 元素数量 | 30个以上会明显变慢 | 数百个也能保持流畅 |
| 点击判定 | 依赖事件目标,天然知道点到哪个节点 | 要自己算坐标距离 |
| 动画方式 | CSS transition或JS改style | 每帧统一重绘 |
| 适合场景 | 原型、交互简单、元素少 | 动态画面多、对象数量大、要做特效 |
这个项目最终选Canvas,不是因为DOM方案不能做,而是因为用Canvas能让“游戏循环”这个核心概念更纯粹:一帧一帧地清屏、更新、绘制,这是后面做任何网页游戏、复杂动效甚至小游戏开发都绕不开的模型。练手项目应该贴近未来会用到的思维方式,而不是为了省事走捷径。
1.3 动手之前先定义“做完”的标准
做项目最容易犯的毛病是边写边加需求,最后项目永远收不了尾。我给这个打气球游戏先定了一个“最小可玩版本”验收标准:
- 能开始游戏,有倒计时;
- 气球从底部出现并持续向上移动;
- 点击气球能得分,点击空白不误判;
- 游戏结束能显示最终分数;
- 能重新开始,且状态完全重置。
除此之外的连击提示、音效、特殊气球、排行榜都属于“加分项”,全部往后放。先把这个标准跑通,再考虑锦上添花。这个思路本身就值得单独说一下:很多人写前端练手项目失败,不是能力不够,是目标边界不清楚,总想在第一个版本里把所有东西做全,结果每个模块都半生不熟。真正能写完的练手项目,一定是先有个“能跑的丑东西”,再一点点变好。
2. 游戏对象与生命周期管理:气球池、时间线、计分状态怎么设计
2.1 用状态机管理游戏流程:ready、playing、over三个状态
打气球游戏虽然小,依然存在流程问题:用户没点“开始游戏”时气球不该动;时间到了后再点击不该加分;游戏结束点击画布不应该有反应。如果每个事件都单独判断一堆条件,逻辑很快就会乱。
解决办法是引入一个简单的状态机,整个游戏只用三个状态:
READY:初始等待状态,什么都不动;PLAYING:正在游戏中,气球生成、移动、点击判定全部开启;OVER:结束状态,只展示结果,等待重新开始。
代码里就是个state变量,配合一个handleClick统一拦截。所有事件处理函数第一件事就是检查当前状态,不是目标状态就直接返回。这比在每个函数里分别判断“时间是否结束、是否已点击开始”要优雅得多,也避免了各种条件组合带来的潜在bug。
这里有一个我踩过的真实坑:早期版本里点击开始按钮后,我忘了把状态切到PLAYING,结果画布上气球已经出现了,点击却毫无反应。问题找了一圈,最后发现是startGame()函数里没有先更新状态。后来我把状态切换固定在每个流程函数的第一行,再也没犯过类似的错。
2.2 气球对象的字段设计与生成策略
每个气球本质是一个JavaScript对象,字段要尽量单一清晰。我的设计是:
{ x: 100, // 中心点的x坐标 y: 520, // 中心点的y坐标 r: 30, // 半径 color: '#e74c3c', speed: 1.2, // 上升速度,单位是像素/帧 alive: true // 是否存活,目前保留,后续做特殊气球时用 }生成策略有几个参数需要认真调:
- x范围:不能贴着画布边缘生成,否则气球半截在屏幕外,玩家点击很别扭。我限制在
radius + 10到canvas.width - radius - 10之间。 - y起点:设为
canvas.height + radius + 10,即从画布底部下方冒出来,视觉上像是地面飘上来的,更自然。 - 速度范围:0.8到1.8像素/帧。速度低于0.8会太慢,玩家等半天;高于1.8会太难打中,尤其大半径气球刚露头就跑。1.8对普通玩家已经偏快,配合大半径算过大致手速后觉得还合适。
- 颜色:从一个色板数组里随机取,不要用纯随机RGB,不然容易出现和背景混在一起的颜色。我选的是红色、蓝色、绿色、黄色、紫色、橙色这一类明显区别于天空底色的值。
此外要设计同屏上限。现在每600毫秒尝试生成一个,但如果玩家点击能力跟不上,气球越积越多,画面会失控,帧率也会被拖累。所以MAX_BALLOONS我设成了30,到了上限后生成逻辑直接跳过,把压力控制在合理范围。
2.3 淘汰、越界与实际的对象回收:为什么从后往前删除
气球的生命周期只有两段:从底部飘上来,直到被点击或者飘出屏幕顶部。因此“删除过期气球”就跟“生成气球”一样重要。
更新逻辑里,每帧遍历气球数组,让y减少,然后判断如果y + r < 0就说明整个气球已经离开画面顶部,直接从数组里splice掉。
这里有个细节比较关键:遍历的时候必须倒序删除。splice会改变数组索引,如果正序循环,删除前面元素后,后面的元素会往前补位,导致原本没遍历到的元素被跳过。倒序遍历就没有这个问题,删除任何一个元素都不会影响还没处理到的索引。这是我建议所有人在写数组过滤删除时都养成习惯的一点。
至于性能,当前同屏最多30个气球,每次splice的时间几乎可以忽略。真正的对象池优化是另一回事:频繁new和销毁对象会让垃圾回收压力变大,但在这个数量级上完全不需要,强行引入对象池反而是过度设计。把代码写简单、写清楚,比写出“看起来很聪明”的复杂方案更重要。
3. Canvas渲染链路与点击命中判定:从点击坐标到气球数学判定
3.1 主循环:requestAnimationFrame与增量时间控制
游戏动画最忌讳的做法是用setInterval(fn, 16)硬编码频率。浏览器的实际刷新率可能是60帧、90帧甚至120帧,setInterval在标签页切到后台时还会被节流甚至暂停,动画会变得忽快忽慢。
正确姿势是requestAnimationFrame:浏览器会在每次准备刷新画面时调用回调,帧率自动和显示设备匹配。主循环的结构是:
function tick(timestamp) { if (state !== State.PLAYING) return; const dt = Math.min(timestamp - prevTimestamp, 100); prevTimestamp = timestamp; spawnIfNeeded(dt); updateBalloons(dt); drawGame(); animFrameId = requestAnimationFrame(tick); }dt是两帧之间的时间差,单位毫秒。有了它,气球移动速度就不依赖实际帧率了。我的做法是把“1.2像素/帧”作为基准帧率16.67ms下的速度,然后通过frameFactor = dt / 16.67做换算。这样做的好处是:浏览器从60帧降到30帧时,气球移动速度保持不变,不会整体变慢。
有一个小坑需要注意:如果用户在游戏途中切到其他标签页,回来时dt可能会很大,比如几万毫秒。如果不做限制,一帧之内所有气球都会瞬移出屏幕。所以Math.min(timestamp - prevTimestamp, 100)这个上限定得很有必要,超过100毫秒按100毫秒处理,画面顶多是轻微跳帧,不会直接崩掉。
3.2 点击命中的数学判定:距离公式和坐标换算
用户点击Canvas时,我们要判断有没有点中气球。在Canvas方案里没有“自动命中”,必须自己算。
核心原理就是点到圆心的距离:如果鼠标点击点(px, py)到气球圆心(b.x, b.y)的直线距离,小于等于气球的半径加上一个容错值HIT_TOLERANCE,就认为是点中了。
const dist = Math.hypot(px - b.x, py - b.y); if (dist <= b.r + CONFIG.HIT_TOLERANCE) { // 命中逻辑 }为什么要加容错值?因为玩家的鼠标不可能每次都非常精准地落在圆内边缘,尤其气球还在移动,差几个像素点不中的体验很糟糕。我实测中HIT_TOLERANCE设6像素比较合适:太小会感觉判定严苛,太大又会觉得“明明是空白却打中”,6是一个几乎感觉不到误差但体验舒适的值。
坐标换算则是另一个容易出问题的地方。Canvas有CSS尺寸和绘图尺寸两套体系。我写的CSS里画布可能被缩放显示,比如移动端画布实际宽度只有400px,但Canvas内部绘图宽度是800px。如果不做换算直接用event.offsetX或者clientX去算,点击判定会整体偏移一半。
正确的做法是拿getBoundingClientRect()拿到画布在页面中的实际显示矩形,然后用比例换算:
const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const px = (e.clientX - rect.left) * scaleX; const py = (e.clientY - rect.top) * scaleY;这段代码同时也是整个项目里最值得反复看的部分:它把“屏幕像素坐标系”转换成了“Canvas绘图坐标系”。理解了这段,之后做任何画布交互都不会被坐标问题卡住。
3.3 画布模糊问题:devicePixelRatio到底是怎么回事
一个我实际跑了很久才注意到的细节:在高分屏(比如部分移动设备、MacBook)上,这个画布看起来会有点毛边模糊。原因出在devicePixelRatio上。
设备像素比的含义简单说就是:一个CSS像素里面实际塞了几个物理像素。在高分屏上这个值是2甚至3,而默认Canvas只在CSS像素尺寸上创建绘图缓冲,等于用了一半的物理分辨率来渲染,放大了自然就糊。
解决思路是让Canvas的物理尺寸放大到CSS像素 × devicePixelRatio,再用ctx.scale(dpr, dpr)把坐标系拉回逻辑尺寸。代码大致是:
const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.scale(dpr, dpr);这段代码我放在完整版里会稍微复杂,所以最终给的示例代码为了保持读源码的清晰度,先不做dpr适配,而是固定800×500。原因有两个:一是让坐标计算和逻辑描述都简单直接,二是不希望初学者因为“画布适配”这个前置问题而放弃理解游戏本身。想做高清适配的,完全可以参考这段自己加上。
4. 完整代码逐段拆解:从HTML骨架到JavaScript渲染循环
4.1 HTML结构与CSS布局
整个项目就是一个单页HTML文件。HTML部分非常轻量:一个展示分数的<span>、一个倒计时、一个<canvas>、一个开始按钮。
CSS用了弹性布局居中游戏容器,背景做成深色渐变色,让画布更突出。画布本身设了cursor: crosshair,鼠标放上去是十字准星样式,这点虽然不起眼但对打气球体验影响很大——没有准星光标的时候,玩家总感觉“瞄不准”,这是纯粹的交互心理问题,值得记下来。
4.2 配置常量与工具函数:把魔法数字收进配置区
写代码我一直坚持一个原则:不把魔法数字散落在逻辑代码里。这个游戏里所有可调参数,我都收进了一个CONFIG对象:
const CONFIG = { GAME_DURATION: 60, BALLOON_MIN_RADIUS: 22, BALLOON_MAX_RADIUS: 38, BALLOON_MIN_SPEED: 0.8, BALLOON_MAX_SPEED: 1.8, BALLOON_CREATE_INTERVAL: 600, MAX_BALLOONS: 30, HIT_TOLERANCE: 6, COLORS: ['#e74c3c', '#3498db', '#2ecc71', '#f1c40f', '#9b59b6', '#e67e22'] };好处是显而易见的:改难度、改时长、改颜色,永远不需要去代码深处翻找,只看这个配置区就够了。这其实也是在模拟真实项目的“配置化”思路,哪怕项目再小,这个习惯都值得保留。
4.3 渲染函数与主循环的搭配
drawGame()函数依旧开启新帧时先清屏,然后画天空渐变、遍历气球数组逐个绘制。气球的绘制不是简单画一个圆,我加了几笔细节:
- 主体圆形用气球的颜色填充;
- 左上角画一个小半透明白色圆形作为高光,模拟气球的塑料反光;
- 底部画一个小尖角,再画一条曲线当绳子。
这几笔代码量很少,但视觉上比一个光秃秃的圆要立体得多。游戏项目里“好看”往往是几个高光和阴影堆出来的,不需要多复杂的图片素材。
绘制代码里我用了ctx.arc()、quadraticCurveTo()这类API,目的是让读者熟悉Canvas基础绘图。主循环的调用顺序是固定的:生成新气球、更新位置、绘制画面。主循环里我单独处理了“同一帧内是否需要多次生成气球”的问题,用while而不是简单的if,避免长时间卡顿后一次只补一个气球导致节奏变慢。
4.4 点击事件与计时器的状态联动
事件处理里最关键的是handleClick函数。我直接改用了pointerdown事件监听,而不是click。原因是click在移动端有大约300毫秒延迟,就算用touch-action处理,手感也不够爽快;pointerdown能同时覆盖鼠标和触摸,响应更跟手。
handleClick里首先判断状态,然后做坐标换算,再到气球数组倒序查找命中。命中后score += 10并立刻生成一个新气球。这里有个细节是“立即补一个”:如果不补,点击频率高的时候画面上的气球数量会快速减少,游戏节奏就断了。
倒计时用了一个独立的setInterval每秒减一次。有人会问,为什么不用主循环里的dt累加来倒计时?简单回答是:setInterval生命周期独立,逻辑更清晰,而且即便主循环卡帧,倒计时也不会静止。但要注意,游戏结束时必须clearInterval,否则下一局会同时跑两个计时器。我在startGame开头做了清理,这个设计建议直接照抄。
4.5 完整可运行代码
以下是整个项目的完整代码,保存成一个.html文件,浏览器直接打开就能玩。我加了不少注释,原因前面说过:这份代码不只是拿来跑的,更是拿来当教材读的。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>HTML5打气球小游戏</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Arial, "PingFang SC", "Microsoft YaHei", sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; justify-content: center; align-items: center; user-select: none; } .game-wrapper { background: #0f3460; border-radius: 16px; padding: 20px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); text-align: center; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; color: #fff; font-size: 18px; } #gameCanvas { width: 100%; max-width: 800px; aspect-ratio: 8 / 5; background: #87CEEB; border-radius: 12px; cursor: crosshair; display: block; margin: 0 auto; touch-action: manipulation; } .controls { margin-top: 12px; display: flex; justify-content: center; gap: 12px; } button { background: #e94560; color: #fff; border: none; border-radius: 8px; padding: 10px 24px; font-size: 16px; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; } button:hover { background: #d63851; transform: translateY(-2px); } button:disabled { background: #888; cursor: not-allowed; } .tip { color: rgba(255, 255, 255, 0.7); font-size: 13px; margin-top: 10px; } </style> </head> <body> <div class="game-wrapper"> <div class="header"> <span>分数:<span id="score">0</span></span> <span>时间:<span id="timer">60</span>s</span> </div> <canvas id="gameCanvas" width="800" height="500"></canvas> <div class="controls"> <button id="startBtn">开始游戏</button> </div> <div class="tip">点击画布中的气球即可得分,60秒内看看你能打中多少个。</div> </div> <script> (function () { // ===== 配置区 ===== const CONFIG = { GAME_DURATION: 60, // 一局时长(秒) BALLOON_MIN_RADIUS: 22, // 最小半径 BALLOON_MAX_RADIUS: 38, // 最大半径 BALLOON_MIN_SPEED: 0.8, // 最小上升速度(像素/帧,基准帧16.67ms) BALLOON_MAX_SPEED: 1.8, // 最大上升速度 BALLOON_CREATE_INTERVAL: 600, // 生成间隔(毫秒) MAX_BALLOONS: 30, // 同屏最大气球数 HIT_TOLERANCE: 6, // 点击判定容错像素 COLORS: ['#e74c3c', '#3498db', '#2ecc71', '#f1c40f', '#9b59b6', '#e67e22'] }; // ===== DOM 引用 ===== const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const scoreEl = document.getElementById('score'); const timerEl = document.getElementById('timer'); const startBtn = document.getElementById('startBtn'); // ===== 游戏状态 ===== const State = { READY: 'ready', PLAYING: 'playing', OVER: 'over' }; let state = State.READY; let score = 0; let timeLeft = CONFIG.GAME_DURATION; let balloons = []; let animFrameId = null; let prevTimestamp = 0; let createTimer = 0; let countdownTimer = null; // ===== 工具函数 ===== function randomRange(min, max) { return min + Math.random() * (max - min); } function randomInt(min, max) { return Math.floor(randomRange(min, max + 1)); } // ===== 气球生成 ===== function createBalloon() { const radius = randomRange(CONFIG.BALLOON_MIN_RADIUS, CONFIG.BALLOON_MAX_RADIUS); return { x: randomRange(radius + 10, canvas.width - radius - 10), y: canvas.height + radius + 10, r: radius, color: CONFIG.COLORS[randomInt(0, CONFIG.COLORS.length - 1)], speed: randomRange(CONFIG.BALLOON_MIN_SPEED, CONFIG.BALLOON_MAX_SPEED), alive: true }; } function spawnIfNeeded(dt) { createTimer += dt; // 用 while 而不是 if:避免长时间卡顿后一次只补一个气球 while (createTimer >= CONFIG.BALLOON_CREATE_INTERVAL) { if (balloons.length >= CONFIG.MAX_BALLOONS) { createTimer = 0; break; } createTimer -= CONFIG.BALLOON_CREATE_INTERVAL; balloons.push(createBalloon()); // 气球数量偏少时多补一个,保持画面不冷场 if (balloons.length < 6) { balloons.push(createBalloon()); } } } // ===== 更新逻辑 ===== function updateBalloons(dt) { const frameFactor = dt / 16.67; for (let i = balloons.length - 1; i >= 0; i--) { const b = balloons[i]; b.y -= b.speed * frameFactor; // 飘出屏幕顶部的气球直接淘汰 if (b.y + b.r < 0) { balloons.splice(i, 1); } } } // ===== 渲染 ===== function drawSky() { const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height); gradient.addColorStop(0, '#87CEEB'); gradient.addColorStop(1, '#d6eaf8'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvas.width, canvas.height); } function drawBalloon(b) { // 气球主体 ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fillStyle = b.color; ctx.fill(); // 高光 ctx.beginPath(); ctx.arc(b.x - b.r * 0.3, b.y - b.r * 0.3, b.r * 0.18, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.5)'; ctx.fill(); // 底部小尖角和绳子 const tipX = b.x; const tipY = b.y + b.r; ctx.beginPath(); ctx.moveTo(tipX - 5, tipY); ctx.lineTo(tipX + 5, tipY); ctx.lineTo(tipX, tipY + 10); ctx.closePath(); ctx.fillStyle = b.color; ctx.fill(); ctx.beginPath(); ctx.strokeStyle = 'rgba(0, 0, 0, 0.35)'; ctx.lineWidth = 1; ctx.moveTo(tipX, tipY + 10); ctx.quadraticCurveTo(tipX + 4, tipY + 13, tipX + 2, tipY + 18); ctx.stroke(); } function drawGame() { ctx.clearRect(0, 0, canvas.width, canvas.height); drawSky(); for (const b of balloons) { drawBalloon(b); } if (state === State.READY) { ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#fff'; ctx.font = 'bold 36px Arial, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('点击"开始游戏"', canvas.width / 2, canvas.height / 2 - 20); ctx.font = '18px Arial, sans-serif'; ctx.fillText('看准气球,点下去!', canvas.width / 2, canvas.height / 2 + 30); } if (state === State.OVER) { ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#f1c40f'; ctx.font = 'bold 42px Arial, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('时间到!', canvas.width / 2, canvas.height / 2 - 30); ctx.fillStyle = '#fff'; ctx.font = 'bold 30px Arial, sans-serif'; ctx.fillText('得分:' + score, canvas.width / 2, canvas.height / 2 + 25); } } // ===== 主循环 ===== function tick(timestamp) { if (state !== State.PLAYING) { animFrameId = null; return; } if (!prevTimestamp) prevTimestamp = timestamp; const dt = Math.min(timestamp - prevTimestamp, 100); prevTimestamp = timestamp; spawnIfNeeded(dt); updateBalloons(dt); drawGame(); animFrameId = requestAnimationFrame(tick); } // ===== 计分与时序 ===== function startCountdown() { countdownTimer = setInterval(function () { timeLeft -= 1; timerEl.textContent = timeLeft; if (timeLeft <= 0) { gameOver(); } }, 1000); } function startGame() { if (animFrameId) cancelAnimationFrame(animFrameId); if (countdownTimer) clearInterval(countdownTimer); state = State.PLAYING; score = 0; timeLeft = CONFIG.GAME_DURATION; balloons = []; prevTimestamp = 0; createTimer = 0; scoreEl.textContent = score; timerEl.textContent = timeLeft; startBtn.textContent = '进行中...'; startBtn.disabled = true; // 开局先来一排气球,让玩家进入状态 for (let i = 0; i < 5; i++) { balloons.push(createBalloon()); } drawGame(); animFrameId = requestAnimationFrame(tick); startCountdown(); } function gameOver() { state = State.OVER; clearInterval(countdownTimer); startBtn.textContent = '再来一局'; startBtn.disabled = false; drawGame(); } // ===== 点击判定 ===== function handleClick(e) { if (state !== State.PLAYING) return; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const px = (e.clientX - rect.left) * scaleX; const py = (e.clientY - rect.top) * scaleY; // 倒序遍历,优先命中视觉上最上层的气球 for (let i = balloons.length - 1; i >= 0; i--) { const b = balloons[i]; const dist = Math.hypot(px - b.x, py - b.y); if (dist <= b.r + CONFIG.HIT_TOLERANCE) { balloons.splice(i, 1); score += 10; scoreEl.textContent = score; balloons.push(createBalloon()); drawGame(); return; } } } // ===== 事件绑定 ===== canvas.addEventListener('pointerdown', handleClick); startBtn.addEventListener('click', function () { if (state === State.READY || state === State.OVER) { startGame(); } }); // 初始渲染 drawGame(); })(); </script> </body> </html>5. 跑起来之后的实测观察、兼容性排查与下一步扩展方向
5.1 性能观察:气球数量、帧率与垃圾回收
代码写完在桌面浏览器实测,最直观的感受是流畅度完全没压力。Canvas只画圆形、渐变、线段这些基础图形,同屏气球即使冲到30个上限,帧率也稳定在60帧。
这里有个值得观察的现象:气球的创建和销毁其实一直在发生,每次生成气球都要新建一个对象,点击命中又要splice加新建。有人会担心这种频繁创建销毁是否影响性能。实测下来,30个对象对现代浏览器来说属于“痒痒挠”级别,完全不需要用对象池来优化。真正的性能瓶颈在于我之前提到的“大型代码块触发GC停顿”,但那是几百上千个对象才需要考虑的事。
做练手项目时,我建议大家养成用Chrome DevTools的Performance面板看一眼的习惯。哪怕项目很小,里面能看的东西都不少:主线程占用率、每帧耗时、是否有长任务阻塞。这个游戏我测下来主线程占用大约在5%到10%之间,足够说明Canvas方案的余量有多大。
5.2 移动端适配:CSS缩放与pointer事件带来的兼容差异
我在手机浏览器上也试了这份代码。CSS里max-width和aspect-ratio保证了画布会被等比缩小,touch-action: manipulation消除了移动端点击延迟,pointerdown也保证了触摸响应速度。
这三点组合下来,移动端的点击判定依然准确,因为坐标换算是基于getBoundingClientRect的动态值,画布无论被放大还是缩小,都能把屏幕坐标还原回绘图坐标。
有一点要提醒:如果真要上移动端做比较正式的项目,建议把GAME_DURATION缩短到30秒左右,因为手机上手指点击区域比鼠标大也用不了那么精确,适合做“短平快”的节奏。另外,手机横屏视野更宽,但Canvas是固定800×500比例的,在竖屏下画布会缩得很小,这时可以考虑把画布改成纵向长条比例,这是这款游戏比较适合调整的地方。
5.3 从打气球延伸出去:音效、道具、关卡与排行榜
这个基础版本跑通后,接下来的扩展方向非常多,我列几个性价比最高的:
音效与命中反馈:用AudioContext生成短促的“啵”音,几行代码就能搞定,但反馈感立刻提升一个档次。这是我认为排第一的扩展项。
不同分值的气球:金色气球加50分,灰色气球打了扣分,能增加博弈感。记得配置区里预留的alive字段,这时候就派上用场了。
连击系统:连续命中时显示连击数,连击越高单球分值越高,这能有效提升上头指数和重玩率。
难度曲线:目前气球速度是固定的,可以改成“游戏每过10秒,气球整体速度提升10%”。改起来只动CONFIG和生成函数里的速度计算,但游戏体验会明显更有层次。
本地排行榜:用localStorage保存历史最高分,在gameOver时写进去,开场时读出来。这个扩展能让人多玩好几局,也是练手的好机会。
这些方向我建议一个一个来,改一次量一次鼠标,保持项目始终处于“能跑”的状态。不要一次性全部塞进去,否则就又回到开头说的“收不了尾”的老问题。
这个项目做完之后我的一个真实体会是:小游戏练手项目的价值不在于功能多炫,而在于让你完整走一遍“设计对象、管理状态、处理交互、观察性能、迭代调整”的闭环。打气球是那个最趁手的小切口,把这条链路走顺了,后面再去做复杂项目时就能理解那些框架和工程工具到底解决了什么问题。