40 关、Boss 战,以及开发中踩过的 10 个坑
Python · pygamev2.1.0阅读约 10 分钟
小时候在红白机上守老鹰的那种紧张感,我一直想亲手做一遍。于是有了这个项目:一个用 Python + pygame 写的《坦克大战》复刻版——40 张关卡、10 种敌方坦克、每 5 关一场 Boss 战、游戏内关卡编辑器,打包成一个双击就能玩的 EXE。
这篇文章分两半:前一半介绍游戏本身,后一半是开发过程里真正踩过、并且值得分享的 10 个坑。如果你只想玩,看完前一半直接去下载;如果你也在写小游戏,后一半可能比游戏本身更有用。

一、游戏一览
规则和原版一样:守住画面底部中央的老鹰,清光本关所有敌人。老鹰被打掉,或者所有玩家的命用完,游戏结束。支持单人,也支持两个人同屏合作。
40 张左右对称的关卡
每张图都是 13×13 格,每一张都严格左右对称。对称不只是为了好看:它保证左右两路进攻的敌人、左右两个玩家拿到的地形机会完全均等。地形有五种,每种的玩法都不一样:
| 地形 | 坦克 | 子弹 | 说明 |
|---|---|---|---|
| 砖墙 | 挡 | 可打碎 | 按 16 像素的小块逐块碎,可以打出半格宽的缺口 |
| 钢板 | 挡 | 挡 | 满级子弹、重炮兵和 Boss 能破坏 |
| 河流 | 挡 | 穿过 | 隔河对射的经典地形 |
| 森林 | 穿过 | 穿过 | 盖在坦克上方,可以藏身 |
| 雪地 | 打滑 | 穿过 | 松开方向键后还会滑一段 |

森林有个细节值得一提:最早的版本里,树冠是完全不透明的,坦克一开进去就彻底消失,连自己在哪都找不到。现在改成了半透明遮蔽——藏进去看得见轮廓,看不清细节,掩体的作用还在。

10 种敌人,逐关解锁
原版只有 4 种敌人。这里扩到了 10 种,每隔几关解锁一种新面孔,让 40 关一路都有新东西可看:
| 类型 | 特点 | 血 | 解锁 |
|---|---|---|---|
| 普通兵 | 基准型 | 1 | 第 1 关 |
| 快速兵 | 速度约为普通兵两倍 | 1 | 第 2 关 |
| 轻坦克 | 全场最快,贴脸骚扰 | 1 | 第 3 关 |
| 火力兵 | 子弹极快,能同时打两发 | 1 | 第 5 关 |
| 装甲兵 | 耐打,掉血会变色 | 4 | 第 8 关 |
| 狙击兵 | 弹速最快,瞄得极准 | 2 | 第 12 关 |
| 重炮兵 | 很慢,但子弹能打碎钢板 | 3 | 第 16 关 |
| 幽灵兵 | 周期性变成半透明 | 2 | 第 21 关 |
| 重坦克 | 又慢又厚,双发 | 8 | 第 26 关 |
| 精英兵 | 三连发 | 5 | 第 31 关 |
多血的敌人会随剩余血量从本色渐变到红色,还剩多少一眼看得出来。每关还有 4 辆闪红的敌人,打掉必掉道具。

每 5 关一场 Boss 战
第 5、15、25、35 关是小型 Boss「装甲首领」,第 10、20、30、40 关是大型 Boss「钢铁堡垒」——24 点血、子弹能破钢板,还会不定时来一发三向散射,逼你换位置而不是站桩对轰。

- Boss 排在出兵队列的最后,清完杂兵才出场,作为一关的高潮。
- Boss 体型比普通坦克大一圈,会碾碎挡路的墙(后面会讲为什么这是必须的)。
- 手雷道具对 Boss 只造成固定伤害,不能一颗秒杀——否则一个道具就把整场 Boss 战跳过去了。
吃星星,看得见的成长
原版的星星只加火力。这里吃一颗星星会同时发生三件事:等级 +1(弹速、同屏弹数、伤害上升,满级还能打碎钢板),血量上限 +1,车体外观换一档。
中弹时先扣血,没死就掉一级,车体退回上一档外观——不用看数字也知道自己刚被削弱了。
自己画关卡
主菜单里有关卡编辑器:用键盘或鼠标在 13×13 的格子上画地形,一键左右镜像,保存后可以直接试玩。

保存前会自动检查三件事:敌人能不能走到老鹰、玩家出不出得来、障碍物密度合不合理。画出死图时,编辑器会直接告诉你哪里不对,而不是等你开局才发现玩不了。
操作与难度
| 移动 | 开火 | |
|---|---|---|
| 玩家一 | W A S D | 空格 或 J |
| 玩家二 | 方向键 | 回车 或 小键盘 0 |
| 通用 | ESC / P 暂停 F11 全屏 M 静音(开火键按住可连射) | |
难度分四档:休闲(敌人不攻老巢,适合熟悉规则)、简单、普通(默认,接近原版街机的压迫感)、困难(AI 会包抄、躲子弹、预判射击,老鹰中一发就输)。
下载与运行
- Windows 直接玩:下载压缩包,解压后双击
坦克大战.exe。想装进开始菜单就双击同目录的安装.bat,不需要管理员权限。 - 从源码运行:
pip install pygame numpy,然后python main.py。numpy 只用来合成音效,没有它游戏照样能玩,只是没声音。
PyInstaller 打包的 EXE 偶尔会被杀毒软件误报。这个包没有加壳、没有用 UPX 压缩,也写全了版本信息;如果仍被拦截,可以改用源码运行。
二、开发中踩过的 10 个坑
下面这些不是”最佳实践清单”,每一条背后都有一次具体的翻车。按主题分成四组:素材与关卡、把”感觉”变成数字、游戏逻辑、Windows 打包。
坑 1:素材文件是打包的麻烦之源——那就一张都不要
游戏里的坦克、砖墙、爆炸、道具图标、8-bit 音效,全部是运行时用代码生成的。坦克是在 16×16 的逻辑网格上用矩形拼出来、再按需要缩放和旋转;音效是用 numpy 合成的方波和噪声。
好处在打包时最明显:PyInstaller 配置里不用声明任何资源文件,也就不存在”打包后找不到图片”这类问题。连程序图标都是用游戏自己的绘图函数画出来、再存成 .ico 的。
顺带一个 Pillow 的小坑:存多尺寸 ICO 时,Pillow 会以第一张图为源去缩放。我第一次把 16×16 的放在最前面,结果所有大尺寸都是放大出来的糊图,整个文件只有 332 字节。把最大的那张排到第一位就好了。
坑 2:40 张对称地图,手写一定会写歪——所以只写一半
13 个字符宽的地图行,多一格少一格肉眼根本看不出来,写 40 张必然有几张左右不对称。解决办法是只写左半张(7 列,第 7 列是中轴),右半张由程序镜像出来:
def _M(half_rows):
"""把 13 行 x 7 列的左半张图镜像成完整地图。"""
out = []
for h in half_rows:
assert len(h) == 7
out.append(h + h[:6][::-1]) # 中轴不重复
return out
# "..@.@.." → "..@.@...@.@.."
这样”地图必须对称”就不再是一条需要人记住的规矩,而是结构上不可能违反的事实。同样的思路贯穿整个项目:每当想写”注意不要……”的注释时,先想想能不能改成一个函数、一张表或一个断言,让错误根本写不出来。
坑 3:编辑器和测试各写一套校验,迟早会对不上
地图合不合法(敌人能否走到老鹰、出生点是否被堵、障碍物密度是否在 6%~58% 之间),编辑器保存时要查,自动化测试检查 40 张官方图时也要查。这两处调用的是同一个 validate_rows() 函数。
于是”能存进去的图一定能玩”和”官方图都合法”成了同一件事,不会出现编辑器放过、测试却报错(或者反过来)的情况。连通性用的是一个简单的 BFS:砖墙算可通行(因为能打掉),钢板和河流算墙。
坑 4:”辨识度要高”没法验收——除非把它变成一个数
“雪地和森林要一眼能认出来”是个主观需求,改几轮颜色很容易陷入”好像还行吧”的循环。我的做法是把它翻译成一条硬线:算每种地形贴图的平均亮度,要求它们拉开明显的档次。
forest = _lum(assets.tree_unit(0)) # 平均亮度,0~255
brick = _lum(assets.brick_unit())
snow = _lum(assets.snow_unit(0))
assert snow > 180, "雪地不够亮"
assert snow - forest > 120, "雪地和森林拉不开"
assert brick - forest > 25, "森林和砖墙太接近"
“森林不要完全挡住坦克”也是这样测的:分别渲染”林子里有坦克”和”林子里没坦克”两张图,要求那一格里至少 35% 的像素不同。不透明的树冠会让这个差值变成 0,测试立刻变红。
坑 5:难度调参别靠手感,写个机器人去打
调难度最容易出现的情况是:开发者自己打了几百遍,觉得”刚刚好”,新玩家一上手就被虐哭。我写了一个很笨的机器人玩家——不躲子弹、不捡道具,只会找最靠近老鹰的敌人对齐开火——让它自动把 40 关跑一遍,统计通关率和失败原因。
平衡模拟:40 关 x 机器人玩家(难度 普通)
用时 8.1s 通关 10/40 = 25%
失败分类:老巢被拆 11 全军覆没 19
这个数字推翻过我好几次判断。这是整个调参过程的记录:
| 阶段 | 默认难度 | 机器人通关率 | 发现了什么 |
|---|---|---|---|
| 第一次测 | 简单 | 21%(抽样 14 关) | 老巢被拆的案例里,六成是机器人自己打的(见坑 6) |
| 修好机器人 | 简单 | 60% | 仍有 32% 的关卡死于老巢被流弹拆掉 |
| 给老巢一次容错 | 简单 | 75% | 玩家实测反馈:太简单 |
| 整体收紧 | 普通 | 0%(抽样 14 关) | 老巢中位第 16 秒失守,最早只有 5 秒 |
| 定稿 | 普通 | 25% | 主要死因变成被敌人击毁,而不是开局被偷家 |
两条经验:
- 失败一定要分类统计。“失败 30 次”没有信息量;”老巢中位第 16 秒失守”直接指出了问题:不是敌人太强,而是太多敌人从开局就在隔墙轰老鹰。最后查到是一行
rush_p * 1.3,连不负责攻巢的杂兵也有近三成会去打老鹰。 - “难”和”挫败”是两个旋钮。把老鹰调得一碰就碎,数字上是变难了,体验上只是开局即结束。最终版的难度增量全部来自敌人本身:更多、更快、更准,外加 Boss。
另外要说清楚:这个机器人全程停在 1 级、只有一发子弹,所以 25% 是下界,只适合用来比较版本之间的变化,不能当成真人的通关率。
坑 6:测试红了,先怀疑测试本身
这个项目里,测试台自己出错了三次,每次看起来都像是游戏有问题:
- 机器人拆自家老巢。它出生在老鹰旁边,还会随机开火,10 次”老巢被拆”里有 6 次是它自己打的。真人不会这么干,所以加了一条”开火前检查弹道上是不是自家老鹰”。
- 换尺子量东西。调难度的同时,我顺手把机器人的底盘从 3 血的重装型换成了 1 血的标准型,结果数字骤降,根本分不清是难度变了还是机器人变弱了。量东西的尺子,中途不能换。
- 测试前提不干净。测”Boss 能碾开墙”时,我把 Boss 放在正式关卡里,它身上正好压着地图自带的钢板。”路不通”跟碾墙无关。改成在空白地图上测,才测到了真正想测的东西。
坑 7:判断”卡住了”,要看一段时间的净位移
参考代码里的 AI 用”相邻两帧位移小于 0.4 像素”来判断自己卡住了。平衡模拟跑到第 31 关时,敌我双方整整 200 秒一动不动:两辆坦克互相顶住,每帧都在原地来回抖零点几像素,这个判据永远不会触发。
# 每 1.2 秒看一次净位移,抖动骗不过它
self.jam_t += dt
if self.jam_t >= 1.2:
if abs(t.x - self.jam_pos[0]) < 10 and abs(t.y - self.jam_pos[1]) < 10:
self._break_jam() # 先吸附回整格,再随机换向
self.jam_pos = (t.x, t.y)
self.jam_t = 0.0
破僵局时的关键一步是先吸附回整格:卡死的坦克几乎总是半格错位、互相楔住,对齐之后往往就能走了。
坑 8:两辆车重叠后,只能转向、走不了一步
这是玩家实测时报上来的 bug:自己的坦克和敌方坦克重叠之后,按方向键只能转向,一步也挪不动。原因很朴素——移动前的碰撞检查把”已经压在自己身上的那辆车”也算成了障碍,于是四个方向的目标位置全都判定为被占。
# 先记下"当前就和我重叠"的坦克,本次移动不把它们算作障碍
me = self.rect
overlapping = {id(o) for o in others
if o is not self and o.alive and me.colliderect(o.rect)}
def free(dist):
r = me.move(dx * dist, dy * dist)
if tmap.rect_blocked(r):
return False
return not any(r.colliderect(o.rect) for o in others
if o is not self and o.alive
and id(o) not in overlapping)
这样两辆车能互相”穿”出去,分开的那一刻碰撞立刻恢复正常。短暂的穿模,比彻底动不了要好得多。修完之后加了一条回归测试:故意把两辆车摆成重叠,断言四个方向里至少有一个能动。
坑 9:Boss 比路还宽
Boss 的体型是 48 像素,而关卡里大量通道只有一格(32 像素)宽。起初我以为”会碾墙”只是个帅气的特性,后来发现它是必需品:不给 Boss 碾墙的能力,它会卡在出生点一整局。
这个改动还牵出一串连锁修改:所有写死了 TILE(32 像素)的地方——碰撞矩形、转向吸附、开火点、出生点空位检查、贴图——都要改成读坦克自己的 size。测试里也加了一条结构性检查:按 Boss 的实际体型在每个 Boss 关上跑一遍 BFS,确认它从出生点走得到老鹰所在的区域。只按单格检查的话,这类宽度问题是查不出来的。
坑 10:Windows 打包,三个”看起来像别的问题”的问题
① .bat 文件里的中文把命令劈成了两半。构建脚本报出这样的错:
'vel' is not recognized as an internal or external command
ERROR: Invalid requirement: 'numpy)'
看起来像脚本写错了,其实是编码问题:cmd 按当前控制台代码页逐字节解析批处理,而 chcp 65001 要到下一条命令才生效,UTF-8 存盘的中文就会让解析器错位。中文 Windows 的控制台默认是 GBK,所以最后的做法是:批处理源文件用 UTF-8 写(方便维护),发布前用一个小脚本转成 GBK,并且不写 chcp、不用括号复合语句。
② 路径里的 \v 变成了垂直制表符。用 Python 生成批处理时,字符串里的 tools\verify_build.py 被当成了转义,\v 变成了一个肉眼看不见的控制字符,报错信息里只剩下 toolserify_build.py。教训:Windows 路径要么用原始字符串,要么干脆写正斜杠。
③ 一个会假装成功的构建脚本。有一次构建一路打印到”构建完成”,打开发布包一看,里面根本没有 EXE——中间某一步把产物删掉了,而批处理的 copy 失败并不会让脚本停下来。后来把”产物是否存在、大小是否合理、压缩包里是否真有 EXE”交给一个 Python 脚本检查,任何一项不对就以非零退出码中止。