🎮 第3章 《飞机大战》游戏
📄 一句话概括

先列清单再动手,是为了出问题时能定位「是缺哪一块」。 状态全放一个字典里,暂停、存档、回放就都变成「拷一份」。 所有坐标按固定的设计分辨率算,别写死成「我这台电脑多大」。

📝 本章和前面几章的关系

第 2 章把零件认完了,这一章用它们拼第一个完整游戏。 教材第 3 章开头四节:【项目介绍】【项目规划】【记录状态】【游戏窗口】。 这四节都在写代码之前——真正要写的东西,前面这一节比后面十几节还重要。 本篇不打一行游戏逻辑,只把「要做什么、分几块、状态放哪、窗口多大」定下来。

一、先说清楚要做的是什么

玩法三句话能说完:你在底部左右移动,自动往上打子弹,敌机从上往下掉,打中就加分, 漏过去不扣分但连着漏三架就结束。 玩法说得清,代码才写得动。说不清的时候写出来的东西,半路一定会推倒重来。 这个游戏刻意做小:没有关卡、没有道具、没有 boss。 因为它要演示的是「一个游戏怎么组织」,不是「一个游戏有多好玩」。

教材 3.1 节那句「本项目是一个简单的 2D 射击游戏」,就是这个意思。 简单不是偷懒,是把注意力留给结构。

二、拆成七块

模块 管什么 对应教材哪节
状态 分数、命数、暂停标志 3.3
窗口 开多大、标题、帧率 3.4
循环 每帧按顺序做四件事 3.5
素材 图片怎么进来、怎么复用 3.6
角色 背景、战机、子弹、敌机 3.7 到 3.10
界面 按钮、记分牌、结束框 3.11、3.12
声音与兜底 音效、异常处理 3.13、3.14

这张表的最大用处不是分工,是排错。画面不动了,你就按表往下查:循环转没转?状态改了没? 图贴了没?有表可查,比坐着发呆强。 七块里最容易被忽略的是最后一块。 新手写游戏很少想到「出错怎么办」,结果一个素材没找到,窗口卡死在屏幕上,进程还在后台跑。

三、状态放一处

先看散着写会怎么样:score 一个全局变量、lives 一个、paused 一个、level 一个, 散在文件各处。 能跑,但一到「暂停」「存档」「重开」就麻烦了——你得挨个记住有哪些变量要处理,漏一个就是 bug。 集中写法是一个字典:state = {'score': 0, 'lives': 3, 'paused': False, 'level': 1}。

好处立刻显现。演示里做了这么一件事:打到 300 分、掉一条命,然后 copy.deepcopy 拍一张快照; 接着让「误操作」把分数改成一万多、命改成 0;再把快照填回去——一切回到拍快照那一刻。 state.update(snap) 这一句就是「读档」。 暂停、存档、回放、撤销,本质上都是同一件事:把一份状态拷来拷去。 这也是为什么第 7 章做马里奥时,状态会单独放一个文件——项目一大,状态就是全局的公共地带, 必须一眼能看全。

四、窗口和设计分辨率

演示里开的是 480×700 的竖屏窗口。竖屏是因为这类游戏的老家在手机上,手指按住飞机左右划。 但真正要定下来的不是窗口多大,是「设计分辨率」多大。 意思是:游戏里所有坐标、所有距离,都按一个固定的尺寸来设计,比如 480×700。 真机上窗口可能是别的尺寸,那就整体缩放。

反过来做——按「我这台电脑屏幕多大」来写坐标——换台机器全乱套。 这是新手做游戏最容易留下的一笔技术债。 演示里算了一下:480×700 的画面,一个像素 4 字节,一共 1344000 字节,差不多 1.3 MB。 拿 32×32 当一格,能摆 15 列、21 行。 这些数不是给你背的,是让你对「一屏能放下多少东西」有个手感。后面排界面、算敌机数量,都靠这个手感。

五、目录先分好层

演示里真建了一棵目录树出来:main.py、config.py、game/ 三个文件、assets/ 三张图。 assets 放素材,game 放代码,根目录放入口和配置。素材单独放的好处是:换个皮不用动一行代码。 现在这些文件都是空的,看着像多此一举。 但等你写到第 7 章,角色类有八个、场景有四个的时候,这一层分层会救你。

一句话总结这一篇: 动手之前,把「做什么、分几块、状态在哪、坐标按谁算」四件事写下来。四件事写清楚, 后面的代码就是填空题。

六、动手 Demo:飞机大战:先规划,再开窗口

先把模块清单列出来,再把状态集中到一个字典里试暂停快照。 然后真开一个 480×700 的窗口,算一算它有多大。 最后真建一棵项目目录树出来。

import copy, os
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame

W, H = 480, 700

def info(s):
    print('  ' + s)

def note(s):
    print('  ! ' + s)

def sec(t):
    print(); print('  ' + t); print('  ' + '-' * 68)

# ① 状态集中放一处
sec('① 状态放一处,别散着')
state = {'score': 0, 'lives': 3, 'paused': False, 'level': 1}
info('开局:%s' % state)
state['score'] += 300
state['lives'] -= 1
info('打掉三架、被撞一次:%s' % state)

snap = copy.deepcopy(state)          # 拍快照
state['score'] += 9999               # 暂停期间被改乱
state['lives'] = 0
info('被改乱:%s' % state)
state.update(snap)                   # 读档
info('恢复快照:%s' % state)

# ② 窗口
sec('② 窗口开多大')
pygame.init()
scr = pygame.display.set_mode((W, H))
info('开一个 %dx%d 的竖屏窗口' % (W, H))
info('整幅画面 %d 字节' % (W * H * scr.get_bytesize()))
info('拿 32x32 当一格,能摆 %d 列 x %d 行' % (W // 32, H // 32))

# ③ 目录结构
sec('③ 目录怎么摆')
for rel in ('main.py', 'config.py', 'game/__init__.py',
            'game/states.py', 'game/sprites.py',
            'assets/plane.png', 'assets/enemy.png', 'assets/bullet.png'):
    p = os.path.join('proj', rel)
    os.makedirs(os.path.dirname(p), exist_ok=True)
    open(p, 'w').close()
pygame.quit()

实跑输出:

--------------------------------------------------------------------
飞机大战:先规划,再开窗口
--------------------------------------------------------------------

  ① 把这游戏拆成几块
  --------------------------------------------------------------------

| 模块 | 管什么 | 教材哪节 |
|---|---|---|
| 状态 | 分数、命数、暂停标志 | 3.3 记录状态 |
| 窗口 | 开多大、标题、帧率 | 3.4 游戏窗口 |
| 循环 | 每帧按顺序做四件事 | 3.5 游戏循环 |
| 素材 | 图片怎么进来、怎么复用 | 3.6 加载图片 |
| 角色 | 背景、战机、子弹、敌机 | 3.7 到 3.10 |
| 界面 | 按钮、记分牌、结束框 | 3.11、3.12 |
| 声音与兜底 | 音效、异常处理 | 3.13、3.14 |

  ! 先列清单再动手,是为了让「写不出来」这件事有个定位——是缺模块,不是缺灵感。

  ② 状态放一处,别散着
  --------------------------------------------------------------------
  开局:{'score': 0, 'lives': 3, 'paused': False, 'level': 1}
  打掉三架、被撞一次:{'score': 300, 'lives': 2, 'paused': False, 'level': 1}
  玩家按了暂停,这时候拍一张快照
  暂停期间被误操作改乱了:{'score': 10299, 'lives': 0, 'paused': False, 'level': 1}
  恢复快照:{'score': 300, 'lives': 2, 'paused': False, 'level': 1}
  ! 状态全在一个字典里,「暂停」「存档」「回放」就都变成「拷一份」。

  ③ 窗口开多大
  --------------------------------------------------------------------
  开一个 480×700 的竖屏窗口
  一个像素占 4 字节,整幅画面 1,344,000 字节
  如果拿 32×32 当一格,屏幕大概能摆 15 列 × 21 行
  ! 竖屏是因为手机。电脑上做可以直接用横屏,逻辑一模一样。

  ④ 目录怎么摆
  --------------------------------------------------------------------
  一个能长大的项目,一开始就分好层:
  ├── assets/
  │   ├── bullet.png
  │   ├── enemy.png
  │   └── plane.png
  ├── config.py
  ├── game/
  │   ├── __init__.py
  │   ├── sprites.py
  │   └── states.py
  └── main.py
  ! assets 放素材,game 放代码。素材单独放,换个皮不用动代码。

  ⑤ 设计分辨率与真实窗口
  --------------------------------------------------------------------
  游戏里所有坐标按 480×700 设计,别写死成「我电脑多大」。
  换台机器、换手机屏,按比例缩放就行;写死了就得改一堆数。
  ! 这条在真机上做游戏是硬要求。本课的例子全部按 480×700 算。

第一段那四行字典初始化,看着平淡,但它决定了后面所有代码的样子。 状态集中之后,「这一局得了多少分」只有一个来源,不会有「两个地方各记一份、对不上」这种事。 快照那一段要连着看:先 deepcopy 存下来,再随便改,最后 update 填回去。 三行就是「暂停」的完整实现。很多新手觉得暂停很难,其实难在状态散着,不难在暂停本身。 第二段那两个数字——1344000 字节、15 列 21 行——是为了让你对画面大小有手感。 1.3 MB 听起来不大,但一帧一帧地清、一帧一帧地画,累起来就是负担。 第三段这棵树里全是空文件,跑完就可以删。 它演示的是「一开始就把结构摆好」这个习惯,不是「必须这么建」。 项目小的时候怎么放都行,项目大了再改结构就得动所有 import。

30 秒实验

  1. 把 state 里再加一个 level 之外的字段(比如 combo),看快照恢复之后它是不是也回到原值。
  2. 把窗口尺寸改成 720×1280(手机竖屏的常见尺寸),重跑,看那两行数字怎么变。
  3. 把 deepcopy 换成直接赋值 snap = state,重跑,看恢复之后是不是真的回到原值——想想为什么。

常见误区

  1. 「先写代码,结构以后再说。」能跑是先决条件,但结构决定了你能改多少次。 这个游戏只有七块,现在不列清单,写到一半就不知道该往哪加了。
  2. 「状态用全局变量最省事。」一个两个还行,四个五个就开始出「这里改了那里没改」的 bug。 状态集中,才能整体备份和整体恢复。
  3. 「窗口多大就按多大写坐标。」换台机器就崩。先定设计分辨率,再按比例缩放到真实窗口。
自测三问(点开对答案) 1. 为什么「暂停」功能在状态集中之后变得很简单? —— 因为暂停需要「记下当前所有状态,恢复时一键还原」。 状态集中在一个字典里,`deepcopy` 一份就是存档,`update` 回去就是读档。 如果状态散在十几个变量里,你得挨个记、挨个还原,漏一个就出错。 2. 「设计分辨率」和「窗口尺寸」有什么区别?为什么要分开? —— 设计分辨率是你写代码时用的坐标系(比如 480×700),窗口尺寸是玩家屏幕上真实的像素数。 分开之后,换设备只要按比例缩放,所有坐标不用改。 混在一起的话,每换一个屏幕就要改一遍所有数字。 3. `snap = state` 和 `snap = copy.deepcopy(state)` 有什么不一样? —— 前者只是给同一个字典起了个别名,改 `state` 就是改 `snap`,快照根本没存下来。 后者是新建一个字典、把里面每一层都复制一遍,两者互不影响。 **这就是「引用」和「副本」的区别**,第 4 章讲可变类型时还会碰到。

结构定完了,下一篇进循环——游戏真正「动」起来的地方。

相关笔记