
在上一篇文章中我们已经完成了飞行棋项目的基础框架搭建包括棋盘生成、棋子移动逻辑和基础的玩家回合控制。本篇我们将深入核心实现完整的游戏规则、UI交互、音效动画以及最终的打包发布带你从“能跑”到“好玩”构建一个功能完备、体验流畅的2D飞行棋游戏。本文适合已经具备Unity和C#基础并完成了上篇内容学习的开发者。通过本篇你将掌握游戏核心规则起飞、跳跃、撞击、终点判定的精细化实现。UGUI与游戏逻辑的深度绑定与数据驱动更新。使用Unity Animator和Audio Source为游戏增添动效与音效。对游戏进行最终优化并打包成可执行的PC端应用程序。1. 核心游戏规则的实现与优化在上篇的移动基础上真实的飞行棋规则更为复杂。我们需要为棋子赋予“状态”并完善每一步移动背后的逻辑判断。1.1 棋子状态与规则枚举首先我们定义棋子可能处于的状态和游戏中用到的规则类型这有助于我们写出更清晰、易于维护的代码。// 文件路径Assets/Scripts/Enums/GameEnums.cs namespace FlightChess.Enums { // 棋子状态 public enum PieceState { InHangar, // 在停机坪未起飞 OnTrack, // 在航道上已起飞 Finished // 已到达终点 } // 格子类型在上篇基础上扩展 public enum GridType { Normal, // 普通格 Start, // 起飞点 Jump, // 跳跃格如前进N步 Back, // 后退格 Stop, // 暂停一轮格 Safe, // 安全格不会被撞 FinalTrack // 终点冲刺道格 } // 玩家状态 public enum PlayerTurnState { Waiting, // 等待掷骰子 Moving, // 棋子移动中 Finished // 本回合行动结束 } }1.2 扩展棋盘格子数据类我们需要为每个格子存储更丰富的信息以支持复杂的规则判断。// 文件路径Assets/Scripts/Data/GridData.cs using UnityEngine; using FlightChess.Enums; namespace FlightChess.Data { [System.Serializable] public class GridData { public int GridIndex; // 格子序号0起始 public Vector2 WorldPosition; // 世界坐标 public GridType Type; // 格子类型 public int LinkedGridIndex -1; // 关联格子如跳跃到的目标格-1表示无 // 根据格子类型执行额外效果返回是否需要额外回合等 public GridEffect ExecuteEffect() { GridEffect effect new GridEffect(); switch (Type) { case GridType.Jump: effect.Message $触发跳跃前进到第{LinkedGridIndex1}格; effect.TargetGridIndex LinkedGridIndex; break; case GridType.Back: effect.Message 踩中后退格后退3格; effect.MoveOffset -3; break; case GridType.Stop: effect.Message 踩中暂停格下回合停一次; effect.SkipNextTurn true; break; case GridType.Safe: effect.Message 进入安全区不会被撞击。; effect.IsSafe true; break; default: effect.Message ; break; } return effect; } } // 格子效果类用于封装一次格子交互的结果 public class GridEffect { public string Message; public int TargetGridIndex -1; // 直接跳跃的目标 public int MoveOffset 0; // 额外的移动步数正前进负后退 public bool SkipNextTurn false; public bool IsSafe false; } }1.3 实现完整的移动与规则逻辑现在在GameController中我们重写并扩展移动逻辑使其融入规则判断。// 文件路径Assets/Scripts/Controllers/GameController.cs (部分更新) using System.Collections; using UnityEngine; using UnityEngine.UI; using FlightChess.Data; using FlightChess.Enums; public class GameController : MonoBehaviour { // ... 其他变量声明如players, dice等与上篇相同 ... private PlayerTurnState _currentTurnState PlayerTurnState.Waiting; // 掷骰子后的主逻辑入口 public void OnDiceRolled(int dicePoints) { if (_currentTurnState ! PlayerTurnState.Waiting) return; _currentTurnState PlayerTurnState.Moving; StartCoroutine(ProcessPlayerTurn(dicePoints)); } private IEnumerator ProcessPlayerTurn(int dicePoints) { PlayerData currentPlayer _players[_currentPlayerIndex]; PieceController selectedPiece GetMovablePiece(currentPlayer, dicePoints); if (selectedPiece null) { // 没有棋子可以移动 Debug.Log(${currentPlayer.PlayerName} 没有棋子可以移动回合结束。); UIManager.Instance.ShowMessage(${currentPlayer.PlayerName} 无法移动); EndTurn(); yield break; } // 移动前状态 PieceState initialState selectedPiece.CurrentState; int startGridIndex selectedPiece.CurrentGridIndex; // 核心移动序列 yield return StartCoroutine(MovePieceWithRules(selectedPiece, dicePoints)); // 移动后处理检查是否完成、触发格子效果、检查撞击 yield return StartCoroutine(PostMoveProcessing(selectedPiece, initialState, startGridIndex)); // 回合结束 EndTurn(); } private IEnumerator MovePieceWithRules(PieceController piece, int steps) { for (int i 0; i steps; i) { int nextIndex piece.CurrentGridIndex 1; // 检查是否进入终点冲刺道 if (piece.CurrentGridIndex _boardData.NormalTrackCount - 1) { // 进入终点冲刺道逻辑需根据玩家颜色映射到对应的终点道索引 nextIndex GetFinalTrackIndex(piece.PlayerId, piece.CurrentGridIndex); } if (nextIndex _boardData.AllGrids.Count) { piece.MoveToGrid(_boardData.AllGrids[nextIndex]); yield return new WaitForSeconds(0.3f); // 每步移动间隔 } else { // 超出棋盘通常意味着即将到达终点由PostMoveProcessing处理 break; } } } private IEnumerator PostMoveProcessing(PieceController movedPiece, PieceState initialState, int startGridIndex) { GridData landedGrid _boardData.AllGrids[movedPiece.CurrentGridIndex]; GridEffect effect landedGrid.ExecuteEffect(); if (!string.IsNullOrEmpty(effect.Message)) { UIManager.Instance.ShowMessage(effect.Message); yield return new WaitForSeconds(1f); } // 处理跳跃或额外移动 if (effect.TargetGridIndex ! -1) { movedPiece.JumpToGrid(_boardData.AllGrids[effect.TargetGridIndex]); yield return new WaitForSeconds(0.5f); // 跳跃后需要重新获取所在的格子 landedGrid _boardData.AllGrids[movedPiece.CurrentGridIndex]; } else if (effect.MoveOffset ! 0) { yield return StartCoroutine(MovePieceWithRules(movedPiece, effect.MoveOffset)); // 后退后也需要重新获取格子 landedGrid _boardData.AllGrids[movedPiece.CurrentGridIndex]; } // 检查撞击只有非安全格且不是自己人的棋子才能被撞 if (!effect.IsSafe initialState PieceState.OnTrack) { PieceController pieceToKick GetPieceAtGrid(landedGrid.GridIndex, movedPiece.PlayerId); if (pieceToKick ! null pieceToKick.CurrentState PieceState.OnTrack) { // 将对方棋子撞回停机坪 pieceToKick.ReturnToHangar(); UIManager.Instance.ShowMessage(${movedPiece.PlayerId} 撞飞了 {pieceToKick.PlayerId} 的棋子); yield return new WaitForSeconds(0.7f); } } // 检查是否到达终点 if (movedPiece.CurrentGridIndex _boardData.AllGrids.Count - 1) { movedPiece.SetState(PieceState.Finished); UIManager.Instance.ShowMessage(${movedPiece.PlayerId} 的一颗棋子到达终点); // 检查该玩家是否所有棋子都终点了即获胜 if (CheckPlayerWin(movedPiece.PlayerId)) { GameOver(movedPiece.PlayerId); yield break; } } // 如果触发暂停标记当前玩家 if (effect.SkipNextTurn) { _players[_currentPlayerIndex].SkipNextTurn true; } } private void EndTurn() { // 如果当前玩家下回合被暂停则直接跳过 if (_players[_currentPlayerIndex].SkipNextTurn) { UIManager.Instance.ShowMessage(${_players[_currentPlayerIndex].PlayerName} 被暂停一轮); _players[_currentPlayerIndex].SkipNextTurn false; SwitchToNextPlayer(); } _currentTurnState PlayerTurnState.Waiting; SwitchToNextPlayer(); UIManager.Instance.UpdateCurrentPlayerDisplay(_players[_currentPlayerIndex].PlayerName); } // 辅助方法获取指定格子上非自己的棋子 private PieceController GetPieceAtGrid(int gridIndex, int excludePlayerId) { foreach (var player in _players) { if (player.PlayerId excludePlayerId) continue; foreach (var piece in player.Pieces) { if (piece.CurrentGridIndex gridIndex piece.CurrentState PieceState.OnTrack) { return piece; } } } return null; } // 辅助方法检查玩家是否获胜 private bool CheckPlayerWin(int playerId) { PlayerData player _players.Find(p p.PlayerId playerId); foreach (var piece in player.Pieces) { if (piece.CurrentState ! PieceState.Finished) { return false; } } return true; } private void GameOver(int winnerPlayerId) { Debug.Log($游戏结束玩家 {winnerPlayerId} 获胜); UIManager.Instance.ShowGameOverPanel($玩家 {winnerPlayerId} 获胜); // 可以在这里停止所有输入播放胜利动画等 _currentTurnState PlayerTurnState.Finished; } }2. 游戏UI系统的构建一个友好的UI是游戏体验的关键。我们将创建一个UIManager单例来集中管理所有UI元素。2.1 创建UI Manager与基础UI首先在场景中创建Canvas并布置必要的UI元素当前玩家提示、骰子按钮、骰子点数显示、信息提示框、游戏结束面板。// 文件路径Assets/Scripts/Managers/UIManager.cs using UnityEngine; using UnityEngine.UI; using TMPro; // 使用TextMeshPro以获得更佳视觉效果 public class UIManager : MonoBehaviour { public static UIManager Instance { get; private set; } [Header(UI References)] [SerializeField] private TextMeshProUGUI _currentPlayerText; [SerializeField] private Button _rollDiceButton; [SerializeField] private TextMeshProUGUI _diceResultText; [SerializeField] private GameObject _messagePanel; [SerializeField] private TextMeshProUGUI _messageText; [SerializeField] private GameObject _gameOverPanel; [SerializeField] private TextMeshProUGUI _gameOverText; [Header(Settings)] [SerializeField] private float _messageDisplayTime 2f; private void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } // 初始隐藏面板 _messagePanel.SetActive(false); _gameOverPanel.SetActive(false); } private void Start() { // 绑定骰子按钮事件 if (_rollDiceButton ! null) { _rollDiceButton.onClick.AddListener(OnRollDiceButtonClicked); } else { Debug.LogError(Roll Dice Button is not assigned in UIManager!); } } // 更新当前玩家显示 public void UpdateCurrentPlayerDisplay(string playerName) { if (_currentPlayerText ! null) _currentPlayerText.text $当前回合: {playerName}; } // 更新骰子结果显示 public void UpdateDiceResult(int result) { if (_diceResultText ! null) _diceResultText.text result.ToString(); } // 显示临时信息如“触发跳跃” public void ShowMessage(string msg) { if (_messagePanel null || _messageText null) return; _messageText.text msg; _messagePanel.SetActive(true); CancelInvoke(nameof(HideMessage)); // 取消之前的隐藏调用 Invoke(nameof(HideMessage), _messageDisplayTime); } private void HideMessage() { if (_messagePanel ! null) _messagePanel.SetActive(false); } // 显示游戏结束面板 public void ShowGameOverPanel(string winnerInfo) { if (_gameOverPanel null || _gameOverText null) return; _gameOverText.text winnerInfo; _gameOverPanel.SetActive(true); // 游戏结束时禁用骰子按钮 if (_rollDiceButton ! null) _rollDiceButton.interactable false; } // 设置骰子按钮交互状态 public void SetDiceButtonInteractable(bool interactable) { if (_rollDiceButton ! null) _rollDiceButton.interactable interactable; } // 骰子按钮点击事件 private void OnRollDiceButtonClicked() { // 通知GameController掷骰子 GameController.Instance?.PlayerRollDice(); // 点击后暂时禁用按钮防止连点 SetDiceButtonInteractable(false); } // 提供给GameController在回合开始时重新启用按钮 public void EnableDiceButtonForTurn() { SetDiceButtonInteractable(true); } }注意需要在GameController中当回合切换至等待掷骰状态时调用UIManager.Instance.EnableDiceButtonForTurn();。2.2 棋子选择UI当有多个棋子可以移动时例如掷出6点可以起飞新棋子也可以移动场上棋子需要让玩家选择移动哪一个。我们创建一个简单的选择面板。// 文件路径Assets/Scripts/UI/PieceSelectionPanel.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; public class PieceSelectionPanel : MonoBehaviour { public static PieceSelectionPanel Instance { get; private set; } [SerializeField] private GameObject _panel; [SerializeField] private TextMeshProUGUI _titleText; [SerializeField] private Transform _buttonContainer; [SerializeField] private GameObject _pieceButtonPrefab; private System.Actionint _onPieceSelectedCallback; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); } else { Instance this; } _panel.SetActive(false); } public void ShowSelection(string title, Liststring pieceOptions, System.Actionint callback) { _titleText.text title; _onPieceSelectedCallback callback; // 清除旧按钮 foreach (Transform child in _buttonContainer) { Destroy(child.gameObject); } // 创建新按钮 for (int i 0; i pieceOptions.Count; i) { int index i; // 闭包捕获 GameObject buttonObj Instantiate(_pieceButtonPrefab, _buttonContainer); Button button buttonObj.GetComponentButton(); TextMeshProUGUI buttonText buttonObj.GetComponentInChildrenTextMeshProUGUI(); if (buttonText ! null) buttonText.text pieceOptions[i]; button.onClick.AddListener(() OnPieceSelected(index)); } _panel.SetActive(true); } private void OnPieceSelected(int pieceIndex) { _onPieceSelectedCallback?.Invoke(pieceIndex); Hide(); } public void Hide() { _panel.SetActive(false); } }在GameController的GetMovablePiece方法中如果找到多个可移动棋子则调用此选择面板。3. 动画与音效的集成视听反馈能极大提升游戏质感。我们将为骰子滚动、棋子移动、撞击等添加动画和音效。3.1 骰子动画为骰子创建一个简单的旋转动画并在掷出后显示点数。创建骰子动画控制器在Animator中创建两个状态Idle和Roll。Roll状态连接一个旋转动画片段。编写骰子动画控制脚本// 文件路径Assets/Scripts/Effects/DiceAnimator.cs using UnityEngine; public class DiceAnimator : MonoBehaviour { private Animator _animator; private System.Actionint _onRollComplete; private void Awake() { _animator GetComponentAnimator(); } public void RollDice(System.Actionint onComplete) { _onRollComplete onComplete; _animator.SetTrigger(Roll); // 动画事件或协程在动画结束后调用OnRollAnimationFinished } // 由动画事件调用 public void OnRollAnimationFinished() { int randomPoints Random.Range(1, 7); // 生成1-6的点数 _onRollComplete?.Invoke(randomPoints); } }在GameController中集成修改掷骰逻辑先播放动画再处理结果。3.2 棋子移动动画使用DoTween或LeanTween这类插件可以轻松实现平滑移动。这里以代码控制为例// 在PieceController中添加 using UnityEngine; using System.Collections; public class PieceController : MonoBehaviour { // ... 其他变量和属性 ... public void MoveToGridSmooth(GridData targetGrid, float duration 0.5f) { StartCoroutine(MoveCoroutine(targetGrid.WorldPosition, duration)); } private IEnumerator MoveCoroutine(Vector3 targetPos, float duration) { Vector3 startPos transform.position; float elapsed 0f; while (elapsed duration) { transform.position Vector3.Lerp(startPos, targetPos, elapsed / duration); elapsed Time.deltaTime; yield return null; } transform.position targetPos; // 移动完成可以触发事件 OnMoveCompleted?.Invoke(); } public void JumpToGrid(GridData targetGrid) { // 跳跃可以是一个缩放移动的协程 StartCoroutine(JumpCoroutine(targetGrid.WorldPosition)); } private IEnumerator JumpCoroutine(Vector3 targetPos) { Vector3 startPos transform.position; float jumpHeight 1.5f; float duration 0.6f; float elapsed 0f; while (elapsed duration) { float t elapsed / duration; // 抛物线运动 Vector3 currentPos Vector3.Lerp(startPos, targetPos, t); currentPos.y Mathf.Sin(t * Mathf.PI) * jumpHeight; transform.position currentPos; elapsed Time.deltaTime; yield return null; } transform.position targetPos; } }3.3 音效管理创建一个简单的音效管理器统一播放游戏内的各种声音。// 文件路径Assets/Scripts/Managers/AudioManager.cs using UnityEngine; public class AudioManager : MonoBehaviour { public static AudioManager Instance { get; private set; } [System.Serializable] public class SoundEffect { public string name; public AudioClip clip; [Range(0f, 1f)] public float volume 1f; } [SerializeField] private SoundEffect[] _soundEffects; [SerializeField] private AudioSource _sfxSource; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); } else { Instance this; DontDestroyOnLoad(gameObject); // 跨场景不销毁 } } public void PlaySFX(string soundName) { SoundEffect sfx System.Array.Find(_soundEffects, s s.name soundName); if (sfx ! null _sfxSource ! null) { _sfxSource.PlayOneShot(sfx.clip, sfx.volume); } else { Debug.LogWarning($Sound effect {soundName} not found or AudioSource not set.); } } }在需要播放音效的地方调用例如GameController.OnDiceRolled开始时AudioManager.Instance.PlaySFX(DiceRoll);棋子被撞时AudioManager.Instance.PlaySFX(Hit);到达终点时AudioManager.Instance.PlaySFX(Finish);4. 游戏数据持久化与设置为了让游戏体验更完整我们可以加入简单的数据保存如音效开关和游戏设置。4.1 玩家偏好设置使用PlayerPrefs存储简单的设置。// 文件路径Assets/Scripts/Managers/SettingsManager.cs using UnityEngine; using UnityEngine.UI; public class SettingsManager : MonoBehaviour { public static SettingsManager Instance { get; private set; } [Header(UI Toggles)] [SerializeField] private Toggle _musicToggle; [SerializeField] private Toggle _sfxToggle; private const string MUSIC_KEY MusicEnabled; private const string SFX_KEY SFXEnabled; public bool IsMusicEnabled { get; private set; } true; public bool IsSFXEnabled { get; private set; } true; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); } else { Instance this; DontDestroyOnLoad(gameObject); LoadSettings(); } } private void Start() { if (_musicToggle ! null) { _musicToggle.isOn IsMusicEnabled; _musicToggle.onValueChanged.AddListener(SetMusicEnabled); } if (_sfxToggle ! null) { _sfxToggle.isOn IsSFXEnabled; _sfxToggle.onValueChanged.AddListener(SetSFXEnabled); } } private void LoadSettings() { IsMusicEnabled PlayerPrefs.GetInt(MUSIC_KEY, 1) 1; IsSFXEnabled PlayerPrefs.GetInt(SFX_KEY, 1) 1; } public void SetMusicEnabled(bool enabled) { IsMusicEnabled enabled; PlayerPrefs.SetInt(MUSIC_KEY, enabled ? 1 : 0); PlayerPrefs.Save(); // 通知背景音乐管理器 BackgroundMusic.Instance?.SetMute(!enabled); } public void SetSFXEnabled(bool enabled) { IsSFXEnabled enabled; PlayerPrefs.SetInt(SFX_KEY, enabled ? 1 : 0); PlayerPrefs.Save(); // AudioManager可以根据这个状态决定是否播放音效 AudioManager.Instance?.GetComponentAudioSource().mute !enabled; } }4.2 游戏状态保存进阶对于更复杂的进度保存可以定义一个GameSaveData类并使用JsonUtility或Newtonsoft.Json序列化后存储。// 文件路径Assets/Scripts/Data/GameSaveData.cs using System.Collections.Generic; [System.Serializable] public class GameSaveData { public int CurrentPlayerIndex; public ListPlayerSaveData Players; public int[] DiceHistory; // 可选记录历史 // ... 其他需要保存的状态 } [System.Serializable] public class PlayerSaveData { public int PlayerId; public string PlayerName; public ListPieceSaveData Pieces; public bool SkipNextTurn; } [System.Serializable] public class PieceSaveData { public int PieceId; public int CurrentGridIndex; public int State; // 对应PieceState枚举的int值 }保存和加载的方法可以放在GameController中。5. 游戏优化与发布准备在打包前进行一些优化以确保游戏运行流畅。5.1 性能优化建议对象池管理棋子频繁实例化/销毁棋子可能产生GC。可以初始化所有棋子通过激活/失活来控制显示。减少不必要的Update确保只有需要每帧更新的对象如摄像机、UI动画才有Update方法。逻辑更新尽量使用事件驱动。合并材质与图集将棋子和棋盘的Sprite打包成图集减少Draw Call。使用Addressable或Resources管理资源对于大型项目使用资源管理系统能更好地控制内存。5.2 构建设置与发布场景管理确保File - Build Settings中的“Scenes In Build”包含了你的游戏主场景。图标与名称在Player Settings中设置公司名、产品名和图标。分辨率与窗口在Player Settings - Resolution and Presentation中设置默认窗口模式、分辨率等。构建PC端选择目标平台为Windows, Mac Linux Standalone。在右侧选择目标操作系统如Windows。点击Build选择输出文件夹Unity将生成可执行文件及相关数据文件。5.3 常见构建问题排查问题现象可能原因解决思路构建后UI不显示或错位Canvas缩放模式或锚点设置不当分辨率不匹配检查Canvas的Canvas Scaler组件设置为Scale With Screen Size并设定参考分辨率。检查UI元素的锚点是否与父对象对齐。构建后脚本丢失或报错脚本编译错误脚本被意外删除或移动在构建前确保Console窗口没有任何错误。检查项目中的脚本文件是否都在正确位置。构建文件体积过大包含了未使用的资源纹理压缩格式不当在Build Settings中点击Player Settings - Publishing Settings启用Strip Engine Code。检查纹理导入设置使用合适的压缩格式如ASTC, ETC2。运行时卡顿或崩溃内存泄漏无限循环协程复杂运算在Update中使用Profiler (Window - Analysis - Profiler) 分析性能瓶颈。检查协程中的循环条件。将繁重计算移到帧外或使用Job System。6. 项目扩展思路与进阶学习完成基础飞行棋后你可以尝试以下方向进行扩展深化你的Unity技能AI对手实现不同难度的电脑AI。简单AI可以随机移动中级AI可以评估“撞击对手”或“进入安全区”的收益高级AI可以使用博弈树进行有限深度的搜索。网络对战使用Unity Netcode或Photon PUN等网络插件实现本地或在线多人对战。这涉及到网络状态同步、权威服务器逻辑等复杂概念。更丰富的道具与事件系统设计“遥控骰子”、“护盾”、“转向”等道具卡。创建一个事件总线Event Bus来解耦道具触发、UI更新和逻辑处理。数据统计与成就系统记录玩家的获胜次数、最大连胜、单场掷出6的次数等并据此解锁成就。这需要设计一个更健壮的数据管理层。移植到移动端调整UI布局以适应触摸屏优化性能以适应移动设备并考虑添加陀螺仪摇骰子等趣味操作。通过这个完整的飞行棋项目你不仅实践了Unity 2D游戏开发的核心流程更触及了状态管理、事件驱动、UI绑定、动画音效集成和基础优化等工程化概念。建议你将代码整理好上传到GitHub作为你学习路上的一个扎实的作品集项目。接下来你可以尝试用同样的思路去开发其他类型的棋盘游戏或2D游戏不断巩固和扩展你的开发能力。如果在实现过程中遇到任何问题欢迎在评论区交流讨论。