Unity游戏架构实践:动作分离模式在魔鬼与牧师游戏中的应用

1. 项目概述与核心思路

“魔鬼与牧师”这个游戏,本质上是一个经典的过河逻辑谜题,也叫“传教士与野人”问题。游戏规则很简单:三名牧师和三名魔鬼要安全地渡过一条河,只有一艘小船,船最多能载两人。在任何时候,无论是在岸上还是在船上,如果魔鬼的数量超过了牧师的数量(且牧师数量大于0),魔鬼就会攻击牧师,游戏失败。目标是把六个人全部安全送到对岸。

用Unity3D来实现这个游戏,听起来像是个简单的2D逻辑游戏,但“动作分离版”这个后缀,才是这个项目的技术核心和价值所在。它不是一个简单的状态机切换或者一堆if-else判断,而是要求我们采用一种更优雅、更符合软件工程思想的架构来组织代码。简单来说,“动作分离”就是把“做什么”(游戏逻辑,如计算下一个合法状态)和“怎么做”(游戏表现,如人物移动、船体动画)彻底分开。逻辑层只关心状态数据(谁在左岸、谁在右岸、船在哪边),而表现层则根据逻辑层发出的指令,去播放对应的动画、移动物体、更新UI。

为什么要大费周章地做动作分离?直接写在一起不是更快吗?我刚开始做小游戏时也这么想,直到项目稍微复杂一点,改个动画要动逻辑代码,加个新功能又怕把原来的表现搞乱,调试起来苦不堪言。动作分离带来的好处是巨大的:逻辑可测试(你可以写单元测试验证所有过河规则,无需启动游戏)、表现易替换(今天用方块表示人物,明天想换成3D模型,只需换表现层,逻辑层一行代码不用动)、代码易维护(职责清晰,新人接手也能快速看懂)。这个项目,就是一次绝佳的、轻量级的架构设计实践。

2. 核心架构设计:模型、控制器与视图的分离

要实现动作分离,我们通常会采用一种变体的MVC(Model-View-Controller)或者更贴近游戏开发的ECS(Entity-Component-System)思想。对于这个规模的游戏,一个清晰的三层架构就足够了,我将其称为“状态模型-逻辑控制器-表现视图”架构。

2.1 状态模型 (Model) —— 游戏的数据核心

这一层只负责定义和存储游戏的核心数据,它不应该有任何Unity的API(如GameObject,Transform)。它是一个纯粹的C#类,甚至可以放在一个独立的程序集中。

首先,我们需要定义游戏中的对象和位置。我会创建一个枚举和两个核心类。

// 定义位置 public enum BankSide { Left, Right } // 定义角色类型 public enum CharacterType { Priest, Devil } // 角色数据模型 public class Character { public CharacterType Type { get; set; } public BankSide CurrentSide { get; set; } // 可以扩展ID、名字等属性 } // 游戏核心状态模型 public class RiverCrossingModel { // 所有角色列表 public List<Character> AllCharacters { get; private set; } // 船的位置 public BankSide BoatSide { get; private set; } // 游戏是否结束 public bool IsGameOver { get; private set; } // 游戏是否胜利 public bool IsGameWin { get; private set; } // 构造函数,初始化3牧师3魔鬼在左岸 public RiverCrossingModel() { AllCharacters = new List<Character>(); for (int i = 0; i < 3; i++) { AllCharacters.Add(new Character { Type = CharacterType.Priest, CurrentSide = BankSide.Left }); AllCharacters.Add(new Character { Type = CharacterType.Devil, CurrentSide = BankSide.Left }); } BoatSide = BankSide.Left; IsGameOver = false; IsGameWin = false; } // 核心方法:尝试移动角色上/下船,并过河。返回移动是否合法。 public bool TryMoveCharacters(List<Character> charactersToMove) { // 1. 验证:移动的角色是否都在船所在的岸边? // 2. 验证:移动的角色数量是否为1或2? // 3. 执行移动逻辑(改变角色的CurrentSide) // 4. 移动后,检查当前两岸状态是否合法(魔鬼数量不能超过牧师数量) // 5. 如果非法,游戏结束(IsGameOver = true) // 6. 如果全部角色都到了右岸,游戏胜利(IsGameWin = true) // 返回true表示移动合法且已执行 } // 获取当前左岸/右岸的角色列表(用于视图更新) public List<Character> GetCharactersOnSide(BankSide side) { return AllCharacters.Where(c => c.CurrentSide == side).ToList(); } }

这个RiverCrossingModel就是游戏的“大脑”。它不知道Unity是什么,只关心数据规则。TryMoveCharacters方法是整个游戏的逻辑枢纽,所有规则判断都在这里。

实操心得:在模型层,务必保持其“纯净”。不要为了“方便”而在模型里调用Debug.Log或者访问任何GameObject。这能确保你的游戏逻辑可以轻松地被单元测试框架(如NUnit)测试。你可以模拟各种操作序列,验证TryMoveCharacters的返回值是否正确,游戏状态是否按预期改变。

2.2 逻辑控制器 (Controller) —— 用户输入与模型的中介

控制器负责接收用户的输入(点击UI按钮、点击角色等),将其转化为对模型的操作指令,并根据模型返回的结果,向视图层发出“该做什么”的指令。它知道模型和视图,是二者的协调者。

public class GameController : MonoBehaviour { private RiverCrossingModel _gameModel; private GameView _gameView; // 视图层接口 private List<Character> _selectedCharacters = new List<Character>(); // 当前选中的待移动角色 void Start() { _gameModel = new RiverCrossingModel(); _gameView = GetComponent<GameView>(); // 或通过其他方式获取 _gameView.InitializeView(_gameModel); // 初始化视图,根据模型状态创建场景物体 } // 当用户点击一个角色时,由视图层调用此方法 public void OnCharacterClicked(Character character) { // 如果角色不在船所在的岸边,或游戏已结束,则忽略 if (character.CurrentSide != _gameModel.BoatSide || _gameModel.IsGameOver) return; // 选中/取消选中逻辑 if (_selectedCharacters.Contains(character)) { _selectedCharacters.Remove(character); _gameView.OnCharacterDeselected(character); // 通知视图更新选中状态(如取消高亮) } else { if (_selectedCharacters.Count < 2) // 最多选两个 { _selectedCharacters.Add(character); _gameView.OnCharacterSelected(character); // 通知视图高亮显示 } } } // 当用户点击“开船”按钮时 public void OnSailButtonClicked() { if (_selectedCharacters.Count == 0) return; // 至少选一个人 // 调用模型的核心逻辑 bool moveSuccess = _gameModel.TryMoveCharacters(_selectedCharacters); if (moveSuccess) { // 移动成功,通知视图层执行过河动画 _gameView.PerformCrossingAnimation(_selectedCharacters, _gameModel.BoatSide); // 清空选择 _selectedCharacters.Clear(); _gameView.ClearAllSelections(); // 检查游戏状态 if (_gameModel.IsGameWin) { _gameView.ShowGameWin(); } else if (_gameModel.IsGameOver) { _gameView.ShowGameOver(); } else { // 游戏继续,可能需要更新岸上角色显示 _gameView.UpdateCharactersOnBank(_gameModel.BoatSide); // 更新对岸角色显示 } } else { // 移动非法(理论上TryMoveCharacters已做检查,这里可能是其他逻辑错误) _gameView.ShowInvalidMoveHint(); _selectedCharacters.Clear(); _gameView.ClearAllSelections(); } } // 当动画结束后,由视图层调用,以最终更新物体位置 public void OnCrossingAnimationFinished() { // 动画播完了,才真正更新视图层GameObject的世界位置 _gameView.SyncViewToModel(_gameModel); } }

控制器就像一个交通警察,它不关心角色具体怎么“走”过去(那是视图的事),它只告诉视图:“现在让选中的这两个角色执行过河动作,船开到对岸去”。视图层执行完毕后,回调通知控制器,控制器再告诉视图去同步一次数据。

2.3 表现视图 (View) —— 一切你看得见的东西

视图层是Unity的“主场”,它包含所有的GameObjectUIAnimationParticle System等。它监听控制器的指令,播放动画、更新位置、显示UI。

public class GameView : MonoBehaviour { // 角色预制体、船物体、两岸位置标记等引用 public GameObject priestPrefab; public GameObject devilPrefab; public GameObject boat; public Transform leftBankPosition; public Transform rightBankPosition; public Transform boatSeatPosition1; public Transform boatSeatPosition2; // UI引用 public Button sailButton; public Text gameStateText; // 用于关联模型角色和视图GameObject的字典 private Dictionary<Character, GameObject> _characterGameObjectMap = new Dictionary<Character, GameObject>(); private Dictionary<Character, Image> _characterSelectionHighlightMap = new Dictionary<Character, Image>(); // 高亮组件 private GameController _controller; void Start() { _controller = GetComponent<GameController>(); sailButton.onClick.AddListener(() => _controller.OnSailButtonClicked()); } // 由控制器调用,初始化场景 public void InitializeView(RiverCrossingModel model) { ClearView(); foreach (var character in model.AllCharacters) { GameObject charPrefab = character.Type == CharacterType.Priest ? priestPrefab : devilPrefab; Vector3 spawnPos = GetBankWorldPosition(character.CurrentSide); GameObject charGo = Instantiate(charPrefab, spawnPos, Quaternion.identity); // 给角色视图添加点击事件 CharacterView charView = charGo.GetComponent<CharacterView>(); if (charView != null) { charView.Initialize(character, _controller); } _characterGameObjectMap[character] = charGo; // 初始化高亮(可以先隐藏) // ... } UpdateBoatPosition(model.BoatSide); } // 控制器通知:角色被选中 public void OnCharacterSelected(Character character) { if (_characterGameObjectMap.TryGetValue(character, out GameObject go)) { // 显示高亮效果,例如改变材质颜色或显示一个光圈 // go.GetComponent<Renderer>().material.color = Color.yellow; Debug.Log($"Character selected: {character.Type}"); } } // 执行过河动画 public void PerformCrossingAnimation(List<Character> characters, BankSide targetBoatSide) { // 1. 将选中的角色GameObject移动到船上的座位(使用Dotween或LeanTween做平滑移动) for (int i = 0; i < characters.Count; i++) { Character c = characters[i]; GameObject charGo = _characterGameObjectMap[c]; Transform targetSeat = (i == 0) ? boatSeatPosition1 : boatSeatPosition2; charGo.transform.DOMove(targetSeat.position, 0.5f); } // 2. 移动船到对岸(同样使用动画) Vector3 targetBoatPos = (targetBoatSide == BankSide.Left) ? leftBankPosition.position : rightBankPosition.position; boat.transform.DOMove(targetBoatPos, 1.0f).OnComplete(() => { // 3. 动画完成后,通知控制器 _controller.OnCrossingAnimationFinished(); }); } // 动画结束后,同步所有GameObject位置到模型最新状态 public void SyncViewToModel(RiverCrossingModel model) { foreach (var character in model.AllCharacters) { if (_characterGameObjectMap.TryGetValue(character, out GameObject go)) { // 如果角色在船上,位置由船决定,这里只需更新岸上角色的位置 // 更精细的管理可以维护一个“角色当前在船上还是岸上”的视图状态 // 简单起见,我们根据角色位置直接设置 Vector3 targetPos = GetBankWorldPosition(character.CurrentSide); go.transform.position = targetPos; // 直接设置,或使用一个极短的动画 } } UpdateBoatPosition(model.BoatSide); } private Vector3 GetBankWorldPosition(BankSide side) { // 返回岸边某个生成点的位置,可以设计多个位置点避免重叠 return (side == BankSide.Left) ? leftBankPosition.position : rightBankPosition.position; } private void UpdateBoatPosition(BankSide side) { Vector3 pos = (side == BankSide.Left) ? leftBankPosition.position : rightBankPosition.position; boat.transform.position = pos; } public void ShowGameWin() { gameStateText.text = "胜利!所有角色安全过河!"; } public void ShowGameOver() { gameStateText.text = "失败!牧师被吃了!"; } public void ShowInvalidMoveHint() { /* 可以显示一个提示框 */ } public void ClearAllSelections() { /* 清除所有高亮效果 */ } }

视图层GameView是依赖Unity引擎的,它负责“表演”。注意PerformCrossingAnimation方法,它使用了DOMove(假设引入了DOTween插件)来制作平滑移动动画。动画播放期间,模型的数据(角色的CurrentSide)其实已经改变了,但视图物体的世界坐标还没变,直到SyncViewToModel被调用。这就是“动作分离”的精髓:逻辑瞬间完成,表现异步播放。

3. 关键实现细节与“动作分离”的落地

架构搭好了,但要让其流畅运行,有几个关键细节必须处理好,这也是新手最容易踩坑的地方。

3.1 角色与视图的关联管理

如何将模型层的Character对象和场景中的GameObject一一对应?上面使用了Dictionary<Character, GameObject>。这里有个进阶技巧:为每个角色视图创建一个CharacterView组件,挂在角色GameObject上。

// CharacterView.cs 挂在每个角色预制体上 public class CharacterView : MonoBehaviour { private Character _linkedCharacter; private GameController _controller; public void Initialize(Character character, GameController controller) { _linkedCharacter = character; _controller = controller; // 添加点击事件(也可以用EventTrigger) Button btn = gameObject.GetComponent<Button>(); // 如果角色是UI Image // 或者使用Collider + OnMouseDown,取决于你的实现是2D Sprite还是3D模型 if (btn != null) { btn.onClick.AddListener(OnClick); } else { // 为3D物体添加一个脚本,监听点击 } } void OnClick() { _controller.OnCharacterClicked(_linkedCharacter); } // 可以被GameView调用来改变外观(如选中高亮) public void SetHighlight(bool isOn) { // 改变材质或激活一个高亮子物体 } }

这样,点击事件的处理就封装在了视图对象自身,它只需回调给控制器,并传递与之关联的Character模型对象。控制器和主GameView都不需要关心具体是哪个GameObject被点了,它们只操作Character对象。

3.2 动画与逻辑的同步时机

这是动作分离中最微妙的一环。流程必须是:

  1. 用户操作->控制器->调用模型TryMoveCharacters
  2. 模型验证并立即更新内部状态CurrentSide,BoatSide),返回成功。
  3. 控制器收到成功,命令视图PerformCrossingAnimation
  4. 视图开始播放移动角色上船船体移动的动画。
  5. 船体移动动画结束时,视图回调控制器OnCrossingAnimationFinished
  6. 控制器命令视图SyncViewToModel,视图将角色GameObject的位置一次性更新到模型计算出的新岸边位置。

踩坑实录:千万不要在播放动画的中间(例如用协程yield return new WaitForSeconds)去更新模型状态,或者根据动画的中间状态去做逻辑判断。模型的状态变更必须是原子性的、即刻的。动画只是“视觉反馈”,它的时间长度不应该影响游戏逻辑。我曾在一个项目里,因为把状态更新放在协程里,导致快速连续点击时,逻辑状态和视觉表现完全错乱。

3.3 游戏状态验证逻辑的实现

TryMoveCharacters方法是游戏规则的守护者。它的实现必须严谨:

public bool TryMoveCharacters(List<Character> charactersToMove) { // 条件1: 游戏已结束不能再移动 if (IsGameOver || IsGameWin) return false; // 条件2: 移动人数为1或2 if (charactersToMove == null || charactersToMove.Count == 0 || charactersToMove.Count > 2) return false; // 条件3: 所有待移动角色必须在船所在的岸边 if (charactersToMove.Any(c => c.CurrentSide != BoatSide)) return false; // 条件4: 船上只能有这些待移动角色(简化逻辑,每次开船前必须选人,开船后船上无人) // 在我们的设计里,角色上船是视觉表现,模型层面“上船”即“改变所在岸”。 // 所以这里直接改变角色岸边归属。 // 记录移动前的状态,用于可能的回滚(或直接计算移动后状态) var oldStates = charactersToMove.Select(c => c.CurrentSide).ToList(); // 执行移动:将选中角色的岸边切换到对岸 BankSide oppositeSide = (BoatSide == BankSide.Left) ? BankSide.Right : BankSide.Left; foreach (var character in charactersToMove) { character.CurrentSide = oppositeSide; } // 船也移动到对岸 BoatSide = oppositeSide; // 移动后,检查两岸安全性 if (!IsGameStateSafe()) { // 状态不安全,游戏结束 IsGameOver = true; // 注意:这里不回滚状态,因为失败状态需要被记录和显示 return true; // 移动操作本身是“成功”执行的,只是导致了失败结局 } // 检查是否胜利(所有角色都在右岸) if (AllCharacters.All(c => c.CurrentSide == BankSide.Right)) { IsGameWin = true; } return true; } private bool IsGameStateSafe() { foreach (BankSide side in Enum.GetValues(typeof(BankSide))) { var charactersOnSide = GetCharactersOnSide(side); int priestCount = charactersOnSide.Count(c => c.Type == CharacterType.Priest); int devilCount = charactersOnSide.Count(c => c.Type == CharacterType.Devil); // 关键规则:如果该岸有牧师(priestCount > 0),则魔鬼数量不能超过牧师数量 if (priestCount > 0 && devilCount > priestCount) { return false; // 不安全 } // 如果该岸没有牧师,只有魔鬼,是安全的(魔鬼不会互吃) } return true; }

注意TryMoveCharacters的返回值:它返回true表示“移动指令被接受并执行了”,无论执行后是成功、失败还是胜利。调用者(控制器)需要通过检查IsGameOverIsGameWin来确定具体结果。这种设计比返回一个复杂的枚举更清晰。

4. 界面交互与用户体验优化

有了核心架构,我们可以让游戏玩起来更舒服。

4.1 使用UGUI构建交互界面

在Unity中,使用UGUI来制作游戏界面是最直接的选择。我们需要:

  • 角色按钮/图片:每个牧师和魔鬼都是一个可点击的ImageButton组件。将其与CharacterView脚本关联。
  • 船体对象:可以是一个Image或一个SpriteRenderer的2D精灵,也可以是一个3D模型。
  • 控制按钮:一个“开船(Sail)”按钮,点击后触发控制器的对应方法。
  • 状态提示文本:用于显示“游戏胜利”、“游戏失败”或当前行动提示。
  • 重新开始按钮:重置模型和视图状态。

布局上,通常左岸和右岸在屏幕左右两侧,船在中间河道上下移动。角色在各自岸边排开,点击后可以有一个视觉反馈(如放大缩小、颜色变化)。

4.2 利用Dotween增强动画表现

干巴巴的瞬间移动很生硬。使用DOTween插件(或Unity自带的LeanTween)可以轻松制作出平滑的动画。

// 在GameView中更优雅的动画示例 public void PerformCrossingAnimation(List<Character> characters, BankSide targetBoatSide) { Sequence crossingSequence = DOTween.Sequence(); // 步骤1: 角色走上船(假设有上船的动画位置) foreach (var character in characters) { GameObject charGo = _characterGameObjectMap[character]; // 假设我们为船预设了两个座位点的Transform Transform seat = GetNextAvailableSeat(); crossingSequence.Join(charGo.transform.DOMove(seat.position, 0.3f).SetEase(Ease.OutBack)); } // 步骤2: 船移动到对岸 Vector3 targetBoatPos = GetBoatTargetPosition(targetBoatSide); crossingSequence.Append(boat.transform.DOMove(targetBoatPos, 0.8f).SetEase(Ease.InOutSine)); // 步骤3: 角色走下船(视觉上,在SyncViewToModel中处理会更简单,这里演示另一种) // 实际上,更清晰的做法是:船到岸动画结束后,在OnComplete里直接调用SyncViewToModel crossingSequence.OnComplete(() => { _controller.OnCrossingAnimationFinished(); }); }

通过Sequence来组织动画序列,让“上船-开船”流程更有节奏感。SetEase可以设置不同的缓动函数,让移动更自然。

4.3 实现游戏重置与状态管理

游戏结束后,需要提供重新开始的功能。这需要重置模型和视图。

// 在GameController中 public void RestartGame() { // 1. 销毁所有现有的视图物体 _gameView.ClearView(); // 2. 创建新的模型 _gameModel = new RiverCrossingModel(); // 3. 重新初始化视图 _gameView.InitializeView(_gameModel); // 4. 重置控制器内部状态 _selectedCharacters.Clear(); }

GameView中实现ClearView方法,遍历_characterGameObjectMapDestroy每个GameObject,然后清空字典。

5. 项目扩展与进阶思考

一个基础的“动作分离版”魔鬼与牧师已经完成。但我们可以让它变得更有趣、更专业。

5.1 引入有限状态机管理游戏流程

目前游戏状态(进行中、胜利、失败)是分散的布尔变量。对于更复杂的状态(如“等待选择”、“动画播放中”、“暂停”),可以使用一个简单的状态机来管理。

public enum GameState { WaitingForSelection, // 等待玩家选择角色 AnimationPlaying, // 过河动画播放中 GameOver, GameWin } public class GameController : MonoBehaviour { public GameState CurrentState { get; private set; } public void OnCharacterClicked(Character character) { if (CurrentState != GameState.WaitingForSelection) return; // ... 原有的选择逻辑 } public void OnSailButtonClicked() { if (CurrentState != GameState.WaitingForSelection) return; CurrentState = GameState.AnimationPlaying; // ... 原有的开船逻辑 } public void OnCrossingAnimationFinished() { if (_gameModel.IsGameWin) CurrentState = GameState.GameWin; else if (_gameModel.IsGameOver) CurrentState = GameState.GameOver; else CurrentState = GameState.WaitingForSelection; // ... 同步视图 } }

这样能更严格地控制在不同状态下哪些输入是有效的,避免动画播放时玩家还能乱点导致的bug。

5.2 设计更复杂的角色行为与AI

当前版本是纯玩家操作。可以增加一个“AI模式”,让电脑自动求解并演示过河步骤。这需要实现一个搜索算法(如BFS广度优先搜索)来遍历所有可能的游戏状态,找到一条从初始状态到胜利状态的路径。模型层RiverCrossingModel需要提供一个方法,用于生成从当前状态出发、所有合法的下一步状态集合。然后AI控制器就可以驱动游戏自动进行。

5.3 数据持久化与关卡设计

将模型数据(角色数量、船容量、胜利条件)设计成可配置的。例如,可以创建一个LevelDataScriptableObject,定义“4个牧师、4个魔鬼、船容量为3”的关卡。游戏开始时加载关卡数据来初始化模型。这样就能轻松扩展出不同难度的谜题。甚至可以加入“存储/加载”游戏进度的功能,将RiverCrossingModel序列化成JSON或二进制数据保存起来。

5.4 加入音效与粒子特效

视图层的工作不只是移动物体。在角色被选中时播放一个“点击”音效,在船移动时播放划水声,在游戏胜利时播放欢呼声和绽放粒子特效,在游戏失败时播放低沉的音效和红色警告效果。这些都属于“表现”的范畴,通过视图层的事件(如OnCharacterSelected,ShowGameWin)来触发,完美符合动作分离的原则。

6. 常见问题与调试技巧

在实现过程中,你几乎一定会遇到下面这几个问题。

问题1:点击角色没反应。

  • 排查:首先检查CharacterView脚本是否正确地挂载到了每个角色预制体上,并且Initialize方法被调用,_linkedCharacter_controller是否被正确赋值。
  • 技巧:在OnClick方法里加一句Debug.Log(“Clicked: ” + _linkedCharacter.Type);,看控制台是否有输出。没有输出说明点击事件没触发,检查GameObject是否有Collider(3D物体)或Image/Button组件(UI物体),以及射线阻挡。

问题2:动画播放后,角色位置错乱。

  • 排查:这是最典型的动作分离不同步问题。检查PerformCrossingAnimationSyncViewToModel的调用时机。确保SyncViewToModel是在船移动动画完全结束后才被调用。使用DOTween的OnComplete回调是可靠的做法。
  • 技巧:在SyncViewToModel方法中,打印每个角色模型中的CurrentSide和其对应GameObjecttransform.position,对比是否一致。

问题3:游戏规则判断似乎有误。

  • 排查:脱离Unity,为RiverCrossingModel类编写单元测试。这是动作分离架构最大的优势之一。你可以创建一个测试项目,直接实例化模型,调用TryMoveCharacters,并断言(Assert)游戏状态是否符合预期。
  • 技巧:在TryMoveCharacters方法内部的关键判断点添加详细的日志,记录移动前、移动后的两岸人数,方便追踪逻辑流程。

问题4:重新开始游戏后,旧的角色物体还在。

  • 排查GameView.ClearView()方法是否正确地遍历并销毁了_characterGameObjectMap中的所有GameObject?是否在销毁后清空了字典?在RestartGame中,是否在创建新模型之前调用了ClearView
  • 技巧:在ClearView中,使用foreach (var kvp in _characterGameObjectMap.ToList())来遍历副本,因为直接遍历字典并在循环内销毁可能会引发异常。

这个“魔鬼与牧师(动作分离版)”项目,虽然规则简单,但作为一个架构练习,其价值远超一个普通的小游戏。它强迫你思考数据的流向、职责的划分、模块的解耦。当你成功实现它,并看着逻辑和表现清晰分离、各司其职的代码时,你对Unity游戏开发的理解会上一个坚实的台阶。以后再面对更复杂的项目,你自然会知道如何规划代码结构,而不是把所有东西都塞进一个巨大的GameManager里。