用 AI 做游戏:14 轮迭代踩出来的坑

在线试玩:https://thunder-strike.app.workbuddy.host/(网页版,拖动操控、自动开火、轻点清屏) 源码:https://github.com/yyl1208/ai-dump/tree/main/thunder-strike

这份文档里的每一条都是真踩过的,不是理论推导。

项目背景:thunder-strike,一个雷电(Raiden)风格的竖版弹幕射击微信小游戏。 零依赖 Canvas 2D 手写,没有游戏引擎,运行时 163.7 KB(微信 4MB 主包的 4.00%)。 31 种差异化敌人、6 条强化路线、13 种 Boss 弹幕、4 把可切换武器。

从第一行代码到能上线分享,一共迭代了 14 轮。几乎每一轮都至少有一个真 bug, 而其中大部分不是代码写错了,是"设计看着对、跑起来悄悄变了味"。


零、先把结论说在前面

如果只记一句话,记这条:

AI 做游戏,最贵的不是写代码,是验证。 让 AI 生成一段弹幕逻辑可能只要 10 分钟;判断"这段行为到底对不对"要花掉半天。 而更贵的是第二层:判断"我用来验证的那条断言,是不是真的在验证东西"。

这不是危言耸听。这个项目里:

  • 有一次 bug 让"点开始游戏白放一颗炸弹",它藏了很久才被发现——因为没有人会觉得点个开始按钮会消耗道具
  • 有一条断言在仓库里躺了两周,一直显示通过,直到某天它随机失败才被发现:它从来就没验证过它以为在验证的东西
  • 有一次数值调整,让后期关卡的敌人子弹被静默丢弃——界面上看起来是"难度很高",实际是池子满了,新子弹根本没生成

这三条的共同点是:它们都不报错。程序跑得很正常,测试全绿,只有玩家的手感在变差。

所以这份文档的重点不在“怎么写游戏”,而在“怎么让 AI 写出来的游戏不变质”。


一、需求阶段:先把人话翻译成指标

坑 1:直接按用户说的做,而不先翻译成可测量的量

用户会说:"太难了"、"Boss 太软"、"子弹太小"、"不能让他一直玩"。

这些全是体感描述,不是需求。如果直接照着字面去做,你会得到一个结果分布很宽、 无法验收的东西——做完也不知道到底算不算做好了。

正确做法是把每一句都翻译成硬指标:

用户说 翻译成 典型目标
太脆 / 没手感 单怪 TTK(击杀耗时) 0.4~2.5s,低于 0.15s 就是"出场即蒸发"
难度要线性递增 同一张关卡表内逐关压力单调 100% 递增,不允许台阶或回落
不能一直玩 中坚兵 TTK 超过阈值的关卡号 街机类约第 1520 关,单局 2025 分钟
后期子弹更密集 同屏敌弹数 + 每发弹数 手机上 250~350 发是"密集",800+ 是"没法看"

关键细节:TTK 必须用该关玩家的实际 DPS 算,不能用满级 DPS。 玩家输出会封顶(满级 + 满专精),敌人血量指数增长,两者的交叉点才是真正的"血量墙"所在关卡。 用满级 DPS 算出来的"墙"是假的。

坑 2:用户说的数量和他的真实诉求不一致,闷头做不吭声

用户原话:"每关设计一个送道具的怪,一碰就碎那种。"

同一个诉求里其实压着另一个:"现在这个难度有点太难了。" 我把投放量做成了每波 1 个(也就是每关 3 个),而不是字面上的每关 1 个。 理由是:他的核心诉求是降低难度,送道具的频率直接服务于这个目标。

但没有闷头做——做完后明确告诉他:你原话是"每关一个",我实际做成每波一个(每关 3 个), 理由是难度诉求,改回一行配置的事,CONFIG.supply.perWave = 1。

这条经验的一般形式是:当字面需求和推导出的真实目标冲突时,按真实目标做,但一定要说。 沉默地"优化"用户的需求,是 AI 协作里最容易失控的一步。

坑 3:没问清楚就开写

有一轮用户的原话是:"boss 形态多设计,比如优先攻击左右两边。"

看起来很明确。但真正动手前必须问清三件事:

  1. 关卡结构:后期是"越来越难",还是"设一个终点通关"?
  2. 擦弹机制(贴着子弹飞积攒能量):保留还是完全删除?
  3. 血量整体:偏脆(爽快)还是偏硬(TTK 0.4~2.5s)?

这三个答案会让同一个"Boss 多做几种形态"的实现完全不同。用 AskUserQuestion 花 30 秒问清楚,比重写三遍便宜得多。


二、数值设计:四个让游戏悄悄变质的陷阱

这一节是纯数值的,和 AI 关系不大——但正因为 AI 生成数值的速度极快, 这些陷阱会被指数级放大。人和 AI 一起改十个参数,比人单独改十个参数更容易翻车。

陷阱 1:难度系数是连乘的,会把对象池打满

这是这个项目最贵的一课。

弹幕游戏的压力来自四个系数相乘:敌人数量 × 每发弹数 × 射速 × 每波组数。

初版配置看着每项都很温和:"每项都 +80%"。但连乘起来:

1.8 × 1.95 × 2.6 ≈ 9.3 倍

后果是:第 8 关开始,同屏敌弹顶到对象池上限(900),新子弹被静默丢弃。

请注意这里的性质——这不是难度,这是 bug。玩家看到的是"后期密得离谱", 开发者的直觉反应是"可能有点难,调一下",于是继续往上调参数, 实际在做的事是:用更高的数字去补偿一个丢失功能的 bug。

而且它还有第二个后果没那么显眼:900 发子弹 × 每个 3 条弧线填充 ≈ 2700 次绘制调用/帧, 低端安卓直接掉帧。玩家以为游戏卡,其实是参数配错了。

修法(写进基准工具的输出里):

同屏敌弹峰值 / 池上限     ← 顶到就是事故

调参目标:峰值压到池上限的 90% 以下,让池子只当兜底,不当常态。 最终把四项收到 1.8 × 1.45 × 1.5 ≈ 4.8 倍才安全。

防复发:把"峰值/池上限"做成压测脚本的固定输出行,每次调完参数看一眼。 这件事不能靠"感觉好像能跑"。

陷阱 2:循环利用关卡表 = 锯齿,不是斜坡

只有 3 张关卡表轮换,而各张表的"开火密度"天生差十几倍 (第一张表是新手表,怪几乎不开火)。于是实际难度曲线长这样:

8 → 63 → 53 → 9 → 65 → ...

每轮回一圈,难度掉回新手村。玩家会感到"莫名其妙突然变简单"。

修法:循环加压——每完成一圈,给每一波追加一组高压怪(射手型),并且必须封顶叠加次数:

CONFIG.reinforce = { startLoop: 2, stackMax: 2, groups: [...] };

不封顶的线性叠加迟早又把池子打满(回到陷阱 1)。

验收注意:要看的是同一张表内是否单调。跨表的起伏是刻意设计的手感节奏,不算问题。

陷阱 3:参数之间偷偷耦合

实例:循环加压的强度写成——

// 错:加压强度直接取总圈数,于是改 startLoop 会顺带改掉单圈强度
const stacks = Math.min(this.loops, R.stackMax);

// 对:从"加压起始圈"起算,两个参数解耦
const stacks = Math.min(this.loops - R.startLoop + 1, R.stackMax);

这两个参数本来应该各管一件事:startLoop 管"从第几圈开始加压", stackMax 管"最多叠几层"。但写成前者之后,把 startLoop 从 1 改成 2, 首圈的加压强度会顺带从 +1 轮变成 +2 轮——你在调 A,B 悄悄变了。

判据:改 A 参数时,B 参数描述的现象应保持不变。做不到就是耦合了。

连带教训:测试要按配置算期望值(atLoop(R.startLoop)),不要硬编码圈号。 硬编码的断言在参数一变就报假故障,反而会误导你去改本来正确的代码。

陷阱 4:改了模型权重,阈值规则"静默失效"

这是最阴的一种,因为它完全不报错。

掉落掉落系统用"威胁值"分档:weakThreat 以下算杂兵(偏发回血道具), eliteThreat 以上算精英(偏发武器道具)。它们是绝对数。

后来调整了 threatOf() 的血量权重(0.5 → 0.12),威胁值整体刻度平移:

改之前:威胁值分布的 min ≈ 6.58 / 中位 ≈ 17.7 / max ≈ 40.8
而 drop.weakThreat 还是 5

5 已经掉到分布最小值以下了。于是"杂兵偏发回血"这条规则再也不会命中—— 不是报错,是它悄悄不再生效了。玩家永远不会察觉这回事,只会觉得"掉落好像有点怪"。

修法:改完任何权重,重跑一次全怪种威胁值分布,按分位数重设阈值:

weakThreat  → 取 p25 附近
eliteThreat → 取 p75 附近

并且必须有断言护住:"打杂兵时回血权重 > 打精英时回血权重"。 有这条断言托底,刻度一平移立刻变红。


三、AI 生成代码的特有坑

这一节是人类单人开发不太会遇到、但 AI 协作一定会遇到的。

坑 4:AI 改代码会破坏文件结构,而它在 CRLF 文件上更不可靠

真实事故:给 startWave() 函数末尾追加一段"生成补给舱"的代码。 用 IDE 的跨行替换,在 CRLF 换行的文件上匹配不可靠, 结果那段代码被插到了函数的闭合括号之外,直接 SyntaxError。

更麻烦的是,这类破坏不一定当场炸。有些错位会安静地产出一段"语法正确但逻辑不执行"的代码。

硬性规则:每插一块代码就立刻 node --check(或等价的全量语法检查)。 这条要写成流程,不能靠"我记得检查"。

坑 5:AI 删功能永远删不干净

"删除擦弹机制"是一个跨策划+实现+UI 的动作。当时修完自以为删干净了, 结果两周后在三个地方发现残留:

位置 残留内容
src/ui.js 玩法页 还有一行「擦弹:贴着敌弹飞可充能,攒满换一颗炸弹」
src/boot.js 分享卡片 文案还是「拖动操控 · 自动开火 · 贴弹擦弹充能」
src/config.js 引擎线的描述还写着「移速 / 擦弹范围 / 充能」
index.html 底部提示 「贴弹飞可擦弹充能」

这三处没有任何一处报错。它们只是安静地对玩家撒谎: 告诉玩家一个不存在的机制,玩家照着做,没反应。

防复发:删除任何机制时,先全局搜它的关键词(中文名 + 英文变量名), 把结果列表当成 TODO 清单逐条对齐。不能只在代码逻辑里搜,UI 文案和配置文件同样要搜。

同样的道理反过来也成立:加功能时也要 checklist。加一个数值机制, 要考虑它在"每一帧 / 每关 / 每局 / 重开"四个时机分别怎么重置——下面这条就是漏了这个。

坑 6:状态初始化漏了,症状是"偶发 NaN"

// this.frame 未在 reset() 里初始化
// 结果:激光同帧去重的 _laserFrame 首帧为 NaN,去重逻辑静默失效

还有一次:

// const self = this 写在了 if 块内部
// 结果:emitOptions 回调报 self is not defined —— 但只在特定分支才炸

这类问题的共同点是:它只在特定路径触发,所以冒烟测试很容易漏。 而且 AI 生成代码时特别容易犯——因为 AI 是"增量地"往已有函数里加东西, 它对"这个函数开头应该有什么"的记忆不如对整个函数的完整认知可靠。

防复发:冒烟测试里要有"跑完整流程"的那一组(不是只测单个函数), 至少跑到第 3 关以上。这两条 bug 都是在流程压测那组里被抓出来的。

坑 7:魔法数字会随着平台移植一起爆炸

早期代码里写死了 1334(iPhone 6 的设计高度)和一堆手工算出来的像素坐标。

移植到微信小游戏时全炸了:不同手机有不同的安全区(刘海、Home 指示条), 纵向分辨率从 1334 到 2000+ 都有。

更隐蔽的一处:HUD 的路线 chips 用了手算的 208px 宽度, 而字体宽度在不同平台不一样——同一段代码在 web 上对齐,到安卓机上就串行。

修法:

  • 布局统一走 uiLayout(view),按安全区 + 屏幕底部对齐,不写死高度
  • 任何依赖文字宽度的排版,改成固定步长或者用 measureText 真测

坑 8:绘制尺寸和判定尺寸是两套体系

这一条非常典型,也是很多新手/AI 容易做错的地方。

原 collide() 的实现里,敌弹判定半径是硬编码 5:

// 原实现
d2 <= (pr + 5) * (pr + 5)   // 5 是硬编码的,跟绘制尺寸完全无关

后果:子弹画得再大,判定范围也不变。玩家看到一颗巨大的子弹从身边飞过, 理智告诉他这肯定碰到了,结果判定说没事——或者反过来,一颗看起来很小的子弹莫名其妙崩了他。

这种事对玩家的杀伤力极大,因为它破坏了视觉和操作的因果一致性。

修法:把半径打通成一条链路——

makeBullet 加 r 字段 → spawnBullet 按 opt.r 赋值
  → 受击圈 (hitR + b.r)
  → 绘制半径用同一个 b.r

然后配一条断言锁死:

// 同为 18px 直径:小玉 r5 只擦身而过,大玉 r13 直接命中

四、最重要的部分:AI 写的测试会"自证正确"

这一节是全篇的重心。

AI 有个很隐蔽的特性:它写的测试,倾向于证明它写的代码是对的。 因为测试是照着实现写的,而不是照着"应该是什么"写的。 结果是所有断言都绿着,但什么都拦不住。

下面三条是应对方法。

4.1 断言可能是"空过"的 → 用变异测试验证有效性

判一条断言有没有用,唯一可靠的办法是亲手把它该拦的 bug 造出来。

为此固化了一个工具 tools/mutate.js:故意改坏关键逻辑,看断言会不会响。

{ name: '位移容差改成逐轴判定(斜着拖动会被误判成轻点)',
  from: 'return dx * dx + dy * dy <= lim * lim;',
  to:   'return Math.abs(dx) <= lim && Math.abs(dy) <= lim;' }

判据:变异数 == 抓到数。有 MISSED 就说明那条断言没有拦截力,等于没写。

当前 8/8 CAUGHT,每条变异都精确打在对应的断言上:

CAUGHT [smoke.js] 位移容差改成逐轴判定
        斜向 11,11 直线距离 16 已超容差 14,按逐轴判定会误判成轻点
CAUGHT [smoke.js] 完全忽略 tapGuard 钩子
        按下时就该问一次 tapGuard,实际 0 次
CAUGHT [smoke-wx.js] 真机链路:点「开始游戏」白放炸弹
        点「开始」白放了一颗炸弹

两个实现要点:

  1. 每个变异都用 try/finally 保证逐字节还原,跑完和备份比对
  2. 走构建产物的测试套件,变异源码后必须先重新构建——否则测的还是旧代码,会得出"变异没被抓到"的假结论

4.2 断言可能随机翻车 → 用稳定性检查剿灭

真实事故:有一条断言 弹幕墙缺口太小,无法穿过,大约 17% 概率随机红灯(12 次跑挂 2 次)。

根因很有代表性:弹幕墙的缺口位置是随机的——

const gapStart = Math.floor(Math.random() * (w.count - w.gap));

gapStart 可以是 0 或边界值。缺口贴边时,通道在「屏幕边界 ↔ 第一颗弹」之间, 而断言只比较相邻子弹的间距(这时恒为 1 个格距),于是把合法情况误判成"没缺口"。

为什么这件事特别严重(比这条断言本身重要得多):

随机失败比不测更糟。 它会训练人忽略红灯——"哦这条老样子,重跑一下就好"。 更糟的是它会让变异测试出现假阳性:改坏的代码"被抓到了", 其实是另一个断言在随机翻车,而真正的漏洞在旁边笑。 这个项目里真的发生过,白排查了一整轮。

修法:改成按"玩家的判定点能不能钻过去"来测:

// 每颗弹在 x 轴上撑开一段禁区,在玩家中心的可达区间里找最宽空档
const bands = xs.map(x => [x - C.bossWall.r - hitR, x + C.bossWall.r + hitR]);
// 屏幕边界也当成候选通道:缺口可能开在贴边处

阈值卡在"一个格距",用变异验证过边界合理:gap=1 稳定拦下,gap=2/3 通过。

固化工具 tools/stability.js,反复跑套件揪出随机失败的断言:

node tools/stability.js 30                    # smoke.js 跑 30 次
node tools/stability.js 10 smoke-wx.js        # 小游戏端跑 10 次(自动重建)

修完后连跑 30 次,0 失败。

4.3 断言测的不是你想测的东西

这是五个具体的坑,每一个都发生过。

① 不要拿实测值去断言"不该变化"的量

有一版断言"第 11 关之后弹速不再上涨",用的是实测像素弹速。 但霰弹的弹速公式自带随机因子 speed * (0.75 + rand * 0.5),波动能到 30% ——于是 560 → 530 也会误报。

改法:改成确定性推导——直接断言系数封顶,且封顶关卡用公式算,别硬编码:

const capStage = Math.ceil((C.difficulty.speedMax - 1) / C.difficulty.speedGrow);
assert(at(capStage).speed === C.difficulty.speedMax);

② 多张关卡表轮换时,任何跨关比较都必须先确认是同表

曾经拿"第 2 关"和"第 4 关"比波次组数,看着像"没加压"。 实际它们属于不同的关卡表,基础组数本来就不一样。 正确做法是固定同一张表比较:groupCount(table * loop)。

③ 新增机制后,旧的统计函数要排除新机制的产物

加了"每波空投补给舱"之后,原来的"每组数量"统计把补给组也算进去了, 于是报出"新手关被加压了"的假故障。

④ 测试之间会互相污染

有一次测道具权重,怎么算都是错的。原因是前面的压测用例跑过一遍, 玩家已经吃到了各种道具(专精等级变了,弹数当然对不上)。 改成 freshGame().player 之后正常。

⑤ 断言的时机本身也可能是错的

这条放到下一节单独讲,因为它不只是测试问题,根子上是设计问题。

4.4 一个特别好用的测试手法:故意让环境“缺一块”

移植到微信小游戏时,怎么保证代码里没有偷偷依赖 DOM?

答案是写测试时 故意不定义 document:

// tools/smoke-wx.js 用 Node vm 伪造整套 wx API 来跑构建产物,
// 关键一招:全局故意不定义 document
// —— 只要哪里漏了 DOM 依赖,测试立刻抛错

比"人眼看一遍有没有用 document"可靠一万倍。 同理,dpr 故意给 3(验证封顶 2 的逻辑),wx.getSystemInfoSync 的老接口路径也专门测。


五、交互与手感:判定时机

这一节是一条通用法则,用一个真实 bug 讲。

坑 9:手势判定写在"抬手"而不是"按下",会跨状态错位

需求是"轻点屏幕放炸弹清屏",同时"拖动屏幕操控飞机"。同一个手势两种含义。

第一版把判定写在**抬手(up)**那一刻:位移没超阈值 + 时长够短 = 轻点。逻辑看着无懈可击。

但真机上:点「开始游戏」会白放一颗炸弹(开局 2 颗变 1 颗)。

根因时序:

1. 手指按下         → 记录基准点,pressed=true
2. 主循环消费按下   → 命中"开始游戏"按钮 → 进入战斗 → 画面重绘,
                      菜单按钮的命中区被 UI.resetHits() 刷掉
3. 手指抬起         → 此时判定"这次点了什么",
                      但按下的那个按钮的命中区已经不存在了
                      → 排除逻辑失效 → 判定为"点在空处" → 放炸弹

核心教训:

输入判定类逻辑(手势、按钮、状态机)必须在"按下/触发"那一刻定状态, 不能等抬手、也不能等下一帧。中间会被画面切页、命中区刷新打断。

修法:输入层暴露钩子,让游戏层在按下的那一刻回答"这次手势算不算":

// platform.js(输入层,不认识 UI)
if (!wasActive) {                      // wasActive:多指时第二根手指不重置基准
  state.tapX = p.x; state.tapY = p.y; state.tapT = Date.now();
  state.tapMoved = !!(state.tapGuard && state.tapGuard(p.x, p.y));
}

// boot.js(游戏层,知道按钮在哪)
input.tapGuard = function (x, y) {
  if (state !== 'playing' || paused) return true;      // 非战斗页一律不算
  return UI.hitTest(x, y) !== null ||
         game.isBombButton(x, y) ||
         hitCircle(x, y, uiLayout(view).pause);
};

好处是语义正确:问题本来就是"按下时是不是按在按钮上",答案不会随时间变。

这个机制还有三个容易漏的细节:

  • 位移要按直线距离算(dx² + dy² <= lim²),不能逐轴算。 否则斜着拖一下,逐轴各自都在容差内,会被误判成轻点
  • 拖出容差就永久作废,中途拖回原点也不算。 只在抬手时看最终位移的实现,会让每次拖动结束都白送一颗雷
  • 系统打断要单独走取消分支:onTouchCancel / pointercancel (来电、下拉通知栏)必须直接丢弃,否则接个电话放一颗炸弹

坑 10:操控是相对还是绝对,决定了一个设计能不能成立

轻点清屏之所以能和拖拽操控安全共存,是因为操控是相对偏移:

// entities.js Player.update
if (!this.grab) {
  this.grab = true;
  this.gx = input.x; this.gy = input.y;   // 记录抓取点
  this.px = this.x;  this.py = this.y;
}
this.x = this.px + (input.x - this.gx);   // 按位移增量走,不是直接跳到手指位置
this.y = this.py + (input.y - this.gy);

如果是"绝对跟随"(飞机直接跳到手指位置),那么轻点边角区域会把飞机瞬间拽走, 这个功能就废了。

结论:设计交互功能前先看操控模型。同样的需求,在相对和绝对两种模型下可行性完全不同。

坑 11:UI 元素的坐标会悄悄打架

暂停按钮的位置 x=646,和第三颗血量心形的绘制位置 view.w-36-2*34 = 646 完全重合。两者互相点不到。

这一轮之前只测了"布局数值对不对",没测"元素之间会不会重叠"。

防复发:布局断言里加一组"关键 HUD 元素的包围盒互不重叠"。 这类问题肉眼很难发现(因为两个元素都在画,只是互相盖住)。


六、需要一开始就设计好的地方

前面都是"避免做错",这一节是"做对了会省很多事"。

好设计 1:数据驱动,别写 switch

30 种敌人如果是 30 个 switch 分支,加一个新怪要改 5 个文件。

实际做法是行为原语组合:

30 种怪 = 12 种 move × 14 种 attack × 3 种 trait
  move:   straight / weave / sine / drift / dive / retreat / rush / blink / orbit / hover / snipe / carrier
  attack: burst / ring / lob / trail / chain / charge / spread / spawn / arc / cross / spiral / mine / beam / shotgun
  trait:  armor(减伤)/ shield(先破盾)/ split(死亡分裂)

然后配一条断言防止退化成换皮:

// comboKey = move + attack + trait + salvo + laser + ring + burst + pellets
assert(new Set(keys).size === 30, '30 种怪的行为签名必须全唯一');

这条断言的价值在于:它把"不许偷懒改成换皮"变成了机器检查,而不是靠自觉。

好设计 2:两个可复用的数值模型

A. 角色系数推导法

给每种敌人一个"角色定位"(杂兵 / 标准 / 压制 / 刺客 / 坦克 / 炮台 / 狙击 / 母舰), 然后用公式生成具体数值:

hp    = 5 × role.hp × scale
score = 30 × role.score × scale

role.hp:  杂兵 .3 / 标准 1.0 / 压制 1.4 / 刺客 .5 / 坦克 2.6 / 炮台 2.2 / 狙击 1.2 / 母舰 5.2

好处:数值天然自洽。当时验证过——8 个怪里有 5 个的现存 hp 和公式完全吻合, 说明这套系数是可信的,只需要调整分数部分。

B. 威胁值模型(替代"数怪的个数"来配平波次)

Threat = hp×0.12 + (1000/fireIv) × 单波弹数 × 0.8 + speed/100 + 特殊项
特殊项:分裂 +8 / 增援 +6 / 停驻 +2 / 蓄力 +3 / 爆裂 +3 / 尾迹 +2

实测分布:scout 6.6 → hive 40.8。

注意这里 hp 的权重是 0.12,早期是 0.5。改权重的代价见陷阱 4。

好设计 3:平台差异收敛到唯一一个文件

src/platform.js 是唯一允许出现 wx / tt / document 的文件。

这条规矩带来的收益是巨大的——最后移植到微信小游戏时连 weapp-adapter 都没装。 原因是运行时从头就是"零依赖 + 纯 Canvas 2D 手绘 + 无 DOM 调用"。 adapter 是给依赖 DOM/BOM 的第三方库擦屁股的,自己写的代码没这个包袱。

这个项目里 Platform 层要处理的差异:

事项 处理
新旧接口兼容 getWindowInfo() 优先,回落 getSystemInfoSync()(基础库 2.20 前后)
dpr 封顶 2,3x 屏全分辨率画满屏弹幕会掉帧
安全区 safeArea.top / windowHeight - safeArea.bottom 换算成设计坐标
字体 小游戏返回 sans-serif;system-ui 在部分安卓机上解析失败变方块
生命周期 切后台暂停 + 停 rAF,回来重新对齐时间基准(否则回来 dt 爆炸)
折叠屏/旋转 onWindowResize → applyResize(),view 是引用,游戏内自动跟随

好设计 4:把有争议的设计选择变成配置项

不要把一个"你觉得对、但别人可能不认同"的取舍写死在代码里,把它做成配置:

CONFIG.player.autoBombSave = 0   // 每局代劳上限,0 = 不代劳,设 1 即新手保护

这个配置的来历很有意思。最初 hurtPlayer() 里写死了"残血且有炸弹就自动放", 结果只要有炸弹就无限保命——炸弹从"主动技能"变成"第二条命",紧张感全无。

用 tools/survival.js(固定随机种子跑 24 局)量化对比:

代劳 ∞ 次(原)   89.0s  中位 51.4s  第 2.1 关  死亡率 88%
代劳 1 次         40.9s  中位 40.5s  第 1.2 关  100%
代劳 0 次 · 自己按 39.1s  中位 37.4s  第 1.2 关  100%
代劳 0 次 · 从不按 30.9s  中位 35.8s  第 1.0 关  100%

读法很有意思:∞→1 就吃掉 90% 的问题(89s→41s);1→0 只差 1.8s(噪声内)。 但"自己按"比"从不按"多活 8.2s——说明「提醒 + 自己按」这个设计真的在产生决策价值。

所以最终选 0(彻底不代劳),同时告诉用户设 1 是最常用的折中档,改一行配置即可。

这里的教训:tools/survival.js 必须固定随机种子 (用 seedRandom 替换 Math.random),否则掉落与波次的随机性会盖过策略差异 ——实测同参数两次能差一倍(156.9s vs 75.4s)。

好设计 5:删除="全链路"检查

见坑 5。加一句:只要动的是一个"玩家能感知的机制",搜索范围就必须是全项目关键词。


七、发布环节也有坑

坑 12:不能直接把项目根目录发出去

这个项目的 dist/wx/ 是微信小游戏产物(含 project.config.json / app.json)。 如果直接发布项目根目录,发布预检会把整个目录当成小程序直接拒绝。

正确的做法:用一个构建脚本生成干净的发布副本 dist/web/, 只拷 index.html + 被引用的 10 个 src/*.js,不含 wx-entry.js(小游戏入口)。

工具自带自检:

1) index.html 引用的每个 js 都必须真实存在
2) src/ 里不能有多余文件 —— 防止 wx-entry.js 混进去

发布前还要本地起个服务探测一遍(11/11 全部 200),确保别人打开不白屏。

顺带一条:发布副本里要把面向开发者的文案换掉。 根 index.html 的标题是「雷霆战机 · 调试壳」、底部写着「产物是 dist/wx」—— 这些不该出现在朋友打开的页面上。所以做法是:src/ 和根 index.html 完全不动, 发布版是独立副本,两边各说各话。

关于"发布的是网页还是小程序"

必须说清楚:发布出去的一定是网页链接,不是小程序。 小程序是另一套代码体系(WXML/WXSS + app.json),HTML 版本无法"转换"过去, 微信开发者工具也帮不上忙——它只认已有的小程序工程。


八、工具箱:这套验证流程沉淀下来的东西

工具 作用 用法
tools/smoke.js web 端主测试,22 组断言 node tools/smoke.js
tools/smoke-wx.js 微信端测试(跑构建产物),14 组 node tools/smoke-wx.js
tools/stability.js 揪随机失败的断言 node tools/stability.js 30
tools/mutate.js 验证断言有没有拦截力 node tools/mutate.js
tools/difficulty-curve.js 上帝模式逐关压测 输出密度/TTK/吃伤曲线
tools/survival.js 固定种子跑 N 局,对比策略 量化"某个改动到底让玩家多活多久"
tools/stats-dps.js 武器 DPS / TTK 计算 配平数值
tools/build-wx.js 拼接成小游戏单文件 产出 dist/wx/game.js
tools/build-web.js 生成干净发布副本 产出 dist/web/
tools/serve.js 本地静态服务 node tools/serve.js 8123

九、最后:如果重来一次,我会先做这三件事

  1. 第一天就建 stability.js 和 mutate.js。 它们总共不到 200 行,但能拦住这个项目里最难查的那一类 bug (随机失败 + 空过断言)。后悔没有更早建。

  2. 所有数值先脚本实测再设计。 这个项目的第四轮"成长与数值设计"是先跑脚本测出一堆硬伤 (武器 Lv1 DPS 差 2.4 倍、敌人性价比失衡、成长是单轴的),再动手改。 那轮一个字代码没写,产出的是设计文档——但它是整个项目性价比最高的一轮。

  3. 每加一条规则就配一条断言,并且当场用变异验证它。 不是"写完测试就完事",是"证明这条测试会响"。 这条习惯在这个项目里至少提早发现了 5 个隐藏 bug。


一点体感总结

和 AI 一起做游戏,你的角色不是"写代码的人",而是"定义什么是对的的人"。

AI 生成速度带来的最大风险,不是代码质量差,而是: 代码产出的速度远远超过了你验证它的速度, 于是系统里会积累一堆"看起来在工作、实际在自转"的东西—— 静默失效的规则、从不响的断言、用更高数字补偿着的 bug。

所以这套流程的全部重心只有一个:想办法让"错了"这件事尽快响。


附:源码与在线试玩

代码在仓库的 thunder-strike/ 目录下,里面 tools/ 那几个脚本 (smoke.js / smoke-wx.js / stability.js / mutate.js)就是这套验证流程跑起来的地方。 整个项目零依赖,index.html 用浏览器打开就能玩,不需要装任何东西。

Programs must be written for people to read, and only incidentally for machines to execute.
SICP
← 返回