ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原生三件套开发打气球小游戏:状态管理、事件委托与动画循环

原生三件套开发打气球小游戏:状态管理、事件委托与动画循环

刚把HTML5打气球这个小游戏做完,趁着代码还热乎,我把整个项目的设计思路、核心实现和踩坑过程整理成一份分析报告。这个项目虽然看起来简单,就是一个点击气球得分的休闲小游戏,但它几乎把前端三件套里最常用的知识点都串起来了:DOM操作、事件处理、CSS动画、定时器、随机数逻辑、状态管理,甚至还能顺带练到性能优化和移动端适配。我见过不少初学者用各种框架写小游戏,结果被脚手架和依赖折腾得够呛,其实用原生HTML加CSS加JavaScript就能把一个游戏从零写到能玩、能计分、能结束、能重开,这个过程对理解浏览器工作原理特别有帮助。

我写的这个版本是这样的:屏幕上会不断冒出大小不一的气球,从底部向上升,你需要在它们飞走之前点中它们,每点中一个就加分,点空或者让气球飞走会累计失误,倒计时结束游戏停止,最后显示本局得分,还会把最高分记录在浏览器本地。如果你正在学前端,或者刚看完基础语法想找个综合练习,这份报告里的完整代码和拆解思路可以直接抄作业,我尽量把每个设计决策的为什么也说清楚,不只是贴一段能跑的代码。

1. 项目整体设计与技术选型解析

1.1 为什么选原生三件套而不是框架和小游戏引擎

打气球这个项目在技术选型上有个很典型的判断标准:一个项目该不该上框架、该不该上引擎,取决于它的状态复杂度和渲染压力。我做这个游戏的时候,游戏内同时存在的动画对象最多二十来个,状态只有分数、失误数、倒计时、是否结束这几项,完全没有组件树更新、跨组件通信、复杂状态同步的需求。这种情况下引入框架反而多了一层心智负担——JSX要编译,状态管理要设计,打包要配置,本来一个HTML文件双击就能跑的事,硬生生变成了一个工程。

小游戏引擎就更没必要了。引擎的强项是复杂的场景管理、精灵动画、物理碰撞、多平台发布,而打气球的核心判断只是“鼠标点在不在气球上”、核心反馈只是“气球裂开并消失”。用Canvas加引擎能实现,但用DOM元素加CSS动画同样能实现,而且DOM方案对初学者更友好,调试的时候可以在浏览器开发者工具里直接看到每个气球的样式和位置,出了问题一眼就能定位。我个人的原则是:能用简洁方案就不用重型方案,等核心逻辑跑通、确认性能瓶颈真的出现之后,再谈优化甚至重构。

1.2 游戏循环与状态管理:如何组织一段“永动”逻辑

网页游戏和普通网页最大的区别在于,普通页面是用户操作触发变化,而游戏是不断有东西在自行变化。打气球游戏里,气球会持续生成、持续上升、持续飞出屏幕,同时倒计时一直在走。这就需要一个“游戏循环”,本质上是一段反复执行的逻辑,负责更新所有物体的状态,再把新状态渲染到页面上。

我用了两种机制配合:requestAnimationFrame负责每帧更新气球位置,setInterval负责控制倒计时和气球生成节奏。这里有个关键点容易搞混——requestAnimationFrame是跟着显示器刷新率走的,通常是每秒60帧,适合做连续位置更新;setInterval的最小间隔虽然可以设得很小,但浏览器在标签页切到后台时会把它降频甚至挂起,所以不能拿它做逐帧动画。倒计时用setInterval没问题,因为倒计时精度要求不高,而且游戏没结束前用户几乎不会切走标签页。

整个游戏状态我用一个对象统一管理:gameState里放着score、miss、timeLeft、isRunning、highScore这几个字段。之所以集中而不是散落在各种全局变量里,是因为重置游戏、结束游戏、更新UI这几件事都要反复读写这些值,集中管理后只需要写一个updateUI()函数就能保证界面和状态永远一致。这是我从做表单项目里学来的教训:状态分散之后,最容易出现界面上显示的和实际值对不上。

1.3 渲染方案权衡:DOM元素加CSS动画,还是JavaScript逐帧挪位置

气球上升这个动作,我最初想的是最省事的方式:给气球加一个CSS动画,让它在几秒内从底部平移到顶部,用animation-fill-mode: forwards停在终点。但实测下来发现一个麻烦——CSS动画执行过程中,元素的实际位置是由浏览器合成的,JavaScript很难在任意时刻拿到气球的实时坐标。气球点中后要做“破裂”反馈,或者当它飞出屏幕时要精确知道它是否已经越界,这些判断都依赖坐标信息。用getBoundingClientRect()去取位置每帧都做一次也不是不行,但频繁触发布局读取会拖慢页面。

所以我把方案改成了JavaScript逐帧控制top值:每帧根据气球的速度加速度计算新位置,直接写入style.transform,再用translateY让位置变化发生在合成层,尽量不触发重排。这样做有几个好处:一是命中判断可以直接拿数值算;二是气球可以根据难度动态变速;三是点中后可以瞬间冻结位置,做破裂动画。代价是每帧要多写几行代码,但这笔交易很划算。纯CSS动画适合的是固定轨道、不需要交互判断的场景,一旦涉及“点击命中”和“动态逻辑”,就得把控制权交给JavaScript。

2. 核心玩法拆解与关键细节实现

2.1 气球生成器:随机位置、随机大小、随机速度的原理

气球不能机械地从一个点冒出来,那样玩一两局就腻了。我的生成器由三个随机变量组成:水平起始位置、气球的直径、上升速度。水平位置要避开屏幕边缘,不然气球一半在屏幕外,点击区域不完整,所以我让left的取值范围是[50, 容器宽度 - 50]。直径在60px到100px之间随机,这个范围是我试出来的——太小了难点,特别是手机上手指头一挡就看不见;太大了整个屏全是气球,视觉上很乱。速度则根据当前难度动态计算,基础速度从1.2开始,每过10秒提升0.2,封顶3.0。

生成间隔也是动态的。开局是每800毫秒生成一个,随着难度提升逐步缩到400毫秒。这里要小心一个问题:如果生成间隔小于单个气球的存活时间,屏幕上的气球就会只增不减,最后叠成一片。所以我计算了气球从底部到顶部大约需要的时间(容器高度除以速度),保证生成间隔始终大于这个存活时间的二分之一,给玩家留出反应余量。这些参数我没有拍脑袋定,而是用一个简单的规则控制:上手的前10秒要让玩家觉得“很容易”,后面再逐步施压,不然挫败感太强。

2.2 命中判定与事件委托:为什么不用每个气球单独绑定事件

最直白的做法是给每个新生成的气球addEventListener('click', handler),但我没这么干,而是用了事件委托:只给游戏容器绑定一次点击事件,然后通过event.target判断用户点的是不是气球。这样做的好处,一是气球数量多了之后不用反复绑定解绑事件,二是动态创建的元素不需要额外初始化事件,三是在事件处理里可以统一拦截误触。

判断命中有一个关键细节:气球的外层是个div,里面有可能是气球主体或者破裂后生成的碎片元素。我用closest('.balloon')向上查找最近的元素,如果找到了,就说明点中了气球。这里千万别直接判断event.target.classList.contains('balloon'),因为一旦气球里还有子元素,点击子元素的时候这个判断就失效了。我用的是pointerdown事件而不是click,因为pointerdown触发时机更早,玩家的操作感更跟手,尤其在快速连点时,click可能会因为元素移动而产生延迟感,pointerdown基本没有延迟。

2.3 计分与失误规则:双击保护、误触拦截、分子分母控制

计分规则表面简单:点中气球得1分。但实际操作中有两个隐蔽的问题。第一个是双击误触:一个气球被点中后,动画还没播完,玩家又快速点了同一个位置,结果分数又加了一次。解决办法是在气球被点中的瞬间加一个标记,比如给它设置>balloon-game/ index.html style.css game.js

跑起来只要用浏览器打开index.html就行。这里需要提醒一句:如果用file://协议直接打开,某些浏览器可能会限制localStorage,导致最高分存不上。最好在本地起一个静态服务,比如在项目目录下执行python -m http.server 8000或者用VS Code的Live Server插件,访问http://localhost:8000。这个问题我遇到过不止一次,很多人写好了代码双击打开,发现高分记录一刷新就没了,以为是逻辑写错了,其实只是协议限制。

3.2 HTML骨架:语义化容器与页面布局

页面布局我分了三个区域:顶部状态栏、游戏区、底部控制栏。状态栏里放分数、失误、倒计时,游戏区是气球的活动范围,控制栏放一个开始按钮和重开按钮。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="style.css"> <title>打气球小游戏</title> </head> <body> <main class="game-wrapper"> <header class="score-board"> <div class="score-item">得分 <span id="score">0</span></div> <div class="score-item">失误 <span id="miss">0</span></div> <div class="score-item">倒计时 <span id="timer">60</span></div> <div class="score-item">最高分 <span id="highScore">0</span></div> </header> <section class="game-container" id="gameArea"> <div class="game-over-panel" id="gameOverPanel"> <h2>游戏结束</h2> <p>本局得分:<span id="finalScore">0</span></p> <button id="restartBtn">再来一局</button> </div> </section> <footer class="control-bar"> <button id="startBtn">开始游戏</button> </footer> </main> <script src="game.js"></script> </body> </html>

meta viewport这个标签不能省,没有它手机打开时页面会按980px宽度渲染,游戏区域会变得非常小。game-over-panel默认隐藏,游戏结束时展示出来,用CSS的display: none控制显隐即可,没有必要去做复杂的动画。状态栏里的span元素都用id标记,是为了在JavaScript里直接getElementById修改文本,简单直接。

3.3 CSS核心:弹性布局、动画反馈和移动端适配

CSS部分最需要花心思的是气球的视觉样式和点中后的破裂动画。气球本身我用一个圆形的div,通过background: radial-gradient做出高光效果,再用手写一个带三角形的“气球嘴”伪元素。高光很重要,没有高光的气球看起来就是一个色块,非常廉价。破裂动画则是用@keyframes配合transform: scale加透明度,让气球在被点中的瞬间先放大到1.2倍再缩小消失,配合碎片飞散的效果。

* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; } body { font-family: Arial, sans-serif; background: linear-gradient(180deg, #a8d8ff, #e0f2ff); min-height: 100vh; display: flex; justify-content: center; align-items: center; } .game-wrapper { width: min(90vw, 420px); background: rgba(255, 255, 255, 0.6); border-radius: 16px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); overflow: hidden; } .score-board { display: flex; justify-content: space-around; padding: 12px 8px; background: #ffffff; font-weight: bold; border-bottom: 1px solid #ddd; } .score-item { font-size: 14px; color: #333; } .score-item span { color: #ff5722; font-size: 18px; } .game-container { position: relative; width: 100%; height: 70vh; max-height: 600px; min-height: 400px; overflow: hidden; touch-action: manipulation; } .balloon { position: absolute; bottom: -120px; border-radius: 50%; cursor: pointer; background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.9), var(--color, #ff6b6b) 40%, var(--color, #ff6b6b)); border: 2px solid rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .balloon::after { content: ''; position: absolute; bottom: -12px; left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 12px solid rgba(0, 0, 0, 0.2); } .balloon.pop { animation: pop .3s forwards; } @keyframes pop { 0% { transform: scale(1); opacity: 1; } 40% { transform: scale(1.3); opacity: 1; } 100% { transform: scale(0); opacity: 0; } } .control-bar { padding: 12px; text-align: center; background: #ffffff; border-top: 1px solid #ddd; } .control-bar button { padding: 10px 24px; font-size: 16px; border: none; border-radius: 8px; background: #ff5722; color: #fff; cursor: pointer; } .game-over-panel { position: absolute; inset: 0; display: none; flex-direction: column; justify-content: center; align-items: center; background: rgba(0, 0, 0, 0.6); color: #fff; z-index: 10; } .game-over-panel.show { display: flex; }

user-select: none是为了防止快速连点时选中文本,这在游戏场景很重要,不然点几下屏幕上全是蓝色选区。touch-action: manipulation是为了消除移动端双击缩放延迟。气球颜色我用var(--color)这个CSS变量控制,这样在JavaScript里动态设置不同的球体颜色非常方便,只需要改element.style.setProperty('--color', '#ffcf40')就能换色,不用每个颜色去写一个类。

3.4 JavaScript核心:从初始化到游戏结束的完整逻辑

JavaScript是整个游戏的大脑。我按功能拆成几个部分:状态管理、生成器、更新循环、事件处理、UI刷新、游戏生命周期。下面是完整代码,我在关键位置写了注释。

const gameArea = document.getElementById('gameArea'); const scoreEl = document.getElementById('score'); const missEl = document.getElementById('miss'); const timerEl = document.getElementById('timer'); const highScoreEl = document.getElementById('highScore'); const startBtn = document.getElementById('startBtn'); const restartBtn = document.getElementById('restartBtn'); const gameOverPanel = document.getElementById('gameOverPanel'); const finalScoreEl = document.getElementById('finalScore'); const gameState = { score: 0, miss: 0, timeLeft: 60, isRunning: false, maxMiss: 10, highScore: Number(localStorage.getItem('balloonHighScore') || 0), balloonCount: 0, }; const colors = ['#ff6b6b', '#ffcf40', '#4ecdc4', '#6bc9ff', '#c780fa', '#ff9f43']; let spawnTimer = null; let countdownTimer = null; let animationFrameId = null; highScoreEl.textContent = gameState.highScore; function startGame() { resetState(); gameState.isRunning = true; startBtn.disabled = true; startBtn.textContent = '游戏中...'; gameOverPanel.classList.remove('show'); spawnTimer = setInterval(spawnBalloon, 800); countdownTimer = setInterval(() => { gameState.timeLeft--; updateUI(); if (gameState.timeLeft <= 0) { endGame(); } }, 1000); requestAnimationFrame(updateBalloons); } function resetState() { gameState.score = 0; gameState.miss = 0; gameState.timeLeft = 60; gameState.balloonCount = 0; document.querySelectorAll('.balloon').forEach(b => b.remove()); clearInterval(spawnTimer); clearInterval(countdownTimer); updateUI(); } function spawnBalloon() { if (!gameState.isRunning) return; const containerRect = gameArea.getBoundingClientRect(); const size = Math.random() * 40 + 60; const left = Math.random() * (containerRect.width - size + 60) + 30; const speed = Math.min(1.2 + (60 - gameState.timeLeft) * 0.03, 3.0); const balloon = document.createElement('div'); balloon.className = 'balloon'; balloon.dataset.hit = 'false'; balloon.dataset.speed = speed; balloon.style.width = size + 'px'; balloon.style.height = size + 'px'; balloon.style.left = left + 'px'; balloon.style.bottom = '-120px'; balloon.style.setProperty('--color', colors[Math.floor(Math.random() * colors.length)]); gameArea.appendChild(balloon); gameState.balloonCount++; } function updateBalloons() { const balloons = document.querySelectorAll('.balloon'); const containerHeight = gameArea.clientHeight; balloons.forEach((balloon) => { const bottom = parseFloat(balloon.style.bottom) || -120; const speed = parseFloat(balloon.dataset.speed); const newBottom = bottom + speed; if (newBottom > containerHeight + 10) { if (balloon.dataset.hit === 'false' && gameState.isRunning) { gameState.miss++; updateUI(); if (gameState.miss >= gameState.maxMiss) { endGame(); } } balloon.remove(); } else { balloon.style.bottom = newBottom + 'px'; } }); if (gameState.isRunning) { animationFrameId = requestAnimationFrame(updateBalloons); } } gameArea.addEventListener('pointerdown', (e) => { if (!gameState.isRunning) return; const balloon = e.target.closest('.balloon'); if (!balloon) return; if (balloon.dataset.hit === 'true') return; balloon.dataset.hit = 'true'; gameState.score++; balloon.classList.add('pop'); setTimeout(() => balloon.remove(), 350); updateUI(); }); function updateUI() { scoreEl.textContent = gameState.score; missEl.textContent = gameState.miss + ' / ' + gameState.maxMiss; timerEl.textContent = gameState.timeLeft; if (gameState.score > gameState.highScore) { gameState.highScore = gameState.score; highScoreEl.textContent = gameState.highScore; } } function endGame() { gameState.isRunning = false; clearInterval(spawnTimer); clearInterval(countdownTimer); cancelAnimationFrame(animationFrameId); document.querySelectorAll('.balloon').forEach(b => b.remove()); if (gameState.score > gameState.highScore) { gameState.highScore = gameState.score; localStorage.setItem('balloonHighScore', gameState.highScore); } finalScoreEl.textContent = gameState.score; gameOverPanel.classList.add('show'); startBtn.disabled = false; startBtn.textContent = '开始游戏'; } startBtn.addEventListener('click', startGame); restartBtn.addEventListener('click', () => { gameOverPanel.classList.remove('show'); startGame(); });

有几个细节我在写的时候特别注意。气球的位置我是用bottom而不是top来控制的,因为CSS里面bottom更符合“从底部冒出来”的直觉,而且当气球飞出去时判断newBottom > containerHeight更加直观。气球的初始bottom设置成了-120px,也就是说它在屏幕下方之外先出现,然后慢慢升上来,玩家不会看到一个气球凭空在底部冒出一截,观感更自然。updateBalloons函数每帧都会重新查一次querySelectorAll('.balloon'),这在气球数量不多的情况下性能消耗可以忽略,但如果以后要支持上百个气球同时存在,建议改成用一个数组来维护气球对象,避免反复查询DOM。

3.5 难度曲线调参:让前10秒简单,后30秒刺激

参数调节是游戏体验的灵魂。我一开始把生成间隔固定在了500毫秒,速度是1.5,结果玩起来非常手忙脚乱,尤其开局还没准备好,屏幕上瞬间就有七八个气球在飘,失误率很高。后来我用了一个阶梯式难度:每隔10秒,生成间隔减少80毫秒、速度增加0.3,到最后一个区间维持稳定。直观地说,前10秒是热身,中间20秒是正常节奏,最后30秒才是极限挑战。这个曲线不是一次定出来的,我反复试了几轮才确定。这里分享我的调节心得:先固定一个让你觉得“刚好能应付”的中段参数,再往两个方向推导开局和后期参数,你会发现手感立刻就有了。

4. 常见问题排查与性能避坑实录

4.1 气球点不中或者点了没反应

这个问题十有八九是事件绑定的位置不对。我最初版本把点击事件绑定到了每个气球上,但气球移动速度加快后,click事件在mousedown和mouseup之间如果元素移出了手指范围,就不会触发。改成pointerdown并委托到容器上之后,问题就消失了。如果还有问题,检查一下gameState.isRunning为false时的事件拦截,游戏结束之后残留的气球虽然会被清除,但如果事件监听器直接挂在气球上,可能会出现内存泄漏。

4.2 游戏运行一会儿变卡,点击有延迟

这个问题要分两层排查。第一层是检查是否有内存泄漏——被点中的气球如果只是加了隐藏样式而没有remove(),它们会一直挂在DOM树里,越积越多,页面自然会越来越卡。第二层是事件堆积——如果requestAnimationFrame在游戏结束后没有用cancelAnimationFrame取消,循环会继续空转,白白消耗处理器资源。我用Chrome DevTools的Performance面板录过一段60秒的游戏过程,挂了3分钟没有操作后,内存曲线依然平稳,才算放心。

4.3 手机上玩的时候气球位置偏移、字体太小

移动端和PC端的差异主要体现在容器宽度和点击精度上。我在CSS里用的是position: absolute加left像素值,如果容器宽度用百分比,气球生成时计算containerRect.width是相对当前视口算的,一般不会偏差。但要注意100vh在某些手机浏览器上会包含地址栏的高度,导致游戏区底部被遮挡。我最终用了height: 70vh加min-height: 400px的折中方案,既保证小屏上能玩,又不至于把地址栏算进去。字号方面,我测过几台安卓机,14px到18px的状态栏信息在小屏上都还看得清,就不再放大了。

4.4 最高分存不上、一刷新就丢

前面提过,file://协议下localStorage可能不可用。更隐蔽的问题是:如果用户在隐私模式下打开页面,写入localStorage会抛出异常,如果我没做容错处理,游戏会在结束时报错。所以我在读取和写入高分的代码外面都加了try...catch包裹。这个细节一般教程不会讲,但实际环境里很容易遇到。

4.5 常见问题速查表

现象可能原因解决办法
气球点中没反应click在快速移动中失效改用pointerdown并委托到容器
分数一次加2分事件触发两次或子元素冒泡用>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进