Unity游戏开发实战:基于有限状态机构建可交互电梯系统 1. 项目概述从零构建一个可交互的Unity电梯系统在游戏开发中尤其是第一人称冒险、恐怖解谜或是现代都市模拟类游戏里电梯是一个能极大增强环境沉浸感和关卡设计灵活性的交互元素。它不仅仅是连接不同楼层的工具更是营造氛围、控制游戏节奏、甚至触发关键剧情的重要载体。很多新手开发者甚至一些有经验的同行在初次尝试实现电梯功能时往往会陷入一些误区要么用过于简单的Transform.Translate导致运动生硬、穿模要么状态管理混乱电梯响应不跟手又或者忽略了音效、UI反馈这些提升体验的关键细节。今天我就结合自己多年在Unity项目中的实战经验带你从头到尾、由浅入深地打磨一个功能完整、性能可靠、体验丝滑的电梯系统。我们不止要实现“按按钮电梯动”这个基础功能更要深入探讨如何设计一个健壮的状态机、如何处理玩家与电梯的交互、如何添加视觉与听觉反馈最终打造一个可以直接复用到你下一个项目中的高质量预制件。无论你是Unity初学者还是想优化现有方案的开发者这篇指南都将提供清晰的路径和实用的代码。2. 核心需求与功能拆解一个合格的电梯应该什么样在动手写第一行代码之前我们必须明确目标。一个在游戏中合格的电梯系统远不止一个上下移动的方块那么简单。我们需要拆解其核心需求这有助于我们设计出结构清晰、易于扩展的代码架构。2.1 基础移动功能这是电梯的“本体”。它需要能够沿着垂直的Y轴在两个或多个固定的楼层位置之间平滑移动。移动必须是受控的、可预测的并且要考虑到物理碰撞避免玩家或物品被卡在电梯顶部或底部。2.2 楼层管理与目标调度电梯需要知道它当前位于哪一层以及它应该前往哪一层。当同时有多个楼层呼叫比如1楼和3楼都按了按钮时电梯需要一套调度逻辑来决定移动顺序。是“先来先服务”还是“顺路优先”这直接影响了玩家的体验。2.3 用户交互接口玩家如何与电梯交互通常有两种方式轿厢外呼叫在每层楼的电梯门外有一个“上行”或“下行”按钮用于呼叫电梯到本层。轿厢内选层进入电梯后内部有一个面板上面有数字按钮用于选择要去的目标楼层。交互需要提供清晰的视觉和听觉反馈让玩家明确知道自己的操作已被接收。2.4 状态反馈与动画电梯处于什么状态必须清晰地传达给玩家。常见的状态包括空闲Idle、运行中Moving、到达楼层停靠Arriving、开门DoorOpening、等待Waiting、关门DoorClosing。每个状态的切换都应有相应的动画如门开关、楼层指示灯变化和音效如到达“叮”声、运行电机声。2.5 物理与角色交互当电梯移动时站在电梯内的玩家或NPC应该随之同步移动而不是因为物理引擎的检测而掉下去。这通常需要通过将玩家设置为电梯的子物体或使用物理力/速度同步来实现。基于以上需求我们将采用一个以有限状态机FSM为核心的架构来驱动整个电梯系统。状态机能让逻辑条理清晰避免复杂的if-else嵌套是游戏开发中管理复杂对象行为的利器。3. 环境准备与场景搭建工欲善其事必先利其器。我们先搭建一个简单的测试场景。3.1 创建基础场景结构新建一个3D项目建议使用URP或HDRP以获取更好的图形效果但核心逻辑通用。在场景中创建一个地面Plane重置其Transform。创建电梯井可以使用四个细长的CubeScale: 0.2, 10, 2围成一个长方形区域或者直接用一个中空的Box Collider来模拟井道边界防止玩家掉出去。3.2 构建电梯本体与楼层创建电梯轿厢创建一个Cube重命名为ElevatorCab。调整其Scale为 (2, 2.5, 2)使其看起来像一个轿厢。为其添加一个Box Collider用于物理碰撞和一个Rigidbody组件。这里有个关键设置将Rigidbody的Is Kinematic属性勾选上。这意味着电梯将由我们的脚本直接控制其位置而不受物理引擎的力如重力影响但依然能与其他刚体发生碰撞。标记楼层位置在电梯井内规划好各个楼层的高度。例如我们做3层楼。在Hierarchy中创建三个空物体分别命名为Floor_1,Floor_2,Floor_3。将它们放置在电梯井内不同的Y轴高度上例如Y0, Y5, Y10。这些空物体的位置就是电梯需要精确停靠的目标点。记录下它们的Y坐标值。3.3 创建交互UI创建楼层按钮面板内部在电梯轿厢ElevatorCab内部创建一个UI Canvas。将其Render Mode设置为World Space然后调整Rect Transform的Position和Scale将其贴在轿厢内壁上。在这个Canvas下创建多个Button分别命名为Button_1,Button_2,Button_3按钮文字对应楼层号。创建呼叫按钮外部在每一层楼的电梯门外例如在Floor_1空物体下也创建一个World Space的Canvas并添加两个Button分别命名为Call_Up和Call_Down对于顶层只有Call_Down底层只有Call_Up。注意使用World Space UI时要确保Canvas的Scale设置得当否则按钮可能过大或过小看不清。一个常用的技巧是设置Scale为 (0.01, 0.01, 0.01)然后调整Width/Height为100-200这样按钮的大小就比较合适。4. 核心脚本设计与实现有限状态机驱动这是整个项目的灵魂。我们将创建一个ElevatorSystem脚本来管理所有逻辑。4.1 定义电梯状态枚举首先明确电梯可能处于的所有状态。这有助于我们理清逻辑。public enum ElevatorState { Idle, // 空闲等待指令 MovingUp, // 正在上升 MovingDown, // 正在下降 Arriving, // 正在减速、精确对准楼层可播放到达音效 DoorOpening, // 开门中 Waiting, // 开门等待乘客进出 DoorClosing // 关门中 }4.2 构建ElevatorSystem主类我们创建一个核心的ElevatorSystem类它继承自MonoBehaviour并挂载在电梯轿厢ElevatorCab上。using UnityEngine; using UnityEngine.Events; using System.Collections.Generic; public class ElevatorSystem : MonoBehaviour { [Header(楼层设置)] public float[] floorHeights; // 各楼层的Y轴坐标例如[0, 5, 10] public int currentFloorIndex 0; // 当前所在楼层索引 private int targetFloorIndex 0; // 目标楼层索引 [Header(移动参数)] public float moveSpeed 3.0f; public float acceleration 2.0f; // 加速度 public float deceleration 2.0f; // 减速度 private float currentSpeed 0f; private Vector3 targetPosition; [Header(门控制)] public Animator doorAnimator; // 引用门的Animator组件 public float doorOpenTime 2.0f; // 开门动画时长 public float waitTime 3.0f; // 开门后等待时间 private float stateTimer 0f; [Header(状态)] public ElevatorState currentState ElevatorState.Idle; private Queueint requestQueue new Queueint(); // 请求队列 [Header(事件)] public UnityEventint OnFloorArrived; // 到达某层时触发参数为楼层索引 public UnityEventElevatorState OnStateChanged; // 状态改变时触发 void Start() { if (floorHeights null || floorHeights.Length 0) { Debug.LogError(未设置楼层高度); return; } // 初始化电梯位置到第一层 Vector3 pos transform.position; pos.y floorHeights[currentFloorIndex]; transform.position pos; targetPosition transform.position; OnStateChanged?.Invoke(currentState); } void Update() { // 根据当前状态执行不同的逻辑 switch (currentState) { case ElevatorState.Idle: CheckRequests(); break; case ElevatorState.MovingUp: case ElevatorState.MovingDown: MoveElevator(); break; case ElevatorState.Arriving: HandleArriving(); break; case ElevatorState.DoorOpening: case ElevatorState.Waiting: case ElevatorState.DoorClosing: UpdateStateTimer(); break; } } }4.3 实现状态切换与移动逻辑现在我们来填充状态机中各个状态的具体行为。1. 请求调度 (CheckRequests)当电梯空闲时检查是否有未处理的楼层请求。private void CheckRequests() { if (requestQueue.Count 0) { targetFloorIndex requestQueue.Dequeue(); if (targetFloorIndex currentFloorIndex) { ChangeState(ElevatorState.MovingUp); } else if (targetFloorIndex currentFloorIndex) { ChangeState(ElevatorState.MovingDown); } else { // 请求楼层就是当前楼层直接开门 ChangeState(ElevatorState.DoorOpening); } } }2. 移动过程 (MoveElevator)这是电梯运行的核心。我们使用平滑的加速和减速模拟真实电梯的启停感。private void MoveElevator() { // 计算目标位置 targetPosition.y floorHeights[targetFloorIndex]; float distanceToTarget targetPosition.y - transform.position.y; float direction Mathf.Sign(distanceToTarget); // 移动方向1为上-1为下 // 计算当前所需速度基于距离的加速/减速 float distanceToDecelerate (currentSpeed * currentSpeed) / (2 * deceleration); // 从当前速度减速到0所需的距离 if (Mathf.Abs(distanceToTarget) distanceToDecelerate) { // 进入减速阶段 currentSpeed - deceleration * Time.deltaTime; if (currentSpeed 0.1f) currentSpeed 0.1f; // 保持一个最小速度防止卡住 } else { // 加速或匀速阶段 currentSpeed acceleration * Time.deltaTime; if (currentSpeed moveSpeed) currentSpeed moveSpeed; // 限制最大速度 } // 应用移动 float moveStep currentSpeed * Time.deltaTime * direction; // 防止过冲Overshoot if (Mathf.Abs(moveStep) Mathf.Abs(distanceToTarget)) { moveStep distanceToTarget; } transform.Translate(0, moveStep, 0); // 检查是否到达目标楼层允许一个很小的误差范围 if (Mathf.Abs(transform.position.y - targetPosition.y) 0.01f) { transform.position targetPosition; // 精确对齐 currentFloorIndex targetFloorIndex; currentSpeed 0f; ChangeState(ElevatorState.Arriving); } }3. 到达处理 (HandleArriving)电梯精确停靠后触发到达事件并切换到开门流程。private void HandleArriving() { // 触发到达事件可以用于播放“叮”声、更新楼层显示等 OnFloorArrived?.Invoke(currentFloorIndex); // 短暂停留后开门 stateTimer 0.5f; // 停稳后0.5秒开门 // 这里可以用一个协程或计时器为了简单我们借用状态机计时 ChangeState(ElevatorState.DoorOpening); }4. 状态计时器 (UpdateStateTimer)用于管理开门、等待、关门这些有时长要求的状态。private void UpdateStateTimer() { stateTimer - Time.deltaTime; if (stateTimer 0) { switch (currentState) { case ElevatorState.DoorOpening: // 开门动画结束进入等待状态 doorAnimator?.SetBool(IsOpen, true); stateTimer waitTime; ChangeState(ElevatorState.Waiting); break; case ElevatorState.Waiting: // 等待结束开始关门 stateTimer doorOpenTime; // 假设关门和开门时间一样 ChangeState(ElevatorState.DoorClosing); break; case ElevatorState.DoorClosing: // 关门动画结束恢复空闲状态 doorAnimator?.SetBool(IsOpen, false); ChangeState(ElevatorState.Idle); break; } } }5. 状态切换辅助方法 (ChangeState)这是一个关键方法用于安全地切换状态并触发相关事件。private void ChangeState(ElevatorState newState) { if (currentState newState) return; // 退出旧状态的清理工作如果需要 switch (currentState) { // 可以在这里添加状态退出逻辑 } currentState newState; Debug.Log($电梯状态切换至: {currentState}); // 触发状态改变事件 OnStateChanged?.Invoke(currentState); // 进入新状态的初始化工作 switch (newState) { case ElevatorState.DoorOpening: stateTimer doorOpenTime; break; case ElevatorState.Idle: // 空闲后再次检查请求处理连续呼叫 CheckRequests(); break; } }4.4 添加外部调用接口电梯系统需要接收来自按钮的指令。// 外部呼叫电梯到某一层 public void RequestFloor(int floorIndex) { if (floorIndex 0 || floorIndex floorHeights.Length) { Debug.LogWarning($请求的楼层 {floorIndex} 无效。); return; } // 如果请求的就是当前楼层且电梯门关着直接开门 if (floorIndex currentFloorIndex (currentState ElevatorState.Idle || currentState ElevatorState.DoorClosing)) { // 如果正在关门取消关门直接开门 if (currentState ElevatorState.DoorClosing) { ChangeState(ElevatorState.DoorOpening); } else { ChangeState(ElevatorState.DoorOpening); } return; } // 避免重复添加相同的请求 if (!requestQueue.Contains(floorIndex) floorIndex ! targetFloorIndex) { requestQueue.Enqueue(floorIndex); Debug.Log($已添加楼层请求: {floorIndex}); // 如果电梯当前空闲立即处理新请求 if (currentState ElevatorState.Idle) { CheckRequests(); } } } // 内部按钮按下设置目标楼层与RequestFloor逻辑类似但可以区分内外 public void SelectFloor(int floorIndex) { RequestFloor(floorIndex); // 目前可以共用同一逻辑 }5. 交互连接与UI反馈脚本写好了现在需要把UI按钮和我们的电梯系统连接起来。5.1 配置按钮事件在Unity编辑器中选中电梯轿厢内部的按钮如Button_1。在Inspector面板的Button组件下方找到On Click()事件列表。点击“”号添加一个新的事件。将场景中的ElevatorCab挂载了ElevatorSystem脚本的对象拖拽到事件对象的栏位。在下拉菜单中选择ElevatorSystem - SelectFloor (int)。在出现的输入框中填入对应的楼层索引例如1楼填02楼填1以此类推。对外部的呼叫按钮Call_Up/Call_Down也做类似操作调用RequestFloor方法并传入对应的楼层索引。5.2 实现楼层显示与状态提示为了更好的反馈我们可以创建简单的UI来显示电梯状态。在电梯内部或外部的Canvas上创建一个Text组件命名为StatusDisplay。在ElevatorSystem脚本中添加一个Text或TextMeshProUGUI的公共引用。在Update方法中或者更好的方式是在OnStateChanged事件中更新这个显示文本。[Header(UI引用)] public Text statusDisplayText; // 或使用 TextMeshProUGUI // 在ChangeState方法中触发事件后更新UI OnStateChanged?.Invoke(currentState); UpdateStatusDisplay(); private void UpdateStatusDisplay() { if (statusDisplayText ! null) { string stateStr ; switch (currentState) { case ElevatorState.Idle: stateStr 空闲; break; case ElevatorState.MovingUp: stateStr $上升至 {targetFloorIndex 1}F; break; // ... 其他状态 } statusDisplayText.text ${currentFloorIndex 1}F | {stateStr}; } }5.3 添加音效反馈音效能极大提升沉浸感。我们需要几个关键的音频片段运行电机声、到达“叮”声、开关门声。在ElevatorSystem脚本中添加AudioSource组件引用。准备多个AudioClip公共变量如movingSound,arrivalSound,doorOpenSound,doorCloseSound。在状态切换的适当时机播放音效。例如在ChangeState方法中进入MovingUp/MovingDown时开始循环播放电机声进入Arriving时播放“叮”声并停止电机声在DoorOpening和DoorClosing时播放对应的音效。实操心得对于循环播放的电机声建议使用两个AudioSource一个用于循环音效另一个用于一次性音效如到达声。这样可以独立控制它们的播放和停止避免冲突。同时电机声的音量或音调可以根据电梯的当前速度进行微调加速时音调升高减速时降低细节拉满。6. 高级功能与优化方案基础功能实现后我们可以考虑一些增强功能和优化点让你的电梯系统更专业。6.1 调度算法优化我们之前使用了简单的队列FIFO。但对于多层楼更智能的调度能提升效率。可以实现一个**“顺路优先”** 算法当电梯上行时只响应比当前楼层高且比当前目标楼层低的请求。当电梯下行时只响应比当前楼层低且比当前目标楼层高的请求。其他请求放入等待队列等电梯完成当前方向所有请求并改变方向后再处理。这需要更复杂的请求队列管理可以将Queueint替换为一个按优先级排序的列表。6.2 玩家与电梯同步移动这是确保玩家站在电梯内不掉下去的关键。有几种常见方法设为子物体当玩家进入电梯触发区域时将玩家的GameObject设置为电梯轿厢的子物体。这样玩家的Transform会随父物体电梯一起移动。注意这可能会干扰玩家自身的移动控制脚本需要处理好坐标系的转换。使用CharacterController如果玩家使用CharacterController可以在电梯移动时手动调用CharacterController.Move()将电梯本帧的位移向量施加给玩家。使用物理力如果使用Rigidbody可以在玩家站在电梯上时给玩家施加一个与电梯速度相同的速度。这需要更精细的物理检测。推荐使用第一种方法简单可靠。我们需要在电梯轿厢底部添加一个触发器Trigger Collider并编写脚本检测玩家进入和退出。public class ElevatorPassengerZone : MonoBehaviour { private ElevatorSystem elevatorSystem; private Transform playerTransform; void Start() { elevatorSystem GetComponentInParentElevatorSystem(); } void OnTriggerEnter(Collider other) { if (other.CompareTag(Player)) { playerTransform other.transform; playerTransform.SetParent(transform); // 设置为电梯的子物体 Debug.Log(玩家进入电梯); } } void OnTriggerExit(Collider other) { if (other.CompareTag(Player) other.transform playerTransform) { playerTransform.SetParent(null); // 解除父子关系 // 保持玩家在世界坐标中的位置不变防止弹出 playerTransform.position other.transform.position; playerTransform null; Debug.Log(玩家离开电梯); } } }6.3 性能优化与对象池如果你的场景中有很多电梯或者电梯逻辑非常复杂比如每层楼门都是独立的可交互对象需要考虑性能。减少Update中的计算只在状态改变时更新UI而不是每帧都更新。使用事件驱动很多逻辑如播放音效、更新灯光可以通过监听OnFloorArrived、OnStateChanged等UnityEvent来触发而不是在Update中轮询。对象池管理楼层按钮如果楼层数动态变化可以使用对象池来生成和管理每层的UI按钮避免频繁的Instantiate和Destroy。6.4 动画与特效集成门动画使用Animator Controller控制门的开关动画在DoorOpening和DoorClosing状态触发对应的Bool参数。楼层指示灯创建一个材质数组对应不同状态常亮、闪烁、熄灭。当电梯到达某层或响应某层呼叫时切换对应楼层指示灯的材质。屏幕震动或视角晃动在电梯启动和停止的瞬间可以给主相机添加一个轻微的抖动效果增强重量感和真实感。7. 常见问题排查与调试技巧在开发过程中你肯定会遇到各种“电梯惊魂”。这里记录一些我踩过的坑和解决方法。7.1 电梯移动不流畅或抖动原因1在Update中直接修改Transform.position。如果同时有物理引擎或其他脚本也在影响该物体可能会产生冲突。解决确保电梯的Rigidbody是Kinematic的并且移动逻辑只由我们的ElevatorSystem控制。使用Transform.Translate或直接赋值position都是可以的但要保证每帧只由一个地方控制。原因2帧率不稳定导致每帧移动距离计算有误差。解决确保所有与速度、时间相关的计算都乘以Time.deltaTime。我们的MoveElevator函数中已经正确使用了。7.2 玩家在电梯上滑落或穿模原因父子物体关系设置不当或者玩家的碰撞体与电梯的碰撞体发生异常交互。解决检查电梯和玩家的碰撞体是否都是合理的不要有重叠或过于尖锐的形状。确保在OnTriggerEnter中正确设置了父级并在OnTriggerExit中及时解除。解除时使用SetParent(null, true)第二个参数为true可以保持世界坐标不变防止玩家被弹飞。如果玩家使用Rigidbody可以尝试在进入电梯时将其Rigidbody.isKinematic临时设为true如果游戏设计允许或者使用Rigidbody.MovePosition来同步移动。7.3 按钮点击无反应或逻辑错乱原因1UI事件未正确绑定。解决仔细检查Inspector面板中Button的OnClick事件列表确保目标对象和函数选择正确特别是整型参数的输入框要填对楼层索引从0开始。原因2请求队列逻辑有Bug。解决在RequestFloor和CheckRequests方法中添加Debug.Log打印出当前请求队列、当前楼层、目标楼层等信息跟踪电梯的决策过程。7.4 电梯状态机卡在某个状态原因状态切换条件不满足或者计时器stateTimer没有被正确重置。解决在ChangeState方法中加入更详细的日志。检查每个状态切换到下一个状态的条件。特别是依赖计时器的状态DoorOpening, Waiting, DoorClosing确保stateTimer在进入状态时被正确赋值并在UpdateStateTimer中递减。7.5 多电梯协同工作进阶如果你需要实现一个有多部电梯的复杂系统如双子电梯单个ElevatorSystem就不够了。你需要一个更上层的ElevatorManager来接收所有楼层的呼叫请求并根据每部电梯的当前位置、运行方向和负载智能地将请求分配给最“合适”的电梯。这涉及到更复杂的调度算法如LOOK算法、SCAN算法是面试中常考的系统设计题。实现思路是让ElevatorManager持有所有ElevatorSystem的引用并实现一个中央调度器来处理外部的RequestFloor调用。从创建一个简单的上下移动方块到一个拥有状态机、智能调度、完整音效反馈和玩家交互的电梯系统这个过程涵盖了Unity游戏开发中许多核心概念GameObject与组件、Transform操作、UI事件、动画状态机、物理交互、脚本架构设计以及基本的算法思想。最关键的是理解状态机如何让复杂的行为变得清晰可控以及如何通过事件驱动来解耦各个功能模块。在实际项目中你可以将这个ElevatorSystem脚本和相关的UI、模型打包成一个Prefab预制件。以后在任何需要电梯的场景中直接拖入这个Prefab配置好楼层高度和按钮引用一个功能齐全的电梯就部署好了。这种模块化、可复用的开发思维能极大提升你的开发效率。希望这个详细的指南能帮你少走弯路如果你在实现过程中有任何新的想法或遇到了其他问题欢迎随时交流。