先列清单再动手,是为了出问题时能定位「是缺哪一块」。 状态全放一个字典里,暂停、存档、回放就都变成「拷一份」。 所有坐标按固定的设计分辨率算,别写死成「我这台电脑多大」。
第 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 秒实验
- 把
state里再加一个level之外的字段(比如combo),看快照恢复之后它是不是也回到原值。 - 把窗口尺寸改成 720×1280(手机竖屏的常见尺寸),重跑,看那两行数字怎么变。
- 把
deepcopy换成直接赋值snap = state,重跑,看恢复之后是不是真的回到原值——想想为什么。
常见误区
- 「先写代码,结构以后再说。」能跑是先决条件,但结构决定了你能改多少次。 这个游戏只有七块,现在不列清单,写到一半就不知道该往哪加了。
- 「状态用全局变量最省事。」一个两个还行,四个五个就开始出「这里改了那里没改」的 bug。 状态集中,才能整体备份和整体恢复。
- 「窗口多大就按多大写坐标。」换台机器就崩。先定设计分辨率,再按比例缩放到真实窗口。
自测三问(点开对答案)
1. 为什么「暂停」功能在状态集中之后变得很简单? —— 因为暂停需要「记下当前所有状态,恢复时一键还原」。 状态集中在一个字典里,`deepcopy` 一份就是存档,`update` 回去就是读档。 如果状态散在十几个变量里,你得挨个记、挨个还原,漏一个就出错。 2. 「设计分辨率」和「窗口尺寸」有什么区别?为什么要分开? —— 设计分辨率是你写代码时用的坐标系(比如 480×700),窗口尺寸是玩家屏幕上真实的像素数。 分开之后,换设备只要按比例缩放,所有坐标不用改。 混在一起的话,每换一个屏幕就要改一遍所有数字。 3. `snap = state` 和 `snap = copy.deepcopy(state)` 有什么不一样? —— 前者只是给同一个字典起了个别名,改 `state` 就是改 `snap`,快照根本没存下来。 后者是新建一个字典、把里面每一层都复制一遍,两者互不影响。 **这就是「引用」和「副本」的区别**,第 4 章讲可变类型时还会碰到。结构定完了,下一篇进循环——游戏真正「动」起来的地方。
- ☐ (本篇完,下一篇 3.2 游戏循环与加载图片)