下载 App
10/16案例分享
【案例分享】6 分钟只杀 3 只怪:无头模拟揪出的 11 个机制级缺陷
一句话结论
「打不中」「数值不涨」「游戏卡死」这类问题,靠看画面是找不到的。
把游戏逻辑从渲染里剥出来,做成一个无头模拟(不开窗口、纯跑数值、定时打印状态),11 个机制级缺陷会在几分钟内自己浮出来 —— 其中 3 个完全不报错,2 个只在长局出现。
下面每一条都有具体参数、现象和修法。
为什么必须做无头模拟
我原本的验收方式是:打开游戏跑一局,看伤害数字涨不涨、怪死得快不快。
这个方式有三个盲区:
肉眼只能看「像不像在打」,看不出判定圈内实际有几只怪 —— 缺陷 2 就是这样漏掉的
一局 6 分钟,长局才触发的边界要跑很多遍才撞上 —— 缺陷 11 漏了很久
数值是否正确,眼睛无法给出读数 —— 缺陷 1、10 都是靠算出来的
无头模拟的做法很简单:主循环不变,只把渲染那一层去掉,然后把状态定时打印出来。
-- 伪代码:无头模拟主循环
local t = 0
while t < 360 do
step(dt) -- 完全相同的逻辑步进
t = t + dt
if math.floor(t) ~= math.floor(t - dt) then
print(string.format(
"t=%3d 动量=%.2f 等级=%2d 击杀=%5d 伤害=%8d 判定圈内=%3d 经验球堆积=%4d",
t, player.momentum, player.level,
stats.kills, stats.damage, stats.inRing, stats.orbBacklog))
end
end
关键在最后几列 —— 它们是「看得见的读数」,把「我以为在打」变成一条数轴上的值。下面的缺陷,全部是这几列数字不对劲才暴露的。
缺陷 1:动量平衡点算错,玩家永远上不去
现象:动量条最高就停在 2.4 倍左右,怎么玩都上不去,但代码里上限写的是 9 倍。
根因:每秒累积 gainPerSec = 1.15,而向 1.0 回归的流失是 1.15 × 0.55 = 0.6325。这是个一阶系统,平衡点在
数学平衡点 = 1 + (1.15 - 0.6325) / 0.6325 ≈ 2.39
实测峰值 2.4 倍 —— 和推导吻合到小数点后一位。不是手速问题,是数学上就到不了。
修:把回归系数从 0.55 降到 0.02,累积提到 0.45,平衡点被抬到上限之上。
验证:修后峰值可达 9.8 倍,且 5 倍前涨得快、5→8 倍明显变慢(这是故意留的博弈区)。
这条的教训:先算平衡点,再调参数。调参之前不知道平衡点在哪,就是在瞎试。
缺陷 2:脉冲判定与高速移动自相矛盾(最隐蔽的一个)
现象:判定圈明明罩住了怪,怪就是不死。一局 6 分钟只杀 3 只。
根因:判定曾是「每 0.28 秒一次脉冲」,判定圈半径 54px。玩家以 240px/s 掠过时,单只怪停留在圈内的时间是
2 × 54 / 240 = 0.45 秒
看着够 —— 但怪同时在朝玩家移动,相对停留时间更短,恰好每次脉冲都擦肩而过。
修:改成逐帧连续碾压,每帧按 dt 结算,掠过即受伤。
验证:击杀数从 3 只 → 约 19000 只(6 分钟)。
离散判定 + 高速运动 = 漏检。这是游戏里反复出现的经典组合。
缺陷 3:一个被删掉的字段,让怪从空间网格里消失(且不报错)
现象:判定圈内显示 0 只怪,伤害几乎为 0,控制台一个字都不报。
根因:重构时漏掉了 C.hit.knockback 字段,于是
local kb = undefined * n -- = NaN
e.x = e.x + kb -- 敌人坐标变成 NaN
空间网格的 key 由坐标生成,这只怪此后永远落在 "NaN,NaN" 格里,任何查询都再也找不到它 —— 它还在屏幕上,还在动,但对伤害系统已经不存在了。
修:补回字段,并在 config 里留下警示注释。
验证:判定圈内数量恢复正常,伤害回到百万量级。
移植到 Lua / 触发器时务必做同样的空值防护。这类缺陷的可怕之处在于:它不崩、不报错、画面正常,只是逻辑静默失效。
缺陷 4:同类分离力变成「斥力护盾」
现象:怪群在玩家周围被整片推开,永远进不了判定圈。
根因:分离位移被放大到 sx × 260,密集时叠加 6 倍 → 约 1560px/s 的外推力,远超怪自己的追击速度。
修:分离位移限速为 e.speed × 0.45,且必须严格小于追击速度。
验证:怪群能贴到玩家身上,判定圈内有怪了。
参数化的时候问一句:这个力的上限是多少,和追击速度比谁大?
缺陷 5:无敌帧让怪群完全无害
现象:被 50 只怪包住,血量全程满格,「别被围住」的威胁彻底消失。
根因:接触伤害靠 0.5 秒无敌帧节流 → 被 50 只怪围住也只吃一次伤害。
修:接触伤害按同时贴身数量逐帧累加,上限 10 个来源(防秒杀)。
验证:被围住会在数秒内掉血,围杀压力回来了。
缺陷 6:转向惩罚在低速时也扣 ——「一动就死」
现象:绕圈和贪经验的打法 5 秒暴毙,动量恒为 1.0。
根因:角速度惩罚没乘速度比。玩家刚起步时随便转个方向,角速度就爆到 π 以上,动量被瞬间扣光。
修:
local drain = baseDrain * (speed / maxSpeed)
站着原地转身不该消耗任何动量。
验证:绕圈/贪经验策略从 5 秒暴毙恢复到 360 秒完赛。
缺陷 7:升级数值格式化错误 ——「生命上限 +4400%」
现象:升级面板显示「生命上限 +4400%」。
根因:hp 的 per: 22 是绝对值,却被按 per × 100 渲染成百分比。同时 A8 疾奔回血 的 per: 0.35 实际含义是每秒回 35% 最大生命(应为 0.35%)。
修:按 unit 字段(pct / flat / num)正确换算;A8 改为 0.0035。
这条是靠人眼看截图才发现的 —— 程序化像素统计完全测不出来。所以无头模拟不是万能的,它和人工抽查是互补的。
缺陷 8:升级面板与 HUD 三层叠字
现象:连击数 ×54、时间、动量条透过半透明面板糊在标题上;底部 build 概览固定显示 5 项,玩家只点了生命时其余全是 +0%,看起来像坏了。
修:升级期间不画 HUD;概览改为只列已投资的项目;面板纵向分区重排。
同样是「看截图」才能发现的类型。自动化测不出「难看」。
缺陷 9:「连锁反应」把屏幕糊满肥皂泡
现象:后期屏幕上全是半透明圆圈,什么都看不清。
根因:areaDamage 无条件添加视觉光环,而「连锁反应」每次击杀都会调用它。6000+ 次击杀 → 光环生成速率远超消散速率。
修:光环与伤害解耦(新增 showRing 参数),对象池容量从 40 降到 18。
通用规律:任何「每次事件都生成一个视觉对象」的写法,都要问生成速率和消散速率谁大。
缺陷 10:经验球堆积到 733 个,等级成长被砍半(影响最大的一条)
现象:40 秒内经验球堆积 733 个,等级只有 13(本该 20+),还额外拖性能。
根因:磁吸半径 110px,而玩家以 340px/s 直线跑动 —— 被动磁吸只在球贴脸时才生效,身后的球全部漏掉。
修:改为扫掠式椭圆吸取 —— 拾取范围沿运动方向拉长,磁吸判定提前到 2.2 倍半径。
验证:
堆积峰值:修前 733 → 修后 41
平均堆积:修前 410 → 修后 4.2
40 秒等级:修前 13 → 修后 20
这条是「看起来在正常工作,实际成长速度被砍掉一大半」的典型。只看画面完全正常,只有打印「经验球堆积」这一列才看得出来。
缺陷 11:升级池点满后状态机永久卡死(最危险的一条)
现象:模拟器死循环,跑不完一局。
根因:
function take()
if #choices == 0 then return end -- 直接返回
...
end
choices 为空时直接 return,而 pendingLevels 永不减少 → 状态永久卡在 levelup → 游戏彻底死住。
长局必然触发(升级池总容量 79 次,而经验曲线允许升到 85 级)。
修:openLevelUp 与 take 都显式处理「池已空」边界(清空待升级并退回 play);并在模拟器里加迭代上限护栏,防止再次卡死工具链。
最危险的地方在于:它是长局才触发的。手动玩几分钟永远撞不上,而玩家玩到 6 分钟必死。无头模拟跑长局是唯一能提前发现它的方式。
附带:一个被数据推翻的设计假设
早期把「原地高速旋转」(spin)当作必须被惩罚的作弊打法。
实测两者总路程几乎相同:
绕圈风筝:总路程 59385 px,活动半径 513 px,总伤害 106 万
原地缠斗:总路程 51787 px,活动半径 91 px,总伤害 96 万
也就是说 spin 照样跑了 5 万像素的路、照样在吃怪,拿 96 万是数值上合理的 —— 游戏从未规定「必须跑大圈」。它本质是「贴脸缠斗」流派,与绕圈风筝同源,机制上无法也不该区分。
结论:假设要写下来,然后用数据去撞它。撞不动的假设就该删。
什么情况下这套方法不适用
无头模拟不是万能的。下面这几种情况,它帮不上忙,甚至会把方向带偏:
表现类缺陷(叠字、颜色难看、动画生硬):模拟器不产出像素,没有画面可看。该人眼看截图 —— 缺陷 7、8 就是这么发现的。
手感类问题(转向跟手不跟手、打击感):手感的判据是「人的主观反应时间」,不是数值。该真人试玩,而且要多次。
依赖随机数的平衡:单次跑出的数字没有统计意义。该多局取分布,看的是方差不是均值。
纯视觉特效的帧率:模拟器没有渲染,测不出 GPU 开销。该真机跑 + 帧时间采集。
首次上手的引导体验:新手卡在哪,模拟器里的 bot 不会卡。该看真实玩家的录屏。
最关键的一条:模拟器里的 bot 是零失误最优操作。我的六种打法里,除站桩外全部 100% 完赛 —— 但这不代表真人也能完赛。真人会挨打、会犹豫、会被弹窗打断。
所以模拟器给出的数字要这样读:
「站桩 7.6 秒必死」→ 可信。这是机制约束,真人只会死得更快
「直线狂奔 100% 完赛」→ 只说明机制上允许,不说明难度合适
把后者当成「难度已经够了」,是我在这个项目里犯过的判断错误。
一句话:模拟器验的是「机制对不对」,不是「玩起来爽不爽」。
可迁移的经验
1. 先算平衡点,再调参数。一阶系统的心算只要一行,能省掉几十轮瞎试。
2. 离散判定 + 高速运动 = 漏检。判定频率和相对速度必须一起算。
3. NaN 会静默摧毁基于坐标索引的一切结构(空间网格、四叉树、哈希桶)。表现是「东西还在,但查不到」,且不报错。
4. 打印读数,不要只看画面。判定圈内数量、经验球堆积、动量峰值 —— 这几列数字比任何肉眼判断都准。
5. 自动化测不出「难看」。数值类缺陷交给无头模拟,表现类缺陷仍要人眼看截图。
6. 假设要写成表,用数据撞。撞不动就删,别留着一个没验证的「设计意图」。
无头模拟的成本很低 —— 主循环本来就是纯逻辑,去掉渲染层就能跑。它换来的是一双能看见「数值对不对」的眼睛。




