ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity三军混战工程实战:多兵种AI与战斗系统拆解

Unity三军混战工程实战:多兵种AI与战斗系统拆解

简介:这份资源是作者 kk041 分享的 Unity 3D 实战项目《海陆空大战》的完整工程包,面向已掌握 C# 基础、希望从零散教程走向完整项目实战的 Unity 学习者与独立开发者。项目覆盖 3D 场景搭建、物理引擎模拟、C# 脚本逻辑控制、碰撞与刚体交互、UNet 多人网络同步以及 LOD 与纹理压缩等性能优化环节,可作为理解商业级游戏架构的参考样本。压缩包为 rar 格式,共 30 个文件、约 27.81MB,包含可执行程序、资源与配置数据、托管程序集及 Unity 内置资源等,目录结构清晰,便于按模块拆解研究。目前已有 216 人学习下载。读者可从中获得一套可运行的完整项目骨架,对照脚本与场景资源理解组件协作方式,并借鉴网络同步与优化思路,适合作为进阶练习与二次开发的起点。

1. 从一份“三军混战”的工程包说起

前阵子拿到一个用 u3d 做的海陆空大战工程包,解压后第一反应是:这不是那种只放了几张贴图、打开就报错的“演示壳子”,而是一个能直接跑起来的三军对抗原型。陆地上有坦克巡逻,海面有舰船游弋,空中还有飞行单位盘旋,三种单位共用一套目标搜索和开火逻辑,切换场景就能看到不同兵种在同一战场里互相锁定。对想学 Unity 战斗系统怎么搭、单位 AI 怎么分层、多兵种怎么统一管理的开发者来说,这份工程比看十篇理论文章都直接。它适合两类人:一类是刚学完 Unity 基础、想找个完整战斗 Demo 拆着练手的新手;另一类是手里有项目、需要参考多单位调度和状态机写法的熟手。下面我按“能干什么、怎么跑、怎么改”的顺序,把这个包拆开讲。

2. 工程结构与技术选型:三军单位是怎么被组织起来的

2.1 目录分层与场景组织

拿到一个 Unity 工程,我习惯先看 Assets 下的目录,因为它基本暴露了作者的架构思路。这个包的目录大致是这样组织的:

Assets/ ├── Scenes/ │ ├── MainMenu.unity # 入口菜单场景 │ └── Battle_SeaLandAir.unity # 三军混战主战场 ├── Scripts/ │ ├── Core/ # 游戏管理器、单位注册、全局事件 │ ├── Units/ # 坦克、舰船、飞机三类单位脚本 │ ├── AI/ # 目标搜索、状态机、行为决策 │ ├── Combat/ # 武器、弹道、伤害结算 │ └── UI/ # 血条、单位信息面板 ├── Prefabs/ │ ├── Units/ # 三类单位预制体 │ └── Effects/ # 开火、爆炸特效 ├── Materials/ # 单位与地形材质 └── Resources/ # 运行时动态加载的配置

这个分层的关键点在于:单位脚本只负责“自己是什么”,AI 脚本负责“该干什么”,Combat 脚本负责“打出去怎么算”。三者解耦,新增一个兵种时不用动 AI 和战斗逻辑,只要继承单位基类、配好武器参数就能接入战场。场景方面,主战场场景里放了一个地形、一个水面平面和若干空域触发器,三军单位分别在不同高度层活动,靠碰撞层(Layer)区分彼此的攻击目标。

2.2 单位基类与多兵种统一管理

三类单位能共用一套调度,靠的是一个抽象基类。核心写法大致如下:

// UnitBase.cs —— 所有兵种的公共父类 public abstract class UnitBase : MonoBehaviour { public UnitType unitType; // 陆军/海军/空军 public float maxHealth = 100f; public float moveSpeed = 5f; public float attackRange = 20f; public float attackDamage = 10f; public float attackInterval = 1.5f; protected float currentHealth; protected float attackTimer; protected Transform currentTarget; protected virtual void Start() { currentHealth = maxHealth; UnitManager.Instance.Register(this); // 注册到全局管理器 } public virtual void TakeDamage(float damage) { currentHealth -= damage; if (currentHealth <= 0f) Die(); } protected abstract void Move(); protected abstract void Attack(); protected virtual void Die() { UnitManager.Instance.Unregister(this); Destroy(gameObject); } }

这段代码的逻辑很直白:UnitBase把血量、移速、攻击范围、攻击间隔这些所有单位都有的属性收拢在一起,Move和Attack声明为抽象方法,逼着每个兵种自己实现移动和攻击方式——坦克是履带式贴地移动,舰船受水面约束,飞机则是三维空间自由移动。UnitManager单例负责统一注册和注销,战斗时遍历注册表就能拿到全场存活单位,不用满场景FindObjectsOfType,这是性能上的关键一笔。

参数上要留意attackRange和attackInterval:前者决定 AI 什么时候从“追击”切到“开火”,后者决定开火频率。我一般会把这两个值做成 ScriptableObject 配置,方便策划调平衡,而不是硬编码在预制体上。

2.3 目标搜索与状态机驱动

单位“看见敌人”这件事,靠的是一个按兵种过滤的目标搜索器。核心逻辑是遍历UnitManager的注册表,按攻击层和距离筛选:

// TargetFinder.cs —— 按兵种和距离找最近的可攻击目标 public Transform FindNearestEnemy(UnitBase self) { Transform nearest = null; float minDist = float.MaxValue; foreach (var unit in UnitManager.Instance.AllUnits) { if (unit == self || unit.unitType == self.unitType) continue; // 同兵种不互打 if (!IsAttackableLayer(self, unit)) continue; // 层级过滤 float dist = Vector3.Distance(self.transform.position, unit.transform.position); if (dist < minDist && dist <= self.attackRange * 2f) // 搜索半径略大于攻击半径 { minDist = dist; nearest = unit.transform; } } return nearest; }

这里有两个设计取舍值得说。第一,同兵种不互打,靠unitType判断,避免友军误伤逻辑把 AI 搞复杂;第二,搜索半径设成攻击半径的两倍,是为了让单位在进入射程前就开始转向追击,而不是走到脸上才反应,观感上更像“有意识”。状态机部分用的是一个轻量枚举驱动:Idle → Chase → Attack → Dead,每个状态在Update里根据目标是否存在、距离是否进入射程来切换。没有上行为树插件,是因为这个体量的 Demo 用枚举状态机足够,调试时打断点也直观。

3. 跑起来:从打开工程到三军开打的完整步骤

3.1 环境准备与工程导入

这个包是基于 Unity 常规 3D 模板做的,没有依赖冷门插件,导入前确认两件事:Unity 版本尽量用工程里ProjectSettings记录的版本打开,版本跨太大容易出现预制体引用丢失;另外确认安装了内置渲染管线相关模块,工程用的是标准材质,没走 URP/HDRP,所以不需要额外装渲染包。

导入步骤不复杂,但顺序别乱:

# 1. 解压工程包到本地目录(路径不要带中文和空格) # 2. 打开 Unity Hub,选择“Add project from disk”,指向解压后的根目录 # 3. 用工程记录的版本打开,等待资源导入完成 # 4. 导入完成后检查 Console 是否有红色报错,黄色警告可先忽略

导入过程中如果卡在 “Importing Assets” 很久,多半是贴图和模型在重新生成缩略图,等它跑完就行,别中途强退,否则容易留下损坏的.meta文件。导入结束后,先在 Project 窗口确认Scenes/Battle_SeaLandAir.unity存在且能双击打开,这是判断工程完整性的第一道关。

3.2 场景运行与参数检查

打开主战场场景后,先别急着点播放,按这个顺序检查一遍:

第一步,看 Hierarchy 里三军单位是否都在场景中,坦克、舰船、飞机各至少一个,且它们的UnitBase组件上unitType分别配成了陆军、海军、空军。第二步,选中UnitManager对象,确认它是场景里唯一挂载的实例,单例重复会导致注册表错乱。第三步,检查单位的碰撞层设置,攻击层过滤依赖 Layer 配置,如果 Layer 没对上,单位会“看不见”敌人,表现为全场发呆。

确认无误后点播放,正常表现是:各单位先进入巡逻状态,当敌方单位进入搜索半径后开始转向追击,进入攻击射程后按attackInterval节奏开火,血条随伤害下降,单位死亡后从场上移除。如果播放后单位原地不动,优先查UnitManager是否为空、目标搜索是否返回 null;如果单位追着打但打不掉血,查武器伤害结算里的层过滤和TakeDamage调用链。

3.3 打包与运行验证

想在独立环境里验证,走一次打包流程:

# Unity 菜单:File -> Build Settings # 1. 将 Battle_SeaLandAir 场景加入 Scenes In Build # 2. 目标平台选 PC Standalone,架构按机器选 # 3. 点击 Build,输出到空目录 # 4. 运行生成的 exe,确认三军单位行为与编辑器内一致

打包后重点看两处差异:一是Resources动态加载的配置在打包后路径是否仍然有效,二是特效和材质在目标平台上是否正常显示。常见翻车点是编辑器里靠AssetDatabase路径加载的资源,打包后路径失效,这类问题在编辑器里完全看不出来,只有跑 exe 才暴露,所以打包验证这一步别省。

4. 核心模块拆解:战斗结算与单位调度的关键实现

4.1 武器与伤害结算链路

开火这件事,从“决定打”到“扣血”中间隔着一条链路:状态机判定进入射程 → 调用Attack→ 生成弹道或直接命中 → 结算伤害 → 目标TakeDamage。直接命中型的结算大致如下:

// Weapon.cs —— 武器开火与伤害结算 public class Weapon : MonoBehaviour { public float damage = 10f; public float range = 20f; public LayerMask hitMask; // 可命中的层 public GameObject muzzleEffect; // 枪口特效 public void FireAt(Transform target, UnitBase owner) { if (target == null) return; if (Vector3.Distance(transform.position, target.position) > range) return; if (muzzleEffect != null) Instantiate(muzzleEffect, transform.position, transform.rotation); // 射线检测确认命中,避免穿墙打人 if (Physics.Raycast(transform.position, (target.position - transform.position).normalized, out RaycastHit hit, range, hitMask)) { var victim = hit.collider.GetComponentInParent<UnitBase>(); if (victim != null && victim.unitType != owner.unitType) victim.TakeDamage(damage); } } }

这段的关键在hitMask和射线检测:不是拿到目标引用就直接扣血,而是先射线确认中间没有遮挡,这样地形和建筑能自然形成掩体,不用额外写遮挡逻辑。GetComponentInParent是因为碰撞体可能挂在子物体上,直接GetComponent会漏。参数上range要和单位基类的attackRange保持一致,否则会出现“AI 以为在射程内、武器却打不到”的错位,我一般让武器从单位基类读取射程,避免两处配置打架。

4.2 单位管理器与全局调度

UnitManager是整个战场的黑匣子,所有单位的生死都从这里过一道:

// UnitManager.cs —— 全局单位注册与查询 public class UnitManager : MonoBehaviour { public static UnitManager Instance { get; private set; } private readonly List<UnitBase> allUnits = new List<UnitBase>(); public IReadOnlyList<UnitBase> AllUnits => allUnits; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; } public void Register(UnitBase unit) { if (!allUnits.Contains(unit)) allUnits.Add(unit); } public void Unregister(UnitBase unit) { allUnits.Remove(unit); } public List<UnitBase> GetUnitsByType(UnitType type) { return allUnits.FindAll(u => u.unitType == type && u != null); } }

单例在Awake里做了重复实例销毁,这是防止场景里误放两个管理器导致注册表分裂。AllUnits暴露为只读接口,外部只能查不能改,注册和注销必须走方法,保证列表一致性。GetUnitsByType用于 UI 统计各兵种存活数量,也方便后续做“全歼某兵种获胜”的胜负判定。性能上,单位数量在几十到上百级别时,线性遍历完全够用,真到几百上千再考虑分空间网格,这个 Demo 没必要提前优化。

4.3 三军差异化移动的实现

三类单位的移动差异,落在各自对Move的重写上。坦克贴地、舰船锁水面高度、飞机三维移动,核心区别是约束轴不同:

// TankUnit.cs —— 陆军贴地移动 protected override void Move() { if (currentTarget == null) return; Vector3 dir = (currentTarget.position - transform.position); dir.y = 0f; // 锁死垂直方向,贴地走 transform.position += dir.normalized * moveSpeed * Time.deltaTime; if (dir.sqrMagnitude > 0.01f) transform.rotation = Quaternion.LookRotation(dir); } // PlaneUnit.cs —— 空军三维移动 protected override void Move() { if (currentTarget == null) return; Vector3 dir = (currentTarget.position - transform.position).normalized; transform.position += dir * moveSpeed * Time.deltaTime; // 保留 y 轴,自由爬升俯冲 transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(dir), 5f * Time.deltaTime); // 转向带插值,飞行更顺 }

坦克把dir.y清零,保证不会因为目标在高处而“飞起来”;飞机保留 y 轴并给旋转加Slerp插值,避免瞬间转向显得僵硬。舰船介于两者之间,锁的是水面高度而非地面高度。这种“基类定契约、子类管约束”的写法,新增兵种时只要想清楚它受哪个轴约束,移动逻辑十几行就能写完,这是这个工程结构上最值得抄的一点。

5. 避坑与排查:跑这个工程最容易翻车的几处

5.1 单位全场发呆不攻击

现象是播放后所有单位原地不动,或者只巡逻不追击。原因通常有两个:一是UnitManager实例为空或重复,注册表里根本没有单位;二是目标搜索的层过滤没配对,IsAttackableLayer永远返回 false。解决方式是先在Start里打日志确认注册数量,再检查单位的 Layer 和武器hitMask是否一致。我踩过一次是场景里复制了管理器对象,单例把后一个销毁了,但前一个的引用被预制体覆盖,结果注册表是空的,查了半天才发现是场景布置问题。

5.2 打中了却不掉血

现象是特效正常播放、射线也命中了,但目标血量不变。原因多半是GetComponentInParent<UnitBase>()拿到的不是预期单位,或者伤害结算里unitType判断把友军过滤掉了却误判了敌我。解决时在TakeDamage入口打一行日志,确认伤害值、目标类型和当前血量,基本一眼能定位。另一个隐蔽原因是血条 UI 订阅了错误的事件,血量其实掉了但显示没刷新,这种要区分“逻辑没掉血”和“显示没更新”。

5.3 打包后资源加载失败

现象是编辑器里一切正常,打包出的 exe 运行后模型变白、特效消失或配置读不到。原因是运行时动态加载走了AssetDatabase这类仅编辑器可用的接口,打包后路径失效。解决办法是把动态加载统一改成Resources.Load或 Addressables,并确认资源确实放在Resources目录下。血泪经验是:凡是运行时才加载的东西,编辑器里跑通不算数,必须打包验证一遍。

5.4 单位数量一多就卡顿

现象是单位到几十个后帧率明显下降。原因通常是每帧大量FindObjectsOfType、频繁Instantiate特效、或者目标搜索里做了全表遍历加复杂计算。解决方向:目标搜索改成定时刷新(比如每 0.2 秒一次)而不是每帧,特效走对象池复用,遍历时提前用距离平方比较避免开方。这个 Demo 默认规模不大,但二次开发加单位时这几处最先暴露。

5.5 版本不匹配导致预制体引用丢失

现象是打开工程后大量预制体显示 Missing Script 或引用为空。原因是 Unity 版本跨度过大,序列化格式不兼容。解决方式是尽量用工程记录的版本打开,实在要用新版本,先备份再升级,升级后逐个检查单位预制体的组件引用。后悔药就是备份,升级前把整个工程目录复制一份,出问题能立刻回退。

6. 二次开发:把 Demo 改成能玩的关卡

拿到这个工程,最自然的改造方向是把它从“单位互殴演示”变成“有胜负、有波次、有操作”的关卡。我的做法是先加一层关卡控制器,管住刷兵节奏和胜负判定,再动单位参数做兵种克制。

刷兵部分,用协程按波次生成单位,每波配置兵种和数量:

// WaveSpawner.cs —— 按波次生成三军单位 [System.Serializable] public class WaveConfig { public UnitType type; public GameObject prefab; public int count; public float spawnInterval; } public IEnumerator SpawnWave(List<WaveConfig> wave) { foreach (var cfg in wave) { for (int i = 0; i < cfg.count; i++) { Vector3 pos = GetSpawnPoint(cfg.type); // 按兵种选出生点:陆地/水面/空中 Instantiate(cfg.prefab, pos, Quaternion.identity); yield return new WaitForSeconds(cfg.spawnInterval); } } }

胜负判定挂在UnitManager上,每帧或定时检查双方存活数量,一方归零就触发结算。兵种克制不用改基类,在TakeDamage前加一层伤害系数表即可,比如坦克对舰船减伤、飞机对坦克增伤,用字典按(攻击方类型, 受击方类型)查系数,改平衡时只动配置不动逻辑。

验证改造是否成功,我一般走三步:先在编辑器里跑满三波,看刷兵节奏和帧率;再打包跑一次,确认动态生成的单位资源加载正常;最后故意把某一方数量调成零,验证胜负判定不会卡死。这套流程走下来,基本能把改造引入的问题挡在交付前。

从那以后我每次拆这类战斗工程,都强制先跑一遍“注册表是否为空、层过滤是否配对、打包是否正常”这三项检查,再谈改功能——因为这三个地方出的问题,表现都像“AI 坏了”,实际跟 AI 一点关系没有。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进