☰
一个 Python 新手,靠 AI 帮忙把贪吃蛇做成了“蛇娘“游戏(5/5):给 exe 装个“黑匣子“,抓到一个只差 1 像素的崩溃
2026/10/3 7:17:44 网站建设 项目流程

系列:《一个 Python 新手,靠 AI 帮忙把贪吃蛇做成了"蛇娘"游戏》共 5 篇,这是最后一篇。讲打包、一次真实的崩溃排查,以及我这个新手的收官感想。

前四篇讲完了架构、画面、战斗和内容。这最后一篇,我想讲一个真实发生、把我折腾得够呛的 bug——它也是我整个开发过程里,学到最多的一次。

事情是这样的:游戏做得差不多了,我用 PyInstaller 打包成一个.exe,希望可以自己体验一下。结果开始游戏中点到了无尽模式,游戏直接报错崩溃了。

我当时就懵了。这一篇,就是我怎么从这个"毫无头绪"的困境里,一步步抓到真凶的全过程。如果你也遇到过"开发时好好的、一打包就崩、还啥都不报"的诡异问题,希望这篇能帮到你。


一、噩梦开始:exe 崩了,却一个字都不肯说

我这边"选无尽模式就闪退、没有任何提示"。作为一个新手,我的第一反应是慌——没有报错信息,我连从哪查起都不知道。

我把情况描述给 AI,它问了我一个关键问题:"你打包的时候,是不是设置了console=False?" 我说是(因为游戏窗口带个黑乎乎的命令行窗口太丑了)。它一针见血:

"这就是问题所在。console=False的 exe,程序崩溃时不会像命令行那样把错误打印出来,而是直接静默退出。你现在等于蒙着眼睛找 bug。"

我恍然大悟。原来在 PyCharm 里跑,报错会打印在控制台;可打包成没有控制台的 exe 后,异常无处可去,程序就直接消失了。不是没报错,是报错被我"关掉"了。


二、第一步不是修 bug,而是"让崩溃说话"

我以为 AI 会直接帮我猜哪里错了,结果它给了一个我当时没想到的建议:

"先别急着找 bug。你现在最该做的,是让程序崩溃的时候,能把错误信息留下来。"

它让我写一个"崩溃兜底"模块——我把它理解成给游戏装了个黑匣子(就像飞机那样,出事了能回溯)。这个模块干两件事:把完整的错误堆栈写进一个日志文件,再弹一个系统消息框告诉玩家"出错了",而不是让窗口默默消失。

下面是这个crash.py的核心(我删了一些边角,保留了主干):

# 教学简化版·非完整可运行·by 【外收内放】 import os, sys, time, traceback, ctypes def report_crash(exc=None, context=""): """崩溃时:把完整 traceback 写进 crash.log,再弹一个系统消息框。""" tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) # ① 追加写到 exe 同目录的 crash.log(BASE_DIR = exe 所在目录,历次崩溃都留痕) with open(os.path.join(BASE_DIR, "crash.log"), "a", encoding="utf-8") as f: f.write(f"崩溃时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n") f.write(f"上下文: {context}\n{tb}\n") # ② 弹一个 Windows 原生错误框(0x10=错误图标, 0x1000=置顶),取 traceback 最后一行当摘要 ctypes.windll.user32.MessageBoxW( 0, f"出错了:\n{tb.strip().splitlines()[-1]}", "贪吃蛇娘化版", 0x10 | 0x1000) def install_excepthook(): """兜住任何漏网的异常(连主循环之外的都不放过)。""" sys.excepthook = lambda etype, value, tb: report_crash(value, "sys.excepthook")

然后我把它接到游戏主循环里——把每一帧的"处理输入 → 更新 → 绘制"整个包进try,一旦哪个界面崩了,就记下是哪个界面崩的:

# 教学简化版·非完整可运行·by 【外收内放】 try: scene.handle_events(events) scene.update(dt) scene.draw() except Exception as e: # 绝不让你打包的 exe 静默闪退 report_crash(e, context=f"scene={type(scene).__name__}") self.running = False

说句实话,这段代码里的ctypes.windll、traceback.format_exception、sys.excepthook,我一个都不熟,基本都是 AI 写的。但我看得懂它在干嘛——"把错误写下来 + 弹出来",这就够了。新手用 AI 的一个正确姿势我觉得就是这样:不一定要会写每一行,但要能看懂它替你做了什么。


三、黑匣子立大功:拿到真凶了

我重新打包了一个带"黑匣子"的 exe 再试玩一遍,再点一次无尽模式。这次,游戏崩溃时弹出了一个错误框,exe 旁边还多了一个crash.log。我查看了日志发给AI,内容大概是这样:

============================================================ 崩溃时间 : 2026-10-02 00:48:37 上下文 : scene=SceneSelectScene Python : 3.14.7 打包运行 : True ============================================================ Traceback (most recent call last): File "scenes\scene_select.py", line 164, in _draw_card img = img.subsurface(pygame.Rect(0, top, preview.w, preview.h)).copy() ValueError: subsurface rectangle outside surface area

看到没?"上下文 scene=SceneSelectScene"直接告诉我是"选场景"这个界面崩的,最后一行ValueError: subsurface rectangle outside surface area(子表面矩形超出了表面范围)就是错误本身。

我之前完全看不懂这个错。AI 解释:subsurface是从一张大图上"裁一块小的",这个报错的意思是——我要裁的范围,比图片本身还大,裁到外面去了。

当时崩溃就发生在进入这里之前的一个界面,正常点击无尽是可以进入当前界面的,然而却崩溃了。


四、根因:一个"只差 1 像素"的取整误差

顺着这条线,我找到了崩溃的地方:选场景界面要给每张地图卡片画一个预览小图,做法是"先把背景图缩放到卡片宽度,再从中间裁一块"。

问题出在"缩放"这一步。我的缩放函数get_scaled里,是这么算目标宽度的:

# 教学简化版·非完整可运行·by 【外收内放】 ratio = width / w # 目标宽 / 原图宽 # ❌ 我最初的写法:用 int() 截断 surf = smoothscale(base, (int(w * ratio), int(h * ratio)))

AI 让我盯着int(w * ratio)这行看。它说:你请求把图缩放到width宽,但int()是直接砍掉小数。由于浮点数计算有微小误差,w * ratio有时会算出类似360.9999999这样的值,int()一砍就变成360——比你请求的 361 少了整整 1 像素。

而下游裁剪时,却仍然按"卡片宽度 361"去subsurface。图片实际只有 360 宽,你要裁 361,就多裁了这 1 像素 → 越界 → 崩溃。

真凶,就是这看不见的 1 个像素。


五、解开谜团

这才是这个 bug 最"阴险"的地方,也是我最想分享的教训。

我后来才想明白:卡片预览图的宽度preview.w,是根据窗口分辨率算出来的。而"取整会不会恰好差 1 像素",完全取决于这个宽度的具体数值:

  • 我的自动化测试,固定跑在一个分辨率下,算出的宽度恰好不触发那个 0.9999 的误差;
  • 我模拟在电脑正常玩的真实分辨率,算出的宽度恰好落在会差 1 像素的档位上。

所以同一个 bug,在本地电脑那儿必崩,在程序中测试却是不会显现的。这种"依赖特定数值才会触发"的 bug,靠'我这儿跑着没问题'是永远发现不了的。

AI 教我的办法是:与其瞎猜,不如写个"探针",把所有可能的情况扫一遍。我写了个小脚本,模拟从 0.5 倍到 2.2 倍的各种窗口缩放(等于覆盖各种分辨率),逐个检查"旧写法会不会越界"。结果触目惊心——

旧写法在 179 个缩放档位下都会差 1 像素、并复现出和在本地电脑一模一样的ValueError;而改成新写法后,0 个越界。到这一刻,我才 100% 确认自己找对了根因,而不是碰运气。


六、修复:改一个round,再加一道保险

修复分两步,这个"两步走"的思路也是 AI 教的:

第一步,根治——把int()截断换成round()四舍五入,360.9999就会正确地变成 361,和图片宽度对上:

# 教学简化版·非完整可运行·by 【外收内放】 # ✅ 改用 round:360.9999… → 361,和下游要裁的宽度对齐 surf = smoothscale(base, (round(w * ratio), round(h * ratio)))

第二步,加保险——AI 说:"根因要修,但最好再补一道防御,万一将来别的地方又出现类似的取整差,也不至于直接崩。" 于是我在裁剪前,把要裁的宽高死死钳制在图片实际尺寸之内:

# 教学简化版·非完整可运行·by 【外收内放】 iw, ih = img.get_size() cw, ch = min(preview.w, iw), min(preview.h, ih) # 裁多大都不超过图片本身 img = img.subsurface(pygame.Rect((iw - cw) // 2, (ih - ch) // 2, cw, ch)).copy()

"修根因 + 加防御"双管齐下,这个折磨了我好几天的 bug,才算真正了结。这件事让我记住一句话:改 bug 不能只求"这次不崩了",要问"同类问题会不会在别处再冒出来"。


七、顺带聊聊打包:PyInstaller 的几个新手坑

既然讲到 exe,就把打包的经验也一起记下来。我的打包配置(.spec文件)关键就几行:

# 教学简化版·非完整可运行·by 【外收内放】 a = Analysis(['main.py'], datas=[('assets', 'assets'), ('data', 'data')]) # 把素材和 JSON 配置打进包 exe = EXE(..., name='贪吃蛇娘化版', runtime_tmpdir=None, # 打成单个 exe 文件(onefile) upx=True, # 压缩体积 console=False) # ⚠️ 无控制台——正是它导致崩溃时"静默闪退"

新手打包最容易栽的两个坑:

  1. 忘了打包素材:datas里一定要把assets(图片)和data(JSON)带上,否则 exe 一跑就"找不到文件";
  2. 路径问题:打包后,程序运行时的"当前目录"和你开发时不一样了。读取素材的路径要特殊处理(打包后资源在一个临时解压目录里,而存档、日志这类要写在 exe 旁边)。这块我一开始也搞混过,是 AI 帮我理清"哪些路径跟着 exe 走、哪些跟着资源包走"。

另外,为了不让"低级错误"浪费一次几分钟的打包,我写了个selfcheck.py小工具,每次打包前先静态扫一遍:

# 教学简化版·非完整可运行·by 【外收内放】 # 打包前先跑一遍自检,把低级错误挡在打包之前: check_syntax() # ① 每个 .py 能不能通过语法解析 check_settings_imports() # ② from settings import X 的 X,settings 里真的存在吗 check_json() # ③ data/ 下的 JSON 格式对不对 check_assets() # ④ 代码和 JSON 里引用的图片,文件在不在 check_undefined_names() # ⑤ 有没有用了却忘了 import 的名字

这五关任意一关报错,我就先修再打包,省下了无数次"打包五分钟、一跑就崩、发现是个拼写错误"的冤枉时间。对新手来说,把'检查'也自动化,是很值的一笔投入。


八、系列收官:一个"三无新手"做下来,最大的收获

五篇写到这里,就收官了。回头看这个项目——一个不会画画、不懂音乐、Python 才学几个月的人,居然真的做出了一个有 6 个角色、4 张地图、连招、肉鸽卡池、还能打包成 exe 的游戏。放在一年前,我是不敢想的,目前游戏大致框架已经做出来了,后续可能就是优化了。

如果说最大的收获是什么,不是某个具体技术,而是一套"和 AI 一起解决问题"的方法:

  1. 遇到不懂的,先让 AI 讲原理,别急着抄代码——抄了也不踏实,讲懂了才敢改;
  2. 出了 bug,先想办法"让它说话"(装日志、加打印、写探针),而不是盯着代码干瞪眼;
  3. 把重复的事交给代码(切图脚本、自检工具、自动试玩),哪怕一开始写工具比手动做还慢;
  4. 每次踩坑都记下来——这个系列本身,就是我踩坑记录的一部分。

我依然是个新手,这五篇里一定有很多讲得不严谨、甚至讲错的地方,非常欢迎评论区的大佬指正,我会认真看、认真改。

如果你也是刚起步、想做点东西又怕自己"什么都不会"的新手,希望我这五篇啰嗦的踩坑记录,能让你觉得:"原来他也是一路错过来的,那我也可以试试。"

那就够了。谢谢你看到这里。

最后分享一下游戏的抽卡界面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询