ARTICLE DETAIL

资讯详情

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

Vibe Coding实战:用React和Canvas从零完成飞机大战游戏

Vibe Coding实战:用React和Canvas从零完成飞机大战游戏 前阵子朋友问我Vibe Coding 到底靠不靠谱能不能拿来写点真东西。我不太喜欢空口解释所以直接用了周末两天用 React 做了一个最经典的练手项目飞机大战游戏。做完之后我的感受很直接——Vibe Coding 不是玄学也不是AI 全部代劳、我躺在旁边等结果它更像一场需要你全程主导节奏的协作。这篇文章不写那些概念性的介绍只把这两天里我是怎么跟 AI 协作、怎么把游戏从零搭出来、在哪几个地方差点被 AI 带沟里以及最终沉淀下来的边界感讲清楚。如果你也想试试 Vibe Coding或者想在 React 里做一个小游戏这份复盘应该能帮你少踩几个我踩过的坑。1. Vibe Coding 为什么能用来做游戏一次完整的起步对话1.1 我理解的Vibe Coding不是让AI全代劳而是描述-生成-验证-修正的循环Vibe Coding 这个词最近在圈子里传得很热很多人把它理解成随便说句话就能得到整个项目在我看来这是一种误读。它真正的变化是你不再花大量时间在语法和 API 细节上而是把注意力集中在你到底想要什么以及AI 给的东西对不对这两件事上。换句话说编程的重心从写变成了审。我自己的实践循环是这样的把游戏需求拆成小块 → 用自然语言把每一块的期望讲清楚 → AI 生成代码 → 我运行、观察、检查边界 → 发现问题再让它修正。这个循环看起来跟传统开发很像唯一的区别是敲键盘这个体力活大部分被 AI 接走了我的角色变成了产品经理加测试加架构师。做飞机大战这种小项目特别适合验证这套流程因为范围小、反馈快改一行代码马上能感受到效果AI 写得好不好也一眼就能看出来。打个比方以前自己写代码像一个人下厨洗菜切菜配菜炒菜都是自己干Vibe Coding 更像你带了一个执行力很强的帮厨你负责定菜单、把控火候、尝味道。但帮厨不会主动思考这道菜是不是太咸了它只是照你说的做。所以你对菜的理解越清楚最后出来的东西才越靠谱。1.2 为什么选飞机大战这个项目练手选飞机大战不是随手一拍。这个游戏麻雀虽小五脏俱全几乎覆盖了前端游戏会遇到的所有典型问题Canvas 渲染、游戏循环、键盘输入、碰撞检测、状态切换、计分和生命值管理甚至还有音效和动画。拿它来试 Vibe Coding正好可以看看 AI 在这些不同类型的代码上分别表现如何。另外它的范围非常可控。对我来说一个周末完成绰绰有余不会因为项目太大而失去对全局的掌控感。如果你现在让我用 Vibe Coding 做一个电商后台我大概率要花很多时间跟 AI 对齐业务模型那不是体验编程方式的好场景。但游戏不一样它的逻辑是通用的、自包含的即使 AI 某段代码写得稀烂修复成本也不高。对想入门 Vibe Coding 的朋友我真心建议从小游戏或者工具型页面开始练手别一上来就挑战复杂业务系统。顺便说一句这个小项目对准备前端面试也很有用。组件划分、生命周期函数、性能优化这些面试高频题在飞机大战里全都能落地你可以一边玩一边把知识点串起来比死记硬背强得多。1.3 第一段提示词怎么写把模糊想法变成可执行方案我打开 AI 编程助手后第一句话并没有写帮我做个飞机大战而是直接把技术栈、功能和约束一次说清楚请帮我用 React TypeScript Canvas 实现一个飞机大战游戏。 要求玩家用键盘控制飞机移动并射击敌机从顶部随机生成并下落 有计分系统和生命值系统游戏需要开始画面、进行中、结束画面。 先不要写完整代码先给我技术方案和文件结构。最后一句先不要写完整代码是关键。让 AI 先输出方案而不是直接生成一坨代码能有效避免它一次性铺一大堆不可控的东西。AI 给我的方案很清晰组件层拆出 App、GameCanvas、HUD、StartScreen、GameOverScreen高频变化的游戏运行数据放进 ref低频的界面状态放进 useState工具函数单独放在 game 目录下。说实话这个结构已经是一个合格前端工程师的水平了。我当时确认了方案可行之后才让它动笔写代码。这也是我这次实践里学到的一个重要原则Vibe Coding 的上限取决于你把需求讲得多清楚。你给 AI 的信息越结构化它还给你的代码就越接近你想要的东西。1.4 环境准备一行命令搭起React项目框架技术栈我选了 Vite React TypeScript原因只有一个快。Vite 的启动速度比老牌的 Create React App 快一个量级而且模板自带 TypeScript省得后面补类型。创建项目的命令我放在这npm create vitelatest plane-battle -- --template react-ts cd plane-battle npm install npm run dev跑起来之后我先把项目里自带的示例代码全部清掉替换成一个空的 App 组件和一个全屏 Canvas 组件。这么做是给 AI 一个干净的工作区省得它读到你不需要的代码后产生混乱。这里多说一句npm create vite在较新版本里可能会提示交互式选择框架如果你遇到这种情况也可以直接手动选择 React TypeScript 选项不影响后续操作。Node 版本建议 18 以上太老的版本跑 Vite 会报兼容性问题。2. 游戏架构是怎么被聊出来的Canvas与React组件边界划分2.1 先让AI做架构师一场关于组件与状态的分工讨论环境准备好后我继续跟 AI 聊架构。我问它游戏循环、玩家位置、子弹数组这些高频变化的数据应该放哪为什么这个问题是我故意抛的因为我想看它是否理解 React 的性能特性而不是机械地写代码。AI 的回答很到位高频变化的数据不要直接放进 React state否则setState一秒执行几十次组件会反复重渲染Canvas 上画的内容也跟着抖动正确做法是用useRef保存这些运行期数据只在必要时通过回调通知 React 更新 UI。当时看到这个回答我心里其实挺满意的。它主动提到了很多人做 React 游戏时最容易踩的坑。我后来复盘发现AI 能给出这个方案是因为我前面把约束讲得足够具体。如果你只是说做一个飞机大战它可能真的会把玩家坐标写进 state跑起来卡成幻灯片。这又一次说明了你的约束越清晰AI 的设计越合理。在 Vibe Coding 里提问本身就是一种架构能力。2.2 Canvas负责帧级渲染React负责界面与状态这条边界的意义架构确定后我们的分工很明确React 管什么时候进入游戏、游戏结束后显示什么、分数和生命值怎么展示这些偏界面的事情Canvas 管每一帧画什么飞机、什么子弹、什么敌机这些偏渲染的事情。两者之间通过回调通信而不是互相直接读写数据。文件结构也跟着这个思路定了下来src/ App.tsx // 游戏阶段状态机menu / playing / gameover components/ GameCanvas.tsx // Canvas渲染 游戏循环 HUD.tsx // 分数/生命值展示 StartScreen.tsx // 开始画面 GameOverScreen.tsx // 结束画面 game/ engine.ts // 游戏循环、update/render entities.ts // 玩家、敌机、子弹的类型与创建函数 collision.ts // 碰撞检测纯函数 constants.ts // 画布宽高、速度、生成间隔等配置这个结构里的关键点是渲染流程的归属。画布渲染流程从清屏、绘制、到下一帧的持续循环完全放在 GameCanvas 内部不经过 React 的虚拟 DOMReact 只在状态切换的瞬间介入。这个边界划清楚后后续所有模块的提示词都能聚焦到各自的文件里上下文不会越扯越长也为我后面要讲到的AI 遗忘问题提前打好了预防针。2.3 游戏循环的接入点requestAnimationFrame与状态机的耦合游戏循环是飞机大战的心脏它长这样调用requestAnimationFrame(loop)每次 loop 里计算时间差dt然后根据当前游戏阶段决定是更新逻辑还是只渲染画面。这里我贴一段简化后的示意代码它代表了我们最终的实现方式useEffect(() { let rafId 0; let lastTime performance.now(); const game createGame(); const loop (timestamp: number) { const dt (timestamp - lastTime) / 1000; lastTime timestamp; if (phaseRef.current playing) { game.update(dt); } game.render(ctx); rafId requestAnimationFrame(loop); }; rafId requestAnimationFrame(loop); return () cancelAnimationFrame(rafId); }, []);注意这段代码里有两个细节。第一phase不是通过useState直接读的而是存在phaseRef里。为什么因为在事件回调或 rAF 回调里读取 state 容易拿到旧值用 ref 可以随时拿到最新状态。第二effect 的依赖数组是空的这保证了游戏循环只启动一次不会被 React 的生命周期反复触发。这个细节我当时没太在意结果后来就在这里翻了个大车第 4 章我会详细讲。3. 三大核心模块逐个攻破从控制到碰撞的实战记录3.1 玩家控制按键集合与移动计算的AI实现架构搭好后我开始让 AI 逐个模块写代码。第一个模块是玩家控制。我的提示词是请实现玩家飞机的键盘控制方向键和WASD都能移动支持长按持续移动 空格键射击射击要有间隔。移动速度用常量配置。AI 第一次给出的版本很朴素在keydown事件里直接修改玩家坐标。这个写法有一个问题键盘的keydown事件本身会按系统频率重复触发长按方向键时飞机移动速度会忽快忽慢而且同时按两个方向键时位置更新会乱掉。我让 AI 改成按键集合方案把所有被按下的键记录到一个Set里每帧在update函数里统一读取这些按键状态再计算位移。这个方案的妙处在于无论键盘事件触发多少次最终移动只取决于当前按住了哪些键逻辑干净且稳定。简化后的核心代码是这样的const keys new Setstring(); window.addEventListener(keydown, e keys.add(e.code)); window.addEventListener(keyup, e keys.delete(e.code)); function updatePlayer(player: Player, dt: number) { let dx 0; let dy 0; if (keys.has(ArrowLeft) || keys.has(KeyA)) dx - 1; if (keys.has(ArrowRight) || keys.has(KeyD)) dx 1; if (keys.has(ArrowUp) || keys.has(KeyW)) dy - 1; if (keys.has(ArrowDown) || keys.has(KeyS)) dy 1; player.x dx * player.speed * dt; player.y dy * player.speed * dt; player.x clamp(player.x, 0, WIDTH - player.width); }代码里还加了clamp把飞机限制在画布范围内这是游戏手感的第一个保障。后来我还让 AI 给移动加了一点点惯性也就是目标位置和当前位置之间做一个插值让飞机不显得那么硬。这种手感层面的优化AI 能很快理解并实现但前提是你得主动提出来。你不说它默认给你的就是最简单的匀速直线运动。3.2 敌机生成与碰撞检测AI最容易写歪的部分第二个模块是敌机生成和碰撞检测。提示词我这样写敌机从顶部随机生成向下移动速度随机子弹与敌机、敌机与玩家发生矩形碰撞检测 碰撞后如果是子弹击中敌机则加分如果是敌机撞击玩家则扣命。AI 很快给出了矩形碰撞检测函数function rectHit(a: Rect, b: Rect): boolean { return a.x b.x b.width a.x a.width b.x a.y b.y b.height a.y a.height b.y; }这个公式本身是标准的矩形相交判断没有毛病。但问题往往不出在公式上而出在坐标系的约定上。rectHit里的a.x到底是飞机的左上角还是中心点AI 在上下文里很容易前后不一致。这个坑我在第 4 章会详细讲这里先按下不表。除了碰撞检测敌机生成还有一个隐藏细节生成间隔要随着游戏进程逐渐缩短难度曲线才会合理。AI 默认给的版本是固定间隔生成我要求它改成每过 20 秒生成间隔缩短 10%并设一个下限这样游戏越玩越刺激但又不会难到无解。难点设计这种东西AI 不会替你想你给出规则它才能执行。3.3 计分、生命值与游戏状态切换先想清楚再让AI写第三个模块是计分、生命值和游戏状态。我给的提示词是击落一架敌机加10分玩家被敌机撞击或者敌机到达底部就扣一条命 生命值为0时游戏结束显示GameOver画面与最终得分重新开始必须重置所有运行状态。这里我没有让 AI 直接写而是先让它给出了状态机的设计。游戏阶段我定义了四个menu、playing、paused、gameover。在 React 层我用useState保存当前阶段在游戏引擎层所有的实体数据都在引擎对象里。两个方向之间的同步我坚持只从引擎向 React 单向同步引擎修改分数调用onScoreChange回调React 层拿到新分数后更新 UI反过来React 不会把分数写回引擎。这个单向数据流帮我避免了很多奇怪的双向同步 Bug。AI 在这个模块里表现得中规中矩但有一个点它容易漏游戏结束的时候要清掉画布上的残留内容否则最后一帧画面会一直挂在屏幕上。我让我加了一个半透明黑色遮罩 居中显示 Game Over的处理玩家操控也要及时禁用不然键盘事件还会继续移动那架已经不存在的飞机。总的来说三大模块做完之后游戏已经可以完整跑起来了。但紧接着我就在联调和体验阶段连续踩了好几个坑接下来这部分是这篇文章的重头戏。4. 踩坑实录AI写游戏代码最容易翻车的四个地方4.1 上下文越写越长AI开始遗忘自己定过的坐标约定做到碰撞检测联调的时候我遇到了第一个大坑。当时项目已经写了挺多代码AI 的上下文里塞满了各种模块的对话记录。我让它往引擎里新增一种分裂敌机它新生成的代码里敌机坐标用的是中心点 x/y表示中心位置但整个项目此前的约定是左上角 width/height。两边混在一起结果就是所有碰撞都偏移了半个机身。我当时玩了几局总感觉有些子弹明明离敌机还有一段距离敌机却炸了有些子弹明明穿过了敌机却没反应。第一反应是碰撞公式写错了但公式一眼扫过去没问题。后来我在碰撞函数里加了打印日志把参与碰撞的两个矩形坐标输出才发现差异刚好等于width / 2和height / 2。再翻 AI 生成的代码确认是坐标锚点不统一。这个经历让我明白了Vibe Coding 的长对话有上下文窗口的天花板AI 不会像人一样牢牢记住自己最早定下的约定。我的解决办法有两个。第一项目里建立了一个CONVENTIONS.md文件显式写清楚实体坐标锚点统一为左上角坐标单位是像素dt单位是秒这些约定每次开新对话前都把它贴给 AI。第二把坐标相关的计算收敛到一个文件里减少跨文件传递时发生歧义的可能。这是我在整个项目里收获最大的一条经验AI 写代码你得给它一本项目宪法否则它写着写着就会跑偏。4.2 useEffect生命周期陷阱暂停功能把游戏循环跑出了双份第二个坑来自 React 本身的特性。我给游戏加了暂停功能实现方式是点击按钮切换phase到paused再点一下切回playing。刚开始一切正常但我很快发现一个诡异的现象每点击一次暂停再继续飞机和子弹的速度就会翻倍。玩了几轮之后子弹快得跟机关枪扫射一样完全没法正常游戏。我的排查过程是这样的先怀疑是dt计算出了问题毕竟速度翻倍最直接的解释就是dt被算成原来的两倍。但检查代码dt (timestamp - lastTime) / 1000这行没问题。然后我在 loop 函数里打印requestAnimationFrame返回的 id结果发现每次暂停恢复后会出现两个不同的 id 交替打印。这说明内存里同时跑着两个游戏循环。根因很快定位我当时把游戏循环放在了useEffect里依赖数组里有phase。每次切换暂停/继续phase变化effect 重新执行新的 rAF 循环开始而旧循环没有被正确取消于是两个循环同时在跑所有速度全部翻倍。这里我想多说几句 React 生命周期函数。很多从 Class 组件时代过来的人可能还记得componentDidMount、componentDidUpdate、componentWillUnmount这三件套。useEffect这个 Hook 把这三件事合并成了一件事依赖数组决定它要不要重跑。问题在于游戏循环本质上是一个持续运行的进程不是一次性的副作用把它绑在会变化的依赖上就是给自己挖坑。修复方式就是我第 2 章里写的那样循环放在空依赖数组里启动phase通过ref读取让循环本身不被 React 渲染周期打断。 提示如果你在 React 里做游戏或者动画一定要记住requestAnimationFrame 循环应该只启动一次不要在依赖数组里放任何会变化的变量。4.3 碰撞检测的坐标系错位一次靠打印日志定位的Bug第 4.1 节里提到的坐标锚点问题其实不是一个单独的坑而是一整类问题。在实际打游戏的时候玩家飞机的坐标锚点是左上角而 AI 新生成的敌机坐标锚点是中心点肉眼看起来飞机轮廓是重叠的但rectHit判断出来的碰撞区域整体偏移了半个机身。我当时是靠打印日志锁定的。在碰撞检测函数入口加了一行console.log(bullet:, bullet, enemy:, enemy, hit:, rectHit(bullet, enemy))然后故意朝敌机边缘射击。日志里bullet.x和enemy.x的差值刚好等于enemy.width / 2。这一刻我立刻明白了问题不在碰撞公式在数据本身。修复的方式也很直接统一让 AI 把所有实体的x、y理解为左上角坐标。同时我把碰撞检测函数拆成了纯函数单独放进了collision.ts并用 Vitest 给它写了两组基础测试一组验证两个矩形相交应该返回 true另一组验证相离应该返回 false。从那以后每次 AI 修改引擎代码我都能跑一次测试来兜底它如果又把坐标锚点改回去了测试会立刻亮红灯。import { describe, it, expect } from vitest; import { rectHit } from ../game/collision; describe(rectHit, () { it(两个矩形相交时返回true, () { const a { x: 0, y: 0, width: 10, height: 10 }; const b { x: 5, y: 5, width: 10, height: 10 }; expect(rectHit(a, b)).toBe(true); }); it(两个矩形相离时返回false, () { const a { x: 0, y: 0, width: 10, height: 10 }; const b { x: 20, y: 20, width: 10, height: 10 }; expect(rectHit(a, b)).toBe(false); }); });这段代码不是什么高深的东西但它代表了我对 Vibe Coding 的一个核心态度AI 生成代码很爽但它的最大风险是表面正确。这种几何相关的函数用单元测试把边界锁死是人应该做的事。4.4 性能衰减子弹数组无限增长用对象池解决第三个坑出现在游戏玩到三分钟之后。帧率肉眼可见地往下掉飞机移动开始发飘子弹也不跟手了。我打开浏览器 Performance 面板发现每一帧的脚本执行时间越来越长主要消耗在数组遍历上。原因很简单子弹和敌机对象只创建、不回收。AI 生成的代码里子弹飞出屏幕后只是不再渲染但对象还留在数组里敌机被击毁后也只是标记为不绘制并没有从数组里移除。游戏越玩数组越长每帧要做的碰撞检测次数就越多性能自然雪崩。修复方案有两个。第一是边界剔除子弹飞出屏幕或者超出某个寿命阈值直接从数组里删除敌机飞到底部也一样处理。第二是对象池提前预分配一组子弹对象子弹被发射时从池子里取一个回收后放回池子避免频繁创建销毁对象造成 GC 压力。两个方案配合起来数组长度被控制在稳定范围内帧率恢复到了流畅水平。这里也想提醒一句AI 能写出优化的代码但什么时候该优化这件事它不会主动告诉你。性能瓶颈的定位需要人来做这恰恰是 Vibe Coding 替代不了的部分。我当时如果直接把 AI 的第一版代码拿去长时间玩根本不会意识到数组在膨胀因为短时间内的体验是完全正常的。这种问题只有带着性能意识去实测才能发现。5. Vibe Coding 的边界在哪里AI负责写我负责懂5.1 好的提问是Vibe Coding的一半工程能力做这个项目的过程中我越来越感觉到AI 的能力边界是在快速扩大的但使用者的提问能力成了新的瓶颈。同样是让 AI 写一个碰撞检测模块问帮我写碰撞检测和问请实现矩形碰撞检测坐标锚点统一为左上角返回布尔值并给出一组单元测试得到的结果质量完全不同。我总结了自己在这次项目里用得比较顺手的提问技巧分享给各位指明技术栈和运行环境比如 React 版本、TypeScript、Canvas。先要方案再要代码。让 AI 在动手前想清楚结构。一个任务开一轮对话不把两个不相关的需求混在一起问。对关键函数要求 AI 写明参数含义和假设条件。边界条件主动说清楚比如坐标锚点、dt的单位、碰撞检测的矩形含义。这套方法本质上没有多玄妙它就是把需求拆解做扎实了。Vibe Coding 并没有降低需求分析的门槛它只是把打字写代码这个环节变成了打字讲需求。顺便提一嘴最近看到的 Agent 框架里经常提到规划-执行-反思的循环我觉得 Vibe Coding 的个人实践其实也是这个套路。我先规划架构AI 执行生成我再通过测试和实际运行来反思纠错一轮一轮循环下去。理解了这套底层逻辑你对 AI 在项目里扮演的角色会清醒很多。5.2 code review还是得自己来AI不会主动承认我不确定我在这两天里遇到过好几次代码能跑但逻辑明显不对的情况。最典型的一次是 AI 直接引用了一张我还没下载的飞机贴图路径ctx.drawImage跑起来直接报错但它自己不觉得有问题。还有一次它把玩家速度初始值写成了 0导致飞机按方向键毫无反应这段代码没有任何语法错误编译也通过只有真正上手玩才知道不对劲。这些经历让我形成了一个习惯AI 写完的每个模块我必须自己通读一遍重点检查它有没有引用不存在的资源、有没有把初始化逻辑放在错误的位置、有没有把常量硬编码到业务逻辑里。我更愿意把 AI 看作一个执行力超强的初级工程师而不是靠谱的资深专家。初级工程师写完代码需要 reviewAI 写代码也一样。 提示AI 不会主动告诉你这里我不确定或者这个资源不存在。它在信息不足时倾向于编一个看起来合理的值继续往下走。所有不被你确认过的东西都要默认它可能是错的。5.3 哪类代码适合留给AI哪类必须人肉把关经过这次实践我给 AI 和人的分工划了一条比较清晰的线。适合让 AI 去做的模板化 UI 组件、工具函数、状态机骨架、Canvas 绘制基础代码、常见算法实现。这些代码模式固定、标准明确AI 生成的效率极高而且质量稳定。必须人来把关的跟游戏手感、体验细节、性能边界相关的逻辑关键状态机的转移顺序任何涉及你打算怎么发展这个代码的架构决策。举例来说碰撞后敌机消失可以让 AI 写但碰撞后要不要加一个 0.1 秒的爆炸动画来增强手感这个问题就得人拍板。再比如暂停时要隐藏 Canvas 画面这件事AI 不会主动想到因为它没有玩过你这个游戏。我做了一张简单的分工对照表方便你参考任务类型适合AI必须人把关组件模板和样式骨架是否矩形碰撞检测是但需测试锁定坐标系约定游戏状态机框架是状态转移的合理性移动手感与惯性参数否是需要实际游玩调整性能优化方案否AI给思路是瓶颈定位靠人资源引用与加载否是AI会编造路径判断标准其实很朴素凡是跑起来能看见对错的AI 能帮你做凡是要玩过才知道好不好的必须自己上手。这次飞机大战项目里所有 AI 觉得差不多了的地方在我实际玩过之后都还有优化的空间这一点体会特别深。项目收尾那天我又多玩了好几局反复调整了敌机的生成频率和子弹的射速终于把它调到有点难但还能打的状态。经历了这次完整的 Vibe Coding 实践我现在每天写代码的流程已经变了AI 负责把每个模块的骨架搭出来我负责把每个模块的细节和手感调到满意为止。我也不再强求 AI 一句话写出完美代码而是养成了一个习惯——每轮对话结束前我会让它总结一下刚才我用了哪些坐标约定、状态命名和常量配置再把这些摘要贴进项目里的约定文件。这样即使 AI 的上下文会话关闭下一个新会话还能快速接上。这个做法不是什么高大上的技巧但如果你也打算用 Vibe Coding 做一个完整的小项目它大概率能帮你省下好几个小时的调 bug 时间。
返回列表