U3 → Godot 复刻课程 交互式课程站 · 本地学习件

第 1 单元 / 理论派

讲义 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. 意图层是机制和动态之间的缓冲带。 玩家按键的时机、长短,先变成”意图”,再由移动系统解释。以后你要改”按键怎么被理解”(比如加输入缓冲),只改意图层,不碰移动公式。
  2. 权威状态只有一份。 单机阶段就一份;以后做联机,这份状态会搬到服务端。今天就养成”只改一个地方”的习惯。
  3. 固定步是体验的一部分。 第 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_STAND4.5
冲刺SPEED_SPRINT7.0
蹲走SPEED_CROUCH2.5
趴行SPEED_PRONE1.5

本练习只用站立步行这一个值。你工程里的 tuning.gd 数值以你自己的为准,文档里写的 4.5 是原版值,不一定和你的一样。

这个练习只改它,别的不动。

试次步行速度说明
试次 1你工程里的当前值(原版参照 4.5)基准
试次 27.0偏快,接近原版冲刺速度
试次 32.5偏慢,接近原版蹲走速度

步骤(严格按顺序)

  1. 先写预测,不许先跑。 每个试次 3 行:
试次:步行速度 = ___
预测 1(我走起来的手感):...
预测 2(我会在哪里停下来,停的原因):...
预测 3(我会不会想绕路、会不会去撞墙、会不会来回试):...
  1. 改成试次 1 的值,跑一分钟,只做一件事:走。走到你想停下为止。停下的那一刻,记下”我为什么停下”。
  2. 改成试次 2,重复。
  3. 改成试次 3,重复。
  4. 对照你写的预测,逐条标 ✅ 或 ❌。 重点是 ❌ 的那几条——预测错的地方就是这一课真正学到的东西。

复盘问题(用你自己的话答,一行一条)

  1. 哪个速度下你最想停下来?是因为”手感不跟手”,还是”走到目的地了”?
  2. 哪个速度下你最愿意绕远路?为什么?
  3. 你预测错的那一条,当时是怎么推理的?推理的哪一步跳过了”玩家实际会做什么”?

为什么选这个参数

因为它最直接地把”机制 → 动态 → 审美”的断层摆在明面上:你只改了一个数字,但你改的不是”走多快”,而是你和目的地之间的心理距离。

如果你预测全部命中,也不代表你对了,只代表这个参数在你的直觉覆盖范围内。下一课的练习会让你至少猜错一条。

交作业

把三段预测 + 每个试次的一行观察,加上”哪个速度最不跟手”那一句,发群里 @理论派。不需要格式漂亮,需要真实。


6. 参数速查表(本讲义引用的数值)

参数位置值核对状态
站立步行速度 SPEED_STANDUnturned/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_SPRINTUnturned/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_CROUCHUnturned/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_PRONEUnturned/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_HZ60 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.mdSAMPLES=4、RATE=0.08 s,即 12.5 Hz 逻辑 / 50 Hz 采样已由解剖官回读源码
你工程里的步行速度复刻工程 tuning.gd待你确认以你的工程为准

未验证:

  1. 已核对(复刻工程 tuning.gd):SPEED_STAND、SPEED_SPRINT、SPEED_CROUCH、SPEED_PRONE、LOGIC_HZ 均存在,数值与本讲义一致。
  2. 原版速度单位和 Godot 里的米制是否一一对应,未对齐,不要直接换算。

7. 本讲义就记三句

  1. 机制你写,审美玩家得,中间那层动态是翻译,不许跳过。
  2. 同一个数字,在不同的链路环节(意图、状态、物理)里作用不同。 改之前先问它在哪一层生效。
  3. 砍机制的时刻,成本不在代码行数上,在动态和审美上。

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 帧语义。