
你有没有过这样的经历想学 Unity 2D 游戏开发跟着教程一步步做代码敲了功能实现了但关上教程后自己面对一个空白项目却不知道从哪里开始或者做出来的东西总感觉“不像个游戏”界面简陋、逻辑混乱、代码难以维护更像是一堆功能的拼凑。今天我们不谈那些宏大的游戏设计理论也不去追逐最新的渲染技术。我们就从一个最经典、最接地气的项目开始——用 Unity 和 C# 从头构建一个完整的 2D 飞行棋游戏。这个项目看似简单却是一个绝佳的“麻雀虽小五脏俱全”的实战样本。它能让你一次性串联起 Unity 2D 开发中 UI 搭建、核心游戏逻辑、状态管理、动画交互、数据持久化等多个核心环节。更重要的是通过这个项目我想传递一个核心观点游戏开发入门真正的难点不在于学会某个 API 怎么调用而在于如何将零散的知识点组织成一个有清晰结构、可扩展、易维护的完整项目。很多人卡在“知道”但“不会用”的阶段缺的就是这样一次从零到一的、有完整上下文的实战演练。本文将分为上、下两篇。上篇我们将聚焦于项目框架搭建、核心数据模型设计、棋盘与棋子的可视化呈现以及最基础的单人移动逻辑。我们会像搭积木一样先建立起稳固的“地基”和“骨架”。1. 为什么从飞行棋开始它远比你想象的更有价值你可能觉得飞行棋太“小儿科”了网上随便找个脚本就能跑起来。但恰恰是这种规则明确、元素简单的游戏最适合用来解剖游戏开发的基本范式。它避开了复杂的图形学、物理引擎和 AI 算法让我们可以集中精力解决游戏开发中最本质的问题状态管理与玩家交互。一个完整的飞行棋游戏至少包含以下你必须掌握的开发模块UI 系统 (UGUI)游戏主界面、玩家信息面板、骰子按钮、状态提示文本。2D 精灵 (Sprite) 管理棋盘格、四种颜色的棋子、骰子动画。核心数据模型玩家类、棋子类、棋盘格类以及它们之间的关系。游戏状态机游戏初始化、等待掷骰子、棋子移动、触发格子事件如跳跃、暂停、判断胜负等状态的流转。用户输入与反馈点击掷骰子、选择要移动的棋子、移动动画。基础动画与特效骰子旋转、棋子移动路径动画。如果你能独立完成这样一个项目那么你就已经掌握了用 Unity 制作大部分回合制、棋盘类 2D 游戏如象棋、跳棋、大富翁的通用能力。这个过程的训练价值远高于孤立地学习十个 UI 控件或二十个 C# 语法知识点。2. 项目初始化别急着画棋盘先想好代码怎么组织很多新手拿到项目第一反应是打开 Unity开始导入图片、摆放 UI。这往往会导致后期代码混乱牵一发而动全身。我们的第一步应该在纸上或思维导图里进行简单的架构设计。2.1 创建项目与目录结构打开 Unity Hub创建一个新的 2D 项目命名为FlightChess2D。在Assets文件夹下创建清晰的项目目录。一个良好的结构是未来维护的基础。Assets/ ├── Scripts/ # 所有C#脚本 │ ├── Core/ # 核心数据模型和枚举 │ ├── Managers/ # 单例管理器游戏管理器、音频管理器等 │ ├── UI/ # 与UI控件相关的脚本 │ └── Components/ # 挂在GameObject上的组件脚本 ├── Sprites/ # 所有图片资源棋盘、棋子、骰子、UI元素 ├── Prefabs/ # 预制体棋子、格子等 ├── Scenes/ # 场景文件 └── Settings/ # 可能用到的ScriptableObject等配置资源这个结构不是固定的但它传递了一个重要理念按功能而非按类型来组织资源。把所有的脚本都扔在一个文件夹里是项目走向混乱的开始。2.2 定义核心数据模型Core在Scripts/Core/下我们先创建几个最基础的 C# 脚本。它们不继承MonoBehaviour是纯粹的“数据类”或“枚举”用于描述游戏规则。Enums.cs集中定义枚举避免魔法数字。namespace FlightChess.Core { // 玩家颜色 public enum PlayerColor { Red, Yellow, Blue, Green } // 格子类型 public enum GridType { Normal, // 普通格 Start, // 起飞点 Airport, // 机场跳跃 Pause, // 暂停一次 Backward, // 后退 Goal // 终点 } // 游戏状态 public enum GameState { WaitingForRoll, // 等待掷骰子 Moving, // 棋子移动中 SelectingPiece, // 选择移动棋子当有多架飞机可走时 GameOver // 游戏结束 } }Player.cs玩家类持有其所有棋子的状态。using System.Collections.Generic; namespace FlightChess.Core { [System.Serializable] // 方便后续可能的数据持久化 public class Player { public PlayerColor color; public bool isActive; // 是否轮到该玩家 public ListPiece pieces; // 该玩家的四枚棋子 public int startGridIndex; // 该玩家的起飞点在棋盘数组中的索引 public Player(PlayerColor color, int startIndex) { this.color color; this.startGridIndex startIndex; this.isActive false; pieces new ListPiece(); // 初始化4枚棋子状态均为“在基地” for (int i 0; i 4; i) { pieces.Add(new Piece(i, this)); } } // 检查是否有棋子可以移动不在基地且未到达终点 public bool HasMovablePieces() { foreach (var piece in pieces) { if (piece.CanMove()) { return true; } } return false; } } }Piece.cs棋子类代表一架飞机。namespace FlightChess.Core { [System.Serializable] public class Piece { public int id; // 棋子ID (0-3) public Player owner; // 所属玩家 public PieceState state; public int currentGridIndex; // 当前所在格子的索引-1表示在基地 public Piece(int id, Player owner) { this.id id; this.owner owner; this.state PieceState.InBase; this.currentGridIndex -1; } // 判断棋子当前是否可移动 public bool CanMove() { return state PieceState.OnBoard || state PieceState.ReadyToStart; } // 棋子状态 public enum PieceState { InBase, // 在基地 ReadyToStart, // 在起飞点等待指令起飞 OnBoard, // 在棋盘上移动 Finished // 已到达终点 } } }Grid.cs棋盘格子类。namespace FlightChess.Core { [System.Serializable] public class Grid { public int index; // 在棋盘路径上的顺序索引 public GridType type; public int linkedGridIndex -1; // 用于机场跳跃、后退等指向另一个格子索引 // 这个格子的世界坐标将由Unity的Transform组件提供这里先预留 // public Vector2 worldPosition; } }在项目初期花时间设计这些类看起来慢了但它是一劳永逸的。当游戏逻辑变得复杂时清晰的数据结构能让你快速定位问题所在而不是在一堆散乱的变量里挣扎。3. 构建游戏管理器游戏运行的“大脑”有了数据模型我们需要一个“大脑”来协调一切。这就是GameManager它通常被设计成一个单例Singleton在整个游戏生命周期中只有一个实例。在Scripts/Managers/下创建GameManager.cs。using System.Collections.Generic; using UnityEngine; using FlightChess.Core; namespace FlightChess.Managers { public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } // 当前游戏状态 public GameState CurrentState { get; private set; } // 所有玩家 public ListPlayer players; // 棋盘格子列表 public ListGrid boardGrids; // 当前回合的玩家索引 private int _currentPlayerIndex; // 本次掷出的骰子点数 private int _currentDiceValue; private void Awake() { // 简单的单例模式确保只有一个GameManager if (Instance null) { Instance this; DontDestroyOnLoad(gameObject); // 跨场景不销毁 } else { Destroy(gameObject); } InitializeGame(); } private void InitializeGame() { // 1. 初始化棋盘 CreateBoardGrids(); // 2. 初始化玩家 CreatePlayers(); // 3. 设置游戏初始状态 CurrentState GameState.WaitingForRoll; _currentPlayerIndex 0; players[_currentPlayerIndex].isActive true; // 4. 通知UI更新这里需要UI管理器后续实现 // UIManager.Instance.UpdatePlayerTurn(players[_currentPlayerIndex].color); } private void CreateBoardGrids() { boardGrids new ListGrid(); // 这里手动创建或从配置文件读取棋盘布局。 // 示例创建一个有40个普通格4个起飞点4个机场4个暂停格的简单棋盘。 int totalGrids 52; // 假设总格数 for (int i 0; i totalGrids; i) { Grid grid new Grid { index i }; // 根据索引设置格子类型这里需要你的棋盘设计图 // 例如索引0,13,26,39是四个玩家的起飞点(GridType.Start) // 索引5,18,31,44是机场(GridType.Airport)linkedGridIndex指向跳跃目标 // 其他为普通格(GridType.Normal) grid.type GridType.Normal; // 默认 boardGrids.Add(grid); } // 实际项目中这部分数据最好用ScriptableObject或JSON配置便于修改。 } private void CreatePlayers() { players new ListPlayer(); // 假设棋盘上四个起飞点的索引是0,13,26,39 players.Add(new Player(PlayerColor.Red, 0)); players.Add(new Player(PlayerColor.Yellow, 13)); players.Add(new Player(PlayerColor.Blue, 26)); players.Add(new Player(PlayerColor.Green, 39)); } // 外部调用掷骰子 public void RollDice() { if (CurrentState ! GameState.WaitingForRoll) { Debug.LogWarning(现在不能掷骰子); return; } // 模拟随机掷骰子 (1-6) _currentDiceValue Random.Range(1, 7); Debug.Log($玩家 {players[_currentPlayerIndex].color} 掷出了 {_currentDiceValue} 点); // 处理掷骰子结果 ProcessAfterRoll(_currentDiceValue); } private void ProcessAfterRoll(int diceValue) { Player currentPlayer players[_currentPlayerIndex]; bool hasMovablePieces currentPlayer.HasMovablePieces(); if (diceValue 6) { // 掷出6点可以起飞一架在基地的飞机或者移动棋盘上的飞机 // 如果有在基地的飞机优先让其进入“准备起飞”状态 // 这里逻辑简化直接进入选择棋子状态 CurrentState GameState.SelectingPiece; // 高亮显示当前玩家可移动的棋子包括基地里可起飞的 // HighlightMovablePieces(currentPlayer, diceValue); } else { if (hasMovablePieces) { // 有棋子可移动进入选择棋子状态 CurrentState GameState.SelectingPiece; // HighlightMovablePieces(currentPlayer, diceValue); } else { // 没有棋子可移动回合结束 Debug.Log($玩家 {currentPlayer.color} 没有棋子可以移动回合结束。); EndCurrentTurn(); } } } // 选择棋子后移动棋子 public void MovePiece(Piece piece, int steps) { if (CurrentState ! GameState.SelectingPiece) return; CurrentState GameState.Moving; // 计算目标格子索引需要处理棋盘循环、跳跃等逻辑 int targetIndex CalculateTargetGridIndex(piece, steps); // 更新棋子数据 piece.currentGridIndex targetIndex; // 处理目标格子事件如跳跃、暂停 ProcessGridEvent(piece, boardGrids[targetIndex]); // 播放移动动画协程实现 StartCoroutine(MovePieceAnimation(piece, targetIndex)); } private System.Collections.IEnumerator MovePieceAnimation(Piece piece, int targetIndex) { // 这里应通过Piece的GameObject进行视觉移动 // 获取棋子的Transform并逐帧移动到目标格子的世界坐标 // yield return new WaitForSeconds(...); // 动画完成后... OnPieceMoveFinished(piece); } private void OnPieceMoveFinished(Piece piece) { // 检查是否触发撞子需判断目标格子是否有其他玩家的棋子 // CheckCollision(piece); // 检查是否到达终点 // CheckIfPieceFinished(piece); // 判断玩家是否所有棋子都到达终点游戏胜利 // CheckWinCondition(piece.owner); // 如果当前掷骰子点数不是6或者游戏规则规定移动后即结束回合 if (_currentDiceValue ! 6 /* 或其他规则 */) { EndCurrentTurn(); } else { // 掷出6点可以再掷一次回到等待状态 CurrentState GameState.WaitingForRoll; // 更新UI提示 } } private void EndCurrentTurn() { players[_currentPlayerIndex].isActive false; _currentPlayerIndex (_currentPlayerIndex 1) % players.Count; players[_currentPlayerIndex].isActive true; CurrentState GameState.WaitingForRoll; _currentDiceValue 0; Debug.Log($轮到玩家 {players[_currentPlayerIndex].color}。); } // 计算目标位置简化版未处理路径循环 private int CalculateTargetGridIndex(Piece piece, int steps) { if (piece.state Piece.PieceState.ReadyToStart) { // 从起飞点开始移动 return piece.owner.startGridIndex; } else { // 在棋盘上移动 int newIndex piece.currentGridIndex steps; // 需要处理索引超出棋盘长度时的循环逻辑 // if (newIndex boardGrids.Count) ... return newIndex; } } private void ProcessGridEvent(Piece piece, Grid grid) { switch (grid.type) { case GridType.Airport: piece.currentGridIndex grid.linkedGridIndex; Debug.Log($棋子跳跃到 {grid.linkedGridIndex}); break; case GridType.Pause: // 标记该棋子暂停一回合 // piece.skipNextTurn true; break; case GridType.Backward: // 后退逻辑 // piece.currentGridIndex - someSteps; break; default: break; } } } }这个GameManager已经勾勒出了游戏的核心循环等待掷骰 - 处理结果 - 选择棋子 - 移动并触发事件 - 判断回合结束。它目前还有很多留白如动画、碰撞、UI更新但骨架已经非常清晰。在后续开发中我们只需要往这个骨架里填充“肉”即可。4. 从数据到视觉创建棋盘与棋子的 GameObject数据层准备好了现在我们需要在 Unity 场景中看到它们。这一步的关键是建立数据模型与 GameObject 的关联。4.1 创建棋盘预制体在Scenes文件夹中创建主场景MainScene。导入棋盘和棋子的 Sprite 图片到Assets/Sprites/。在场景中创建一个空的 GameObject命名为Board。根据GameManager中boardGrids的数据我们需要动态生成格子。但为了简化上手我们可以先手动布置一个静态棋盘。创建一个Grid预制体一个带有SpriteRenderer显示格子图片和BoxCollider2D用于后续点击交互的 GameObject。根据你的棋盘设计图在Board下实例化这些Grid预制体摆成飞行棋的环形路径。记录下每个格子的世界坐标Transform.position。关键一步我们需要将视觉上的格子与数据模型Grid关联起来。创建一个脚本GridController.cs挂在每个Grid预制体上。using UnityEngine; using FlightChess.Core; namespace FlightChess.Components { public class GridController : MonoBehaviour { public int gridIndex; // 对应数据模型 Grid 的 index public GridType gridType; // 可以在Inspector中设置或者由BoardManager在生成时赋值 // 提供一个方法用于获取此格子的世界坐标供棋子移动使用 public Vector2 GetPosition() { return transform.position; } } }创建一个BoardManager.cs脚本挂在Board对象上。它的职责是在Start()或Awake()中收集所有子物体GridController组件并按某种顺序例如按名称或手动设置索引存入一个列表。将这个列表同步或提供给GameManager.Instance.boardGrids需要将Grid数据类的worldPosition与GridController的GetPosition()关联。一种常见做法是BoardManager根据GridController的信息初始化GameManager的boardGrids列表。4.2 创建棋子预制体与控制器创建Piece预制体一个带有SpriteRenderer显示棋子颜色的 GameObject。创建脚本PieceController.cs挂在预制体上。using UnityEngine; using FlightChess.Core; namespace FlightChess.Components { public class PieceController : MonoBehaviour { // 关联的数据模型 public Piece linkedPiece; private SpriteRenderer _spriteRenderer; private void Awake() { _spriteRenderer GetComponentSpriteRenderer(); } // 由GameManager或PlayerManager调用用于建立关联 public void Initialize(Piece piece, Sprite sprite) { linkedPiece piece; _spriteRenderer.sprite sprite; // 初始位置设置为该玩家基地或起飞点 UpdatePosition(); } // 更新棋子视觉位置到其 currentGridIndex 对应的格子 public void UpdatePosition() { if (linkedPiece.state Piece.PieceState.InBase) { // 移动到屏幕外的基地位置 transform.position GetBasePosition(linkedPiece.owner.color); } else if (linkedPiece.state Piece.PieceState.ReadyToStart) { // 移动到起飞点 Grid startGrid GameManager.Instance.boardGrids[linkedPiece.owner.startGridIndex]; // 需要从BoardManager获取对应GridController的世界坐标 transform.position BoardManager.Instance.GetGridWorldPosition(startGrid.index); } else if (linkedPiece.state Piece.PieceState.OnBoard) { // 移动到棋盘上对应格子 transform.position BoardManager.Instance.GetGridWorldPosition(linkedPiece.currentGridIndex); } // Finished状态可以不显示或显示在终点区 } // 播放移动动画被GameManager.MovePieceAnimation调用 public IEnumerator MoveToPosition(Vector2 targetPos, float duration) { Vector2 startPos transform.position; float elapsed 0f; while (elapsed duration) { transform.position Vector2.Lerp(startPos, targetPos, elapsed / duration); elapsed Time.deltaTime; yield return null; } transform.position targetPos; } private Vector2 GetBasePosition(PlayerColor color) { // 根据颜色返回基地坐标可以写死或通过一个配置类获取 switch (color) { // ... 返回不同的 Vector2 default: return Vector2.zero; } } } }创建一个PieceManager或由GameManager负责在游戏初始化时为每个Player的每个Piece数据模型实例化一个PieceController预制体并调用Initialize方法建立关联。至此我们完成了上篇的所有核心工作定义了游戏的数据骨架Core创建了驱动游戏的大脑GameManager并搭建了数据与视觉的桥梁GridController, PieceController。虽然还没有华丽的UI和动画但一个可运行、逻辑清晰的飞行棋内核已经构建完毕。在下篇中我们将在此基础上实现完整的 UI 交互掷骰子按钮、玩家信息、状态提示完善棋子的移动动画与碰撞逻辑处理复杂的游戏规则如迭子、撞子、终点判定并最终完成游戏胜负判断与重新开始功能。回顾一下上篇的重点先设计数据再构建管理逻辑最后关联视觉对象。这个顺序能最大程度保证你的代码结构清晰避免后期陷入“拆东墙补西墙”的混乱。当你掌握了这种从核心逻辑出发的开发思路未来面对更复杂的游戏项目时你也能从容地将其分解为可管理的数据模块和逻辑模块。