我是一个业余做游戏的人。今年 9 月底,我用 WorkBuddy 起了一个项目:一个 Teardown(《拆迁》)风格的体素破坏游戏——玩家驾驶飞机冲进城市,把楼撞穿、撞塌,看废墟堆成山。
到今天(10 月 7 日),这个项目迭代到了第 55 轮。最终产物是一个 1.66 MB 的单文件 HTML——双击就能玩,不需要装环境、不需要起服务器、没有任何外部依赖。
但真正让我想写这篇文章的,不是这个游戏做成了什么样,而是中间那些没人告诉过我的坑。
我自己是写代码的,我原本以为"用 AI 做游戏"的瓶颈是 AI 写不出复杂逻辑。做了 55 轮之后我发现完全不是——AI 写得出来,问题是我根本不知道它写对了没有。
这篇文章就讲这个:当你有一个能写代码的 AI,做游戏真正的功夫花在哪。
开局做的第一个决定,不是玩法,是交付形态。
我给自己定的硬约束:
项 | 约束 | 为什么 |
|---|---|---|
交付物 | 单个 .html 文件 | 双击即玩,不用装 Python、不用起本地服务器 |
依赖 | 零外部依赖,库内联 | 断网也能跑,发给谁都能开 |
渲染 | three.js + WebGL2 | 不吃素材文件,程序化生成一切 |
音效 | WebAudio 程序合成 | 没有音频素材要管,噪声+低通包络就够了 |
体积 | 控制在 2 MB 内 | 留出增长空间 |
这个约束后面救了我很多次。因为"单文件"意味着没有构建工具、没有模块打包、没有 npm——所有代码堆在一个文件里。到第 55 轮,源码 game.js 已经 21,265 行。
这带来一个副作用,后面专门讲:在 2 万行的单文件里改东西,出错的方式非常隐蔽。
体素游戏的核心矛盾很简单:格子多到离谱,每个格子还不能单独画。
我的做法:
① 用最省的类型存世界

世界边长 500 单位的时候,数组是 1000 × 1000 × 100 = 1 亿格,占 300 MB。初始化一次 1.5 秒。这是内存换速度——查一个格子的材质是 O(1) 下标访问,比任何对象结构都快。
② 把所有静态体素塞进一个 InstancedMesh
一万八千个方块,一次 draw call 画完。不是"每栋楼一个 mesh",是"整个城市一个 mesh"。
③ 破坏 = 把实例矩阵缩放归零,并把下标还回空闲池
④ 碎片独立成池,落地后"固化"回静态体素
爆炸产生的碎片有 2400 个槽位,飞完落地静止,就把它栅格化写回世界数组、复用空闲池。
这一步是整个项目最漂亮的设计:容量守恒。不管你怎么炸,总实例数不涨。炸碎的楼变成地面的废墟,废墟就是新的静态体素。所以我可以炸一整天,帧率不会因为"碎片越积越多"而崩。
⑤ 整栋坍塌 = 把剩余体素全部转成飞散碎片
破坏率超过阈值,剩下的部分不再是静态格子,整体转成刚体块往下倒。
到后期这套东西长成了一个四层倒塌引擎:
外加一个形态选型层:根据几何条件(切口高度比、足迹覆盖率、高宽比、支撑偏心)自动选12 种倒塌形态之一。不掷骰子——同一个切口条件,永远塌出同一个形状。
55 轮听起来很猛,实际的组织方式非常朴素:
一轮 = 一件大事。
因为改一轮的成本是这样的:
一轮只做一件事,是为了让第 5 步的"数字变化"能被唯一归因。一轮改三个系统,测试出问题你不知道是哪个引起的。
另外我养成了一个习惯叫"攒需求模式":想到什么都先记下来,不动手,等攒够了说一句"开始改",一次性实现。这样避免"边做边改"把全局状态搅乱。
这是全文最重要的一节。
我遇到的最大障碍不是代码写不出来,而是我没办法用眼睛验收。
游戏是视觉产品,但我的工作方式决定了:截图我读不了。而且在这个项目里,msedge --screenshot 抓 WebGL 内容本身就不可靠——合成时可能丢帧,抓出来是黑的。
那我怎么知道"楼塌得对不对"、"音效有没有响"、"帧率有没有掉"?
答案是:把渲染管线的关键数值暴露出来,直接读回。
不用系统自带浏览器之外的东西。Windows 上自带 Edge,用 headless 模式就够了,而且能用真实 GPU——这点很关键,因为软件光栅(SwiftShader)和真 GPU 差两个数量级,任何性能结论都必须来自真 GPU。
① 接管 rAF——否则动画冻在第 2 帧
headless 下原生 requestAnimationFrame 不会持续触发,游戏循环会卡死在头两帧,你测的全是假数据。
② 短路渲染——但这里有个巨坑
测游戏逻辑的时候(比如"坍塌流程对不对"),渲染是纯浪费。所以我短路渲染。
我第一版这么写:
看起来对,实际上一点用都没有。因为 three.js 的 render 是实例方法——它在构造函数里就 this.render = function(){} 赋值的。覆盖原型对实例毫无影响。
后果非常恶劣:我所有声称"已跳过渲染"的测试,其实一直在跑完整渲染。我看到"慢"是渲染慢,不是逻辑慢,于是差点去优化错误的代码。
正确做法是包装构造函数,在实例上打补丁:
判断方法:短路之后帧耗时应该掉一到两个数量级。没掉,就是没生效。
③ 结果回传——把数字写进 DOM 再读回来
然后用 --dump-dom 抓回来,或者更稳的话——起一个本地 HTTP 服务让页面 fetch 回传。后者能用真实时间跑,还能顺便抓显卡型号:
性能数据必须和显卡型号绑在一起才有意义。我这台是 Intel UHD 核显——所以那些"30~59 ms/帧"的数字,参照系是核显。
这一条帮我定位了至少五个 bug。
用户说"坦克上不去"、"车掉下来了"、"冲击层不触发",我去看最终状态什么都看不出来。但只要逐帧打日志,问题立刻现形。
最典型的一个:"泰坦尼克号迟迟不去救援"。
我第一反应是"它卡住了"。加了逐帧轨迹探针之后:
指标 | 实测 |
|---|---|
出场那一帧 | 状态立刻切到出发(一帧都没停) |
停顿帧数 | 0 |
出场位置到待泊点 | 301 单位 |
航速 | 7.0 单位/秒 ⇒ 要开 43 秒 |
它根本没停,它只是慢。如果我不逐帧看,我会去查一个不存在的"卡住"逻辑。
要验证"核爆会闪屏、会震屏",直觉做法是在爆炸函数里读累加器。
这一定是假阳性。
因为页面主循环的顺序问题:爆炸事件可能排在相机更新之后,它设的震屏值会一直留到下一帧。于是你在帧末读到的永远是非零——看起来像震了,其实根本没进渲染。
正确做法:在消费端计数。
改完之后立刻分真假:改前"核爆前也有 28 帧抖动",改后"核爆前 shakeApply 0 / flashPaint 0,尽管同期已累计 44 次爆炸"。
判据:
这个 bug 藏了 10 轮没被发现。
我写了个测试夹具叫 rescueDockNow(),用来验证救援船靠岸。它直接设状态 st = 1,跳到靠岸那一步。
于是每次都"通过"。但真实路径里有一步烂掉了:
靠岸判据是 Math.abs(u.ang - want) < 0.06——角度差没做归一化。目标角是 +π,船从 −115° 转过来最终停在 −π。+180° 和 −180° 是同一个方向,但 |−π − π| = 2π ≈ 6.28,条件永远不成立。
结果是:泰坦尼克号贴到岸边之后永远卡住、不靠泊、不接人——整条救援链在最关键的一步断掉。
而我的测试一直显示绿灯,因为夹具把这一步整个跳过去了。
教训:夹具必须走真实入口。 一个绕过真实路径的测试,比没有测试更危险——它给你虚假的安全感。
做到 55 轮,坑基本都集中在这几族。它们共同的特点是:不报错、不崩溃、node --check 也通过,只是悄悄失效。
坑 | 症状 | 修法 |
|---|---|---|
JS 负数取模 | arr[i % n],i 为负 → 索引负 → undefined → 颜色变 0 → 整栋建筑只剩外壳 | Math.abs(i) % n |
"静默覆盖"(栽了 7 次) | 新写的调试对象键 / 顶层 function 撞上文件后面 17k 行处的同名项,后声明的赢;新加的条件被整段丢掉 | 写进构建脚本硬拦:扫重复键 + 重复顶层 function 声明,重复就退出并打印行号 |
字段名写错不报错 | c.dir 写成 c.dirO → undefined → NaN → 循环一次都不进 | 凡筛选/计数处,先回报"筛掉几个、留几个" |
InstancedMesh.count 默认 = 容量 | 预留的槽位照样过顶点着色器,白跑 8~21% | 显式 mesh.count = 实际用量 |
指针锁定返回 rejected Promise | 无用户手势时 requestPointerLock() 抛未捕获承诺 | 必须 .catch 兜底 |
删除体素要补"暴露面" | 楼是实心的,切面下面是内芯格 → 只删不补 → 切面 32% 透空 | 水平切面补正下方那格 |
掩码查询越界 | 负下标 → undefined,而 !undefined 为真 → 块一步都不掉 | 四邻查询限制在轮廓数组范围内 |
探针用世界坐标反推格号 | 浮点在 .5 上翻车,差一格,读到隔壁列 | 让页面暴露格号本身,探针只压缩输出 |
最值得说的是"静默覆盖"。它的第二种形态特别阴:补丁脚本里"替换区间"的结束标记会保留在结果里,新串又抄了一遍同一个函数 → 文件里出现两份 → 后声明的赢。语法检查通过、运行不报错,只是新加的条件整段消失。
我的解法是不靠记性,写进构建脚本:
而且一定要用"故意造一个重复"验证它真的会拦——看到退出码非 0 才算数。否则你只是加了一段自己都没验过的代码。
这台机器(Intel UHD 核显)的噪声大到你无法想象。
同一份构建、同一个场景、同样的测试,avgSyncMs 测出过 2.08 和 14.17——差 7 倍。
更离谱的是它会在两个状态之间整体翻转:同一份构建同样的墙钟时长,帧数会是 ~850(快态,12ms) 或 ~480(慢态,38ms)。
所以我总结出这几条:
① 绝对帧耗时跨运行不可比。只有同一次运行内的相对值有意义。
② 顺序有偏差——谁先跑谁快。实测 4 对全部一致:先跑的那次 22.727.5ms,后跑的那次 26.434.5ms。差值大到盖过版本差。所以必须交错 A/B,各测 3~4 次,比中位数。
③ 用帧数当"状态指示灯"。帧数 > 700 是快态(数据可用),600~700 是过渡(丢弃),< 600 是慢态(数据不可用)。
有一次交错 A/B 里,A 组是 12.77 / 12.85 / 12.10(帧数 802/856/874),B 组是 29.53 / 12.03 / 39.06(帧数 603/867/479)。B 组中间那次 867 帧的样本才是有效数据——它和 A 一样快。不看帧数,我差点得出"改动慢了 3 倍"的错误结论。
④ 拿不到绝对帧耗时的时候,改比"与机器状态无关的间接量":
renderer.info.render.calls(draw call)这些量同一份构建永远同一批数字,可以直接判定"改动变重了还是变轻了"。
我第 43 轮就是这么验证的:机器全程慢态,一个快样本都拿不到。但实例数从 48.0 万降到 43.9 万——渲染负载明确下降了,结论成立。
⑤ 想知道"是哪个物体吃掉帧率",就把它单独关掉再测。
靠这招我定位到过一次:以为水面拖性能,关掉水面毫无变化——真凶是远景里整座城市 40 万方块全部以亚像素三角形光栅化(≈480 万三角形)。别靠猜,开关一下就知道。
这一点对所有做游戏的人都成立,用 AI 也一样。
第一版的手感参数是我凭感觉写的。全部推翻重调了三轮:
参数 | 最终值 | 调它的原因 |
|---|---|---|
碰撞半径 | 3.9 | 要对齐飞机的翼展,不能是"差不多" |
转向速率 | 2.1 | 1.55 时手感太钝 |
巡航/加力速度 | 34 / 62 | 拉开"平飞"和"加速"的体感差 |
初始高度 | 10.5 | 13 太高,只在楼顶擦过,撞不到东西 |
"手感太钝"这种判断,AI 不会替你做。你只能玩,然后改数字,再玩。
我还学到一件事:"明显性"要按玩家实际视角设计。
我做了个直升机侧面的火箭巢,想让它在视觉上"明显一点"。第一版把功夫全花在朝前那一面——巢口、面板、发射管。
结果玩家看的是机尾后上方的追尾相机。那些东西根本不在视野里。
我用一个夹具把每个部件的 8 个角用当前相机投影到屏幕,算各占多少像素:
部件 | 屏幕像素(1100×700,相机距 14.3) |
|---|---|
机身 | 43×75 |
巢体(单个) | 32×45 |
前端面板(朝前,尾视角看不到) | 27×25(占屏幕 0.0x%) |
发射管(单根) | 改前 1×2 px → 改后 6~9 px |
先问"玩家从哪个角度看",再决定在哪一面做文章。
顺带一个元教训:这个测量夹具自己也错了。第一版把角点写成 (sx*0.5*a, ...),但缩放已经写在实例矩阵里了 → 双重缩放,0.15 的发射管量出 1 像素。
"测量工具本身也可能是错的"——先用已知尺寸的物体对一遍量纲。
有一次我反馈"楼的外墙看起来像好多块板飘在空中"。
我的直觉是"体素缺失了,程序漏了格子"。
但我先量了数据——加了个探针,把体素数组暴露出来,逐栋楼扫外墙一圈统计空格数。结果:
真正的元凶是配色与抖动:玻璃占墙面 20.8%,且窗格按 (x+z) % 1.6 算,在各面墙上错位;再加上每个体素都做 0.90 + rand*0.20 的随机明暗——整面墙被碎成深浅不一的贴片。
教训:用户说"东西缺了一块"时,先量数据再改代码。这次量出来和直觉完全相反——不缺几何,缺秩序。
回到开头那个问题。
用 AI 做游戏,AI 写代码不是瓶颈。2 万行的单文件,55 轮的迭代,四层倒塌引擎,六十艘船的编队——这些它都写得出来。
真正的瓶颈是验收:
这些方法没有一条是"AI 技巧",全是工程纪律。但它们决定了我能不能在 55 轮之后还保持清醒。
如果你也想用 WorkBuddy 做游戏,我的建议:
工具:WorkBuddy(桌面端)· 技术栈:three.js + WebGL2 + WebAudio · 验证:系统自带 Edge headless + 探针注入
#WorkBuddy #AI办公 #效率工具 #游戏开发
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。