纯前端小项目做到能拿得出手,比堆框架更能练人。最近我抽空把之前拖了很久的一个想法落地了:用原生 HTML、CSS 和 JavaScript 写一个激光射击游戏。这个项目没有依赖任何构建工具、没有图片资源、没有后端接口,全部逻辑和视觉都靠浏览器三件套硬抠出来,最终用 200 来行代码实现了一个带粒子光效、动态计分、多波次敌人的可玩原型。
这个项目适合三类人看:一是刚学完 JS 基础语法、想做点东西练手的前端新人;二是面试前想准备一个“麻雀虽小五脏俱全”的个人项目的求职者;三是平时写业务写腻了、想回归原生 DOM 和动画本质找点乐子的老手。整个项目我可以一次性放出完整代码,但在贴代码之前,我更想把设计思路、踩过的坑、调试过程都讲透,因为代码只是结果,思路和排障过程才是真正的经验值。
1. 项目整体设计与思路拆解
1.1 为什么选择原生三件套而不是框架
先用大白话说清楚我这边的结论:做一个小型射击游戏,原生 DOM + CSS + JS 完全够用,而且比引入游戏框架更可控。很多人一提到做游戏就想着 Canvas、WebGL、Phaser、PixiJS,但这些方案都有学习成本。如果用 Canvas 来做,虽然性能上限高,但实现“点击屏幕射出一道激光”这种交互时,所有元素都要自己管理坐标和重绘,光是把坐标系搞清楚就得花不少时间。用原生 DOM 做,浏览器已经帮你处理了布局、定位和事件派发,开发起来更快。
这个游戏最核心的交互是:玩家点击屏幕,从炮台位置向点击处发射一束激光,然后命中目标。这个逻辑如果用 DOM 实现,无非是创建一个 div 元素,设置它的宽度代表激光长度,再通过计算角度做旋转。整个流程没有复杂的矩阵变换,不需要逐帧重绘整张画布,用 CSS transform 配合 JavaScript 动态设置样式就能搞定,性能完全足够。
另外还有个现实考虑:原生三件套不依赖网络加载任何库,你写完之后,把 HTML 文件双击打开就能运行。这对于新手来说非常友好,不用搭 Node 环境、不用执行 npm install,对初学者最劝退的就是环境问题。从纯工程角度看,这个项目规模小、状态简单、场景固定,引入框架反而是在增加不必要的抽象层。
1.2 玩法逻辑与核心循环设计
游戏的核心玩法我定位成:发射站位于屏幕底部中心,敌人从屏幕上方和两侧分批出现并缓慢移动,玩家通过鼠标点击位置来瞄准和射击,命中敌人获得分数,漏掉的敌人碰到底部防线则扣除生命值。
别看玩法简单,它背后有一个很标准的游戏框架——输入、更新、渲染三大循环。你可以把这套框架迁移到任何游戏项目里:
- 输入层:监听鼠标的 click 事件,拿到屏幕坐标,换算成游戏世界坐标;
- 更新层:维护敌人的位置、速度、存活状态,维护分数和生命值,处理命中判定;
- 渲染层:把更新后的数据映射到 DOM 上,包括生成敌人元素、移动它们的位置、添加和移除激光效果。
这个“三个层”的划分很关键。新手最容易犯的错误是把逻辑和表现混在一起——在每个点击事件里面直接改分数、直接创建元素、直接判断生死,最后代码越写越乱,出现一个 bug 都要找老半天。我在这个项目里刻意分开了:事件回调只负责接收输入并调用处理函数,处理函数更新数据状态,然后由统一的渲染函数把状态同步到界面。这样哪怕后期要加音效、加动画,改动都能控制在一个明确的位置。
1.3 数据状态与界面表现如何映射
这个项目里的游戏状态其实只有三部分:敌人集合、玩家属性(分数和生命值)、游戏状态标记(运行中、暂停、结束)。我统一用一个对象来管理,而不是散落在地球各地的全局变量。这样做的好处是方便统一初始化和重置。比如定义为:
const state = { score: 0, lives: 5, running: false, enemies: [], };敌人的数据结构设计也很重要。每个敌人至少需要 x、y、radius、speed、alive 这几个字段。x 和 y 表示它在屏幕上的逻辑坐标,radius 是命中判定的半径,speed 决定移动速度,alive 表示是否已经被消灭。敌人移动时,只需要在动画循环中不断更新它的 x 和 y,然后把对应的 DOM 元素位置同步过去。如果你把“位置”这个信息同时存在 DOM 和 JS 里,就会产生数据同步的问题。这里的原则是:一切以 JS 里的状态为准,DOM 只是状态的投影。
2. 核心模块拆分与关键技术选型
2.1 HTML 结构设计:从低耦合容器开始
整个游戏的 HTML 结构我控制在三个层级以内:最外层的 #game 容器负责限定游戏区域大小并处理溢出裁剪;内部是 #muzzle(炮台)、#scene(敌人和特效的挂载点)和 #hud(计分面板)。这样的好处是:
- 敌人的生命周期可以完全在 #scene 内部创建和销毁,不会影响其他界面;
- 计分面板悬浮在最上层,不参与游戏世界的布局;
- 炮台作为固定元素,不用被频繁重绘。
这种做法其实也借鉴了游戏引擎里的场景图(Scene Graph)思想:容器之间是树状关系,各节点只负责自己的渲染内容,互不干扰。用 HTML 模板来呈现的话,大致长这样:
<div id="game"> <div id="muzzle"></div> <div id="scene"></div> <div id="hud"> <span>得分:<span id="score">0</span></span> <span>生命:<span id="lives">5</span></span> </div> <div id="start-overlay"> <h2>激光射击</h2> <p>点击屏幕发射激光,消灭敌人</p> <button id="start-btn">开始游戏</button> </div> </div>这种结构非常简单,但是每个节点都有明确职责。做任何页面类项目时,我都建议大家先花几分钟把 DOM 结构想好,而不是边走边加 div,否则后期很容易出现样式覆盖和层级混乱。
2.2 CSS 视觉动效:涟漪光圈、激光光晕与 3D 旋转
既然项目名称里包含“激光射击”,视觉表现上一定要有明显的“激光感”。我第一次实现激光时就踩了坑:直接画一条白色直线拉倒,效果干巴巴的,完全没有射击手感。后来我把激光拆成了“光束核心”和“光晕层”来叠加,用 CSS 的多层阴影模拟出高亮光柱的感觉。
具体做法是在激光元素上同时添加 box-shadow 和半透明的颜色渐变:
.beam-core { height: 4px; background: linear-gradient(90deg, rgba(255, 255, 255, 0.2), #fff); box-shadow: 0 0 12px rgba(0, 255, 255, 0.8), 0 0 32px rgba(0, 255, 255, 0.4); }这样打出来的光束就会有高亮核心和外溢光晕的双层效果。同时,为了让射击动作更有饱满感,我在每个射击点额外生成一个“涟漪光圈”的 div,用 CSS 动画让一个圆环从小变大、透明度从高到低,模拟能量扩散的瞬间:
@keyframes ripple { from { transform: scale(0.2); opacity: 1; } to { transform: scale(2.5); opacity: 0; } }热搜词里有人搜“css 涟漪光圈扩散”就是这个玩法。这个动画其实还可以延伸到爆炸特效上:打中敌人时,在命中点生成一个红色系的涟漪,敌人在消失前也做一次放大淡出的动画,你会发现游戏手感一下子好了一个档次。视觉反馈不是锦上添花,它直接影响玩家对“打中了吗”的判断速度。
另外一个有意思的方向是用 CSS 的 transform 做 3D 旋转。比如transform: rotateY(60deg) translateZ(300px)这个写法,会让元素在三维空间中围绕 Y 轴旋转 60 度,然后沿 Z 轴往外移动 300 像素,视觉效果就类似一个面向斜侧方、悬浮在三维纵深上的卡片。这类效果放在战斗场景的入场动画里会很惊艳,比如敌人的图标旋转着“飞来”的时候。我在这个项目里没把 3D 做得太重,因为射击游戏追求的是清晰的战场视野,过多 3D 变换会干扰瞄准,但值得理解其中原理。CSS transform 的执行顺序是从右往左的,所以rotateY(60deg) translateZ(300px)和translateZ(300px) rotateY(60deg)出来的效果完全不同,前者是旋转后再沿着元素自身的 Z 轴移动,后者是先沿着原 Z 轴移动再旋转。理解这个顺序规则,你才能写出想要的立体效果,而不是靠瞎试。
2.3 JavaScript 架构:事件监听、动画循环与碰撞检测
从架构角度,这个项目我用到了四个关键技术点:事件委托、requestAnimationFrame 动画循环、简单的距离检测、状态驱动渲染。
事件委托是一个很实用的技巧。敌人元素会不断创建和销毁,如果你给每个敌人单独绑定点击事件,内存里就会积累一堆监听器,还容易造成事件回调引用已移除节点的问题。正确做法是在 #game 容器上只绑定一个 click 监听器,通过事件对象的 target 判断玩家点到了哪里。但这里有个商业细节:射击游戏不适合用“目标元素”来判定命中,因为玩家可能在敌人旁边的空隙处点击,也应该算“开枪”。所以更好的做法是无论点到了什么,都读取点击坐标,然后自己做算法层面的命中判定,而不是依赖 DOM 事件命中。
这意味着碰撞检测要走算法:拿到鼠标点击点的 x、y,遍历所有敌人,计算点击点和敌人中心点的距离,如果小于敌人半径,就判定命中。距离公式就是初中几何:d = Math.hypot(ex - mx, ey - my)。这个方案的优势是判定逻辑完全由数据驱动,和 DOM 元素是否被点击到无关,既准确又高效。实战中我还把这个判定半径稍微扩大了一点,让玩家射击时感觉更宽容,游戏手感会更好。
动画循环方面,用requestAnimationFrame而不是setInterval,区别在于浏览器会在下一次屏幕刷新前调用回调,帧率自然和屏幕同步,不会出现掉帧、卡顿或定时器堆积的问题。循环内部的逻辑应该是:清空页面上的激光特效 → 更新所有敌人的位置 → 检查敌人是否越过底线 → 同步所有元素到新的位置。
3. 完整代码实现与核心环节精讲
3.1 页面骨架搭建和基础样式
我先给出完整的 HTML 和 CSS 组合。其中 CSS 不只是让页面“变好看”,很多关键交互都靠样式在支撑,比如游戏区域裁剪、激光动画、敌人消失动画等。页面的背景我用了一个深蓝色的渐变,模拟太空战场的氛围:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>激光射击游戏</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: radial-gradient(circle at center, #0a1428 0%, #030712 100%); font-family: system-ui, sans-serif; overflow: hidden; } #game { position: relative; width: 900px; height: 600px; background: linear-gradient(180deg, #071126 0%, #0d1b3a 100%); border: 1px solid rgba(0, 255, 255, 0.25); border-radius: 12px; overflow: hidden; cursor: crosshair; box-shadow: 0 0 40px rgba(0, 255, 255, 0.15); } #muzzle { position: absolute; left: 50%; bottom: 30px; width: 60px; height: 60px; transform: translateX(-50%); background: radial-gradient(circle, #66f0ff 0%, #0a4a6e 100%); border-radius: 50%; box-shadow: 0 0 30px rgba(0, 180, 255, 0.8); } #scene { position: absolute; inset: 0; } #hud { position: absolute; top: 16px; left: 16px; right: 16px; display: flex; justify-content: space-between; color: #9be9ff; font-size: 18px; font-weight: bold; z-index: 10; text-shadow: 0 0 12px rgba(0, 255, 255, 0.4); } #start-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; background: rgba(1, 5, 12, 0.88); z-index: 20; color: #c8ffff; } #start-overlay h2 { font-size: 42px; letter-spacing: 8px; margin-bottom: 12px; } #start-overlay button { margin-top: 20px; padding: 12px 36px; font-size: 18px; background: transparent; color: #66f0ff; border: 2px solid #66f0ff; border-radius: 30px; cursor: pointer; transition: all 0.2s; } #start-overlay button:hover { background: rgba(0, 255, 255, 0.15); } .enemy { position: absolute; width: 44px; height: 44px; border-radius: 50%; background: radial-gradient(circle at 30% 30%, #ff8099, #a02a3a); box-shadow: 0 0 16px rgba(255, 40, 80, 0.6); transform: translate(-50%, -50%); } .beam { position: absolute; border-radius: 8px; transform-origin: left center; pointer-events: none; } .beam-core { height: 4px; background: linear-gradient(90deg, rgba(255,255,255,0.1) 0%, #66f0ff 100%); box-shadow: 0 0 14px #04c4ff, 0 0 30px rgba(4, 196, 255, 0.5); } .ripple { position: absolute; width: 80px; height: 80px; border: 3px solid #66f0ff; border-radius: 50%; pointer-events: none; animation: ripple 0.45s ease-out forwards; } .ripple-hit { border-color: #ff5a7a; } .enemy-die { animation: enemyDie 0.3s ease-in forwards; } @keyframes ripple { from { transform: scale(0.2); opacity: 1; } to { transform: scale(2.4); opacity: 0; } } @keyframes enemyDie { from { transform: translate(-50%, -50%) scale(1); opacity: 1; } to { transform: translate(-50%, -50%) scale(1.8); opacity: 0; } } </style> </head> <body> ... </body> </html>CSS 里transform-origin: left center这行是关键。激光的定位逻辑是:把光束的起点作为元素的左侧中心,旋转时以起点为中心转动,这样设置角度后激光就能从炮口精准指向鼠标位置。不理解这行的话,激光束的位置会完全对不上。这类细节是我实际调试中最常踩的坑,建议大家把 transform-origin 当作样式调优的第一排查点。
3.2 核心游戏逻辑的代码实现
接下来是最重要的 JavaScript 部分。代码我分成了三个大模块:工具与状态、游戏对象管理、事件与循环启动。先用工具函数获取必要的坐标:
const gameEl = document.getElementById('game'); const sceneEl = document.getElementById('scene'); const muzzleEl = document.getElementById('muzzle'); const scoreEl = document.getElementById('score'); const livesEl = document.getElementById('lives'); const startOverlay = document.getElementById('start-overlay'); const state = { score: 0, lives: 5, running: false, enemies: [], }; function getMuzzleCenter() { const rect = muzzleEl.getBoundingClientRect(); return { x: rect.left + rect.width / 2 - gameEl.getBoundingClientRect().left, y: rect.top + rect.height / 2 - gameEl.getBoundingClientRect().top }; }getBoundingClientRect 返回的是相对于视口的坐标,而游戏内元素使用的是相对于 #game 容器的坐标,所以两者相减是必须的换算步骤。很多人拿到的坐标偏了,就是漏了这一步。
然后实现射击函数。它接收一个鼠标坐标,完成光束创建、命中判定、敌人销毁三件事:
function fireAt(clientX, clientY) { if (!state.running) return; const gameRect = gameEl.getBoundingClientRect(); const mx = clientX - gameRect.left; const my = clientY - gameRect.top; const origin = getMuzzleCenter(); // 创建激光束 const dx = mx - origin.x; const dy = my - origin.y; const length = Math.hypot(dx, dy); const angle = Math.atan2(dy, dx); const beam = document.createElement('div'); beam.className = 'beam'; beam.style.width = length + 'px'; beam.style.height = '4px'; beam.style.left = origin.x + 'px'; beam.style.top = origin.y + 'px'; beam.style.transform = 'rotate(' + angle + 'rad)'; beam.innerHTML = '<div class="beam-core" style="width:100%;height:100%"></div>'; sceneEl.appendChild(beam); // 生成射击点涟漪效果 const ripple = document.createElement('div'); ripple.className = 'ripple'; ripple.style.left = mx + 'px'; ripple.style.top = my + 'px'; sceneEl.appendChild(ripple); // 清理光束与涟漪,避免 DOM 无限膨胀 setTimeout(() => { beam.remove(); ripple.remove(); }, 500); // 命中判定 let hitEnemy = null; for (const enemy of state.enemies) { if (!enemy.alive) continue; const dist = Math.hypot(enemy.x - mx, enemy.y - my); if (dist < enemy.radius + 20) { hitEnemy = enemy; break; } } if (hitEnemy) { killEnemy(hitEnemy, mx, my); } }这里要重点解释一下光束的方向计算。页面坐标系的 Y 轴是向下的,所以一个鼠标位置相对于炮台的角度可以交给Math.atan2(dy, dx)直接算出来,返回的是弧度值,CSS 的 rotate 也支持弧度单位。如果把角度转成度数再拼字符串,两者结果一样,但弧度写法少一次换算。这个换算也是我从“可用”到“用好”的关键认知。
消灭敌人的函数负责三个阶段:将敌人标记为死亡、播放死亡动画、更新分数并及时清理 DOM 节点:
function killEnemy(enemy, hitX, hitY) { enemy.alive = false; enemy.el.classList.add('enemy-die'); // 命中点生成红色涟漪 const ripple = document.createElement('div'); ripple.className = 'ripple ripple-hit'; ripple.style.left = hitX + 'px'; ripple.style.top = hitY + 'px'; sceneEl.appendChild(ripple); setTimeout(() => { ripple.remove(); enemy.el.remove(); }, 350); state.score += 10; scoreEl.textContent = state.score; }敌人的生成和移动,我用了“按间隔生成 + 动画循环移动”的组合方案。生成敌人不放在动画循环里,而是套一个 setInterval 来控制出怪频率;移动则放在 requestAnimationFrame 中,保证平滑。这样设计的好处是两个生命周期解耦:生成频率对应游戏难度,移动频率对应画面流畅度,互不干扰。
function spawnEnemy() { const el = document.createElement('div'); el.className = 'enemy'; sceneEl.appendChild(el); const side = Math.floor(Math.random() * 3); const enemy = { x: 0, y: 0, radius: 22, speed: 1.2 + Math.random() * 1.6, alive: true, el: el, }; if (side === 0) { enemy.x = Math.random() * gameEl.clientWidth; enemy.y = -30; } else if (side === 1) { enemy.x = -30; enemy.y = Math.random() * gameEl.clientHeight * 0.5; } else { enemy.x = gameEl.clientWidth + 30; enemy.y = Math.random() * gameEl.clientHeight * 0.5; } state.enemies.push(enemy); el.style.left = enemy.x + 'px'; el.style.top = enemy.y + 'px'; }动画循环的每一帧虽然短,但可以做不少事。完整逻辑是:
let spawnTimer = null; function gameLoop() { if (!state.running) return; for (const enemy of state.enemies) { if (!enemy.alive) continue; // 敌人朝炮台方向移动 const muzzle = getMuzzleCenter(); const dx = muzzle.x - enemy.x; const dy = muzzle.y - enemy.y; const dist = Math.hypot(dx, dy); enemy.x += (dx / dist) * enemy.speed; enemy.y += (dy / dist) * enemy.speed; enemy.el.style.left = enemy.x + 'px'; enemy.el.style.top = enemy.y + 'px'; // 敌人到达炮台附近,扣除生命 if (dist < 40) { enemy.alive = false; enemy.el.remove(); state.lives--; livesEl.textContent = state.lives; if (state.lives <= 0) { endGame(); return; } } } requestAnimationFrame(gameLoop); }这里有一个自认为比较实用的设计:敌人不是垂直往下砸,而是朝着炮台方向追击。追击算法就是先算出单位方向向量,再用速度乘以方向向量,这样敌人无论从屏幕哪个方向出现,移动路径都自然且可预测。很多做射击游戏的新手容易把移动写死成“x 加固定值、y 加固定值”,一旦目标点变化就需要大量规则弥补,但从一开始就向量化移动会更省力。
开始和结束游戏的逻辑如下:
function startGame() { state.running = true; state.score = 0; state.lives = 5; state.enemies.forEach(e => e.el.remove()); state.enemies = []; scoreEl.textContent = '0'; livesEl.textContent = '5'; startOverlay.style.display = 'none'; spawnTimer = setInterval(() => { if (!state.running) return; spawnEnemy(); }, 1000); requestAnimationFrame(gameLoop); } function endGame() { state.running = false; clearInterval(spawnTimer); startOverlay.style.display = 'flex'; startOverlay.querySelector('h2').textContent = '游戏结束'; startOverlay.querySelector('p').textContent = '最终得分:' + state.score; startOverlay.querySelector('button').textContent = '再来一次'; // 清理残留动画元素 sceneEl.querySelectorAll('.beam, .ripple').forEach(el => el.remove()); } document.getElementById('start-btn').addEventListener('click', () => { const h2 = startOverlay.querySelector('h2'); h2.textContent = '激光射击'; startOverlay.querySelector('p').textContent = '点击屏幕发射激光,消灭敌人'; startOverlay.querySelector('button').textContent = '开始游戏'; startGame(); }); gameEl.addEventListener('click', (e) => { fireAt(e.clientX, e.clientY); });事件绑定最外层容器而不是每个敌人,就是前面说的事件委托。整套代码组合下来,一个可以玩的激光射击游戏就完成了。
3.3 计分规则与难度曲线的调优心得
第一次版本里,我只设了固定出怪间隔 1000 毫秒,结果玩家五分钟就能摸出规律:一个敌人 10 分,一直点就行,游戏变得单调。后来我调整了一版,出怪间隔随分数提升逐渐缩短,还让敌人血量出现差异。实现难度不大,但游戏耐玩度明显提升:
function getSpawnInterval() { const base = 1000; const reduce = Math.floor(state.score / 100) * 80; return Math.max(base - reduce, 400); }再把 spawn 的 setInterval 改成在每次调用的 setTimeout 里重新注册,这样间隔能动态变化。这类“曲线设计”不需要复杂公式,关键是让玩家在 1 到 3 局内逐渐感受到压力上升,而不是一开始就劝退。
计分规则也要给玩家“爽感”。我这边额外设计了爆头判定:如果击中点到敌人中心的距离小于敌人半径的 40%,奖励 15 分并生成更大的金色涟漪。这个规则让玩家在瞄准时有追求精确度的动力,比单纯连击数更有操作感。代码只需在 killEnemy 之前拿到距离,再分支处理。
4. 常见问题与排障实录
4.1 动画卡顿的排查方向
项目做了一半的时候,我发现敌人数量稍微多一点、或者射击频率高一些时,页面就肉眼可见地掉帧。排查过程从三个方向入手:
- 节点堆积:每发射一颗激光我都在 DOM 里生成两个元素,如果不清理会越积越多。解决方法是事件触发后立即用 setTimeout 在动画结束后 remove;
- 强制同步布局:有些循环里,每改一个元素位置就立刻读取 offsetWidth 或 getBoundingClientRect,会导致浏览器反复重新计算布局。我尽量把坐标计算先算好,再一次性写入 style;
- 动画频率:把 setInterval 中的移动逻辑改成 requestAnimationFrame,保证和屏幕刷新频率同步。
排查完这三个点,掉帧问题基本解决。这类问题有一个通用排查心法:先在 Performance 面板录制一段操作,看 Main 线程里是否有大段的紫色和黄色任务;紫色是渲染,黄色是脚本,哪个占比高就针对哪个优化。
4.2 坐标偏移的常见原因
这个项目的坐标换算是一个高频出问题点。统计下来最容易踩的坑有三个:
一是监听事件挂错了元素。如果你把 click 挂到 #scene 上,e.clientX和e.clientY依然是视口坐标系,而你的敌人位置是按 #game 内部坐标算的,这两者之间差了一个 #game 的偏移量。二是没有考虑页面滚动。哪怕页面只有一个游戏容器,只要 body 出现滚动条,就必须把 scrollX 也加上。三是窗口缩放引起的坐标系变化。解决方案始终一致:取游戏容器getBoundingClientRect(),用鼠标视口坐标减去容器左上角视口坐标,得到相对于容器左上角的逻辑坐标。记住这套换算,任何 DOM 项目通用。
4.3 事件穿透与误触发问题
射击游戏里玩家会快速连点,这时候容易出现两个哭笑不得的问题:点到了“开始游戏”按钮后,同一处位置又触发了一次射击事件;游戏结束后残留在界面上的敌人还在移动。这些其实都是事件穿透和状态未清理导致。
第一类问题的解法是按钮点击之后立即把开始面板隐藏,不要依赖事件冒泡顺序。我这边在 startBtn 的点击回调里加了一句e.stopPropagation(),阻止事件继续冒泡到 #game 容器,避免按钮点击被当成一次射击。第二类问题是在 endGame 里先置 state.running 为 false,再清理所有敌人和特效元素,这样动画循环下一次进来时直接退出,不会继续移动任何敌人。
4.4 浏览器兼容性与调试建议
这个项目用到的语法都是 ES6 级别的基础语法,现代浏览器都能正常运行。唯一要注意的是Math.hypot在非常老的环境下不支持,如果要做兼容,可以用Math.sqrt(dx * dx + dy * dy)代替。CSS 的gap属性、inset简写也只建议在较新浏览器中用,老项目里可以写成top:0;right:0;bottom:0;left:0。
调试建议上,我强烈推荐学会使用 Chrome DevTools 的 Elements 面板和 Sources 面板。在 Elements 面板里选中敌人元素,可以直接修改它的 left 和 top 看定位问题出在哪;在 Sources 面板里给 fireAt 打断点,再点一次屏幕就能看到每一步执行的变量值。还有一个隐藏技巧:如果你怀疑 CSS 动画异常,可以在 DevTools 的 Rendering 选项里开启“Paint flashing”,所有进行重绘的区域会短暂闪烁,一眼就能看出哪些元素在反复重绘。
5. 从项目到扩展:还能往哪些方向迭代
这个项目做到能顺畅游玩,只是一个起点。后续我自己规划了几个扩展方向,分享出来供大家参考:
- 增加多种敌人类型:狙击型敌人会朝玩家发射激光、自爆型敌人靠近后会引爆范围伤害、躲避型敌人左右蛇形移动,武器克制关系也要跟着调整。
- 触摸支持:在移动端把 click 换成 touch 事件,再处理多点触控和翻滚,这样游戏就能拿上手机玩。
- 音效和背景音乐:完全用 Web Audio API 合成激光音效,不需要任何音频文件,用一段振荡器加上频率递减就能模拟出“biu”的射击声。
- 最高分持久化:用 localStorage 保存玩家的最好成绩。很多小白项目都忽略了这一点,其实几行代码就能实现,但对用户的留存体验提升非常明显。
- 完整代码整理:把 3.1 到 3.2 的代码片段拼合成一个 single HTML 文件,注意把
<script>标签放在 body 末尾,避免操作尚未解析的 DOM。
我个人实际测试下来的经验是:这类小型游戏项目最重要的不是代码写得多么花哨,而是让“输入到反馈”的链路做到极致流畅。每次点击、每个动画、每次加分,都直接刺激到玩家的感官,这个项目才真正有了灵魂。你完全可以使用上面的完整代码作为基础,去调整敌人样式、射击手感、特效颜色,然后把它变成你自己的作品。也别怕改坏,改坏了重新刷新,本来就是前端项目最大的试错成本优势。