ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

原生三件套手写激光射击游戏:从思路到完整代码

原生三件套手写激光射击游戏:从思路到完整代码 纯前端小项目做到能拿得出手比堆框架更能练人。最近我抽空把之前拖了很久的一个想法落地了用原生 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 idgame div idmuzzle/div div idscene/div div idhud span得分span idscore0/span/span span生命span idlives5/span/span /div div idstart-overlay h2激光射击/h2 p点击屏幕发射激光消灭敌人/p button idstart-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 langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 /htmlCSS 里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 classbeam-core stylewidth: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。我个人实际测试下来的经验是这类小型游戏项目最重要的不是代码写得多么花哨而是让“输入到反馈”的链路做到极致流畅。每次点击、每个动画、每次加分都直接刺激到玩家的感官这个项目才真正有了灵魂。你完全可以使用上面的完整代码作为基础去调整敌人样式、射击手感、特效颜色然后把它变成你自己的作品。也别怕改坏改坏了重新刷新本来就是前端项目最大的试错成本优势。
返回列表