1. 项目概述从零到一构建你的3D棋盘几年前我接手了一个看似简单的需求为一个教育类应用开发一个3D国际象棋模块。客户的要求是“要看起来专业玩起来流畅并且能让新手快速上手”。起初我以为这不过是个建模加规则逻辑的活儿但真正动手后才发现从棋盘格子的精准对位、棋子的物理交互到复杂的游戏规则状态机每一步都藏着不少门道。市面上虽然有一些现成的资源但要么过于庞杂难以定制要么功能简陋达不到产品级要求。于是我决定从头梳理一套清晰、可复现的开发流程也就是你现在看到的这份指南。这份指南的目标是带你完整走一遍在Unity中创建一个功能完备、视觉效果专业的3D国际象棋游戏的全过程。它不仅仅是一份“点击这里拖动那里”的操作手册更重要的是我会分享在实现每个核心模块时为什么选择某种方案以及在实际开发中容易踩到的坑和对应的解决方案。无论你是刚接触Unity不久的新手想通过一个完整的项目练手还是有一定经验的开发者需要快速搭建一个象棋功能模块这篇内容都能提供直接的参考价值。我们将从最基础的场景搭建开始逐步深入到棋子移动规则、胜负判定、用户交互乃至一些简单的AI对战思路最终得到一个可以直接运行、甚至稍加美化就能上架的游戏原型。2. 核心设计思路与架构规划在动手写第一行代码之前花点时间规划整个项目的架构是至关重要的。一个混乱的结构会让后续的规则添加、功能扩展和调试变得异常痛苦。对于3D国际象棋我们可以将其核心拆解为几个相对独立又相互关联的模块。2.1 数据层游戏状态的核心所有象棋逻辑的基石是数据。我们需要一个中心化的数据结构来表征某一时刻棋盘的全貌。我通常会创建一个名为ChessGameState的类它不依赖于任何Unity的MonoBehaviour是一个纯粹的C#类。这个类包含以下核心数据棋盘数组一个8x8的二维数组用于存储每个格子上棋子的引用或一个代表棋子类型和颜色的枚举值。这是所有逻辑判断的源头。当前行动方记录当前该谁走棋白方或黑方。棋子列表除了棋盘数组维护一个所有存活棋子的列表便于遍历和查找。游戏状态枚举值如“进行中”、“白方胜”、“黑方胜”、“和棋”等。历史着法记录用于实现“悔棋”功能以及判断“长将”等和棋规则。为什么选择二维数组而不是更复杂的图结构因为国际象棋棋盘本身就是标准的8x8网格数组的访问速度O(1)是最快的逻辑上也最直观。将游戏状态数据与视觉表现GameObject分离是保证逻辑清晰的关键。视觉对象只是数据的“视图”这样在实现网络对战或AI推演时我们可以轻松地操作和复制纯数据状态而不受场景中物体的影响。2.2 逻辑层规则引擎的实现这是最复杂也最有趣的部分。我们需要为每种棋子类型王、后、车、象、马、兵定义其独特的移动规则。我的建议是为所有棋子建立一个基类Piece然后为每种棋子创建派生类如Pawn,Knight,Bishop等。每个棋子类都需要实现一个关键方法GetLegalMoves(ChessGameState state, Position currentPos)。这个方法接收当前游戏状态和棋子位置返回一个所有合法目标位置的列表。这里有几个需要特别注意的规则细节路径阻挡车、后、象的移动路径上不能有其它棋子阻挡。需要在计算路径时逐格检查。吃子规则大部分棋子移动和吃子规则相同但兵是例外。兵只能斜着吃子却不能斜着移动。特殊规则王车易位需要检查王和车是否从未移动过王是否处于被将军状态以及经过和到达的格子是否安全。吃过路兵仅限对方兵第一次移动且前进两格落在己方兵相邻位置时在下一回合可以斜吃。兵的升变兵到达对方底线后必须立即升变为后、车、象、马中的一种。将这些规则封装在独立的逻辑层使得单元测试成为可能。你可以创建一些特定的棋盘状态测试你的GetLegalMoves函数是否返回了预期的结果这能极大提升代码的可靠性。2.3 表现层Unity场景中的视觉与交互这一层负责将数据层和逻辑层的结果以3D形式呈现给玩家并处理玩家的输入。主要包括场景构建创建棋盘和棋子的3D模型。可以使用简单的Cube和自定义模型也可以从Asset Store购买高质量的模型包。棋子控制器每个棋子GameObject上挂载一个PieceController脚本。它持有对逻辑层Piece对象的引用并负责处理拖拽、点击等交互事件。当玩家试图移动一个棋子时控制器会向逻辑层询问合法落点并高亮显示这些格子。棋盘高亮系统这是一个提升体验的重要细节。当玩家选中一个棋子时需要视觉反馈。我通常会创建一个半透明的绿色材质动态实例化并放置在所有合法移动的格子上方。摄像机控制允许玩家旋转、缩放视角以便更好地观察棋盘。注意务必保持表现层与逻辑层的单向依赖。即PieceController知道并调用逻辑层的Piece和ChessGameState但逻辑层的类绝对不应该引用任何GameObject或MonoBehaviour。这是保证架构清晰、可测试性的生命线。3. 实战开发一步步搭建游戏场景理论说完了我们打开Unity开始动手。我假设你已经安装了Unity Hub和至少一个较新版本的Unity编辑器如2021 LTS或2022 LTS。3.1 创建项目与基础场景首先创建一个新的3D核心模板项目。进入场景后我们先处理棋盘。创建棋盘在场景中创建一个空的GameObject命名为Chessboard。我们需要一个8x8的格子。最直观的方法是使用两个不同的材质来区分黑白格。你可以创建两个材质Material_White和Material_Black分别赋予白色和深灰色如#769656。写一个简单的编辑器脚本或直接在Start()方法中用循环生成64个Cube。每个Cube的缩放设为 (1, 0.2, 1)位置根据行列计算x col - 3.5f; z row - 3.5f;。根据(rowcol)%2的结果来分配材质。更高效的做法是使用一个单独的、带有棋盘纹理的平面Plane作为底板然后在每个格子中心点放置一个不可见的碰撞体Box Collider用于点击检测。这样绘制调用Draw Call更少性能更好。我通常采用这种“视觉与逻辑分离”的方式一个带纹理的平面负责渲染64个空物体带碰撞体负责逻辑格点。导入与摆放棋子你可以使用简单的几何体如圆柱体、球体组合来代表棋子这对于原型阶段完全足够。如果想更美观可以去Unity Asset Store搜索“Chess Pieces”有很多免费或付费的高质量模型包。为每种棋子创建一个预制体Prefab命名规范如Prefab_White_King,Prefab_Black_Pawn。根据初始棋盘布局在Chessboard下实例化这些预制体摆放在正确的位置上例如白兵在第2排即z1-3.5-2.5的位置。建议再创建一个空的PiecesGameObject作为所有棋子的父物体便于管理。3.2 实现棋子逻辑基类与具体规则在Scripts文件夹下创建我们的核心逻辑代码。定义枚举和数据结构// PieceType.cs public enum PieceType { None, Pawn, Knight, Bishop, Rook, Queen, King } public enum PlayerColor { White, Black } // Position.cs 或 Board坐标 public struct BoardPosition { public int row; // 0-7 public int col; // 0-7 public BoardPosition(int r, int c) { row r; col c; } // 可以重载运算符方便计算 }创建棋子基类Piecepublic abstract class Piece { public PieceType Type { get; protected set; } public PlayerColor Color { get; private set; } public bool HasMoved { get; set; } false; // 用于王车易位和兵的第一步 protected Piece(PlayerColor color) { Color color; } // 核心方法获取所有合法移动目标位 public abstract ListBoardPosition GetLegalMoves(BoardPosition currentPos, ChessGameState gameState); // 判断某一步是否合法通常先获取所有合法移动再判断是否包含目标点 public virtual bool IsMoveValid(BoardPosition from, BoardPosition to, ChessGameState gameState) { var legalMoves GetLegalMoves(from, gameState); return legalMoves.Contains(to); } }实现具体棋子类——以“马(Knight)”为例 马的移动规则最简单因为它不受路径阻挡影响是“跳”着走的。public class Knight : Piece { public Knight(PlayerColor color) : base(color) { Type PieceType.Knight; } private static readonly (int, int)[] _moveOffsets { (2, 1), (1, 2), (-1, 2), (-2, 1), (-2, -1), (-1, -2), (1, -2), (2, -1) }; public override ListBoardPosition GetLegalMoves(BoardPosition currentPos, ChessGameState gameState) { ListBoardPosition moves new ListBoardPosition(); foreach (var (rowDelta, colDelta) in _moveOffsets) { int newRow currentPos.row rowDelta; int newCol currentPos.col colDelta; // 检查是否在棋盘范围内 if (newRow 0 newRow 8 newCol 0 newCol 8) { BoardPosition target new BoardPosition(newRow, newCol); Piece targetPiece gameState.GetPieceAt(target); // 如果目标格为空或是敌方棋子则为合法移动马可以吃子 if (targetPiece null || targetPiece.Color ! this.Color) { moves.Add(target); } } } return moves; } }兵的规则会复杂很多需要区分第一次移动可走两格、普通移动一格、吃子斜前一格、吃过路兵以及升变。王车易位的规则则需要放在King类和ChessGameState中进行综合判断涉及王和车是否移动过、路径是否被攻击等。3.3 构建游戏状态管理器ChessGameState类是大脑。它需要提供接口供表现层查询和修改状态。public class ChessGameState { private Piece[,] board new Piece[8, 8]; public PlayerColor CurrentPlayer { get; private set; } PlayerColor.White; public GameStatus Status { get; private set; } GameStatus.Playing; // 初始化棋盘 public void InitializeStandardBoard() { // 清空棋盘 // 摆放棋子board[0,0] new Rook(PlayerColor.Black); ... // 白兵摆放在 row1, 黑兵摆放在 row6 } public Piece GetPieceAt(BoardPosition pos) board[pos.row, pos.col]; public void SetPieceAt(BoardPosition pos, Piece piece) board[pos.row, pos.col] piece; // 尝试移动棋子返回移动是否成功 public bool TryMovePiece(BoardPosition from, BoardPosition to) { Piece movingPiece GetPieceAt(from); if (movingPiece null || movingPiece.Color ! CurrentPlayer) return false; if (!movingPiece.IsMoveValid(from, to, this)) return false; // 执行移动临时状态 Piece capturedPiece GetPieceAt(to); SetPieceAt(to, movingPiece); SetPieceAt(from, null); movingPiece.HasMoved true; // 检查移动后自己的王是否被将军自杀式移动是非法的 if (IsKingInCheck(CurrentPlayer)) { // 撤销移动 SetPieceAt(from, movingPiece); SetPieceAt(to, capturedPiece); return false; } // 移动合法处理特殊规则如升变 HandlePromotion(to, movingPiece); // 切换行棋方 CurrentPlayer (CurrentPlayer PlayerColor.White) ? PlayerColor.Black : PlayerColor.White; // 检查游戏是否结束 UpdateGameStatus(); return true; } private bool IsKingInCheck(PlayerColor color) { /* 遍历所有敌方棋子看其合法移动是否包含己方王的位置 */ } private void UpdateGameStatus() { /* 判断是否将死、逼和等 */ } private void HandlePromotion(BoardPosition pos, Piece piece) { /* 如果是兵到底线触发升变UI */ } }4. 连接逻辑与表现交互控制器的实现现在我们需要让场景中的棋子能够被玩家拖动并与我们的逻辑层通信。创建PieceController脚本 将其挂载到每个棋子预制体的根节点上。public class PieceController : MonoBehaviour { public Piece LogicalPiece { get; set; } // 由游戏管理器在初始化时赋值 private ChessGameState gameState; private Vector3 startPosition; private bool isDragging false; void Start() { gameState FindObjectOfTypeGameManager().GameState; // 假设有一个总的GameManager } void OnMouseDown() { if (gameState.CurrentPlayer ! LogicalPiece.Color) return; startPosition transform.position; isDragging true; // 通知高亮系统显示此棋子的合法走法 Highlighter.Instance.HighlightMoves(LogicalPiece, GetBoardPosition()); } void OnMouseDrag() { if (!isDragging) return; // 将鼠标屏幕坐标转换为世界坐标在棋盘平面 Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f, LayerMask.GetMask(Board))) { transform.position hit.point Vector3.up * 0.5f; // 稍微抬高避免与棋盘穿插 } } void OnMouseUp() { if (!isDragging) return; isDragging false; // 获取释放位置对应的格子 Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f, LayerMask.GetMask(Board))) { BoardPosition targetPos ConvertHitToBoardPosition(hit); BoardPosition startPos GetBoardPosition(); // 请求游戏状态管理器执行移动 bool moveSuccess gameState.TryMovePiece(startPos, targetPos); if (moveSuccess) { // 移动成功更新棋子位置到目标格中心 transform.position GetWorldPosition(targetPos); } else { // 移动非法回到原位置 transform.position startPosition; } } else { // 未释放在棋盘上回到原位置 transform.position startPosition; } // 清除高亮 Highlighter.Instance.ClearHighlights(); } private BoardPosition GetBoardPosition() { /* 根据transform.position反算行列 */ } private Vector3 GetWorldPosition(BoardPosition pos) { /* 根据行列计算世界坐标 */ } }实现高亮系统Highlighter 这是一个单例类负责在玩家选中棋子时在合法移动的格子上方生成高亮指示器例如一个半透明的绿色Quad。public class Highlighter : MonoBehaviour { public static Highlighter Instance; public GameObject highlightPrefab; // 一个简单的平面预制体 private ListGameObject currentHighlights new ListGameObject(); void Awake() { Instance this; } public void HighlightMoves(Piece piece, BoardPosition from) { ClearHighlights(); var legalMoves piece.GetLegalMoves(from, GameManager.Instance.GameState); foreach (var move in legalMoves) { Vector3 worldPos /* 计算格子中心的世界坐标 */; GameObject hl Instantiate(highlightPrefab, worldPos Vector3.up * 0.01f, Quaternion.identity); currentHighlights.Add(hl); } } public void ClearHighlights() { foreach (var hl in currentHighlights) Destroy(hl); currentHighlights.Clear(); } }5. 高级功能与性能优化当基础功能完成后可以考虑添加一些提升体验和完成度的功能。5.1 胜负判定与游戏状态UI在ChessGameState.UpdateGameStatus()方法中完善状态判断将死 (Checkmate)当前行棋方无任何合法着法任何一步都无法解除将军状态。逼和 (Stalemate)当前行棋方未被将军但无任何合法着法。长将 (Perpetual Check)需要通过着法历史记录来判断是否同一局面重复出现三次或符合特定规则。子力不足 (Insufficient Material)双方都只剩下无法将死的子力组合如王对王、王象对王等。当状态改变时触发事件通知UI管理器显示“将军”、“将死”、“和棋”等提示信息。5.2 移动动画与音效直接让棋子“跳”到目标位置很生硬。可以使用Vector3.Lerp或Dotween插件来实现平滑移动。// 在PieceController中 public void MoveToPosition(Vector3 targetWorldPos, float duration 0.3f) { StartCoroutine(MoveCoroutine(targetWorldPos, duration)); } IEnumerator MoveCoroutine(Vector3 target, float duration) { float elapsed 0; Vector3 start transform.position; while (elapsed duration) { transform.position Vector3.Lerp(start, target, elapsed / duration); elapsed Time.deltaTime; yield return null; } transform.position target; }同时在移动开始和结束时播放对应的音效移动声、吃子声能极大增强沉浸感。5.3 性能注意事项避免每帧查找对象FindObjectOfType、GetComponent等操作应在Start或Awake中缓存结果而不是在Update中调用。高亮对象池频繁实例化和销毁高亮指示器会产生GC垃圾回收压力。可以改用对象池技术预先创建一批高亮对象循环使用。逻辑计算优化GetLegalMoves是性能热点。对于兵、王这类移动范围有限的棋子计算量不大。但对于后、车、象这类远程棋子需要进行射线扫描。确保你的扫描算法在遇到第一个棋子时就停止并且将结果缓存起来直到棋盘状态发生改变再重新计算。5.4 简单AI对手的实现可选为游戏添加一个简单的AI可以让单人体验更完整。一个最基础的实现是“随机AI”获取所有己方棋子的所有合法移动。从中随机选择一步执行。 稍微高级一点可以用“贪婪AI”获取所有合法移动。为每一步移动评估一个分数例如吃子得分移动到中心格得分保护己方棋子得分。选择分数最高的那一步。 这只是一个起点更复杂的AI会涉及最小最大算法Minimax和阿尔法贝塔剪枝Alpha-Beta Pruning这需要专门的设计。6. 常见问题与调试技巧在开发过程中你几乎一定会遇到下面这些问题。问题1棋子移动规则看似正确但某些特殊走法如王车易位、吃过路兵不生效。排查首先检查你的ChessGameState中是否完整记录了游戏历史状态如“王和车是否移动过”、“上一步是否是兵移动两格”。这些信息是判断特殊走法的前提。添加详细的Debug.Log在尝试移动时打印出所有判断条件的结果。技巧为特殊规则编写独立的单元测试函数。创建一个特定的初始棋盘状态然后调用移动函数断言结果是否符合预期。问题2拖动棋子时棋子会“穿”过棋盘或卡在奇怪的位置。排查检查棋子和棋盘的Layer设置。确保棋子的碰撞体如果有不会阻挡射线检测。在OnMouseDrag的射线检测中LayerMask参数应只包含棋盘层。同时确保棋盘碰撞体的大小和位置完全覆盖视觉网格。技巧在Scene视图中开启Gizmos显示碰撞体直观地查看碰撞体范围。拖动时用Debug.DrawRay画出射线看它击中了哪里。问题3游戏运行一段时间后变卡。排查使用Unity ProfilerWindow Analysis Profiler查看CPU和GC垃圾回收情况。很可能是每帧都在动态计算合法移动或频繁实例化/销毁对象。解决实现移动结果的缓存。只有当棋盘状态真正改变有棋子移动或吃子后才重新计算相关棋子的合法移动。对高亮指示器使用对象池。问题4判断“将军”和“将死”的逻辑有误。这是最常见的逻辑Bug。关键在于模拟移动后必须检查移动方的王是否被攻击。也就是说你不能走一步让自己被将军的棋。在TryMovePiece中执行移动临时修改棋盘数组后立即调用IsKingInCheck(CurrentPlayer)检查如果为真则撤销移动并返回失败。将死的判断则是在当前玩家所有可能的移动中没有任何一步可以使其王脱离被将军状态。这需要遍历所有己方棋子的所有合法移动对每一步进行模拟和检查计算量较大注意优化。问题5网络热词中提到的“Unity性能优化”相关。批处理Batching确保棋盘和棋子的材质尽可能共享减少Draw Call。静态的棋盘可以标记为Static启用静态批处理。LOD与遮挡剔除对于复杂的棋子模型如果摄像机拉远可以使用更简化的模型LOD。不过对于象棋这种小场景通常不是必须。脚本优化避免在Update中做复杂的计算或查找。将必要的计算分摊到多帧进行或使用事件驱动。开发这样一个项目最大的体会是“分而治之”的重要性。将数据、逻辑、表现清晰地分离能让复杂的规则变得可控。另一个深刻的教训是尽早并频繁地进行测试。不要等到所有棋子规则都写完才测试每实现一个比如马就立刻在场景中摆好位置手动拖动测试它的移动是否合乎规则。这样能最快地定位问题所在。当你看到两个简单的几何体在亲手搭建的棋盘上按照千年传承的规则运转时那种成就感远非调用一个现成的游戏引擎组件可比。