☰
原生JavaScript实现打气球小游戏:从零搭建前端交互项目
2026/10/13 12:08:22 网站建设 项目流程

开头

最近整理了一个用原生 HTML + CSS + JavaScript 写的 HTML5 打气球小游戏项目,前后迭代了好几版。这游戏看起来就是个“点一点”的休闲小东西,但真动手做起来,里面涉及随机生成、动画驱动、事件绑定、状态管理、计时结算好几个关键环节,几乎把前端基础的方方面面都串了一遍。网上类似的代码很多,但大多只丢个效果出来,关键逻辑为什么这么写、踩了哪些坑,基本没人讲。

所以我把这个项目从需求拆解、结构设计到核心逻辑、问题排查完整梳理了一遍,同时也把可直接运行的完整代码附在文末。适合刚学完三件套想做第一个完整项目的人,也适合那些已经能做静态页面、但想练一练交互逻辑和动画控制的朋友。这篇文章会把每个关键设计背后的思考都摊开来讲,让你不光是抄代码,而是真的明白这东西是怎么跑起来的。

1. 项目拆解:打气球游戏到底在练什么

1.1 核心玩法与功能清单

先把游戏完整过一遍:开局后游戏区域内不断出现五颜六色的气球,气球从底部升起、逐渐向上飘动。玩家需要在气球飘出屏幕之前点击爆破它,每成功点爆一个得 1 分,游戏限时 30 秒,时间结束后弹出结算面板并显示最终得分,点击“再来一局”可以重新开始。

就这么简单一个循环,拆下来可以整理出下面这张功能清单:

功能模块行为描述涉及技术点
气球生成随机位置、随机颜色、随机速度生成气球DOM 创建、随机算法
气球移动气球从底部向上持续飘动requestAnimationFrame 或 CSS 动画
点击爆破点击气球后播放破裂效果并移除事件委托、状态标记、CSS 过渡
计分系统每爆破一个气球加 1 分数据更新与 DOM 同步
倒计时系统30 秒倒计时,归零时结束游戏setInterval 与状态切换
游戏重置结束后可重新开局,所有数据归零状态重置、定时器清理

这个清单就是整个项目的“需求文档”。我强烈建议每个准备动手做项目的人,开工前先把功能拆成这样的表格,不用特别正式,但一定要把每个模块的职责和涉及的技术点写清楚。这个习惯能避免写到一半“东补西补”,越写越乱。

1.2 为什么只选原生三件套,而不是框架或 Canvas

我见过不少人上来就想用 Vue 或者 React 写这种小游戏,也有人直接开 Canvas 画。作为练手项目,我反而强烈推荐用最原始的方式:HTML 负责结构、CSS 负责样式和部分动画、JavaScript 负责逻辑,三个文件分得清清楚楚。

原因有三点:

第一,用原生 DOM 操作能逼你理解浏览器的渲染机制。什么时候创建节点、什么时候销毁节点、事件委托为什么能省性能,这些问题在用框架时往往被底层封装掩盖了。自己动手 append、remove 一遍,比看十篇框架文档都管用。

第二,Canvas 虽然画气球很爽,但对于刚入门的人来说,它的“画了就不能改”“全部自己控制坐标系”模式很容易劝退。打气球这个游戏本质上就是一堆大小不一的圆形 DOM 元素在移动,用 CSS + DOM 做出来天然就有盒模型、伪元素、过渡动画这些基础知识点,学习密度更高。

第三,纯三件套的项目可以直接双击 HTML 文件打开运行,不需要 npm install、不需要构建步骤。对于初学者来说,能立刻看到效果、随手改刷新就能看到变化,这种正反馈非常重要,能让你把精力完全集中在逻辑本身,而不是被工具链牵着走。

如果你已经用框架写腻了 CRUD 页面,再回头用原生写一个小游戏,也会有一种“原来基础这么重要”的体会。这个项目就是那块最趁手的磨刀石。

2. 核心机制设计:怎么让“打气球”手感像样

2.1 气球的生成策略:随机数、分层与节奏控制

气球的生成是整个游戏的入口。如果气球刷新位置没有处理好,前一个气球还没飘走、后一个就在同一个位置叠上来,看起来会非常别扭。但在小游戏范畴内,我不建议做严格的碰撞检测——那种对于一个休闲游戏来说太过笨重,而且代码复杂度会成倍上升,穷举两两之间的位置关系完全是在给自己找麻烦。

我采用的方案是:游戏区域用position: relative的容器,每个气球用position: absolute定位,x 坐标在[容器宽度 - 气球直径]范围内随机,y 坐标从容器底部的一小段范围内随机起步(比如底部 0 到 100px 区间),这样第一批气球看起来是“次第冒出来”的,而不是全部整整齐齐从边缘起飞。

气球直径我固定在 60px。也就是说,生成算法里要让随机范围留出这段距离:

const x = Math.random() * (gameArea.clientWidth - BALLOON_SIZE); const y = gameArea.clientHeight - 40 - Math.random() * 80;

这里第一行的clientWidth - BALLOON_SIZE就是为了防止气球一半身子超出右边界。第二行的-40是预留底部视觉空隙,- Math.random() * 80让多个气球不在同一条水平线上同时冒头。

生成节奏同样需要控制。我一般设置两个参数来控制:生成间隔和气球速度。默认间隔在 800ms 到 1300ms 之间随机,速度在 50px/s 到 120px/s 之间随机。为什么不用固定间隔?因为如果所有气球以完全一致的节奏生成和运动,玩起来会显得非常机械,玩家很快就能预判下一个气球的位置,游戏就失去挑战感了。

还有一点很重要:同屏气球数量需要上限。我限制同时存在的气球不超过 10 个,数量到达上限时暂停生成。原因后面会详细说,这里先记住结论——无限生成 DOM 节点是游戏卡顿的头号元凶。

2.2 动画驱动方案:requestAnimationFrame 才是正解

气球的移动效果有两种实现思路:CSS 动画(transform + transition)和 JavaScript 的 requestAnimationFrame(简称 rAF)逐帧驱动。我把两种方案的优缺点放在一起对比:

方案优点缺点适用场景
CSS 动画代码简单、浏览器自动优化、不需要手动管理帧率中途改速度比较麻烦,需要额外获取动画状态,移除节点时容易产生动画残留固定轨迹、不需要与游戏状态联动的效果
requestAnimationFrame每帧都可以做逻辑判断,能精确控制每个气球的位置,能与碰撞、计分等联动需要自己管理循环、清除上一帧的监听游戏核心循环、多个对象同时运动、随时需要改变行为的场景

这个项目里我选了 rAF。主要原因是游戏内的气球位置需要随逻辑不断更新,而且气球爆破时还要触发一套独立的 CSS 过渡动画,最后再统一从 DOM 中移除。如果全用 CSS 动画,很容易出现“动画还没播完节点已经被删了”“倒计时结束了气球的定时器还在跑”这类竞态问题。

rAF 的实现模式很固定。核心思路是维护一个更新函数,浏览器每次刷新屏幕前都会调用它一次,在更新函数里遍历所有气球对象,把每个气球的 y 坐标减去速度乘以时间差,然后把这个新坐标写回 DOM。正常情况下浏览器刷新率是 60Hz,也就是说每秒会跑 60 次更新,肉眼看起来就是流畅的连续上升。

这里有一个性能要点:rAF 回调用deltaTime(时间差)来计算每帧的移动距离,而不是每帧固定减少固定像素。虽然桌面浏览器高刷屏下常用 60Hz,但如果你写死每帧移动某个固定值,到了 120Hz 的显示器上气球就会快一倍,游戏体验完全不同。用deltaTime之后,移动距离 = 速度 × 时间差,物理直觉就和现实一致了。

2.3 点击交互与计分逻辑:事件委托兼防重复点击

点击处理是这个小游戏里最值得展开的部分。初学者最常见的写法是给每个气球元素都绑定一个click事件,这在小规模下没什么问题,但这个游戏的气球是不断动态增删的,每页也不断创建新节点,如果所有气球都绑独立监听器,监听器数量会随着游戏时长堆积,内存压力和 GC 压力都会上来。

我用的是事件委托。具体做法是在游戏容器上挂一个click监听器,通过event.target判断被点击的元素是不是一个气球,再拿它身上的自定义属性或数据去匹配气球数据对象。形象点说,这就像餐厅里只有一个服务员站在门口,客人一进来先看对方的需求再去分配菜单,而不是每个桌椅旁边各安排一个服务员。

关键代码示意:

gameArea.addEventListener('click', (e) => { const balloonEl = e.target.closest('.balloon'); if (!balloonEl) return; // 通过数据索引找到对应的气球对象 const index = Number(balloonEl.dataset.index); popBalloon(index); });

用dataset.index做关联还有一个额外好处:可以防止同一个气球被重复点击。每次爆破时,我会先检查对应的气球对象是否处于“已爆破”状态,如果是就直接忽略。这样比分数的判断还要多一层保险,因为即使计分逻辑写错了,重复点击也不至于把同一个气球反复加分好几回。

计分逻辑本身非常简单:score += 1然后更新 DOM 里的得分文本。但要注意循环与初始化的陷阱——如果开始新游戏时没有把score清零,下一局一开始就会接着上一局的分数继续加,看起来像产生了灵异事件。所以重置函数里一定要把数据状态、DOM 显示、定时器全部一起重置,这是后面第三部分重点讲的内容。

3. 完整实现流程:从零搭建一套可运行的游戏

3.1 HTML 结构设计

整体页面我用一个游戏容器包住,里面分三个区块:顶部信息栏(得分和倒计时)、游戏区域(气球都在这里产生)、底部开始按钮。结束弹窗我用遮罩层实现,平时隐藏,游戏结束时显示。

<div class="game-wrapper"> <div class="game-header"> <span class="score-board">得分:<span id="score">0</span></span> <span class="timer-board">时间:<span id="timer">30</span>秒</span> </div> <div id="gameArea" class="game-area"></div> <button id="startBtn" class="start-btn">开始游戏</button> <div class="overlay" id="overlay"> <div class="overlay-card"> <h2>游戏结束</h2> <p>最终得分:<span id="finalScore">0</span></p> <button id="restartBtn" class="restart-btn">再来一局</button> </div> </div> </div>

这个结构里有两个细节值得说明。

第一个是信息栏和游戏区域之间的层级关系。整个页面从上到下排列,游戏区域是唯一需要相对定位的容器,我的布局用的就是经典的 flex 纵向排列。这样气球绝对定位时,参考坐标系就是gameArea,不会跑到信息栏上面去。

第二个是遮罩层。它的定位是position: fixed,铺满整个视口,背景用半透明黑色。这样游戏结束弹出结算面板时,玩家不能继续点击下面的气球,同时“再来一局”按钮天然拥有最高的点击优先级。后面调试点击问题时你会发现,遮罩层的 z-index 设置错误是导致游戏区域点击不灵的主要原因,这个结构从一开始就避免了这个坑。

3.2 CSS 样式与动画细节

CSS 部分除了常规的整页样式,核心在气球的样式和两个动画状态:普通上升、爆破消失。

先看气球的视觉样式。一个标准的气球怎么用 CSS 画出来?最方便的方式是用一个圆形主体加一个底部小三角。圆形主体本身就是一个border-radius: 50%的块元素,加上一点线性渐变显得有高光。底部的小三角则用::after伪元素,做一个倒置的等腰三角形:

.balloon { position: absolute; width: 60px; height: 72px; border-radius: 50%; cursor: pointer; } .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: 14px solid currentColor; }

这里我用了一个小技巧:通过border-top: 14px solid currentColor引用父元素当前的文字颜色,这样只需要给不同气球设置color属性,三角就会自动跟随主体颜色变化,不同颜色的气球样式只需要在类名上切换一个 CSS 变量。比如:

.balloon-red { color: #ff6b6b; background: radial-gradient(circle at 30% 30%, #ffa8a8, #ff6b6b); } .balloon-blue { color: #4dabf7; background: radial-gradient(circle at 30% 30%, #a5d8ff, #4dabf7); }

爆破反馈是这个游戏手感的灵魂。如果只是单纯按下就消失,玩家会感到非常木然。我设置的爆破动画包括两步:破裂瞬间气球稍微放大、颜色变暗,然后淡出缩小。这两步我用了一个过渡类.pop来触发:

.balloon.pop { transform: scale(1.4) rotate(10deg); opacity: 0; transition: transform 0.12s ease-out, opacity 0.12s ease-out; }

执行顺序是:点击时先通过 rAF 把气球的状态标记为已爆,再从 DOM 上添加.pop类触发动画,最后用setTimeout在动画结束后把节点从 DOM 中移除。这里有个反直觉的认知:动画结束之前立刻移除节点会导致动画直接被打断,视觉上就是个“闪没”,所以宁可晚 100ms 再删节点,也不要把移除和动画触发写在同一行。

3.3 JavaScript 逻辑实现

进入最核心的部分。我用一个集中式的状态对象管理游戏所有数据,所有子模块都围绕这个对象运作。这样可以避免散落一地的全局变量互相干扰:

const gameState = { isRunning: false, score: 0, timeLeft: 30, balloons: [], spawnTimer: null, timerInterval: null, rafId: null, lastFrameTime: 0 };

balloons数组用来保存所有“活着”的气球数据对象,里面存了元素引用、当前 x、y、速度、是否已爆等字段。每次 rAF 更新时,就遍历这个数组移动气球位置。这种用数组做实体管理的方式,是游戏开发的通用模式,以后哪怕换到 Canvas 写复杂很多倍的游戏,结构也是类似的。

游戏开始时做的事情依次是:清空上一局残留的定时器与动画帧、重置分数、重置倒计时、清空气球数组与容器内的残留节点、启动倒计时定时器、启动生成定时器、启动 rAF 循环。注意这里的“清空上一局残留”极其重要。如果玩家在极短时间内连续点击开始按钮,而你没有先clearInterval上次的定时器,就会造成两套生成器同时在跑,气球刷新速度直接翻倍。这个坑我踩过,修起来也很简单,但新手往往根本想不到是这个原因。

生成气球函数负责创建元素、初始化坐标、加入数组。核心逻辑如下:

function spawnBalloon() { const el = document.createElement('div'); const x = Math.random() * (gameArea.clientWidth - BALLOON_SIZE); const y = gameArea.clientHeight - 40 - Math.random() * 80; const speed = 50 + Math.random() * 70; const colorClass = `balloon-${COLORS[Math.floor(Math.random() * COLORS.length)]}`; el.className = `balloon ${colorClass}`; el.style.left = x + 'px'; el.style.top = y + 'px'; gameArea.appendChild(el); gameState.balloons.push({ el, x, y, speed, active: true }); }

rAF 更新循环对每个气球做四件事:位移计算、越界判断、移除越界气球、更新 DOM 位置。具体逻辑是:

function updateGame(timestamp) { if (!gameState.isRunning) return; const dt = (timestamp - gameState.lastFrameTime) / 1000; gameState.lastFrameTime = timestamp; for (let i = gameState.balloons.length - 1; i >= 0; i--) { const b = gameState.balloons[i]; if (!b.active) continue; b.y -= b.speed * dt; b.el.style.top = b.y + 'px'; if (b.y < -BALLOON_HEIGHT) { b.el.remove(); gameState.balloons.splice(i, 1); } } if (gameState.isRunning) { gameState.rafId = requestAnimationFrame(updateGame); } }

遍历时为什么要从数组末尾往开头走?因为删除元素后索引会变化,倒序遍历可以避免“漏删元素”的经典 bug。这条经验几乎适用于所有语言的所有数组删除场景,建议直接刻进肌肉记忆。

爆破函数更加直接:标记active = false、计分、加.pop类、100ms 后移除节点并从数组删除。因为事件委托已经在前面处理了防止重复点击的逻辑,所以这里不用再额外判断。计分更新的地方强烈建议独立成一个函数统一更新,避免在多个位置写同样的 DOM 更新语句:

function popBalloon(index) { const b = gameState.balloons[index]; if (!b || !b.active) return; b.active = false; b.el.classList.add('pop'); gameState.score += 1; document.getElementById('score').textContent = gameState.score; setTimeout(() => { b.el.remove(); const idx = gameState.balloons.indexOf(b); if (idx !== -1) gameState.balloons.splice(idx, 1); }, 150); }

倒计时用setInterval每 1000ms 减一秒,到 0 就调用结束函数。结束函数同样要清理所有定时器和 rAF 循环,然后把遮罩层从display: none切换为可见。

4. 常见问题与排查技巧实录

4.1 气球点击无响应:从层级与事件两处排查

如果你把代码跑起来后发现气球怎么点都没反应,最可能的有两个原因。

第一个是视觉层被盖住了。检查游戏区域和遮罩层、信息栏的z-index设置。如果某个元素设置了更高的层叠顺序,即使它在视觉上看起来在游戏区域“后面”,实际点击事件也会被它截获或者被浏览器分配给其他元素。我的建议是所有定位元素里,只有遮罩层需要z-index: 999,其他元素不要随便加z-index。去掉多余的层叠设置,就是解决这个问题的第一步。

第二个是事件委托的closest选择器没有匹配上。我用的是e.target.closest('.balloon'),这要求气球元素必须是委托容器gameArea的后代。如果你把容器监听绑到了外层的大容器上,而气球在视觉上被别的容器包了一层,closest依然能向上找到正确祖先,通常不会出问题。真正的坑在于:一旦气球的三角伪元素区域被点击,closest返回的仍是父元素,所以不用单独给::after写事件,这个设计本身能规避不少麻烦。

排查技巧:在浏览器控制台手动执行document.querySelectorAll('.balloon').length,看看气球是否真的创建了;再执行document.elementFromPoint(x, y)点击坐标返回什么元素。这两招基本能定位绝大多数点击无响应问题。

4.2 游戏越玩越卡:DOM 节点堆积与动画未清理

越跑越卡几乎可以断定是这两个原因:一是上一轮游戏生成的 DOM 节点没有被完全移除,二是每次点击生成的过渡动画导致多余的样式计算。

DOM 节点堆积怎么检查?在开发者工具的 Elements 面板里搜索balloon,如果游戏已经结束、气球数量却还在增加,那就是断开游戏时漏掉了清理循环。这个问题我在 3.3 节强调过,清理逻辑要放在endGame()里一次性做掉。

还有一个隐藏很深的问题:如果一个气球已经被active = false标记,但它还在balloons数组里,那每次 rAF 更新都会遍历到这个无效对象。虽然只是if (!b.active) continue;一句跳过,但积累多了仍然浪费。所以在爆破函数中,不管从视觉层还是从数据层,都要及时移除节点和数组项。同屏超过 10 个气球后肉眼能感觉到流畅度下降,也是因为每次更新循环都在处理大量空转对象。

4.3 游戏结束还能点出来分数:竞态与清理漏网

这个 bug 很经典:倒计时已经归零、结算面板也弹出来了,但玩家继续点击残留的气球,分数竟然还在涨。原因在于endGame()虽然停止了 rAF 循环和定时器,但事件委托是挂在游戏容器上的,游戏结束后它依然有效。玩家点到一个尚未移除的气球,popBalloon被触发,score += 1照常执行。

解决思路有两种。第一种是在popBalloon里开头检查if (!gameState.isRunning) return;,让游戏结束后的所有点击都直接忽略。第二种是更彻底的做法,在endGame()里把游戏容器内所有剩余气球元素全部移除,同时清空balloons数组。我两个方案都做,双保险让这个问题在逻辑上不可能再出现。这个现象也说明了状态标志位在游戏开发中的核心地位——数据状态不到位,什么诡异情况都能冒出来。

下面把常见问题整理成速查表,方便你排查时直接对照:

现象可能原因快速排查方法
气球点不爆z-index 遮罩覆盖 / 事件委托选择器错误用elementFromPoint检查点击位置实际元素
分数重复增加缺少爆破状态标记检查popBalloon里是否设置active = false
气球越飘越快定时器重复创建检查startGame是否先清理旧定时器
结算后还能得分缺少运行状态检查在popBalloon开头判断isRunning
页面逐渐卡顿气球 DOM 未清理Elements 面板搜索.balloon统计数量
开始按钮点击后无反应startGame未被触发 / 状态未重置控制台查看gameState.isRunning值

5. 完整代码与本地运行指南

5.1 完整可运行代码:HTML/CSS/JS 三文件

这一部分就是文首承诺的完整代码。你只需要新建三个文件,分别命名为index.html、style.css、script.js,把对应代码粘贴进去,然后直接用浏览器打开index.html就能玩。所有资源都是浏览器原生能力,不需要安装任何依赖,也不需要启动本地服务器。

文件一:index.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> <link rel="stylesheet" href="style.css" /> </head> <body> <div class="game-wrapper"> <div class="game-header"> <span class="score-board">得分:<span id="score">0</span></span> <span class="timer-board">时间:<span id="timer">30</span>秒</span> </div> <div id="gameArea" class="game-area"></div> <button id="startBtn" class="start-btn">开始游戏</button> <div class="overlay" id="overlay"> <div class="overlay-card"> <h2>游戏结束</h2> <p>最终得分:<span id="finalScore">0</span></p> <button id="restartBtn" class="restart-btn">再来一局</button> </div> </div> </div> <script src="script.js"></script> </body> </html>

文件二:style.css

* { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: linear-gradient(135deg, #e3f2fd, #f3e5f5); min-height: 100vh; display: flex; align-items: center; justify-content: center; } .game-wrapper { width: 420px; background: #fff; border-radius: 16px; box-shadow: 0 12px 30px rgba(0, 0, 0, 0.15); overflow: hidden; position: relative; } .game-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; background: #ffffff; border-bottom: 1px solid #eee; font-size: 16px; font-weight: 600; } .score-board { color: #e8590c; } .timer-board { color: #1c7ed6; } .game-area { position: relative; width: 100%; height: 500px; overflow: hidden; background: linear-gradient(#e0f2ff, #fff3bf); cursor: crosshair; } .balloon { position: absolute; width: 60px; height: 72px; border-radius: 50%; cursor: pointer; user-select: none; transition: none; } .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: 14px solid currentColor; } .balloon-red { color: #ff6b6b; background: radial-gradient(circle at 30% 30%, #ffa8a8, #ff6b6b 70%); } .balloon-blue { color: #4dabf7; background: radial-gradient(circle at 30% 30%, #a5d8ff, #4dabf7 70%); } .balloon-yellow { color: #ffd43b; background: radial-gradient(circle at 30% 30%, #ffe066, #ffd43b 70%); } .balloon-green { color: #69db7c; background: radial-gradient(circle at 30% 30%, #b2f2bb, #69db7c 70%); height: 72px; } .balloon.pop { transform: scale(1.4) rotate(10deg); opacity: 0; transition: transform 0.12s ease-out, opacity 0.12s ease-out; } .start-btn, .restart-btn { display: block; width: 100%; padding: 14px; border: none; font-size: 18px; font-weight: 700; background: #37b24d; color: #fff; cursor: pointer; letter-spacing: 2px; } .start-btn:hover, .restart-btn:hover { background: #2f9e44; } .overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.6); display: none; align-items: center; justify-content: center; z-index: 999; } .overlay.show { display: flex; } .overlay-card { background: #fff; border-radius: 14px; padding: 36px 48px; text-align: center; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.3); } .overlay-card h2 { font-size: 24px; margin-bottom: 10px; color: #212529; } .overlay-card p { font-size: 20px; margin-bottom: 20px; color: #e8590c; } .restart-btn { border-radius: 8px; padding: 12px 28px; width: auto; }

文件三:script.js

const BALLOON_SIZE = 60; const BALLOON_HEIGHT = 72; const MAX_BALLOONS = 10; const GAME_DURATION = 30; const COLORS = ['red', 'blue', 'yellow', 'green']; const gameArea = document.getElementById('gameArea'); const scoreEl = document.getElementById('score'); const timerEl = document.getElementById('timer'); const finalScoreEl = document.getElementById('finalScore'); const startBtn = document.getElementById('startBtn'); const restartBtn = document.getElementById('restartBtn'); const overlay = document.getElementById('overlay'); const gameState = { isRunning: false, score: 0, timeLeft: GAME_DURATION, balloons: [], spawnTimer: null, timerInterval: null, rafId: null, lastFrameTime: 0 }; function startGame() { clearInterval(gameState.spawnTimer); clearInterval(gameState.timerInterval); cancelAnimationFrame(gameState.rafId); gameState.isRunning = true; gameState.score = 0; gameState.timeLeft = GAME_DURATION; gameState.balloons.forEach(b => b.el.remove()); gameState.balloons = []; gameArea.querySelectorAll('.balloon').forEach(el => el.remove()); scoreEl.textContent = '0'; timerEl.textContent = GAME_DURATION; overlay.classList.remove('show'); gameState.lastFrameTime = performance.now(); gameState.spawnTimer = setInterval(spawnBalloon, 800 + Math.random() * 500); gameState.timerInterval = setInterval(updateTimer, 1000); gameState.rafId = requestAnimationFrame(updateGame); } function spawnBalloon() { if (!gameState.isRunning) return; if (gameState.balloons.length >= MAX_BALLOONS) return; const el = document.createElement('div'); const colorClass = `balloon-${COLORS[Math.floor(Math.random() * COLORS.length)]}`; el.className = `balloon ${colorClass}`; el.dataset.index = String(gameState.balloons.length); const x = Math.random() * (gameArea.clientWidth - BALLOON_SIZE); const y = gameArea.clientHeight - 40 - Math.random() * 80; const speed = 50 + Math.random() * 70; el.style.left = x + 'px'; el.style.top = y + 'px'; gameArea.appendChild(el); gameState.balloons.push({ el, x, y, speed, active: true }); } function updateGame(timestamp) { if (!gameState.isRunning) return; const dt = Math.min((timestamp - gameState.lastFrameTime) / 1000, 0.1); gameState.lastFrameTime = timestamp; for (let i = gameState.balloons.length - 1; i >= 0; i--) { const b = gameState.balloons[i]; if (!b.active) continue; b.y -= b.speed * dt; b.el.style.top = b.y + 'px'; if (b.y < -BALLOON_HEIGHT) { b.el.remove(); gameState.balloons.splice(i, 1); } } gameState.rafId = requestAnimationFrame(updateGame); } gameArea.addEventListener('click', (e) => { const balloonEl = e.target.closest('.balloon'); if (!balloonEl) return; const index = Number(balloonEl.dataset.index); popBalloon(index); }); function popBalloon(index) { if (!gameState.isRunning) return; const b = gameState.balloons[index]; if (!b || !b.active) return; b.active = false; b.el.classList.add('pop'); gameState.score += 1; scoreEl.textContent = gameState.score; setTimeout(() => { b.el.remove(); const idx = gameState.balloons.indexOf(b); if (idx !== -1) gameState.balloons.splice(idx, 1); }, 150); } function updateTimer() { gameState.timeLeft -= 1; timerEl.textContent = gameState.timeLeft; if (gameState.timeLeft <= 0) { endGame(); } } function endGame() { gameState.isRunning = false; clearInterval(gameState.spawnTimer); clearInterval(gameState.timerInterval); cancelAnimationFrame(gameState.rafId); gameState.balloons.forEach(b => b.el.remove()); gameState.balloons = []; gameArea.querySelectorAll('.balloon').forEach(el => el.remove()); finalScoreEl.textContent = gameState.score; overlay.classList.add('show'); } startBtn.addEventListener('click', startGame); restartBtn.addEventListener('click', startGame); window.addEventListener('load', () => { // 页面加载完成时预生成一个气球,方便玩家直观看到效果 spawnBalloon(); });

5.2 运行方式与浏览器兼容提示

把三个文件放在同一目录下,双击index.html就能直接运行。不需要npm、不需要webpack、不需要vite,这就是原生三件套最大的魅力。

有两点值得说明。第一,脚本中使用了Element.closest、dataset、requestAnimationFrame这些标准 API,目前所有现代浏览器都支持,不需要考虑IE兼容问题。如果你需要兼容旧浏览器,唯一要改的地方是把closest换成手写祖先查找逻辑,但现代前端开发中这基本没必要。

第二,关于本地预览,如果你用 VS Code 并且装了Live Server插件,也可以用插件启动一个临时服务器再打开页面。两者体验没区别,只是Live Server会自动刷新,改代码后不用手动切窗口刷新。对调试来说会稍微方便一点,个人推荐用这种方式。

结尾

我个人在实际操作中最大的体会是:这个小游戏看起来简单,但真正能把它写利索,你对 DOM 操作、定时器管理、事件机制、状态驱动这套前端底层的理解会有一个质的飞跃。我第一版代码就是把所有逻辑一股脑塞在点击事件里,后来越改越乱,才意识到要用“状态对象 + 统一更新循环 + 事件委托”这套结构。后来再做稍微复杂一点的页面或游戏,都是同一个套路在支撑。

几个真心建议:一是数据状态和界面显示一定要分开管理,改数据的地方和显示数据的地方尽量解耦;二是定时器和 rAF 请求一定要记录返回值,结束游戏时统一清理,否则一定会出现幽灵操作;三是别急着加新功能,先把这个基础版本跑顺,之后想增加难度等级、音效反馈、双人模式都是顺理成章的事。做小游戏的快乐不在于代码多花哨,而在于你能清楚地知道每一个节点为什么存在、每一帧发生了什么。这个项目做完,你就有资格往前端逻辑更复杂的下一个目标进发了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询