尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Unity游戏开发:基于MVC架构实现牧师与魔鬼渡河游戏

Unity游戏开发:基于MVC架构实现牧师与魔鬼渡河游戏 1. 项目概述与核心思路最近在重温一些经典的游戏设计模式案例发现“牧师与魔鬼”Priests and Deivls 常被误拼为Deivls 应为Devils这个渡河逻辑谜题是理解状态管理、有限状态机和游戏逻辑解耦的绝佳练手项目。它规则简单三名牧师和三名魔鬼要安全渡河船一次最多载两人任何一岸若魔鬼数量多于牧师除非该岸没有牧师则游戏失败。这个项目在Unity里实现远不止是摆几个模型让它们移动那么简单它真正考验的是你对游戏架构底层逻辑的梳理能力。很多新手会一头扎进具体的移动动画和点击交互里最后发现状态判断的代码像一团乱麻牵一发而动全身。我这次的目标是构建一个清晰、可扩展的架构让游戏逻辑、数据、表现层各司其职这样无论是未来增加新角色、新规则还是优化UI和动画都能做到心中有数改动从容。这个项目适合已经熟悉Unity基本操作如场景搭建、预制体、简单脚本编写但希望提升代码架构能力和对设计模式理解的开发者。通过它你不仅能做出一个可玩的游戏更能深刻体会到“数据驱动”和“模型-视图-控制器MVC”或类似模式在游戏开发中的实际威力。你会发现把核心规则计算从Unity的GameObject和MonoBehaviour中剥离出来后整个项目的可测试性和可维护性会有质的飞跃。2. 架构设计与核心模型抽象2.1 为什么需要数据与表现分离直接想到的实现方式可能是创建六个GameObject分别挂上Priest或Devil的脚本脚本里记录自己是牧师还是魔鬼、当前在哪一岸。然后通过OnMouseDown处理点击用一个Boat脚本管理船的位置和载客。逻辑判断则散落在各个点击事件和船的移动函数中。这种做法在小项目中似乎可行但隐患巨大。首先游戏规则如胜负判定和游戏表现如角色移动动画高度耦合。你想测试一下游戏逻辑是否正确必须运行游戏、点击UI无法进行单元测试。其次当你想改变规则比如增加船容量、或者增加一种新角色“天使”你会发现需要修改无数个地方的代码极易出错。因此我的设计核心是建立一个纯粹的数据模型Model来管理游戏状态和规则让Unity的场景对象View仅仅作为这个数据模型的“可视化投影”。所有游戏规则的运算如移动是否合法、游戏是否结束都在数据模型中完成与GameObject、Transform、Animator等Unity引擎特有的概念无关。2.2 核心数据模型设计我设计了三个核心的C#类普通类 不继承MonoBehaviour来构建这个数据模型。GameCharacter类代表一个游戏角色。它只关心最本质的属性。public enum CharacterType { Priest, Devil } public class GameCharacter { public CharacterType Type { get; private set; } public int Id { get; private set; } // 用于区分同类型角色 public bool IsOnBoat { get; set; } // 是否在船上 public BankSide CurrentSide { get; set; } // 当前所在岸 public GameCharacter(CharacterType type, int id) { Type type; Id id; CurrentSide BankSide.Left; // 初始在左岸 IsOnBoat false; } }注意这里使用BankSide枚举Left,Right而不是Vector3位置来记录角色位置这是实现数据与表现分离的关键一步。位置是表现层的概念而“在哪一岸”是逻辑层的概念。Boat类代表船。它管理自身的状态和承载的角色。public class Boat { public BankSide CurrentSide { get; private set; } private GameCharacter[] _passengers; public int Capacity { get; private set; } public Boat(int capacity 2) { Capacity capacity; _passengers new GameCharacter[capacity]; CurrentSide BankSide.Left; } public bool IsEmpty() { return _passengers.All(p p null); } public bool IsFull() { return _passengers.All(p p ! null); } public bool TryEmbark(GameCharacter character) { // 尝试上船逻辑... } public bool TryDisembark(int seatIndex) { // 尝试下船逻辑... } public void Move() { CurrentSide (CurrentSide BankSide.Left) ? BankSide.Right : BankSide.Left; } }GameModel类这是游戏最核心的“大脑”。它持有所有角色和船的实例并封装了所有的游戏规则。public class GameModel { public ListGameCharacter AllCharacters { get; private set; } public Boat GameBoat { get; private set; } public GameStatus CurrentStatus { get; private set; } public event Action OnGameStateChanged; // 状态改变事件 public GameModel() { InitializeGame(); } private void InitializeGame() { // 初始化3个牧师3个魔鬼 AllCharacters new ListGameCharacter(); for (int i 0; i 3; i) { AllCharacters.Add(new GameCharacter(CharacterType.Priest, i)); AllCharacters.Add(new GameCharacter(CharacterType.Devil, i)); } GameBoat new Boat(2); CurrentStatus GameStatus.Playing; } public bool CanMoveBoat() { // 船上有至少一个人才能开船 return !GameBoat.IsEmpty(); } public void MoveBoat() { if (!CanMoveBoat()) return; GameBoat.Move(); // 移动后更新船上角色的“岸”属性 foreach (var character in AllCharacters.Where(c c.IsOnBoat)) { character.CurrentSide GameBoat.CurrentSide; } CheckGameStatus(); OnGameStateChanged?.Invoke(); // 通知视图更新 } private void CheckGameStatus() { // 检查每一岸的牧师和魔鬼数量 var leftBank AllCharacters.Where(c c.CurrentSide BankSide.Left !c.IsOnBoat).ToList(); var rightBank AllCharacters.Where(c c.CurrentSide BankSide.Right !c.IsOnBoat).ToList(); bool leftSafe IsBankSafe(leftBank); bool rightSafe IsBankSafe(rightBank); if (!leftSafe || !rightSafe) { CurrentStatus GameStatus.Lose; } else if (rightBank.Count 6) // 所有角色都到了右岸且不在船上 { CurrentStatus GameStatus.Win; } else { CurrentStatus GameStatus.Playing; } } private bool IsBankSafe(ListGameCharacter bankCharacters) { int priestCount bankCharacters.Count(c c.Type CharacterType.Priest); int devilCount bankCharacters.Count(c c.Type CharacterType.Devil); // 安全条件没有牧师或者牧师数量大于等于魔鬼数量 return priestCount 0 || priestCount devilCount; } // 上船、下船等操作也封装在这里并触发OnGameStateChanged事件 }这个GameModel完全独立。你可以在一个纯C#的控制台应用程序中实例化它调用它的方法并观察CurrentStatus的变化从而在不启动Unity的情况下验证你的游戏逻辑是否正确。这是架构清晰带来的第一个巨大好处。3. 视图控制器与用户交互实现3.1 连接模型与视图ViewController有了纯净的模型我们需要一个桥梁来同步模型状态和Unity场景的显示。这个桥梁就是ViewController或称为GameController它是一个MonoBehaviour。它的职责很明确持有GameModel实例它是游戏逻辑的入口。监听模型事件当GameModel的OnGameStateChanged事件触发时更新所有游戏对象的显示。接收用户输入处理UI按钮点击或3D物体的点击事件将其转化为对GameModel的方法调用。管理视图对象维护角色、船、岸等GameObject的引用并根据模型数据设置它们的位置、旋转、激活状态等。public class GameController : MonoBehaviour { private GameModel _gameModel; [Header(View References)] public GameObject priestPrefab; public GameObject devilPrefab; public Transform leftBankPosition; public Transform rightBankPosition; public Transform boatPosition; public Transform[] boatSeatPositions; // 船上座位位置 private DictionaryGameCharacter, GameObject _characterViewMap; // 模型到视图的映射 void Start() { _gameModel new GameModel(); _characterViewMap new DictionaryGameCharacter, GameObject(); InitializeViews(); _gameModel.OnGameStateChanged UpdateAllViews; } private void InitializeViews() { // 根据_gameModel.AllCharacters创建并摆放所有角色GameObject foreach (var character in _gameModel.AllCharacters) { GameObject prefab character.Type CharacterType.Priest ? priestPrefab : devilPrefab; GameObject charObj Instantiate(prefab); _characterViewMap[character] charObj; UpdateCharacterView(character); // 设置初始位置 } UpdateBoatView(); } // 当模型状态改变时由事件驱动调用 private void UpdateAllViews() { foreach (var kvp in _characterViewMap) { UpdateCharacterView(kvp.Key); } UpdateBoatView(); UpdateGameStatusUI(); // 更新UI显示胜利或失败 } private void UpdateCharacterView(GameCharacter character) { GameObject charObj _characterViewMap[character]; if (character.IsOnBoat) { // 计算在船上的座位索引和位置 int seatIndex GetBoatSeatIndex(character); charObj.transform.position boatSeatPositions[seatIndex].position; } else { // 根据所在岸放置在岸边的某个位置可以简单偏移 Transform bank character.CurrentSide BankSide.Left ? leftBankPosition : rightBankPosition; charObj.transform.position bank.position CalculateBankOffset(character); } } // 提供给UI按钮调用的方法 public void OnCharacterClicked(int characterId) { // 通过ID找到对应的GameCharacter模型 var character _gameModel.AllCharacters.FirstOrDefault(c c.Id characterId c.Type ...); if (character ! null) { if (character.IsOnBoat) { _gameModel.TryDisembark(character); } else if (_gameModel.CanEmbark(character)) { _gameModel.TryEmbark(character); } // 模型操作会触发OnGameStateChanged进而自动更新视图 } } public void OnMoveBoatClicked() { _gameModel.MoveBoat(); } }这种模式被称为“观察者模式”或“事件驱动”。视图不主动轮询模型的状态而是订阅模型的变化事件。当模型内部状态因任何操作改变时它发出一个信号所有相关的视图自动更新。这保证了数据的一致性也使得代码逻辑非常清晰。3.2 用户交互与输入处理用户交互主要有两种点击角色上/下船点击船移动。我们可以为每个角色GameObject添加一个CharacterView脚本它持有对应的GameCharacter模型引用并在OnMouseDown中通知GameController。public class CharacterView : MonoBehaviour { public GameCharacter LinkedCharacter { get; set; } private GameController _controller; void Start() { _controller FindObjectOfTypeGameController(); // 简单查找更好的做法是依赖注入 } void OnMouseDown() { if (_controller ! null LinkedCharacter ! null) { // 通知控制器这个角色被点击了 _controller.OnCharacterClicked(LinkedCharacter); } } }在GameController的InitializeViews中创建角色GameObject后需要将CharacterView脚本挂上去并设置LinkedCharacter。对于船的移动可以直接在Unity中创建一个UI按钮如“Move Boat”将其OnClick()事件绑定到GameController的OnMoveBoatClicked方法。实操心得在处理点击时特别是3D物体要确保GameObject有Collider组件。对于2D UI式的点击也可以使用EventTrigger组件。为了更好的体验可以在点击时加入简单的视觉反馈比如改变角色颜色或缩放这个反馈应该在CharacterView的OnMouseDown和OnMouseUp中处理与核心逻辑无关。4. 场景搭建与视觉表现细节4.1 基础场景构建场景不需要很复杂但清晰的视觉分区很重要。地形可以使用一个平面Plane作为地面或者使用Terrain工具创建一个简单的河流和两岸的地形。用不同的材质或颜色区分左岸、右岸和水域。船一个简单的长方体或导入一个船模型。为其添加一个空物体作为Boat节点并将两个表示座位的空物体如Seat_Left,Seat_Right作为其子物体。GameController中引用的boatSeatPositions就是这两个子物体的Transform。角色牧师和魔鬼使用不同的预制体。可以用胶囊体Capsule搭配不同颜色材质如白色代表牧师红色代表魔鬼或者从Asset Store找一些简单的低多边形模型。关键是预制体要挂载CharacterView脚本和Collider如Box Collider。岸在左右两岸各创建一个空物体如LeftBank,RightBank作为角色在岸上时的父节点或位置参考点。GameController中的leftBankPosition和rightBankPosition就指向这两个物体。4.2 位置计算与动画在UpdateCharacterView中我们需要计算角色在岸上的具体位置避免堆叠。private Vector3 CalculateBankOffset(GameCharacter character) { // 简单方案根据角色ID和类型计算一个偏移 int index _gameModel.AllCharacters .Where(c c.CurrentSide character.CurrentSide !c.IsOnBoat) .ToList() .IndexOf(character); float spacing 1.5f; return new Vector3(index * spacing, 0, 0); }更复杂的方案可以预先在岸的Transform下布置好多个子物体作为“停靠点”。关于动画为了保持逻辑纯净不建议在模型操作中直接播放动画。应该在视图更新时检测状态变化然后触发动画。例如在GameController的UpdateCharacterView中可以判断角色GameObject的当前位置和目标位置如果不同则启动一个协程Coroutine进行平滑移动Lerp。private IEnumerator MoveCharacterToPosition(GameObject charObj, Vector3 targetPos, float duration) { Vector3 startPos charObj.transform.position; float elapsed 0f; while (elapsed duration) { charObj.transform.position Vector3.Lerp(startPos, targetPos, elapsed / duration); elapsed Time.deltaTime; yield return null; } charObj.transform.position targetPos; }然后在UpdateCharacterView中不再直接设置position而是调用StartCoroutine(MoveCharacterToPosition(charObj, targetPos, 0.5f))。船的移动同理。注意事项使用协程做动画时要处理好多次快速点击导致多个协程同时运行的问题。一个简单的办法是在CharacterView或GameController中记录当前正在移动的角色如果上一个移动动画未结束则忽略新的移动请求或者中断上一个动画。5. 游戏状态管理与UI反馈5.1 状态流转与UI更新GameModel中的GameStatusPlaying, Win, Lose是游戏的核心状态。当状态变为Win或Lose时我们需要在UI上给予明确反馈并阻止玩家继续操作。在GameController中private void UpdateGameStatusUI() { switch (_gameModel.CurrentStatus) { case GameStatus.Win: winPanel.SetActive(true); // 显示胜利UI面板 // 可以禁用所有角色的点击交互 SetAllCharactersInteractable(false); break; case GameStatus.Lose: losePanel.SetActive(true); // 显示失败UI面板 SetAllCharactersInteractable(false); break; case GameStatus.Playing: winPanel.SetActive(false); losePanel.SetActive(false); SetAllCharactersInteractable(true); break; } } private void SetAllCharactersInteractable(bool interactable) { foreach (var view in _characterViewMap.Values) { var charView view.GetComponentCharacterView(); if (charView ! null) { charView.enabled interactable; // 直接禁用脚本 } } // 同时也可以禁用船的移动按钮 moveBoatButton.interactable interactable; }胜利/失败面板是简单的UI Canvas包含文字和“重新开始”按钮。5.2 重置游戏功能重置功能非常重要。正确的做法不是重新加载场景而是重置GameModel和所有视图。public void RestartGame() { // 1. 销毁所有现有的角色视图对象 foreach (var obj in _characterViewMap.Values) { Destroy(obj); } _characterViewMap.Clear(); // 2. 创建新的模型实例或者重置现有模型的状态 _gameModel new GameModel(); // 重新订阅事件 _gameModel.OnGameStateChanged UpdateAllViews; // 3. 重新初始化视图 InitializeViews(); UpdateGameStatusUI(); }这样做比SceneManager.LoadScene更快且能保持所有资源引用有效。6. 项目扩展与优化思路一个基础版本完成后可以考虑以下方向进行扩展这能很好地检验你的架构是否真的灵活。增加角色类型与规则比如加入一个“天使”规则是天使不能和魔鬼单独在岸上。你只需要在GameModel中增加CharacterType.Angel并在IsBankSafe方法中添加对新规则的判断。视图层只需增加天使的预制体GameController的初始化部分稍作修改即可。核心逻辑的修改被控制在最小范围。实现AI自动求解由于游戏状态明确左岸牧师数、魔鬼数、船的位置等这是一个经典的搜索问题如BFS广度优先搜索。你可以写一个独立的AISolver类它接受一个GameModel状态作为输入返回一系列操作步骤。然后在游戏中加入一个“提示”或“自动演示”按钮调用这个求解器并逐步执行操作。这完全得益于数据模型的独立性。美化与音效为角色移动、上船下船、胜利失败添加音效和粒子效果。这些都属于视图层增强在GameController更新视图的相应位置如开始移动协程时、状态改变时触发对应的AudioSource.Play()或ParticleSystem.Play()即可不会污染核心逻辑。数据持久化如果需要保存游戏进度只需要让GameModel实现序列化接口如[System.Serializable]将AllCharacters、GameBoat、CurrentStatus等属性保存下来即可。视图状态不需要保存。7. 常见问题与调试技巧在实现过程中你可能会遇到以下典型问题点击无反应检查Collider确保角色和船的GameObject有Collider组件如果是3D点击用Box Collider等2D UI点击用Graphic Raycaster和EventTrigger。检查脚本引用CharacterView脚本中的LinkedCharacter是否在GameController初始化时被正确赋值_controller引用是否获取到检查图层Layer确保点击的物体所在的图层没有被摄像机Camera的Culling Mask排除。角色移动位置错乱或堆叠调试CalculateBankOffset在计算偏移时打印日志查看计算出的索引和最终位置是否正确。检查模型状态在UpdateCharacterView中打印角色的IsOnBoat和CurrentSide属性确保模型状态与你的操作预期一致。动画协程冲突确保同一时间一个角色只有一个移动协程在运行。可以在CharacterView中加一个bool isMoving标志位。游戏状态判断错误单元测试你的GameModel这是架构分离最大的优势。新建一个C#脚本不要继承MonoBehaviour在Start或Awake里写测试逻辑。例如void TestModel() { var model new GameModel(); // 模拟操作 var firstPriest model.AllCharacters.First(c c.Type CharacterType.Priest); model.TryEmbark(firstPriest); model.MoveBoat(); // 检查状态 Debug.Log($After move, boat side: {model.GameBoat.CurrentSide}, priest side: {firstPriest.CurrentSide}); Debug.Log($Game status: {model.CurrentStatus}); }仔细核对IsBankSafe逻辑这是游戏规则的核心确保条件(priestCount 0 || priestCount devilCount)完全符合题目要求。事件未触发视图不更新确保在GameController的Start或Awake中正确订阅了_gameModel.OnGameStateChanged事件。确保在GameModel的每一个可能改变状态的操作如MoveBoat,TryEmbark,TryDisembark的最后都调用了OnGameStateChanged?.Invoke()。检查是否有其他地方创建了新的GameModel实例导致事件订阅丢失。性能与代码组织避免在Update中频繁查找对象FindObjectOfType、GetComponent这类操作应放在Start或Awake中将结果缓存起来。使用对象池Object Pooling如果你频繁地销毁和实例化角色对象虽然在重启时发生可以考虑实现一个简单的对象池来提升性能。考虑使用ScriptableObject对于游戏配置数据如角色移动速度、船容量、两岸位置等可以创建ScriptableObject资产使调整参数更便捷且与代码分离。通过这个项目你将深刻体会到一个清晰的架构如何让复杂的逻辑变得井然有序。它迫使你思考数据的本质和表现的形式而这正是从实现功能到设计系统的关键一步。下次当你面对一个更复杂的游戏机制时不妨先问自己它的核心数据模型是什么
返回列表