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

项目一大,第一件事不是写代码,是分层:场景、角色、控制、数据。 八种角色里只有四种会动,但八种都要参与碰撞,所以它们是一个基类。 关卡两百格宽、窗口只有二十五格,所以必须有横向滚动的镜头。

📝 本章和前面几章的关系

前面两章一个做完了《俄罗斯方块》,一个认完了 Cocos2d 的进阶功能。 这一章做第三个、也是最大的一个项目:超级马里奥。 教材第 7 章开头两节:【项目介绍】【项目规划】【游戏窗口与菜单场景】。 这一篇还是不打一行游戏逻辑,只把「怎么分层、有哪几个东西、窗口多大」定下来。

一、先分层,再谈内容

前两个项目小,随便怎么写都能改。 这个项目不行——八种角色、两百格地图、四个场景,混在一起写,改一处崩三处。

层 管什么 不该管什么
场景层 谁现在在演:菜单、游戏、暂停、结算 具体画什么
角色层 每个角色自己的状态和规则 别的角色怎么想
控制层 输入、物理、碰撞、计时 画面长什么样
数据层 地图、参数、素材索引 任何逻辑

规矩只有一条:上层可以问下层要数据,下层不许反过来调上层。 为什么这条这么重要?因为「谁调谁」决定了你改代码时会不会牵连一片。 角色层里写一句「播放暂停菜单」,第二天想加个新场景就得改角色代码——这种耦合会越滚越大。 分层的成本是一开始多写几个文件。收益是写到第八个角色的时候,你还知道东西该放哪。

二、八种角色

角色 会什么 怎么死
马里奥 走、跑、跳、顶砖、踩敌人 碰敌人、掉坑、时间到
板栗仔 左右走,撞墙回头 被踩、被顶
乌龟 左右走,撞墙回头 被踩变壳
乌龟壳 被踢了会滑 撞墙反弹
食人花 上下伸缩 打不死
金币 原地转 被吃到
砖块 能被顶碎或顶出东西 被顶
旗杆 不动的终点 碰到就通关

八种里只有四种会移动:马里奥、板栗仔、乌龟、乌龟壳。 食人花原地伸缩,金币原地转,砖块和旗杆干脆不动。 但八种都要参与碰撞——所以它们都是「角色」,都从同一个基类派生。下一篇会看到, 基类只放位置速度和重力,各自特殊的那几行写在子类里。

三、状态

这个项目的状态比俄罗斯方块多两个:命(lives)和时间(time)。 演示里跑了一段:吃金币加 200 分,金币凑满 100 加一条命。那三行代码就是「加命」的全部实现。 状态集中放的好处,到这里已经是第三次讲了。 这一次多一个理由:这个项目有存档需求。玩到第 3 关退出,下次接着玩——存的就是这个字典。

时间那一项要注意:它是「倒计时」,每帧减。 但减的是真实时间,不是帧数。按帧减的话,机器快慢会让倒计时快慢不一样。

四、窗口与关卡尺寸

窗口 800×600,瓦片 32 像素,横着 25 格、竖着 18 格。 典型关卡 200 格宽,也就是 6400 像素,是窗口的 8 倍。 这 8 倍就是「为什么必须滚动镜头」的全部理由。窗口装不下世界,镜头就得跟着人走。

对比一下前面两个项目:飞机大战和俄罗斯方块的世界都只有一屏,所以镜头永远不动。 镜头动不动,是这三个项目最大的结构差别。 滚动本身很简单:镜头 x = 角色 x - 窗口宽 / 2,再钳到 0 到 地图宽 - 窗口宽 之间。 两行,但决定了整个画面怎么动。

五、场景栈:深了一层

演示跑了八步:加载 → 主菜单 → 游戏 → 暂停 → 提示层 → 结算,全程栈最深只有 2。 看起来跟俄罗斯方块一样。但这一章多了一个东西:提示层。 提示层是叠在游戏场景上面的一层(比如「按任意键继续」),不是一整个场景。 所以它用 push 而不是 replace——底下的游戏画面还要留着,只是暂时不响应输入。

这就是「场景」和「层」的差别在实际项目里的样子。第五篇会看到, 这个差别直接决定「按返回键回到哪」。

六、三个项目横向对比

对比项 飞机大战 俄罗斯方块 超级马里奥
镜头 不动 不动 跟着角色横向滚
世界 一屏 一屏 两百格宽
角色数 4 类 1 类 8 类
地图 无 10×20 固定 按数据摆

看出规律没有:每往后一个项目,复杂度就往上加一层,但骨架一次都没变。 状态、场景、主循环、碰撞、绘制——这五样在三个项目里是同一套。这也是这本书真正要教的东西。 换一个引擎、换一门语言,这五样还是要这么搭。API 会变,结构不会。

七、动手 Demo:超级马里奥:先分层,再开窗口

先把四层和八种角色列清楚,再看状态怎么变。 然后算窗口和关卡的比例,看为什么必须滚动镜头。 最后跑一遍场景栈的八步,注意提示层用的是 push 不是 replace。

W, H, TILE = 800, 600, 32

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

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

# ① 状态
sec('① 状态放一处')
st = {'lives': 3, 'coins': 0, 'score': 0, 'time': 300, 'level': 1}
info('开局 %s' % st)
st['coins'] = 99
for _ in range(2):
    st['coins'] += 1
    st['score'] += 200
    if st['coins'] % 100 == 0:
        st['lives'] += 1
        info('金币凑满 100,加一条命')
info('现在 %s' % st)

# ② 窗口 vs 关卡
sec('② 窗口装不下世界')
info('窗口 %dx%d,瓦片 %d 像素,横 %d 格竖 %d 格'
     % (W, H, TILE, W // TILE, H // TILE))
level = 200
info('关卡 %d 格 = %d 像素,是窗口的 %.1f 倍'
     % (level, level * TILE, level * TILE / float(W)))

# ③ 镜头
sec('③ 镜头跟随')
def cam(px):
    c = px - W // 2
    return max(0, min(level * TILE - W, c))
for px in (0, 200, 400, 3000, 6300):
    info('角色在 x = %4d -> 镜头 x = %d' % (px, cam(px)))

# ④ 场景栈
sec('④ 场景栈')
stack, top = [], [0]
def push(s):
    stack.append(s); top[0] = max(top[0], len(stack))
    info('push(%s)  栈 %s' % (s, stack))
def pop():
    info('pop()  弹出 %s,栈 %s' % (stack.pop(), stack))
def replace(s):
    old = stack.pop(); stack.append(s)
    info('replace(%s)  换掉 %s,栈 %s' % (s, old, stack))
push('加载'); replace('主菜单'); replace('游戏')
push('暂停'); pop(); push('提示层'); pop(); replace('结算')
info('全程最深 %d 个' % top[0])

实跑输出:

--------------------------------------------------------------------
超级马里奥:先分层,再谈内容
--------------------------------------------------------------------

  ① 项目一大,第一件事是分层
  --------------------------------------------------------------------

| 层 | 管什么 | 不该管什么 |
|---|---|---|
| 场景层 | 谁现在在演:菜单、游戏、暂停、结算 | 具体画什么 |
| 角色层 | 每个角色自己的状态和规则 | 别的角色怎么想 |
| 控制层 | 输入、物理、碰撞、计时 | 画面长什么样 |
| 数据层 | 地图、参数、素材索引 | 任何逻辑 |

  ! 四层之间只允许「上层问下层要数据」,不许下层反过来调上层。

  ② 八种角色
  --------------------------------------------------------------------

| 角色 | 会什么 | 怎么死 |
|---|---|---|
| 马里奥 | 走、跑、跳、顶砖、踩敌人 | 碰敌人、掉坑、时间到 |
| 板栗仔 | 左右走,撞墙回头 | 被踩、被顶 |
| 乌龟 | 左右走,撞墙回头 | 被踩变壳 |
| 乌龟壳 | 被踢了会滑 | 撞墙反弹 |
| 食人花 | 上下伸缩 | 打不死 |
| 金币 | 原地转 | 被吃到 |
| 砖块 | 能被顶碎或顶出东西 | 被顶 |
| 旗杆 | 不动的终点 | 碰到就通关 |

  ! 八种角色里只有前四种会移动。不会动的那些也是「角色」,因为它们都要参与碰撞。

  ③ 状态
  --------------------------------------------------------------------
  开局 {'lives': 3, 'coins': 0, 'score': 0, 'time': 300, 'level': 1}
  吃一个金币 {'lives': 3, 'coins': 1, 'score': 200, 'time': 300, 'level': 1}
  金币凑满 100,加一条命
  现在 {'lives': 4, 'coins': 100, 'score': 400, 'time': 300, 'level': 1}
  ! 这个游戏的状态比俄罗斯方块多「命」和「时间」,其他结构一模一样。

  ④ 窗口与设计分辨率
  --------------------------------------------------------------------
  窗口 800×600,瓦片 32 像素
  横着 25 格,竖着 18 格
  典型关卡宽度 200 格 = 6400 像素,是窗口的 8.0 倍
  ! 关卡很宽、窗口很窄,所以必须滚动镜头。这是它跟俄罗斯方块最大的结构差别。

  ⑤ 场景栈:比前两个游戏深一层
  --------------------------------------------------------------------
    push(加载)  栈 ['加载']
    replace(主菜单)  换掉 加载,栈 ['主菜单']
    replace(游戏)  换掉 主菜单,栈 ['游戏']
    push(暂停)  栈 ['游戏', '暂停']
    pop()  弹出 暂停,栈 ['游戏']
    push(提示层)  栈 ['游戏', '提示层']
    pop()  弹出 提示层,栈 ['游戏']
    replace(结算)  换掉 游戏,栈 ['结算']
  全程最深的时候栈里有 2 个
  ! 「提示层」是叠在游戏上面的一层,不是整个场景——所以它用 push 而不是 replace。

  ⑥ 三个项目的结构对比
  --------------------------------------------------------------------

|  | 飞机大战 | 俄罗斯方块 | 超级马里奥 |
|---|---|---|---|
| 镜头 | 不动 | 不动 | 跟着角色横向滚 |
| 世界 | 一屏 | 一屏 | 两百格宽 |
| 角色数 | 4 类 | 1 类(方块) | 8 类 |
| 地图 | 无 | 10×20 固定 | 按数据摆 |

  ! 三个项目一层比一层复杂,但骨架——状态、场景、循环、碰撞——一次都没变。

第一段那个「金币凑满 100 加命」,代码只有三行。 游戏里那些看起来复杂的奖励规则,拆开都是这种三行。 第二段那个 8 倍是这一篇的关键数字。 它解释了为什么这个项目复杂一个量级——一旦世界比窗口大,镜头、裁剪、地图数据就全来了。 第三段那个 cam() 有两行:先算「角色居中」,再钳到合法范围。 不加钳制,角色走到关卡最左边,画面就会露出地图外面的空白。 第四段注意「提示层」用的是 push。 底下的游戏画面还留着,只是暂时不接收输入。用 replace 的话,关掉提示就得重进游戏。 整段代码不依赖 Cocos2d,纯 Python 能跑。规划阶段的代码本来就该跟引擎无关。

30 秒实验

  1. 把 level 从 200 改成 25,重跑第三段,看镜头还动不动。
  2. 把窗口宽改成 400,重跑第三段,看镜头位置怎么变。
  3. 把第四段那一步 push('提示层') 改成 replace('提示层'),看栈的变化和退出后的状态。

常见误区

  1. 「分层是给大团队用的,我一个人写不用。」一个人写更要分。 三个月后你就是那个「不熟悉代码的新人」。
  2. 「八种角色写八个类,各写各的。」位置、速度、重力、边界钳制会抄八遍。 提一个基类,八份变一份。
  3. 「镜头跟着角色就行,不用钳制。」走到关卡边缘,画面会露出地图外的空白。 钳到 0 到 地图宽 - 窗口宽。
自测三问(点开对答案) 1. 为什么八种角色要用同一个基类? —— 因为位置、速度、宽高、重力、出边界的钳制这五样是共用的。 提到基类之后,加第九种角色只要写它特殊的那几行。 2. 关卡 200 格、瓦片 32 像素、窗口 800 宽,为什么必须滚动镜头? —— 关卡 6400 像素,窗口只有 800,是 8 倍。装不下就只能显示一部分,所以镜头必须跟着角色移动。 **这一步是「世界比屏幕大」的直接后果。** 3. 提示层为什么用 push 而不是 replace? —— 因为提示层是叠在游戏场景上面的一层,底下的游戏还要留着——关掉提示之后要接着玩。 replace 会把游戏场景扔掉。

骨架定完了,下一篇把菜单、HUD 和提示层摆出来——这三样都是「画在最上面」的东西。

相关笔记