在实际游戏开发中,很多开发者都是从 Unity 的 3D 项目开始学习,但 2D 游戏因其独特的艺术风格和相对简单的物理逻辑,往往是独立开发者和小团队的首选。飞行棋作为一款经典的棋盘类游戏,其规则清晰、逻辑明确,非常适合作为 2D 游戏开发的入门实战项目。通过这个项目,你不仅能掌握 Unity 2D 的核心模块,如 Sprite 管理、UI 系统、动画状态机和简单的游戏逻辑编程,还能理解一个完整游戏项目从场景搭建、角色控制到规则实现的完整流程。本文将以 C# 为主要脚本语言,带你从零开始,一步步构建一个可运行、可对战的 2D 飞行棋游戏。我们将聚焦于游戏的核心玩法实现,包括棋盘生成、棋子移动、回合制逻辑和胜负判定,为你后续添加更复杂的特效、音效或网络功能打下坚实基础。
1. 理解 2D 飞行棋的核心机制与 Unity 工程准备
在开始写代码之前,必须理清飞行棋的游戏规则,并将其转化为可编程的逻辑模块。这能避免在开发中期陷入逻辑混乱。
1.1 飞行棋规则拆解与数据建模
飞行棋通常包含 2-4 名玩家,每位玩家有 4 枚棋子。棋盘是一个闭合的路径,包含起点、终点、普通格子、安全格(如“机场”)、跳跃格(如“飞跃”)和特殊事件格。棋子从基地出发,根据骰子点数沿固定路径移动,所有棋子到达终点后该玩家获胜。
在 Unity 中,我们需要为这些概念建立数据模型:
- 棋盘 (Board):一个
GameObject,是所有格子的容器。其核心是一个List<Grid>或数组,按行走顺序存储所有格子信息。 - 格子 (Grid):一个
ScriptableObject或MonoBehaviour组件。它需要记录格子的类型(普通、起点、终点、安全区、跳跃、惩罚等)、位置、以及当前格子上停留的棋子列表。 - 棋子 (Piece):一个
MonoBehaviour组件,挂载在代表棋子的 Sprite 上。它需要知道自己的所属玩家、当前所在的格子索引、是否在基地、是否已到达终点等状态。 - 玩家 (Player):一个管理类。它持有该玩家的所有棋子引用、颜色信息、当前回合状态等。
- 游戏管理器 (GameManager):单例模式的核心控制器。负责管理游戏状态(如当前玩家、骰子点数、回合阶段)、处理输入、驱动游戏流程和判定胜负。
1.2 Unity 项目初始化与 2D 环境设置
启动 Unity Hub,创建一个新的 2D 项目,命名为“FlightChess2D”。确保项目模板选择“2D”。
创建清晰的项目文件夹结构是良好工程习惯的开始:
Assets/ ├── Scripts/ │ ├── Managers/ // 存放 GameManager, UIManager 等 │ ├── Gameplay/ // 存放 Player, Piece, Grid, Dice 等核心逻辑 │ └── Utilities/ // 存放单例、扩展方法等工具类 ├── Sprites/ // 存放所有图片资源,如棋盘、棋子、骰子面 ├── Prefabs/ // 存放预制体,如棋子、格子 ├── Scenes/ // 存放游戏场景 └── Settings/ // 存放 ScriptableObject 资产,如游戏配置进入Edit -> Project Settings -> Editor,将Version Control模式设置为Visible Meta Files,将Asset Serialization模式设置为Force Text。这有利于版本管理(如 Git)和合并。
2. 构建游戏世界:棋盘与角色的可视化实现
游戏的可视化部分是玩家最直接的感受。我们将先搭建静态场景,再注入动态逻辑。
2.1 设计并导入棋盘与棋子 Sprite
你需要准备或绘制一些基本的 2D 精灵图(Sprite)。至少需要:
- 棋盘背景图:一张完整的棋盘布局图,或者由多个格子图块拼接而成。
- 棋子 Sprite:四种不同颜色的圆形或飞机形状图片,代表不同玩家。
- 格子指示器:用于高亮显示可移动目标或当前选中棋子的简单图形。
- 骰子 Sprite:6 张图片,分别显示 1 到 6 点。
将图片文件拖入Assets/Sprites文件夹。在 Inspector 窗口中,确保它们的Texture Type设置为Sprite (2D and UI),并根据像素风格调整Filter Mode(通常为Point (no filter))和Pixels Per Unit(例如 100)。
2.2 创建棋盘网格逻辑与预制体
我们不直接使用一张完整的棋盘图片作为逻辑依据,而是创建逻辑格子来管理游戏状态。
首先,创建Grid脚本,它主要是一个数据容器:
// Scripts/Gameplay/Grid.cs using UnityEngine; using System.Collections.Generic; public class Grid : MonoBehaviour { public enum GridType { Normal, Start, Safe, Jump, Penalty, Final } public GridType gridType = GridType.Normal; public int gridIndex; // 在棋盘路径上的顺序 public List<Piece> piecesOnGrid = new List<Piece>(); // 当前格上的棋子 // 可视化反馈,例如当棋子移动到此格时高亮 private SpriteRenderer spriteRenderer; private Color originalColor; void Awake() { spriteRenderer = GetComponent<SpriteRenderer>(); if(spriteRenderer != null) originalColor = spriteRenderer.color; } public void Highlight(bool isOn) { if(spriteRenderer != null) spriteRenderer.color = isOn ? Color.yellow : originalColor; } }接下来,在场景中创建空物体Board。然后,根据你的棋盘布局,在Board下创建一系列空物体作为格子,并重命名为Grid_0,Grid_1... 为每个格子物体挂载Grid脚本,并手动设置其gridIndex和gridType。这一步虽然繁琐,但建立了游戏最基础的逻辑地图。
完成后,将Grid物体拖入Assets/Prefabs文件夹生成预制体,方便复用。然后,创建一个BoardManager脚本来管理所有这些格子。
// Scripts/Managers/BoardManager.cs using UnityEngine; using System.Collections.Generic; public class BoardManager : MonoBehaviour { public static BoardManager Instance; // 简单单例,便于访问 public List<Grid> allGrids = new List<Grid>(); // 按顺序存储所有格子 void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); // 初始化时,可以手动将格子拖入这个列表,或者用代码查找子物体 // 这里假设格子都是 Board 的直接子物体,并且已按顺序排好 InitializeGrids(); } void InitializeGrids() { allGrids.Clear(); foreach (Transform child in transform) { Grid grid = child.GetComponent<Grid>(); if (grid != null) { allGrids.Add(grid); // 确保 gridIndex 与列表索引一致(可选,但建议) grid.gridIndex = allGrids.Count - 1; } } Debug.Log($"棋盘初始化完成,共有 {allGrids.Count} 个格子。"); } // 根据索引获取格子 public Grid GetGridByIndex(int index) { if (index >= 0 && index < allGrids.Count) return allGrids[index]; return null; } }2.3 制作棋子与玩家逻辑
创建Piece脚本,并将其挂载到棋子 Sprite 上。
// Scripts/Gameplay/Piece.cs using UnityEngine; public class Piece : MonoBehaviour { public Player owner; // 所属玩家 public int currentGridIndex = -1; // -1 表示在基地,-2 表示已到达终点 public bool isInBase = true; public bool isFinished = false; private Grid currentGrid; // 当前所在的逻辑格引用 // 移动到目标格子 public void MoveToGrid(int targetGridIndex) { if (targetGridIndex < 0) return; // 处理基地和终点的逻辑 Grid targetGrid = BoardManager.Instance.GetGridByIndex(targetGridIndex); if (targetGrid == null) return; // 离开当前格子 if (currentGrid != null) { currentGrid.piecesOnGrid.Remove(this); } // 更新状态 currentGridIndex = targetGridIndex; currentGrid = targetGrid; currentGrid.piecesOnGrid.Add(this); isInBase = false; // 更新视觉位置(可以加入动画) transform.position = targetGrid.transform.position; // 处理格子特殊事件(如跳跃、暂停) HandleGridSpecialEffect(targetGrid); } void HandleGridSpecialEffect(Grid grid) { switch (grid.gridType) { case Grid.GridType.Jump: // 例如,跳到前方第5格 int jumpTarget = currentGridIndex + 5; MoveToGrid(jumpTarget); break; case Grid.GridType.Penalty: // 暂停一回合,由 GameManager 处理 break; // ... 其他类型 } } }创建Player类,它不一定是MonoBehaviour,而是一个纯数据和管理类。
// Scripts/Gameplay/Player.cs using System.Collections.Generic; using UnityEngine; [System.Serializable] public class Player { public int playerId; public Color playerColor; public List<Piece> pieces = new List<Piece>(4); // 每位玩家4枚棋子 public bool isActive = false; // 检查是否所有棋子都已到达终点 public bool HasAllPiecesFinished() { foreach (var piece in pieces) { if (!piece.isFinished) return false; } return true; } // 获取还在基地或可移动的棋子 public List<Piece> GetMovablePieces(int diceValue) { List<Piece> movablePieces = new List<Piece>(); foreach (var piece in pieces) { if (piece.isFinished) continue; // 简单规则:在基地且掷出6点,或不在基地,即可移动 if ((piece.isInBase && diceValue == 6) || !piece.isInBase) { movablePieces.Add(piece); } } return movablePieces; } }3. 驱动游戏进程:回合制逻辑与用户交互
游戏的核心循环是“掷骰子 -> 选棋子 -> 移动棋子”。我们需要一个强大的GameManager来协调这一切。
3.1 实现游戏管理器与状态机
GameManager将作为游戏的大脑,管理回合状态。
// Scripts/Managers/GameManager.cs using System.Collections.Generic; using UnityEngine; public class GameManager : MonoBehaviour { public static GameManager Instance; public List<Player> players = new List<Player>(); public int currentPlayerIndex = 0; public int currentDiceValue = 0; private enum GameState { WaitForRoll, WaitForPieceSelection, ProcessingMove } private GameState currentState = GameState.WaitForRoll; public UIManager uiManager; // 需要在 Inspector 中关联 void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); InitializePlayers(); } void InitializePlayers() { // 示例:初始化2名玩家 for (int i = 0; i < 2; i++) { Player p = new Player(); p.playerId = i; p.playerColor = (i == 0) ? Color.red : Color.blue; // 这里需要关联已经实例化在场景中的棋子 Prefab // 通常通过 FindObjectsOfType<Piece>() 然后根据 owner 或位置来分配 players.Add(p); } StartPlayerTurn(0); // 玩家0开始 } // 开始一个玩家的回合 public void StartPlayerTurn(int playerIndex) { currentPlayerIndex = playerIndex; players[currentPlayerIndex].isActive = true; currentState = GameState.WaitForRoll; uiManager.UpdateStatusText($"玩家 {currentPlayerIndex+1} 的回合,请掷骰子。"); uiManager.SetDiceButtonInteractable(true); } // 由UI按钮调用 public void OnRollDiceButtonClicked() { if (currentState != GameState.WaitForRoll) return; currentDiceValue = Random.Range(1, 7); // 生成1-6随机数 uiManager.ShowDiceFace(currentDiceValue); uiManager.SetDiceButtonInteractable(false); // 检查当前玩家是否有可移动的棋子 List<Piece> movablePieces = players[currentPlayerIndex].GetMovablePieces(currentDiceValue); if (movablePieces.Count == 0) { uiManager.UpdateStatusText($"玩家 {currentPlayerIndex+1} 没有可移动的棋子,回合结束。"); Invoke(nameof(EndCurrentTurn), 1.5f); // 延迟1.5秒后结束回合 } else { currentState = GameState.WaitForPieceSelection; uiManager.UpdateStatusText($"掷出 {currentDiceValue} 点,请选择要移动的棋子。"); HighlightMovablePieces(movablePieces); // 高亮可移动的棋子 } } void HighlightMovablePieces(List<Piece> pieces) { // 清除所有高亮 foreach (var player in players) foreach (var piece in player.pieces) piece.GetComponent<SpriteRenderer>().color = piece.owner.playerColor; // 高亮可移动棋子 foreach (var piece in pieces) { piece.GetComponent<SpriteRenderer>().color = Color.green; } } // 由棋子被点击时调用 public void OnPieceSelected(Piece selectedPiece) { if (currentState != GameState.WaitForPieceSelection) return; if (selectedPiece.owner != players[currentPlayerIndex]) return; List<Piece> movablePieces = players[currentPlayerIndex].GetMovablePieces(currentDiceValue); if (!movablePieces.Contains(selectedPiece)) return; // 开始移动 currentState = GameState.ProcessingMove; MovePiece(selectedPiece, currentDiceValue); } void MovePiece(Piece piece, int steps) { int targetIndex = piece.currentGridIndex + steps; // 这里需要处理棋盘边界、终点逻辑等 if (targetIndex >= BoardManager.Instance.allGrids.Count) { targetIndex = BoardManager.Instance.allGrids.Count - 1; // 简单处理,实际应处理终点逻辑 } piece.MoveToGrid(targetIndex); // 检查是否到达终点 // ... 省略终点判定逻辑 // 移动完成后,结束回合 Invoke(nameof(EndCurrentTurn), 0.5f); } void EndCurrentTurn() { players[currentPlayerIndex].isActive = false; ClearAllHighlights(); // 检查胜负 if (players[currentPlayerIndex].HasAllPiecesFinished()) { uiManager.ShowGameOverPanel($"玩家 {currentPlayerIndex+1} 获胜!"); return; } // 切换到下一个玩家 int nextPlayerIndex = (currentPlayerIndex + 1) % players.Count; StartPlayerTurn(nextPlayerIndex); } void ClearAllHighlights() { // 清除棋子和格子的高亮 // ... 实现略 } }3.2 创建用户界面与骰子系统
UI 是玩家交互的窗口。我们创建一个简单的 UI 管理器。
- 在场景中创建 Canvas。
- 在 Canvas 下创建:
Button(掷骰子按钮)Text(状态显示文本)Image(用于显示骰子点数的图片)- 另一个
Text或Panel(游戏结束提示)
创建UIManager脚本:
// Scripts/Managers/UIManager.cs using UnityEngine; using UnityEngine.UI; public class UIManager : MonoBehaviour { public Button rollDiceButton; public Text statusText; public Image diceImage; public Sprite[] diceFaces; // 在 Inspector 中按顺序拖入6个骰子面Sprite public GameObject gameOverPanel; public Text gameOverText; void Start() { rollDiceButton.onClick.AddListener(() => GameManager.Instance.OnRollDiceButtonClicked()); gameOverPanel.SetActive(false); } public void UpdateStatusText(string message) { statusText.text = message; } public void ShowDiceFace(int value) { if (value >= 1 && value <= 6 && diceFaces[value-1] != null) { diceImage.sprite = diceFaces[value-1]; } } public void SetDiceButtonInteractable(bool interactable) { rollDiceButton.interactable = interactable; } public void ShowGameOverPanel(string message) { gameOverPanel.SetActive(true); gameOverText.text = message; } }别忘了为棋子添加点击事件。在Piece脚本的Start方法中,可以添加碰撞器并处理点击(对于 2D,使用Collider2D)。
// 在 Piece.cs 中添加 private void OnMouseDown() { if (GameManager.Instance != null) { GameManager.Instance.OnPieceSelected(this); } }确保棋子的 GameObject 有Collider2D(如Box Collider 2D)组件。
4. 运行测试、问题排查与优化方向
完成以上步骤后,将GameManager、BoardManager、UIManager脚本分别挂载到场景中空的 GameObject 上,并在 Inspector 面板中完成必要的关联(如将 UI 元素拖到UIManager的对应字段,将骰子面 Sprite 数组赋值)。运行游戏,点击掷骰子按钮,然后点击高亮的棋子,观察棋子是否按点数移动。
4.1 常见问题与排查清单
在开发过程中,你可能会遇到以下典型问题:
| 问题现象 | 可能原因 | 检查与解决方案 |
|---|---|---|
| 点击掷骰子按钮无反应 | 1. 按钮事件未绑定。 2. GameManager.Instance为空。3. 当前游戏状态不是 WaitForRoll。 | 1. 检查UIManager的Start方法中是否添加了监听。2. 检查 GameManager脚本是否挂载,Awake 方法是否执行。3. 在 OnRollDiceButtonClicked开始处添加Debug.Log打印当前状态。 |
| 棋子无法被点击选中 | 1. 棋子没有Collider2D。2. 棋子上层有 UI 元素遮挡。 3. OnMouseDown方法未被调用。 | 1. 为棋子 GameObject 添加Box Collider 2D。2. 检查 Canvas 的渲染模式,或调整棋子与 UI 的 Sorting Layer。 3. 确认 Piece脚本已挂载,且GameManager.Instance不为空。 |
| 棋子移动后位置不对 | 1.BoardManager.allGrids顺序与棋盘逻辑顺序不一致。2. MoveToGrid中计算目标索引的逻辑错误。3. 格子预制体的位置未对齐。 | 1. 在BoardManager.InitializeGrids后打印所有格子的索引和位置进行核对。2. 单步调试 MovePiece方法,检查targetIndex计算。3. 在场景视图中仔细检查每个 Grid物体的 Transform 位置。 |
| 游戏状态逻辑混乱(如连续掷骰) | GameState状态机转换有遗漏或条件判断不严。 | 在每个改变状态的地方(如OnRollDiceButtonClicked,OnPieceSelected,EndCurrentTurn)打印日志,梳理状态流转路径。确保每个操作前都检查了currentState。 |
| 玩家棋子列表为空,导致空引用 | InitializePlayers方法中没有正确找到并分配棋子给玩家。 | 在InitializePlayers中编写代码查找场景中所有Piece,并根据其初始位置或预设的 Tag 分配给对应玩家。或者使用预制体在代码中实例化并分配。 |
4.2 核心优化与扩展实践
当前实现是一个最小可运行版本。要使其更完整、健壮,需要考虑以下方面:
- 棋子移动动画:不要直接修改
transform.position。使用Vector3.Lerp或DOTween插件实现平滑移动,移动完成后才更新逻辑状态。 - 完整的棋盘规则:实现真正的终点区域逻辑(棋子必须精确掷出点数进入终点)、迭子(撞击)规则(将对手棋子送回基地)、以及更多特殊格子(如暂停一回合、后退等)。
- 输入与反馈优化:
- 为可移动棋子的高亮添加更明显的效果(如缩放、发光)。
- 在棋子移动路径上显示预览。
- 添加音效(掷骰子、移动、撞击、胜利)。
- 数据与配置分离:将棋盘布局、玩家数量、颜色等配置数据抽离到
ScriptableObject(如GameConfig)中,使设计人员无需修改代码即可调整游戏参数。 - 代码结构优化:
- 使用事件(
Action或UnityEvent)来解耦模块。例如,GameManager抛出“回合开始”、“骰子掷出”、“游戏结束”等事件,UIManager和音效管理器监听这些事件并作出反应。 - 将棋子的移动规则(如如何处理跳跃格)从
Piece脚本中抽离,放到BoardManager或专门的RuleManager中,使逻辑更清晰。
- 使用事件(
- 异常处理与日志:在关键函数开始和结束处添加日志输出,便于调试。对可能出现的数组越界、空引用进行防御性检查。
通过这个项目,你不仅学会了如何用 Unity 和 C# 构建一个 2D 棋盘游戏的核心循环,更重要的是掌握了将游戏规则转化为代码逻辑、管理复杂游戏状态以及组织项目结构的基本方法。接下来,你可以尝试为游戏添加 AI 对手、联网对战功能,或者用 Tilemap 来构建更复杂的棋盘,这些都是在现有框架上自然的延伸。