讲义 1/1 · mda/01-mda-framework
单元一 MDA 讲义:机制、动态、审美,三层怎么串起来
讲义:理论派 · 对应单元一的前两课(第 1 课「工程骨架与运行循环」、第 2 课「输入、意图与移动」) 本讲义只用你第 1、2 课会真正碰到的东西来举例:
tuning.gd里的速度表、60 Hz 逻辑帧率、“输入 → 意图 → 权威状态”这条链。 第 3、4 课才会出现的姿态与视角(站蹲趴、spring_length、俯仰夹取)不在本讲义里练习。
1. 三层结构:一条数据流
MDA 是一套分析工具,它把一个游戏拆成三层,并要求你把它们当成一条单向的流水线来看:
Mechanics 机制 ← 你写的代码:规则、数值、算法、状态机
↓ (玩家在真实时间里操作它,产生行为)
Dynamics 动态 ← 玩家实际做出来的事:绕路、停下、来回试、放弃
↓ (这些行为在人脑里变成感受)
Aesthetics 审美 ← 玩家说出口的体验:跟手、顿挫、想再试一次
- 机制是作者写的:确定的、可枚举的,你打开
tuning.gd就能数出来。 - 动态是玩家跑出来的:不写进任何文件,是机制的涌现结果。同一套移动代码,不同玩家会走出不同的路线和节奏。
- 审美是玩家感受到的:只存在于人的体验里,你看不见,只能通过行为和提问去推断。
关键结论:这条流水线是单向的,中间那层不可跳过。 所以”我把速度改了,手感就一定变成 X”这个推理永远不成立。你只能提出假设,然后去测。
2. Aesthetics 的八类:把”好玩”拆成可数的词
“好玩”没法讨论,所以学术界给它拆了八个格子(Hunicke / LeBlanc / Zubek 2004 提出,行业里当通用词汇用,属于常见经验框架,不是定论):
| 类别 | 大白话 | 玩家嘴里的话 |
|---|---|---|
| Sensation 感官 | 身体上的直接刺激:声音、画面、速度感 | “这一下加速好冲” |
| Fantasy 幻想 | 扮演一个不是自己的角色/世界 | “我是个在外面跑的人” |
| Narrative 叙事 | 有起承转合的事件串 | “我先去那边,再回来” |
| Challenge 挑战 | 需要技巧、有失败可能 | “这段跳不上去” |
| Fellowship 社交 | 和别人一起/对抗别人 | (单机阶段用不上) |
| Discovery 探索 | 发现新东西、新规则、新地方 | “原来这里能爬上去” |
| Expression 表达 | 把自己的想法变成游戏里的东西 | (后面的课才会有) |
| Submission 消遣 | 放空、节奏化、不用动脑 | “走来走去挺放松” |
⚠️ 一个常见误解:这八类不是互斥的八种游戏类型。一个游戏同时提供好几类,同一分钟内都可能切换。分类是用来提问的:这个机制在服务哪几类?我砍掉它,少的是哪几类?
3. 为什么必须懂动态:四个常见错判
| 错判 | 为什么错 | 在本项目里的样子 |
|---|---|---|
| “速度调快一点,就更刺激” | 速度上去,操控感可能变差,玩家反而觉得”飘” | 速度从 4.5 拉到 7.0,停步变难,玩家开始贴墙蹭 |
| “玩家会按我设想的方式玩” | 玩家永远会找到你没想过的走法(涌现) | 你设计的是”按住走”,玩家学会”点一下走”来省力 |
| “参数调小一点,手感就温和” | 参数是乘在一起的,单看一个数没有意义 | 速度、加速度、摩擦、帧率一起决定手感,单改一个会出乎意料 |
| “这个机制没用,砍了” | 砍掉的是机制,改变的是动态和审美 | 砍掉输入缓冲,操作会变”吃键”,玩家会怪自己手慢 |
最后一条是本讲义的重点:砍机制的代价,永远要算在动态和审美那一层,不能只算在代码量那一层。这个话题后面会专门展开,具体放在哪一课,等本批验收后再定。
4. 本单元的核心:输入 → 意图 → 权威状态
第 2 课里有一条链,理论上就是 MDA 的 Mechanics 那一层的骨架:
键盘/手柄(原始输入)
↓ Input Map:把"按 W"翻译成"前进"这个动作
意图 Intent(前进 1.0、跳跃 false ……)
↓ Movement:用 tuning.gd 的速度表算出"这一帧应该走多快"
权威状态(速度向量、位置)
↓ move_and_slide:真正推动胶囊,撞墙就停
屏幕上的位置
理论上要记住的三点:
- 意图层是机制和动态之间的缓冲带。 玩家按键的时机、长短,先变成”意图”,再由移动系统解释。以后你要改”按键怎么被理解”(比如加输入缓冲),只改意图层,不碰移动公式。
- 权威状态只有一份。 单机阶段就一份;以后做联机,这份状态会搬到服务端。今天就养成”只改一个地方”的习惯。
- 固定步是体验的一部分。 第 1 课的逻辑帧率决定了”每秒推几次游戏规则”。原版是每 4 个物理帧跑 1 次逻辑帧、步长 0.08 s,即 12.5 Hz 逻辑帧 + 50 Hz 采样(出处
Unturned/Player/PlayerInput.cs:878-879Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:877-880 { public static readonly uint SAMPLES = 4; public static readonly float RATE = 0.08f;,见docs/systems/01-player-controller.md,解剖官已回读源码),最坏输入延迟约 80 ms;复刻工程改成 60 Hz,与物理帧对齐,单机下延迟几乎为零。这是一次机制取舍:手感变跟手了,但联机时的同步节奏要重新考虑。
4.5 为什么要多一层意图:直接读输入不行吗
先给一句你读完要能说出来的话:
采样发生在物理帧,消费发生在逻辑帧,两者不同步,所以中间必须有一层 Intent,把”按过”记下来,等逻辑帧来取。
下面分三步讲清楚这句话。
第一步:两个节拍。 输入是按物理帧采样的:player.gd 第 30–31 行,每个物理帧先执行一次 intent.sample()。规则是按逻辑步执行的:第 32–35 行,累加器攒够一个 LOGIC_STEP 才跑一次 _simulate()。两者的节拍不一定一致,原版更是差得很远(采样 50 Hz,逻辑 12.5 Hz)。
第二步:错位会丢东西。 设想一个物理帧里没有逻辑步,而你直接在逻辑步里读 Input.is_action_just_pressed("jump")。那个”刚按下”的信号只存在于按下那一帧,等逻辑步到来时它已经不在了,跳跃就被吞掉。意图层的做法是:采样时看到”刚按下”就上锁(_jump_latch = true,见 player_intent.gd 第 20–21 行),直到逻辑步调用 take_jump() 取走才清零(第 31–33 行)。锁把”按过”从一帧的瞬时信号,变成一直有效、直到被消费的状态。
第三步:为什么不直接把意图写进移动代码。 因为分了层,输入的语义只有一个改动点:重映射按键、加输入缓冲、录制回放、AI 代替玩家操作,都只换意图层的填充方式,移动公式、姿态机、摄像机一行都不用动。联机时(单元七)意图还要发给服务端,它本来就是一份可以独立搬运的数据。
一个细节(Godot 4.7.2 headless 实测,用 Input.action_press 注入):在第 N 个物理帧按下时,is_action_pressed 同一帧就为真,而 is_action_just_pressed 要到第 N+1 帧才为真,且只持续一帧;按住不放时它也只出现一次。所以”刚按下”这个信号是有一帧滞后、且只存在一帧的。这正是锁存要解决的问题。
代价:多一层间接,初学者会觉得按键到动作绕了远。锁存也有坑:当前实现只锁”按下”,不管”松开”。如果跳跃在两次逻辑步之间按下又松开,行为取决于你怎么清锁,这是后面要处理的边界。
注意一个现实:复刻工程的逻辑帧率现在是 60 Hz,和物理帧一致,所以每个物理帧通常正好跑一个逻辑步,两个节拍几乎重合,错位的代价暂时看不出来。只有把逻辑帧率降低(原版 12.5 Hz),错位才会真的丢键。这一点留给你去验证,本讲义不提前给结论。
脚注(学员可自行验证):上面那条”按下那一帧
is_action_just_pressed为假、下一帧才为真”,是我们在无头模式注入事件测出来的,不是真键盘。你可以用真键盘确认:在player.gd的_physics_process开头加一行print(Engine.get_physics_frames(), " pressed=", Input.is_action_pressed("jump"), " just=", Input.is_action_just_pressed("jump")),按一次空格,看同一帧和下一帧的输出。若你的结果和讲义不同,把两行输出贴到群里,这比讲义本身更值钱。
5. 第一个练习:只改一个数,先预测,再实测
目标:不碰代码逻辑,只改一个数字,亲身体会”中间隔着动态”这件事。
前提:第 1、2 课的工程能跑(按 W 胶囊能动、会撞墙停下、tuning.gd 参数表存在)。
本节改的只有一处:步行速度。 这个练习没有标准答案——答案在你自己的实测里。所以顺序很重要:先写预测,再动手。
已收起:步骤(严格按顺序)、复盘问题(用你自己的话答,一行一条)
要改的参数:步行速度
tuning.gd 里的步行速度。参照源码(Unturned 的 Unturned/Player/PlayerMovement.cs:91-96Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:90-97
private static readonly float SPEED_CLIMB = 4.5f;
private static readonly float SPEED_SWIM = 3f;
private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f;)里的数值:
| 状态 | 源码常量 | 数值(m/s,源码里的单位以原版为准) |
|---|---|---|
| 站立步行 | SPEED_STAND | 4.5 |
| 冲刺 | SPEED_SPRINT | 7.0 |
| 蹲走 | SPEED_CROUCH | 2.5 |
| 趴行 | SPEED_PRONE | 1.5 |
本练习只用站立步行这一个值。你工程里的 tuning.gd 数值以你自己的为准,文档里写的 4.5 是原版值,不一定和你的一样。
这个练习只改它,别的不动。
| 试次 | 步行速度 | 说明 |
|---|---|---|
| 试次 1 | 你工程里的当前值(原版参照 4.5) | 基准 |
| 试次 2 | 7.0 | 偏快,接近原版冲刺速度 |
| 试次 3 | 2.5 | 偏慢,接近原版蹲走速度 |
步骤(严格按顺序)
- 先写预测,不许先跑。 每个试次 3 行:
试次:步行速度 = ___
预测 1(我走起来的手感):...
预测 2(我会在哪里停下来,停的原因):...
预测 3(我会不会想绕路、会不会去撞墙、会不会来回试):...
- 改成试次 1 的值,跑一分钟,只做一件事:走。走到你想停下为止。停下的那一刻,记下”我为什么停下”。
- 改成试次 2,重复。
- 改成试次 3,重复。
- 对照你写的预测,逐条标 ✅ 或 ❌。 重点是 ❌ 的那几条——预测错的地方就是这一课真正学到的东西。
复盘问题(用你自己的话答,一行一条)
- 哪个速度下你最想停下来?是因为”手感不跟手”,还是”走到目的地了”?
- 哪个速度下你最愿意绕远路?为什么?
- 你预测错的那一条,当时是怎么推理的?推理的哪一步跳过了”玩家实际会做什么”?
为什么选这个参数
因为它最直接地把”机制 → 动态 → 审美”的断层摆在明面上:你只改了一个数字,但你改的不是”走多快”,而是你和目的地之间的心理距离。
如果你预测全部命中,也不代表你对了,只代表这个参数在你的直觉覆盖范围内。下一课的练习会让你至少猜错一条。
交作业
把三段预测 + 每个试次的一行观察,加上”哪个速度最不跟手”那一句,发群里 @理论派。不需要格式漂亮,需要真实。
6. 参数速查表(本讲义引用的数值)
| 参数 | 位置 | 值 | 核对状态 |
|---|---|---|---|
站立步行速度 SPEED_STAND | Unturned/Player/PlayerMovement.cs:94Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:93-95 private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f; | 4.5 | 已回读源码 |
冲刺速度 SPEED_SPRINT | Unturned/Player/PlayerMovement.cs:93Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:92-94 private static readonly float SPEED_SWIM = 3f;
private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f; | 7.0 | 已回读源码 |
蹲走速度 SPEED_CROUCH | Unturned/Player/PlayerMovement.cs:95Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:94-96 private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f; | 2.5 | 已回读源码 |
趴行速度 SPEED_PRONE | Unturned/Player/PlayerMovement.cs:96Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:95-97 private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f; | 1.5 | 已回读源码 |
| 逻辑帧率(复刻) | 复刻工程 reference/01-player-controller/scripts/tuning.gd:10 LOGIC_HZ | 60 Hz | 已核对:变量名与数值一致 |
| 逻辑帧率(原版) | Unturned/Player/PlayerInput.cs:878-879Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:877-880 {
public static readonly uint SAMPLES = 4;
public static readonly float RATE = 0.08f;,见 docs/systems/01-player-controller.md | SAMPLES=4、RATE=0.08 s,即 12.5 Hz 逻辑 / 50 Hz 采样 | 已由解剖官回读源码 |
| 你工程里的步行速度 | 复刻工程 tuning.gd | 待你确认 | 以你的工程为准 |
未验证:
- 已核对(复刻工程
tuning.gd):SPEED_STAND、SPEED_SPRINT、SPEED_CROUCH、SPEED_PRONE、LOGIC_HZ均存在,数值与本讲义一致。 - 原版速度单位和 Godot 里的米制是否一一对应,未对齐,不要直接换算。
7. 本讲义就记三句
- 机制你写,审美玩家得,中间那层动态是翻译,不许跳过。
- 同一个数字,在不同的链路环节(意图、状态、物理)里作用不同。 改之前先问它在哪一层生效。
- 砍机制的时刻,成本不在代码行数上,在动态和审美上。
8. 本讲义的改动记录
- 2026-10-11:按 Chief 的批次指令重写。术语统一为”单元 / 第 N 课”,旧术语一律不再出现。
- 摄像机练习(
spring_length、俯仰、球扫)移出本讲义:属于第 3、4 课,用户还没做到。 - 练习改为只改第 1、2 课碰到的步行速度(
tuning.gd)。 - 僵尸案例与建造案例原样移入
docs/_staging/unit-04-06-cases.md,分别属于单元四至单元六,等开课时从暂存区取用。 - 文件改名为
unit-01-mda-framework.md,与全队命名规范对齐。 - 速度数值补上
PlayerMovement.cs:91-96Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:90-97 private static readonly float SPEED_CLIMB = 4.5f; private static readonly float SPEED_SWIM = 3f; private static readonly float SPEED_SPRINT = 7f; private static readonly float SPEED_STAND = 4.5f; private static readonly float SPEED_CROUCH = 2.5f; private static readonly float SPEED_PRONE = 1.5f;行号,已回读源码核对。 - 逻辑帧率原版出处改为
PlayerInput.cs:878-879Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:877-880 { public static readonly uint SAMPLES = 4; public static readonly float RATE = 0.08f;并引用解剖官说明书;新增 §4.5(三级标题)意图层深挖,含 Godot 4.7.2 headless 实测的just_pressed帧语义。
- 摄像机练习(