17. 门、机关和状态
第 13 章那个开关有一个特点:每次按下,灯瞬间变状态。第 14 章走进区域、第 15 章拿起来、第 16 章坐下来,全都是即时切换。
但门不是这样。门按一下要花半秒滑开。再按一下要花半秒合上。中间那半秒动画里它处于「开了一半」的状态,不能再被点。门有自己的「现在是哪个状态」,按按钮翻状态,状态变了再触发动画。
这一章做一扇真的门,把这条更长的链路一次走通:状态字段 → 翻转状态 → Animator 演示动画。下次见到机关、电梯、可切换显示模式的展品,骨架都从这里来。
三件原本独立的事,串成一条链路
Section titled “三件原本独立的事,串成一条链路”整个机关大致是这样运转的:
玩家按按钮 → 脚本翻转 isOpen → Animator.SetBool 通知动画机 ↓ Animator 检查参数变化 → 播开门 / 关门动画这一章会按从右往左的顺序搭:先做能播放的动画,再做控制动画的状态字段,最后接上玩家按按钮的事件。每一段单独做完都能验证,最后接起来就行。
第一步:建一扇可旋转的门
Section titled “第一步:建一扇可旋转的门”打开第二部那间小屋,在墙上找一个「想留出门洞」的位置。门最简单的做法是用一个 Cube 当门板。
Hierarchy 空白处右键 Create Empty,命名 DoorPivot。位置摆在门铰链该在的位置,比如门框左边边缘:
DoorPivotPosition -1.5, 1.0, 2 # 门框左侧上方Rotation 0, 0, 0Scale 1, 1, 1DoorPivot 下右键 3D Object → Cube,命名 DoorBoard:
DoorBoardPosition 0.5, 0, 0 # 让门板从 pivot 向右展开Rotation 0, 0, 0Scale 1.0, 2.0, 0.05 # 1 米宽、2 米高、5 厘米厚Hierarchy└─ DoorPivot (空 GameObject,铰链点) └─ DoorBoard (Cube,门板本体)这套结构很重要:旋转 DoorPivot 等于让门绕铰链转。如果不分两层,直接旋转一个 Cube,它会绕自身中心转,门看起来像在原地打转而不是绕铰链开门。
第二步:给门做一段开关动画
Section titled “第二步:给门做一段开关动画”要让门「滑动」开关,得有一段动画。Unity 的 Animation 系统能记录「几个时间点上的旋转值」,然后按时间播放。
这一章用最简单的「用 Animator 直接录制旋转」的做法。复杂动画后面章节再展开。
确保选中的是 DoorPivot(不是 DoorBoard),打开 Animation 面板(菜单栏 Window → Animation → Animation,或快捷键 Ctrl+6)。面板会显示 Create 按钮。点它。
弹出保存对话框,命名 Door_Open.anim,保存到 Assets/Animations/(没这个目录就新建一个)。
保存后会自动给 DoorPivot 加一个 Animator 组件,并创建一个同名 Animator Controller 文件 DoorPivot.controller。这一步先用着,下面会替换。
录制开门动画:
- Animation 面板顶部的红色圆点(Record Mode),点击进入录制状态。整个 Inspector 会变红。
- 时间轴拖到第
0:00(最左端),在 Inspector 里把DoorPivot的 Rotation Y 设为0。Animation 面板会出现一行Rotation关键帧。 - 时间轴拖到
0:30(半秒位置),把DoorPivot的 Rotation Y 改成90(开 90 度的样子)。会自动多出一帧。 - 再次点击红色圆点,退出录制。
这是开门动画的全部内容:从 Y=0 到 Y=90,半秒。
Animation 面板左上角下拉框(默认显示 Door_Open)→ Create New Clip → 命名 Door_Close.anim,保存到同一目录。
切到 Door_Close:
- 进入录制。
- 第
0:00:Rotation Y =90。 - 第
0:30:Rotation Y =0。 - 退出录制。
两段动画做完了。临时按 Animation 面板的播放键能预览各自的播放效果。
第三步:用 Animator Controller 控制状态切换
Section titled “第三步:用 Animator Controller 控制状态切换”刚才 Unity 自动生成的 DoorPivot.controller 现在长这样:里头有两个状态 Door_Open 和 Door_Close,但它们之间没有切换条件,所以一直在播第一个。要让它根据「门是开还是关」切换,得加一个布尔参数和两条切换线。
双击 DoorPivot.controller 打开 Animator 面板。
Animator 面板左上角有个 Parameters 标签。点开 → + → Bool,命名 IsOpen。默认值不勾(关)。
配置状态切换
Section titled “配置状态切换”Animator 视图里两个状态之间画切换线:
- 右键
Door_Close状态 →Make Transition,拖到Door_Open。一条箭头连过去。 - 选中这条箭头,Inspector 里:
Has Exit Time:取消勾选。这非常关键,下面专门讲。Conditions→+→IsOpen→true。
- 反方向也一条:右键
Door_Open→Make Transition→Door_Close。 - 选中这条箭头:
Has Exit Time:取消勾选。Conditions→+→IsOpen→false。
把默认状态确认一下:右键 Door_Close(在视图里那个橙色的就是默认状态)→ 如果不是橙色的话 Set as Layer Default State,把 Door_Close 设为默认。门一开始是关的。
到这里 Animator 已经能根据 IsOpen 参数切换动画了。下一步把这个参数接到玩家按按钮的事件。
第四步:写状态字段和按钮脚本
Section titled “第四步:写状态字段和按钮脚本”Assets/Scripts/ 下新建 U# Script DoorController:
using UdonSharp;using UnityEngine;
public class DoorController : UdonSharpBehaviour{ [Tooltip("门枢轴上的 Animator 组件")] public Animator doorAnimator;
[Tooltip("门当前的开关状态。运行时由脚本维护,不要在 Inspector 里手动改")] private bool isOpen = false;
public override void Interact() { // 状态翻转 isOpen = !isOpen;
// 把状态推给 Animator if (doorAnimator != null) { doorAnimator.SetBool("IsOpen", isOpen); } }}短短几行,但跟前几章已经多了一件新事:isOpen 这个字段在两次 Interact 之间还活着。第一次按它从 false 翻成 true,第二次从 true 翻成 false。脚本第一次需要记忆。
这件事的术语叫「实例字段」。每个挂着 DoorController 的物体都有自己的一份 isOpen,不会被别的门搞乱。
放一个 DoorButton 小 Cube 当门把手 / 开关。可以贴在门板边、贴在墙上、做成一个小拉手。
DoorButtonPosition -0.5, 1.2, 2 # 门旁边Scale 0.15, 0.3, 0.05把 DoorController.cs 挂到 DoorButton 上。Inspector 里:
- 把
DoorPivot上的Animator组件拖到Door Animator字段(直接拖DoorPivot物体也行)。 Udon Behaviour的Interaction Text设为开/关门。- 确认
DoorButton上有Box Collider、未勾Is Trigger。
Build & Test
Section titled “Build & Test”Build & Test 进世界。走到门把手前 Interact 一次,门绕铰链转开 90 度。再 Interact 一次,门转回来。半秒动画看着是真的「滑开 / 合上」,不是瞬间切换。
状态在哪、谁负责把它做出来
Section titled “状态在哪、谁负责把它做出来”回头看这条链路上数据是怎么流的:
- 状态在脚本里:
isOpen这个 bool 字段,是单一真实来源。脚本翻一下,状态变。 - 状态被推给 Animator:
doorAnimator.SetBool("IsOpen", isOpen)把 C# 的状态推到 Animator 的参数里。 - Animator 把状态变成动作:根据
IsOpen参数切换状态,播对应动画。
这三件事各管各的。换句话说:脚本不直接旋转门板。它只负责「现在该开还是关」,旋转的事让 Animator 做。
不止两个状态:用 int 做更复杂的机关
Section titled “不止两个状态:用 int 做更复杂的机关”门只有「开 / 关」两态,bool 够用。换成「红 / 黄 / 绿」三态信号灯就不够了,得用 int。
VRChat 桌面信号灯例子:
using UdonSharp;using UnityEngine;
public class TrafficLight : UdonSharpBehaviour{ public Light trafficLight;
// 三种状态用三个常量代替魔法数字 private const int STATE_RED = 0; private const int STATE_YELLOW = 1; private const int STATE_GREEN = 2;
private int currentState = STATE_RED;
public override void Interact() { currentState = (currentState + 1) % 3; ApplyState(); }
private void ApplyState() { if (trafficLight == null) return;
if (currentState == STATE_RED) { trafficLight.color = Color.red; } else if (currentState == STATE_YELLOW) { trafficLight.color = Color.yellow; } else if (currentState == STATE_GREEN) { trafficLight.color = Color.green; } }}跟门的脚本骨架是同一份:状态字段 → 翻转 → 推到外部表现。区别只是:
- 状态从 bool 变成 int,能装更多档。
- 用
const int给每个数字一个名字(STATE_RED比0容易读得多)。 - 「推到外部表现」由
ApplyState()这个内部方法集中处理。
这套写法规模再大也按这个思路:所有「现在的状态」集中放一起,所有「根据状态做表现」集中放一起,两边各有自己的小函数。代码不容易乱。
防双击:用一个布尔锁住动画过程
Section titled “防双击:用一个布尔锁住动画过程”回到那扇门。再做一个细节:动画期间不让玩家再按。
门正在滑开的半秒,玩家如果连点两下,会出现「门刚开到一半被指令重新关上、动画乱跳」的别扭表现。可以用一个简单的「锁」挡住:
using UdonSharp;using UnityEngine;
public class DoorController : UdonSharpBehaviour{ public Animator doorAnimator;
[Tooltip("动画时长,跟 Animator 里两段动画的时长一致")] public float animationDuration = 0.5f;
private bool isOpen = false; private bool isAnimating = false;
public override void Interact() { if (isAnimating) return;
isAnimating = true; isOpen = !isOpen;
if (doorAnimator != null) { doorAnimator.SetBool("IsOpen", isOpen); }
SendCustomEventDelayedSeconds(nameof(OnAnimationFinished), animationDuration); }
public void OnAnimationFinished() { isAnimating = false; }}第 12 章见过 SendCustomEventDelayedSeconds:UdonSharp 不能用 yield / Coroutine,要「过 N 秒之后做某事」就用它。这里调用方式是:
SendCustomEventDelayedSeconds(nameof(OnAnimationFinished), animationDuration);nameof(OnAnimationFinished) 把方法名转成字符串 "OnAnimationFinished",animationDuration 是延迟秒数。半秒之后 VRChat 会调一次 OnAnimationFinished,把锁解掉。
OnAnimationFinished 写成 public 是有原因的:第 12 章提过 SendCustomEventXxx 调用的目标方法必须是 public。这一行不是给外人调用的,但因为是延时事件的目标,必须 public。
VRC Object Sync 不适合做门
Section titled “VRC Object Sync 不适合做门”第 15 章末尾让 Pickup 配 VRC Object Sync 是顺路的。门不一样:门状态是布尔的、是按钮触发的、跟物理无关,强行加 VRC Object Sync 会让门的 Transform 被网络同步接管,反而打架。
要让门在多人下同步,靠的是 [UdonSynced] 标记 isOpen 字段 + 在 Interact 里调 RequestSerialization() + 实现 OnDeserialization()。这是第六部要展开的事,第 28 章会从这扇门开始讲多人同步。这一章先把单人版本做扎实。
自己改三个版本
Section titled “自己改三个版本”把 DoorController 当骨架:
- 把动画时长从 0.5 秒改成 1.5 秒,门变得「沉重」。再改成 0.15 秒,门「啪」地打开。同一段脚本同一份 Animator,节奏完全不同。
- 加一个开门音效:
public AudioSource openSound,在isOpen翻成 true 的那一刻调openSound.Play()。 - 把按按钮换成走进区域:用第 14 章的
OnPlayerTriggerEnter触发Interact()体内那段逻辑(提示:把翻转和 SetBool 抽出一个方法ToggleDoor(),让Interact和OnPlayerTriggerEnter都调它)。
第三个改完之后,门就变成了「自动门」。同一扇门、同一个 Animator,触发方式由几行代码决定。
一段状态机交互的骨架
Section titled “一段状态机交互的骨架”这一章引入了第四部到现在为止最复杂的一条骨架:
- 一个状态字段(
bool isOpen/int currentState),是这台机器的「记忆」。 - 一个动作触发(
Interact/ Trigger / 计时器),翻转状态。 - 一段「把状态变成可见效果」的逻辑:调
Animator.SetBool让动画播、改Light.color让灯变色、SetActive让物体显隐。 - 可选的「动画期间锁住」机制(
isAnimating+SendCustomEventDelayedSeconds),防双击和打架。
VRChat 世界里,凡是「按一下变样、再按一下变回来」的机关,几乎都用这个骨架。看到的越复杂的机关(电梯、可换皮的展品、解谜机关),多数也只是把这套骨架的状态从 bool 扩到 int,再扩到几个互相关联的字段。
到这里第四部的所有动手章已经写完。从按一下开关,到坐下来照镜子,再到一扇会「记住自己」的门,这五章其实在讲同一件事:触发 → 改状态 → 反映到世界上。下一节理解章把这件事拆成一个能直接套用到任何新交互的三段式。