☰
纯前端激光射击游戏实战:HTML+CSS+JS三件套从零实现
2026/10/2 3:07:21 网站建设 项目流程

做前端这些年,我越来越觉得“写游戏”是检验基本功最直接的方式。不需要多复杂的玩法,一个“靶子会动、点击就射击”的激光射击游戏,就足够把HTML、CSS、JavaScript三者的关系讲得明明白白。这篇文章我会完整记录自己从零实现这个纯前端激光射击游戏项目的全过程,包括整体设计、激光特效的实现原理、命中判定逻辑,以及调试过程中踩过的那些坑。项目零依赖、不借助任何框架,一个HTML文件就能跑起来,适合刚学完前端基础想练手的朋友,也适合需要做课堂演示或内部技术分享的开发者。

先说清楚这个游戏到底做了什么:画面中央是一片深空风格的场景,底部有一个隐藏的炮口,你点击屏幕上任意位置,就会射出一道激光光束,命中那个不断移动的圆形靶心就能得分,游戏限时30秒,时间归零自动结算。我选择用DOM元素加CSS动画来实现激光和涟漪光圈扩散效果,而不是一上来就上Canvas,目的就是让初学者能看清每一步的原理。

1. 项目背景拆解:这个激光射击游戏到底做了什么

1.1 标题里的三件套,其实是Web开发的标准分工

很多朋友看到“html+css+javascript”这个组合,第一反应是“又是基础三件套”,但真正动手做一个项目时才会发现,这三者的边界常常是模糊的。在这个激光射击游戏里,我把它们拆得非常清楚:HTML负责搭建游戏场景的骨架,包括游戏容器、目标元素、激光元素、计分面板和开始按钮;CSS负责一切视觉表现,比如深空背景、靶心的立体光晕、激光光束的发光效果、命中时的涟漪光圈扩散动画;JavaScript负责所有交互行为,从目标的随机移动、点击事件的捕捉,到碰撞检测、计分和倒计时。

这三层对应到实际开发里,就是“结构、表现、行为”分离。你可以把HTML想象成毛坯房的墙体结构,CSS是室内的装修和灯光设计,而JavaScript是让灯能亮、门能开、人能在房间里活动的电力系统和智能控制。任何一个纯前端项目,不管多复杂,最后都是在跟这三层打交道。这个激光射击游戏就是一个把三件套协作关系压缩到最小的理想样本。

1.2 这个项目适合谁看?看完能解决什么问题

我推荐两类人来读这篇项目分析。第一类是刚学完HTML、CSS、JavaScript基础语法,却不知道怎么写一个完整小游戏的新手。很多教程在讲语法时都是零散的知识点,比如“transform可以实现旋转”“事件监听可以点击触发”,但没有人告诉你这些零散知识点是怎么拼成一个可玩的东西的。通过这个项目,你会发现:CSS的transform配合Math.atan2可以画出一条指向任意位置的激光;circle碰撞检测只需要一句距离公式;游戏循环不一定非用requestAnimationFrame,setInterval也能完成“每秒倒计时”的任务。

第二类是正在准备技术分享或者给学生做前端演示的工程师。纯前端小游戏是最有感染力的演示素材之一,因为它反馈即时、视觉效果强、代码量又小。我在这个项目里刻意避免使用任何第三方库,也避免使用Canvas,为的就是让每一个效果都能在代码里找到直接对应关系。你可以把它当成教学工具,也可以顺手改造成团建抽奖、产品运营活动页之类的小组件。

2. 设计决策分析:为什么纯DOM方案比Canvas更合适

2.1 放弃Canvas的四个理由

在动手写之前,我其实纠结过到底用Canvas还是纯DOM。最终选了纯DOM方案,理由有四条。第一条是学习门槛:Canvas需要开发者手动管理整个画布的每一帧绘制,背景、靶子、激光、涟漪全都要用ctx绘出来,等于把“页面渲染”的活揽到自己身上;而纯DOM方案里,浏览器本身就帮你做了布局和渲染,你要处理的核心问题只剩“把元素放对位置”和“让动画跑起来”。

第二条是调试体验。纯DOM元素可以在浏览器的Elements面板里直接查看,CSS动画可以在开发者工具的Animations面板里回放和调整速度,甚至随时暂停。而Canvas动画一旦跑起来,你看到的只是一块冷冷清清的画布,想定位问题只能靠console.log,对新手来说相当劝退。第三条是性能匹配:这个游戏最多同时存在一个靶心、一条激光、几个涟漪光圈,元素数量极少,纯DOM的渲染压力完全可以忽略,根本不需要用Canvas那种“底层绘图”方案。第四条是代码可读性:CSS负责动画、JS负责逻辑,各管各的,分享给别人看的时候更容易讲清楚。

当然,我不否认Canvas的价值。如果这个游戏的目标数量变成几十个、粒子特效上百个、需要逐帧计算物理碰撞,那Canvas甚至WebGL会更加合适。但作为教学项目和轻量级游戏,纯DOM方案是更好的起点。

2.2 玩法规则与模块划分,先画一张逻辑地图

这个游戏的玩法规则是我刻意控制在最小范围内的:玩家点击场景中的任意位置,从底部中心炮口发射一条激光,判定这次射击是否命中靶心;命中得分且靶心立刻换位置,未命中则靶心继续按自己的节奏移动;总时长30秒,倒计时归零后弹出“再来一局”按钮。

别看规则简单,真正落地时也要划分出五个清晰的模块:场景初始化模块负责获取游戏容器和DOM元素引用,计算炮口坐标;目标管理模块负责靶心的生成和随机移动;激光特效模块负责创建或更新激光元素的位置、长度和角度;碰撞检测模块负责判断点击点是否落入靶心范围;计分计时模块负责维护得分和倒计时。模块之间通过事件和状态变量协作,不互相直接操作对方的内部数据。比如碰撞检测模块只负责返回“命中/未命中”这个结果,后续的加分和换靶动作由主流程来控制,这样即使以后改成多靶模式,也能轻松扩展。

3. 核心实现逐段拆解:HTML骨架、CSS特效、JS交互

3.1 HTML骨架:一张干净到极致的游戏场景

先看HTML部分,这个游戏的结构其实少得可怜:

<div id="game" class="game"> <div id="laser" class="laser"></div> <div id="target" class="target"></div> <div class="score-board"> <span>得分:<b id="score">0</b></span> <span>剩余时间:<b id="timer">30</b>s</span> </div> <button id="startBtn" class="start-btn">开始游戏</button> </div>

我特意没有选择更“语义化”的,也没有在里面塞一堆描述性文字,因为游戏场景本质上是一块“交互画布”,div是最合适的基础容器。激光和靶心都采用绝对定位,意味着它们可以自由地在容器内移动,而不受普通文档流影响。计分面板是一个叠加在场景左上角的浮层,为了不让鼠标点击面板时误触发射激光,我给面板设置了pointer-events: none。开始按钮永远悬浮在场景中央,游戏运行时会隐藏,游戏结束时重新显示。这里有个细节值得新手注意:button元素默认带边框和背景,我在这里全部重写覆盖掉了,不然会和深空背景格格不入。

3.2 CSS特效:激光光束、涟漪光圈扩散和命中光晕

CSS是让这个游戏“看起来值钱”的关键。先说背景,我用radial-gradient从场景中心向外扩散出一个更亮的椭圆,模拟深空中的聚光效果,配色选的是深蓝到黑,再配合靶心的红色激光和命中时候的青色涟漪,主视觉对比非常强。

激光光束的实现思路是:它本身是一个只有4像素宽、默认4像素长的元素,发射时JavaScript会把它的宽度设为炮口到点击点的距离,把transform角度设为Math.atan2计算出的角度,并配合box-shadow营造发光感。CSS部分最需要注意的是transform-origin,这条激光必须以炮口为中心旋转,所以必须设成left center,不然激光会从元素中心往外扩,看起来就是从半空中射出去,而不是从炮口里发射出来的。

涟漪光圈扩散是这个项目里最出效果的一个CSS动画,核心代码如下:

.ripple { position: absolute; width: 20px; height: 20px; border: 3px solid #00ffcc; border-radius: 50%; box-shadow: 0 0 15px 2px rgba(0, 255, 204, 0.7); animation: rippleAnim 0.6s ease-out forwards; } @keyframes rippleAnim { 0% { transform: scale(0.3); opacity: 1; } 100% { transform: scale(3); opacity: 0; } }

它的原理其实特别朴素:一个带粗边框的圆形,在0.6秒内从0.3倍大小放大到3倍大小,同时透明度从1降到0。因为动画缓动函数用的是ease-out,所以最终的视觉是“光圈先快速扩散、后缓慢淡出”,很像水面涟漪或者冲击波的扩散效果。命中时,JavaScript会动态创建一个div元素并挂上ripple类,放在点击坐标处,然后通过监听animationend事件把这个节点移除,避免多次命中后DOM里堆积一堆没用的光圈残留。这套“动态创建动画节点+动画结束后移除节点”的模式在很多前端特效里都通用。

靶心本身的光晕也值得一提。我用radial-gradient在圆形内部做出了从高光到暗红的立体感,再用20像素的白色圆点模拟核心发光点,最后用box-shadow在靶心外围加了一圈红色光晕,让它在深色背景上非常显眼,玩家一眼就能看到移动目标在哪里。

3.3 JS交互:射击判定、随机移动与计分循环这样写

JavaScript是这个项目的灵魂。先看坐标获取和炮口计算,这里我踩过一个很典型的坑:如果用offsetX和offsetY获取点击坐标,你得到的是点击点相对“当前目标元素”的偏移,这个“当前目标元素”可能是激光、靶心甚至按钮,一旦点到了靶心上,坐标基准就变了,判定会莫名其妙地偏移。正确的做法是使用clientX和clientY,再减去游戏容器的getBoundingClientRect().left和top,这样不管点击在哪个子元素上,都能换算成以游戏容器为原点的相对坐标。

炮口的位置我故意设计在底部88%高度处,并且只取游戏容器的宽度一半作为X坐标。这意味着玩家每一次点击,激光都是从“画面底部中央”射出来的,符合直觉上的“我的炮台在下面”的感觉。计算激光角度是使用Math.atan2(dy, dx),它返回的角度精确到四个象限,例如点击点在炮口左边时,dx为负数,角度会自动落在负值区,CSS的rotate会正确地往逆时针方向偏转,完全不需要自己写正负判断逻辑。这一点很多新手会绕弯路,我在这里替大家避个雷。

碰撞检测部分用的是圆的碰撞模型。靶心元素实际尺寸是80乘80像素,半径为40像素,我只要判断“点击点到靶心中心点的距离是否小于等于40像素”即可。距离公式就是小学数学的勾股定理:dx的平方加dy的平方,再开根号。用这个模型比判断矩形边界更符合射击游戏的直觉,因为玩家会感觉“我打中了靶子的边缘也算命中”,而不是必须严格点在最中心的像素上。

计分和倒计时则通过两个定时器来实现:setInterval每秒将剩余时间减1,直到归零;setTimeout每1.4秒调用一次moveTarget,让靶心在场景上半部分随机换一个位置。这里有一个容易踩的定时器叠加坑:如果玩家在游戏进行中再次点击开始按钮,不先清除旧的interval和timeout,就会出现“一秒钟扣两秒”的现象。所以我在startGame函数里第一步就是clearInterval和clearTimeout,并用isRunning标志位拦截重复启动。

4. 完整代码演示:一个HTML文件就能跑起来的激光射击游戏

4.1 完整可运行的代码

下面这个代码就是整个项目的完整版本,把HTML、CSS、JavaScript统一放在了同一个HTML文件中。你可以直接复制保存成shooter.html,然后双击用浏览器打开,点“开始游戏”就能玩。代码里我保留了一些中文注释,方便二次修改。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>激光射击小游戏</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0e14; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: Arial, sans-serif; } .game { position: relative; width: 800px; height: 500px; background: radial-gradient(ellipse at center, #14213d 0%, #0a0e14 80%); border: 1px solid #1f3a5f; border-radius: 12px; overflow: hidden; cursor: crosshair; user-select: none; } .laser { position: absolute; width: 4px; height: 4px; border-radius: 2px; background: #ff3366; box-shadow: 0 0 12px 3px rgba(255, 51, 102, 0.8); transform-origin: left center; pointer-events: none; opacity: 0; z-index: 10; } .target { position: absolute; left: 360px; top: 200px; width: 80px; height: 80px; border-radius: 50%; background: radial-gradient(circle at 30% 30%, #ff7b9c, #d6002e); cursor: crosshair; box-shadow: 0 0 20px 6px rgba(214, 0, 46, 0.5); z-index: 1; } .target::after { content: ''; position: absolute; left: 50%; top: 50%; width: 20px; height: 20px; border-radius: 50%; background: #fff; transform: translate(-50%, -50%); box-shadow: 0 0 10px 2px rgba(255, 255, 255, 0.9); } .ripple { position: absolute; width: 20px; height: 20px; border: 3px solid #00ffcc; border-radius: 50%; box-shadow: 0 0 15px 2px rgba(0, 255, 204, 0.7); pointer-events: none; z-index: 20; animation: rippleAnim 0.6s ease-out forwards; } @keyframes rippleAnim { 0% { transform: scale(0.3); opacity: 1; } 100% { transform: scale(3); opacity: 0; } } .score-board { position: absolute; top: 12px; left: 12px; right: 12px; display: flex; justify-content: space-between; color: #d7e6ff; font-size: 14px; z-index: 30; pointer-events: none; text-shadow: 0 0 5px rgba(0, 255, 204, 0.5); } .start-btn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 10px 22px; border: none; border-radius: 6px; background: #00ffcc; color: #0a0e14; font-size: 18px; cursor: pointer; z-index: 40; box-shadow: 0 0 18px rgba(0, 255, 204, 0.6); transition: transform 0.2s; } .start-btn:hover { transform: translate(-50%, -50%) scale(1.08); } </style> </head> <body> <div id="game" class="game"> <div id="laser" class="laser"></div> <div id="target" class="target"></div> <div class="score-board"> <span>得分:<b id="score">0</b></span> <span>剩余时间:<b id="timer">30</b>s</span> </div> <button id="startBtn" class="start-btn">开始游戏</button> </div> <script> const game = document.getElementById('game'); const laser = document.getElementById('laser'); const target = document.getElementById('target'); const scoreEl = document.getElementById('score'); const timerEl = document.getElementById('timer'); const startBtn = document.getElementById('startBtn'); let score = 0; let timeLeft = 30; let timerId = null; let moveTimerId = null; let isRunning = false; const TARGET_RADIUS = 40; const GUN_POINT_RATIO = 0.88; function getGunPoint() { const rect = game.getBoundingClientRect(); return { x: rect.width / 2, y: rect.height * GUN_POINT_RATIO }; } function getRelativePoint(e) { const rect = game.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } function moveTarget() { const rect = game.getBoundingClientRect(); const maxX = rect.width - 80; const maxY = rect.height * 0.7 - 80; const x = 10 + Math.random() * (maxX - 10); const y = 10 + Math.random() * (maxY - 10); target.style.left = x + 'px'; target.style.top = y + 'px'; } function fireLaser(from, to) { const dx = to.x - from.x; const dy = to.y - from.y; const distance = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; laser.style.left = from.x + 'px'; laser.style.top = (from.y - 2) + 'px'; laser.style.width = distance + 'px'; laser.style.height = '4px'; laser.style.transform = 'rotate(' + angle + 'deg)'; laser.style.opacity = 1; clearTimeout(laser._timer); laser._timer = setTimeout(function () { laser.style.opacity = 0; }, 180); } function showRipple(x, y) { const ripple = document.createElement('div'); ripple.className = 'ripple'; ripple.style.left = (x - 10) + 'px'; ripple.style.top = (y - 10) + 'px'; game.appendChild(ripple); ripple.addEventListener('animationend', function () { ripple.remove(); }); } function isHit(point) { const targetX = parseFloat(target.style.left) + TARGET_RADIUS; const targetY = parseFloat(target.style.top) + TARGET_RADIUS; const dx = point.x - targetX; const dy = point.y - targetY; return Math.sqrt(dx * dx + dy * dy) <= TARGET_RADIUS; } function scheduleMove() { if (moveTimerId) clearTimeout(moveTimerId); if (!isRunning) return; moveTimerId = setTimeout(function () { moveTarget(); scheduleMove(); }, 1400); } function startGame() { if (isRunning) return; isRunning = true; score = 0; timeLeft = 30; scoreEl.textContent = '0'; timerEl.textContent = '30'; startBtn.style.display = 'none'; if (timerId) clearInterval(timerId); timerId = setInterval(function () { timeLeft--; timerEl.textContent = timeLeft; if (timeLeft <= 0) { clearInterval(timerId); clearTimeout(moveTimerId); isRunning = false; startBtn.style.display = 'block'; startBtn.textContent = '再来一局'; } }, 1000); moveTarget(); scheduleMove(); } function onShoot(e) { if (!isRunning) return; const point = getRelativePoint(e); const from = getGunPoint(); fireLaser(from, point); if (isHit(point)) { score++; scoreEl.textContent = score; showRipple(point.x, point.y); moveTarget(); } } game.addEventListener('click', onShoot); startBtn.addEventListener('click', function (e) { e.stopPropagation(); startGame(); }); </script> </body> </html>

4.2 运行步骤与三个关键参数的调法

运行这个项目只需要三步:把上面的代码复制到任意文本编辑器里,保存为后缀名是.html的文件,然后双击用浏览器打开,最后点击“开始游戏”按钮开始射击。因为我没用任何构建工具和外部CDN资源,所以即使在完全离线的环境里也能正常运行,这也是我坚持“零依赖”的原因之一。

如果你想改变游戏手感,优先调整的是三个参数。第一个是TARGET_RADIUS,它既是靶子的物理半径,也是碰撞判定的半径。调大到50,命中变得更容易,适合给我这种手残玩家;调小到25,难度马上飙升,适合挑战极限。第二个是GUN_POINT_RATIO,它控制炮口在垂直方向上的位置,0.88意味着炮口在底部88%高度处,改到0.5就是“大炮在画面中间发射”,玩法会变得很不一样。第三个是moveTarget中的移动间隔1400毫秒,数值越小靶子换位越快,游戏节奏越紧凑。我建议你先把时间参数固定成30秒,然后一点点调整这三个值,找到自己觉得最舒服的平衡点。

调试的时候,我强烈建议打开浏览器的开发者工具,在Animations面板里查看涟漪光圈动画的完整生命周期,这样可以直观看到缓动曲线。如果发现点击位置和实际视觉位置不一样,可以在onShoot函数里临时加一行console.log(point.x, point.y),再结合getBoundingClientRect输出容器位置对比,基本就能定位到坐标基准问题。

5. 项目复盘:常见问题、排查思路与扩展建议

5.1 调试中常见的4个坑,我一个个排过来了

第一个坑是坐标基准错乱。最初我图省事用了e.offsetX和e.offsetY,结果发现点击靶心时命中判定完全乱套。原因就是offsetX是相对“事件目标”而非“游戏容器”的偏移,当目标元素是靶心或涟漪时,坐标起点就变了。统一改用clientX减去容器左上角坐标后,一切都恢复正常,这个教训我建议所有做DOM点击游戏的朋友记住。

第二个坑是定时器叠加。有一次我在游戏结束后马上再点开始,发现倒计时直接从25秒跳到24、22、21,明显是每秒扣了两三次。查代码才发现旧的回调没有清理,startGame把新的setInterval叠加到了旧的上面。修复方法是在startGame开头clearInterval和clearTimeout,同时用isRunning标志位阻止重复进入启动流程。这个坑在轮播图、抽奖、倒计时组件里都会遇到,值得养成条件反射。

第三个坑是激光光束偏移。第一次跑通时,激光虽然会发射,但总是从炮口偏上几像素的位置射出去,目标方向也差一点。排查后发现是transform-origin没有设置,元素默认从中点开始缩放旋转,而不是从炮口端点。把transform-origin改成left center,并把元素的top调整为“炮口y坐标减去元素高度一半”,光束才真正像从炮口中心射出来。

第四个坑是涟漪节点堆积。命中几次后我打开Elements面板,看到一堆class为ripple的div堆在场景里,它们虽然透明度已经变成0,但节点还挂在DOM上。多次命中后这种无效节点越来越多,虽然数量少时不影响性能,但一旦将来改成多目标高频射击,就会拖慢页面。所以我养成了“动态动画节点必须在animationend时remove”的习惯,这也算是一种性能洁癖。

5.2 想让它更像一个“成品游戏”,可以做这三个扩展

第一个扩展方向是增加多目标和难度梯度。把靶心从单个变成一个数组,用循环创建多个target元素,每个target拥有独立的移动定时器,命中任何一个就加分并让对应靶心换位。这样游戏挑战性大大提高,但要注意两点:一是不宜同时创建超过五个靶心,DOM数量多了会影响点击事件和渲染性能;二是所有靶心的移动定时器需要在结束游戏时统一清理,防止僵尸定时器。

第二个扩展方向是拆成多页面或打包部署。现在的版本是单HTML文件,如果想要更像一个完整产品,可以拆成开始页、游戏页、结算页。开始页放游戏名称和“开始游戏”按钮,游戏页就是当前这个射击场景,结算页用localStorage读取上一局的得分并显示排名。打包时用Vite或Parcel这类工具把多个HTML作为入口,处理完资源路径后部署到静态服务器即可。这个扩展不复杂,关键点在于页面之间的状态传递,直接用localStorage最省事。

第三个扩展方向是接入音效和更强的反馈。用Web Audio API可以零依赖地生成命中时的“叮”声和发射时的“咻”声,不需要准备任何音频素材,代码量也只有十几行。还可以在命中时给游戏容器加一个短暂屏幕震动类,利用CSS的transform微移模拟后坐力,反馈感立刻提升一个档次。这些都属于“不改变核心玩法,但显著提升体验”的锦上添花。

我个人在做完这个项目后最大的体会是:纯前端小游戏最核心的东西不是花哨的视觉特效,而是把坐标计算、定时器管理、事件监听这些基础概念彻底弄清楚。很多人都问我“激光的旋转角度怎么算”“为什么命中判定不准”,本质都是基础概念缺课。建议你把这份代码当作一个起点,改一改参数、加一个功能、拆一个页面,动手踩一遍坑,比反复看十遍教程都有用。

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

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

立即咨询