当你跟着这门课一路写到这里,前面七节课已经把坦克的车身、履带、炮塔一笔一画地画在画布上了。但很多学员到这一步都会盯着屏幕问一句:老师,它为什么不动?这个问题正是第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触发,就把对应键位记为true;keyup触发,就把对应键位记为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)=1,sin(0)=0,所以向上键让坦克沿x正方向移动,也就是向右走;direction = Math.PI / 2时,cos=0,sin=1,坦克就沿着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 lang="zh-CN"> <head> <meta charset="UTF-8"> <title>坦克动起来 - 第0008课</title> <style> canvas { border: 1px solid #aaa; } </style> </head> <body> <canvas id="game" width="800" height="600"></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基准和三角函数方向定义不一致 | 统一车头朝向,确认direction=0对应车头朝右 |
| 画面一闪一闪 | 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对象旁边再维护一个炮弹对象数组,按空格键时在坦克当前坐标和方向生成一颗子弹,每帧让子弹沿车头方向移动,超出画布就销毁。这个扩展能自然用上前面所有知识点:状态数组、循环更新、三角函数的位移计算。它比添加熊熊火焰粒子特效有意义得多,因为它在练的是游戏逻辑的核心,而不是表面的炫技。
我个人在上这节课时最强烈的感受是:当学生第一次独立写出能让坦克转过身、绕过边界、停在画面中央的时候,他们脸上那种“我真的在做游戏”的表情,比任何教学演示都有说服力。你如果学到这里,也给自己一个这样的正反馈——别急着翻下一课,先把这辆坦克开出去,在画布上逛几圈,感受一下每一行代码带来的即时反馈。这比多敲十遍抄代码都有效。