给一款足球游戏外挂一个联赛模式:三周和一路的坑
游戏里没有生涯联赛,我就在游戏外面做了一个:赛程、转会、成长、董事会全在外面,游戏只负责踢球。记录这三周里最难的几件事:让游戏重读数据、塞不回去的压缩槽位、没有脸的新人、和存档绑死的数据库,以及今天刚修的阵型 bug。
给一款足球游戏外挂一个联赛模式:三周和一路的坑
以前玩足球游戏,我最喜欢的是那种接手一支球队、一个赛季一个赛季踢下去的生涯模式。我现在玩的这款没有,剩下的是抽卡和在线对战。
所以我决定自己做一个。思路很简单:游戏只当比赛启动器,负责踢球、提供球员和画面;联赛系统(赛程、积分、转会、成长、董事会、存档)全部在游戏外面,跑在一个本地网页程序里。
从写下第一版设计文档,到做出第一个能给别人玩的版本,用了三周。现在它能做到的事:
- 联赛按真实日历踢,洲际赛事、杯赛也在;
- 伤病、停赛、体能,AI 会按这些挑首发和轮换;
- 球员会成长、衰退、退役,青训会冒出新人;
- 转会、合同、工资、董事会目标、赛季中被炒;
- 更衣室心情和手动战术板。
玩起来的感觉,就像 FM 和一款动作足球游戏拼在了一起。
但这篇不讲功能,讲坑。下面几件事,每一件都让我卡过不止一天。
先说两条自己定的规矩
- 只用游戏自己的数据。 球员、球队、名单从游戏的数据库包里读;队徽、头像从游戏容器里拆;比赛数据来自赛后统计导出。不接外部球员库,不下网上的脸图和队徽。
- 以游戏里实测为准。 分析结论和游戏里看到的对不上时,信游戏。
第二条听起来像废话,后面你会看到它救了我好几次。
坑一:游戏只在启动时读一次数据
联赛里发生的事(伤停、转会、成长)最后都要变成游戏里看得到的东西:对面 AI 不派伤停的人上场,卖掉的球员不再出现在你的阵容里。
问题是,游戏启动时读一次数据库,之后就不读了。 改文件要重开游戏,这体验太差。
我最早的想法是在游戏读文件时把数据换掉。钩子写了,日志也有,后来一查:303 次读取,全是 SKIP,0 次替换。 游戏读这个文件是异步的,那段代码只处理同步读。这个功能「从来没生效过」,只是没人发现。
后来换了一条路,分三步:
- 影子数据库:游戏启动时,宿主把数据库拷一份影子,游戏打开数据库时改开影子。真正的文件一个字节都不动。
- 原地写:游戏开着时只改影子里的几张表,不改目录、不改长度(游戏按启动时缓存的偏移去读)。
- 强制重载:通过静态分析找到游戏自己的「重读数据库」函数。游戏登录时走的就是它。钩住每帧更新,收到请求就在游戏线程里调一次。
实测:写请求发出去 7–9 毫秒后开始重载,大约 920 毫秒重载完。不用重启,不用点登录,也没有过场动画。进友谊赛一看,伯恩茅斯的首发和影子里改的一模一样。
教训:日志里「有这个功能」不等于「这个功能在工作」。 先数一下它真的生效了几次。
坑二:压缩后的槽位,差 235 字节就写不进去
「原地写、不能变长」这条规矩,后来咬了我两次。
第一次,用户(其实就是我自己)关着游戏连踢了两个赛季,卖了不少人。开游戏一看,卖掉的人还在队里,但年龄和能力都对得上。
日志里抓到了现场:阵容表打包后 196,982 字节,原槽位 196,747 字节,多了 235 字节,整张表没写进去。能力和年龄走的是另一张表,所以只有阵容错了。
根因是转会的写法:每笔转会都「删一行,再在块尾加一行」。两季 1,611 笔换人,把文件后半截搬来搬去,30% 的字节都变了,zlib 再也压不回原来的大小。
修法是让来的人直接填走的人留下的坑,号码和角色原地继承,只有净增减才动块尾。再加一个兜底:默认压缩放不下时,换几种 zlib 参数,取最小的那个。
第二次更离谱:外观表连一个字节都没改,我们压出来也放不回原槽位。 游戏原版用的是比 zlib 更强的压缩器:
| 压缩方式 | 大小 | 原槽位 1,183,672 |
|---|---|---|
| zlib 9 | 1,213,359 | 超 29 KB |
| libdeflate 12 级 | 1,185,315 | 超 1.6 KB |
| zopfli 5 轮 | 1,183,362 | 能放下,余 310 字节,耗时 4–5 秒 |
zopfli 能塞回去,但表一变又会超,还要多装两个依赖。最后的办法是不跟它比压缩率:游戏启动时拷的影子换成一份「影子来源」,几张要写的表事先用我们自己的压缩器重压一遍,后面补零,留出 10% 的富余。目录里记的大小连补的零一起算,游戏照样认。
教训:在别人的格式里做原地修改时,先确认你能把原样写回去。 连原样都写不回去,改什么都是空谈。
坑三:新人没有脸
球员会退役,世界就得有新人补进来,否则几个赛季后全世界都是老头。(第一版就是这样:18–20 岁的球员四季从 1,430 人掉到 68 人。)
新人放在哪?最直接的想法是复用退役球员的 ID。但 3D 真脸是按 ID 挂的,复用 ID,新人就会长着退役球员的脸。
最后用的是「容器」:球员表里那些不在任何队、没有真脸标记的记录,有 7,226 条,够用几十个赛季。新人写进容器,外观整条抄一个同国籍的捐赠者,游戏会按外观参数捏出一张新面孔。
实测时还踩了一个小坑:新人的位置熟练度全是 0,游戏就当他「不会踢这个位置」,总评显示 43。熟练度照能力模板那个人抄过来以后,显示就正常了。
坑四:存档和数据库绑死了
有一天我换了一份数据库,再进游戏,阿森纳面目全非:后来加的 6 名球员没了,23 个原版老将冒了出来,而且已经是第 8 季,全是 31–39 岁的老头。
原因是存档只记「在数据库上改了什么」(转会、成长、换人),底子每次都从游戏目录现读。数据库一换,底子就变了。
更糟的是,另一个功能(每次启动随机庆祝动作)会直接写真正的数据库:文件从 16 MB 被重写成 20.6 MB,连编辑器的备份也被冲掉了。换之前的那份,再也找不回来。
修法是每个存档自带一份开档时的数据库底子,文件名用内容哈希,写好就不再改。联赛读它,游戏里的影子也从它拷。顺手把所有写真数据库的地方都清掉了:现在这个程序只读游戏文件。
教训:存「差异」之前,先把「基准」钉死。 差异是相对于某个东西的,那个东西要是会变,差异就没有意义。
坑五:数值没法靠亲手踢来调
伤病率、成长速度、身价、董事会信任度、名人堂门槛……每个数后面都写着「等踢几季再调」。可亲手踢一季要几十个小时。
于是做了一个开发者模式:把存档拷一份,托管你的球队,快进 N 个赛季,每季末出一份体检报告:世界有没有变老、钱有没有膨胀、80 分以上的球员是不是太多了。每个数旁边都写着参考值,超出的标红。
上面那个「四季后只剩 68 个年轻人」就是这样发现的。真实俱乐部除了退役,还有人去别的联赛、去海外,所以「离开」要分两种。每个年龄段离开的概率,是从数据库自己的年龄分布倒推出来的:约 8,000 名球员,逐岁相减。
教训:让系统自己跑给你看。 靠体感调数,调到第三个系统就乱了。
坑六:今天刚修的,阵型不生效
做出第一个版本的第二天收到一条反馈:玩得很开心,但战术页改的阵型和位置,进游戏后好像不生效。
查下来,写入本身完全正常:每场都写了阵容表,重载也成功了。错在对位置,而且是两层错叠在一起:
- 阵型从来没写进游戏。 我当初只写了「哪 11 个人首发」,没碰游戏的战术板。你选了 3-5-2,游戏里阿森纳还是原来的 4-2-3-1。
- 槽位顺序对不上。 游戏阵型表里后卫的顺序是
门将, 中卫, 中卫, 右后卫, 左后卫…,我的预设是门将, 右后卫, 中卫, 中卫, 左后卫…。游戏按槽位决定谁踢什么位置,所以就算阵型一样,你排的右后卫进了游戏也会去踢中卫。
修法:开球前把你选的阵型写进游戏的战术板,坐标照抄游戏里同形状最常见的那套(6 套预设在游戏自己的 1,916 套阵型里都找得到);首发按游戏的槽位顺序重排;两个中卫、两个前锋按站位图的左右对上。
教训:「写进去了」不等于「生效了」。 测试只证明了我写了我想写的东西,没证明游戏用它做了我以为的事。
我是怎么和 AI 一起做的
这个项目是我和 Claude Code 搭档做的。三周能做完一百多条需求,靠的不是让 AI 一直写代码,而是几条规矩:
- 一份设计文档管所有需求。 我每提一个想法,就记一条
R-xx:我的原话、目标、数据从哪来、怎么设计、怎么验收、还有什么没决定。做之前先看,做完回来更新。这份文档现在有几千行,它也是项目的记忆。 - 测试锁行为。 几百个测试,很多直接在真数据库的副本上跑:写进影子,重新打开,看得到改动,长度不变。
- 能进游戏验的就进游戏验。 AI 能用按键和截图自己操作游戏:进友谊赛、看比赛计划里的总评、对比改动前后。坑一的重载、坑三的新人,都是这样验出来的。
- 不确定的地方写出来。 文档里很多条后面都挂着「没进游戏验」。这不丢人,丢人的是假装验过。
总结
| # | 问题 | 根因 | 教训 |
|---|---|---|---|
| 1 | 游戏不重读数据 | 只在启动时读一次;旧钩子从没生效 | 先数它真的生效了几次 |
| 2 | 写不进影子 | 原地写不能变长,原版压缩比 zlib 强 | 先确认能把原样写回去 |
| 3 | 新人没有脸 | 真脸按 ID 挂 | 用空容器,不占别人的身份 |
| 4 | 换数据库毁存档 | 存的是差异,基准会变 | 先把基准钉死 |
| 5 | 数值调不动 | 亲手踢一季太慢 | 让系统自己快进给你看 |
| 6 | 阵型不生效 | 阵型没写、槽位顺序不同 | 写进去 ≠ 生效 |
回头看,这六个坑没有一个是算法难题,全是「我以为游戏是这样的」:以为它会重读,以为能写回去,以为 ID 只是个编号,以为底子不会变,以为槽位顺序是一样的。
外挂一个系统到一个不是为你设计的游戏上,大部分时间都在修正这些「以为」。
by Jiahao Ren | github.com/Giggitycountless | jiahao.uk