ARTICLE DETAIL

资讯详情

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

Canvas坦克游戏开发:用requestAnimationFrame实现键盘控制的平滑移动

Canvas坦克游戏开发:用requestAnimationFrame实现键盘控制的平滑移动 当你跟着这门课一路写到这里前面七节课已经把坦克的车身、履带、炮塔一笔一画地画在画布上了。但很多学员到这一步都会盯着屏幕问一句老师它为什么不动这个问题正是第0008课要解决的。这节课的主题就是让坦克真的动起来——不是换一张图片假装移动而是从代码层面建立一个可持续变化的游戏循环让坦克通过键盘输入实时改位置、转方向。如果你正在学Canvas动画或者想弄明白那些网页小游戏里的物体是怎么“活”起来的这节内容会给你一个非常扎实的起点。1. 先把“动起来”这件事拆开看1.1 动画的本质是状态在变画面跟着重画很多初学Canvas的人会有个误解觉得动画是“让一个图形自己滑过去”其实底层完全不是这么回事。你看到的每一帧画面都是独立绘制出来的静态图像所谓动画就是在很短的时间间隔里不断擦掉旧画面、换成新画面。人眼有视觉暂留效应当画面每秒刷新超过一定频率时大脑就把这些离散的帧“脑补”成了连续运动。电影和电视能放靠的就是这个原理游戏也一样。放到Canvas里一帧动画的完整套路是三步清空画布、更新状态、重新绘制。举个例子坦克当前坐标是x你想让它往右走那就是每帧给x加一个固定值然后在新的x坐标位置上重新画出坦克。代码看起来像是这样function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); tank.x 1; // 状态更新 drawTank(tank.x, tank.y); // 重新绘制 }这段代码一旦被持续调用坦克就会在画布上“动”起来。但这里的核心并不仅仅是“画”而是分出了三层东西状态、更新逻辑、渲染逻辑。如果你直接把所有内容堆在一起后面加功能会越来越乱。1.2 为什么要用 requestAnimationFrame 而不是 setInterval初学者最容易走的一条弯路是用setInterval来做循环动画。它确实能让函数每隔固定时间执行一次但问题在于setInterval只保证“时间间隔”不保证“绘制时机”。浏览器内部可能会在同一帧里执行多次回调也可能因为标签页切换或设备省电策略而暂停结果就是动画要么跳帧要么卡顿甚至出现画面撕裂。现代浏览器提供requestAnimationFrame来解决这个问题。它会配合屏幕的刷新频率来调度回调大部分设备的刷新率是60Hz也就是每秒钟执行约60次画出来自然顺畅。它的另一个好处是当页面切到后台或不可见时浏览器会自动暂停调度不会浪费CPU资源。我写循环动画时习惯用一个最容易理解的模板function loop() { update(); // 更新数据 draw(); // 绘制画面 requestAnimationFrame(loop); // 请求下一帧 } requestAnimationFrame(loop);注意loop函数永远不会自己停下来它干完一帧活之后又把自己加入渲染队列形成无限循环。这个循环就是游戏世界的“心跳”。第0008课要做的就是给这个心搏加内容。2. 工程重构把坦克从“画出来”变成“跑起来”2.1 从静态绘制代码改造成状态对象在前几课里你可能画坦克时是直接在代码里写死的坐标。比如这样ctx.fillRect(400, 300, 40, 50);这样能画出来但坦克没有“身份”所有属性都散落在代码各个角落你没法对它做统一的控制。真正的游戏开发里每个物体都应该是一个独立的对象包含位置、速度、角度等属性。进入动态阶段后第一件事就是把坦克抽成一个状态对象。一个最小的坦克状态对象可以长这样const tank { x: 400, // 画布上的横坐标 y: 300, // 画布上的纵坐标 direction: 0, // 车头朝向0表示正右方向弧度制 speed: 3 // 每帧移动的像素距离 };为什么要把坐标单独抽出来因为update阶段只改这些数字draw阶段才去读这些数字。状态和表现分离之后坦克的所有行为都变成了对这几个数字的修改逻辑清晰得多。这里有个非常重要的点direction使用的是弧度制而不是角度。角度是我们平时说的0度、90度、180度但Math.sin和Math.cos这类三角函数接收的参数都是弧度。一个完整的圆周是2 * Math.PI弧度对应360度。你可以在代码里用度数和弧度互转但入门期我建议直接习惯弧度省掉不必要的换算。2.2 最小循环模板update draw工程结构上我不建议把所有代码都丢在loop函数里。更好的做法是拆成update和draw两个函数。update负责改数据、计算逻辑、处理输入结果draw只负责根据这些数据做绘制。这样拆开之后你调试会轻松很多——如果画面不对你知道去查draw如果行为不对你知道去查update。最小模板长这样function update() { // 在这里修改坦克的位置、方向等状态 } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(tank.x, tank.y); ctx.rotate(tank.direction); // 在原点处画出坦克炮管默认朝右 ctx.restore(); } function loop() { update(); draw(); requestAnimationFrame(loop); }ctx.save()和ctx.restore()在这里是必须的。如果你用了translate和rotate修改了画布坐标系却不恢复下一帧绘制时坐标系还是“歪”的坦克会在奇怪的位置、以奇怪的角度出现。这两个方法可以理解成入栈和出栈save把当前画布状态存起来restore把它还原回去。记住它们必须成对出现。3. 让坦克听键盘的话3.1 用 keys 对象管理持续按下的状态坦克动起来之后下一步就是让它响应用户输入。这里最常见的初级错误是在keydown事件里直接给坦克坐标加偏移。这么写确实能动但你会发现按住方向键时坦克是一下一下跳着走的不是连续移动。原因是keydown事件在按住时会自动重复触发而且重复的间隔由操作系统控制跟游戏循环并不同步。正解是维护一个keys对象只记录哪些键正处于按下状态移动逻辑放到update里去读这个对象。每当keydown触发就把对应键位记为truekeyup触发就把对应键位记为false。这样每帧判断一次持续按住就持续移动完全没有重复触发的问题。实现代码const keys {}; window.addEventListener(keydown, (e) { if (e.repeat) return; // 跳过系统自动重复触发 keys[e.key] true; if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight].includes(e.key)) { e.preventDefault(); // 防止页面滚动 } }); window.addEventListener(keyup, (e) { keys[e.key] false; });这里有两个细节值得专门提一下。一是e.repeat按住按键时系统会不断派发keydown事件如果不检查repeat即使有keys对象也会被反复覆盖成true虽然没有大碍但逻辑上不干净而且会触发preventDefault导致的滚动问题二是preventDefault()方向键默认行为是滚动页面如果不拦截游戏过程中页面会上下飘非常影响体验。如果你还想兼容WASD移动只需在update里同时判断w、a、s、d即可。3.2 方向键与炮管角度三角函数为什么要在这里出现现在处理转向。方向键的Left和Right不是用来直接移动坦克的而是改变坦克的朝向角度。在update里写成if (keys[ArrowLeft]) tank.direction - 0.05; if (keys[ArrowRight]) tank.direction 0.05;0.05是每帧的转向弧度增量。60帧每秒的情况下一秒钟大约转3弧度差不多172度每秒手感算是比较快的。你可以在实际运行时觉得太灵敏就调小这个值。有了朝向角度之后前进后退的位移处理就涉及到三角函数了。这里我先定义坦克车头默认朝向direction 0时车头朝右。用这个定义Math.cos(direction)就是水平方向的分量Math.sin(direction)就是垂直方向的分量。当角度从0变到90度时车头从右转到下这也是Canvas的坐标系特点——y轴向下为正。前进和后退的代码if (keys[ArrowUp]) { tank.x Math.cos(tank.direction) * tank.speed; tank.y Math.sin(tank.direction) * tank.speed; } if (keys[ArrowDown]) { tank.x - Math.cos(tank.direction) * tank.speed; tank.y - Math.sin(tank.direction) * tank.speed; }如果你去推一下公式direction 0时cos(0)1sin(0)0所以向上键让坦克沿x正方向移动也就是向右走direction Math.PI / 2时cos0sin1坦克就沿着y正方向移动也就是向下走。这个方向正好对应车头朝向。简单说先确定车头指向前进就是沿着车头的单位向量走。如果你希望初始朝上或是朝向有差异只需要改坦克的绘制方向或者改默认角度但三角函数的关系不变。3.3 斜向移动的“超速”问题与归一化很多人写完上面的代码后会玩了一会儿就发现同时按左上斜向移动时坦克的速度怎么比只按正方向要快原因是这样按上键时x方向增加speed个像素y方向不动按左键时x方向不变y方向减少speed个像素。当两个键同时按下x和y方向都各加了speed像素合速度的长度变成了原来的Math.sqrt(2)倍大约1.414倍。也就是说坦克抄斜线时加速了这显然不合理。解决办法叫“向量归一化”。一个直观但不追求严谨的做法是先算出位移向量判断是否同时有两个方向被按下如果是就把位移值乘以约0.707也就是1 / Math.sqrt(2)。更工程化的做法是算出向量长度之后做归一化逻辑对新手更清晰一点let dx 0, dy 0; if (keys[ArrowUp]) { dx Math.cos(tank.direction) * tank.speed; dy Math.sin(tank.direction) * tank.speed; } if (keys[ArrowDown]) { dx - Math.cos(tank.direction) * tank.speed; dy - Math.sin(tank.direction) * tank.speed; } if (keys[ArrowLeft]) { tank.direction - 0.05; } if (keys[ArrowRight]) { tank.direction 0.05; } const length Math.sqrt(dx * dx dy * dy); if (length 0 length tank.speed) { dx dx / length * tank.speed; dy dy / length * tank.speed; } tank.x dx; tank.y dy;这段代码先算出实际的位移向量再用长度除以原始速度把速度约束到与原来一致。如果你不动方向键只是朝正方向走length就等于speed不会改变。只有在斜向移动时才发生修正。这个“限制最大速度”的思路在后面的物理游戏里也很常用比如做玩家角色移动时几乎都会遇到。4. 一套可直接跑起来的完整原型与参数调试4.1 完整代码坦克可以移动和转向前面几节拆成了很多碎片这里我给出一段可以直接复制到HTML文件里的完整参考代码。它包含了游戏循环、状态对象、键盘控制、边界检测、坦克绘制。你把它保存成index.html浏览器打开就能玩。!DOCTYPE html html langzh-CN head meta charsetUTF-8 title坦克动起来 - 第0008课/title style canvas { border: 1px solid #aaa; } /style /head body canvas idgame width800 height600/canvas script const canvas document.getElementById(game); const ctx canvas.getContext(2d); const tank { x: 400, y: 300, direction: 0, speed: 3 }; const keys {}; function update() { let dx 0, dy 0; if (keys[ArrowLeft]) tank.direction - 0.05; if (keys[ArrowRight]) tank.direction 0.05; if (keys[ArrowUp]) { dx Math.cos(tank.direction) * tank.speed; dy Math.sin(tank.direction) * tank.speed; } if (keys[ArrowDown]) { dx - Math.cos(tank.direction) * tank.speed; dy - Math.sin(tank.direction) * tank.speed; } // 斜向移动速度归一化 const length Math.sqrt(dx * dx dy * dy); if (length tank.speed) { dx dx / length * tank.speed; dy dy / length * tank.speed; } tank.x dx; tank.y dy; // 边界检测用坦克中心点到画布边缘的最小距离粗略控制 const r 30; tank.x Math.max(r, Math.min(canvas.width - r, tank.x)); tank.y Math.max(r, Math.min(canvas.height - r, tank.y)); } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(tank.x, tank.y); ctx.rotate(tank.direction); // 履带 ctx.fillStyle #2f4f2f; ctx.fillRect(-24, -30, 48, 14); ctx.fillRect(-24, 16, 48, 14); // 车身 ctx.fillStyle #4a7a4a; ctx.fillRect(-20, -22, 40, 44); // 炮塔底座 ctx.fillStyle #5a5a5a; ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); // 炮管车头朝右时炮管从原点向右延伸 ctx.fillStyle #777; ctx.fillRect(0, -4, 30, 8); ctx.restore(); } function loop() { update(); draw(); requestAnimationFrame(loop); } window.addEventListener(keydown, (e) { if (e.repeat) return; keys[e.key] true; if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight].includes(e.key)) { e.preventDefault(); } }); window.addEventListener(keyup, (e) { keys[e.key] false; }); requestAnimationFrame(loop); /script /body /html注意炮管绘制方向fillRect(0, -4, 30, 8)表示炮管从坦克中心点往右延伸30像素高8像素。因为方向为0时rotate不改变方向所以车头默认朝右。这跟你之前画坦克时可能用的正面或侧面视角无关关键是方向角度的基准要和炮管绘制方向一致。4.2 速度参数到底怎么算才合适速度数值不能拍脑袋填。在60Hz刷新率下speed 3意味着每帧移动3像素一秒钟移动大约180像素。800像素宽的画布从最左边到最右边大约要4.4秒这个速度在演示动画里是合适的。如果你觉得太慢可以调到4~5如果调到8以上坦克会显得非常飘按一下方向键就冲出去一大截。转向速度0.05弧度每帧在60Hz下每秒约3弧度也就是172度每秒。这个数值对于坦克来说偏灵敏但因为是玩家手动操控转向体验会比较带感。如果做的是需要精细瞄准的游戏建议降到0.02~0.03。还有一点需要提前解释上面这个方案把所有速度都写成了“每帧多少像素”这种方式在入门阶段最直观但它有个隐患——不同设备的刷新率不同。在60Hz的屏幕上速度是每秒180像素在144Hz的屏幕上速度就变成每秒432像素坦克会明显变快。真正的游戏开发里通常会引入deltaTime上一帧到这一帧的真实时间差把速度单位从“像素每帧”换算成“像素每秒”。考虑到第0008课的核心目标是先把循环和输入跑通我没有在代码里引入deltaTime否则会让逻辑复杂不少。等你掌握了这套骨架下一阶段强烈建议补上这一课。4.3 边界碰撞最小改动和最稳做法我这里用的是最小实现把坦克当成一个半径30像素的圆限制它的中心点坐标不能超出画布边缘。代码只有两行tank.x Math.max(30, Math.min(canvas.width - 30, tank.x)); tank.y Math.max(30, Math.min(canvas.height - 30, tank.y));它的逻辑是先把tank.x下限限定为30上限限定为canvas.width - 30再取中间值。坦克跑到左边界时坐标会被强制拉回30冲到右边界时会被压到770画布宽800时。这套方式稳、简单、不容易出bug适合所有初学者。但你要知道它本质上是“圆心限制”不是真正基于车体的AABB碰撞检测。坦克旋转之后车体四个角的实际轮廓其实已经超出了中心点半径30像素的范围。所以在坦克抵到画布边缘时你可能会觉得“履带都快伸出去了还在往里压”这属于正常现象。如果你追求更精确的车体碰撞得专门做矩形的分离轴检测复杂度会上一个台阶。现阶段先把边界限制跑通以后做子弹碰撞时再上正式方案。5. 我在这节课踩过的坑和大部分学员的报错5.1 常见问题速查表我每次带学员走这个例子几乎都会遇到相同的问题列表。整理成一张速查表你遇到类似情况直接对号入座。现象原因排查与解决按方向键没反应事件监听没绑定到window焦点在其它元素上把addEventListener挂到window并点击画布区域后重试按住方向键只动一下在keydown里直接移动坐标重复触发被系统控制改用keys对象在update里持续读取状态坦克能转向但前进方向不对direction基准和三角函数方向定义不一致统一车头朝向确认direction0对应车头朝右画面一闪一闪clearRect调用时机不对或清屏范围小于画布在draw开头清空整个canvas.width和canvas.height坦克瞬移、旋转混乱每次translate和rotate之后没有save/restore确保save/restore成对出现绘制完成后恢复坐标系斜着走速度更快同时按两个方向键向量长度叠加做归一化修正限制合速度长度为speed页面跟着滚动方向键触发了浏览器默认行为对方向键调用e.preventDefault()坦克消失不见坐标被推到画布外或绘制尺寸为负数打印tank.x和tank.y检查边界限制逻辑5.2 画面闪烁与“图层”问题闪烁这个坑看起来不大实际上非常影响体验。最常见的原因是clearRect只清了画布的一部分上一帧的残留图形和这一帧的新图形叠在一起看起来就像闪光。解决办法很直接每一帧第一句就ctx.clearRect(0, 0, canvas.width, canvas.height)别偷懒别只清坦克所在的局部。另一个闪烁的场景是绘制顺序问题。如果你把坦克背景和地面都画在同一个画布上然后又清空了整个画布地面每帧都要重新画视觉上会有轻微抖动。更高级的解法是使用离屏Canvas把静止的地面先画在一个后台canvas里每帧直接drawImage到主画布再在这个画布上画动态坦克。这个优化属于“双缓冲”的思路但现在还不是必须的。你只要知道如果清了整屏后地面也跟着闪烁就说明地面绘制本身太耗或者顺序不对。5.3 方向逻辑错乱坐标系是个反思维陷阱用Canvas画图久了才能体会到屏幕坐标系简直就是新手的第一个大坑。数学课上常用的坐标系y轴向上是正的而Canvas的y轴向下是正的。这意味着当你执行ctx.rotate()时正角度的旋转方向是顺时针不是数学课本上的逆时针。我遇到过好几个学员写这样的代码时很困惑他们希望坦克按Left键左传于是写tank.direction - 0.05然后发现坦克往右转。这里没有错只是逻辑和直觉不匹配——因为在屏幕上想要让坦克“看起来向左转”如果车头朝右初始方向向左转对应的是减小角度这在Canvas里反而是往逆时针方向走。你要做的不是硬记左键对应加还是减而是在理解坐标系后问自己我的绘图方向、角度增长方向、车头模型朝向三者是否一致。如果实在被这一套绕晕有个朴素的调试方法在draw函数临时打印tank.direction把坦克放在画布中央按一次左键观察方向值是变大还是变小然后对照实际图形往哪边转直接调一次符号就明白了。6. 从“能动”到“能玩”下一课的方向课程进行到这一步坦克已经能在画布上自由移动、灵活转向了。很多学员在成功跑起来的那一刻是非常兴奋的甚至会主动去加些功能。这时候我会让他们先别急着堆特效而是顺着已有的骨架做一次最小的功能扩展给坦克加一发炮弹。炮弹扩展其实就是在tank对象旁边再维护一个炮弹对象数组按空格键时在坦克当前坐标和方向生成一颗子弹每帧让子弹沿车头方向移动超出画布就销毁。这个扩展能自然用上前面所有知识点状态数组、循环更新、三角函数的位移计算。它比添加熊熊火焰粒子特效有意义得多因为它在练的是游戏逻辑的核心而不是表面的炫技。我个人在上这节课时最强烈的感受是当学生第一次独立写出能让坦克转过身、绕过边界、停在画面中央的时候他们脸上那种“我真的在做游戏”的表情比任何教学演示都有说服力。你如果学到这里也给自己一个这样的正反馈——别急着翻下一课先把这辆坦克开出去在画布上逛几圈感受一下每一行代码带来的即时反馈。这比多敲十遍抄代码都有效。
返回列表