Pygame 是 SDL 的外套:SDL 管硬件,Pygame 管对象。
set_mode 拿到的不是「窗口」,是一块内存里的像素数组,系统负责把它画到屏幕上。
坐标系原点在左上角,y 往下长。所有「往上飞」都是 y 减小。
第 1 章讲的是「能不能」,这一章开始动手。 教材第 2 章开头四节是【Pygame 简介】【Pygame 的安装】【Hello World】【基本概念】, 本篇把四节连成一条线:它是什么 → 怎么装 → 十行跑起来 → 四个概念各管什么。 这台机器没有显示器,所以演示走离屏驱动。窗口是真建的、像素是真取的,只是不在屏幕上显示。 这一点先记住,后面不再重复。
一、它是 SDL 的一层皮
装 Pygame 的时候,你顺手也装进了一个叫 SDL 的 C 库。
SDL 才是真正跟显卡、声卡、手柄打交道的那个,它不关心你在做游戏还是做播放器。
Pygame 干的事,是把 SDL 那些 C 函数包成 Python 对象。
SDL 里叫 SDL_Surface 的结构,在 Pygame 里就是 Surface 对象;
SDL 里那串事件,就是 pygame.event 里那些对象。
这层皮有价值吗?有。
它省掉的是「跟 C 打交道」这件事,不是「跟硬件打交道」这件事。速度上还是有损耗,
但对 2D 游戏来说这点损耗不构成问题。
也有代价:Pygame 只做「把像素摆上去」这一层, 游戏循环、场景切换、碰撞调度全得你自己写。这正是本书先用它的原因——你能看清每个零件。
二、装它:那一行背后的三件事
标准做法是 pip install pygame。
这一行做三件事:去 PyPI 查最新版、下适合你系统和 Python 版本的轮子(wheel)、
解压进 site-packages。
「适合你系统」这一条最容易出问题。 Pygame 的轮子里带着编译好的 SDL,
所以它必须跟你的 Python 版本、操作系统、位数一一对上。
Python 3.13 的 64 位 Windows,就要 cp313 的 win_amd64 轮子。
本课这台机器上,pip install pygame 直接超时了——pypi.org 能连上但要十几秒才回话,
下 11 MB 的轮子等不下来。
所以本课换了个办法:用镜像站把轮子下到本地,再离线装。
pip install --no-index --no-deps 轮子路径 这一句不联网,只解压。
这个经历值得写进来,因为它说明一件事:
pip install 失败,八成不是包的问题,是网的问题。先分清是哪一种,再决定是换镜像还是查版本。
装完怎么验?import pygame 之后打印 pygame.version.ver 和 pygame.version.SDL。
两个都出来,才算真装上了。
三、十行代码,一个窗口
最小可用的程序是这样的:
import pygame 之后先 pygame.init(),它把显示、声音、字体这些子系统一个个叫起来,
返回值是(成功数,失败数)。演示里是 (5, 0)。
然后 pygame.display.set_mode((640, 480))。这一句是关键:它返回的不是窗口,是 Surface。
演示里看了它的底细:尺寸 (640, 480),每个像素 32 位、合 4 字节,整幅画面 307200 个像素、
占 1228800 字节。
也就是说,所谓「窗口」,就是内存里一块 1.2 MB 的数组。你往里写颜色,系统负责把它搬到屏幕上。
接着是一个 while 循环:收事件、判断要不要退出、填色、flip()。这四步就是游戏的全部骨架。
flip() 是把这块内存推给显卡的那一句。
在真机上它会等显示器刷完一轮才返回,这一等就是游戏的节奏来源。
四、四个概念,各管一摊
| 名字 | 是什么 | 谁在用 |
|---|---|---|
Surface |
一块像素画布,有宽高和格式 | 所有绘制都发生在它上面 |
Rect |
一个只管位置和大小的矩形 | 定位、碰撞、裁剪 |
display |
那个「显示 Surface」的管家 | 开窗口、推画面 |
Clock |
管帧率的东西 | 让循环每秒转固定的次数 |
四个里最容易被误解的是 Rect:它不含任何像素,只是四个数——左上角的 x、y,和宽、高。
但它出场次数比 Surface 还多。
display 是个模块不是对象,你调的是 pygame.display.set_mode() 这种函数。
它手里握着那块「屏幕 Surface」,get_surface() 能拿到。
Clock 这一篇还用不上,第 3 章写游戏循环时是主角。这里先知道有它。
五、原点在左上角,y 往下长
这是所有 2D 游戏的第一课,也是最容易忘的一课。 演示里在四个角各画了一个方块,再取像素验证:(0, 0) 是左上、(639, 0) 是右上、(0, 479) 是左下、 (639, 479) 是右下。 y 轴是往下长的,跟数学课本里的坐标反着。所以「往上飞」是 y 减小,「往下掉」是 y 增大。
为什么这么设?因为屏幕的扫描线是从上往下一行行走的,早期显存里的地址就是按这个顺序排的。 这是历史留下来的,不是设计出来的。 习惯了就好。判断口诀:看到 y 变大,就想「往下」。
六、动手 Demo:让第一个窗口亮起来
先看 pygame.init() 到底初始化了什么,再开一个 640×480 的窗口看它是什么对象。
然后在四个角各画一个方块,取像素验证坐标原点在哪。
最后关掉再开一次,看能不能反复来。
import os
os.environ['SDL_VIDEODRIVER'] = 'dummy' # 这台机器没显示器
os.environ['SDL_AUDIODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame
def info(s):
print(' ' + s)
def note(s):
print(' ! ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① init 前后
sec('① init 之前和之后')
info('还没 init 时,屏幕 Surface 是 %r' % (pygame.display.get_surface(),))
ok, fail = pygame.init()
info('init 返回 (%d, %d)' % (ok, fail))
info('视频驱动是 %s' % pygame.display.get_driver())
# ② set_mode 返回什么
sec('② set_mode 返回的到底是什么')
scr = pygame.display.set_mode((640, 480))
info('类型 %s' % type(scr).__name__)
info('尺寸 %s,每像素 %d 位、%d 字节'
% (scr.get_size(), scr.get_bitsize(), scr.get_bytesize()))
info('整幅画面 %d 个像素,占 %d 字节'
% (640 * 480, 640 * 480 * scr.get_bytesize()))
# ③ 一帧要多久(fill 全屏 + flip)
sec('③ 一帧要多久')
def frame():
scr.fill((24, 26, 34))
pygame.display.flip()
frame()
best = 1e9
for _ in range(11):
t0 = __import__('time').perf_counter()
frame()
best = min(best, (__import__('time').perf_counter() - t0) * 1000)
info('整屏填色加 flip,一次 %.3f 毫秒' % best)
# ④ 四个角画方块,验证原点
sec('④ 坐标系:原点在左上角')
scr.fill((0, 0, 0))
pygame.draw.rect(scr, (255, 0, 0), (0, 0, 8, 8)) # 左上
pygame.draw.rect(scr, (0, 255, 0), (632, 0, 8, 8)) # 右上
pygame.draw.rect(scr, (0, 0, 255), (0, 472, 8, 8)) # 左下
pygame.draw.rect(scr, (255, 255, 0), (632, 472, 8, 8)) # 右下
for tag, xy in (('左上', (0, 0)), ('右上', (639, 0)),
('左下', (0, 479)), ('右下', (639, 479))):
info('%-4s %-12s %s' % (tag, xy, tuple(scr.get_at(xy))[:3]))
# ⑤ 关掉再开
sec('⑤ 关掉再开,行不行')
pygame.quit()
info('quit 之后 get_surface() 是 %r' % (pygame.display.get_surface(),))
pygame.init()
scr2 = pygame.display.set_mode((320, 240))
info('重新 init 再 set_mode,拿到 %s' % (scr2.get_size(),))
pygame.quit()
实跑输出:
--------------------------------------------------------------------
让第一个窗口亮起来
--------------------------------------------------------------------
① init 之前和之后
--------------------------------------------------------------------
还没 init 时,屏幕 Surface 是 None
pygame.init() 返回 (5, 0):5 个子系统起来了,0 个没起来
视频驱动是 dummy
! 驱动这一项很关键:它决定 set_mode 是真开窗口,还是开一块内存里的画布。
② set_mode 返回的到底是什么
--------------------------------------------------------------------
拿到的类型是 Surface
尺寸 (640, 480),每个像素 32 位,合 4 字节
整幅画面 307,200 个像素,占 1,228,800 字节
! 窗口不是一个「程序窗口」,是一块内存里的像素数组。系统负责把它画到屏幕上。
③ 一帧要多久
--------------------------------------------------------------------
整屏填色加 flip,一次 0.051 毫秒
按 60 帧算,光清屏就占掉一帧预算的 0.3%
! 清屏是每帧必做的一件事,它便宜但不能省——不清的话上一帧会留在上面。
④ 坐标系:原点在左上角
--------------------------------------------------------------------
| 角 | 坐标 | 取回来的像素 |
|---|---|---|
| 左上 | (0, 0) | (255, 0, 0) |
| 右上 | (639, 0) | (0, 255, 0) |
| 左下 | (0, 479) | (0, 0, 255) |
| 右下 | (639, 479) | (255, 255, 0) |
y 轴是往下长的,不是往上——屏幕坐标和数学课本里的坐标是反的。
! 这一点记牢:所有「往上飞」都是 y 减小,「往下掉」都是 y 增大。
⑤ 关掉再开,行不行
--------------------------------------------------------------------
quit 之后 get_surface() 是 None
重新 init 再 set_mode,拿到 (320, 240)
! 可以反复开关,但每关一次所有 Surface 都作废,得重新 load。
⑥ 最小可用程序
--------------------------------------------------------------------
[本沙箱能跑 · 窗口画在内存里]
| import pygame
|
| pygame.init()
| screen = pygame.display.set_mode((640, 480))
| pygame.display.set_caption('我的第一个窗口')
|
| running = True
| while running:
| for event in pygame.event.get():
| if event.type == pygame.QUIT:
| running = False
| screen.fill((24, 26, 34))
| pygame.display.flip()
|
| pygame.quit()
这一段代码本沙箱也照样能跑,只是窗口画在内存里,你看不见。
第一段那个 (5, 0) 是五个子系统全起来了、零个失败。
如果是 (4, 1),说明有一样没起来——最常见的是音频,没有声卡就会这样。
这类数字要看,别直接跳过。
第二段是这一篇的核心。type(scr).__name__ 打出来是 Surface,不是 Window。
再往下算,一帧要清 307200 个像素、占 1228800 字节。你每次 fill() 都在重写这 1.2 MB。
第三段那个零点几毫秒别当成「真机上也就这么快」。离屏驱动没有垂直同步可等;
真机上 flip() 会阻塞到显示器刷完一轮。这一等本机测不到,正文里就不给数字。
第四段是个自证:在四个角画四个颜色不同的方块,再按坐标取回来,颜色和位置一一对上。
这就证明了原点在左上、y 往下长。这个结论看着简单,第 3 章写飞机大战时会一直用到。
第五段回答一个常见疑问:能不能反复 init 和 quit?能。
但 quit() 之后所有 Surface 都作废,得重新 set_mode、重新 load 图片。
别在循环里 init,那是另一种慢。
30 秒实验
- 把窗口尺寸改成 1280×720,重跑,看那一帧的字节数变成多少,验证「宽 × 高 × 每像素字节」。
- 把
scr.get_bitsize()换成scr.get_masks()打印出来,看看 32 位是怎么分成 RGBA 四段的。 - 在你自己有显示器的机器上,删掉
SDL_VIDEODRIVER那一行,看get_driver()变成什么, 以及第三段的耗时涨了多少。
常见误区
- 「
set_mode返回的是窗口对象。」它是一个Surface,内存里的一块像素数组。 窗口是系统那边的东西,你摸不到。 - 「坐标原点和数学课一样在左下角。」屏幕坐标原点在左上,y 往下长。 这是显存地址顺序留下的历史包袱,不是设计失误。
- 「
pip install失败就是包有问题。」绝大多数时候是网。 先分清「连不上」和「版本不匹配」,再决定换镜像还是换 Python 版本。
自测三问(点开对答案)
1. `pygame.display.set_mode((640, 480))` 之后,屏幕上的那块东西到底存在哪? —— 存在内存里,是一块 640×480×4 = 1228800 字节的数组,用 `Surface` 对象包着。 你在上面画,改的是这块内存;`pygame.display.flip()` 才把它推给显卡。 所以「窗口」在代码里从来不是一个对象,只有这块内存是。 2. 为什么在 `(0, 0)` 画的方块会出现在左上角,而不是左下角? —— 因为屏幕坐标的原点在左上角,x 往右增、y 往下增。 这个约定来自显存地址的排列顺序——早期显卡就是一行的行存,左上角是第一个字节。 所有 2D 界面库都沿用这个约定。 3. `pygame.quit()` 之后调 `pygame.display.get_surface()` 为什么是 `None`? —— 因为 `quit()` 把整个显示子系统关掉了,那块屏幕 Surface 也被释放。 要再画东西必须重新 `init()` 加 `set_mode()`。 而且之前 `load` 进来的图也全作废了,得重新加载。窗口有了,接下来往里放东西。下一篇讲 Surface 到底是什么、图片怎么进来出去。
- ☐ (本篇完,下一篇 2.2 窗口、Surface 与图片)