☰
Unity独立开发战棋游戏:从数据建模到AI的完整实现指南
2026/10/6 8:26:43 网站建设 项目流程

简介:这是一款基于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 地图用二维数组还是 Tilemap

Tilemap 负责显示,二维数组负责逻辑,两者用同一套坐标。不要试图从 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 单位与格子的绑定关系

单位不持有格子引用,格子也不持有单位引用,两者通过一个Dictionary<GridPos, UnitData>关联。这样单位移动时只需要改字典的 key,不用去通知格子对象。字典的增删要包一层方法,避免忘记同步。

public class UnitRegistry { private Dictionary<GridPos, UnitData> occupancy = new Dictionary<GridPos, UnitData>(); private List<UnitData> allUnits = new List<UnitData>(); 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 IEnumerable<UnitData> 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 Dictionary<GridPos, int> GetMoveRange(BattleMap map, GridPos start, int moveRange) { var cost = new Dictionary<GridPos, int> { [start] = 0 }; var queue = new Queue<GridPos>(); 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 List<GridPos> FindPath(BattleMap map, GridPos start, GridPos goal, int maxCost) { var open = new PriorityQueue<GridPos, int>(); var gScore = new Dictionary<GridPos, int> { [start] = 0 }; var cameFrom = new Dictionary<GridPos, 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.LoadAssetAtPath<LevelData>(AssetDatabase.GUIDToAssetPath(g)); var occupied = new HashSet<GridPos>(); 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 说明重复。这个校验放在提交前跑一次,比进游戏点半天快得多。

最后说个我自己的习惯:每做完一个系统,先写一个「最小验证场景」,只放这个系统需要的最少对象,跑通了再往主场景里合。战棋的坑大多出在系统之间的耦合上,单独跑没问题,合起来就翻车。这个习惯让我少熬了很多夜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询