AI 写了个恐怖游戏,我花两天把它从"能跑"修成"能玩

一次 Phaser 3 网页游戏的完整复盘:4600 行代码、26 个 Bug、6 个致命级陷阱,以及那些”读代码永远发现不了”的问题。


先说结论

这个项目的起点很普通:一份 500 行的游戏设计文档,交给 AI 生成代码,一周内出了一个能启动的原型。

然后我接手做代码审查,结果是这样的:

指标 数值
源码规模 13 个 JS 模块,约 4600 行
发现问题 26 个(6 个致命级 / 15 个逻辑缺陷 / 5 个健壮性)
修复后验证 26 项全流程自动化测试全部通过,控制台零报错
移动端 HUD 完全重构,20 项专项测试通过

最值得说的一句话:这 26 个 Bug 里,有 6 个是”读代码看不出来、只有跑起来才会暴露”的。

这篇文章记录那些真正有价值的坑——不是语法错误,而是引擎行为的误用、时序的错位、以及”看起来对但其实是错的”设计。


一、这是个什么游戏

《逃离恐怖奶奶》(Escape from Horror Grandma),Phaser 3 做的横版潜行恐怖游戏,浏览器直接打开就能玩,无需安装。

玩家从噩梦中醒来,发现自己被锁在奶奶的老宅里。你没有任何攻击能力,只能靠黑暗、躲藏和声音判断,在被”它”抓到之前逃出去。

几个核心设计:

  • 五锁逃脱:正门需要集齐 5 件道具,每件都藏在高危险区域
  • 提灯的权衡:点亮看得清但暴露度高,熄灭安全但几乎看不见
  • 奶奶状态机:巡逻 → 调查 → 追捕 → 搜索 → 狂暴
  • 画外穿行:奶奶从屏幕边缘消失,2 秒后从另一侧出现,制造”她抄近道了”的压迫感
  • 躲藏 + 屏息:躲进衣柜后可按住屏息 10 秒,完全无声但视野变窄

设计文档里有一条我特别喜欢的支柱,叫不对称信息情绪引擎

恐怖来自”我知道危险存在,但我可以选择何时面对”。所有子系统服务于同一个情绪目标——“她知道你在附近,但她不知道你在这里”。

这条支柱后来成了很多 Bug 的判定标准:凡是让玩家感到”被系统惩罚”而非”被恐惧笼罩”的机制,都是 Bug。


二、技术架构:零构建

技术选型走的是极简路线:

  • Phaser 3.60(Arcade Physics),本地 vendor 一份,离线可跑
  • 纯 ES Module,无 Vite / Webpack,`` 直接跑
  • 事件总线(EventBus)解耦 12 个子系统
  • Web Audio 程序化合成音效——全部用 Oscillator + GainNode + StereoPanner 现场合成,零音频素材

模块划分:

1
2
3
4
5
6
7
8
9
js/
├── main.js # 入口与游戏配置
├── core/ # 配置、事件总线、存档、节奏系统
├── player/Player.js # 玩家控制器(PC / 移动端双输入)
├── granny/Granny.js # 奶奶 AI(状态机 + 画外穿行)
├── scenes/House.js # 主场景
├── ui/ # 背包、躲藏系统
├── audio/ # 音频管理、视觉氛围
└── input/ # 移动端 HUD

架构本身没大问题,问题全在实现细节里。


三、六个”读代码看不出来”的坑

坑 1:所有平台碰撞体都是 32×32

这是最严重的一个,也是最隐蔽的。

代码是这样写的,看起来非常标准:

1
this.platforms.create(1000, 550).setSize(2000, 100).refreshBody();

地面、墙壁、平台全都这么写。语法没问题,逻辑看起来也对。

但实际上,这行代码完全无效。

原因在 Phaser 3.60 的实现里:ArcadeSpritesetSize 被物理组件覆盖成了转发给 body.setSize,但紧接着调用的 refreshBody() 又按贴图的**默认显示尺寸(32×32)**把碰撞体覆盖回去。

结果就是:地面、墙壁、所有平台,实际碰撞体都是 32×32 的小方块。玩家和奶奶根本没站在地面上,而是掉到了世界边界的底部。

这个 Bug 的诡异之处在于游戏”看起来能玩”——你能左右移动,能跳跃,画面完全正常。只有当你实测物理体位置,才会发现 y 坐标是 675 而不是 475。

正解

1
this.platforms.create(1000, 550).body.setSize(2000, 100);

直接操作 StaticBody,StaticBody.setSize 会以物体中心重新定位,实测生效。

排查方法:我下载了完整版 phaser.js,用 Node 脚本切片读源码,确认了 setSize 在物理组件里被覆盖的行为。凭记忆修这类问题,大概率会修错。


坑 2:手动重力导致的”隔帧振荡”

玩家用的是手动重力方案(allowGravity = false),落地时把垂直速度清零:

1
2
3
4
5
if (!onGround) {
this.verticalVelocity += this.manualGravity * deltaSeconds;
} else {
this.verticalVelocity = 0;
}

看起来很合理对吧?

问题在于:速度清零后,物理体与地面恰好分离,下一帧 blocked.down 变成 false → 又开始下压 → 又接触 → 又清零……

实测下来,blocked.down隔帧交替 true/false 的。

后果有两个,都不太直观:

  1. 跳跃 5 次全部失败——因为判定落地那一刻,blocked.down 恰好是 false
  2. 站立不动的玩家在持续吸引奶奶——velocity.y 周期性非零,噪声等级在 0 和 5 之间跳动,AI 以为你在跑

解法:落地时保持一个微小的下压速度,让物理体每帧都与地面保持接触。

1
this.body.setVelocityY(onGround ? 30 : this.verticalVelocity);

同时 jump() 里要立即应用速度,否则起跳会被下一帧的下压速度抵消。


坑 3:JustDown 的”独占消费”

这个坑很经典,值得单独记住。

Phaser 的 JustDown(key) 只在首次查询时返回 true,之后就消费掉了。

而代码里有两处都在轮询同一个 E 键:

  • Player.handleInput():判断是否触发交互
  • HidingSystem.update():判断是否退出躲藏

Player 先执行,把按键标志消费了,HidingSystem 后执行,永远读到 false

后果:玩家一旦躲进衣柜,就再也出不来了。 而且连锁引发一堆问题——躲藏中无法交互存档、无法触发被抓判定。

解法:Player 里先判断 isHiding,躲藏状态下不消费按键标志,留给 HidingSystem 处理。

1
2
3
4
const isHidingNow = hidingSystem && hidingSystem.isHiding;
if (!isHidingNow && Phaser.Input.Keyboard.JustDown(this.keys.E)) {
this.interact();
}

坑 4:状态机的路由优先级

奶奶有五个状态,代码里是这样路由的:

1
2
3
4
5
6
7
8
9
if (this.isBerserk) {
this.updateBerserk();
return; // ← 问题在这里
}

if (this.isInTransit) {
this.updateOffscreenTransit();
return;
}

狂暴分支在画外穿行检查之前就 return 了。

如果奶奶在狂暴状态下触发了画外穿行,updateOffscreenTransit 永远不会执行——奶奶就凭空消失了,再也不回来。

解法:把穿行检查提到最高优先级。穿行中的奶奶已经离屏,本来就不该执行任何状态机逻辑。

这提醒我:状态机的路由顺序本身就是业务逻辑,值得写注释说明为什么是这个顺序。


坑 5:躲藏机制形同虚设

设计文档里明确写了:奶奶搜索时会逐一检查躲藏点,躲藏中的玩家需要通过”被发现”判定才能被抓。

代码里也确实实现了 checkIfDiscovered() 方法。

但这个方法从头到尾没有被调用过。

tryCatchPlayer() 里只做了距离判断——不管你有没有躲起来,奶奶靠近就抓。躲藏系统等于白做。

解法:抓捕判定里接入躲藏状态检查,屏息状态下可以躲过近距离搜索。


坑 6:画外穿行被墙卡死

原实现要求奶奶走到窗口外的出口点(x = -100 或 2100),然后触发穿行。

但左右两侧有墙(x = -25 和 2025),物理体挡住了去路。奶奶永远走不到出口点,到达条件的事件永远不满足,轮询无限进行——同时定时器泄漏

解法:按设计文档”从屏幕边缘消失约 2 秒后从另一侧出现”的描述重写——立即隐藏进入 2 秒倒计时,计时结束后 body.reset() 从另一侧出现。


四、手感调优:数值也是设计

修完功能 Bug 之后,试玩反馈了三个手感问题,这类问题的特点是”数值对了但感觉不对”。

1. 蹲伏后无法恢复原来位置

只改碰撞体高度时,Arcade 的居中对齐会让身体中心上移、底部悬空,物理引擎把玩家往下推;恢复站立时又被上推。sprite.y 永久偏移,跳跃也回不去。

解法是主动补偿:蹲伏/恢复时按高度差的一半调整 sprite.y,再 body.reset() 同步物理体。保证底部始终贴地,蹲伏前后位置完全重合。

2. 跳跃高度太大

jumpForce 从 -600 降到 -350。

3. 重力太小,空中停留太久

manualGravity 从 600 提到 1000(上升高度从约 300px 降到约 70px,滞空从约 2 秒降到约 0.7 秒)。

注意这里有个连带影响:重力加大后,落地维持的微下压速度也要从 5 提到 30,否则坑 2 的振荡会复发。同时噪声判定要忽略这个下压速度,不然站着不动也会被判定为”在移动”。

手感调优的教训:物理参数之间是耦合的,改一个要检查全部。


五、移动端:从”能玩”到”好用”

原方案参考《Alto’s Adventure》做了单手操作:自动向前行走、点击跳跃、滑动转向、靠近可交互物弹出卡片。

手机上一试,体验很差:

  • 交互卡片浮在画面正中央,遮住视线
  • 玩家不知道该点哪里跳跃、怎么转向
  • 蹲伏、提灯、背包、屏息这些功能根本没有入口

我把它推倒重做成了传统手游 HUD:

位置 功能
左下角 左右方向键
右下角 跳跃 / 蹲伏 / 冲刺
顶部左右 背包 / 提灯
顶部中央 动态交互提示(显示”躲进旧木柜”这类具体动作)
躲藏中 屏幕中央出现”屏息”按住按钮 + “离开”按钮

按钮统一用 Pointer Events,同时兼容真实触屏和自动化测试。

这里还踩了一个二次坑:交互提示最初放在底部中央,测试时发现它和右下角的蹲伏按钮横向重叠——玩家点蹲伏,实际触发了存档交互。后来移到顶部中央才解决。

这个坑是自动化测试发现的,手动点很难复现。这也是我坚持写测试脚本的原因。


六、方法论:为什么”读代码”不够

这次复盘最大的收获,是关于验证方式的三条经验。

1. 必须跑起来,而且要自动化

26 个 Bug 里有 6 个是纯运行时问题。我搭了一套本地服务器 + 无头浏览器的自动化测试,模拟完整游戏流程:启动、移动、跳跃、蹲伏、提灯、拾取、躲藏、屏息、抓捕、穿行、晚饭时间、窗口缩放、存档、10 秒稳定性。

这套脚本的价值不只是”验证通过”,更在于改完之后可以随时重跑,确认没有回归。后面做手感调优和移动端重构时,它救了我好几次。

2. 引擎行为要查源码,不要凭记忆

Body.setSize 的缩放语义、TimerEvent.reset 的签名、StaticBody.setSize 的定位行为——这些我原本都有”印象”,但印象是错的。

我的做法是下载一份完整版引擎源码,用 Node 脚本切片读实现。花 10 分钟确认,省掉 2 小时的误修。

3. 设计文档是判定的最终依据

“这算不算 Bug”这个问题,很多时候代码说了不算。比如躲藏机制——代码能跑,没报错,但和设计文档里”躲藏需要被发现判定”的描述不符,那就是 Bug。

有设计文档的项目,审查时一定要对照文档看”意图”,而不只是看”实现”。


七、接下来要做的

目前完成的是 M0 原型:灰盒场景、玩家控制、奶奶 AI、躲藏点、提灯系统。

按里程碑规划,后面还有:

  • M1:一层完整玩法闭环(正门钥匙 + 谜题 + 正式剪影美术)
  • M2:全屋 12 区域、五锁路线、记忆碎片、双结局、三档难度
  • M3:氛围打磨、手机端内测

最想验证的是那套”画外穿行 + 声像定位”的纵深欺骗——理论上它能把横版被压扁的左右二元选择,重新变回多向躲避的心理压迫。但这个只有真人试玩才能校准。


写在最后

这个项目给我的最大感触是:AI 生成代码的速度快得惊人,但”能跑”和”能玩”之间,隔着一整层工程实践。

语法错误 AI 自己就能修。真正需要人的,是那些藏在引擎行为、时序耦合、设计意图里的东西——而这些,恰恰只能靠”跑起来看”才能发现。

如果你也在用 AI 做游戏或者前端项目,我的建议是:在写第一行代码之前,先把自动化验证环境搭好。 它会在后面替你省下大量时间。


文中涉及的代码片段均已简化,路径与配置细节做了脱敏处理。