ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI写小游戏代码测试全绿却进Cocos黑屏?环境适配才是关键

AI写小游戏代码测试全绿却进Cocos黑屏?环境适配才是关键

上个月我在做一个微信小游戏《清单之外》,玩法很轻:每天从“清单之外”的任务池里随机抽三条小任务,完成一条就攒一个徽章,本质上是个收集养成向的休闲游戏。开发方式有点特殊——核心逻辑几乎全部交给AI来写,我主要负责场景搭建、资源制作,还有最后真机适配。项目做到一半时,单元测试跑下来全是绿的,我以为这波稳了,结果往Cocos Creator里一挂,启动直接黑屏,控制台刷了几行报错,连首帧画面都没出来。

当时我整个人是懵的:AI写的逻辑层明明是通的,测试也全过了,怎么一进引擎就炸?后来花了好几个晚上排查,才发现“全绿”这件事本身就是个陷阱。这篇文章就是把这次完整的踩坑过程复盘出来,聊聊为什么单元测试通过和游戏能跑之间隔着好几道坎,也把AI辅助开发实际工作中的那张“安全边界”划清楚。如果你准备用AI来写小游戏代码,尤其是最终要发微信小游戏的,这篇应该能帮你省掉不少我交过的学费。

1. 项目复盘:游戏本身、技术选型和“全绿”是怎么来的

1.1 《清单之外》的玩法定位与技术选型

《清单之外》的核心玩法一句话就能说清:玩家每天进入游戏,系统会自动刷新三条“清单之外的挑战”,完成对应的动作就能获得徽章碎片,集齐一套可以点亮一张主题卡。这个玩法的好处是代码量不大,但需要处理的逻辑不少——任务池抽选、每日重置、完成判定、徽章进度、存档读取,还有一套NPC对话触发的随机事件。

技术选型上没有太多犹豫,直接用Cocos Creator做客户端,TypeScript写逻辑,最终构建成微信小游戏。选择Cocos主要是因为它的2D工作流成熟,发布微信小游戏是一键流程,社区里适配资料也多。微信小游戏本身就是一个特殊的JavaScript运行环境,很多浏览器API不存在,比如window、document、localStorage这些常规Web习惯,在实际运行时都可能缺失或行为不同。这一点决定了后续的坑都是从这里冒出来的。

项目一开始我就打算用AI辅助写代码,理由是任务池算法、日期判断、进度管理这类逻辑,边界清楚、规则明确,AI生成起来效率很高。实际也确实如此,数据模型、随机抽选、每日零点重置等模块,AI只花了几分钟就给了我一套像模像样的实现,还顺手配了单元测试。

1.2 AI到底帮我写了哪几个模块

这次AI承担开发的模块大概有四块:

  • 任务数据模型:任务类型定义、奖励数值、稀有度权重。
  • 每日任务池抽选算法:根据日期种子决定当天任务,保证同一天不同设备读到的任务一致。
  • 完成任务判定逻辑:根据用户的操作类型和参数校验是否匹配任务要求。
  • 徽章进度与存储封装:进度变化、徽章解锁条件判断、存档的读写接口。

这些模块的共同特点是:它们都不直接依赖引擎或硬件能力,大部分是纯数据逻辑,用普通的TypeScript对象和函数就能表达。AI写的代码在结构和可读性上让我比较满意,测试用例覆盖了边界情况,跑起来也是一片绿。我当时还特意看了覆盖率报告,核心逻辑的语句覆盖率到了九成以上。

现在回头看,问题恰恰出在这个“九成覆盖率”上——它覆盖的是代码逻辑,不是运行环境。章节跑在Node环境里,测试时用的存储、时间、随机数都是mock出来的;而真实游戏跑在Cocos的组件化环境里,面对的API完全不是同一套。这是我前期完全没有意识到的一件事。

1.3 “测试全绿”是怎么跑出来的

测试框架用的是Vitest,跑在Node环境里。AI生成的测试文件大致长这样:

import { describe, it, expect, vi } from 'vitest'; import { pickDailyTasks } from './taskPool'; import { loadProgress, saveProgress } from './storage'; vi.mock('./storage', () => { const store = new Map(); return { loadProgress: vi.fn(() => JSON.parse(store.get('progress') || 'null')), saveProgress: vi.fn((data) => store.set('progress', JSON.stringify(data))) }; }); describe('taskPool', () => { it('同一天返回相同任务', () => { const a = pickDailyTasks('2025-04-01'); const b = pickDailyTasks('2025-04-01'); expect(a.map(t => t.id)).toEqual(b.map(t => t.id)); }); });

注意第三行的vi.mock('./storage')——它把存储模块整个换成了内存Map。在测试环境里这没问题,逻辑对就行。但真实环境里,sotrage模块调用的是Cocos的sys.localStorage,再往平台层看是微信的wx.setStorageSync。AI在Node环境里测试,mock掉了所有环境差异,可它不知道真实运行时那些API能不能用。

这就是“测试全绿”最大的欺骗性:绿得越彻底,越容易让人忘记环境真实性的缺失。这个教训值好几晚的调试点。

2. 全绿背后藏着四个致命差异

2.1 第一刀:生命周期和启动时序

Cocos Creator的脚本不是一进场景就把所有代码从上到下执行一遍。每个挂到节点上的组件,都要等场景加载完成后,依次触发onLoad、start、update这些生命周期回调。很多AI生成的代码,常常在模块顶层直接写“访问引擎API”的初始化逻辑,比如:

// 这种顶层直接拿着引擎能力初始化的写法是重灾区 const storage = wx.getStorageSync('dailyTasks');

这段代码如果出现在普通网页环境里,没问题;但出现在Cocos工程里,脚本文件在导入阶段就可能执行,此时场景还没就绪,节点还没创建,甚至平台能力还没有完全注入。于是报错五花八门:Cannot read property 'getStorageSync' of undefined、wx is not defined,最惨的是没有报错,只是黑屏没有任何反应。

正确做法是把所有依赖引擎能力的操作都挪到onLoad之后,让引擎完成初始化再执行自己的逻辑。比如读取存档这件事,我就改成在组件的onLoad里通过sys.localStorage.getItem去拉取。而AI生成的纯逻辑代码,比如任务抽选函数,则保持纯粹的函数式,只接收参数、返回结果,不碰任何全局对象。这种“逻辑与引擎解耦”的写法,后来成了我重构的主线。

2.2 第二刀:渲染方式根本不是同一套

另一个问题的爆发点是渲染。AI在生成“画任务卡片”“画进度环”这类代码时,非常自然地使用了Canvas 2D那套API:

const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f00'; ctx.fillRect(0, 0, 100, 40);

在小游戏环境里,没有document,没有createElement。微信小游戏要求你直接操作底层Canvas,而且即便拿到了Canvas上下文,Cocos Creator的渲染机制也完全不走裸Canvas。Cocos有自己的节点、组件、渲染管线,你要画一个进度环,正确思路是给节点挂上Graphics组件,然后调用circle、fill这些方法:

import { _decorator, Component, Graphics } from 'cc'; @ccclass('RingView') export class RingView extends Component { start() { const g = this.getComponent(Graphics)!; g.lineWidth = 10; g.circle(0, 0, 60); g.stroke(); } }

也就是说,AI的“画图”和Cocos的“画图”根本是两个宇宙。我后来总结出一个判断标准:凡代码里出现canvas、ctx、document这三个词,就要拉响红色警报。这类代码放到Cocos里,即使勉强运行,也会因为渲染体系和引擎冲突而出现绘制结果错乱、节点坐标对不上、显示层级莫名其妙等问题。

2.3 第三刀:坐标系和屏幕适配

坐标系的坑更隐蔽。微信小游戏的Canvas坐标系和浏览器一样,原点在左上角,x轴向右,y轴向下;而Cocos Creator的坐标系原点在屏幕中心,y轴向上。感受一下这个区别:

  • 想让卡片出现在屏幕右上角:
    • Canvas思路:x取screenWidth-100,y取100;
    • Cocos思路:x取visibleSize.width/2-100,y取visibleSize.height/2-100,而且节点锚点不同,最终位置还会受影响。

AI不懂这个,它按浏览器坐标系的习惯去写,最直接的后果就是小游戏在低端机不同分辨率下部分元素直接跑到屏幕外面。尤其是适配微信小游戏的fitHeight、fitWidth设置,和设计分辨率之间还有一层换算关系。这些坐标算法和设计稿配置,AI是完全无从推断的,它没有项目上下文,自然不可能替你算对。

这块最终的修复方案是:把坐标换算统一收敛在一个工具类里,节点摆放一律使用Cocos的坐标,设计分辨率锁定为固定的横屏尺寸,再配合微信小游戏适配模式。所有坐标相关的计算不放逻辑层,放表现层。

2.4 第四刀:存储和平台能力依赖

存储模块是这次唯一一个AI还原生封装过、但实际还是炸了的模块。AI写的存储封装用的是localStorage:

export function saveProgress(data: Progress) { localStorage.setItem('progress', JSON.stringify(data)); } export function loadProgress(): Progress | null { const raw = localStorage.getItem('progress'); return raw ? JSON.parse(raw) : null; }

浏览器里这是对的,但微信小游戏环境没有localStorage。在Cocos里,跨平台的封装是sys.localStorage,发布到微信平台后,引擎会把它桥接到wx.setStorageSync。所以正确的写法是:

import { sys } from 'cc'; const KEY = 'clear_list_progress'; export function saveProgress(data: Progress) { sys.localStorage.setItem(KEY, JSON.stringify(data)); } export function loadProgress(): Progress | null { const raw = sys.localStorage.getItem(KEY); return raw ? JSON.parse(raw) : null; }

这个差异如果不改,构建出来的小游戏包在微信开发者工具里会直接抛异常。更麻烦的是,如果AI在多个地方分散使用localStorage,改起来非常费劲。我当时是把所有存储相关调用全部搜索出来,集中替换成统一的存储管理模块,再跑一遍真机。

下面这张表,是我这次踩完坑后整理出来的环境差异对照:

维度纯逻辑层测试环境Cocos引擎真实环境
存储mock出来的Map或localStoragesys.localStorage/wx.*存储桥
渲染不涉及,或DOM mock节点 + 组件 + Graphics/渲染器
坐标系无屏幕概念屏幕中心原点,y轴向上
生命周期同步执行,无生命周期onLoad -> start -> update
全局对象Node环境可安全使用window/document不可用,仅wx前缀
异步资源测试里直接返回结果需要加载回调或Promise机制

这张表背后是一句话:逻辑正确只是起点,环境适配才是能不能跑起来的关键。

3. 从黑屏到定位根因的完整排查实录

3.1 先说现象:三种失败其实完全不同

开始排查前,先把“进Cocos失败”说清楚,失败的形式有不同含义。我这次遇到的是黑屏,但控制台有没有报错、有没有白屏、有没有闪退,对应的排查方向都不一样。

  • 黑屏+有报错:说明引擎已经启动,但脚本在执行过程中挂了,错误信息能直接指出问题在哪一行。
  • 黑屏+无报错:多个信号被吞掉,或者脚本根本没有被加载,优先查构建配置、资源路径、插件脚本顺序。
  • 白屏:场景加载出来了,但没有渲染任何内容,最常见是摄像机方向不对或者节点不可见。
  • 闪退:往往是小游戏内存不足或API调用异常,需要看微信开发者工具的性能面板和日志归档。

我遇到的是小黑屏+报错,属于最好定位的那种。控制台给出的报错是localStorage is not defined,定位到storage.ts第17行。那一刻我反而松了一口气——至少引擎是活的,能指引修。真正难搞的是那些“没有任何报错但就是没画面的黑屏”,下面会讲我们如何用日志定位。

3.2 加日志和二分法定位:一步步逼近问题

排查决断不能靠猜。我当时采取的策略是:在引擎最小可行路径上插入大量日志。具体来说,我给主场景的第一个节点的onLoad加了日志,给脚本入口文件的顶层也加了日志,然后在start里也加了日志。过程跑起来后,哪段日志没打印,就能立刻锁定断点。

举例,如果onLoad日志都没打出来,说明这个组件脚本没有被正确关联到节点上,或者脚本编译时就因语法错误、运行时异常中断了。如果onLoad打了、start没打,说明onLoad里有异常,后面所有内容都不会执行。这套二分法特别适合定位AI生成代码接入引擎时的初始化问题。

我当时还用了另一个很笨但有效的方法——逐步注释。把非核心模块全部注释,只保留一个挂空节点的场景,确认能渲染出一个纯色背景,再逐步放开一个一个模块,每放开一个就跑一遍预览。跑到哪个模块时黑屏,根因就在哪个模块。

3.3 揪出三个真正的根因

经过二分法排查后,锁定最后三个表现各异的根因,挨个说明:

第一是存储模块的localStorage调用。只要进入start阶段读取进度,就会触发异常,整个组件的启动流程终止。这个修复最简单,换成sys.localStorage即可。

第二个是任务池模块在模块顶层执行了随机数初始化。AI代码在顶层直接调用了一个依赖Date.now()的种子生成逻辑。这本身没问题,但它在模块加载时就执行,并且干扰了Cocos的脚本加载顺序,导致部分依赖该随机数的场景组件在onLoad时拿到的数据是过期或空值的。解决办法是把初始化动作移到逻辑管理器里显式调用。

第三是UI绘制代码试图调用原生Canvas API直接绘制任务卡片。那一整段绘制逻辑在真机黑屏中占了大头,因为它在onLoad里直接创建Canvas上下文,但微信小游戏环境中这种调用可能返回null,之后的绘制全失败,画面就没渲染了。最终把绘制代码全部改成Cocos的Graphics组件和Sprite节点。

3.4 修复后的回归测试:全绿但仍然不行怎么办

修复完这三个问题之后,我重新在微信开发者工具里跑了预览,画面出来了,但还是有一个小问题——任务徽章的位置偏移。这个问题的起因正是前面说的坐标系差异。UI节点没按Cocos坐标调整,个别元素跑到屏幕边缘外。我又统一改了坐标换算工具类,把任务卡片的锚点和位置换算成屏幕中心坐标系,这才真正全部跑通。

回归测试我做了两层:第一层是原有Vitest测试,继续保持全绿,因为改动没有破坏纯逻辑;第二层是真机验收,在微信开发者工具和两台真机上各跑一遍完整流程,确认任务刷新、完成、徽章收集三个核心链路正常,没有卡死或白屏。

这里我想强调一句:测试全绿之后,最需要做的是在真正目标平台上跑一遍最小闭环。哪怕只是从启动到看到第一帧画面,也要尽早跑。如果这一步晚了,修复成本会指数级上升。我这次是到集成阶段才跑,等于把各种环境问题堆到了一起,排查效率很低。

4. AI辅助开发这趟水到底有多深

4.1 AI代码的三大典型命门

这次项目让我把AI代码的问题总结成了三类命门,以后遇到同类情况可以直接按图索骥。

第一类是“浏览器假设”。AI的预训练语料里绝大部分是网页或Node代码,生成时默认存在window、document、localStorage这些对象。在小游戏引擎里,这些对象经常不存在或行为不同。所以拿到AI代码的第一件事,就是全局搜索这三个词,一旦出现,就要问自己:它到底在什么环境里才能跑?

第二类是“引擎瞎猜”。AI对Cocos Creator的API并不是完全无知,但它会混用旧版本API、推荐不存在的组件,甚至把2.x和3.x的用法揉在一起。比如有的代码里同时出现cc.Node和@ccclass,一看就是模型在撒谎。凡涉及引擎具体API,必须对照当前版本的官方文档。

第三类是“空有结构,没有边界”。AI很喜欢把所有逻辑写在一个大文件里,动不动就上百行。实际项目里更要命的是,它会把“取数据”和“画界面”混在一起,让后续替换存储、替换渲染层都变得异常痛苦。这次踩坑后,我强制要求AI代码按“纯逻辑层”和“引擎适配层”分离:前者不允许import任何cc模块或平台API,后者才是负责调用引擎能力的薄薄一层。

4.2 单元测试的盲区比想象中大

写完测试全绿很容易让人膨胀,但必须承认:单元测试验证的是“函数输入输出对不对”,不是“游戏在设备上能不能用”。这次项目的测试覆盖了任务抽选、进度计算、徽章解锁条件,它们都是无状态的纯函数,所以测试通过很正常。但真正出错的部分——存储模块的localStorage、Canvas的先后顺序、坐标系的换算——恰恰全都没有被真实测试覆盖。

如果你想用自动化测试覆盖环境问题,正确做法不是指望Node环境里mock,而是在小游戏开发者工具里跑集成测试,或者至少增加一个“启动冒烟测试”:脚本加载后能否成功执行onLoad,获取到第一帧渲染结果。哪怕只是日志断言,也比几千行单元测试更有用。

另一个关键点是:AI生成的测试用例往往会“测试AI自己写的代码”,容易出现“对着特征写测试”的问题。也就是说,测试验证的是AI代码的当前行为,而不是业务需求本来的约束。比如每日任务应该是“同一天相同”,但如果AI某天写成了“同一种子相同”,测试可能还是会过,因为它就是照当前实现生成的期望值。最好是自己先梳理几条核心业务规则,让测试对准规则,而不是对准实现。

4.3 正确的AI辅助姿势:先生存后完善

经过这次教训,我现在的AI辅助开发流程已经改成“先生存后完善”的六步:

  1. 先把最小可运行闭环跑通:从启动到第一帧有画面,哪怕只有背景色。这一步先不要贪多。
  2. 让AI只写纯逻辑模块:数据模型、任务抽选、进度计算、存储接口的抽象定义都可以让它来,但不要让它碰任何渲染代码。
  3. 人工负责“引擎适配层”:Cocos的生命周期、节点操作、坐标系、渲染组件这些,由我来写或者让AI写完我逐行审。
  4. 环境隔离:任何读取存储、读取时间、生成随机数的地方,都通过接口注入,而不是直接在模块内部调用全局API。
  5. 尽早集成,尽早真机:每完成一小块,就丢进Cocos预览里跑一遍,能快速暴露环境差异。
  6. 测试分两层:逻辑层交给Vitest,运行层靠真机冒烟和日志验证。

这套流程下来,AI依然能显著提效,但判断风险的地方由我控制,不会再出现“全绿但进Cocos黑屏”这种返工了。

4.4 我现在的工作流和工具组合

结合这次经验,目前我用下来的比较顺手的组合是:

  • 需求拆解:先把玩法拆成“数据规则”和“界面流程”两张清单。
  • AI工具辅助生成数据层、工具函数、测试用例。
  • Cocos Creator负责场景和UI组装,图形资源用现成的或简单程序化生成。
  • 代码用一般性的格式化工具统一风格,避免AI代码不同文件风格差异过大。
  • 真机验证现在固定至少两台设备:一台低端安卓、一台iOS,性能和兼容性问题尽早暴露。

另外我强烈建议把项目中所有“非Cocos标准API”的调用都收拢到一个platform.ts文件里,统一适配。比如存储、震动反馈、分享、系统时间读取,都通过这个文件暴露接口。这样即便AI生成的逻辑里偶尔偷用平台API,也能在编译期快速发现,不至于散落各处改到崩溃。

5. 修复后的效果和几条保命建议

5.1 修复后实际跑起来是什么样

修复完之后,《清单之外》在微信开发者工具里完成了从启动到收集徽章的全流程验证。启动耗时降到可接受范围,没有再出现黑屏或空白页。每天零点之后进入游戏,会正确按日期种子刷新三条任务;完成任务后点确认,徽章碎片+1;集齐后点亮卡片,触发一次简单的弹窗反馈。真机上跑了两台旧设备,也没有再出现闪退和位置偏移。

比较有意思的是,整个问题修复过程中,AI生成的纯逻辑代码一行没改。任务抽选函数、完成判定、进度计算,依旧是原有实现。这也说明AI在“规则明确的计算”上确实好用,只是引擎这一层,不是它的主场。

5.2 给准备入坑的人几条保命建议

如果你也想用AI辅助写小游戏,我的建议很简单,但每条都是踩完坑换来的:

  • 第一,拿到AI代码先搜document、window、localStorage、canvas这四个词,发现就标记风险。
  • 第二,任何涉及引擎生命周期、屏幕坐标、场景节点的代码,都必须人工过一遍。
  • 第三,测试不是终点,真机跑通才是。第一帧画面出来的越早,后面返工越少。
  • 第四,积累一套本项目的“环境差异速查表”,类似上面那张表格,粘在项目仓库的README里,方便随时核对。
  • 第五,如果AI代码出现你完全不理解的部分,宁可重写也不要盲信。理解不了的东西放进项目里,就是埋雷。

5.3 后续还能怎么扩展

这次项目跑顺之后,我已经在规划一些扩展方向了。比如给每日任务增加玩家自选模板,把任务池改成动态内容配置,不用改代码就能运营活动;再比如把进度存档接上云存储,支持跨设备续玩。这些功能里,AI依然可以在我给清楚规则的情况下帮我生成数据层和业务层,但引擎适配的部分依旧走人工评审。

说实话,这次“测试全绿、进Cocos失败”的教训对我来说很值。它让我真正理解了“可测试”和“可运行”之间的差距。AI写代码的能力毋庸置疑,它帮我把大量繁琐的数据计算和边界判断工作做了,速度和覆盖面都是手写没法比的。但它不会知道你用的引擎API长什么样,也不会替你判断运行环境支持哪些对象。真正成熟的工作流,不是把所有工作丢给AI,而分清楚哪一层适合交给它,哪一层必须由人来兜底。

最后留一个小技巧:任何从网页或Node环境的代码搬进Cocos的第一步,先把代码里所有“全局对象”找出来,凡涉及window/document/localStorage的,一律改成引擎提供的接口或你自己的适配层。这个动作做好,至少能跳过这次复盘里80%的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进