ARTICLE DETAIL

资讯详情

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

前端小游戏开发实战:游戏循环、碰撞检测与性能优化全解析

前端小游戏开发实战:游戏循环、碰撞检测与性能优化全解析 简介这是一份面向 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 idstage width480 height320/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 和 xr改半径要同时改判定dt 上限 50防卡顿后的瞬间位移网页游戏常用 50100ms动作类取小值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 触摸事件把「点住拖动」映射成虚拟摇杆移动端没有键盘常见做法是虚拟摇杆。下面把触摸事件处理成一个范围在 01 的向量它和键盘采集器的结果结构一致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 加 dt5. 把代码挂进现有页面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?stage7speed3来测试预设关卡。stage 只在进入 RUNNING 时读取speed 乘到对象池初始化或移动速度上。这里有一个容易踩的坑URLSearchParams 的 get 返回字符串转换数字必须显式 Number 并做兜底否则 speed 参与乘法运算会变成字符串拼接。这套参数入口建议在开发期保留发布时再把入口收起来只保留默认值即可。本文还有配套的精品资源点击获取
返回列表