在实际游戏开发中,从零开始构建一个完整的项目是检验技术理解和工程能力的最佳方式。飞行棋作为一款经典的棋盘游戏,规则清晰、交互明确,非常适合作为 Unity 2D 开发的入门实战项目。上半部分我们可能已经搭建了基础场景、绘制了棋盘、实现了棋子的基本移动。下半部分,我们将聚焦于游戏的核心逻辑闭环:从玩家轮流掷骰子、棋子行走与碰撞判定、特殊格子效果(如跳跃、暂停、返回起点),到游戏胜负判定、UI交互反馈以及最终的打包发布。本文将手把手带你完成这些关键模块的开发,让你不仅掌握 Unity 2D 的组件使用,更能理解一个完整游戏项目的代码组织与逻辑架构。
本文适合已经了解 Unity 界面基础操作和 C# 基本语法的开发者。通过本文,你将能独立完成一个具备可玩性的飞行棋游戏,并掌握处理游戏状态机、规则逻辑、动画与音效集成等核心技能。
1. 核心游戏逻辑与状态管理
一个可运行的飞行棋游戏,其核心是清晰的状态流转。我们需要管理当前是哪个玩家的回合、游戏是否开始或结束、骰子是否已投掷、棋子是否在移动中等一系列状态。
1.1 设计游戏管理器 (GameManager)
游戏管理器应作为单例存在,负责协调所有子系统。我们首先定义游戏的核心状态。
// GameManager.cs using UnityEngine; using System.Collections.Generic; public class GameManager : MonoBehaviour { public static GameManager Instance; // 单例实例 public enum GameState { WaitingToStart, PlayerTurn, RollingDice, MovingPiece, ProcessingTileEffect, GameOver } public GameState CurrentState { get; private set; } public enum Player { Red, Blue, Green, Yellow } public Player CurrentPlayer { get; private set; } public int CurrentDiceValue { get; private set; } public List<Player> playerOrder; // 玩家顺序 private int currentPlayerIndex = 0; // 棋子预制件和起点位置 public GameObject piecePrefab; public Transform[] startPositions; // 对应红、蓝、绿、黄玩家的起点 void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); // 跨场景保持 } else { Destroy(gameObject); } InitializeGame(); } void InitializeGame() { CurrentState = GameState.WaitingToStart; playerOrder = new List<Player> { Player.Red, Player.Blue, Player.Green, Player.Yellow }; CurrentPlayer = playerOrder[0]; // 初始化棋子等... } }这个GameManager定义了游戏的基本状态枚举和当前玩家。Awake方法确保它作为单例存在,InitializeGame方法用于重置游戏数据。
1.2 实现掷骰子与回合切换
掷骰子是一个关键交互,它触发状态从PlayerTurn切换到RollingDice,再根据结果切换到MovingPiece。
// GameManager.cs (续) public void RollDice() { if (CurrentState != GameState.PlayerTurn) { Debug.LogWarning("现在不是投掷骰子的时机!"); return; } CurrentState = GameState.RollingDice; // 模拟骰子动画,这里用随机数代替 int diceResult = Random.Range(1, 7); // 生成1-6的随机数 CurrentDiceValue = diceResult; Debug.Log($"{CurrentPlayer} 玩家掷出了 {diceResult} 点"); // 假设骰子动画结束后调用此方法 OnDiceRollFinished(); } private void OnDiceRollFinished() { CurrentState = GameState.MovingPiece; // 通知当前玩家的棋子开始移动 // 移动完成后,会触发 ProcessTurnEnd } public void ProcessTurnEnd() { // 检查当前玩家是否所有棋子都已到达终点,判断游戏是否结束 if (CheckPlayerWin(CurrentPlayer)) { CurrentState = GameState.GameOver; Debug.Log($"游戏结束!获胜者是:{CurrentPlayer}"); // 触发UI显示 return; } // 切换到下一个玩家 currentPlayerIndex = (currentPlayerIndex + 1) % playerOrder.Count; CurrentPlayer = playerOrder[currentPlayerIndex]; CurrentState = GameState.PlayerTurn; Debug.Log($"轮到 {CurrentPlayer} 玩家"); }RollDice方法包含了状态检查和随机数生成。ProcessTurnEnd方法处理回合结束的逻辑,包括胜负判定和玩家切换,这是游戏循环的驱动核心。
2. 棋子移动与棋盘格子系统
棋子需要沿着预设的路径移动。我们需要一个棋盘数据管理器来定义路径,并让棋子能够根据骰子点数前进。
2.1 创建棋盘路径与格子数据
在场景中,我们可以使用空物体(如Waypoint)来标记路径点,并按顺序组织。
// BoardManager.cs using UnityEngine; using System.Collections.Generic; public class BoardManager : MonoBehaviour { public static BoardManager Instance; // 存储所有路径点的位置,顺序即为行走顺序 public List<Vector3> waypoints = new List<Vector3>(); // 特殊格子字典:格子索引 -> 特殊效果类型 public Dictionary<int, SpecialTileEffect> specialTiles = new Dictionary<int, SpecialTileEffect>(); public enum SpecialTileEffect { None, JumpForward, JumpBackward, StopOneTurn, BackToStart } void Awake() { if (Instance == null) Instance = this; // 初始化路径点:可以手动在Inspector中赋值,或通过子物体自动获取 InitializeWaypoints(); InitializeSpecialTiles(); } void InitializeWaypoints() { // 示例:手动赋值或在Start中通过查找“Waypoint”标签的子物体获取位置 waypoints.Clear(); foreach (Transform child in transform) { if (child.CompareTag("Waypoint")) { waypoints.Add(child.position); } } Debug.Log($"棋盘路径初始化完成,共有 {waypoints.Count} 个路径点。"); } void InitializeSpecialTiles() { // 预设一些特殊格子,例如:第5格跳跃到第10格,第12格暂停一回合 specialTiles.Clear(); specialTiles.Add(5, SpecialTileEffect.JumpForward); specialTiles.Add(12, SpecialTileEffect.StopOneTurn); specialTiles.Add(18, SpecialTileEffect.BackToStart); // ... 可根据棋盘设计添加更多 } // 根据当前格子索引和骰子点数,计算目标格子索引,并处理特殊效果 public int GetTargetTileIndex(int currentIndex, int diceValue, out SpecialTileEffect effect) { int targetIndex = currentIndex + diceValue; effect = SpecialTileEffect.None; // 如果目标索引超出路径范围,说明已经到达或超过终点,棋子不再移动(或触发胜利逻辑) if (targetIndex >= waypoints.Count) { // 简单处理:停留在最后一个格子 targetIndex = waypoints.Count - 1; return targetIndex; } // 检查目标格子是否为特殊格子 if (specialTiles.TryGetValue(targetIndex, out SpecialTileEffect tileEffect)) { effect = tileEffect; // 根据效果再次调整目标索引 switch (tileEffect) { case SpecialTileEffect.JumpForward: targetIndex += 3; // 向前跳3格 break; case SpecialTileEffect.JumpBackward: targetIndex -= 2; // 向后跳2格 break; case SpecialTileEffect.BackToStart: targetIndex = 0; // 回到起点 break; // StopOneTurn 不需要改变索引,但需要影响玩家状态 } } return targetIndex; } public Vector3 GetWaypointPosition(int index) { if (index >= 0 && index < waypoints.Count) return waypoints[index]; return Vector3.zero; } }BoardManager管理了棋盘的物理路径和逻辑规则。GetTargetTileIndex方法是核心,它根据骰子点数和当前格子,计算出考虑特殊效果后的最终目标格子。
2.2 实现棋子移动组件
每个棋子都需要知道自己的当前位置(格子索引)和归属玩家,并能够平滑移动到目标点。
// PieceController.cs using UnityEngine; using System.Collections; public class PieceController : MonoBehaviour { public GameManager.Player owner; // 这个棋子属于哪个玩家 public int currentTileIndex = 0; // 当前所在的格子索引(起点为0) public float moveSpeed = 5.0f; private bool isMoving = false; // 由GameManager调用,传入骰子点数 public void MoveSteps(int steps) { if (isMoving) return; StartCoroutine(MoveAlongPath(steps)); } IEnumerator MoveAlongPath(int steps) { isMoving = true; int startIndex = currentTileIndex; BoardManager.SpecialTileEffect finalEffect; // 获取最终的目标格子索引和可能触发的特殊效果 int targetIndex = BoardManager.Instance.GetTargetTileIndex(startIndex, steps, out finalEffect); // 一格一格地移动,产生动画效果 for (int i = startIndex + 1; i <= targetIndex; i++) { Vector3 targetPos = BoardManager.Instance.GetWaypointPosition(i); while (Vector3.Distance(transform.position, targetPos) > 0.01f) { transform.position = Vector3.MoveTowards(transform.position, targetPos, moveSpeed * Time.deltaTime); yield return null; } // 每到达一格,可以触发音效或粒子效果 yield return new WaitForSeconds(0.1f); // 短暂停顿,让移动更清晰 } // 更新棋子逻辑位置 currentTileIndex = targetIndex; isMoving = false; // 移动结束,处理特殊效果 ProcessTileEffect(finalEffect); // 通知GameManager,这个棋子的移动动作完成了 // 这里需要判断是否所有可移动棋子都已完成动作,简化处理:单个棋子移动完即回合结束 GameManager.Instance.ProcessTurnEnd(); } void ProcessTileEffect(BoardManager.SpecialTileEffect effect) { switch (effect) { case BoardManager.SpecialTileEffect.StopOneTurn: Debug.Log($"{owner}玩家的棋子在特殊格子上,下一回合暂停!"); // 可以为玩家添加一个“暂停”状态标记 break; case BoardManager.SpecialTileEffect.BackToStart: Debug.Log($"{owner}玩家的棋子被送回起点!"); StartCoroutine(MoveBackToStart()); break; // 其他效果... default: break; } } IEnumerator MoveBackToStart() { // 快速移动回起点 Vector3 startPos = BoardManager.Instance.GetWaypointPosition(0); while (Vector3.Distance(transform.position, startPos) > 0.01f) { transform.position = Vector3.MoveTowards(transform.position, startPos, moveSpeed * 2 * Time.deltaTime); yield return null; } currentTileIndex = 0; } }PieceController使用协程MoveAlongPath来实现棋子的平滑移动动画。它每帧向下一路径点移动,直到到达最终格子。移动结束后,会处理格子效果并通知游戏管理器回合结束。
3. 用户界面 (UI) 与交互反馈
游戏需要UI来显示状态、提供操作按钮并反馈信息。我们将使用 Unity 的 UGUI 系统。
3.1 创建基础游戏UI
创建一个 Canvas,并添加以下基本UI元素:
- 状态文本 (TextMeshPro - StatusText):显示当前玩家和游戏状态。
- 骰子按钮 (Button):点击触发掷骰子。
- 骰子点数显示 (TextMeshPro - DiceValueText):显示掷出的点数。
- 获胜提示面板 (Panel):游戏结束时显示获胜者。
为UI创建控制器脚本:
// UIManager.cs using UnityEngine; using TMPro; using UnityEngine.UI; public class UIManager : MonoBehaviour { public static UIManager Instance; public TextMeshProUGUI statusText; public TextMeshProUGUI diceValueText; public Button rollDiceButton; public GameObject winPanel; public TextMeshProUGUI winText; void Awake() { if (Instance == null) Instance = this; } void Start() { // 绑定按钮事件 rollDiceButton.onClick.AddListener(OnRollDiceButtonClicked); winPanel.SetActive(false); UpdateUI(); } void OnRollDiceButtonClicked() { GameManager.Instance.RollDice(); // 按钮点击后暂时禁用,等待移动结束 rollDiceButton.interactable = false; } // 此方法由GameManager在状态变化时调用 public void UpdateUI() { var gm = GameManager.Instance; statusText.text = $"当前玩家: {gm.CurrentPlayer}\n状态: {gm.CurrentState}"; if (gm.CurrentState == GameManager.GameState.PlayerTurn) { rollDiceButton.interactable = true; diceValueText.text = "准备掷骰子"; } else if (gm.CurrentState == GameManager.GameState.RollingDice) { rollDiceButton.interactable = false; diceValueText.text = "骰子转动中..."; } else if (gm.CurrentState == GameManager.GameState.MovingPiece) { rollDiceButton.interactable = false; diceValueText.text = $"移动中... 点数: {gm.CurrentDiceValue}"; } else if (gm.CurrentState == GameManager.GameState.GameOver) { rollDiceButton.interactable = false; diceValueText.text = "游戏结束"; } } public void ShowWinPanel(GameManager.Player winner) { winPanel.SetActive(true); winText.text = $"恭喜!{winner} 玩家获胜!"; } }UI管理器负责根据游戏状态更新界面元素。按钮点击事件直接调用GameManager的公共方法。UpdateUI方法需要被集成到游戏状态变化的各个节点进行调用。
3.2 集成UI更新到游戏流程
修改GameManager,在关键状态变化处通知UI更新。
// GameManager.cs (补充) void Start() { UIManager.Instance.UpdateUI(); } private void OnDiceRollFinished() { CurrentState = GameState.MovingPiece; UIManager.Instance.UpdateUI(); // 找到当前玩家的一个棋子并移动(简化逻辑:只移动第一个棋子) MoveCurrentPlayerPiece(); } private void MoveCurrentPlayerPiece() { // 这里需要根据你的棋子管理方式,找到当前玩家的一个可移动棋子 // 假设我们有一个列表存储了所有棋子 // PieceController targetPiece = ...; // targetPiece.MoveSteps(CurrentDiceValue); } public void ProcessTurnEnd() { if (CheckPlayerWin(CurrentPlayer)) { CurrentState = GameState.GameOver; UIManager.Instance.ShowWinPanel(CurrentPlayer); UIManager.Instance.UpdateUI(); return; } currentPlayerIndex = (currentPlayerIndex + 1) % playerOrder.Count; CurrentPlayer = playerOrder[currentPlayerIndex]; CurrentState = GameState.PlayerTurn; UIManager.Instance.UpdateUI(); }这样,游戏状态、UI显示和玩家操作就联动起来了。
4. 动画、音效与游戏优化
基础逻辑完成后,加入视听反馈能极大提升游戏体验。
4.1 添加骰子滚动动画
我们可以创建一个简单的骰子模型或UI图片,通过快速切换面来模拟滚动。
// DiceAnimator.cs using UnityEngine; using UnityEngine.UI; using System.Collections; public class DiceAnimator : MonoBehaviour { public Sprite[] diceFaces; // 6个骰子面的Sprite,索引0-5对应点数1-6 public Image diceImageUI; // 用于显示骰子的UI Image组件 public float rollDuration = 1.0f; // 滚动持续时间 public IEnumerator RollDiceAnimation(int finalValue) { float elapsedTime = 0f; float interval = 0.1f; // 切换面的间隔 while (elapsedTime < rollDuration) { // 在最终结果确定前,随机显示一个面 int randomFace = Random.Range(0, 6); diceImageUI.sprite = diceFaces[randomFace]; yield return new WaitForSeconds(interval); elapsedTime += interval; } // 动画结束,显示最终点数对应的面 diceImageUI.sprite = diceFaces[finalValue - 1]; // 点数1对应索引0 } }在GameManager.RollDice方法中,启动这个动画协程,并在动画结束后调用OnDiceRollFinished。
4.2 添加音效
在 Unity 中创建 AudioSource 组件,并为不同事件(掷骰子、移动、到达特殊格子、获胜)分配 AudioClip。
// SoundManager.cs using UnityEngine; public class SoundManager : MonoBehaviour { public static SoundManager Instance; public AudioSource audioSource; public AudioClip diceRollSound; public AudioClip pieceMoveSound; public AudioClip specialTileSound; public AudioClip winSound; void Awake() { if (Instance == null) Instance = this; } public void PlayDiceRoll() => PlaySound(diceRollSound); public void PlayPieceMove() => PlaySound(pieceMoveSound); public void PlaySpecialTile() => PlaySound(specialTileSound); public void PlayWin() => PlaySound(winSound); private void PlaySound(AudioClip clip) { if (clip != null && audioSource != null) { audioSource.PlayOneShot(clip); } } }在GameManager和PieceController的相应位置调用SoundManager.Instance.PlayXXX()方法。
4.3 性能与代码优化建议
- 对象池管理棋子:频繁实例化/销毁棋子会产生GC。可以在游戏初始化时为每个玩家创建好棋子(如4个),并放入对象池,仅通过激活/失活来控制显示。
- 使用 ScriptableObject 管理游戏数据:将棋盘路径、特殊格子位置、玩家颜色等配置数据抽离到 ScriptableObject 中,便于设计和平衡调整,无需修改代码。
- 状态机明确化:当前
GameState枚举可以进一步细化,并使用专门的状态机模式(如状态模式或 Unity Animator 状态机)来管理,使逻辑更清晰。 - 输入控制:除了UI按钮,可以考虑增加键盘快捷键(如空格键掷骰子),并做好输入状态判断,防止在动画播放时重复触发。
5. 常见问题排查与调试
在开发过程中,你可能会遇到以下典型问题。
5.1 棋子移动位置不正确
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 棋子不移动或瞬移 | waypoints列表为空或顺序错误 | 在BoardManager的Awake或Start中打印waypoints.Count,并在Scene视图查看Waypoint物体的顺序。 | 确保所有路径点都正确标记了“Waypoint”标签,并且作为BoardManager游戏对象的子物体,顺序即行走顺序。 |
| 棋子移动终点偏移 | 路径点位置是局部坐标而非世界坐标 | 检查waypoints中存储的是transform.position(世界坐标)还是transform.localPosition。 | 在InitializeWaypoints中,应使用child.position(世界坐标)或根据你的棋盘设计决定。 |
| 移动动画卡顿 | 协程MoveAlongPath中的移动逻辑或等待时间有问题 | 检查moveSpeed值是否过小,或WaitForSeconds的间隔是否过长。 | 调整moveSpeed(如10),减少中间等待时间。确保在while循环中使用Time.deltaTime乘以速度。 |
5.2 游戏状态流转混乱
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 玩家可以连续掷骰子 | RollDice方法的状态检查未生效,或UI按钮未及时禁用 | 在RollDice开始处添加Debug.Log打印当前状态。检查UIManager.UpdateUI中按钮交互状态的逻辑。 | 确保CurrentState在MovingPiece和ProcessingTileEffect等状态时,RollDice方法会提前返回。在状态切换后立即调用UIManager.Instance.UpdateUI()。 |
| 回合结束后未切换玩家 | ProcessTurnEnd方法未被调用,或玩家索引计算错误 | 在PieceController.MoveAlongPath协程末尾的ProcessTurnEnd调用处打断点或打印日志。 | 确保棋子移动协程结束后能正确调用GameManager.Instance.ProcessTurnEnd()。检查currentPlayerIndex的自增和取模逻辑。 |
5.3 UI显示不更新
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 状态文本始终不变 | UIManager.UpdateUI()未被调用 | 在UpdateUI方法开始处添加Debug.Log。 | 在GameManager中所有改变CurrentState或CurrentPlayer的地方,都调用一次UIManager.Instance.UpdateUI()。 |
| 获胜面板不显示 | winPanel未在Inspector中赋值,或SetActive条件不满足 | 检查UIManager脚本组件上winPanel变量是否关联。检查ShowWinPanel方法是否被调用。 | 在Unity编辑器中拖拽赋值。在GameManager.ProcessTurnEnd的胜利分支中,调用UIManager.Instance.ShowWinPanel(winner)。 |
5.4 音效或动画不播放
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 掷骰子没有声音 | AudioClip未赋值或AudioSource未配置 | 检查SoundManager的Inspector面板,确认diceRollSound等字段已拖入音频文件。确认audioSource组件所在物体未被禁用。 | 为SoundManager所在的游戏对象添加AudioSource组件,并将该组件拖拽到audioSource字段。在播放声音前,可以添加Debug.Log确认方法被触发。 |
| 骰子动画不显示最终点数 | 动画协程提前结束,或最终点数索引错误 | 检查RollDiceAnimation协程中的finalValue参数是否正确(1-6)。检查diceFaces数组长度是否为6,且顺序对应点数1-6。 | 确保finalValue - 1作为数组索引是有效的。在协程结束后,可以手动设置一次diceImageUI.sprite以确保无误。 |
6. 项目构建与发布准备
完成所有功能开发与测试后,最后一步是构建可执行文件。
- 场景管理:确保所有游戏对象(棋盘、棋子、UI Canvas、管理器等)都放在一个或多个场景中。通常主游戏场景包含全部内容。
- 构建设置:点击菜单栏
File -> Build Settings。- 将当前主场景拖入
Scenes In Build列表。 - 选择目标平台(如 PC, Mac & Linux Standalone)。
- 点击
Player Settings...,可以设置公司名、产品名、图标、分辨率等。
- 将当前主场景拖入
- 分辨率与UI适配:在
Player Settings的Resolution and Presentation中,可以设置默认窗口大小。确保你的 Canvas 的Canvas Scaler组件设置正确(例如Scale With Screen Size),以适应不同分辨率。 - 构建与运行:在 Build Settings 窗口点击
Build,选择输出文件夹,Unity 会生成可执行文件和相关数据文件。运行该可执行文件进行最终测试。
通过以上步骤,你已经完成了一个具备核心玩法的 Unity 2D 飞行棋游戏。这个项目涵盖了状态管理、物理移动、UI交互、动画音效等基础模块。要将其打磨得更完善,可以考虑加入更多功能,如多个棋子选择规则、网络对战、存档系统、更丰富的视觉特效等。最重要的是,通过这个项目理解如何将复杂的游戏规则分解为可管理的代码模块,这是独立游戏开发的关键能力。