ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity 2D飞行棋实战:从零构建回合制游戏完整开发流程

Unity 2D飞行棋实战:从零构建回合制游戏完整开发流程 很多Unity初学者都有这样的困惑跟着教程做完了几个小Demo但一到自己动手做完整项目就无从下手。特别是2D游戏开发看似简单但要把UI、逻辑、动画、数据管理这些模块串联成一个可玩、可扩展的项目中间隔着一条巨大的鸿沟。你可能会画一个角色会写一段移动代码但如何让四个玩家轮流掷骰子如何判断棋子是否到达终点如何设计一个清晰易懂的游戏状态机这些才是实战中的真问题。今天我们就以经典的《飞行棋》为蓝本用Unity和C#从头搭建一个完整的2D游戏项目。这篇文章的目的不是复刻一个“能跑就行”的Demo而是要带你走一遍小型商业级2D游戏的完整开发流程。你会学到如何将一个大需求拆解成可管理的模块如棋盘、棋子、玩家、规则引擎如何用面向对象的思想设计游戏架构以及如何用Unity的UGUI和2D Sprite系统高效实现视觉效果。更重要的是我们会重点讲解那些教程里很少提但实际开发中一定会遇到的“坑”比如回合制逻辑的同步、动画与状态的衔接、游戏数据的持久化等。无论你是想巩固Unity 2D基础还是为求职准备一个像样的作品集这个从零到一的《飞行棋》项目都是一个绝佳的练手选择。它能系统性地训练你的工程化思维而不仅仅是API的调用。1. 为什么选择《飞行棋》作为第一个完整2D项目在开始敲代码之前我们先明确这个项目的训练价值。你可能会问为什么不从更简单的“打地鼠”或者“Flappy Bird”开始因为它们太“线性”了。《飞行棋》虽然规则简单但其内在结构包含了2D游戏开发的多个核心范式状态驱动游戏清晰地分为“等待掷骰”、“选择棋子”、“棋子移动”、“触发格子事件”等状态。这迫使你思考如何设计一个健壮的游戏状态机Game State Machine这是复杂游戏如RPG、战棋的基石。数据与表现分离棋盘格子的坐标、类型起点、终点、普通、特殊事件棋子的位置、归属玩家这些都是数据。而格子的贴图、棋子的Sprite、移动的动画这些是表现。一个好的架构必须区分这两者否则代码会很快变得难以维护。UI与游戏逻辑交互需要UI来显示当前玩家、骰子点数、回合信息同时UI按钮如“掷骰子”又要驱动核心游戏逻辑。这涉及到事件驱动编程和MVC或类似模式的初步实践。2D Sprite管理与动画棋子的移动不是瞬移需要平滑的移动动画。这涉及到Unity的Transform位置插值、SpriteRenderer管理甚至简单的帧动画或Dotween动画系统的使用。通过完成这个项目你收获的将不仅仅是一个游戏而是一套应对更复杂2D项目的方法论和工具箱。2. 核心概念与项目架构设计在动手创建第一个GameObject之前我们必须先进行顶层设计。一个混乱的架构会让后续开发举步维艰。2.1 核心数据模型定义我们的游戏世界主要由以下几个核心类构成Board棋盘。负责管理所有Grid格子的布局、寻路逻辑根据骰子点数计算棋子可移动的路径。Grid格子。每个格子需要知道自己的索引在棋盘上的位置、类型起点、跑道、跳跃点、暂停一轮等、世界坐标用于放置棋子。Player玩家。包含玩家ID、颜色、所属的四个Piece棋子的状态是否在基地、是否已到达终点。Piece棋子。最核心的游戏对象之一需要持有对所属Player的引用、当前所在的Grid索引以及自身的UnityGameObject用于显示和移动。GameManager游戏管理器。单例模式的最佳实践场景。它掌管游戏流程状态机、当前回合玩家、骰子结果并协调Board、Player、UI等所有模块的交互。2.2 模块依赖关系一个清晰的依赖关系能避免循环引用和 spaghetti code面条代码。GameManager (最高层协调者) | |--- Board (持有所有Grid) | |--- Grid (基础单元) | |--- Player[4] (每个玩家是一个实例) | |--- Piece[4] (每个棋子是一个实例) | |--- UIManager (控制所有UI更新) | |--- DiceController (控制骰子动画与结果生成)关键原则底层模块如Grid,Piece不应直接调用高层模块如GameManager。它们通过定义事件C#event或Action或由高层模块查询其状态来实现通信。例如Piece移动到新格子时触发一个OnPieceMoved事件GameManager监听这个事件然后去检查该格子是否有特殊效果。3. 开发环境与项目初始化3.1 环境准备Unity版本推荐使用最新的LTS长期支持版本如2022.3 LTS或更新版本。LTS版本稳定性高适合项目开发。本文示例基于Unity 2022.3 LTS。IDEVisual Studio 2022 或 JetBrains Rider。确保已安装Unity开发所需的.NET和游戏开发工作负载。项目模板打开Unity Hub创建新项目时选择“2D (URP)”核心模板。URPUniversal Render Pipeline是Unity新一代的通用渲染管线对2D和3D都有良好支持且性能优于传统的内置渲染管线。3.2 项目文件夹结构规划良好的文件夹结构是项目可维护性的第一步。在Assets目录下创建如下结构Assets/ ├── Art/ # 美术资源 │ ├── Sprites/ # 精灵图片棋盘、棋子、骰子、UI元素 │ └── Prefabs/ # 预制体 ├── Scripts/ # C# 脚本 │ ├── Core/ # 核心数据模型 (Board, Grid, Player, Piece) │ ├── Managers/ # 管理器 (GameManager, UIManager) │ ├── Controllers/# 控制器 (DiceController, CameraController) │ └── Utilities/ # 工具类 (Extensions, Singletons) ├── Scenes/ # 场景文件 ├── Settings/ # URP Asset、Input Actions等设置文件 └── UI/ # UI布局、字体等在Project窗口右键即可创建这些文件夹。事先规划好能让你和你的队友未来的你快速找到任何资源。4. 构建游戏世界棋盘与格子系统这是项目的实体基础。我们将先创建数据再创建表现。4.1 定义格子(Grid)数据类首先创建一个纯粹的C#类来存储格子数据它不继承MonoBehaviour因为它是数据模型。// 文件路径Assets/Scripts/Core/Grid.cs namespace FlightChess.Core { // 格子类型枚举 public enum GridType { Normal, // 普通格子 Start, // 玩家起飞点 Jump, // 跳跃格前进/后退N格 Pause, // 暂停一轮 Safe, // 安全区不能被撞 Goal // 终点格 } public class Grid { public int Index { get; private set; } // 在棋盘路径上的顺序索引 public GridType Type { get; private set; } public Vector3 WorldPosition { get; set; } // 该格子在场景中的世界坐标 // 特殊属性例如跳跃格的目标索引 public int JumpToIndex { get; private set; } -1; public Grid(int index, GridType type) { Index index; Type type; } // 设置跳跃目标仅在Type为Jump时调用 public void SetJumpTarget(int targetIndex) { if (Type GridType.Jump) { JumpToIndex targetIndex; } } // 判断格子是否可被其他棋子占领例如安全区可能有多架飞机 public virtual bool CanOccupy(Piece piece) { // 基础逻辑安全区和终点可以有多架飞机其他格子通常只能有一架后续在Board逻辑中处理碰撞 return Type GridType.Safe || Type GridType.Goal; } } }4.2 创建棋盘(Board)管理器Board类负责初始化所有格子并提供一个关键方法根据起点和步数计算移动路径。// 文件路径Assets/Scripts/Core/Board.cs using System.Collections.Generic; using UnityEngine; namespace FlightChess.Core { public class Board : MonoBehaviour { // 公开的格子列表供其他类查询 public ListGrid PathGrids { get; private set; } new ListGrid(); // 在Inspector中配置棋盘布局可视化编辑 [SerializeField] private Transform[] gridTransforms; // 将场景中的格子空物体拖拽到这里 void Start() { InitializeBoard(); } private void InitializeBoard() { if (gridTransforms null || gridTransforms.Length 0) { Debug.LogError(未分配Grid Transforms请在Inspector中配置。); return; } PathGrids.Clear(); // 假设前4个是玩家起点最后4个是终点区域中间是跑道 for (int i 0; i gridTransforms.Length; i) { GridType type GridType.Normal; // 简化逻辑实际项目中这里应该根据设计图配置 if (i 4) type GridType.Start; else if (i gridTransforms.Length - 4) type GridType.Goal; else if (i 10 || i 20) type GridType.Jump; // 示例跳跃格 Grid grid new Grid(i, type); grid.WorldPosition gridTransforms[i].position; // 配置跳跃格 if (type GridType.Jump) { grid.SetJumpTarget(i 5); // 跳到5格后 } PathGrids.Add(grid); } Debug.Log($棋盘初始化完成共有{PathGrids.Count}个格子。); } /// summary /// 核心方法计算从startGridIndex移动stepCount步后的路径和目标索引。 /// /summary public Listint CalculateMovePath(int startGridIndex, int stepCount) { Listint path new Listint(); int currentIndex startGridIndex; for (int step 0; step stepCount; step) { // 如果已经在终点不再移动 if (PathGrids[currentIndex].Type GridType.Goal) { break; } // 计算下一个索引考虑环路 int nextIndex (currentIndex 1) % PathGrids.Count; // 处理跳跃格逻辑 if (PathGrids[nextIndex].Type GridType.Jump PathGrids[nextIndex].JumpToIndex ! -1) { nextIndex PathGrids[nextIndex].JumpToIndex; } path.Add(nextIndex); currentIndex nextIndex; } return path; // 返回路径上经过的格子索引列表 } // 根据索引获取格子世界坐标 public Vector3 GetGridWorldPosition(int gridIndex) { if (gridIndex 0 gridIndex PathGrids.Count) { return PathGrids[gridIndex].WorldPosition; } return Vector3.zero; } } }4.3 在Unity编辑器中搭建棋盘视觉在场景中创建一个空物体命名为Board并挂载上面的Board脚本。创建另一个空物体作为Grids在其下创建多个空物体按CtrlD复制将它们摆成飞行棋的环形路径。将这些空物体按顺序拖拽到Board脚本的gridTransforms数组里。为不同类型的格子创建Sprite正方形图片并拖到对应的空物体上作为子物体。这样逻辑位置空物体和视觉表现Sprite就分离了。5. 实现游戏核心玩家、棋子与游戏管理器有了棋盘接下来需要让棋子“活”起来。5.1 创建棋子(Piece)控制器Piece是连接数据与表现的桥梁它需要是一个MonoBehaviour。// 文件路径Assets/Scripts/Core/Piece.cs using UnityEngine; using System.Collections; namespace FlightChess.Core { public class Piece : MonoBehaviour { public int PieceId { get; private set; } // 0-3 public Player Owner { get; private set; } public int CurrentGridIndex { get; private set; } -1; // -1表示在基地 public bool IsInBase CurrentGridIndex -1; public bool HasFinished { get; private set; } false; // 移动动画相关 [SerializeField] private float moveDuration 0.3f; private bool isMoving false; public void Initialize(int id, Player owner) { PieceId id; Owner owner; // 可以在这里设置棋子的颜色或Sprite GetComponentSpriteRenderer().color owner.PlayerColor; } public void SetGridIndex(int index) { CurrentGridIndex index; } /// summary /// 移动棋子到目标格子带动画 /// /summary public IEnumerator MoveToGrid(int targetGridIndex, Vector3 targetWorldPos) { if (isMoving || HasFinished) yield break; isMoving true; float elapsedTime 0f; Vector3 startPos transform.position; while (elapsedTime moveDuration) { transform.position Vector3.Lerp(startPos, targetWorldPos, elapsedTime / moveDuration); elapsedTime Time.deltaTime; yield return null; // 等待下一帧 } transform.position targetWorldPos; // 确保精确到达 CurrentGridIndex targetGridIndex; isMoving false; // 移动完成后可以触发事件例如检查是否到达终点 if (GameManager.Instance.Board.PathGrids[targetGridIndex].Type GridType.Goal) { HasFinished true; Debug.Log($棋子{PieceId}到达终点); // 通知GameManager或Player } } } }5.2 创建玩家(Player)数据类Player类主要管理状态和所属棋子。// 文件路径Assets/Scripts/Core/Player.cs using System.Collections.Generic; using UnityEngine; namespace FlightChess.Core { public class Player { public int PlayerId { get; private set; } public Color PlayerColor { get; private set; } public bool IsActive { get; set; } // 是否轮到该玩家 public ListPiece Pieces { get; private set; } new ListPiece(4); public bool AllPiecesFinished Pieces.TrueForAll(p p.HasFinished); public Player(int id, Color color) { PlayerId id; PlayerColor color; } public void AddPiece(Piece piece) { if (Pieces.Count 4) { Pieces.Add(piece); } } // 检查是否有棋子可以移动不在基地且未完成 public bool HasMovablePieces() { return Pieces.Exists(p !p.IsInBase !p.HasFinished); } // 获取所有在基地的棋子 public ListPiece GetPiecesInBase() { return Pieces.FindAll(p p.IsInBase); } } }5.3 创建游戏管理器(GameManager)这是游戏的大脑采用单例模式方便全局访问。// 文件路径Assets/Scripts/Managers/GameManager.cs using System.Collections.Generic; using UnityEngine; namespace FlightChess.Core { public enum GameState { WaitingForRoll, // 等待掷骰子 SelectingPiece, // 选择棋子 MovingPiece, // 棋子移动中 ProcessingGridEvent, // 处理格子事件 GameOver // 游戏结束 } public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public GameState CurrentState { get; private set; } public Board Board { get; private set; } public ListPlayer Players { get; private set; } new ListPlayer(); public int CurrentPlayerIndex { get; private set; } 0; public int LastDiceValue { get; private set; } 0; private void Awake() { // 简单的单例实现确保只有一个实例 if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } InitializeGame(); } private void InitializeGame() { // 1. 查找场景中的Board Board FindObjectOfTypeBoard(); if (Board null) Debug.LogError(场景中未找到Board); // 2. 初始化4名玩家 Players.Clear(); Color[] playerColors { Color.red, Color.blue, Color.green, Color.yellow }; for (int i 0; i 4; i) { Players.Add(new Player(i, playerColors[i])); } // 3. 为每个玩家找到其4个棋子并初始化假设棋子已放在场景中并挂载了Piece脚本 Piece[] allPieces FindObjectsOfTypePiece(); // 这里需要一个方法将棋子与玩家关联例如通过Tag或预设的父子结构。 // 简化处理假设前4个是玩家0的接着4个是玩家1的以此类推。 for (int i 0; i allPieces.Length; i) { int playerId i / 4; if (playerId Players.Count) { allPieces[i].Initialize(i % 4, Players[playerId]); Players[playerId].AddPiece(allPieces[i]); } } // 4. 设置初始状态 CurrentState GameState.WaitingForRoll; Debug.Log(游戏初始化完成等待玩家1掷骰子。); } // 由UI按钮调用 public void OnDiceRolled(int diceValue) { if (CurrentState ! GameState.WaitingForRoll) return; LastDiceValue diceValue; Debug.Log($玩家{CurrentPlayerIndex 1}掷出{diceValue}点。); Player currentPlayer Players[CurrentPlayerIndex]; // 规则判断掷出6点可以从基地起飞一架飞机如果有 if (diceValue 6 currentPlayer.GetPiecesInBase().Count 0) { CurrentState GameState.SelectingPiece; // 这里应该高亮显示可选择的棋子在基地的 Debug.Log(掷出6点可以选择一架飞机起飞或移动已起飞的飞机。); } else if (currentPlayer.HasMovablePieces()) { CurrentState GameState.SelectingPiece; // 高亮显示已起飞且未完成的棋子 Debug.Log(请选择要移动的棋子。); } else { Debug.Log(没有可移动的棋子回合结束。); SwitchToNextPlayer(); } } // 选择棋子后由棋子点击事件调用 public void OnPieceSelected(Piece selectedPiece) { if (CurrentState ! GameState.SelectingPiece) return; Player currentPlayer Players[CurrentPlayerIndex]; // 验证选中的棋子是否属于当前玩家且可移动 if (selectedPiece.Owner ! currentPlayer) return; if (selectedPiece.IsInBase LastDiceValue ! 6) return; // 非6点不能起飞 if (selectedPiece.HasFinished) return; CurrentState GameState.MovingPiece; // 计算移动路径 Listint movePath Board.CalculateMovePath(selectedPiece.CurrentGridIndex, LastDiceValue); // 开始移动协程 StartCoroutine(MovePieceAlongPath(selectedPiece, movePath)); } private System.Collections.IEnumerator MovePieceAlongPath(Piece piece, Listint path) { foreach (int gridIndex in path) { Vector3 targetPos Board.GetGridWorldPosition(gridIndex); yield return StartCoroutine(piece.MoveToGrid(gridIndex, targetPos)); // 每移动一格可以在这里检查碰撞或其他事件简化处理 } // 移动完成处理格子事件 CurrentState GameState.ProcessingGridEvent; ProcessGridEvent(piece.CurrentGridIndex, piece); // 事件处理完后切换到下一个玩家 SwitchToNextPlayer(); } private void ProcessGridEvent(int gridIndex, Piece piece) { Grid grid Board.PathGrids[gridIndex]; switch (grid.Type) { case GridType.Jump: Debug.Log($触发跳跃格跳到{grid.JumpToIndex}); // 这里可以立即触发二次移动 break; case GridType.Pause: Debug.Log(暂停一轮); // 可以给玩家加一个“暂停”状态 break; // ... 处理其他格子类型 } // 处理完后状态回到等待掷骰子 CurrentState GameState.WaitingForRoll; } private void SwitchToNextPlayer() { CurrentPlayerIndex (CurrentPlayerIndex 1) % Players.Count; Debug.Log($轮到玩家{CurrentPlayerIndex 1}的回合。); CurrentState GameState.WaitingForRoll; // 这里应该更新UI显示当前玩家 } } }6. 制作UI与用户交互游戏逻辑通了现在需要让玩家能操作。6.1 创建简单的UI管理器创建一个UIManager脚本来控制UI元素的显示。// 文件路径Assets/Scripts/Managers/UIManager.cs using UnityEngine; using UnityEngine.UI; namespace FlightChess.Core { public class UIManager : MonoBehaviour { public static UIManager Instance { get; private set; } [Header(UI References)] [SerializeField] private Text currentPlayerText; [SerializeField] private Text diceResultText; [SerializeField] private Button rollDiceButton; [SerializeField] private GameObject pieceSelectionPanel; // 选择棋子时的面板 private void Awake() { if (Instance ! null Instance ! this) Destroy(gameObject); else Instance this; } void Start() { // 绑定掷骰子按钮事件 if (rollDiceButton ! null) rollDiceButton.onClick.AddListener(OnRollDiceButtonClicked); UpdateCurrentPlayerUI(0); // 初始玩家 } public void UpdateCurrentPlayerUI(int playerIndex) { if (currentPlayerText ! null) currentPlayerText.text $当前玩家: Player {playerIndex 1}; } public void ShowDiceResult(int value) { if (diceResultText ! null) diceResultText.text $骰子点数: {value}; } public void ShowPieceSelection(bool show) { if (pieceSelectionPanel ! null) pieceSelectionPanel.SetActive(show); } // 模拟掷骰子实际项目可以加入动画 private void OnRollDiceButtonClicked() { int diceValue Random.Range(1, 7); // 生成1-6的随机数 ShowDiceResult(diceValue); // 通知GameManager GameManager.Instance.OnDiceRolled(diceValue); } } }记得在GameManager的SwitchToNextPlayer方法中调用UIManager.Instance.UpdateCurrentPlayerUI(CurrentPlayerIndex);来更新UI。6.2 在Unity中搭建UI创建Canvas。在Canvas下创建Text显示当前玩家创建Button作为“掷骰子”按钮创建另一个Text显示骰子结果。将对应的UI元素拖拽到UIManager脚本的公开字段中。为棋子预制体添加Box Collider 2D和以下脚本以实现点击选择// 文件路径Assets/Scripts/Controllers/PieceClickHandler.cs using UnityEngine; namespace FlightChess.Core { public class PieceClickHandler : MonoBehaviour { private Piece myPiece; void Start() { myPiece GetComponentPiece(); if (myPiece null) { Debug.LogError(PieceClickHandler需要挂载在带有Piece脚本的物体上); } } // 当鼠标点击时需要Camera上有Physics2D Raycaster private void OnMouseDown() { if (myPiece ! null) { GameManager.Instance.OnPieceSelected(myPiece); } } // 注意确保主摄像机有Physics 2D Raycaster组件。 } }7. 运行测试与核心循环验证将所有部分组合起来进行测试。场景组装确保场景中有Board带格子空物体、四个玩家的共16个Piece挂载了Piece和PieceClickHandler脚本、GameManager、UIManager和完整的UI Canvas。运行游戏点击Play按钮。测试流程点击UI上的“掷骰子”按钮观察Console日志和UI点数更新。如果掷出6点尝试点击还在基地的棋子它应该移动到起点格。再次掷骰子点击已起飞的棋子观察它是否沿着棋盘路径移动。测试回合切换UI上的当前玩家信息应随之改变。预期结果你能看到一个基本的、可操作的飞行棋游戏循环。玩家轮流掷骰子选择棋子棋子移动。8. 常见问题与排查思路在实现上述流程时你几乎一定会遇到下面这些问题问题现象可能原因排查方式解决方案点击掷骰子按钮没反应1. 按钮事件未绑定。2.GameManager.Instance为 null。1. 检查UIManager中rollDiceButton.onClick监听。2. 检查GameManager脚本是否被禁用或场景中有多个GameManager。1. 在UIManager.Start()中确认绑定。2. 确保GameManager是单例且唯一。棋子点击无效1.PieceClickHandler脚本未挂载或myPiece为null。2. 摄像机缺少Physics 2D Raycaster组件。3. 棋子Collider被UI遮挡。1. 检查棋子预制体组件。2. 检查主摄像机组件。3. 检查Canvas渲染模式及事件遮挡。1. 确保脚本和依赖组件完整。2. 给主摄像机添加Physics 2D Raycaster。3. 调整UI层级或使用Graphic Raycaster的遮挡设置。棋子移动时瞬移没有动画Piece.MoveToGrid协程未正确启动或moveDuration为0。1. 在GameManager.MovePieceAlongPath中检查StartCoroutine调用。2. 检查moveDuration值。确保使用yield return StartCoroutine(...)来等待移动完成。游戏状态逻辑混乱例如移动中还能掷骰子GameManager的状态(CurrentState)切换逻辑有漏洞或未检查。在OnDiceRolled、OnPieceSelected等方法开头打印当前状态。在所有改变游戏状态的操作入口严格检查CurrentState是否允许该操作。棋盘格子索引错乱Board.InitializeBoard中gridTransforms数组顺序与场景中格子顺序不一致。在InitializeBoard中打印每个格子的索引和位置。在Unity编辑器中仔细排列gridTransforms数组的顺序确保与棋盘路径一致。9. 项目优化与扩展建议一个可玩的Demo完成了但一个完整的项目还需要更多打磨。以下是一些让你项目更上一层楼的建议使用ScriptableObject管理游戏配置将棋盘布局、玩家颜色、格子类型与效果等数据抽离成ScriptableObject资产。这样策划或你自己可以在不修改代码的情况下调整游戏平衡性。实现完整的棋子碰撞与击落规则当棋子A移动到棋子B所在的普通格子时棋子B应被击落回基地。这需要在Piece.MoveToGrid完成后由GameManager检查目标格子上是否有其他玩家的棋子。加入音效与粒子特效为掷骰子、棋子移动、起飞、撞击、胜利等事件添加音效(AudioSource)和简单的粒子系统(ParticleSystem)能极大提升游戏体验。设计更复杂的AI对手实现一个简单的AI例如总是移动最前面的棋子或者优先选择能击落对手的棋子让你可以单人游戏。实现游戏数据存储使用PlayerPrefs或JsonUtility结合文件存储来保存玩家设置、游戏记录或实现存档/读档功能。优化代码结构引入事件中心(EventCenter)来解耦模块通信。例如Piece移动完成时发布一个事件SoundManager和EffectManager监听并播放对应的音效和特效而不是在GameManager里直接调用。制作开始菜单与结束画面使用Unity的Scene Management来切换场景增加游戏流程的完整性。通过这个《飞行棋》项目的上半部分我们系统地实践了从数据建模、架构设计、模块实现到UI集成的完整流程。你不再只是孤立地学习某个Unity组件而是在解决一个真实、连贯的问题。下半部分我们将深入探讨更高级的主题如何用ScriptableObject进行数据驱动设计、如何实现动画状态机让棋子动作更丰富、如何编写一个简单的AI以及如何将项目打包发布。建议你先将当前的部分彻底消化、运行起来并尝试自己实现“碰撞击落”规则这是巩固学习成果的最佳方式。
返回列表