
这几天在编程学习群里看到有人打卡到“Day3 矩形移动”这个练习。在这个阶段多数人会觉得这就是“画一个方块然后用方向键控制它”——听起来像是最简单的一课。但真正动手写之后问题会连续出现为什么方向键按下去没有反应为什么松开键之后矩形还在跑为什么画面会闪烁为什么到了屏幕边缘矩形会被推出画布解决这些问题的过程恰好会触碰到游戏开发、动画编程、交互应用里最重要的一套思维框架——无限循环里的“读输入、改状态、画画面”。这篇文章就把矩形移动这件事拆开讲透从最小实现、键盘状态管理、坐标系与时间步长一直聊到这个练习之后该怎么往下走。1. 为什么一个“会动的矩形”是编程里最重要的一课1.1 矩形移动背后藏着哪三个核心机制表面上看“矩形移动”只是一个图形学入门作业。实际上它一次性把三个核心机制压进了一段非常小的代码里。第一循环机制。程序不是“执行一次就结束”而是要持续运行、持续刷新。无论是用requestAnimationFrame还是while循环本质都一样每一帧做三件事——处理输入、更新状态、重新绘制。第二输入机制。要让矩形响应键盘不能只在按下那一瞬间改坐标因为那样只会移动一次。你需要记录“哪些键正处于按下状态”再在每一帧里去检查这个状态表。第三状态机制。矩形的位置不是一个常量而是一个会随时间变化的状态。x和y在每一帧都可能被更新而绘图函数必须基于最新状态去输出画面。这个“状态与绘制分离”的思路是所有图形界面和游戏程序的地基。这三个机制都不复杂但很多人是第一次在一个程序里同时遇到它们。所以矩形移动不是“太简单”而是“刚好简单到能看清框架又刚好复杂到能暴露思维盲区”。1.2 先想清楚你想学的是绘图 API还是交互逻辑不同技术栈做矩形移动侧重点差别很大。如果用 HTML5 Canvas重点会落在 JavaScript 的事件处理、坐标更新和requestAnimationFrame上如果用 Python 的 pygame重点会落在游戏循环和pygame.Rect的封装上如果只是用 CSS 的transform或transition那其实没有真正练习到“状态更新”只是让浏览器帮你完成动画。这里有一个在学习上容易被忽略的判断矩形移动这个练习的真正价值不在“用什么框架画出矩形”而在“你是否亲手实现了主循环里的状态更新”。如果只是通过 CSS 动画把方块从左移到右那学到的是样式不是交互逻辑。反过来哪怕你用最简单的命令行字符画只要自己维护坐标、处理按键、循环刷新就完成了同样的训练。所以在开始写代码之前先决定你的目标是“会用某个绘图库”还是“理解交互程序的结构”。前者可以直接照着 API 文档写后者建议选择 Canvas、pygame 这类需要自己管理主循环的方案因为它们能在最小代码量里暴露最多细节。2. 让矩形动起来的最小实现先跑通再理解2.1 用 HTML5 Canvas 写一个能移动的矩形这里以 HTML5 Canvas 为例因为它不需要安装任何依赖浏览器直接打开就能跑。先把最小可运行版本写出来再去逐行拆解。!DOCTYPE html html head meta charsetUTF-8 title矩形移动/title style canvas { border: 1px solid #ccc; } /style /head body canvas idgame width480 height320/canvas script const canvas document.getElementById(game); const ctx canvas.getContext(2d); let rect { x: 100, y: 100, w: 40, h: 40, speed: 3 }; const keys {}; document.addEventListener(keydown, (e) { keys[e.code] true; }); document.addEventListener(keyup, (e) { keys[e.code] false; }); function update() { if (keys[ArrowLeft]) rect.x - rect.speed; if (keys[ArrowRight]) rect.x rect.speed; if (keys[ArrowUp]) rect.y - rect.speed; if (keys[ArrowDown]) rect.y rect.speed; } function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #2d7dd2; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); } function loop() { update(); draw(); requestAnimationFrame(loop); } loop(); /script /body /html这个文件保存为rect.html直接用浏览器打开点击画布区域后用方向键就能控制矩形移动。2.2 逐行拆解坐标、速度、绘制是怎么配合的先看数据部分。矩形被定义成一个对象let rect { x: 100, y: 100, w: 40, h: 40, speed: 3 };这里的x和y不是矩形中心的坐标而是它的左上角坐标。w和h是宽高speed是每帧移动的像素数。这个细节很重要很多人第一次调试时发现矩形的位置和预期差了一个矩形自身的宽度或高度就是因为没有分清“左上角坐标”和“中心坐标”的区别。再看输入部分。keydown和keyup做的事情不是直接修改矩形坐标而是维护一个按键状态表const keys {}; document.addEventListener(keydown, (e) { keys[e.code] true; }); document.addEventListener(keyup, (e) { keys[e.code] false; });为什么要多这一步因为键盘事件是“一次性”的。按下方向键时keydown只触发一次如果你在事件里直接写rect.x - rect.speed按一下只移动一次松开再按下又移动一次矩形会一顿一顿地走而不是持续移动。按键状态表把“瞬间事件”转换为“持续状态”才能在每一帧的update里判断“当前这个键是否一直被按着”。最后看循环部分。loop通过requestAnimationFrame不断调用自己每一次调用就是一帧。update根据按键状态更新坐标draw先清空画布再绘制矩形。清空这一步不能省否则上一帧的矩形会残留在画布上形成拖影。注意requestAnimationFrame的触发频率通常与屏幕刷新率一致大多数显示器是 60Hz。这里先不用纠结帧率细节后面会专门讲时间步长问题。3. 从“能动”到“能控制”键盘输入与状态管理3.1 为什么 keydown 和 keyup 不能直接操作坐标上面提过直接在键盘事件里修改坐标会让矩形“按一下动一下”。这里再深入一点如果真的这么写document.addEventListener(keydown, (e) { if (e.code ArrowRight) rect.x 3; });会发生什么按下右方向键矩形往右移动 3 像素然后事件结束。你需要反复按下、松开、再按下矩形才会一步一步移动。这种体验更像“打字”而不是“移动”。更隐蔽的问题是键盘重复触发。在大多数操作系统里长按一个键会触发多次keydown但第一次触发和后续重复触发之间的间隔并不稳定而且如果你同时按两个方向键两者的触发频率还会互相干扰让矩形移动速度忽快忽慢。按键状态表直接绕开这个问题事件只负责记录“按下/松开”这个事实至于要不要移动、移动多少完全交给update在每一帧里统一计算。3.2 按键状态表是第一个“程序状态”keys对象就是最简单的状态管理。它虽然只是一个普通对象却体现了交互程序里非常核心的模式事件发生时不立刻产生业务行为。事件只负责更新状态。每帧从状态推导出要执行的逻辑。这个模式在更复杂的项目里会演变成各种状态机、行为树、实体组件系统等架构。矩形移动当然不需要那么复杂但你已经可以用同样的思维去解释为什么游戏中会有“主循环”这个概念——因为所有行为都由状态在每一帧驱动而不是由分散的事件函数驱动。在这个阶段还有两个细节值得注意。第一要阻止浏览器默认行为。方向键在页面里默认会滚动页面如果你的画布嵌在一个可以滚动的页面里按方向键页面会发生滚动矩形反而不动。常见的处理是在keydown里加e.preventDefault()document.addEventListener(keydown, (e) { if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight].includes(e.code)) { e.preventDefault(); } keys[e.code] true; });第二要注意页面焦点。键盘事件只在页面获得焦点时才能触发。如果点击了浏览器地址栏或开发者工具再按方向键页面收不到事件。这个现象在调试时经常被误判为“代码写错了”。4. 真正决定成败的细节坐标系、帧率与边界检查4.1 坐标系和矩形的边界判断Canvas 的坐标系原点在左上角x向右增大y向下增大。这个“y 轴向下”的方向和数学课上的坐标系正好相反是新手最容易绕晕的地方。按上方向键坐标应该减小而不是增大if (keys[ArrowUp]) rect.y - rect.speed;如果写反了按上方向键矩形会往下走。这不是“bug”而是坐标系定义问题。边界判断也是一个容易出问题的点。默认情况下矩形可以移出画布移出后你只能通过修改坐标把它拉回来。更好的做法是在update里直接限制坐标范围function update() { if (keys[ArrowLeft]) rect.x - rect.speed; if (keys[ArrowRight]) rect.x rect.speed; if (keys[ArrowUp]) rect.y - rect.speed; if (keys[ArrowDown]) rect.y rect.speed; rect.x Math.max(0, Math.min(canvas.width - rect.w, rect.x)); rect.y Math.max(0, Math.min(canvas.height - rect.h, rect.y)); }这里的关键点是canvas.width - rect.w而不是canvas.width。因为x是左上角坐标矩形最右边其实是x w。如果允许x等于canvas.width整个矩形就会完全移出画布。这是矩形移动练习里最常见的边界错误之一。4.2 帧率不稳定导致速度不一致从帧率独立到时间步长之前的写法是“每帧移动固定像素”。在 60Hz 屏幕上这个速度是speed * 60像素/秒。但如果显示器是 120Hz或者浏览器因为某个耗时任务导致掉帧同样的代码在不同设备上速度差别会很大。更准确的做法是用“时间步长”let speed 200; // 单位改成 像素/秒 let lastTime 0; function loop(timestamp) { const dt Math.min((timestamp - lastTime) / 1000, 0.1); // 换算成秒并设置上限 lastTime timestamp; if (keys[ArrowRight]) rect.x speed * dt; if (keys[ArrowLeft]) rect.x - speed * dt; if (keys[ArrowDown]) rect.y speed * dt; if (keys[ArrowUp]) rect.y - speed * dt; rect.x Math.max(0, Math.min(canvas.width - rect.w, rect.x)); rect.y Math.max(0, Math.min(canvas.height - rect.h, rect.y)); draw(); requestAnimationFrame(loop); }dt是上一帧到这一帧的时间差单位是秒。speed * dt表示在这一帧内矩形应该移动“每秒 200 像素”乘以“流逝的时间”。帧率高时dt小每次移动量小帧率低时dt大每次移动量大。综合下来一秒钟的移动距离基本恒定。这里有一个进阶注意点dt不能无限制地大。如果页面在后台挂起了一会儿回到前台时dt可能非常大矩形会“瞬移”一大段距离。常见做法就是上面的Math.min(dt, 0.1)把单帧时间差限制在 100 毫秒以内。这样即使从后台恢复也不会出现跨屏瞬移。4.3 排查链路矩形不动、乱跳、卡住怎么办如果按方向键矩形不移动不要急着改代码按这个顺序排查。第一步确认画布获得了焦点。先点击画布区域再按方向键。如果之前在开发者工具里点击过页面可能没有焦点。第二步确认keydown事件有没有触发。在事件回调里加一个console.log(e.code)看控制台是否输出了ArrowRight。如果没输出问题在事件绑定或页面焦点如果输出了问题在状态更新逻辑。第三步确认requestAnimationFrame循环是否每帧都在执行。可以在update里加一行console.log(tick)。如果只在第一次运行说明循环中断了检查控制台是否有报错。第四步确认矩形坐标有没有变化。在draw里打印rect.x和rect.y。如果坐标在变但画面不动问题出在绘图逻辑如果坐标不变问题出在按键状态更新。第五步确认ctx.clearRect是否执行。不清理画布时矩形每帧都在重画看起来像在原地抖动或产生拖影。按这个顺序排查大部分问题在几分钟内就能定位不至于在同一个地方反复折腾。5. 矩形移动之后下一步该往哪里走5.1 从单矩形到多对象数据结构是分水岭在矩形移动这个练习里你只有一个rect对象。当你开始做两个、三个甚至几十个移动对象时继续用单独的变量就不现实了你很快会引入数组const rects []; for (let i 0; i 10; i) { rects.push({ x: i * 50, y: i * 30, w: 40, h: 40, speed: 2 i }); }然后在update里遍历数组更新在draw里遍历数组绘制。这个转变看起来只是“把变量放进数组”实际上是把“单一对象逻辑”扩展成“批量对象管理”。再往后你可能需要给每个对象增加类型、生命值、运动方向、动画帧等字段对象字段越加越多代码就会自然推动你去设计更清晰的结构。我更愿意把矩形移动看作一个“主动扩展”的训练。进步快的人往往不是因为记得 API而是因为他们主动把一个对象扩展成多个对象又主动把多个对象的管理逻辑重构成更清晰的函数或类。这才是这个练习真正的进阶路线。5.2 碰撞检测、场景管理和状态机矩形移动之后紧接着的经典练习就是碰撞检测。两个矩形相遇时应该停下来、弹开还是消失碰撞检测会逼着你重新审视坐标更新顺序先移动再检测还是先检测再移动如果矩形贴到画布边缘是否允许它和墙体重叠再往后是场景管理。一个稍完整的程序里通常不只有一个“无限循环”还有开始界面、运行界面、暂停界面、结束界面。你可能会写一个scene变量用字符串或枚举表示当前场景在不同场景里执行不同的更新和绘制逻辑。这就是状态机的雏形。虽然听起来比矩形移动复杂得多但底层仍然是你已经练熟的那套“每帧更新状态并绘制”的框架。5.3 学习路径建议与适用边界针对“Day3 矩形移动”这个阶段我的建议是第一先用单一技术栈跑通。不管是 Canvas、pygame 还是其他图形库先完整实现“按键控制矩形移动”这个闭环不要同时学两个框架。第二跑通之后马上加一个边界限制。只移动不限制边界说明还没有真正考虑坐标系和画面尺寸之间的关系。第三主动给矩形加一个“速度变化”场景。比如按住 Shift 键加速或者按空格键瞬间改变位置。这样你会更深刻地理解速度、时间、坐标三者之间的关系。第四如果觉得速度在不同设备上表现不一致主动改为基于时间步长的移动。这一步能提前规避很多后续动画和游戏开发中的性能陷阱。也说说边界。矩形移动这个练习不适合用来深入学习图形渲染细节比如 GPU 管线、着色器、抗锯齿这些已经超出它的范围。它也不适合过度工程化——有人会在这个阶段引入完整的游戏引擎或者复杂的架构模式反而冲淡了主线训练。它的最佳应用范围就是让学习者用最小代码量理解“事件、状态、循环、绘制”这条主链路。看清这条主链路之后后面无论是写一个小游戏、做一个数据可视化还是转向更复杂的图形应用地基都已经在这里打好了。