项目一大,第一件事不是写代码,是分层:场景、角色、控制、数据。 八种角色里只有四种会动,但八种都要参与碰撞,所以它们是一个基类。 关卡两百格宽、窗口只有二十五格,所以必须有横向滚动的镜头。
前面两章一个做完了《俄罗斯方块》,一个认完了 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 秒实验
- 把
level从 200 改成 25,重跑第三段,看镜头还动不动。 - 把窗口宽改成 400,重跑第三段,看镜头位置怎么变。
- 把第四段那一步
push('提示层')改成replace('提示层'),看栈的变化和退出后的状态。
常见误区
- 「分层是给大团队用的,我一个人写不用。」一个人写更要分。 三个月后你就是那个「不熟悉代码的新人」。
- 「八种角色写八个类,各写各的。」位置、速度、重力、边界钳制会抄八遍。 提一个基类,八份变一份。
- 「镜头跟着角色就行,不用钳制。」走到关卡边缘,画面会露出地图外的空白。
钳到
0到地图宽 - 窗口宽。
自测三问(点开对答案)
1. 为什么八种角色要用同一个基类? —— 因为位置、速度、宽高、重力、出边界的钳制这五样是共用的。 提到基类之后,加第九种角色只要写它特殊的那几行。 2. 关卡 200 格、瓦片 32 像素、窗口 800 宽,为什么必须滚动镜头? —— 关卡 6400 像素,窗口只有 800,是 8 倍。装不下就只能显示一部分,所以镜头必须跟着角色移动。 **这一步是「世界比屏幕大」的直接后果。** 3. 提示层为什么用 push 而不是 replace? —— 因为提示层是叠在游戏场景上面的一层,底下的游戏还要留着——关掉提示之后要接着玩。 replace 会把游戏场景扔掉。骨架定完了,下一篇把菜单、HUD 和提示层摆出来——这三样都是「画在最上面」的东西。
- ☐ (本篇完,下一篇 7.2 菜单、HUD 与提示层)