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 | js/ |
架构本身没大问题,问题全在实现细节里。
三、六个”读代码看不出来”的坑
坑 1:所有平台碰撞体都是 32×32
这是最严重的一个,也是最隐蔽的。
代码是这样写的,看起来非常标准:
1 | this.platforms.create(1000, 550).setSize(2000, 100).refreshBody(); |
地面、墙壁、平台全都这么写。语法没问题,逻辑看起来也对。
但实际上,这行代码完全无效。
原因在 Phaser 3.60 的实现里:ArcadeSprite 的 setSize 被物理组件覆盖成了转发给 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 | if (!onGround) { |
看起来很合理对吧?
问题在于:速度清零后,物理体与地面恰好分离,下一帧 blocked.down 变成 false → 又开始下压 → 又接触 → 又清零……
实测下来,blocked.down 是隔帧交替 true/false 的。
后果有两个,都不太直观:
- 跳跃 5 次全部失败——因为判定落地那一刻,
blocked.down恰好是 false - 站立不动的玩家在持续吸引奶奶——
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 | const isHidingNow = hidingSystem && hidingSystem.isHiding; |
坑 4:状态机的路由优先级
奶奶有五个状态,代码里是这样路由的:
1 | if (this.isBerserk) { |
狂暴分支在画外穿行检查之前就 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 做游戏或者前端项目,我的建议是:在写第一行代码之前,先把自动化验证环境搭好。 它会在后面替你省下大量时间。
文中涉及的代码片段均已简化,路径与配置细节做了脱敏处理。