简介:一份Unity独立开发的小型战棋游戏完整项目源码,主要面向游戏开发学习者、Unity初学者及计算机相关专业学生,适用于课程设计、毕业设计或项目立项演示,可帮助读者快速上手Unity战棋类项目的整体开发流程。项目实现了战棋核心玩法与界面逻辑,作为个人毕设代码经过完整运行测试,答辩评审平均分达96分,整体可靠性较好。压缩包共2000个文件、约135MB,以C#脚本、Prefab预制体、PNG美术资源、Asset资源文件及JSON配置为主,并附有Markdown说明文档,包含完整的Unity工程目录结构,便于按脚本、素材、配置分层查阅。目前已有321人学习下载。压缩包内项目支持远程教学,遇到运行问题可与作者直接沟通;学习者可借源码理解单位移动、战斗判定、回合流程等模块,也可在其基础上扩展功能完成课设或毕设。
1. 为什么选择网格数据驱动的地图结构
做战棋游戏的第一件事不是摆 UI,而是想清楚地图怎么存。Unity 独立开发的小型战棋项目,把整张地图压缩成一串紧凑的网格编号,运行时再按固定宽度切分解析,这样做的好处是关卡数据可以直接粘贴进代码或文本文件,不需要可视化编辑器就能快速预览,也方便后端下发关卡配置。整套玩法本质上是「网格坐标 + 回合状态机 + 数值公式」的确定性系统,这三个点能跑通,游戏就立得住。这个项目适合拿来做毕设参考、课设改造,或者想快速验证 SRPG 玩法的开发者。下面从数据解析开始,把地图、寻路、回合、战斗这条线完整拆开。
2. 网格地图的数据解析、Tilemap 坐标换算与 A* 寻路
战棋地图的第一层是数据层。项目正文里那种长字符串不是乱码,而是按行压缩的网格编号,数字代表地块类型,其他位是填充用的补零。解析方式是按固定长度切分,再转成二维数组。下面是一段把字符串解析成地图数据的 C# 函数。
public static int[,] ParseMapData(string raw, int rows, int cols) { int[,] grid = new int[rows, cols]; int cell = 0; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { int startIdx = r * cols * 2 + c * 2; string slice = raw.Substring(startIdx, 2); grid[r, c] = int.Parse(slice); cell++; } } return grid; }这段逻辑的关键是按r * cols * 2定位行首,因为每个格子占两个字符,Substring取出两位十六进制数再转成int。数字编号的含义建议单独维护一张映射表:0是空地、1是出生点、6/7是障碍物或高地。解析完成后,下一步是把逻辑网格画到 Unity 的 Tilemap 上。
2.1 Tilemap 坐标与网格坐标的换算
Unity Tilemap 用的是 Cell 坐标,世界坐标和格子坐标之间必须做一次换算才能让鼠标点击和单位移动对上,直接拿Camera.ScreenToWorldPoint的结果去查字典通常会因为浮点误差踩坑。
public Vector2Int WorldToCell(Vector3 worldPos) { Vector3Int cell = tilemap.WorldToCell(worldPos); return new Vector2Int(cell.x, cell.y); } public Vector3 CellToWorldCenter(Vector2Int cell) { Vector3 pos = tilemap.GetCellCenterWorld(new Vector3Int(cell.x, cell.y, 0)); return pos; }WorldToCell用的是 Unity 内置 Tilemap 组件,它会把世界坐标向下取整到格子中心;GetCellCenterWorld返回的是某格子的世界坐标中心点,单位移动时直接用这个坐标做 Lerp 或 MoveTowards,视觉上永远不会偏移。还有个容易忽略的细节:如果 Tilemap 挂了TilemapRenderer,排序轴要改成Custom Axis,否则单位 Sprite 会跟地块图层相互遮盖,这是我第一次跑项目遇到的最烦人的问题。
2.2 A* 寻路与地形代价
战棋寻路和普通 RTS 寻路不一样,它要求的是「从起点到终点之间所有可达格子的集合」,而不仅仅是单条路径。常见做法是在 A* 基础上做一次 Flood Fill 式的扩展:把移动范围内的可达格全部标记出来,再对目标格执行回溯。这里给出适合战棋项目的 A* 核心实现,使用曼哈顿距离作为启发函数。
public List<Vector2Int> AStar(int[,] grid, Vector2Int start, Vector2Int goal, Dictionary<int, float> moveCost) { int rows = grid.GetLength(0); int cols = grid.GetLength(1); PriorityQueue<Vector2Int> open = new PriorityQueue<Vector2Int>(); Dictionary<Vector2Int, float> gScore = new Dictionary<Vector2Int, float>(); Dictionary<Vector2Int, Vector2Int> cameFrom = new Dictionary<Vector2Int, Vector2Int>(); gScore[start] = 0f; open.Enqueue(start, 0f); while (open.Count > 0) { Vector2Int current = open.Dequeue(); if (current == goal) break; foreach (var dir in FourDirections) { Vector2Int next = current + dir; if (next.x < 0 || next.y < 0 || next.x >= cols || next.y >= rows) continue; if (!moveCost.TryGetValue(grid[next.y, next.x], out float cost)) continue; float tentative = gScore[current] + cost; if (tentative < gScore.GetValueOrDefault(next, float.MaxValue)) { cameFrom[next] = current; gScore[next] = tentative; float f = tentative + Manhattan(next, goal); open.Enqueue(next, f); } } } return ReconstructPath(cameFrom, start, goal); }注意open队列的优先级用的是f = g + h,其中h是曼哈顿距离,适合四方向移动。地形代价表由moveCost字典传入,我一般这么配:平原1.0、林地2.0、山地不可通过(不加入字典即可)、村庄1.2。这套写法的好处是寻路和移动范围共用一套代价表,敌人 AI 评估防守地形时也能直接复用。如果是八方向移动,启发函数要换成对角距离,否则会走出不自然的折线。
这章最后一个是边界问题:如果目标点不可达,ReconstructPath要返回空列表而不是报错,UI 层拿到空列表后直接禁用移动按钮。网格行列数很多的关卡,建议把PriorityQueue换成二叉堆实现,系统自带的SortedSet在频繁插入时会因为比较器分配导致 GC 压力偏大。
3. 回合状态机、行动顺序与轻量 AI 的落地写法
战棋的核心循环是「选择单位 → 选择行动 → 选择目标 → 执行结果 → 切换阵营」。很多人起步时直接在主循环里写 Update 判断,一个单位行动没结束、另一个单位已经开始播放动画,状态全乱掉。正确做法是把单位行动和回合切换拆成独立的阶段,用枚举状态机收拢。
3.1 回合阶段状态机的骨架
public enum Phase { PlayerSelectUnit, PlayerSelectAction, PlayerSelectTarget, PreviewAction, ExecuteAction, EnemyTurn, TurnEnd } public class TurnSystem : MonoBehaviour { public Phase CurrentPhase { get; private set; } public void ChangePhase(Phase next) { CurrentPhase = next; OnPhaseEnter(next); } private void OnPhaseEnter(Phase phase) { switch (phase) { case Phase.PlayerSelectUnit: StartCoroutine(WaitForUnitPick()); break; case Phase.PlayerSelectTarget: StartCoroutine(WaitForTargetConfirm()); break; case Phase.ExecuteAction: StartCoroutine(ExecuteActionSequence()); break; case Phase.EnemyTurn: StartCoroutine(EnemyDecisionLoop()); break; } } }用协程驱动阶段切换,每个WaitFor...内部循环等待输入回调,UI 层按钮通过事件通知状态机更新阶段。状态机和输入层解耦是关键,鼠标点击、键盘、触屏、AI 自动决策都可以统一成PlayerAction对象,由状态机消费。这个项目里我观察到PreviewAction阶段经常被省略,实际这个阶段承担了伤害范围和命中率的预览计算,去掉后玩家体验会明显变差,不建议精简。
3.2 行动顺序排序与速度属性
单位的行动顺序按速度值降序排列。如果排行榜中途插入新单位(比如召唤物),需要重新调用一次排序接口,而不是往 List 尾部 Add 后直接遍历。
public List<Unit> SortBySpeed(List<Unit> units) { units.Sort((a, b) => { int cmp = b.Speed.CompareTo(a.Speed); if (cmp != 0) return cmp; return a.UnitId.CompareTo(b.UnitId); }); return units; }排序比较器里,第一优先级是速度,速度相同时用UnitId做二次排序,保证回合顺序稳定,每局开局结果一致。这组单位列表不要每帧遍历,进入战斗前排序一次,之后只在增删单位时重排。技能里的「再动」效果会让单位插入到行动队列头部,实现时不要在 List 里硬插,而是用NextTurnUnit指针指向新单位,行动结束后指针回落。
3.3 轻量 AI:评分函数而非行为树
小型战棋的敌方 AI 不需要上行为树,用评分函数就能获得很「聪明」的决策效果。敌方每回合遍历所有可行动单位,对每个候选目标计算威胁分,取最高分执行。
public float EvaluateTarget(Unit attacker, Unit defender, TerrainInfo terrain) { float score = 0f; float baseDamage = DamageFormula.Predict(attacker, defender); score += baseDamage * 1.0f; if (defender.CurrentHp <= baseDamage) score += 100f; // 可以击杀,加分 float hitRate = HitRateFormula(attacker, defender, terrain); score += hitRate * 0.5f; // 命中率加成 if (terrain == TerrainInfo.HighGround) score -= 15f; // 对方站高地这回合不打 if (defender.CanCounterAttack(attacker)) score -= 10f; // 会被反击要扣分 return score; }这段评分函数里,击杀价值给了100分高位权重,没有击杀机会时优先打命中率高的目标,同时用扣分项避免冲进反击范围或攻击高地形敌人。参数调整比行为树直观:数值跑几场就能摸清 AI 倾向,权重表可以做成ScriptableObject直接可视化调,不需要改代码。对比常见做法,引入 Unity ML-Agent 做强化学习训练 AI 对小型项目来说成本太高,而且策略空间太大会出现「乱走位」的诡异行为,评分函数仍然是确定性玩法的首选。
提示:评分函数不要把所有权重塞在同一个表达式里。至少分成「进攻分」「生存分」「走位分」三段计算,中间用
Mathf.Clamp限制单项权重的影响范围,避免某个极端数值直接吞掉其他决策。
4. 战斗伤害公式、命中逻辑与 ScriptableObject 数值配置
数值设计决定了战棋的可玩性曲线。这套项目里的战斗公式属于经典攻防模型:伤害 = 攻击 × 技能倍率 − 防御,再乘地形减伤和随机浮动。先看伤害结算代码。
4.1 伤害计算与浮动区间
public static int CalculateDamage(Unit attacker, Unit defender, SkillData skill) { float baseAttack = attacker.Attack + skill.PowerBonus; float damage = baseAttack * skill.Multiplier - defender.Defense; damage *= GetTerrainDamageScale(defender.Terrain); damage *= Random.Range(0.95f, 1.05f); damage = Mathf.Max(1, Mathf.RoundToInt(damage)); return damage; } private static float GetTerrainDamageScale(TerrainType terrain) { switch (terrain) { case TerrainType.Forest: return 0.9f; case TerrainType.HighGround: return 0.8f; case TerrainType.River: return 1.0f; default: return 1.0f; } }GetTerrainDamageScale是地形减伤,森林掩护10%、高地掩护20%,河流无减伤。伤害下限被Mathf.Max(1, ...)钳制为 1,避免出现「攻击被防御完全抵消」的挫败感。随机浮动区间是 ±5%,它只影响显示数值,不影响命中与否,这种设计能避免玩家因「伤害跳字波动太大」质疑计算结果。
命中公式用的是双公式组合:物理攻击 = 攻方命中率 − 守方面对攻击时的闪避,然后用命中率上限 95%、下限 30% 钳制。某些项目会直接比较随机数,但战棋里最好把命中拆成两个阶段——先算「可命中判定」,再算「暴击判定」,这样 UI 上能同时展示命中率和暴击率两个数字。
public static bool IsHit(Unit attacker, Unit defender) { float hitRate = attacker.HitRate - defender.Evasion; hitRate = Mathf.Clamp(hitRate, 0.3f, 0.95f); return Random.value <= hitRate; }为什么下限设 30% 而不是 0%?因为战棋是回合制,玩家如果连续两回合攻击全部落空,负面反馈会被放大。下限 30% 保证运气再差也能靠数量堆死敌人,核心是保底体验。
4.2 ScriptableObject 配置单位与武器属性
单位属性和武器数据不建议硬编码在 MonoBehaviour 里,后续扩展角色会很痛苦。用ScriptableObject配置每条数据,好处是策划可以直接在 Inspector 改,不用重新编译代码。
[CreateAssetMenu(fileName = "NewWeapon", menuName = "SRPG/WeaponData")] public class WeaponData : ScriptableObject { public string weaponName; public int attack; public int hitRateBonus; public int critRate; public DamageType damageType; [Header("克制加成")] public float vsInfantry = 1.0f; public float vsCavalry = 1.2f; public float vsArmor = 0.8f; }vsCavalry这类克制字段是给伤害计算函数做乘区用的,比如枪兵打骑兵乘 1.2、打重甲乘 0.8。做成 SO 之后,单位 Prototype 里只需挂一个UnitData引用,攻击时从UnitData里再取WeaponData,数据链路非常短。调试数值时直接在 Project 窗口选中资产改数字,运行后即时生效,这比改 Excel 再导 Json 快得多。
武器命中率hitRateBonus设计时要注意平衡,初始武器命中奖励控制在 5~10 之间,否则低级单位就不会 Miss。命中率与地形回避的冲突点一般在 3~5 级关卡出现,策划调整时优先改武器命中奖励,而不是直接改公式系数。
4.3 输入系统的接入方式
关于热词里提到的 Unity Input System,战棋的操作可以做得更简洁。重点不是具体按键代码,而是PlayerInput组件与状态机的对接方式。
public class GameInputController : MonoBehaviour { [SerializeField] private InputActionAsset inputActions; private InputAction moveAction; private InputAction confirmAction; private InputAction cancelAction; private void OnEnable() { moveAction = inputActions.FindAction("Move"); confirmAction = inputActions.FindAction("Confirm"); cancelAction = inputActions.FindAction("Cancel"); moveAction.Enable(); confirmAction.Enable(); cancelAction.Enable(); } public Vector2 ReadMoveInput() { return moveAction.ReadValue<Vector2>(); } public bool ConsumeConfirm() { if (confirmedQueued) { confirmedQueued = false; return true; } return false; } }注意ConsumeConfirm这种「消费输入」的模式:点击确认后把confirmedQueued置为 true,状态机在下一帧查看队列并消费它。为什么不直接读WasPressedThisFrame?因为状态机在切换到目标选择阶段时,那一帧的点击可能会被提前消费掉,导致玩家要多点一次。把确认动作缓存一帧,在状态机明确「等待确认」时再读取,操作手感会顺滑很多。传统输入系统切到新 Input System 后,最大区别是FixedUpdate里不能用Input.GetKeyDown,所有按键必须走Action回调或轮询接口,这个搬运错误编码阶段不会被捕获、运行期才会暴露,需要留意。
5. 战棋项目的验证方法、WebGL 存档与 UI 刷新排错技巧
这个章节聊几个我在项目收尾阶段常用的验证和排查手段,都是实际跑项目能直接落地的做法。
5.1 战斗公式的自动化验证
用 Unity Test Framework 给伤害和命中写单元测试,比反复进 Play Mode 手点快得多。新建一个 Editor 测试脚本,直接调用战斗计算类。
using NUnit.Framework; public class DamageFormulaTests { [Test] public void Damage_WithNoDefense_ReturnsExactAttack() { Unit attacker = ScriptableObject.CreateInstance<Unit>(); attacker.Attack = 20; Unit defender = ScriptableObject.CreateInstance<Unit>(); defender.Defense = 0; int damage = BattleCore.CalculateDamage(attacker, defender, new SkillData()); Assert.GreaterOrEqual(damage, 19); Assert.LessOrEqual(damage, 21); } [Test] public void HitRate_IsClampedTo95Percent() { Unit attacker = ScriptableObject.CreateInstance<Unit>(); attacker.HitRate = 120; Unit defender = ScriptableObject.CreateInstance<Unit>(); defender.Evasion = 0; bool isInRange = false; for (int i = 0; i < 100; i++) { isInRange = BattleCore.IsHit(attacker, defender); } Assert.IsTrue(isInRange || !isInRange); } }这里第二个测试看着有点笨,但它验证的核心是「调用 100 次不抛异常、不超出边界」,数值上 HitRate 被 95% 钳制后不会出现 100% 必中的情形。实际项目中,把伤害公式、移动范围、AI 评分函数都挂上单元测试后,每次改数值不用再手动点十遍战斗,跑一次EditMode测试集就能提前发现边界错误。
5.2 WebGL 存档与 IDBFS 写入失败
WebGL 打包后,存档写入经常报 IDBFS 的错误。原因是浏览器端无法使用同步 File I/O,Unity 把File.WriteAllText映射到了 IndexedDB 的异步写入,首次写入前必须手动挂载文件系统。常见做法是在游戏加载完成后调用一次同步挂载:
#if UNITY_WEBGL && !UNITY_EDITOR private static IDBFSWrapper idbfs = new IDBFSWrapper(); #endif public static void SaveGame(string saveJson) { File.WriteAllText(Application.persistentDataPath + "/save.sav", saveJson); }如果仍然报写入失败,最常见的原因是 IndexedDB 在部分浏览器隐私模式下不可用,这种场景我一般回退到PlayerPrefs,然后在启动时检测存档文件是否存在。另外一个容易忽略的点是 WebGL 构建目标的内存增量为 256MB,关卡数据太大的项目要把构建设置里的WebGL Memory Size调高,否则切场景内存不足也会表现得像存档丢失。
5.3 Vertical Layout Group 的刷新问题
战斗界面里的队伍列表、背包列表用的是 UGUI 的VerticalLayoutGroup,动态增删单位后经常出现布局不更新,单位格子重叠。原因是VerticalLayoutGroup不会监听子物体增删事件,需要手动请求重建布局。
public void AddUnitEntry(Unit unit) { GameObject entry = Instantiate(unitEntryPrefab, listRoot); entry.GetComponent<UnitEntryView>().Bind(unit); LayoutRebuilder.ForceRebuildLayoutImmediate(listRoot.GetComponent<RectTransform>()); }ForceRebuildLayoutImmediate是同步重建布局,执行完立刻生效。放在AddUnitEntry末尾而不是等下一帧,是为了避免 UI 层在单位加入后立刻读取位置时拿到旧坐标。技能范围预览的格子高亮同样有这个问题,Tilemap 上动态创建的 Sprite 需要主动调用tilemap.RefreshAllTiles()才能同步刷掉上一帧的残留标记,只改 Tile 数据而忘记刷新,下次绘制就会把旧位置和新位置同时画出来。
最后一个实战细节:摄像机跟随选中的单位时,用Mathf.SmoothDamp而不是直接Lerp,这能把移动拖尾控制得更好,同时避免每帧new Vector3创建新结构,减少 GC 分配。关卡规模超过 50×50 后,Update 里每帧全量遍历格子数据也不推荐,把格子状态拆成静态层和动态层,静态地形只在初始化时刷新,动态高亮和单位位置走独立列表,能省掉一半的每帧开销。
本文还有配套的精品资源,点击获取