ARTICLE DETAIL

资讯详情

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

Unity独立开发战棋游戏:从数据建模到AI的完整实现指南

Unity独立开发战棋游戏:从数据建模到AI的完整实现指南 简介这是一款基于Unity引擎独立开发的小型战棋游戏完整项目源码面向计算机相关专业的在校学生、教师及企业开发者尤其适合作为毕业设计、课程设计、作业或项目初期立项演示的参考素材也适合有一定基础的小白进阶学习。压缩包共约2000个文件整体约135.37MB涵盖30个C#脚本、18个prefab预制体、19个asset资源文件、5个unity场景文件以及大量png贴图、psd源图、mat材质、json配置与xml数据文件完整保留了Unity工程的目录结构与资源组织方式。项目代码均经过测试运行成功后才上传答辩评审平均分达到96分已有322人学习下载。读者可从中获取战棋核心逻辑实现、场景与预制体搭建方式、资源管理思路及完整工程结构也可在现有代码基础上修改扩展实现自定义功能用于毕设、课设或作业等场景。下载后建议先阅读README.md仅供学习参考切勿用于商业用途。1. 从一张 8×8 棋盘说起Unity 独立开发小型战棋到底难在哪很多人第一次动独立战棋的念头都是被《火焰纹章》《陷阵之志》那种「一格一世界」的节奏感勾住的。真上手用 Unity 做才发现难点根本不在美术而在「格子」和「回合」这两件事怎么落到代码里。战棋游戏的核心是离散空间加离散时间地图被切成格子行动被切成回合所有表现层的东西——移动动画、攻击特效、UI 数字滚轮——都得挂在这套离散模型上。独立开发者最容易翻车的地方是把逻辑和表现揉在一起写前期跑得飞快做到第 20 个关卡时发现改一个数值要动五个脚本。这篇笔记按我自己的做法把一款小型战棋从数据建模、地图生成、回合状态机、寻路、战斗结算到打包优化整条链路拆开讲适合已经会 Unity 基础操作、想独立做完一款能上架的小体量战棋的人。全程不依赖任何付费插件用 UGUI 加 Tilemap 就能跑通。2. 战棋的数据建模格子、单位、行动三者怎么解耦2.1 为什么不要用 GameObject 当数据源新手最常见的写法是给每个格子挂一个 Tile 脚本单位挂 Unit 脚本脚本里直接存血量、移动力、坐标。跑起来没问题但一旦要做「悔棋」「AI 推演」「存档读档」就会发现逻辑全散在场景里没法脱离渲染层单独跑。战棋的 AI 需要在不生成任何 GameObject 的前提下模拟几百步走法所以数据必须和表现彻底分开。我一般会建三层纯 C# 数据层不继承 MonoBehaviour、逻辑控制层MonoBehaviour负责调度、表现层动画、特效、UI。数据层用结构体或普通类能被序列化能进存档能被 AI 复制。下面是最小可用的数据结构。// 纯数据层不挂任何 GameObject [System.Serializable] public struct GridPos { public int x, y; public GridPos(int x, int y) { this.x x; this.y y; } public static GridPos operator (GridPos a, GridPos b) new GridPos(a.x b.x, a.y b.y); public override bool Equals(object o) o is GridPos p p.x x p.y y; public override int GetHashCode() x * 397 ^ y; } [System.Serializable] public class UnitData { public int id; public string name; public GridPos pos; public int hp, hpMax; public int atk, def; public int moveRange; // 移动力格子数 public int attackRange; // 攻击距离 public bool hasActed; // 本回合是否已行动 public Faction faction; // 阵营 } public enum Faction { Player, Enemy }这段代码的关键点是GridPos重写了Equals和GetHashCode因为后面寻路要用它当字典的 key。hasActed放在数据层而不是表现层是因为回合重置逻辑要能一次性遍历所有单位改状态不碰任何渲染对象。moveRange和attackRange分开存是因为战棋里「移动后攻击」和「原地攻击」的范围计算方式不同混在一起后面必踩坑。2.2 地图用二维数组还是 TilemapTilemap 负责显示二维数组负责逻辑两者用同一套坐标。不要试图从 Tilemap 反查逻辑Tilemap 的 cell 坐标和你的 GridPos 之间隔着一层转换查多了性能差还容易错位。我的做法是逻辑地图单独存一份TileType[,]Tilemap 只在初始化时按这份数据刷一遍。public enum TileType { Plain, Forest, Mountain, Water } public class BattleMap { public int width, height; public TileType[,] tiles; // 地形消耗进入该格需要多少移动力-1 表示不可通行 public int[,] moveCost; public BattleMap(int w, int h) { width w; height h; tiles new TileType[w, h]; moveCost new int[w, h]; } public bool InBounds(GridPos p) p.x 0 p.x width p.y 0 p.y height; public bool CanEnter(GridPos p) InBounds(p) moveCost[p.x, p.y] 0; }moveCost单独一张表是为了让「森林消耗 2 点移动力」这类规则改起来只动数据不动代码。CanEnter把边界检查和可通行检查合并寻路时调用一次就够。这里有个血泪经验moveCost用 -1 表示不可通行而不是用TileType.Water去判断因为后面你可能想让飞行单位无视水域用消耗表就能表达「对某类单位水域消耗为 1」比在寻路里写 if-else 干净得多。2.3 单位与格子的绑定关系单位不持有格子引用格子也不持有单位引用两者通过一个DictionaryGridPos, UnitData关联。这样单位移动时只需要改字典的 key不用去通知格子对象。字典的增删要包一层方法避免忘记同步。public class UnitRegistry { private DictionaryGridPos, UnitData occupancy new DictionaryGridPos, UnitData(); private ListUnitData allUnits new ListUnitData(); public void Place(UnitData u) { occupancy[u.pos] u; if (!allUnits.Contains(u)) allUnits.Add(u); } public void Move(UnitData u, GridPos target) { occupancy.Remove(u.pos); u.pos target; occupancy[target] u; } public UnitData At(GridPos p) occupancy.TryGetValue(p, out var u) ? u : null; public IEnumerableUnitData All allUnits; }Place里用Contains判断是为了支持「同一单位重复放置」时只加一次列表实际项目里更稳妥的做法是给每个单位一个唯一 id 再用字典存这里为了篇幅从简。Move先删后加的顺序不能反否则目标格已有单位时会被覆盖这个 bug 在混战中特别难查因为表现层看起来只是「两个单位叠在一起」。3. 回合状态机与行动流程把「谁的回合」写清楚3.1 用枚举状态机替代布尔标志战棋的回合流程是玩家回合开始 → 玩家选单位 → 移动 → 攻击/待机 → 下一个单位 → 玩家回合结束 → 敌方回合 → AI 逐个行动 → 回到玩家回合。用一堆bool isPlayerTurn、bool isMoving去控制状态一多就乱。我一般用一个枚举加一个当前状态字段。public enum BattleState { PlayerTurnStart, PlayerSelectUnit, PlayerMoving, PlayerActionMenu, PlayerTurnEnd, EnemyTurnStart, EnemyActing, EnemyTurnEnd, BattleOver } public class BattleController : MonoBehaviour { public BattleState State { get; private set; } private UnitRegistry registry; private BattleMap map; public void ChangeState(BattleState next) { OnExit(State); State next; OnEnter(next); } private void OnEnter(BattleState s) { switch (s) { case BattleState.PlayerTurnStart: ResetActedFlags(Faction.Player); ChangeState(BattleState.PlayerSelectUnit); break; case BattleState.PlayerSelectUnit: // 等待玩家点击单位由输入层调用 SelectUnit break; case BattleState.EnemyTurnStart: ResetActedFlags(Faction.Enemy); ChangeState(BattleState.EnemyActing); break; case BattleState.EnemyActing: StartCoroutine(RunEnemyAI()); break; } } private void OnExit(BattleState s) { // 清理高亮、关闭菜单等 } private void ResetActedFlags(Faction f) { foreach (var u in registry.All) if (u.faction f) u.hasActed false; } }ChangeState里先OnExit再OnEnter保证清理逻辑一定在进入新状态前跑完。PlayerSelectUnit状态本身不做任何事只是「挂起」等输入这是状态机的常见用法把「等待外部事件」也当成一个状态而不是在 Update 里写一堆 if。RunEnemyAI用协程是为了让 AI 行动之间有间隔玩家能看清敌方在干什么直接同步跑完会显得很突兀。3.2 行动流程的完整链路一个单位从被选中到行动结束经过的步骤是固定的把它写成一条链每步只做一件事。public void SelectUnit(UnitData u) { if (State ! BattleState.PlayerSelectUnit) return; if (u.faction ! Faction.Player || u.hasActed) return; selected u; ShowMoveRange(u); // 表现层高亮可移动格 ChangeState(BattleState.PlayerMoving); } public void ConfirmMove(GridPos target) { if (State ! BattleState.PlayerMoving) return; var path Pathfinder.FindPath(map, selected.pos, target, selected.moveRange); if (path null) return; // 不可达 StartCoroutine(AnimateMove(selected, path, () { registry.Move(selected, target); ChangeState(BattleState.PlayerActionMenu); })); } public void ConfirmAction(UnitData target) { if (State ! BattleState.PlayerActionMenu) return; if (target ! null) { int dmg CombatResolver.Resolve(selected, target, map); ApplyDamage(target, dmg); } selected.hasActed true; ClearHighlights(); ChangeState(BattleState.PlayerSelectUnit); }ConfirmMove里先算路径再播动画动画结束后才真正改数据这样表现和逻辑不会不同步。ConfirmAction允许target为 null表示「待机」这是战棋必备的选项很多新手忘了做导致单位移动后必须攻击才能结束行动。hasActed在行动结束后才置位而不是移动后因为移动后还要攻击。3.3 回合切换时的重置与检查回合切换不是简单地把状态改一下还要做三件事重置行动标志、检查胜负条件、刷新 UI。这三件事的顺序有讲究先重置再检查否则上一回合已行动的单位会影响胜负判断。public void EndPlayerTurn() { ChangeState(BattleState.PlayerTurnEnd); if (CheckBattleOver()) { ChangeState(BattleState.BattleOver); return; } ChangeState(BattleState.EnemyTurnStart); } private bool CheckBattleOver() { bool playerAlive false, enemyAlive false; foreach (var u in registry.All) { if (u.hp 0) continue; if (u.faction Faction.Player) playerAlive true; else enemyAlive true; } return !playerAlive || !enemyAlive; }CheckBattleOver遍历所有单位而不是维护计数器是因为单位死亡可能由多种途径触发战斗、地形、剧情计数器容易漏减。单位数量在小型战棋里通常不超过 30 个遍历开销可以忽略。4. 寻路与移动范围A* 在战棋里的正确打开方式4.1 移动范围用 BFS路径用 A*这两个要分开。移动范围是「在移动力限制内能到达的所有格子」用 BFS 按消耗扩散最自然具体走哪条路用 A* 找最短消耗路径。很多人直接用 A* 算范围对每个可达格跑一次格子一多就卡。public static class Pathfinder { // BFS 计算移动范围返回 位置 - 到达消耗 public static DictionaryGridPos, int GetMoveRange(BattleMap map, GridPos start, int moveRange) { var cost new DictionaryGridPos, int { [start] 0 }; var queue new QueueGridPos(); queue.Enqueue(start); var dirs new[] { new GridPos(1,0), new GridPos(-1,0), new GridPos(0,1), new GridPos(0,-1) }; while (queue.Count 0) { var cur queue.Dequeue(); foreach (var d in dirs) { var next cur d; if (!map.CanEnter(next)) continue; int newCost cost[cur] map.moveCost[next.x, next.y]; if (newCost moveRange) continue; if (cost.TryGetValue(next, out int old) old newCost) continue; cost[next] newCost; queue.Enqueue(next); } } return cost; } }cost字典同时充当「已访问」和「最优消耗」两个角色old newCost这个判断保证同一格被更优路径更新时才重新入队。四方向dirs是战棋标准如果你要做八方向注意对角线消耗要单独定义否则会出现「斜着走更省」的怪现象。map.moveCost在CanEnter里已经保证非负这里直接取值即可。4.2 A* 的启发函数与消耗一致性A* 的启发函数必须和实际消耗同量纲。如果地形消耗是 1 或 2启发函数用曼哈顿距离乘 1 是 admissible 的不会高估。用欧几里得距离也行但没必要战棋是四方向移动曼哈顿更贴切。public static ListGridPos FindPath(BattleMap map, GridPos start, GridPos goal, int maxCost) { var open new PriorityQueueGridPos, int(); var gScore new DictionaryGridPos, int { [start] 0 }; var cameFrom new DictionaryGridPos, GridPos(); open.Enqueue(start, 0); var dirs new[] { new GridPos(1,0), new GridPos(-1,0), new GridPos(0,1), new GridPos(0,-1) }; while (open.Count 0) { var cur open.Dequeue(); if (cur.Equals(goal)) return Reconstruct(cameFrom, cur); foreach (var d in dirs) { var next cur d; if (!map.CanEnter(next)) continue; int tentative gScore[cur] map.moveCost[next.x, next.y]; if (tentative maxCost) continue; if (gScore.TryGetValue(next, out int old) tentative old) continue; gScore[next] tentative; cameFrom[next] cur; int f tentative Heuristic(next, goal); open.Enqueue(next, f); } } return null; // 不可达 } private static int Heuristic(GridPos a, GridPos b) Mathf.Abs(a.x - b.x) Mathf.Abs(a.y - b.y);maxCost参数让 A* 和移动范围共享同一个上限避免出现「范围显示能到但寻路说不可达」的矛盾。PriorityQueue是 .NET 的泛型优先队列Unity 2021 以后可用老版本自己用 List 排序也行。Reconstruct从cameFrom回溯注意返回的路径包含起点动画播放时要跳过第一个点。4.3 把范围高亮和寻路结果对齐表现层高亮哪些格子必须和GetMoveRange返回的 key 完全一致。我见过有人高亮用Vector3.Distance算逻辑用 BFS结果地形一复杂就对不上。正确做法是逻辑算完把Dictionary的 key 集合传给表现层表现层只负责染色。public void ShowMoveRange(UnitData u) { var range Pathfinder.GetMoveRange(map, u.pos, u.moveRange); highlightLayer.Clear(); foreach (var pos in range.Keys) { if (pos.Equals(u.pos)) continue; if (registry.At(pos) ! null) continue; // 有单位占位不高亮 highlightLayer.Paint(pos, Color.blue); } }registry.At(pos) ! null这行是必须的否则高亮会盖住友方单位玩家点上去发现走不了体验很差。highlightLayer是一个独立的 Tilemap 或 Sprite 层和地形层分开清理时只清自己这层。5. 战斗结算与 AI让数值和决策都能被验证5.1 伤害公式要可复现、可单测战棋的伤害公式最忌讳写成一大坨内联表达式。我一般抽成一个纯静态方法输入单位、目标、地形输出伤害值不碰任何 Unity API这样能直接在 EditMode 测试里跑。public static class CombatResolver { public static int Resolve(UnitData attacker, UnitData defender, BattleMap map) { int baseDmg attacker.atk - defender.def; if (baseDmg 1) baseDmg 1; // 保底伤害 float terrainMod GetTerrainMod(map, defender.pos); int final Mathf.RoundToInt(baseDmg * terrainMod); return Mathf.Max(1, final); } private static float GetTerrainMod(BattleMap map, GridPos p) { switch (map.tiles[p.x, p.y]) { case TileType.Forest: return 0.8f; // 森林减伤 20% case TileType.Mountain: return 0.7f; default: return 1.0f; } } }baseDmg保底 1 是为了避免高防单位完全免伤导致战斗卡死。地形减伤用乘法而不是减法方便叠加多种修正。这个公式简单到能口算好处是策划改数值时不用问程序坏处是策略深度有限进阶可以加武器克制、背击加成但都要保持「纯函数」这个性质。5.2 敌方 AI 的决策顺序小型战棋的 AI 不需要行为树一个「评估所有可行行动选评分最高的」就够。评分函数决定 AI 像不像人。private IEnumerator RunEnemyAI() { foreach (var enemy in registry.All.Where(u u.faction Faction.Enemy u.hp 0).ToList()) { if (enemy.hasActed) continue; var best EvaluateBestAction(enemy); if (best.target ! null) { yield return AnimateMove(enemy, best.path); registry.Move(enemy, best.dest); ApplyDamage(best.target, CombatResolver.Resolve(enemy, best.target, map)); } else if (best.path ! null) { yield return AnimateMove(enemy, best.path); registry.Move(enemy, best.dest); } enemy.hasActed true; yield return new WaitForSeconds(0.3f); } ChangeState(BattleState.EnemyTurnEnd); ChangeState(BattleState.PlayerTurnStart); }EvaluateBestAction遍历移动范围内每个可达格对每个格再遍历攻击范围内每个敌人算一个分数能击杀得 100 分能造成伤害得伤害值分离最近敌人越近得越高分。选最高分的行动。ToList()是必须的因为 AI 行动过程中可能改变集合直接遍历会抛异常。WaitForSeconds给玩家反应时间数值 0.3 到 0.5 之间比较舒服。5.3 用日志验证 AI 决策AI 出问题时最难查的是「它为什么这么走」。我的习惯是在EvaluateBestAction里把每个候选行动的分数打到 Console用条件编译包起来发布时关掉。[System.Diagnostics.Conditional(AI_DEBUG)] private static void LogAction(string unit, GridPos dest, UnitData target, int score) { Debug.Log($[AI] {unit} - {dest.x},{dest.y} target{target?.name ?? none} score{score}); }Conditional特性让这个方法在没定义AI_DEBUG宏时整个调用被编译器移除零开销。排查 AI 时在 Player Settings 里加上这个宏就能看到完整的决策日志。这比打断点高效因为 AI 是协程断点会打乱时序。6. 避坑与排查独立战棋最容易翻车的 5 个地方6.1 单位移动后坐标和表现不同步现象单位动画走到目标格但逻辑坐标还停在原地下一次移动从旧位置算路径出现「瞬移」或「走回头路」。原因动画协程和registry.Move的执行顺序写反了或者动画被打断时没有回调。解决把registry.Move放在动画结束的回调里并且给移动协程加一个「被打断就立即同步坐标」的兜底。我一般会在AnimateMove里用try/finally保证回调一定执行。6.2 寻路把友方单位当障碍导致范围缩水现象移动范围高亮比预期小明明有路却走不过去。原因GetMoveRange里没有排除「目标格有友方单位」的情况BFS 把友方占位格也当成不可进入。解决BFS 扩散时允许穿过友方单位但高亮和最终落点要排除有单位的格子。也就是CanEnter只管地形单位占位在表现层和ConfirmMove里单独判断。6.3 回合结束条件在单位死亡动画前触发现象最后一个敌人被打死战斗立即结束死亡动画没播完就切场景。原因ApplyDamage里血量归零后直接调用了CheckBattleOver。解决把胜负检查延迟到死亡动画结束的回调里或者用一个「待结算死亡」队列每帧处理一个。小型战棋用协程延迟 0.5 秒最简单。6.4 UGUI 数字滚轮在快速连续扣血时跳变现象血量 UI 用数字滚轮效果连续受击时数字乱跳或停在中间值。原因多个协程同时改同一个 Text互相覆盖。解决给每个单位的血条 UI 维护一个「目标值」只允许一个协程在跑新伤害来了就更新目标值协程每帧向目标值逼近。这是热搜里「unity中实现ui数字滚轮效果」的典型坑战棋里尤其常见。6.5 打包到手机后画面拉伸、点击错位现象编辑器里正常打包到手机后 UI 拉伸点击格子和实际格子对不上。原因Canvas 的CanvasScaler没设对或者摄像机正交尺寸和屏幕比例不匹配。解决CanvasScaler用Scale With Screen Size参考分辨率设成你设计时的分辨率摄像机用固定正交尺寸加Letterbox保证棋盘始终完整可见。点击坐标转换用Camera.ScreenToWorldPoint后再转格子坐标不要用屏幕坐标直接算。7. 进阶技巧用 ScriptableObject 把关卡数据外置做到第 10 关以后你会发现关卡数据写在代码里改起来很痛苦。我的做法是把每关的地图、单位配置、胜负条件做成 ScriptableObject策划或你自己在 Inspector 里填代码只读不写。[CreateAssetMenu(fileName Level_, menuName Tactics/LevelData)] public class LevelData : ScriptableObject { public int width 8, height 8; public TileType[] tiles; // 长度 width*height按行优先 public UnitSpawn[] playerUnits; public UnitSpawn[] enemyUnits; public WinCondition winCondition; } [System.Serializable] public struct UnitSpawn { public string unitName; public int x, y; public int hp, atk, def, moveRange, attackRange; }tiles用一维数组是因为 Unity 不序列化二维数组读的时候用tiles[y * width x]转。UnitSpawn不直接引用UnitData因为UnitData是运行时状态存档和配置要分开。加载关卡时把UnitSpawn转成UnitData再registry.Place。验证关卡数据对不对我一般写一个 Editor 菜单项遍历所有LevelData资源检查单位坐标是否越界、是否重叠、胜负条件是否可达。这个检查脚本不到 50 行但能省下大量「进游戏才发现单位卡墙里」的时间。[MenuItem(Tactics/Validate All Levels)] static void ValidateAll() { var guids AssetDatabase.FindAssets(t:LevelData); foreach (var g in guids) { var level AssetDatabase.LoadAssetAtPathLevelData(AssetDatabase.GUIDToAssetPath(g)); var occupied new HashSetGridPos(); foreach (var u in level.playerUnits.Concat(level.enemyUnits)) { var p new GridPos(u.x, u.y); if (u.x 0 || u.x level.width || u.y 0 || u.y level.height) Debug.LogError(${level.name}: 单位 {u.unitName} 越界 ({u.x},{u.y})); if (!occupied.Add(p)) Debug.LogError(${level.name}: 坐标 ({u.x},{u.y}) 有单位重叠); } } Debug.Log(关卡校验完成); }AssetDatabase.FindAssets按类型找资源occupied.Add返回 false 说明重复。这个校验放在提交前跑一次比进游戏点半天快得多。最后说个我自己的习惯每做完一个系统先写一个「最小验证场景」只放这个系统需要的最少对象跑通了再往主场景里合。战棋的坑大多出在系统之间的耦合上单独跑没问题合起来就翻车。这个习惯让我少熬了很多夜。希望帮到你。本文还有配套的精品资源点击获取
返回列表