前一阵子有人问我,用纯 html+css+javascript 做一个小游戏到底能做到什么程度。我当时正好在整理一个激光射击游戏的项目,就顺手把整个实现过程重新过了一遍。这个项目最吸引我的地方在于:它不需要任何框架和构建工具,一个.html文件就能跑起来,却包含画布绘制、键盘交互、碰撞检测、动画循环、粒子特效这些前端游戏开发最常见的核心要素。如果你想把 JavaScript 的实战能力往上提一截,或者想在简历里放一个能直接在线演示的小项目,这个激光射击游戏是一个性价比很高的练手对象。
我会把项目的结构、视觉设计、核心逻辑、踩坑经验和完整代码都放在下面。代码放在文末,方便你直接复制保存。整个项目我按宽 1440px、高 810px 设计,适配常见桌面浏览器窗口,小屏幕下也能通过 CSS 缩放显示。不管你是刚学完 JavaScript 基础语法想找项目练手,还是已经有几年经验想快速实现一个游戏原型,这篇文章都能给你一个能直接跑通的参考。
1. 为什么用纯前端三件套做激光射击游戏
1.1 激光射击游戏的特性与前端技术的契合点
激光射击游戏天然适合 canvas 和 requestAnimationFrame。射击类游戏的核心循环无非是:生成目标、绘制画面、更新位置、检测碰撞、渲染下一帧。这个过程如果用 DOM 操作来实现,每次移动都要改元素的 left/top,敌人一多就会出现大量重排重绘,性能很快到瓶颈。而 canvas 把整个游戏画面当作一张画布,每帧重绘一次,大量对象都能在同一帧里全部画出来。
另一个好处是激光射击游戏的视觉反馈非常直观。激光射线可以简单到只是一条直线,也可以复杂到叠加发光效果、拖尾残影、命中时的涟漪光圈扩散。这些效果用 canvas 的arc、globalAlpha、shadowBlur就能做出来,不需要额外引入粒子系统库。CSS 在这里负责游戏之外的部分:页面背景、画布边框、按钮、HUD 信息面板。把 HTML 负责结构、CSS 负责样式、JavaScript 负责交互逻辑的分工体现得很干净。
1.2 项目整体效果和技术选型
我做的这个版本包含几个基本玩法:
- 玩家飞船在画布底部移动,左右方向键或 A/D 键控制。
- 按空格键发射激光,激光以竖直向上的光柱形式出现。
- 敌人从顶部随机生成,向下缓慢移动,被激光命中会爆炸并产生涟漪光圈扩散效果。
- 敌人碰到画布底部会扣一条生命,漏掉三个敌人游戏结束。
- 每击毁一个敌人得 10 分,连续命中会有额外加分。
- 难度会随时间缓慢提升,敌人生成速度和移动速度都会增加。
技术选型上没有用任何第三方库。HTML 只提供一个<canvas>和几个界面元素;CSS 负责深空背景、画布流光边框、按钮样式;JavaScript 全部逻辑写在一个script标签里。这样做的好处是你把文件下载到本地双击打开就能玩,不需要启动本地服务器,不需要npm install,非常适合学习阶段反复改动。
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>激光射击游戏</title> <style> /* 样式放这里 */ </style> </head> <body> <canvas id="gameCanvas" width="1440" height="810"></canvas> <script> // 游戏逻辑放这里 </script> </body> </html>这个骨架是项目的基础。下面我会把 CSS 和 JavaScript 拆开讲,因为这两部分分别解决的是“看起来怎么样”和“玩起来怎么样”的问题,思考逻辑完全不同。
2. 页面骨架与视觉样式:HTML+CSS 的细节处理
2.1 画布与界面结构
游戏主体放在一个 1440x810 的画布里。为什么选这个分辨率?因为大多数 16:9 的屏幕都能完整显示,而且画布内部坐标空间固定后,碰撞检测和位置计算都会简单很多。你不需要为了适配不同屏幕去动态缩放游戏逻辑里的坐标,只需要在 CSS 里控制画布的最大宽度和高度。
html, body { margin: 0; height: 100%; background: radial-gradient(circle at 50% 30%, #0b1026 0%, #02020a 100%); display: flex; align-items: center; justify-content: center; overflow: hidden; } #gameCanvas { max-width: 96vw; max-height: 96vh; border: 2px solid rgba(0, 255, 255, 0.6); border-radius: 12px; box-shadow: 0 0 18px rgba(0, 255, 255, 0.5), 0 0 60px rgba(0, 255, 255, 0.15), inset 0 0 30px rgba(0, 0, 0, 0.8); background: #000; display: block; touch-action: none; }画布外框我用了青色发光效果。这里用到的 box-shadow 可以叠多层,第一层贴近边框做高亮,第二层扩散做外发光,第三层 inset 做内部暗角。视觉上像是科幻游戏里的 HUD 边框,不需要图片素材。
游戏信息我用的是一个浮在画布上方的 HUD 层,但为了不遮挡画布内容,我的做法是把 HUD 放在游戏容器外,和画布水平排列。小屏幕下 HUD 会换行到画布上方。这个方案比绝对定位覆盖在画布上更稳,因为 canvas 本身是动态绘制的,用绝对定位需要反复读取 canvas 的位置和尺寸,比较麻烦。
.game-wrapper { display: flex; flex-direction: column; align-items: center; gap: 14px; } .hud { display: flex; gap: 30px; justify-content: center; padding: 10px 24px; background: rgba(0, 20, 40, 0.7); border: 1px solid rgba(0, 255, 255, 0.4); border-radius: 999px; color: #b0f0ff; font-family: "Courier New", monospace; font-size: 18px; }2.2 涟漪光圈扩散与流光边框等 CSS 效果
标题里提到了“激光射击”,很多人会直接想到激光粒子。实际上激光射击游戏里最出效果的不是线条本身,而是命中瞬间的能量扩散。我在 canvas 里实现了涟漪光圈扩散:击中敌人时,会从命中坐标向外画多个同心圆,圆的半径逐渐增大,透明度逐渐降低,直到消失。这个效果如果不用 canvas,用 CSS 动画加绝对定位也能做,但需要频繁创建和删除 DOM 节点,子弹一多页面会很卡。放在 canvas 里就只是数组里多了一个对象。
至于“流光边框”,我的做法是给画布加一层 CSS 边框和 box-shadow,再让边框颜色每隔几秒做一次切换。其实纯 CSS 也能做流光边框渐变,但会用到额外的伪元素和 background-position 动画。在这个游戏里我选择更稳的方案:边框保持青色,box-shadow 用 CSS 动画模拟呼吸效果。
@keyframes borderPulse { 0%, 100% { box-shadow: 0 0 18px rgba(0, 255, 255, 0.5), 0 0 60px rgba(0, 255, 255, 0.15); } 50% { box-shadow: 0 0 24px rgba(0, 255, 255, 0.8), 0 0 90px rgba(0, 255, 255, 0.3); } } #gameCanvas { animation: borderPulse 3s ease-in-out infinite; }2.3 CSS 变量的使用与样式组织
我建议把颜色值抽成 CSS 变量,因为游戏调色时你会频繁试颜色,写死的话改起来很痛苦。比如主色、敌机颜色、警告色分别定义成变量,之后微调主题就只改一处。
:root { --primary-glow: #00ffff; --enemy-color: #ff5e7d; --warning-color: #ffb84d; --text-color: #b0f0ff; --hud-bg: rgba(0, 20, 40, 0.75); }这里想提醒一点:CSS 变量虽然方便,但不要滥用。只有那些会在多个地方复用的设计变量才值得提取,单个页面只出现一次的样式直接写死就行。这个项目的 CSS 变量我只提了四个颜色,够用且不乱。
3. JavaScript 核心逻辑拆解
3.1 游戏状态与数据结构
写游戏逻辑前,最重要的一件事是定义游戏状态。我的经验是:所有变化的数据都集中放,不要在函数里随手存全局变量。这能避免很多“这个值怎么无缘无故变了”的排查时间。
游戏状态我用对象保存:
const state = { player: { x: 720, y: 740, width: 40, height: 30 }, lasers: [], enemies: [], ripples: [], score: 0, lives: 3, gameOver: false, lastShotTime: 0, enemySpawnTimer: 0, difficulty: 1, keys: {}, canvas: null, ctx: null, lastTime: 0, };这里每个数组都是一个动态对象池。激光、敌人、涟漪的效果完全靠数组遍历更新,每次游戏循环时先更新数据,再全部绘制一遍。对象池的好处是增删都在数组层面完成,结构简单,对新手特别友好。
3.2 画布绘图:玩家、敌机、激光与涟漪光圈
canvas 绘制的基本模式是:清空画布,绘制背景,绘制游戏对象,绘制特效。清空画布用clearRect,但纯黑背景下我会直接填充一个半透明黑色矩形,这样能产生拖尾残影。
function drawBackground() { state.ctx.fillStyle = 'rgba(2, 2, 10, 0.25)'; state.ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); }玩家飞船我用一个三角形来表示。三角形并不比矩形复杂,但视觉上更有“飞船感”。绘制时先把坐标系平移到飞船位置,再旋转到朝向角度,最后画出路径。
function drawPlayer() { const p = state.player; const ctx = state.ctx; ctx.save(); ctx.translate(p.x, p.y + p.height / 2); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-p.width / 2, p.height); ctx.lineTo(p.width / 2, p.height); ctx.closePath(); ctx.fillStyle = '#00ffff'; ctx.shadowColor = '#00ffff'; ctx.shadowBlur = 20; ctx.fill(); ctx.restore(); }shadowBlur是 canvas 自带发光效果,性能不高,但对象数量少时完全够用。等敌人数量大量增加后,可以考虑关闭阴影,改用叠加半透明图形模拟发光。
激光射线我画的是上下两段:先画一条细的高亮线,再画一条粗的半透明光柱。这种“双层绘制”是 canvas 发光效果的常用替代方案,视觉上比单纯一条线更有激光感。
function drawLaser(laser) { const ctx = state.ctx; ctx.beginPath(); ctx.moveTo(laser.x, laser.y); ctx.lineTo(laser.x, laser.y - laser.length); ctx.strokeStyle = 'rgba(0, 255, 255, 0.25)'; ctx.lineWidth = 8; ctx.stroke(); ctx.beginPath(); ctx.moveTo(laser.x, laser.y); ctx.lineTo(laser.x, laser.y - laser.length); ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.stroke(); }涟漪光圈扩散的实现思路很直接:每个涟漪对象保存x、y、radius、maxRadius、alpha。每帧更新时半径增加,透明度按比例减少,当半径超过最大值或透明度归零时移出数组。
function updateRipples(deltaTime) { for (let i = state.ripples.length - 1; i >= 0; i--) { const r = state.ripples[i]; r.radius += 300 * deltaTime; r.alpha = Math.max(0, r.alpha - 2 * deltaTime); if (r.alpha <= 0 || r.radius > r.maxRadius) { state.ripples.splice(i, 1); } } } function drawRipples() { const ctx = state.ctx; for (const r of state.ripples) { ctx.beginPath(); ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2); ctx.strokeStyle = `rgba(0, 255, 255, ${r.alpha})`; ctx.lineWidth = 3; ctx.stroke(); } }3.3 游戏循环与 requestAnimationFrame
游戏循环是整个项目的心脏。requestAnimationFrame 会告诉浏览器在下一帧绘制前调用我们传入的函数,这样动画帧率和屏幕刷新率保持同步。
function gameLoop(timestamp) { const deltaTime = Math.min((timestamp - state.lastTime) / 1000, 0.05); state.lastTime = timestamp; update(deltaTime); render(); if (!state.gameOver) { requestAnimationFrame(gameLoop); } }这里有一个很关键的细节:deltaTime要限制最大值,否则当用户切换浏览器标签页再回来时,第一次 update 的间隔可能达到几十秒,敌人会瞬间飞过整个屏幕。我限制每帧最多计算 0.05 秒的增量,避免这种“穿越”问题。
游戏循环流程是:
- 更新时间增量。
- 处理输入。
- 更新玩家位置。
- 生成敌人、更新敌人位置。
- 更新激光位置并检测碰撞。
- 更新涟漪特效。
- 绘制所有内容。
- 请求下一帧。
3.4 键盘事件与玩家控制
键盘控制是前端游戏最容易踩坑的点之一。如果你直接在keydown事件里移动玩家,按住方向键时会发现移动是一卡一卡的,因为键盘事件不会按照物理帧率连续触发。
正确做法是:在事件回调里只记录按键状态,不直接改玩家位置。游戏循环里再根据按键状态统一处理位置变化。
document.addEventListener('keydown', (e) => { state.keys[e.code] = true; if (e.code === 'Space') { e.preventDefault(); tryShoot(); } }); document.addEventListener('keyup', (e) => { state.keys[e.code] = false; });移动逻辑:
function updatePlayer(deltaTime) { const speed = 460 * deltaTime; if (state.keys['ArrowLeft'] || state.keys['KeyA']) { state.player.x -= speed; } if (state.keys['ArrowRight'] || state.keys['KeyD']) { state.player.x += speed; } state.player.x = Math.max(30, Math.min(state.canvas.width - 30, state.player.x)); }这里使用 e.code 而不是 e.key,是因为它绑定的是物理按键位置而不是字符。使用 WASD 和方向键时,即使键盘布局不同也能保持一致。
空格键发射的处理:按住空格应该持续发射,但要加一个发射间隔,避免一秒钟打出几十发激光。我用时间戳控制。
function tryShoot() { const now = performance.now() / 1000; if (now - state.lastShotTime < 0.22) return; state.lastShotTime = now; state.lasers.push({ x: state.player.x, y: state.player.y, length: 28, alive: true, }); }3.5 碰撞检测
碰撞检测在这个项目里用的是最简单的距离判断。因为激光和敌人都可以近似看成一个圆点或小半径圆,所以我存每个对象的x、y和半径,然后判断两点距离是否小于两个半径之和。这就是圆形碰撞检测。
function checkCollisions() { for (let i = state.lasers.length - 1; i >= 0; i--) { const laser = state.lasers[i]; for (let j = state.enemies.length - 1; j >= 0; j--) { const enemy = state.enemies[j]; const dx = laser.x - enemy.x; const dy = laser.y - enemy.y; const dist = Math.hypot(dx, dy); if (dist < enemy.radius + 8) { state.lasers.splice(i, 1); state.enemies.splice(j, 1); state.score += 10; addRipple(enemy.x, enemy.y); break; } } } }这里用Math.hypot计算距离,比手动Math.sqrt(dx*dx + dy*dy)可读性更好,性能差别可以忽略。另一个细节是我用倒序循环遍历数组再配合splice,这样删除元素不会导致后续索引错位。
敌人到达底部时也属于另一种碰撞,是对画布底边的坐标碰撞。判断enemy.y + enemy.radius >= state.canvas.height就扣命并删除该敌人。
3.6 计分与关卡逻辑
计分逻辑我加了一点激励机制。每击毁一个敌人固定加 10 分,但如果玩家在短时间内连续击中敌人,会额外加“连击积分”。连击状态用一个时间窗口控制。
let comboCount = 0; let comboExpireTime = 0; function onEnemyDestroyed(x, y) { const now = performance.now() / 1000; state.ripples.push({ x, y, radius: 10, maxRadius: 70, alpha: 0.8 }); if (now < comboExpireTime) { comboCount++; } else { comboCount = 0; } const comboBonus = Math.min(comboCount * 5, 30); state.score += 10 + comboBonus; comboExpireTime = now + 1.5; }难度递增我使用时间驱动而不是分数驱动:每隔一段时间,敌人生成间隔缩短一点,敌人的移动速度乘一个系数。这样游戏的节奏会渐进式变紧张。
function updateDifficulty() { state.difficulty = 1 + state.score / 500; }生成敌人生成间隔 = 基础间隔 / difficulty。这样分数越高,出怪越快。
4. 容易踩的坑与优化思路
4.1 键盘事件的粘滞问题
最典型的坑是:按住空格键不放时,页面会触发浏览器的“长按重复 keydown”机制,导致射击频率不受你控制。这个问题解决方法是只在第一次触发 keydown 时调用射击函数,还是使用时间戳限流?两种方案我都试过。
如果只在第一次触发时射击,玩家必须反复按空格,手很累。我用的是时间戳限流:允许按住空格连续射击,但每次射击之间至少间隔 220ms。这样玩家可以按住空格扫射,又不至于火力过猛。对应的代码在tryShoot里已经体现。
另一个坑是方向键和空格键会滚动页面或触发按钮焦点行为。我在 keydown 里对Space、ArrowUp、ArrowDown调用了preventDefault()。如果不处理,游戏玩到一半页面突然滚动,体验非常差。
4.2 坐标与尺寸适配
因为画布固定为 1440x810,而 CSS 里max-width: 96vw会自动缩放显示尺寸,这就导致鼠标和触摸检测时要注意坐标换算。如果游戏支持点击发射或触摸控制,你需要把鼠标像素坐标换算成画布逻辑坐标。
function getCanvasPosition(clientX, clientY) { const rect = state.canvas.getBoundingClientRect(); return { x: (clientX - rect.left) * (state.canvas.width / rect.width), y: (clientY - rect.top) * (state.canvas.height / rect.height), }; }我在键盘版里没用到鼠标,但如果后续扩展成移动端触屏版,这个函数就是关键。
4.3 性能优化
当敌人数量超过 50 个、涟漪数量超过 30 个时,canvas 的阴影效果会成为性能瓶颈。我做了几个优化:
- 敌人和玩家的
shadowBlur只在小范围对象上保留。 - 激光的发光用双层绘制替代阴影。
- 涟漪使用
globalAlpha而不是rgba重新拼接颜色字符串,减少字符串计算。 - 对不可见对象立即从数组中移除。
我的判断标准是看 Chrome 开发者工具的 Performance 面板。如果一帧耗时超过 16ms,我就优先检查 draw 相关代码。游戏逻辑(位置更新、碰撞检测)的运算量通常很低,真正耗时的都是阴影、渐变这类绘制指令。
4.4 碰撞判定细化
圆形碰撞检测虽然简单,但在激光宽度较粗时会出现“激光穿模”的观感。激光实际是一条垂直向上的线,如果只把激光当圆心点判断,敌人稍微偏移一点就容易漏判。我把激光的碰撞半径从 0 改成了 8 像素,同时把玩家的飞船命中判定也调大了一点,手感明显变好。
更好的方案是做矩形碰撞检测,但在这个游戏里圆形模型已经足够。矩形检测适合精灵图边界不规则的情况。我倾向于先跑通圆形检测,再根据实际手感调整半径。项目的数据驱动结构允许你随时调整碰撞半径而不影响其他代码。
5. 完整代码与运行说明
5.1 完整代码
下面是整个项目的完整代码,复制保存为.html文件即可运行。目录结构只需要一个文件,不需要额外图片和库。我用的是 html + css + javascript 三件套,全部内容都包含在同一个页面中。
<!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>激光射击游戏</title> <style> :root { --primary-glow: #00ffff; --enemy-color: #ff5e7d; --warning-color: #ffb84d; --text-color: #b0f0ff; --hud-bg: rgba(0, 20, 40, 0.75); } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; background: radial-gradient(circle at 50% 30%, #0b1026 0%, #02020a 100%); display: flex; align-items: center; justify-content: center; overflow: hidden; font-family: "Courier New", Consolas, monospace; } .game-wrapper { display: flex; flex-direction: column; align-items: center; gap: 14px; } .hud { display: flex; gap: 30px; justify-content: center; padding: 10px 24px; background: var(--hud-bg); border: 1px solid rgba(0, 255, 255, 0.4); border-radius: 999px; color: var(--text-color); font-size: 18px; letter-spacing: 1px; user-select: none; } .hud span { color: var(--primary-glow); font-weight: bold; } #gameCanvas { max-width: 96vw; max-height: 80vh; border: 2px solid rgba(0, 255, 255, 0.6); border-radius: 12px; background: #000; display: block; cursor: crosshair; animation: borderPulse 3s ease-in-out infinite; } @keyframes borderPulse { 0%, 100% { box-shadow: 0 0 18px rgba(0, 255, 255, 0.5), 0 0 60px rgba(0, 255, 255, 0.15); } 50% { box-shadow: 0 0 24px rgba(0, 255, 255, 0.8), 0 0 90px rgba(0, 255, 255, 0.3); } } .game-tip { color: rgba(180, 240, 255, 0.6); font-size: 14px; text-align: center; line-height: 1.6; } </style> </head> <body> <div class="game-wrapper"> <div class="hud"> <div>得分 <span id="scoreDisplay">0</span></div> <div>连击 <span id="comboDisplay">0</span></div> <div>生命 <span id="livesDisplay">3</span></div> </div> <canvas id="gameCanvas" width="1440" height="810"></canvas> <div class="game-tip"> 方向键 / A D 移动,空格发射激光。不要让敌机撞到底部! </div> </div> <script> const state = { player: { x: 720, y: 740, width: 40, height: 30 }, lasers: [], enemies: [], ripples: [], score: 0, lives: 3, gameOver: false, lastShotTime: 0, enemySpawnTimer: 0, difficulty: 1, keys: {}, canvas: null, ctx: null, lastTime: 0, }; let comboCount = 0; let comboExpireTime = 0; function init() { state.canvas = document.getElementById('gameCanvas'); state.ctx = state.canvas.getContext('2d'); document.getElementById('scoreDisplay').textContent = '0'; document.getElementById('livesDisplay').textContent = '3'; document.addEventListener('keydown', (e) => { state.keys[e.code] = true; if (e.code === 'Space' || e.code === 'ArrowUp' || e.code === 'ArrowDown') { e.preventDefault(); } if (e.code === 'Space') { tryShoot(); } }); document.addEventListener('keyup', (e) => { state.keys[e.code] = false; }); state.lastTime = performance.now(); requestAnimationFrame(gameLoop); } function updatePlayer(deltaTime) { const speed = 460 * deltaTime; if (state.keys['ArrowLeft'] || state.keys['KeyA']) { state.player.x -= speed; } if (state.keys['ArrowRight'] || state.keys['KeyD']) { state.player.x += speed; } state.player.x = Math.max(40, Math.min(state.canvas.width - 40, state.player.x)); } function tryShoot() { const now = performance.now() / 1000; if (now - state.lastShotTime < 0.22) return; state.lastShotTime = now; state.lasers.push({ x: state.player.x, y: state.player.y - 20, length: 28, alive: true, }); } function spawnEnemy(deltaTime) { if (state.gameOver) return; const baseInterval = 0.8; const interval = baseInterval / state.difficulty; state.enemySpawnTimer -= deltaTime; if (state.enemySpawnTimer <= 0) { state.enemySpawnTimer = interval; const sideFactor = Math.random() * 0.5 + 0.6; const speed = 70 * sideFactor * state.difficulty; state.enemies.push({ x: 60 + Math.random() * (state.canvas.width - 120), y: -30, radius: 14 + Math.random() * 10, speed: Math.min(speed, 190), color: Math.random() > 0.5 ? '#ff5e7d' : '#ffb84d', }); } } function updateEnemies(deltaTime) { for (let i = state.enemies.length - 1; i >= 0; i--) { const enemy = state.enemies[i]; enemy.y += enemy.speed * deltaTime; if (enemy.y - enemy.radius > state.canvas.height) { state.enemies.splice(i, 1); state.lives -= 1; if (state.lives <= 0) { state.gameOver = true; } } } } function updateLasers(deltaTime) { for (let i = state.lasers.length - 1; i >= 0; i--) { const laser = state.lasers[i]; laser.y -= 620 * deltaTime; if (laser.y < -50) { state.lasers.splice(i, 1); } } } function addRipple(x, y) { state.ripples.push({ x, y, radius: 8, maxRadius: 70 + Math.random() * 30, alpha: 0.8, }); } function updateRipples(deltaTime) { for (let i = state.ripples.length - 1; i >= 0; i--) { const r = state.ripples[i]; r.radius += 320 * deltaTime; r.alpha = Math.max(0, r.alpha - 1.8 * deltaTime); if (r.alpha <= 0 || r.radius > r.maxRadius) { state.ripples.splice(i, 1); } } } function onEnemyDestroyed(x, y) { const now = performance.now() / 1000; addRipple(x, y); if (now < comboExpireTime) { comboCount++; } else { comboCount = 0; } const comboBonus = Math.min(comboCount * 5, 30); state.score += 10 + comboBonus; comboExpireTime = now + 1.5; document.getElementById('comboDisplay').textContent = comboCount; } function checkCollisions() { for (let i = state.lasers.length - 1; i >= 0; i--) { const laser = state.lasers[i]; for (let j = state.enemies.length - 1; j >= 0; j--) { const enemy = state.enemies[j]; const dx = laser.x - enemy.x; const dy = laser.y - enemy.y; const dist = Math.hypot(dx, dy); if (dist < enemy.radius + 8) { state.lasers.splice(i, 1); state.enemies.splice(j, 1); onEnemyDestroyed(enemy.x, enemy.y); break; } } } } function drawBackground() { const ctx = state.ctx; ctx.fillStyle = 'rgba(2, 2, 10, 0.25)'; ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); } function drawPlayer() { const p = state.player; const ctx = state.ctx; ctx.save(); ctx.translate(p.x, p.y); ctx.beginPath(); ctx.moveTo(0, -p.height); ctx.lineTo(-p.width / 2, 0); ctx.lineTo(p.width / 2, 0); ctx.closePath(); ctx.fillStyle = '#00ffff'; ctx.shadowColor = '#00ffff'; ctx.shadowBlur = 18; ctx.fill(); ctx.shadowBlur = 0; ctx.restore(); } function drawLasers() { const ctx = state.ctx; for (const laser of state.lasers) { ctx.beginPath(); ctx.moveTo(laser.x, laser.y); ctx.lineTo(laser.x, laser.y - laser.length); ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)'; ctx.lineWidth = 8; ctx.stroke(); ctx.beginPath(); ctx.moveTo(laser.x, laser.y); ctx.lineTo(laser.x, laser.y - laser.length); ctx.strokeStyle = '#eaffff'; ctx.lineWidth = 2; ctx.stroke(); } } function drawEnemies() { const ctx = state.ctx; for (const enemy of state.enemies) { ctx.beginPath(); ctx.arc(enemy.x, enemy.y, enemy.radius, 0, Math.PI * 2); ctx.fillStyle = enemy.color; ctx.shadowColor = enemy.color; ctx.shadowBlur = 12; ctx.fill(); ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(enemy.x, enemy.y, enemy.radius * 0.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fill(); } } function drawRipples() { const ctx = state.ctx; for (const r of state.ripples) { ctx.beginPath(); ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(0, 255, 255, ' + r.alpha + ')'; ctx.lineWidth = 3; ctx.stroke(); ctx.beginPath(); ctx.arc(r.x, r.y, r.radius * 0.5, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(255, 255, 255, ' + r.alpha * 0.5 + ')'; ctx.lineWidth = 1.5; ctx.stroke(); } } function drawGameOver() { const ctx = state.ctx; ctx.fillStyle = 'rgba(0, 10, 20, 0.75)'; ctx.fillRect(0, 0, state.canvas.width, state.canvas.height); ctx.fillStyle = '#00ffff'; ctx.shadowColor = '#00ffff'; ctx.shadowBlur = 30; ctx.font = 'bold 64px "Courier New", monospace'; ctx.textAlign = 'center'; ctx.fillText('GAME OVER', state.canvas.width / 2, state.canvas.height / 2); ctx.shadowBlur = 0; ctx.font = '22px "Courier New", monospace'; ctx.fillStyle = '#b0f0ff'; ctx.fillText('最终得分: ' + state.score, state.canvas.width / 2, state.canvas.height / 2 + 60); ctx.fillText('按 R 重新开始', state.canvas.width / 2, state.canvas.height / 2 + 100); } function update(deltaTime) { if (state.gameOver) { if (state.keys['KeyR']) { resetGame(); } return; } updatePlayer(deltaTime); updateEnemies(deltaTime); updateLasers(deltaTime); updateRipples(deltaTime); spawnEnemy(deltaTime); checkCollisions(); state.difficulty = 1 + state.score / 500; document.getElementById('scoreDisplay').textContent = state.score; document.getElementById('livesDisplay').textContent = state.lives; } function render() { const ctx = state.ctx; drawBackground(); drawPlayer(); drawLasers(); drawEnemies(); drawRipples(); if (state.gameOver) { drawGameOver(); } } function resetGame() { state.lasers = []; state.enemies = []; state.ripples = []; state.score = 0; state.lives = 3; state.gameOver = false; state.lastShotTime = 0; state.enemySpawnTimer = 0; state.difficulty = 1; comboCount = 0; document.getElementById('scoreDisplay').textContent = '0'; document.getElementById('comboDisplay').textContent = '0'; document.getElementById('livesDisplay').textContent = '3'; } function gameLoop(timestamp) { const deltaTime = Math.min((timestamp - state.lastTime) / 1000, 0.05); state.lastTime = timestamp; update(deltaTime); render(); requestAnimationFrame(gameLoop); } init(); </script> </body> </html>5.2 如何修改与扩展
这个版本的功能不算复杂,但作为基础框架已经能跑通完整的游戏循环。我建议拿到代码后先不要急着加功能,而是先做三件小事:
- 把
state.player.x初始值改成 100,看看玩家出生位置的变化。 - 把
tryShoot里的0.22改成0.1和0.5,感受射击频率对游戏难度的影响。 - 把
state.difficulty = 1 + state.score / 500改成1 + state.score / 200,感受难度曲线变陡之后的手感。
这三个小实验能帮助你快速建立“参数直接影响体验”的感觉。
继续扩展的方向有很多:给敌人增加不同血量和移动轨迹,玩家增加护盾和技能冷却,加入音效,甚至加一个排行榜按钮。因为整个游戏的状态都在state对象里,扩展时只需要增加对应的字段和更新函数,不需要推翻原有结构。
我在实际开发中最大的体会是:不要一开始就追求“完整成品”。先让核心循环跑起来,再一个效果一个效果地加。这个激光射击游戏的代码量不大,但每个部分都能单独拆出来研究。当你把它改成自己的版本,加入新的敌人类型和武器系统之后,你会发现自己对 JavaScript 动画和事件处理的理解会明显不一样。