用 Python 复刻红白机《坦克大战》

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:测试红了,先怀疑测试本身

这个项目里,测试台自己出错了三次,每次看起来都像是游戏有问题:

  1. 机器人拆自家老巢。它出生在老鹰旁边,还会随机开火,10 次”老巢被拆”里有 6 次是它自己打的。真人不会这么干,所以加了一条”开火前检查弹道上是不是自家老鹰”。
  2. 换尺子量东西。调难度的同时,我顺手把机器人的底盘从 3 血的重装型换成了 1 血的标准型,结果数字骤降,根本分不清是难度变了还是机器人变弱了。量东西的尺子,中途不能换。
  3. 测试前提不干净。测”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 脚本检查,任何一项不对就以非零退出码中止。