简介这是一份基于Unity独立开发的小型战棋游戏完整项目适合Unity初学者、进阶学习者以及需要完成毕设、课程设计、大作业或工程实训的开发者。项目涵盖地图网格、棋子移动、战斗判定等战棋核心玩法代码结构清晰可直接运行和二次开发。资源共2000个文件压缩包大小137.28MB包含618个info、323个meta、206个png、38个json、30个cs、28个bin、25个md、21个xml、19个asset、18个prefab等类型其中cs为C#游戏脚本prefab与asset为预制体和资源配置png/jpg/hdr为美术素材md与xml/json为说明和配置文档整体目录完整便于按模块查阅。已有703人学习适合作为入门实践或项目参考。通过该资源读者可以获得从场景搭建、逻辑编写到资源管理的全流程示例快速理解Unity战棋游戏的实现思路并基于现有框架扩展自己的功能。1. 战棋游戏的核心逻辑与Unity选型独立开发战棋游戏首当其冲的不是美术资源而是「格子化」这一底层思维。战棋的本质是把连续空间离散成网格所有移动、攻击、技能范围都基于网格坐标计算而不是物理引擎的碰撞体。我在一开始就放弃了对战棋游戏来说过于厚重的NavMesh寻路方案转而自己维护一套基于格子的地图数据原因很直接战棋的寻路结果是路径序列而不是移动轨迹Unity自带寻路是给实时动作游戏用的强行嫁接会让回合制手感变得不伦不类。Unity在小型战棋项目里最合适的定位是「渲染壳 输入中转站」。地图数据用纯C#类维护不挂任何MonoBehaviour单位实体只负责表现动画和播放反馈战斗结算、AI决策放进独立的服务层。这样做的好处是后期无论换Tilemap还是换模型逻辑层都不用动。这个项目适合两类人一是刚学Unity想完整走通一个游戏闭环的初学者二是做过动作游戏想理解回合制系统如何组织状态的进阶开发者。接下来我按一条完整的开发链路把网格建模、行动机制、战斗结算和AI逐层拆开。2. 战棋地图的网格建模与坐标转换2.1 为什么不用Unity Tilemap而是自定义网格类很多教程会引导你用Tilemap做战棋地图但实际做下来会发现Tilemap更偏向「绘制地形」而不是「承载规则」。战棋需要知道某个格子是否可行走、是否被占据、移动代价是多少这些数据如果散落在Tilemap的Tile资产上维护起来很痛苦。我采用的是最朴素的方式二维数组存储地图数据坐标与显示位置之间做数学映射。地图类我定义为纯C#的GridMap不继承MonoBehaviour方便单元测试和序列化存储。核心字段是CellType[,] cells以及一个DictionaryVector2Int, Unit unitMap前者记录地形类型后者记录哪个单位站在哪个格子上。这样的分离让你能随时知道「这个格子能走吗」——查数组即可不需要向Unity发送任何请求。public enum CellType { Plain, // 平地 Obstacle,// 障碍 Forest, // 森林移动代价2防御1 Hill // 山丘移动代价3远程命中-10% } public class GridMap { public int Width { get; private set; } public int Height { get; private set; } private readonly CellType[,] cells; private readonly DictionaryVector2Int, Unit unitMap new(); public GridMap(int width, int height) { Width width; Height height; cells new CellType[width, height]; // 默认全部平地 for (int x 0; x width; x) for (int y 0; y height; y) cells[x, y] CellType.Plain; } public bool IsWalkable(Vector2Int pos) { if (!IsInside(pos)) return false; if (cells[pos.x, pos.y] CellType.Obstacle) return false; return !unitMap.ContainsKey(pos); // 有单位占位也不可走 } public bool IsInside(Vector2Int pos) { return pos.x 0 pos.x Width pos.y 0 pos.y Height; } }这段代码的逻辑很清楚IsWalkable同时判断边界、地形和单位占用。注意DictionaryVector2Int, Unit比在单位身上存坐标更好用因为查询占用关系是O(1)的。Vector2Int直接作为字典键没问题Unity已经实现了它的哈希和相等比较。2.2 世界坐标与网格坐标的互转网格坐标(x, y)与Unity世界坐标的对应关系我习惯用「中心对齐」的方式每个格子的边长为cellSize世界坐标worldPos (x * cellSize, y * cellSize)这样格子中心点恰好是整数坐标乘以边长。注意不要用Vector2直接相乘后取整浮点误差会导致边界处的坐标抖动。public static class GridConverter { public static float CellSize 1f; // 调整这个值可以控制格子视觉大小 public static Vector3 GridToWorld(Vector2Int gridPos) { return new Vector3(gridPos.x * CellSize, 0, gridPos.y * CellSize); } public static Vector2Int WorldToGrid(Vector3 worldPos) { int x Mathf.FloorToInt(worldPos.x / CellSize); int y Mathf.FloorToInt(worldPos.z / CellSize); return new Vector2Int(x, y); } }这里的关键是FloorToInt而不是RoundToInt。鼠标点击时只要点落在某个格子范围内就应该归入左下角的格子。如果使用四舍五入点在世界坐标1.6的位置会归入格子2但视觉上它还在格子1内这会导致点击错位。FloorToInt配合非负坐标就能完美对应。如果你的地图允许负坐标需要预先做偏移把坐标平移到非负区间再转换。2.3 地图编辑器的可视化调试写一个编辑器脚本能让地图数据在Inspector中预览否则难以核对地形配置。这里用到OnDrawGizmos在Scene视图绘制网格边框public class GridDebugger : MonoBehaviour { public GridMap map; public Color walkableColor Color.green; public Color obstacleColor Color.red; private void OnDrawGizmos() { if (map null) return; for (int x 0; x map.Width; x) { for (int y 0; y map.Height; y) { var pos GridConverter.GridToWorld(new Vector2Int(x, y)); // 画一个半透明方块表示格子状态 Gizmos.color map.IsWalkable(new Vector2Int(x, y)) ? walkableColor : obstacleColor; Gizmos.DrawCube(pos Vector3.up * 0.01f, new Vector3(GridConverter.CellSize * 0.9f, 0.02f, GridConverter.CellSize * 0.9f)); } } } }OnDrawGizmos只在编辑器Scene视图生效不打进发布包适合调试。注意DrawCube的高度设置0.02避免与地面重叠产生Z-fighting。你可以在这个基础上加上移动代价的文本显示用Handles.Label在格子中心输出数字排查路径算法时非常直观。3. 回合制行动机制与移动攻击范围计算3.1 状态机驱动的回合流程战棋的核心循环是「己方回合 → 行动 → 结束 → 敌方回合」。每一步都有明确的合法状态不能用简单的bool标记。我定义了一个枚举TurnPhase包括Idle、Selected、Moving、Acting、EnemyTurn、GameOver。只有在Idle状态选择己方单位才会进入SelectedSelected状态下点击空地且路径可达则进入Moving移动完成后进入Acting此时可以选择攻击或使用技能攻击结束回到Idle。public enum TurnPhase { Idle, // 待机可选中己方单位 Selected, // 已选中单位等待移动或攻击指令 Moving, // 单位正在播放移动动画 Acting, // 已移动完毕可攻击/待命 EnemyTurn, // 敌方AI行动中 GameOver // 游戏结束 }状态机的好处在于防止连点导致逻辑错乱。比如玩家在Moving阶段连点其他单位如果没有状态保护就会切换选中目标导致正在移动的单位被「抢走」。我的做法是只有Idle才允许重新选中其余状态下任何点击事件都先通过TryGetAction判断合法性。3.2 移动范围计算BFS比A*更适合做可移动区域计算一个单位可以走到哪些格子本质是求「在移动力限制下的可达区域」用广度优先搜索BFS最合适而不是A*。因为A*是求「两点间最短路径」而移动范围要求列出所有满足代价约束的格子BFS逐层扩展天然能做到。如果地图上存在不同的移动代价森林2、山丘3需要在BFS中使用优先队列来保证每个格子第一次出队时就是最小代价。public class Pathfinder { private static readonly Vector2Int[] Dir4 { new(1, 0), new(-1, 0), new(0, 1), new(0, -1) }; public static DictionaryVector2Int, int GetReachableCells(GridMap map, Vector2Int start, int movePoints) { var cost new DictionaryVector2Int, int(); var pq new PriorityQueueVector2Int, int(); cost[start] 0; pq.Enqueue(start, 0); while (pq.Count 0) { var cur pq.Dequeue(); foreach (var dir in Dir4) { var next cur dir; if (!map.IsWalkable(next)) continue; int terrainCost GetTerrainCost(map.GetCellType(next)); int newCost cost[cur] terrainCost; if (newCost movePoints) continue; if (!cost.ContainsKey(next) || newCost cost[next]) { cost[next] newCost; pq.Enqueue(next, newCost); } } } return cost; } private static int GetTerrainCost(CellType type) type switch { CellType.Plain 1, CellType.Forest 2, CellType.Hill 3, _ int.MaxValue }; }这里用了Unity 2022内置的PriorityQueueTElement, TPriority在System.Collections.Generic命名空间下不需要引入第三方库。cost字典记录每个可达格子的累计移动消耗。注意GetTerrainCost对障碍返回int.MaxValue这样即使某格子意外成为可通行也会因为代价溢出而被排除。3.3 攻击范围与技能特效的独立计算攻击范围不依赖移动路径而是基于单位位置向外辐射的曼哈顿距离。比如近战攻击距离1就是上下左右四格弓箭手攻击距离2~3需要排除距离太近的格子。我通常会写一个GetAttackRange方法返回一组合法的目标网格坐标。这个方法不关心目标是否存在单位只返回「这些格子可以被攻击」真正的攻击逻辑在PerformAttack中再检查目标单位。public static ListVector2Int GetAttackRange(GridMap map, Vector2Int attackerPos, int minRange, int maxRange) { var result new ListVector2Int(); for (int dx -maxRange; dx maxRange; dx) { for (int dy -maxRange; dy maxRange; dy) { int dist Mathf.Abs(dx) Mathf.Abs(dy); if (dist minRange || dist maxRange) continue; var target attackerPos new Vector2Int(dx, dy); if (map.IsInside(target)) result.Add(target); } } return result; }minRange的设计很关键。近战单位如果设minRange1攻击范围就是紧邻的十字远程单位设minRange2可以避免「贴身时射箭」这种反直觉行为。参数maxRange不要设得太大战棋地图通常10x10超过4的射程会破坏平衡性。在实际项目中这些范围参数一般从单位的ScriptableObject配置里读取而不是硬编码在方法里。4. 战斗结算与AI决策的实现4.1 伤害公式的设计与平衡参数伤害公式直接决定游戏手感我推荐用「攻防差 浮动系数」的结构而不是简单的攻击力 - 防御力。后者会导致攻击力和防御力接近时伤害极不稳定或者后期数值膨胀。我采用的公式如下伤害 max(1, (攻击力 * (1 - 免伤率) - 防御力) * 浮动系数 固定修正)浮动系数取0.9到1.1的随机值固定修正可以是武器精通值。免伤率来自地形效果比如森林提供10%免伤。这样设计的好处是即使攻击力低于防御力也至少造成1点伤害避免战斗陷入无限回合。在Unity中实现时我会把战斗结算写成纯静态方法方便写单元测试和调整数值。public static class BattleCalculator { public static int CalculateDamage(Unit attacker, Unit defender, CellType defenderTerrain) { float terrainReduction defenderTerrain switch { CellType.Forest 0.10f, CellType.Hill 0.15f, _ 0f }; float attackPower attacker.Attack * (1f - terrainReduction); float defenseValue defender.Defense; float randomFactor Random.Range(0.9f, 1.1f); int rawDamage Mathf.FloorToInt((attackPower - defenseValue) * randomFactor attacker.FixedBonus); return Mathf.Max(1, rawDamage); } }attacker.FixedBonus用于体现武器间差异例如「破甲剑」固定加5点伤害不参与免伤计算这样即使面对高防御目标也不会被完全抵消。表格参数参考属性取值说明攻击力20~80随等级成长防御力5~30抵消攻击力免伤率0~15%来自地形固定修正0~10来自武器/技能浮动系数0.9~1.1随机4.2 命中率与暴击的伪随机处理很多战棋新手直接用Random.Range判命中结果就是玩家SL大法刷命中率体验极差。我建议使用「伪随机分布」Pseudo Random Distribution每次未命中时增加命中概率保证长期命中率接近面板值同时避免连续Miss。常见做法是设定一个初始概率未命中后概率递增。public class HitChanceSystem { private float increment; private float currentProb; public HitChanceSystem(float baseChance) { // 将显示概率转换为递增概率这是一个近似的查表转换 increment 1f - Mathf.Pow(1f - baseChance, 0.3f); currentProb baseChance * increment; } public bool Roll() { bool hit Random.value currentProb; if (hit) currentProb baseChance * increment; else currentProb increment; return hit; } }这个系统的参数调整比较复杂如果你不想引入额外复杂度仍然可以用普通随机但要在UI上显示真实命中率。注意命中率过高时90%伪随机系统的体感会和普通随机区别不大所以只对中低概率生效即可。4.3 AI决策评分函数与简单搜索理想AI不应该只是「走到最近目标面前平砍」。我实现的AI分两步第一步枚举所有敌方单位可执行的行动组合移动若干格后攻击谁第二步对每个组合计算一个行动评分选择最高分执行。行动评分公式如下评分 攻击收益 * 1.0 目标剩余血量权重 * 0.8 自身生存收益 * 0.5 - 移动消耗 * 0.1攻击收益不只看伤害值还要考虑能否击杀。自己即将死亡时优先保命。为了让AI可玩性更高我还会给AI加一个「恐惧距离」当目标单位血量低于30%AI会倾向集火而不是分散攻击。评分函数必须写成纯函数输入是当前地图状态快照输出是分数方便未来做MCTS等高级算法。public class AIEvaluator { public static float Evaluate(AIAction action, GameState state) { float score 0f; if (action.Damage action.Defender.Hp) { score 100f; // 击杀奖励 score (action.Defender.Hp / (float)action.Defender.MaxHp) * 50f; // 优先处理残血 } else { score action.Damage * 1.0f; } score - action.MoveCost * 0.1f; return score; } }这里AIAction是一个结构体包含攻击者和目标位置、伤害预测、移动路径等。实际运行时用GetReachableCells获取移动范围再对每个可达格调用GetAttackRange组合成所有可能行动。注意AI不要每帧计算应该在一帧内完成所有决策后立即执行否则卡顿明显。5. Unity项目打包与性能优化技巧5.1 避免每帧的隐式分配战棋系统里最容易踩的性能坑是频繁创建临时Vector2Int列表。比如前述GetReachableCells每次调用都会创建一个Dictionary和优先队列如果玩家每点击一次就调用一次GC压力并不大但AI决策时每个单位都要调用多次累计起来就很可观。常见优化手段复用字典容器、用ArrayPool管理临时列表、缓存单位坐标。private readonly DictionaryVector2Int, int cachedCost new(); private readonly ListVector2Int cachedResult new(); public ListVector2Int GetReachableReusable(GridMap map, Vector2Int start, int movePoints) { cachedCost.Clear(); cachedResult.Clear(); // BFS 逻辑同前但复用 cachedCost // ... foreach (var kv in cachedCost) cachedResult.Add(kv.Key); return cachedResult; }注意缓存容器只适合单线程逻辑。如果以后引入多线程寻路需要为每线程准备独立缓存。5.2 UI事件与回合状态同步回合制游戏的UI状态必须跟随TurnPhase变化。我在TurnManager上设置public event ActionTurnPhase OnPhaseChanged所有UI元素订阅这个事件而不是在Update里轮询。例如移动范围高亮组件在Accessable阶段才绘制网格高亮否则关闭节点。这样也方便做打断操作当玩家点击敌人时若当前处于Acting且目标在攻击范围内触发攻击动画攻击结束再恢复Idle。5.3 WebGL打包注意事项如果你想把战棋游戏发布到Web端有几件事必须在编辑器里提前检查。第一Player Settings的Other Settings中启用Auto Graphics API去掉Vulkan只保留WebGL2第二代码中不要使用File.Exists或System.IOWebGL环境没有文件系统存档需改用PlayerPrefs或IndexedDB插件第三纹理压缩格式选择ASTC优先否则移动端浏览器会加载很慢。常见崩溃现象是打包后场景白屏多半是脚本里用了Application.persistentDataPath这个API在WebGL下可以调用但返回的是内存路径写入数据会丢失。5.4 使用Unity Profiler定位回合卡顿当敌人AI回合出现明显卡顿先打开Profiler的CPU Usage模块在时间轴上找到红色的GC Alloc峰值。如果是PriorityQueue频繁入队导致考虑改用手写二叉堆如果是Unit.GetComponent被频繁调用那就属于典型的「每帧访问组件」坏习惯。一个有效技巧是在单位初始化时把组件引用缓存进DictionaryGameObject, Unit避免运行时查找。public class UnitRegistry : MonoBehaviour { private readonly DictionaryGameObject, Unit units new(); public void Register(Unit unit) { units[unit.gameObject] unit; } public bool TryGetUnit(GameObject go, out Unit unit) { return units.TryGetValue(go, out unit); } }把单位查找从GetComponent变为字典查询在单位数量超过30个时收益明显。另外移动动画播放期间逻辑层已经计算完后续操作尽量让动画进程与逻辑进程解耦——单位实际移动的Vector3插值放在协程里不要在动画事件中调用战斗逻辑否则会与输入状态机产生竞态。完成这些改造后战棋项目的核心框架就稳定了后续加技能、加道具、加多地图都是在这个骨架上的填充。本文还有配套的精品资源点击获取