简介:这是一份面向 Web 前端初学者与 JavaScript 游戏开发入门者的代码学习文档。资源包共 1 个 doc 文件,整体约 152KB,以可复制浏览的文档形式整理了一段完整的网页小游戏源码,并系统附带了 HTML 基础、JavaScript 函数、游戏对象、随机数生成、DOM 操作、样式布局、游戏事件与状态维护等知识点说明。游戏示例采用“敌方对象自动移动、玩家对象跟随鼠标躲避”的交互玩法,涉及 formPosition、getMapObj、createYu、randomZL、moveYu 等核心函数,通过数组记录敌方对象、动态创建 div、控制绝对定位坐标和重置状态来实现,结构清晰,适合对照逐段调试。当前已有 1892 人学习浏览,对于希望快速上手 JS 小游戏编程、完成课程设计或进行功能改造的读者,具有直接参考价值。
1. 「JavaScript 小游戏代码」这类汇总,搜得到不代表用得上
「JavaScript 小游戏代码汇总」这种标题,在搜索引擎里翻出来的结果,能直接用的概率其实很低。多数是三种形态:几十个文件夹堆在一个仓库里,依赖过期、注释缺失、打开全是没有实现的 TODO;或者是一段看起来有板有眼的伪代码,粘进页面连 canvas 都拿不到;再或者是示例代码讲解式的文章,思路讲得很热闹,就是没有一份完整能跑的文件。真正缺的不是代码量,而是一条把这些代码串起来的主线:主循环怎么定帧、输入怎么采集、碰撞怎么判断、状态怎么切换。下面这条路是我自己整理代码时常用的,不追求数量,只做能跑、能改、能发布的最小闭环。每一段都可以直接粘进一个空白 HTML 里运行,参数同时标清楚它在改什么。适合两类人:学完 JS 基础想拿小游戏练手的开发者,以及工作中需要在短时间内做一个可交互 demo 的前端。
2. 用 requestAnimationFrame 搭最小游戏循环,先把球跑起来
2.1 游戏循环的核心:rAF 不是定时器,是帧回调
小游戏和普通业务页面的本质区别在于它需要持续推帧,而不是事件驱动的静态 UI。setInterval(fn, 16) 的写法有两个问题:它和浏览器的渲染时机不保证对齐,后台标签页又可能被浏览器降频或暂停,导致「一会儿飞快,一会儿不走」。requestAnimationFrame 的行为则更合适:浏览器每一帧渲染前调用一次回调,页面不可见时自动暂停,回调里还会收到一个时间戳参数 now,类型是 DOMHighResTimeStamp,单位毫秒。
所以最小骨架是:在 frame 回调里先计算时间差 dt,再更新逻辑,再渲染,最后递归请求下一帧。注意回调拿的是「本次帧的时间戳」,不是「已执行次数」——这是 JavaScript 函数里最常见的一种用法,很多人一开始会在这里踩坑,把时间戳当成帧序列号来用。
2.2 最小弹球代码:dt 处理决定手感
下面是一份完整的最小弹球,没有依赖任何库,复制到一个空白 HTML 的 body 里就能跑:
<canvas id="stage" width="480" height="320"></canvas> <script> const canvas = document.querySelector('#stage'); const ctx = canvas.getContext('2d'); const ball = { x: 240, y: 160, // 圆心坐标 r: 10, // 半径 vx: 150, vy: 120 // 速度:像素/秒 }; let lastTime = 0; function frame(now) { const dt = Math.min(now - lastTime, 50); // ms,上限 50 if (lastTime !== 0) update(dt); // 首帧只做初始化 lastTime = now; render(); requestAnimationFrame(frame); } function update(dt) { const s = dt / 1000; ball.x += ball.vx * s; ball.y += ball.vy * s; if (ball.x - ball.r < 0 || ball.x + ball.r > canvas.width) ball.vx = -ball.vx; if (ball.y - ball.r < 0 || ball.y + ball.r > canvas.height) ball.vy = -ball.vy; } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#ff5a5f'; ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); ctx.fill(); } requestAnimationFrame(frame); </script>这段代码有几个关键点。速度用的是「像素/秒」而不是「每帧像素」,因为显示器刷新率在 60Hz 和 75Hz 上表现不一致,按帧走会让同一个游戏在不同屏幕上快慢不同;配合 dt/1000 换算成秒,游戏就只和真实时间相关。dt 的上限取 50ms 是为了防止调试器断点或切后台回来后,把一个大跃迁算进物理更新里——如果没有这个上限,小球可能直接穿过边界。
| 参数 | 含义 | 调整建议 |
|---|---|---|
| vx / vy | 水平/垂直速度,px/s | 数值越大越快;弹球场景建议不超过画布宽度的 1/3 每秒 |
| r | 小球半径 | 撞墙判定用的是 x-r 和 x+r,改半径要同时改判定 |
| dt 上限 50 | 防卡顿后的瞬间位移 | 网页游戏常用 50~100ms,动作类取小值 |
| canvas width/height | 碰撞与渲染坐标系 | 修改后 update/render 里的边界常量要一致 |
2.3 切标签页又回来:时间戳断裂的处理
rAF 在页面切到后台时会自动停,切回来时 now 的时间戳是新的,与上一次的差值会非常大。处理方式是三件套:首帧不更新状态(lastTime !== 0判断)、dt 设上限、暂停时不依赖 cancelAnimationFrame 而是用一个 running 标志。后面两个是很多半成品小游戏「切走再回来球就没了」的根因,写第一版的时候就把它们带上,能省很多调试时间。
其实这块还隐藏着一个常用技巧:rAF 回调拿到的 now 是「本次帧的时刻」,在需要做道具倒计时、buff 持续时间时直接减 lastTime,不要再额外调 Date.now(),避免两个时钟源打架。
3. 三类高频小游戏模板:输入采集、碰撞检测、状态机
小游戏类型五花八门,但把任意一个可玩的游戏拆开,一定会遇到三块逻辑:游戏如何拿到玩家的操作;游戏对象如何互相作用;游戏生命周期如何在不同阶段之间切换。下面分别给出最小可复用片段,并在 3.4 里把这三块和常见游戏类型做一个映射。
3.1 键盘采集器:用 Set 处理方向键
把输入写进事件回调是最常见的错误:keydown 在按住时会重复触发,用户按一个方向键时回调每秒可能触发几十次,直接在里面改坐标,角色会时快时慢。采集器的思路是「事件只更新按键集合,逻辑在 update 中统一消费状态」。
const keys = new Set(); window.addEventListener('keydown', e => { keys.add(e.code); if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) { e.preventDefault(); // 阻止页面滚动 } }); window.addEventListener('keyup', e => keys.delete(e.code)); // 伪代码:在 update 中读取 function readInput(dt) { const speed = 200; // px/s let dx = 0, dy = 0; if (keys.has('ArrowLeft')) dx -= 1; if (keys.has('ArrowRight')) dx += 1; if (keys.has('ArrowUp')) dy -= 1; if (keys.has('ArrowDown')) dy += 1; const len = Math.hypot(dx, dy); if (len > 0) { player.x += (dx / len) * speed * (dt / 1000); player.y += (dy / len) * speed * (dt / 1000); } }这里用 e.code 而不用 e.key 是刻意的。e.key 返回的是当前输入法映射出的字符,在某些键盘布局或输入法状态下会变得不可靠;e.code 返回的物理按键位置如 KeyW、ArrowUp 不会变。方向键加 WASD 双控制的常见做法,就是把 keys.has('ArrowLeft') 与 keys.has('KeyA') 同时判断,抽成一个 isLeft() 之类的方法。
归一化部分:横向和纵向同时按下时,dx 和 dy 都是 1,不做处理速度会变成约 1.41 倍,斜向跑得更快。除以 Math.hypot 后,任意方向的合成速度都保持 200px/s。这个细节在坦克、角色走位这类场景里直接影响手感。这个 input 抽象也可以和触摸逻辑共用,事件源换了,update 里读到的还是同样的坐标增量和方向。
3.2 碰撞检测两种写法:AABB 和圆
小游戏里多数碰撞可以归结为两种:轴对齐矩形和圆。矩形适合物品、砖块、玩家脚下的区域;圆适合球、子弹、爆炸范围。AABB 的检测条件是「重叠」而不是「接触」,很多人会写成 a.x === b.x,这是错误的,碰撞发生在区间相交的任何一个位置,不是一个精确的点。
// 矩形碰撞:基于区间重叠 function rectsHit(a, b) { return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; } // 圆形碰撞:用距离平方比较,省掉每次 sqrt function circlesHit(a, b) { const dx = a.x - b.x; const dy = a.y - b.y; const rr = a.r + b.r; return dx * dx + dy * dy <= rr * rr; }circlesHit 为什么不直接开方:Math.sqrt 不是不能调,而是当碰撞检测的对象一多(比如 500 发子弹对 100 个敌人),每一帧都在做开方,性能就掉下来了。距离平方这种小细节,在游戏循环里累计到成千上万次之后,低端设备上是可以被明显感知的。速度快的对象(子弹、高速角色)不能用「本帧位置」做检测,要用上一帧到本帧的线段式检测,或者把矩形沿运动方向拉长成一个扫掠矩形。练习级别的弹球和打砖块,先保证上面两个函数能接上就行。
3.3 状态机:用映射表管理菜单/运行/结算
游戏从开局到结束,一定有几套完全不同的逻辑:菜单、运行、结算。如果只用 if/else 堆在 update 里,状态一多就看不清了。更自然的是做一个状态名到处理函数的对应表,日常开发里也常用这个思路把「状态编号」和「业务逻辑」解耦。
const GameState = { MENU: 0, RUNNING: 1, GAMEOVER: 2 }; let state = GameState.MENU; function frame(now) { const dt = Math.min(now - lastTime, 50); if (lastTime !== 0) update(dt); lastTime = now; render(); requestAnimationFrame(frame); } function update(dt) { // 状态名到函数的对应关系 const handlers = { [GameState.MENU]: tickMenu, [GameState.RUNNING]: tickGame, [GameState.GAMEOVER]: tickGameOver, }; handlers[state]?.(dt); } function tickMenu(dt) { /* 闪光标题、按空格开始 */ } function tickGame(dt) { /* 游戏主体逻辑 */ } function tickGameOver(dt) { /* 残局动画、按 R 重开 */ }这个模式的优点:新增状态只需要加一个函数和一行映射,update 不再膨胀;调试时在控制台直接改 state 变量,就能跳到指定场景,不需要重新走完整局。这也是「通过名字分发到函数」的典型用法,映射表的 key 可以是数字也可以是字符串,追求的不是性能而是可读性。
状态切换时还有一条约定:迁移和迁移后的初始化分开。从 MENU 进 RUNNING,除了把 state 置为 1,还要把分数清零、玩家坐标重置、对象池复位,这些工作放在 enterRunning() 里执行,而不是在 tickMenu 里顺手做。
| 场景 | 输入 | 碰撞 | 状态机 |
|---|---|---|---|
| 贪吃蛇 | 方向队列 | 节段与自身/食物 | 运行与结算切换 |
| 弹球打砖块 | 键盘左右 | 圆与矩形 | 菜单 → 运行 → 结算 |
| 消除类 | 点击行列 | 网格坐标映射 | 下落动画与结算切换 |
这张表的意思是:先确定游戏类型的三块骨架,再往单元格里填代码。贪吃蛇的节段移动本质是一个队列加一个朝向向量,「吃到食物」就是队首和食物的矩形相交,这些都没有高深的算法,但确实是可玩性成立的地基。
4. Canvas 还是 DOM:渲染选型、触摸适配与 GC 优化
4.1 选型看量级,不看名气
Canvas 适合同屏物体多、需要频繁整体重绘的场景:粒子、弹幕、地图块、大量物体同时运动。DOM 适合元素少、交互以点击为主的场景:卡牌、菜单、合成公式列表——浏览器帮你处理了布局和事件,开发成本低得多。我常用的判断标准是:需要每帧更新的元素超过 20 个就考虑 Canvas,否则优先 DOM,代码更短且便于调试。
最常见的混合方案是 Canvas 画游戏主体,DOM 覆盖一层 UI(分数、按钮、道具图标)。此时要注意两套坐标系之间的换算:Canvas 里的游戏坐标和 DOM 的 CSS 像素坐标不互通,从 DOM 坐标换算到 Canvas 时,必须用 getBoundingClientRect() 拿到元素相对视口的位置,再减去容器偏移,这一步在 BOM 的视口计算体系里非常容易出错。
4.2 触摸事件:把「点住拖动」映射成虚拟摇杆
移动端没有键盘,常见做法是虚拟摇杆。下面把触摸事件处理成一个范围在 0~1 的向量,它和键盘采集器的结果结构一致,update 里只是换一个输入源:
const input = { x: 0, y: 0 }; // 与键盘输入结构一致 let joy = { x: 0, y: 0 }; // 摇杆起点 canvas.addEventListener('touchstart', e => { const t = e.touches[0]; const rect = canvas.getBoundingClientRect(); joy = { x: t.clientX - rect.left, y: t.clientY - rect.top }; e.preventDefault(); }, { passive: false }); canvas.addEventListener('touchmove', e => { const t = e.touches[0]; const rect = canvas.getBoundingClientRect(); let dx = t.clientX - rect.left - joy.x; let dy = t.clientY - rect.top - joy.y; const len = Math.hypot(dx, dy) || 1; const maxR = 48; // 摇杆最大半径 px const ratio = Math.min(1, len / maxR); input.x = (dx / len) * ratio; input.y = (dy / len) * ratio; e.preventDefault(); }, { passive: false }); canvas.addEventListener('touchend', () => { input.x = 0; input.y = 0; });注意几个容易忽略的点。第一,坐标必须减 rect.left 和 rect.top,因为 touch 事件里的 clientX/clientY 是相对视口的,Canvas 没贴左上角时直接拿原始值会和游戏坐标系偏移一段;如果用 CSS 缩放画布,还需要再按比例换算。第二,事件对象第三个参数显式写成{ passive: false },否则在部分浏览器中 touchmove 里的 preventDefault 会被忽略,页面会跟着手指滚动,游戏变成一边玩一边晃。CSS 上加touch-action: none是更省事的第一道防线。
4.3 GC 与对象池:为什么程序越跑越卡
游戏循环每秒执行几十次,如果每帧都在 new 数组、对象,JavaScript 引擎的垃圾回收就不断暂停去找没用的对象,表现就是掉帧。常见做法是预先分配对象,用完只改状态:
const particles = Array.from({ length: 50 }, () => ({ x: 0, y: 0, vx: 0, vy: 0, active: false })); function spawnParticle(x, y, vx, vy) { for (const p of particles) { if (!p.active) { p.x = x; p.y = y; p.vx = vx; p.vy = vy; p.active = true; break; } } } // update 里每帧只遍历 active 的粒子,false 的不绘制、不更新对象池的要点是「创建时一次做完,运行期只复用」。例子里的 50 就是池子容量,粒子再多要考虑扩容策略;对刚上手的游戏,固定池子能直接规避「玩一会儿就卡」的问题。另外两个高频性能坑:每帧去改 DOM 节点的 className 或内联样式,以及每帧调 createElement/removeChild。前者尽量只在数值变化超过阈值时更新,后者尽可能改成先隐藏再显示。Canvas 渲染里也有对应版本:不要在 update 里频繁 save() 和 restore() 而不配对,context 状态栈会被撑大,最终拖慢渲染。
| 症状 | 优先怀疑 | 做法 |
|---|---|---|
| 玩 10 秒后开始卡 | GC 压力 | 对象池、避免每帧 new |
| 背景和角色一起闪 | 每帧全量重绘 | 静态背景先画离屏 canvas,再 drawImage |
| 移动端异常滚动 | touch 事件 passive | { passive: false }加 touch-action |
| 掉帧不均匀 | 用 setInterval 驱动 | 全部改 rAF 加 dt |
5. 把代码挂进现有页面:FPS 探针与调试参数
5.1 快速接入:直接往容器里挂
如果只是在一个已有页面上做临时演示,最省事的方式是container.appendChild(canvas)。挂载前先清理容器里的旧节点,否则每次刷新会叠加多个游戏层。已有页面的 CSS 会接管新插入元素的样式,Canvas 的 display、border、touch-action 都要在 style 里显式声明。很多汇总代码在自己页面跑得好,一旦嵌入别的页面就错位,多半是样式串了。
5.2 FPS 探针:角落里的一个数字
判断是否卡顿不能靠感觉。我一般在游戏循环里挂一个计数器,每凑满 1000ms 计算一次这一秒内的帧数,把值写进屏幕角落的 DOM,不需要引入任何性能库:
let frames = 0; let lastReport = performance.now(); function frame(now) { frames++; if (now - lastReport >= 1000) { fpsText.textContent = `${Math.round(frames * 1000 / (now - lastReport))} FPS`; frames = 0; lastReport = now; } // 其余游戏逻辑,最后 requestAnimationFrame(frame) }这个探针的读法:目标设备上维持在 55 以上算正常;经常在 40 以下波动,就按上一章结尾那张表的顺序排查。注意探针代码里不要做 DOM 写入以外的多余工作,否则测的是它自己的开销。
5.3 URL 参数调状态:不改代码直接测关卡
调试游戏时最烦的是「改代码 → 刷新 → 改回来」。我给游戏加调试入口时,用 URL 参数控制初始状态,做法如下:
const params = new URLSearchParams(location.search); const stage = Number(params.get('stage') || 1); const speed = Number(params.get('speed') || 1); // 兜底:防止 URL 被手改成非法值 if (!Number.isFinite(stage)) stage = 1;于是可以直接访问demo.html?stage=7&speed=3来测试预设关卡。stage 只在进入 RUNNING 时读取,speed 乘到对象池初始化或移动速度上。这里有一个容易踩的坑:URLSearchParams 的 get 返回字符串,转换数字必须显式 Number 并做兜底,否则 speed 参与乘法运算会变成字符串拼接。这套参数入口建议在开发期保留,发布时再把入口收起来,只保留默认值即可。
本文还有配套的精品资源,点击获取