首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >我把一个体素破坏游戏迭代了 55 轮,才明白做游戏最难的不是写代码

我把一个体素破坏游戏迭代了 55 轮,才明白做游戏最难的不是写代码

原创
作者头像
用户12804376
发布于 2026-10-07 18:23:10
发布于 2026-10-07 18:23:10
130
举报

我是一个业余做游戏的人。今年 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 个槽位,飞完落地静止,就把它栅格化写回世界数组、复用空闲池。

这一步是整个项目最漂亮的设计:容量守恒。不管你怎么炸,总实例数不涨。炸碎的楼变成地面的废墟,废墟就是新的静态体素。所以我可以炸一整天,帧率不会因为"碎片越积越多"而崩。

⑤ 整栋坍塌 = 把剩余体素全部转成飞散碎片

破坏率超过阈值,剩下的部分不再是静态格子,整体转成刚体块往下倒。

到后期这套东西长成了一个四层倒塌引擎:

  1. 结构层——柱网承压判定(拆光幕墙不倒,打断一根柱就断)
  2. 刚体层——斜倒、翻滚、落地固化
  3. 冲击层——动量传给邻居,多米诺(楼撞楼)
  4. 颗粒层——废墟的安息角与逐格下沉

外加一个形态选型层:根据几何条件(切口高度比、足迹覆盖率、高宽比、支撑偏心)自动选12 种倒塌形态之一。不掷骰子——同一个切口条件,永远塌出同一个形状。


三、迭代节奏:一轮只做一件大事

55 轮听起来很猛,实际的组织方式非常朴素:

一轮 = 一件大事。

因为改一轮的成本是这样的:

一轮只做一件事,是为了让第 5 步的"数字变化"能被唯一归因。一轮改三个系统,测试出问题你不知道是哪个引起的。

另外我养成了一个习惯叫"攒需求模式":想到什么都先记下来,不动手,等攒够了说一句"开始改",一次性实现。这样避免"边做边改"把全局状态搅乱。


四、核心:我看不到画面,所以我把"看"换成了"量"

这是全文最重要的一节。

我遇到的最大障碍不是代码写不出来,而是我没办法用眼睛验收。

游戏是视觉产品,但我的工作方式决定了:截图我读不了。而且在这个项目里,msedge --screenshot 抓 WebGL 内容本身就不可靠——合成时可能丢帧,抓出来是黑的。

那我怎么知道"楼塌得对不对"、"音效有没有响"、"帧率有没有掉"?

答案是:把渲染管线的关键数值暴露出来,直接读回。

不用系统自带浏览器之外的东西。Windows 上自带 Edge,用 headless 模式就够了,而且能用真实 GPU——这点很关键,因为软件光栅(SwiftShader)和真 GPU 差两个数量级,任何性能结论都必须来自真 GPU。

4.1 三个必须做的接管

① 接管 rAF——否则动画冻在第 2 帧

headless 下原生 requestAnimationFrame 不会持续触发,游戏循环会卡死在头两帧,你测的全是假数据。

② 短路渲染——但这里有个巨坑

测游戏逻辑的时候(比如"坍塌流程对不对"),渲染是纯浪费。所以我短路渲染。

我第一版这么写:

看起来对,实际上一点用都没有。因为 three.js 的 render 是实例方法——它在构造函数里就 this.render = function(){} 赋值的。覆盖原型对实例毫无影响。

后果非常恶劣:我所有声称"已跳过渲染"的测试,其实一直在跑完整渲染。我看到"慢"是渲染慢,不是逻辑慢,于是差点去优化错误的代码。

正确做法是包装构造函数,在实例上打补丁:

判断方法:短路之后帧耗时应该掉一到两个数量级。没掉,就是没生效。

③ 结果回传——把数字写进 DOM 再读回来

然后用 --dump-dom 抓回来,或者更稳的话——起一个本地 HTTP 服务让页面 fetch 回传。后者能用真实时间跑,还能顺便抓显卡型号:

性能数据必须和显卡型号绑在一起才有意义。我这台是 Intel UHD 核显——所以那些"30~59 ms/帧"的数字,参照系是核显。

4.2 逐帧日志 > 只看终态

这一条帮我定位了至少五个 bug。

用户说"坦克上不去"、"车掉下来了"、"冲击层不触发",我去看最终状态什么都看不出来。但只要逐帧打日志,问题立刻现形。

最典型的一个:"泰坦尼克号迟迟不去救援"。

我第一反应是"它卡住了"。加了逐帧轨迹探针之后:

指标

实测

出场那一帧

状态立刻切到出发(一帧都没停)

停顿帧数

0

出场位置到待泊点

301 单位

航速

7.0 单位/秒 ⇒ 要开 43 秒

它根本没停,它只是慢。如果我不逐帧看,我会去查一个不存在的"卡住"逻辑。

4.3 计数必须放在"效果点",不能在"事件点"

要验证"核爆会闪屏、会震屏",直觉做法是在爆炸函数里读累加器。

这一定是假阳性。

因为页面主循环的顺序问题:爆炸事件可能排在相机更新之后,它设的震屏值会一直留到下一帧。于是你在帧末读到的永远是非零——看起来像震了,其实根本没进渲染。

正确做法:在消费端计数。

改完之后立刻分真假:改前"核爆前也有 28 帧抖动",改后"核爆前 shakeApply 0 / flashPaint 0,尽管同期已累计 44 次爆炸"。

判据:

  • 关掉的效果,计数应当恒 0(不是"很小")
  • 打开的效果,计数持续增长
  • 时间窗效果,窗口关闭后增量必须为 0

4.4 最狠的一条:夹具绕过真实路径 = 假绿

这个 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 像素。

"测量工具本身也可能是错的"——先用已知尺寸的物体对一遍量纲。


八、还有一条思维习惯:用户说"缺了一块",先量数据再改代码

有一次我反馈"楼的外墙看起来像好多块板飘在空中"。

我的直觉是"体素缺失了,程序漏了格子"。

但我先量了数据——加了个探针,把体素数组暴露出来,逐栋楼扫外墙一圈统计空格数。结果:

  • 107 栋楼、13.8 万个墙面格子,外墙上一个洞都没有
  • 全城 6742 个"洞"全部来自地标塔的阶梯式收窄——那是造型,不是 bug

真正的元凶是配色与抖动:玻璃占墙面 20.8%,且窗格按 (x+z) % 1.6 算,在各面墙上错位;再加上每个体素都做 0.90 + rand*0.20 的随机明暗——整面墙被碎成深浅不一的贴片。

教训:用户说"东西缺了一块"时,先量数据再改代码。这次量出来和直觉完全相反——不缺几何,缺秩序。


结语:做游戏真正难的,是"知道它对不对"

回到开头那个问题。

用 AI 做游戏,AI 写代码不是瓶颈。2 万行的单文件,55 轮的迭代,四层倒塌引擎,六十艘船的编队——这些它都写得出来。

真正的瓶颈是验收:

  • 我看不到画面,所以我把"看"换成"量"
  • 我的测试夹具可能绕过真实路径,所以我强迫它走真实入口
  • 我的机器噪声大,所以我只信同一次运行内的相对值
  • 我的计数可能读在错误的位置,所以我把计数放到效果点

这些方法没有一条是"AI 技巧",全是工程纪律。但它们决定了我能不能在 55 轮之后还保持清醒。

如果你也想用 WorkBuddy 做游戏,我的建议:

  1. 先钉死交付形态(单文件、零依赖、双击即玩)
  2. 一轮只改一件事,让数字变化能被唯一归因
  3. 第一天就把验证探针搭起来——不要等到出了 bug 才想怎么测
  4. 相信数字,不相信直觉。尤其是"这个东西应该没问题吧"的时候

工具:WorkBuddy(桌面端)· 技术栈:three.js + WebGL2 + WebAudio · 验证:系统自带 Edge headless + 探针注入

#WorkBuddy #AI办公 #效率工具 #游戏开发

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先把"能玩"的边界钉死,再谈玩法
  • 二、架构:数据和渲染必须分家
  • 三、迭代节奏:一轮只做一件大事
  • 四、核心:我看不到画面,所以我把"看"换成了"量"
    • 4.1 三个必须做的接管
    • 4.2 逐帧日志 > 只看终态
    • 4.3 计数必须放在"效果点",不能在"事件点"
    • 4.4 最狠的一条:夹具绕过真实路径 = 假绿
  • 五、踩坑清单(都是真栽过的)
  • 六、性能:只信同一次运行内的相对值
  • 七、手感是"调"出来的,不是"想"出来的
  • 八、还有一条思维习惯:用户说"缺了一块",先量数据再改代码
  • 结语:做游戏真正难的,是"知道它对不对"
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档