🎮 第2章 Pygame
📄 一句话概括

六个绘制函数都是「往某块 Surface 上涂」,第一个参数决定涂哪儿。 写字不是往屏幕上写,是先做一张文字图,再 blit 上去。 move 造新的、move_ip 改自己。这个坑几乎人人都踩。

📝 本章和前面几章的关系

这一篇讲「怎么往画布上放东西」和「怎么算位置」。 教材四节:【图形绘制】【文字显示】【再议屏幕显示】【Rect 对象】。 前三节是「画什么」,最后一节是「画在哪」。 位置这件事在游戏里比图形本身还重要,所以 Rect 单独占一节。 演示里每种图形画完都取像素回来验证,不是「画了就算」。

一、六个函数,一个套路

pygame.draw 底下有一组函数:rect、circle、ellipse、line、polygon、arc。 参数顺序是固定的:先给画在哪块 Surface 上,再给颜色,再给形状参数。 演示里六个各画一个,然后按坐标取回像素,颜色一一对上。 这一步不是多余的——画错了、位置偏了、颜色被后来的盖了,取像素立刻就发现。

最后一个 arc(弧线)那一格是空的,因为弧只有边、不填中心。这个差别在画进度环、血条圆弧时会碰到。

二、线宽是第七个参数

draw.rect(画布, 颜色, 矩形) 是填满,draw.rect(画布, 颜色, 矩形, 线宽) 是只画边框。 演示里做了对照:不给线宽时中心是白的,给了线宽 1 之后中心是黑的、边线是白的。 一块区域填不填,全看这个参数给不给。 同样的参数在 circle、ellipse 上也有效。画空心圆、画按钮边框,全靠它。

三、写字:先做图,再贴

这是 Pygame 里最容易想歪的一处。没有「在某个坐标写一行字」这种函数。 流程是三步:建一个字体对象、调 render() 得到一张文字图、再 blit 到目标上。 演示里那句 render() 返回的 Surface 是 96×32——四个汉字被做成了一张 96 像素宽的图。

想清楚这一点,很多事就顺了:文字可以旋转、可以缩放、可以设透明度、可以当按钮的贴图, 因为它就是一张图。

四、中文字体这事,绕不过去

pygame.font.Font(None, 24) 用的是 Pygame 自带的默认字体,那个字体只有拉丁字母和少量符号。 演示里对比得很清楚:'ab' 两个字母画出 160 个像素, '飞机' 两个汉字只画出 78 个——画出来的是缺字方框,不是字。 所以显示中文必须传一个带汉字的字体文件。 Windows 上是 msyh.ttc(微软雅黑)、simhei.ttf(黑体);macOS 上是 PingFang.ttc; Linux 上一般用 Noto Sans CJK。

发布游戏时不能写死本机路径,得把字体文件一起打包,用相对路径加载。 这件事第 7 章做完整项目时会碰到。

五、抗锯齿:199 种灰和 2 种灰

render(文字, True, 颜色) 第二个参数是抗锯齿开关。 演示里把两张图都贴到黑底上,数它们有多少种不同的灰度:开的 199 种,关的 2 种。 为什么差这么多?关掉的时候每个像素非黑即白; 开着的时候,字的边缘用不同深浅的灰过渡,看起来才不毛糙。

代价是渲染慢一点、内存多一点。字号大、字少的时候开着; 字号很小(十像素上下)、字很多的时候,关掉反而更清楚——灰阶在小尺寸上会糊成一团。

六、再议屏幕显示:贴的顺序就是层级

Pygame 没有「图层」这个概念。谁在上面,全看谁后贴。 演示里先把红块贴到 (40,40),再把蓝块贴到 (80,80),重叠处是蓝的; 把两句的顺序倒过来,同一个点变成红的。 简单到有点粗暴,但足够用。要管层级就在代码里排好顺序:背景先贴、角色其次、界面最后。

这也解释了一个常见 bug:「我明明画了,怎么看不见」——多半是被后画的东西盖住了, 或者画到画面外面去了。

七、Rect:只管位置和大小

pygame.Rect(10, 20, 30, 40) 是 x=10、y=20、宽 30、高 40。它不含一个像素,只是四个数。 但它有一堆现成的属性:topleft、center、midbottom、right、bottom……这些全是现算的, 改一个另几个跟着变。 这个设计很好用。 比如要把一个按钮摆到屏幕底部居中,直接写 r.midbottom = (宽//2, 高),一行搞定,不用自己算减法。

常用的还有 union(两个矩形的最小包围盒)、clip(两者的重叠部分)、inflate(原地放大缩小)、 colliderect(有没有重叠)。

八、move 和 move_ip,差两个字母

a.move(5, 5) 返回一个新的 Rect,a 自己不动。a.move_ip(5, 5) 直接把 a 挪走。 演示里两个都写了一遍:前者 a 还是 (10, 20),返回值是 (15, 25);后者 c 变成了 (15, 25)。 带 ip 的是 in-place。这个命名规则在 Pygame 里到处都是——scale 与 scale_ip、 clamp 与 clamp_ip,一个规律。

什么时候用哪个?循环里挪角色用 move_ip(不用每帧造新对象); 算「如果往右一步会在哪」用 move(不动原对象)。

九、动手 Demo:画图形、写字、算 Rect

六种图形各画一个,再按坐标取像素回来验证。 然后对比抗锯齿开关的效果——用灰度种类数说话。 最后把 Rect 的属性、move 与 move_ip 的差别逐个跑一遍。

import os
os.environ['SDL_VIDEODRIVER'] = '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)

pygame.init()
scr = pygame.display.set_mode((320, 240))

# ① 六个绘制函数
sec('① 六种图形,各画一个')
scr.fill((0, 0, 0))
pygame.draw.rect(scr, (255, 0, 0), (10, 10, 60, 40))
pygame.draw.circle(scr, (0, 255, 0), (120, 30), 25)
pygame.draw.ellipse(scr, (0, 0, 255), (160, 10, 80, 40))
pygame.draw.line(scr, (255, 255, 0), (10, 80), (290, 80), 3)
pygame.draw.polygon(scr, (255, 0, 255), [(30, 110), (60, 160), (0, 160)])
pygame.draw.arc(scr, (0, 255, 255), (120, 100, 80, 60), 0, 3.14, 3)
for tag, xy in (('矩形', (40, 30)), ('圆', (120, 30)), ('椭圆', (200, 30)),
                ('线', (150, 80)), ('多边形', (30, 143))):
    info('%-6s %-12s %s' % (tag, xy, tuple(scr.get_at(xy))[:3]))

# ② 线宽
sec('② 线宽是第七个参数')
scr.fill((0, 0, 0))
pygame.draw.rect(scr, (255, 255, 255), (10, 10, 60, 40))
info('不给线宽:中心 %s' % (tuple(scr.get_at((40, 30)))[:3],))
scr.fill((0, 0, 0))
pygame.draw.rect(scr, (255, 255, 255), (10, 10, 60, 40), 1)
info('线宽 1:中心 %s,边线 %s'
     % (tuple(scr.get_at((40, 30)))[:3], tuple(scr.get_at((10, 30)))[:3]))

# ③ 写字
sec('③ 写字:先做出一个 Surface,再贴')
f = pygame.font.Font('C:/Windows/Fonts/msyh.ttc', 24)
txt = f.render('飞机大战', True, (255, 255, 255))
info('render 返回 Surface,尺寸 %s,行高 %d' % (txt.get_size(), f.get_height()))

# ④ 抗锯齿
sec('④ 抗锯齿:开了会怎样')
def grays(s):
    bg = pygame.Surface(s.get_size())
    bg.fill((0, 0, 0))
    bg.blit(s, (0, 0))
    st = set()
    for x in range(bg.get_width()):
        for y in range(bg.get_height()):
            st.add(bg.get_at((x, y))[0])
    return len(st)

on = f.render('游戏', True, (255, 255, 255))
off = f.render('游戏', False, (255, 255, 255))
info('开抗锯齿 %d 种灰度,关掉 %d 种' % (grays(on), grays(off)))

# ⑤ 贴的顺序
sec('⑤ 谁盖在谁上面,看贴的顺序')
red = pygame.Surface((80, 80)); red.fill((255, 0, 0))
blue = pygame.Surface((80, 80)); blue.fill((0, 0, 255))
scr.fill((0, 0, 0))
scr.blit(red, (40, 40)); scr.blit(blue, (80, 80))
info('先红后蓝,重叠处 (90,90) 是 %s' % (tuple(scr.get_at((90, 90)))[:3],))
scr.fill((0, 0, 0))
scr.blit(blue, (80, 80)); scr.blit(red, (40, 40))
info('倒过来,同一个点变成 %s' % (tuple(scr.get_at((90, 90)))[:3],))

# ⑥ Rect 属性
sec('⑥ Rect:只管位置和大小')
r = pygame.Rect(10, 20, 30, 40)
info('x=%d y=%d w=%d h=%d' % (r.x, r.y, r.width, r.height))
info('topleft %s  center %s  midbottom %s'
     % (r.topleft, r.center, r.midbottom))

# ⑦ move 与 move_ip
sec('⑦ move 和 move_ip,差一个字母差很多')
a = pygame.Rect(10, 20, 30, 40)
b = a.move(5, 5)
info('a.move(5,5):a 还是 %s,返回值是 %s' % (a.topleft, b.topleft))
c = pygame.Rect(10, 20, 30, 40)
c.move_ip(5, 5)
info('c.move_ip(5,5):c 变成 %s' % (c.topleft,))
pygame.quit()

实跑输出:

--------------------------------------------------------------------
画图形、写字、算 Rect
--------------------------------------------------------------------

  ① 六种图形,各画一个
  --------------------------------------------------------------------
  矩形、圆、椭圆、线、多边形、弧线,六个函数各画一个

| 图形 | 调的函数 | 验证点 | 取回来的像素 |
|---|---|---|---|
| 矩形 | draw.rect | (40, 30) | (255, 0, 0) |
| 圆 | draw.circle | (120, 30) | (0, 255, 0) |
| 椭圆 | draw.ellipse | (200, 30) | (0, 0, 255) |
| 线 | draw.line | (150, 80) | (255, 255, 0) |
| 多边形 | draw.polygon | (30, 143) | (255, 0, 255) |
| 弧 | draw.arc | (160, 130) | 空(弧只有边) |

  ! 六个函数都是「往某块 Surface 上涂」,第一个参数是谁,涂哪儿就定了。

  ② 线宽是第七个参数
  --------------------------------------------------------------------
  不给线宽:中心 (40,30) 是 (255, 255, 255)(整块填满)
  线宽 1:中心 (40,30) 是 (0, 0, 0)
          边线 (10,30) 是 (255, 255, 255)
  ! 不给线宽就填满,给了就只画框。这个参数在画血条、按钮边框时天天用。

  ③ 写字:先做出一个 Surface,再贴
  --------------------------------------------------------------------
  render 返回的还是 Surface,尺寸 (96, 32)
  字体对象的行高是 32,文字宽度是 96
  ! 写字不是「往屏幕上写」,是「先做一张文字图,再 blit 上去」。
  默认字体(不给路径)只有拉丁字母:
    render('Hello') 尺寸 (41, 16)

| 渲染的内容 | 图尺寸 | 有笔画的像素 |
|---|---:|---:|
| 'ab'(拉丁字母) | (20, 16) | 160 |
| '飞机'(汉字) | (14, 16) | 78 |

  ! 两个汉字只画出 78 个像素,比两个字母还少——画出来的是缺字方框,不是字。
  所以要显示中文,必须传一个带汉字的字体文件,像上面那个 msyh.ttc。

  ④ 抗锯齿:开了会怎样
  --------------------------------------------------------------------

| 抗锯齿 | 图尺寸 | 贴到黑底后有多少种灰度 |
|---|---:|---:|
| 开(True) | (48, 32) | 199 |
| 关(False) | (48, 32) | 2 |

  ! 开了抗锯齿,边缘靠深浅不同的像素过渡,灰度种类就多了。
  代价是渲染慢一点。文字多、字号小的界面,关掉反而更清楚。

  ⑤ 谁盖在谁上面,看贴的顺序
  --------------------------------------------------------------------
  先贴红再贴蓝,重叠处 (90,90) 是 (0, 0, 255)
  把顺序倒过来,同一个点变成 (255, 0, 0)
  ! 没有 z 轴、没有层级,谁后贴谁在上面。简单,但足够用。

  ⑥ Rect:只管位置和大小
  --------------------------------------------------------------------
  建一个 Rect(10, 20, 30, 40):x=10 y=20 w=30 h=40

| 属性 | 值 | 意思 |
|---|---|---|
| topleft | (10, 20) | 左上角 |
| center | (25, 40) | 中心点 |
| midbottom | (25, 60) | 底边中点 |
| right / bottom | 40 / 60 | 右边、下边 |

  ! 这些属性都是现算的,改一个另几个跟着变。写 midbottom 就等于把整块挪过去。

  ⑦ move 和 move_ip,差一个字母差很多
  --------------------------------------------------------------------
  a.move(5, 5) 之后:a 是 (10, 20),返回值是 (15, 25)
  a 自己没动——move 返回一个新 Rect
  c.move_ip(5, 5) 之后:c 是 (15, 25)
  ! 带 ip 的是 in-place,改自己;不带的是造一个新的。这个坑几乎人人都踩。

  ⑧ 几个常用算式
  --------------------------------------------------------------------
  union 两个矩形:(0, 0, 130×130)
  clip 把 s1 裁进 big:(80, 80, 20×20)
  再裁一个完全在外的:(0, 0, 0×0)
  ! clip 返回的是「两者重叠的部分」,不重叠就是一个空矩形(宽高为 0)。

第一段那五个坐标全都能取回正确的颜色,说明六个函数的位置参数都按「矩形左上角 + 宽高」算。 弧线那一格故意留空,因为它只画边、不填中心,取中心当然是黑的。 第二段是本篇最实用的一行知识。同一个矩形、同一个位置,只差最后一个参数,中心一个是白一个是黑。 画血条、画选择框、画按钮边框,全靠这个参数。 第三段那句 render 返回 96×32,不是「四个字占 96 像素宽」这么简单——它是把字排好版、 连行间距一起做好的一张图。所以你没法直接改图里的字,只能重新 render。 第四段那 199 和 2 差了两个数量级。 抗锯齿的原理是:笔画边缘那些「只盖住半个像素」的地方,用半灰补上,所以灰度种类一下就多了。 关掉就只剩「盖住」和「没盖住」两种。 第五段那个现象在实际写游戏时会反复出现:「明明画了却看不见」,八成是被后画的盖住了。排查顺序: 先看画没画、再看坐标在不在画面里、最后看是不是被盖了。 第七段两个方法只差 _ip,结果完全不同。 记忆法:ip 是 in-place,动自己。这个规律在 Pygame 里一以贯之,不光 Rect 有。

30 秒实验

  1. 把 draw.rect 的线宽从 1 改成 5,再看中心像素,想想为什么还是黑的。
  2. 把 draw.arc 的起始角从 0、结束角从 3.14 改成 1.57 到 4.71,看弧跑到哪个方向去了。
  3. 把 render 的抗锯齿参数换掉,同时把字号从 24 改成 10,再看那两个灰度数——小字号下还差这么多吗?

常见误区

  1. 「有个函数能直接在屏幕上写字。」没有。 写字就是先 render 出一张图,再 blit 上去。想通这一点,文字旋转、缩放、当贴图就都顺了。
  2. 「Pygame 有图层,可以设 z 轴。 」没有层级概念,谁后 blit 谁在上面。 要管层级就在代码里排顺序。
  3. 「move 会把矩形挪走。」move 返回新矩形,原对象不动。 要改自己得用 move_ip。 带 ip 的都是改自己。
自测三问(点开对答案) 1. 为什么 `draw.rect(…, 线宽=1)` 画出来的矩形,中心取像素是黑的? —— 因为给了线宽就只画边框、不填内部。 `draw.rect` 的第四个参数是线宽,不给(或给 0)才填满整个矩形。 中心是空的,所以取回来是背景色。 2. 用 Pygame 默认字体渲染中文,为什么画出来是方框? —— 因为 Pygame 自带的默认字体(`freesansbold.ttf`)只有拉丁字母和少量符号,没有汉字字形。 渲染时找不到对应字形,就画成缺字方框(俗称豆腐块)。 要显示中文必须传一个带汉字的字体文件,比如 `msyh.ttc`。 3. 同一张图、同样的坐标系,为什么「先贴红再贴蓝」和「先贴蓝再贴红」结果不同? —— 因为 Pygame 没有图层,`blit` 就是**直接往像素上覆盖**。后贴的会把先贴的盖掉。 所以绘制顺序就是层级顺序——背景最先贴,角色其次,界面最后。

图形会画了,接下来让它们动起来:事件、精灵、碰撞,还有那个只能建不能响的声音。

相关笔记