Unity 2D飞行棋实战:从零构建完整游戏开发流程
最近在带新人做 Unity 2D 项目时发现很多朋友对完整的游戏开发流程缺乏概念从场景搭建到核心逻辑实现再到UI交互每一步都可能遇到意想不到的“坑”。本文将以经典的飞行棋游戏为蓝本手把手带你从零开始构建一个功能完整的 2D 游戏项目。无论你是刚接触 Unity 的新手还是想系统梳理 2D 开发流程的开发者都能通过这个实战案例掌握从项目初始化、资源管理、核心玩法编程到 UI 集成的全链路技能。我们将使用 Unity 2022 LTS 版本和 C# 进行开发确保代码清晰、结构规范你可以直接复用到自己的项目中。1. 项目背景与核心概念解析在开始敲代码之前我们有必要明确这个项目要做什么以及 Unity 2D 开发中的一些基础概念。这能帮助我们建立正确的认知避免在后续开发中走弯路。1.1 飞行棋游戏规则简述飞行棋是一款适合2-4名玩家的棋盘游戏。每个玩家拥有4架不同颜色的飞机棋子从各自的基地出发沿固定路径绕棋盘一周后最终将所有飞机准确驶入终点区域者获胜。核心规则包括掷骰子玩家轮流掷骰子通常为1-6点根据点数移动棋子。起飞规则只有掷出6点或5点规则可自定义时才能将一架飞机从基地移送到起飞点。跳跃与迭子棋子走到与自己颜色相同的格子时可以跳跃到下一个同色格。如果棋子走到已有敌方棋子的格子可将敌方棋子“撞回”其基地。终点冲刺进入终点通道后棋子必须按掷出的点数精确移动至终点否则需等待下次机会。我们将用程序逻辑来模拟这些规则这是本项目的核心挑战。1.2 Unity 2D 开发核心组件Unity 本质上是一个 3D 引擎但其提供了完整的 2D 工作流。理解以下几个核心组件是进行 2D 开发的基础Sprite精灵2D 图形的基石。一张图片如 PNG导入 Unity 后将其Texture Type设置为Sprite (2D and UI)它就变成了一个 Sprite。我们的棋盘、棋子、骰子面都将以 Sprite 形式存在。Sprite Renderer精灵渲染器附加在 GameObject 上用于在场景中显示 Sprite。你可以通过它控制 Sprite 的颜色、排序层级等。Sorting Layer排序图层 Order in Layer图层内顺序2D 场景没有 Z 轴深度渲染先后由这两个属性决定。Sorting Layer像文件夹Order in Layer是文件夹内的文件顺序数值大的渲染在上层。我们需要用这个来控制棋盘、格子、棋子的显示层次。Orthographic Camera正交摄像机2D 游戏必须使用正交摄像机它不会产生近大远小的透视效果确保 Sprite 无论远近都保持同样大小。UI Canvas画布用于显示所有用户界面元素如按钮、文本、面板。Unity 的 UGUI 系统功能强大我们将用它来制作开始界面、玩家信息面板和操作按钮。理解这些概念后我们就可以开始搭建开发环境了。2. 环境准备与项目初始化一个清晰、规范的项目结构是良好开发的开端。本节将指导你完成 Unity 的安装、项目创建以及基础文件夹结构的搭建。2.1 软件与版本说明Unity Hub Unity Editor建议使用长期支持LTS版本以获得最佳稳定性。本文基于Unity 2022.3 LTS进行演示。请通过 Unity 官网下载 Unity Hub并通过 Hub 安装指定版本的 Editor。开发模块在安装时确保勾选“Windows Build Support”或对应你平台的支持和“WebGL Build Support”可选。对于 2D 开发默认安装的模块已足够。代码编辑器Unity 默认与Visual StudioWindows或Visual Studio for Mac集成。你也可以选择JetBrains Rider或VS Code需安装 C# 插件本文代码示例不依赖特定 IDE。2.2 创建新项目与初始设置打开 Unity Hub点击“新建项目”。选择“2D”核心模板命名为“FlightChess2D”并选择一个合适的存储位置。点击“创建项目”。Unity 会自动生成一个包含基础 2D 场景的项目。项目创建后我们首先来规划文件夹结构。在 Project 窗口的Assets文件夹下右键创建以下文件夹这能让资源管理井井有条Assets/ ├── _Scripts/ # 存放所有C#脚本 │ ├── Core/ # 核心游戏逻辑如GameManager, Dice │ ├── Player/ # 玩家与棋子逻辑 │ ├── UI/ # UI控制脚本 │ └── Utility/ # 工具类、扩展方法 ├── Art/ # 美术资源 │ ├── Sprites/ # 精灵图片棋盘、棋子、骰子、UI元素 │ └── Prefabs/ # 预制体保存配置好的GameObject ├── Scenes/ # 游戏场景 ├── Prefabs/ # 也可放在Art下预制体 ├── Animations/ # 动画控制器和动画片段 └── Settings/ # 可脚本化对象、配置资产创建好文件夹后将默认的SampleScene保存到Scenes文件夹中并重命名为MainGame。2.3 导入素材与初始场景配置由于我们主要关注编程逻辑可以使用简单的占位图。你可以在绘图软件中制作或从免费资源网站获取棋盘一张完整的棋盘俯视图或者由格子拼成的棋盘。棋子四种颜色红、黄、蓝、绿的圆形或飞机形状小图。骰子六张图片分别显示1到6个点。将下载或制作的 PNG 图片拖入Assets/Art/Sprites文件夹。在 Inspector 窗口中确保每一张图片的Texture Type已设置为Sprite (2D and UI)并根据需要设置Pixels Per Unit例如 100表示游戏内100像素对应1个单位。打开MainGame场景进行初始配置将主摄像机Main Camera的Projection属性从Perspective改为Orthographic。调整Size属性直到能在 Game 视图中舒适地看到整个场景例如 Size 设为 5。创建一个空的 GameObject重命名为GameManager它将作为游戏的总控制器。再创建一个空的 GameObject重命名为Board作为所有棋盘格子的父物体。创建一个 UI Canvas重命名为UICanvas。在其下创建 UI Panel重命名为GameUI用于放置游戏内的操作UI。至此我们的开发环境与项目骨架已经搭建完毕。3. 核心系统设计与数据建模在动手制作场景和编写复杂交互之前必须先进行“数据建模”和“系统设计”。这是将游戏规则转化为代码结构的关键一步能极大减少后续的返工。3.1 棋盘格子数据模型棋盘是游戏的核心。我们需要一个数据结构来代表每一个格子。在Assets/_Scripts/Core/文件夹下创建一个 C# 脚本GridPoint.cs。这个脚本不继承MonoBehaviour是一个纯数据类。// 文件路径Assets/_Scripts/Core/GridPoint.cs using UnityEngine; namespace FlightChess2D.Core { // 格子类型枚举 public enum GridType { Normal, // 普通路径格 Start, // 起飞点每种颜色一个 ColorJump, // 同色跳跃格 Safe, // 安全格不能被撞 FinalPath // 终点通道格 } [System.Serializable] // 使其可在Inspector中显示和编辑 public class GridPoint { public int index; // 格子在全局路径中的序号 public Vector2 position; // 格子在场景中的坐标 public GridType type; // 格子类型 public PlayerColor color; // 如果格子有特殊颜色如起飞点、同色格则关联玩家颜色 public int jumpToIndex -1;// 如果是跳跃格跳转到的目标格子索引-1表示无跳转 // 构造函数 public GridPoint(int idx, Vector2 pos, GridType t, PlayerColor c PlayerColor.None, int jumpIdx -1) { index idx; position pos; type t; color c; jumpToIndex jumpIdx; } } }同时我们定义玩家颜色枚举在Assets/_Scripts/Core/下创建Enums.cs或直接放在同一个文件// 文件路径Assets/_Scripts/Core/Enums.cs namespace FlightChess2D.Core { public enum PlayerColor { None 0, Red, Yellow, Blue, Green } public enum GameState { Menu, PlayerTurnStart, RollingDice, Moving, EnemyTurn, GameOver } }这个GridPoint类记录了每个格子的所有信息。我们将用一个ListGridPoint来定义整条飞行棋路径。3.2 玩家与棋子数据模型接下来定义玩家和棋子的数据。创建PlayerData.cs和PieceData.cs。// 文件路径Assets/_Scripts/Player/PlayerData.cs using System.Collections.Generic; using UnityEngine; namespace FlightChess2D.Player { [System.Serializable] public class PlayerData { public PlayerColor color; // 玩家颜色 public string playerName; // 玩家名称 public bool isAI; // 是否为AI玩家 public int startGridIndex; // 该颜色玩家的起飞点在全局路径中的索引 public ListPieceData pieces; // 该玩家拥有的4个棋子 public bool hasPlaneInBase true; // 基地中是否有未起飞的飞机简化逻辑实际需判断4个 public PlayerData(PlayerColor col, string name, int startIdx) { color col; playerName name; startGridIndex startIdx; pieces new ListPieceData(); // 初始化4个棋子初始位置为“基地”用-1表示 for (int i 0; i 4; i) { pieces.Add(new PieceData(i, col)); } hasPlaneInBase true; } // 检查是否有棋子可起飞即掷出6点后 public bool HasPieceInBase() { foreach (var piece in pieces) { if (piece.currentGridIndex -1) // -1 代表在基地 return true; } return false; } // 获取一个在基地的棋子索引 public int GetPieceIndexInBase() { for (int i 0; i pieces.Count; i) { if (pieces[i].currentGridIndex -1) return i; } return -1; // 没有在基地的棋子 } } }// 文件路径Assets/_Scripts/Player/PieceData.cs namespace FlightChess2D.Player { [System.Serializable] public class PieceData { public int pieceId; // 棋子ID (0-3) public PlayerColor ownerColor; // 属于哪个玩家 public int currentGridIndex; // 当前所在格子索引-1表示在基地-2表示已到达终点 public bool isFinished; // 是否已完成旅程 public PieceData(int id, PlayerColor owner) { pieceId id; ownerColor owner; currentGridIndex -1; // 初始在基地 isFinished false; } } }数据模型建立后游戏的核心状态就可以用这些类的实例来描述了。接下来我们将创建游戏的总控制器来管理这些数据。4. 游戏管理器GameManager实现GameManager是游戏的大脑它负责管理游戏状态、回合流程、规则判断和数据存储。我们将其设计为一个单例模式方便全局访问。4.1 创建 GameManager 单例在Assets/_Scripts/Core/下创建GameManager.cs并挂载到场景中的GameManager空物体上。// 文件路径Assets/_Scripts/Core/GameManager.cs using System.Collections.Generic; using UnityEngine; using FlightChess2D.Player; namespace FlightChess2D.Core { public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [Header(Game Settings)] public int numberOfPlayers 4; public ListPlayerColor playerColors new ListPlayerColor { PlayerColor.Red, PlayerColor.Yellow, PlayerColor.Blue, PlayerColor.Green }; [Header(Game State)] public GameState currentGameState GameState.Menu; public int currentPlayerIndex 0; // 当前行动玩家的索引 public int diceResult 0; [Header(Game Data)] public ListGridPoint boardGrids new ListGridPoint(); // 棋盘路径 public ListPlayerData players new ListPlayerData(); // 所有玩家 // 棋盘配置每种颜色的起飞点在路径中的索引需要根据实际棋盘设计填写 private DictionaryPlayerColor, int startGridMap new DictionaryPlayerColor, int() { {PlayerColor.Red, 0}, {PlayerColor.Yellow, 10}, {PlayerColor.Blue, 20}, {PlayerColor.Green, 30} }; private void Awake() { // 实现简单的单例模式 if (Instance null) { Instance this; DontDestroyOnLoad(gameObject); // 跨场景不销毁 } else { Destroy(gameObject); } } private void Start() { InitializeGameData(); // 游戏开始进入菜单或直接开始 // TransitionToState(GameState.Menu); } // 初始化游戏数据 void InitializeGameData() { players.Clear(); // 创建玩家 for (int i 0; i numberOfPlayers; i) { PlayerColor color playerColors[i]; int startIdx startGridMap.ContainsKey(color) ? startGridMap[color] : 0; players.Add(new PlayerData(color, $Player {color}, startIdx)); } // 初始化棋盘路径 (这里需要根据实际棋盘设计填充boardGrids) // 这是一个示例你需要根据你的棋盘Sprite来设置每个格子的位置和类型 // InitializeBoardGrids(); } // 状态转换函数 public void TransitionToState(GameState newState) { currentGameState newState; OnStateEnter(newState); } private void OnStateEnter(GameState state) { switch (state) { case GameState.Menu: // 显示主菜单UI break; case GameState.PlayerTurnStart: Debug.Log($Player {players[currentPlayerIndex].playerName}s turn.); // 激活掷骰子按钮等UI break; case GameState.RollingDice: // 播放掷骰子动画然后得到结果 RollDice(); break; case GameState.Moving: // 处理移动逻辑 ProcessMove(); break; case GameState.EnemyTurn: // 切换到下一个玩家 NextPlayer(); break; case GameState.GameOver: // 显示游戏结束UI break; } } // 掷骰子逻辑 void RollDice() { diceResult Random.Range(1, 7); // 生成1-6的随机数 Debug.Log($Dice Result: {diceResult}); // 触发UI更新显示骰子点数 // UIManager.Instance.UpdateDiceResult(diceResult); // 根据掷骰结果判断下一步 PlayerData currentPlayer players[currentPlayerIndex]; if (diceResult 6 currentPlayer.HasPieceInBase()) { // 掷出6点且有飞机在基地允许起飞 Debug.Log(You got a 6! You can launch a plane from base.); // 这里应触发UI让玩家选择起飞的飞机如果有多个在基地 } else if (diceResult 6) { // 掷出6点但基地无飞机奖励再掷一次根据规则 Debug.Log(You got a 6! Roll again.); TransitionToState(GameState.RollingDice); return; } // 进入移动处理状态 TransitionToState(GameState.Moving); } // 处理移动逻辑 void ProcessMove() { // 这里需要实现根据diceResult和当前玩家状态计算棋子移动后的位置。 // 包括处理起飞、正常移动、跳跃、撞子、到达终点等。 // 这是一个复杂的核心函数我们将在下一节详细实现。 // 移动处理完毕后切换到敌方回合 TransitionToState(GameState.EnemyTurn); } // 切换到下一个玩家 void NextPlayer() { currentPlayerIndex (currentPlayerIndex 1) % players.Count; TransitionToState(GameState.PlayerTurnStart); } // 根据格子索引获取格子数据 public GridPoint GetGridPoint(int index) { if (index 0 index boardGrids.Count) return boardGrids[index]; return null; } // 检查是否有棋子在某格子上用于撞子逻辑 public PieceData GetPieceOnGrid(int gridIndex, PlayerColor excludeColor) { foreach (var player in players) { if (player.color excludeColor) continue; // 排除自己的棋子 foreach (var piece in player.pieces) { if (piece.currentGridIndex gridIndex !piece.isFinished) return piece; } } return null; } } }这个GameManager搭建了游戏的基本框架和流程。它通过GameState枚举来控制游戏的不同阶段如菜单、玩家回合、掷骰、移动等。RollDice方法模拟了掷骰行为而ProcessMove是游戏规则的核心我们将在下一部分重点实现。4.2 初始化棋盘路径数据棋盘路径boardGrids的初始化是关键且繁琐的一步。你需要根据你的棋盘设计图确定每个格子的位置、类型和连接关系。这里提供一个简化的示例函数展示如何手动填充一条小型路径// 在 GameManager.cs 中添加此方法并在 InitializeGameData 中调用 void InitializeBoardGrids() { boardGrids.Clear(); // 假设一条简单的方形路径共40格类似大富翁 int totalGrids 40; float gridSize 1.0f; // 格子间距 for (int i 0; i totalGrids; i) { Vector2 pos Vector2.zero; GridType type GridType.Normal; PlayerColor color PlayerColor.None; int jumpIdx -1; // 计算位置简单矩形 if (i 10) // 上边 pos new Vector2(i * gridSize, 4); else if (i 20) // 右边 pos new Vector2(9 * gridSize, (19 - i) * gridSize); else if (i 30) // 下边 pos new Vector2((29 - i) * gridSize, 0); else // 左边 pos new Vector2(0, (i - 30) * gridSize); // 设置特殊格子示例 if (i 0) { type GridType.Start; color PlayerColor.Red; } else if (i 10) { type GridType.Start; color PlayerColor.Yellow; } else if (i 20) { type GridType.Start; color PlayerColor.Blue; } else if (i 30) { type GridType.Start; color PlayerColor.Green; } else if (i 5 || i 15 || i 25 || i 35) { type GridType.ColorJump; jumpIdx i 5; } // 跳跃格 boardGrids.Add(new GridPoint(i, pos, type, color, jumpIdx)); } // 还需要添加终点通道的格子每种颜色4格 // 这部分逻辑更复杂需要单独设计终点通道的路径索引 }在实际项目中你可能会使用一个编辑器工具或通过读取配置表如 JSON、ScriptableObject来初始化棋盘而不是硬编码在脚本里。为了教程清晰我们先用硬编码方式理解原理。5. 棋盘与棋子的视觉呈现数据逻辑准备好后我们需要在场景中将其可视化。这包括生成棋盘格子和生成棋子。5.1 动态生成棋盘格子我们将创建一个脚本根据GameManager.Instance.boardGrids中的数据在场景中实例化代表每个格子的 GameObject。在Assets/_Scripts/Core/下创建BoardGenerator.cs并挂载到Board物体上。// 文件路径Assets/_Scripts/Core/BoardGenerator.cs using System.Collections.Generic; using UnityEngine; namespace FlightChess2D.Core { public class BoardGenerator : MonoBehaviour { [Header(References)] public GameObject gridPrefab; // 格子预制体一个带有SpriteRenderer的空物体 [Header(Settings)] public Color normalGridColor Color.white; public Color startGridColor Color.gray; public Color jumpGridColor Color.cyan; public Color safeGridColor Color.green; private Dictionaryint, GameObject gridVisualMap new Dictionaryint, GameObject(); void Start() { GenerateBoard(); } public void GenerateBoard() { // 清除旧格子 foreach (Transform child in transform) { Destroy(child.gameObject); } gridVisualMap.Clear(); if (GameManager.Instance null || GameManager.Instance.boardGrids.Count 0) { Debug.LogError(GameManager or board grids not initialized!); return; } foreach (GridPoint grid in GameManager.Instance.boardGrids) { GameObject gridGo Instantiate(gridPrefab, transform); gridGo.name $Grid_{grid.index}; gridGo.transform.position grid.position; SpriteRenderer sr gridGo.GetComponentSpriteRenderer(); if (sr ! null) { // 根据格子类型设置颜色 switch (grid.type) { case GridType.Normal: sr.color normalGridColor; break; case GridType.Start: sr.color startGridColor; break; case GridType.ColorJump: sr.color jumpGridColor; break; case GridType.Safe: sr.color safeGridColor; break; case GridType.FinalPath: sr.color Color.yellow; break; } } // 可以添加一个TextMesh Pro组件显示格子编号便于调试 // ... gridVisualMap[grid.index] gridGo; } Debug.Log($Board generated with {gridVisualMap.Count} grids.); } // 根据格子索引获取视觉物体 public GameObject GetGridVisual(int index) { if (gridVisualMap.ContainsKey(index)) return gridVisualMap[index]; return null; } } }你需要先创建一个gridPrefab在场景中创建一个空的 GameObject添加SpriteRenderer组件并赋予一个简单的 Sprite如一个正方形。然后将其拖入Assets/Art/Prefabs文件夹生成预制体。最后将这个预制体拖到BoardGenerator脚本的gridPrefab字段上。5.2 创建与管理棋子棋子的管理类似。我们创建PieceManager.cs来负责棋子的视觉生成和更新。在Assets/_Scripts/Player/下创建PieceManager.cs可以挂载在GameManager或一个独立的空物体上。// 文件路径Assets/_Scripts/Player/PieceManager.cs using System.Collections.Generic; using UnityEngine; using FlightChess2D.Core; namespace FlightChess2D.Player { public class PieceManager : MonoBehaviour { [System.Serializable] public class PieceVisual { public PlayerColor color; public GameObject piecePrefab; // 对应颜色的棋子预制体 } public ListPieceVisual piecePrefabList new ListPieceVisual(); private DictionaryPlayerColor, GameObject piecePrefabMap new DictionaryPlayerColor, GameObject(); private Dictionaryint, GameObject pieceInstanceMap new Dictionaryint, GameObject(); // Key: 唯一ID (可结合玩家颜色和棋子ID生成) void Start() { InitializePiecePrefabMap(); // 通常不在Start生成而是在游戏开始时由GameManager调用 // GenerateAllPieces(); } void InitializePiecePrefabMap() { foreach (var pv in piecePrefabList) { if (!piecePrefabMap.ContainsKey(pv.color)) { piecePrefabMap[pv.color] pv.piecePrefab; } } } // 生成所有玩家的所有棋子放在初始位置如基地 public void GenerateAllPieces() { ClearAllPieces(); if (GameManager.Instance null) return; foreach (PlayerData player in GameManager.Instance.players) { for (int i 0; i player.pieces.Count; i) { CreatePieceVisual(player.color, player.pieces[i].pieceId, player.pieces[i].currentGridIndex); } } } // 创建一个棋子的视觉对象 void CreatePieceVisual(PlayerColor color, int pieceId, int gridIndex) { if (!piecePrefabMap.ContainsKey(color)) { Debug.LogError($No prefab found for color: {color}); return; } GameObject piecePrefab piecePrefabMap[color]; GameObject pieceInstance Instantiate(piecePrefab, Vector3.zero, Quaternion.identity, this.transform); pieceInstance.name $Piece_{color}_{pieceId}; // 生成一个唯一ID简单示例 int uniqueId ((int)color * 10) pieceId; pieceInstanceMap[uniqueId] pieceInstance; // 设置初始位置 UpdatePiecePosition(color, pieceId, gridIndex); } // 更新棋子的位置 public void UpdatePiecePosition(PlayerColor color, int pieceId, int newGridIndex) { int uniqueId ((int)color * 10) pieceId; if (!pieceInstanceMap.ContainsKey(uniqueId)) { Debug.LogWarning($Piece visual not found for {color}_{pieceId}); return; } GameObject pieceGo pieceInstanceMap[uniqueId]; Vector3 targetPos Vector3.zero; if (newGridIndex -1) { // 在基地放在玩家基地位置需要额外定义基地位置 targetPos GetBasePosition(color, pieceId); } else if (newGridIndex -2) { // 已到达终点放在终点位置 targetPos GetFinishPosition(color, pieceId); } else { // 在棋盘路径上 GridPoint grid GameManager.Instance.GetGridPoint(newGridIndex); if (grid ! null) { targetPos grid.position; // 同一个格子可能有多个棋子需要做偏移避免完全重叠 // 这里简单处理实际需要更精细的布局 targetPos GetStackOffset(color, pieceId, newGridIndex); } } pieceGo.transform.position targetPos; } // 以下方法需要根据你的游戏布局实现 Vector3 GetBasePosition(PlayerColor color, int pieceId) { // 返回对应颜色基地的位置可以根据pieceId做微调 switch (color) { case PlayerColor.Red: return new Vector3(-2, 2, 0); case PlayerColor.Yellow: return new Vector3(2, 2, 0); case PlayerColor.Blue: return new Vector3(2, -2, 0); case PlayerColor.Green: return new Vector3(-2, -2, 0); default: return Vector3.zero; } } Vector3 GetFinishPosition(PlayerColor color, int pieceId) { // 返回对应颜色终点的位置 // 略根据棋盘设计实现 return Vector3.zero; } Vector3 GetStackOffset(PlayerColor color, int pieceId, int gridIndex) { // 计算同格棋子的偏移量例如根据pieceId决定一个环形偏移 // 这是一个简化示例 float angle pieceId * 90f * Mathf.Deg2Rad; float radius 0.2f; return new Vector3(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius, 0); } void ClearAllPieces() { foreach (var piece in pieceInstanceMap.Values) { Destroy(piece); } pieceInstanceMap.Clear(); } } }同样你需要为四种颜色分别创建棋子预制体带有 SpriteRenderer 的 GameObject并拖到PieceManager的piecePrefabList中。至此我们已经完成了游戏核心数据结构的搭建、游戏流程的管理以及棋盘棋子的视觉生成。在下一篇文章下篇中我们将深入实现最复杂的移动规则逻辑、碰撞与胜负判定并完成UI 交互系统掷骰子按钮、玩家信息显示、动画反馈等最终让这个飞行棋游戏真正“跑”起来。