🎮 第7章 《超级马里奥》游戏
📄 一句话概括

HUD 摆在四个角,右对齐的那两个要减去文字宽度。 半透明遮罩每个通道各自算:原值乘(1 减透明度)再加上遮罩值乘透明度。 遮罩和提示文字要分两层,否则字也会被压暗。

📝 本章和前面几章的关系

上一篇定完了结构,这一篇把「画在最上面」的三样东西摆出来:菜单、HUD、提示层。 教材 7.3 到 7.8 节:【菜单背景】【菜单】【记录状态】【HUD】【提示场景】【提示层】。 六节听着多,其实都在回答一个问题:东西画在哪个位置、哪一层、什么顺序。 HUD 坐标、遮罩混色、文字居中,都是真算的。

一、HUD 摆在四个角

分数左上、金币左上偏下、时间右上、命数右上偏下——这是老游戏的标准布局。 演示里四个位置是:(20, 20)、(20, 60)、(780, 20)、(780, 60)。那个 780 是右边缘,不是起点。 右对齐的项目,画的时候要减去文字宽度。这一步太容易忘了,而忘了的后果很直观: 分数一到五位数就顶出屏幕。

演示算了三种宽度的数字:1 位宽 16 像素,起点 764;3 位宽 48,起点 732;5 位宽 80,起点 700。 排得整整齐齐,右边永远留 20 像素。

二、半透明遮罩

暂停的时候整个画面压暗一层,做法是盖一块半透明的黑。 算法每个通道各自算:新值 = 原值 × (1 - 透明度) + 遮罩值 × 透明度。遮罩是黑的,遮罩值就是 0, 所以只剩乘法。 演示里底色 (100, 150, 200),盖 60% 的黑,结果 (40, 60, 80)。心算一下:100 × 0.4 = 40,对得上。

透明度 0.3 时是 (70, 105, 140),0.9 时是 (9, 14, 19)。 0.9 已经几乎全黑了——超过 0.8 就看不清底下了。 这条算式在游戏里到处都用:阴影、天气、血条底衬、场景切换的淡入淡出。认一次,每处都能用。

三、遮罩和文字的顺序

顺序必须是:先画游戏 → 再画遮罩 → 最后画提示文字。 反过来先画文字再盖遮罩,字也被压暗了。新手最常见的做法就是「画完所有东西,最后统一盖一层」。 解决办法有两个:一是严格按顺序画,二是把提示文字放到更高的 z 层。

演示的 z 表就是这样:遮罩 200,提示文字 210。差 10 就够,不用差 100。 这也解释了为什么 z 要用 10、100、200 这种整数:留出间隔,中间想插东西随时插。

四、提示文字居中

居中就是「窗口宽减文字宽,再除二」。演示算了三个:「暂停」宽 48,起点 376; 「游戏结束」宽 96,起点 352;「第 1 关完成」宽 168,起点 316。 这里用的是等宽估算(每个字 24 像素)。 真做的时候,中文和数字宽度不同,得用字体对象量一下真实宽度。 不讲这个细节的后果:中英混排的提示语会明显偏左或偏右。肉眼一看就觉得别扭,但说不上哪里不对。

竖着居中同理,只是把宽度换成高度。别忘了字体本身有上行和下行留白,直接拿字号当高度会偏。

五、菜单背景

菜单背景用平铺:一张 256×256 的图铺 800×600 的窗口,需要 4 列 × 3 行 = 12 张。 算法跟俄罗斯方块那边一模一样,只是尺寸不同。同一套代码直接用,这就是「抽出来」的好处。 菜单背景要动的话,就用取模滚动。也是同一个套路。

三个项目写下来,你会发现平铺、取模、居中、遮罩这些「小算法」反复出现。 把它们写成函数放进一个工具文件,下次直接用。

六、菜单本身

菜单就是一堆按钮加一条排布规则,第五篇已经算过了竖排和横排。 这里多一件事:选中项要高亮。做法是把当前项的图片换成另一张,或者加一个框。 高亮的状态要跟着输入走:上键减一、下键加一、到头循环。三行,但要防越界。

演示里没跑菜单交互(那要真按键),但排布和高亮的原理跟 HUD 一样:都是算位置、按状态选图。

七、动手 Demo:菜单、HUD 与提示层的摆法

先把 HUD 摆到四个角,看右对齐为什么要减文字宽。 再算半透明遮罩的混色,看 0.3、0.6、0.9 三种透明度各是什么颜色。 最后算提示文字的居中位置,把 z 层排一遍。

import math

W, H = 800, 600

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

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

# ① HUD 四角
sec('① HUD 摆四角')
for name, x, y, al in (('分数', 20, 20, '左'), ('金币', 20, 60, '左'),
                       ('时间', W - 20, 20, '右'), ('命数', W - 20, 60, '右')):
    info('%-4s x = %3d  y = %d  %s对齐' % (name, x, y, al))

# ② 右对齐要减宽度
sec('② 右对齐减多少')
for text in ('0', '300', '12500'):
    w = len(text) * 16
    info('%-6s 宽 %2d,起始 x = %d' % (text, w, W - 20 - w))

# ③ 遮罩混色
sec('③ 半透明遮罩')
base = (100, 150, 200)
for a in (0.3, 0.6, 0.9):
    out = tuple(int(base[i] * (1 - a)) for i in range(3))
    info('透明度 %.1f -> %s' % (a, out))

# ④ 居中
sec('④ 文字居中')
for text in ('暂停', '游戏结束', '第 1 关完成'):
    w = len(text) * 24
    info('「%s」宽 %d,起始 x = %d' % (text, w, (W - w) // 2))

# ⑤ 平铺张数
sec('⑤ 菜单背景铺几张')
nx = int(math.ceil(W / 256.0))
ny = int(math.ceil(H / 256.0))
info('256 的图铺 %dx%d 的窗口:横 %d 张纵 %d 张,共 %d 张'
     % (W, H, nx, ny, nx * ny))

实跑输出:

--------------------------------------------------------------------
菜单、HUD 与提示层
--------------------------------------------------------------------

  ① HUD 摆在四个角
  --------------------------------------------------------------------

| 项目 | x | y | 对齐 |
|---|---:|---:|---|
| 分数 | 20 | 20 | 左对齐 |
| 金币 | 20 | 60 | 左对齐 |
| 时间 | 780 | 20 | 右对齐 |
| 命数 | 780 | 60 | 右对齐 |

  ! 右对齐的项目,x 写的是「右边缘」,画的时候要减去文字宽度。这一步很容易忘。

  ② 右对齐要减多少
  --------------------------------------------------------------------
  假设每个数字占 16 像素宽,字号固定

| 文字 | 宽度 | 实际起始 x |
|---:|---:|---:|
| 0 | 16 | 764 |
| 300 | 48 | 732 |
| 12500 | 80 | 700 |

  ! 数字变长的时候起点要往左挪——不减的话,分数一大就顶出屏幕。

  ③ 半透明遮罩怎么算
  --------------------------------------------------------------------
  游戏画面是 (100, 150, 200),暂停时盖一层黑,透明度 0.6
    透明度 0.3 -> (70, 105, 140)
    透明度 0.6 -> (40, 60, 80)
    透明度 0.9 -> (9, 14, 19)
  ! 每个通道各自算:新值 = 原值 × (1 - 透明度) + 遮罩值 × 透明度。黑就是 0,所以只剩乘法。

  ④ 遮罩别盖住提示文字
  --------------------------------------------------------------------
  顺序要这样:先画游戏 → 再画遮罩 → 最后画提示文字。
  反过来先画文字再盖遮罩,字也被压暗了。
  ! 或者把提示文字放到更高的 z 层。两种做法都行,混着用就会出「字看不见」的怪事。

  ⑤ 提示文字居中
  --------------------------------------------------------------------
    「暂停」宽 48,起始 x = (800 - 48) // 2 = 376
    「游戏结束」宽 96,起始 x = (800 - 96) // 2 = 352
    「第 1 关完成」宽 168,起始 x = (800 - 168) // 2 = 316
  ! 居中就是「窗口宽减文字宽,再除二」。中文字和英文数字宽度不同,混排时得逐字算。

  ⑥ z 层怎么排
  --------------------------------------------------------------------

| 层 | z | 放什么 |
|---|---:|---|
| 背景 | -10 | 远景、天空 |
| 地图 | 0 | 砖块、地面 |
| 角色 | 10 | 马里奥、敌人、金币 |
| HUD | 100 | 分数、金币、时间 |
| 遮罩 | 200 | 暂停的半透明黑 |
| 提示文字 | 210 | 暂停、结算的字 |

  ! 遮罩和文字分开两层,就是为了解决第 ④ 段那个顺序问题。

  ⑦ 菜单背景
  --------------------------------------------------------------------
  菜单背景用平铺:一张 256×256 的图铺 800×600 的窗口,需要 12 张
  跟俄罗斯方块那边一模一样的算法,只是尺寸不同。
  ! 同一套平铺代码,在两个项目里直接用。这就是「抽出来」的好处。

第一段那四个坐标,右对齐的两个写的是「右边缘 780」。 这个写法很多教程会搞混——写成起始点,右边就永远差一截。 第二段那个变化很直观:数字从 1 位变 5 位,起点从 764 挪到 700,右边始终留 20 像素。 第三段那个算式记住就行:新值 = 原值 × (1 - 透明度) + 遮罩值 × 透明度。遮罩是黑的就只剩乘法, 遮罩是白的就变成「往白里提」。 第四段用的是等宽估算。 真要精确得量字体,但做原型阶段用估算够了——偏几个像素看不出来,偏一半就明显了。 第五段那个 ceil 跟俄罗斯方块那边一字不差。这就是「小算法反复出现」的证据。

30 秒实验

  1. 把窗口宽从 800 改成 1024,重跑第二段,看三行起始 x 怎么变。
  2. 把 base 换成 (255, 255, 255),重跑第三段,看遮罩变成「往黑里压」还是「往白里提」。
  3. 在第五段里把图从 256 改成 300,重跑,看张数怎么变。

常见误区

  1. 「右对齐就把文字往右边一贴。」不减去宽度,数字一长就出屏。x 存右边缘,画的时候减宽度。
  2. 「画完所有东西最后盖一层遮罩最省事。」字也会被压暗。遮罩和文字分两层。
  3. 「居中除以二就行,不用管字宽。」会明显偏。中英混排尤其明显,得按实际宽度算。
自测三问(点开对答案) 1. 半透明的黑色遮罩盖在 (100, 150, 200) 上,透明度 0.6,结果是多少? —— (40, 60, 80)。每个通道乘 (1 - 0.6) = 0.4:100 × 0.4 = 40,150 × 0.4 = 60,200 × 0.4 = 80。 2. 为什么提示文字要和遮罩分两层? —— 因为画是有顺序的。先画文字再盖遮罩,字也被压暗了。 把文字放到更高的 z 层(比如 210),就不会被遮罩影响。 3. 分辨率 800 宽,一个 5 位数字每个数字 16 像素,右对齐留 20 像素边距,起始 x 是多少? —— 700。5 × 16 = 80,800 - 20 - 80 = 700。

界面摆好了,下一篇进游戏本身:一张地图、一堆砖块、和会闪的那种。

相关笔记