给一款足球游戏外挂一个联赛模式:三周和一路的坑

游戏里没有生涯联赛,我就在游戏外面做了一个:赛程、转会、成长、董事会全在外面,游戏只负责踢球。记录这三周里最难的几件事:让游戏重读数据、塞不回去的压缩槽位、没有脸的新人、和存档绑死的数据库,以及今天刚修的阵型 bug。

· 10 min read

给一款足球游戏外挂一个联赛模式:三周和一路的坑

以前玩足球游戏,我最喜欢的是那种接手一支球队、一个赛季一个赛季踢下去的生涯模式。我现在玩的这款没有,剩下的是抽卡和在线对战。

所以我决定自己做一个。思路很简单:游戏只当比赛启动器,负责踢球、提供球员和画面;联赛系统(赛程、积分、转会、成长、董事会、存档)全部在游戏外面,跑在一个本地网页程序里。

从写下第一版设计文档,到做出第一个能给别人玩的版本,用了三周。现在它能做到的事:

  • 联赛按真实日历踢,洲际赛事、杯赛也在;
  • 伤病、停赛、体能,AI 会按这些挑首发和轮换;
  • 球员会成长、衰退、退役,青训会冒出新人;
  • 转会、合同、工资、董事会目标、赛季中被炒;
  • 更衣室心情和手动战术板。

玩起来的感觉,就像 FM 和一款动作足球游戏拼在了一起。

但这篇不讲功能,讲坑。下面几件事,每一件都让我卡过不止一天。


先说两条自己定的规矩

  1. 只用游戏自己的数据。 球员、球队、名单从游戏的数据库包里读;队徽、头像从游戏容器里拆;比赛数据来自赛后统计导出。不接外部球员库,不下网上的脸图和队徽。
  2. 以游戏里实测为准。 分析结论和游戏里看到的对不上时,信游戏。

第二条听起来像废话,后面你会看到它救了我好几次。


坑一:游戏只在启动时读一次数据

联赛里发生的事(伤停、转会、成长)最后都要变成游戏里看得到的东西:对面 AI 不派伤停的人上场,卖掉的球员不再出现在你的阵容里。

问题是,游戏启动时读一次数据库,之后就不读了。 改文件要重开游戏,这体验太差。

我最早的想法是在游戏读文件时把数据换掉。钩子写了,日志也有,后来一查:303 次读取,全是 SKIP,0 次替换。 游戏读这个文件是异步的,那段代码只处理同步读。这个功能「从来没生效过」,只是没人发现。

后来换了一条路,分三步:

  1. 影子数据库:游戏启动时,宿主把数据库拷一份影子,游戏打开数据库时改开影子。真正的文件一个字节都不动。
  2. 原地写:游戏开着时只改影子里的几张表,不改目录、不改长度(游戏按启动时缓存的偏移去读)。
  3. 强制重载:通过静态分析找到游戏自己的「重读数据库」函数。游戏登录时走的就是它。钩住每帧更新,收到请求就在游戏线程里调一次。

实测:写请求发出去 7–9 毫秒后开始重载,大约 920 毫秒重载完。不用重启,不用点登录,也没有过场动画。进友谊赛一看,伯恩茅斯的首发和影子里改的一模一样。

教训:日志里「有这个功能」不等于「这个功能在工作」。 先数一下它真的生效了几次。


坑二:压缩后的槽位,差 235 字节就写不进去

「原地写、不能变长」这条规矩,后来咬了我两次。

第一次,用户(其实就是我自己)关着游戏连踢了两个赛季,卖了不少人。开游戏一看,卖掉的人还在队里,但年龄和能力都对得上。

日志里抓到了现场:阵容表打包后 196,982 字节,原槽位 196,747 字节,多了 235 字节,整张表没写进去。能力和年龄走的是另一张表,所以只有阵容错了。

根因是转会的写法:每笔转会都「删一行,再在块尾加一行」。两季 1,611 笔换人,把文件后半截搬来搬去,30% 的字节都变了,zlib 再也压不回原来的大小。

修法是让来的人直接填走的人留下的坑,号码和角色原地继承,只有净增减才动块尾。再加一个兜底:默认压缩放不下时,换几种 zlib 参数,取最小的那个。

第二次更离谱:外观表连一个字节都没改,我们压出来也放不回原槽位。 游戏原版用的是比 zlib 更强的压缩器:

压缩方式大小原槽位 1,183,672
zlib 91,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 名球员,逐岁相减。

教训:让系统自己跑给你看。 靠体感调数,调到第三个系统就乱了。


坑六:今天刚修的,阵型不生效

做出第一个版本的第二天收到一条反馈:玩得很开心,但战术页改的阵型和位置,进游戏后好像不生效。

查下来,写入本身完全正常:每场都写了阵容表,重载也成功了。错在对位置,而且是两层错叠在一起:

  1. 阵型从来没写进游戏。 我当初只写了「哪 11 个人首发」,没碰游戏的战术板。你选了 3-5-2,游戏里阿森纳还是原来的 4-2-3-1。
  2. 槽位顺序对不上。 游戏阵型表里后卫的顺序是 门将, 中卫, 中卫, 右后卫, 左后卫…,我的预设是 门将, 右后卫, 中卫, 中卫, 左后卫…。游戏按槽位决定谁踢什么位置,所以就算阵型一样,你排的右后卫进了游戏也会去踢中卫。

修法:开球前把你选的阵型写进游戏的战术板,坐标照抄游戏里同形状最常见的那套(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