
1. 从零拆解模块生成植物大战僵尸这件事到底在做什么很多人第一次看到模块生成植物大战僵尸程序代码这个标题脑子里冒出来的第一个念头是这是不是要做一个完整的游戏引擎其实不是。这里的模块生成指的是一种更轻量、更聚焦的做法——把植物大战僵尸里最核心的几个玩法单元拆成独立模块每个模块负责一件事然后用 HTML、CSS、JavaScript 把它们串起来跑在浏览器里。我之所以对这个方向感兴趣是因为它解决了一个很实际的问题很多人想学前端但对着 TODO List、计算器这类练手项目提不起劲做着做着就放弃了。而植物大战僵尸这个题材天然具备吸引力——它有明确的游戏循环、有资源管理、有碰撞检测、有状态切换几乎涵盖了前端交互开发的所有基础知识点同时又有足够的趣味性让你愿意把它做完。这个项目的核心产出不是一个能玩的游戏而是一套可复用的代码模块。具体来说它包含以下几个关键部分网格系统把草坪划分成 5 行 9 列的逻辑格子所有植物和僵尸的定位都基于这个网格。阳光经济系统阳光的定时掉落、点击收集、消耗扣除本质上是一个资源管理循环。植物模块每种植物有独立的攻击逻辑、冷却时间、生命值。僵尸模块僵尸的移动、啃食、死亡判定。子弹与碰撞豌豆的飞行轨迹和命中检测。游戏主循环用 requestAnimationFrame 驱动整个世界的更新与渲染。适合谁来参考这篇内容如果你已经掌握了 HTML 和 CSS 的基础JavaScript 能写函数和循环但还没做过一个完整的交互项目那这个方向非常适合你。如果你是有经验的前端开发者想找一个有趣的 side project 来练手模块化架构同样能从中获得启发。提示这篇内容不会教你复制粘贴就能跑的完整代码而是把每个模块的设计思路、关键实现和踩坑经验讲清楚。你需要自己动手写才能真正理解其中的逻辑。2. 网格系统整个游戏的地基怎么打2.1 为什么不用绝对定位而选网格坐标初学者做这类游戏最容易犯的错误就是直接用position: absolute把每个植物和僵尸定位到像素坐标上。这样做在元素少的时候没问题但一旦僵尸数量多起来你会发现碰撞检测变得极其痛苦——你需要不断比较两个元素的像素位置还要考虑尺寸差异。正确的做法是引入一层逻辑网格。草坪就是 5 行 9 列的二维数组每个格子有固定的宽高。植物的位置用(row, col)表示僵尸的位置用(row, x)表示其中 x 是浮点数代表僵尸在当前行走了多少像素。这样碰撞检测就变成了简单的数学比较僵尸的 x 坐标是否落入了某个植物格子的像素范围内。const GRID { rows: 5, cols: 9, cellWidth: 80, cellHeight: 100, offsetX: 250, // 草坪左侧留出空间放小推车 offsetY: 80 // 顶部留出空间放阳光计数 }; function cellToPixel(row, col) { return { x: GRID.offsetX col * GRID.cellWidth, y: GRID.offsetY row * GRID.cellHeight }; } function pixelToCell(px, py) { const col Math.floor((px - GRID.offsetX) / GRID.cellWidth); const row Math.floor((py - GRID.offsetY) / GRID.cellHeight); if (row 0 || row GRID.rows || col 0 || col GRID.cols) return null; return { row, col }; }这两个转换函数是整个项目里被调用最频繁的工具函数。鼠标点击时用pixelToCell判断玩家想种在哪一格渲染时用cellToPixel把逻辑坐标转成 CSS 的 left/top 值。2.2 网格坐标系的常见坑我在实际写的时候踩过两个坑这里提前说清楚。第一个坑是行列顺序搞反。pixelToCell返回的{row, col}里row 是纵向的行号col 是横向的列号。但很多人在写循环的时候习惯性写成for (let i 0; i cols; i)然后拿 i 当 row 用结果整个草坪的坐标全乱了。建议在变量命名上就区分清楚比如用r和c或者干脆用rowIndex和colIndex。第二个坑是边界判定遗漏。玩家点击草坪外面的区域时pixelToCell应该返回 null否则会出现种到屏幕外面的 bug。上面代码里的边界检查就是干这个的别省。2.3 渲染层与逻辑层的分离一个容易被忽视的设计问题是逻辑网格和 DOM 元素之间是什么关系我的建议是逻辑层用纯 JavaScript 对象数组来维护游戏状态渲染层只负责把状态同步到 DOM。具体做法是每个游戏实体植物、僵尸、子弹都有一个唯一的 id渲染时根据 id 找到对应的 DOM 元素更新它的 style。这样做的好处是游戏逻辑的更新完全不依赖 DOM你可以随时暂停渲染、加速渲染甚至把渲染层换成 Canvas 而不影响逻辑。对于初学者来说这个分离可能看起来有点多余但当你需要调试为什么僵尸走到植物面前不啃食这类问题时能单独检查逻辑状态而不被 DOM 干扰会省很多时间。3. 阳光经济资源循环的设计与实现3.1 阳光掉落的时间控制阳光系统看起来简单但它是整个游戏节奏的核心。阳光掉落太快游戏没有挑战性掉落太慢玩家前期什么都种不了体验很差。原版植物大战僵尸的设计是自然阳光大约每 10 秒掉落一次每次 25 点开局给 50 点。实现上你需要一个计时器来管理阳光的自然掉落。这里有个细节不要用setInterval因为它的时间精度不可靠而且当你需要暂停游戏时会很麻烦。更好的做法是在游戏主循环里累加时间差let sunTimer 0; const SUN_INTERVAL 10000; // 10秒 function updateSun(deltaTime) { sunTimer deltaTime; if (sunTimer SUN_INTERVAL) { sunTimer - SUN_INTERVAL; spawnSun(); } }deltaTime是上一帧到这一帧经过的毫秒数由主循环传入。这样即使帧率波动阳光掉落的实际间隔也是准确的。3.2 阳光的点击收集与自动消失阳光掉落到草坪上之后玩家需要点击它来收集。这里涉及两个交互逻辑点击收集和超时消失。点击收集的实现很直接给阳光的 DOM 元素绑定 click 事件收集后从数组中移除并更新阳光计数。但要注意一个细节阳光在掉落过程中应该是不可点击的只有落到地面后才能被收集。这个状态可以用一个isCollectable标志来控制。超时消失的逻辑是阳光落地后开始计时如果 8 秒内没有被收集就自动消失。这个机制的存在是为了防止玩家囤积阳光不种植物保持游戏的节奏感。function updateSuns(deltaTime) { for (let i suns.length - 1; i 0; i--) { const sun suns[i]; if (sun.state falling) { sun.y sun.speed * deltaTime / 16; if (sun.y sun.targetY) { sun.y sun.targetY; sun.state idle; sun.lifeTimer 0; } } else if (sun.state idle) { sun.lifeTimer deltaTime; if (sun.lifeTimer 8000) { removeSun(i); } } } }3.3 阳光数值的平衡经验关于阳光数值的设定我试过几组不同的参数分享一些实际感受。开局给 50 阳光是合理的刚好够种一个向日葵50 点。向日葵每 24 秒产出 25 阳光这个节奏意味着你种下第一个向日葵后大约需要等 24 秒才能种第二个。如果你把向日葵的产出间隔缩短到 15 秒游戏会变得太简单延长到 30 秒以上前期会非常煎熬。注意阳光数值的平衡没有标准答案取决于你想让游戏偏休闲还是偏挑战。建议先按原版数值来跑通之后再根据自己的感觉微调。4. 植物模块从向日葵到豌豆射手的差异化设计4.1 植物基类与继承结构每种植物都有一些共同的属性生命值、所在格子、冷却时间、价格。但它们的攻击方式、产出方式各不相同。用面向对象的方式来做就是先定义一个植物基类然后每种植物继承它并重写特定的方法。class Plant { constructor(row, col, config) { this.row row; this.col col; this.hp config.hp; this.maxHp config.hp; this.type config.type; this.cooldown 0; this.attackInterval config.attackInterval || 0; this.id generateId(); } update(deltaTime, gameState) { // 子类重写 } takeDamage(amount) { this.hp - amount; if (this.hp 0) { this.die(); } } die() { // 从游戏状态中移除自己 } }向日葵的update方法就是累加计时器到点产出阳光。豌豆射手的update方法需要先检查当前行有没有僵尸有的话才累加攻击计时器到点发射子弹。4.2 豌豆射手的攻击判定逻辑豌豆射手最核心的逻辑是什么时候开火。原版的规则是只有当同一行存在僵尸时豌豆射手才会进入攻击状态。这个判定看似简单但实现时有个容易忽略的点——僵尸的 x 坐标必须大于豌豆射手的 x 坐标也就是说僵尸在豌豆射手的右侧前方时才开火。如果僵尸已经走到了豌豆射手后面就不应该再开火了。update(deltaTime, gameState) { const zombiesInRow gameState.zombies.filter( z z.row this.row z.x this.getPixelX() z.hp 0 ); if (zombiesInRow.length 0) { this.cooldown 0; return; } this.cooldown deltaTime; if (this.cooldown this.attackInterval) { this.cooldown - this.attackInterval; this.fire(); } }attackInterval设为 1400 毫秒左右比较接近原版手感。太快了游戏会失去难度太慢了僵尸会压过来。4.3 植物种植的合法性校验玩家点击草坪种植物时需要做一系列校验阳光够不够、这个格子有没有被占用、这个格子是不是在草坪范围内、当前是不是在冷却中。这些校验缺一不可否则会出现各种奇怪的 bug。校验项失败时的表现实现方式阳光不足卡片变灰点击无反应比较 sunCount 和 plant.cost格子已占用种植失败不扣阳光检查 grid[row][col] 是否为空超出草坪范围点击无反应pixelToCell 返回 null卡片冷却中卡片上有遮罩动画检查 cardCooldown 计时器我建议把这些校验写成一个独立的canPlant(row, col, plantType)函数返回布尔值。这样在点击事件里只需要调用一次逻辑清晰也方便后续扩展。4.4 植物被啃食的处理僵尸走到植物所在格子时会停下来啃食植物。这个逻辑的实现方式是僵尸每帧检查自己当前所在的格子有没有植物如果有就停止移动开始对植物造成伤害。这里有个细节需要注意僵尸的啃食伤害是按固定间隔触发的不是每帧都扣血。原版的啃食间隔大约是 500 毫秒每次造成 20 点伤害普通僵尸。如果每帧都扣血植物会在瞬间被吃掉游戏就没法玩了。function updateZombieEating(zombie, deltaTime, gameState) { const cell pixelToCell(zombie.x, zombie.getPixelY()); if (!cell) return false; const plant gameState.grid[cell.row][cell.col]; if (!plant || plant.hp 0) return false; zombie.eatTimer deltaTime; if (zombie.eatTimer 500) { zombie.eatTimer - 500; plant.takeDamage(20); } return true; // 表示正在啃食不移动 }5. 僵尸模块移动、啃食与死亡的全流程5.1 僵尸的移动速度与帧率无关化僵尸的移动看起来就是每帧往左移动几个像素但这里有一个帧率陷阱。如果你写zombie.x - 0.5在 60fps 的显示器上僵尸每秒移动 30 像素在 144fps 的显示器上每秒移动 72 像素游戏难度完全不同。正确的做法是用deltaTime来驱动移动const ZOMBIE_SPEED 0.02; // 像素每毫秒 function updateZombiePosition(zombie, deltaTime) { zombie.x - ZOMBIE_SPEED * deltaTime; }这样无论帧率是多少僵尸每秒移动的距离都是 20 像素左右。ZOMBIE_SPEED设为 0.02 意味着僵尸走完一个 80 像素宽的格子需要 4 秒这个速度比较接近原版。5.2 僵尸的生成与波次控制僵尸不能一次性全部出现需要有节奏地生成。最简单的做法是维护一个生成队列每隔一定时间从队列里取出一个僵尸放到最右侧。let spawnTimer 0; const SPAWN_INTERVAL 5000; // 每5秒生成一个僵尸 function updateZombieSpawn(deltaTime, gameState) { spawnTimer deltaTime; if (spawnTimer SPAWN_INTERVAL) { spawnTimer - SPAWN_INTERVAL; const row Math.floor(Math.random() * GRID.rows); gameState.zombies.push(new Zombie(row, GRID.offsetX GRID.cols * GRID.cellWidth)); } }如果你想让游戏有波次感可以把SPAWN_INTERVAL做成动态的——前期间隔长后期逐渐缩短。比如每过 30 秒间隔减少 500 毫秒最低不低于 1500 毫秒。5.3 僵尸死亡的判定与清理僵尸的死亡判定有两个条件生命值降到 0 以下或者走到了草坪最左侧游戏失败。生命值归零时僵尸应该播放一个短暂的死亡动画然后从数组中移除。走到最左侧时如果有小推车就触发小推车没有就游戏结束。清理僵尸时要注意一个问题不要在遍历数组的过程中直接删除元素这会导致索引错乱。正确的做法是倒序遍历或者先标记再统一清理。function cleanupZombies(gameState) { for (let i gameState.zombies.length - 1; i 0; i--) { const z gameState.zombies[i]; if (z.hp 0 || z.x GRID.offsetX - 100) { removeZombieElement(z.id); gameState.zombies.splice(i, 1); } } }5.4 小推车的触发逻辑小推车是游戏里的最后一道防线。当僵尸走到某一行的最左侧时如果该行还有小推车就触发小推车把该行所有僵尸推走。这个逻辑的实现是每帧检查每一行最左侧是否有僵尸如果有且该行小推车未被使用就激活小推车。小推车的动画可以用 CSS transition 来做让它的 left 值在 0.5 秒内从初始位置移动到草坪右侧同时检测路径上的僵尸并清除。6. 子弹与碰撞检测豌豆的飞行与命中6.1 子弹的生成与移动豌豆射手的子弹逻辑相对简单生成时记录所在行和初始 x 坐标然后每帧向右移动。当 x 坐标超出草坪范围时从数组中移除。class Bullet { constructor(row, x, damage) { this.row row; this.x x; this.damage damage; this.speed 0.4; // 像素每毫秒 this.id generateId(); } update(deltaTime) { this.x this.speed * deltaTime; } }子弹速度设为 0.4 像素每毫秒也就是每秒 400 像素大约 0.2 秒穿过一个格子。这个速度在视觉上比较舒服不会快到看不清也不会慢到让玩家着急。6.2 碰撞检测的实现方式碰撞检测的核心是判断子弹是否命中了同一行的某个僵尸。由于子弹和僵尸都是矩形可以用 AABB轴对齐包围盒碰撞检测function checkBulletCollision(bullet, zombies) { for (const zombie of zombies) { if (zombie.row ! bullet.row) continue; if (zombie.hp 0) continue; const zombieLeft zombie.x; const zombieRight zombie.x ZOMBIE_WIDTH; if (bullet.x zombieLeft bullet.x zombieRight) { return zombie; } } return null; }这里用子弹的 x 坐标和僵尸的左右边界做比较而不是用两个矩形的完整重叠检测。因为子弹很小用点检测就够了计算量更小。6.3 命中后的处理与伤害数值子弹命中僵尸后需要做三件事对僵尸造成伤害、移除子弹、如果僵尸死亡则触发死亡逻辑。伤害数值方面普通豌豆的伤害是 20 点普通僵尸的生命值是 100 点也就是说需要 5 发豌豆才能打死一个僵尸。这个数值关系决定了游戏的节奏一个豌豆射手每 1.4 秒发射一发打死一个僵尸需要 7 秒。如果同时有两个僵尸在同一行豌豆射手就应付不过来了玩家需要种更多的豌豆射手或者用其他植物来辅助。提示伤害数值和攻击间隔的乘积决定了 DPS每秒伤害。在设计植物时先确定你希望它多久能打死一个僵尸然后反推攻击间隔和伤害值。7. 游戏主循环requestAnimationFrame 的正确用法7.1 为什么不用 setInterval很多人做游戏循环的第一反应是setInterval(update, 16)大约每秒 60 次。但setInterval有两个致命问题第一它不保证精确的 16 毫秒间隔实际间隔可能波动很大第二当页面切换到后台时浏览器会限制setInterval的执行频率导致游戏逻辑出错。requestAnimationFrame是专门为动画设计的 API它会在浏览器下一次重绘之前调用你的回调函数天然与显示器的刷新率同步。而且当页面不可见时它会自动暂停节省资源。7.2 deltaTime 的计算与使用requestAnimationFrame的回调函数会接收一个时间戳参数表示当前帧的时间。用当前帧时间减去上一帧时间就得到了deltaTime。let lastTime 0; function gameLoop(timestamp) { if (lastTime 0) { lastTime timestamp; requestAnimationFrame(gameLoop); return; } const deltaTime timestamp - lastTime; lastTime timestamp; // 防止切后台回来后 deltaTime 过大 const clampedDelta Math.min(deltaTime, 100); update(clampedDelta); render(); requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里有一个重要的细节deltaTime需要做上限截断。如果玩家切换标签页几分钟后回来deltaTime可能是几十万毫秒直接传给更新函数会导致所有僵尸瞬间移动到最左边。截断到 100 毫秒可以避免这个问题。7.3 更新与渲染的分离update函数负责更新所有游戏实体的逻辑状态render函数负责把状态同步到 DOM。这两个函数应该严格分离不要在update里操作 DOM也不要在render里修改游戏状态。function update(deltaTime) { updateSun(deltaTime); updatePlants(deltaTime); updateZombies(deltaTime); updateBullets(deltaTime); updateCollisions(); cleanupDeadEntities(); } function render() { renderSuns(); renderPlants(); renderZombies(); renderBullets(); renderUI(); }这种分离的好处是你可以在不渲染的情况下单独测试逻辑也可以在不更新逻辑的情况下单独调试渲染。8. 模块化架构把代码拆成可维护的块8.1 文件组织方式当代码量超过几百行之后把所有代码写在一个 HTML 文件里会变得难以维护。我的建议是按功能拆分成多个 JavaScript 文件config.js所有常量配置网格尺寸、植物属性、僵尸属性、阳光数值grid.js网格坐标转换函数plants.js植物类及其子类zombies.js僵尸类bullets.js子弹类sun.js阳光系统game.js主循环和游戏状态管理render.js所有渲染相关函数用script标签按顺序引入这些文件注意依赖关系——config.js和grid.js应该最先加载game.js最后加载。8.2 全局状态的管理游戏状态集中放在一个对象里方便管理和调试const gameState { sunCount: 50, grid: [], // 二维数组存储植物 zombies: [], bullets: [], suns: [], mowers: [], // 小推车状态 isPaused: false, isGameOver: false, elapsedTime: 0 };把所有状态放在一个对象里的好处是你可以在控制台里直接打印gameState来查看当前游戏的所有状态调试非常方便。8.3 模块之间的通信模块之间尽量不要直接互相调用而是通过gameState来间接通信。比如豌豆射手发射子弹时不是直接调用子弹模块的函数而是把子弹对象 push 到gameState.bullets数组里由主循环统一更新。这样做的好处是模块之间的耦合度低你可以单独替换任何一个模块而不影响其他模块。比如你想把子弹的渲染从 DOM 改成 Canvas只需要修改renderBullets函数不需要动子弹的逻辑代码。9. 实际开发中踩过的坑与解决方案9.1 点击事件穿透问题当阳光掉落在植物上方时点击阳光会同时触发阳光收集和植物种植两个事件。这是因为阳光的 DOM 元素和草坪的点击区域重叠了。解决方案是在阳光的 click 事件处理函数里调用event.stopPropagation()阻止事件冒泡到草坪的点击处理函数。或者更简单的方式是在草坪的点击处理函数里检查点击目标是不是阳光元素如果是就忽略。9.2 僵尸重叠时的渲染层级多个僵尸走到同一位置时后生成的僵尸会覆盖先生成的僵尸。这在视觉上看起来很奇怪因为僵尸应该是有前后遮挡关系的。解决方案是给每个僵尸的 DOM 元素设置z-index值等于僵尸的 x 坐标取整。这样 x 坐标大的僵尸更靠右会显示在 x 坐标小的僵尸上面符合透视关系。9.3 内存泄漏与 DOM 元素清理游戏运行时间长了之后如果僵尸和子弹的 DOM 元素没有被正确移除会导致内存占用越来越高页面越来越卡。解决方案是在清理游戏实体时一定要同时移除对应的 DOM 元素。可以给每个实体一个唯一的 idDOM 元素的 id 也设为相同的值清理时用document.getElementById(id).remove()来移除。function removeZombieElement(id) { const el document.getElementById(zombie-${id}); if (el) el.remove(); }9.4 游戏暂停与恢复的处理暂停功能看起来简单但实现时需要注意暂停时不能只是停止主循环还需要记录暂停时的时间戳恢复时重新计算lastTime否则deltaTime会变成一个很大的值。function togglePause() { gameState.isPaused !gameState.isPaused; if (!gameState.isPaused) { lastTime performance.now(); // 重置时间基准 } }10. 从能跑到好玩数值调优与体验打磨10.1 难度曲线的设计一个能跑的植物大战僵尸和一个好玩的植物大战僵尸之间差的是数值调优。我试过的最简单的难度曲线是前 60 秒每 8 秒生成一个僵尸60 秒后每 5 秒生成一个120 秒后每 3 秒生成一个。这样玩家有足够的时间建立防线后期又会感到压力。如果你想让游戏更有层次可以引入不同类型的僵尸普通僵尸速度慢血量低路障僵尸血量高铁桶僵尸血量更高。不同僵尸的混合出现会让玩家需要选择不同的植物来应对。10.2 视觉反馈的重要性玩家点击植物卡片时卡片应该有按下效果种植成功时植物应该有短暂的缩放动画僵尸被击中时应该有闪烁效果阳光被收集时应该有一个飞向计数器的动画。这些视觉反馈看起来是小事但它们直接影响玩家的操作手感。用 CSS transition 和 animation 来实现这些效果是最简单的。比如僵尸被击中的闪烁效果可以用一个 CSS 类来控制.zombie-hit { animation: hitFlash 0.1s ease-in-out; } keyframes hitFlash { 0% { filter: brightness(1); } 50% { filter: brightness(3); } 100% { filter: brightness(1); } }10.3 音效的加入音效是提升游戏体验的捷径。种植时的噗声、豌豆发射的啪声、僵尸啃食的咔嚓声、阳光收集的叮声这些音效不需要多高质量但能极大地增强沉浸感。用 HTML5 的Audio对象来播放音效注意要预加载不要在需要播放时才去加载否则会有延迟。另外浏览器的自动播放策略要求用户至少有一次交互之后才能播放音频所以音效应该在玩家第一次点击之后才开始工作。10.4 移动端适配的注意事项如果你想让游戏在手机上也能玩需要注意几点点击事件要用touchstart而不是click因为click在移动端有 300 毫秒的延迟草坪的尺寸要根据屏幕宽度做响应式缩放植物卡片要足够大方便手指点击。响应式缩放最简单的做法是用 CSS 的transform: scale()根据屏幕宽度和设计宽度的比例来计算缩放系数。这样所有的坐标计算都不需要改只需要在渲染时统一缩放。11. 代码模块的复用与扩展思路11.1 把核心逻辑抽成独立模块当你把这个游戏做完之后你会发现其中的很多模块是可以复用的。网格系统可以用在任何塔防类游戏里资源管理系统可以用在任何有经济循环的游戏里主循环和 deltaTime 的处理可以用在任何实时交互项目里。我的建议是在写的时候就有意识地保持模块的独立性。比如网格模块不要依赖任何游戏特定的逻辑它只负责坐标转换阳光模块不要依赖植物的具体实现它只负责资源的增减和显示。11.2 扩展到其他游戏类型这套模块化的思路不仅适用于植物大战僵尸还可以扩展到其他游戏类型。比如把网格系统改成六边形网格就可以做回合制策略游戏把僵尸换成敌人把植物换成防御塔就是一个标准的塔防游戏把阳光换成金币把植物换成兵种就是一个简单的即时战略游戏。关键在于理解每个模块的职责边界以及模块之间如何通过游戏状态来通信。一旦你掌握了这种架构思路做任何交互式项目都会变得有章可循。11.3 性能优化的方向当僵尸和子弹数量很多时DOM 渲染会成为性能瓶颈。优化的方向有几个一是用 Canvas 替代 DOM 来渲染游戏实体Canvas 的绘制性能远高于 DOM 操作二是做对象池复用已经创建的 DOM 元素而不是频繁创建和销毁三是减少不必要的重排和重绘比如把多个 style 修改合并成一次。不过对于学习目的来说DOM 版本的性能已经足够了。等你把游戏逻辑跑通、玩起来觉得有意思之后再考虑用 Canvas 重写渲染层那会是一个很好的进阶练习。11.4 代码质量的持续改进最后分享一个我在实际开发中的习惯每完成一个模块就花几分钟回顾一下代码看看有没有可以提取的重复逻辑、有没有命名不清晰的地方、有没有可以简化的条件判断。这种小步迭代的习惯比一次性写完再重构要高效得多。另外给关键函数写注释特别是那些涉及坐标转换、碰撞检测、状态切换的函数。过几天你回来看代码时这些注释会帮你快速回忆起当时的思路。