公司动态

Unity3D小游戏源码拆解:六大系统协作与行为树AI实战

📅 2026/8/26 5:46:59
Unity3D小游戏源码拆解:六大系统协作与行为树AI实战
简介在Unity3D游戏开发中如何将对话、任务、背包、武器、存档读档与AI决策高效集成是构建RPG类原型的关键挑战。源码通过ScriptableObject实现数据驱动让策划无需改代码即可调整内容利用事件总线在对话、任务与背包之间解耦通信避免系统互相纠缠。行为树替代传统状态机让敌人AI的决策逻辑更直观、易扩展配合Selector与Sequence节点即可模拟巡逻、追击与攻击。存档读档则围绕persistentDataPath与JsonUtility展开帮助开发者避开序列化多态和场景恢复的常见陷阱。这套设计不仅适合初学者系统学习Unity模块划分也能为快速验证玩法或二开提供坚实基础。文章从系统协作、行为树设计到存储机制逐层剖析并给出面向WebGL与微信小游戏的适配建议为游戏开发工程实践提供参考。 最近我拆解了不少Unity3D项目但“对话、任务、背包、武器、存档读档、行为树AI”这六个功能塞进一个压缩包的确实少见。拿到这份名为《基于Unity3D的小游戏源码》的项目时我第一反应是“又是一个堆功能的教学Demo”但真正跑起来才发现它把常见RPG玩法里最核心的几个子系统都串成了一条可玩的短线和NPC对话、接任务、打小怪、捡武器、退出重进进度还在。对于想学Unity的初学者这份源码是难得的“宏观教材”对于想快速搭原型验证玩法的人它又能省掉不少重复造轮子的时间。所以这篇文章我打算把它从外到内拆一遍重点讲清楚系统之间是怎么协作的、行为树AI为什么适合做角色决策、存档读档有哪些绕不开的坑以及如果你要在这个基础上二开应该从哪里下手。1. 这份源码包拿到手之后先说清楚它到底是什么1.1 项目结构里藏着的信息解压以后我第一件事不是打开工程而是先看目录结构。一个Unity项目的组织方式基本决定了它的维护难度。这份源码的Assets目录大致是这样的Assets/ ├─ Scenes/ │ ├─ Main.unity │ └─ Battle.unity ├─ Scripts/ │ ├─ Dialogue/ │ ├─ Quest/ │ ├─ Inventory/ │ ├─ Weapon/ │ ├─ AI/ │ ├─ Save/ │ └─ Player/ ├─ Data/ │ ├─ Dialogues/ │ ├─ Items/ │ ├─ Quests/ │ ├─ Weapons/ │ └─ AIBehaviors/ ├─ Prefabs/ │ ├─ Player.prefab │ ├─ NPC/ │ ├─ Enemy/ │ └─ PickupItem/ ├─ Art/ │ ├─ Models/ │ ├─ Textures/ │ └─ Animations/ └─ Settings/看到这个结构基本就能判断作者是有意识做模块划分的。Scripts按功能拆成目录Data目录下全是ScriptableObject资源这说明对话、物品、任务、武器甚至是AI配置都不是硬编码在脚本里的而是以数据资产的形式存在。这个设计很关键因为它意味着你不需要修改代码就能调整游戏内容二开的时候舒服很多。1.2 快速跑通的三个前提如果你直接双击项目想跑起来大概率会遇到几个小问题我先把经验摆出来省得你踩坑。第一Unity版本。这份源码工程文件我打开时用的Unity 2020.3 LTS如果你用Unity 2021或更新的版本打开大概率会提示升级API。升级本身没问题但要注意Animator组件里的某些参数名如果在新版本中被改掉可能会导致动画状态异常。我的建议是尽量用2020.3这是当时最稳定的LTS版本。第二输入轴设置。项目里的角色移动大概率用的是老式Input Manager也就是Input.GetAxis(Horizontal)和Input.GetAxis(Vertical)。Unity新版虽然推荐Input System但这个项目没有迁移所以你需要检查Project Settings里的Input Manager是否包含Horizontal和Vertical这两个轴。如果你用的模板创建项目时用的是“3D Core”而不是“第一人称”那么这两个轴默认是有的。第三场景启动顺序。我特别想强调这一点因为很多新手会直接打开Main场景然后按Play结果发现人物动不了、UI不显示。这个项目很可能在Project Settings的Build Settings里把Main.unity设为了第一个场景但如果你是从Assets窗口直接双击进入到某个场景Unity并不会自动加载前置的初始化场景。如果项目里有一个类似GameManager的DontDestroyOnLoad对象它只会在第一个场景中生成那么从其他场景启动就会导致系统初始化不完整。解决办法很简单把Main场景拖到Build Settings的列表最上方或者直接运行Main场景。1.3 一个反直觉的判断系统越多越容易崩但这份源码做了减法很多教学源码的通病是“功能堆叠”每个系统单独看都能用但合在一起就像一堆插线板互相绊脚。这份源码难得的地方在于它做了减法。举例来说背包系统没有做复杂的格子拖拽而是用点击选择装备按钮的方式武器系统没有做连招派生而是用攻击动画伤害判定的简单组合对话系统没有做完整的打字机、立绘、表情系统而是用简单的文本框选项按钮。这不是偷懒而是“够用即可”。对于需要学习的人来说这种刻意简化反而能让你更迅速地抓到每个系统的骨骼不会被花哨的视觉效果蒙住眼睛。2. 对话、任务、背包、武器四个系统是怎么咬合在一起的2.1 对话系统不只是聊天数据驱动与分支触发先看对话系统。我打开Dialogue文件夹发现所有对话资源都是ScriptableObject每个对话资源里包含一个DialogueNode列表节点里有说话人名称、文本内容、可选的选项列表以及每个选项关联的后续节点ID。这里最值得注意的设计是对话节点里可以挂一个QuestTrigger字段当玩家选中某个选项后会调用QuestManager.Instance.AcceptQuest(questId)或CompleteQuest之类的接口。用ScriptableObject做对话数据是我个人很推荐的做法。相比直接写在C#类里它有几个实打实的好处策划同学可以对着Inspector编辑内容不需要碰代码不同对话之间可以互相引用形成分支对话文本和程序逻辑分离后期要接本地化翻译也很方便。但这里也有一个新手容易看懵的点为什么有些对话框里的选项明明显示着点下去却没有反应我对照源码看了一下发现选项的SetActive状态由DialogueCanvasController控制而这个控制器会在对话开始时根据当前节点有没有选项来切换按钮组的显示。如果你在二开时加了新对话但忘记把选项的nextNodeId填对点选项就会走向一个空节点界面卡死。排查的时候建议在DialogueManager里面加一个Debug.Log(currentNode.id)把当前节点打出来很快就能定位到问题。2.2 任务系统通过事件总线与对话/背包解耦任务系统是另一个容易绕晕的地方。我分析完代码后认为作者用的不是“NPC头顶问号任务日志UI”这种重型方案而是一个很轻量的事件驱动结构。QuestManager维护一个Dictionarystring, QuestStateQuestState包括NotStarted、InProgress、CanComplete、Completed四种状态。当对话系统调用了AcceptQuest任务就会从不接受变为进行中。那么任务进度怎么更新呢答案是事件总线。我搜了一遍代码发现项目里有一个GameEvents静态类里面定义了一些事件比如OnItemPickedUp、OnEnemyKilled、OnNPCTalked。物品拾取系统在拾取物品后会调用GameEvents.OnItemPickedUp?.Invoke(itemId)而任务系统会订阅这个事件检查当前进行中的任务有没有“收集某个物品”的需求如果有就更新计数。这个设计的妙处在于任务系统和背包系统互相不认识它们之间只通过事件通信任务逻辑里不需要写“去找背包要数据”这样的耦合代码。后面你想加一个新任务类型比如“杀五只狼”只需要在敌人死亡处发一个OnEnemyKilled事件然后在任务数据里配置好目标ID和数量就行了不需要改动其他系统。但也要提醒一下事件总线用多了以后项目里会出现“找不到谁订阅了谁”的困扰。我自己的习惯是事件名的定义要足够语义化最好带上触发源比如OnEnemyKilled(Transform killer, EnemyType type)并且每个事件在定义处写清楚注释否则项目大了以后调试事件流会非常痛苦。2.3 背包与武器系统物品数据和装备槽位的双向联动背包系统和武器系统在这份源码里的关系非常紧密。我把物品的基类定义简化为public enum ItemType { Consumable, QuestItem, Weapon } [CreateAssetMenu(menuName Game/Item)] public class ItemData : ScriptableObject { public string itemId; public string itemName; public Sprite icon; public ItemType itemType; public int maxStack; }武器则是ItemData的一个扩展[CreateAssetMenu(menuName Game/Weapon)] public class WeaponData : ItemData { public int attackPower; public float attackRange; public float attackCooldown; public GameObject weaponPrefab; }背包系统本身存储的是InventorySlot列表每格包含一个ItemData引用和堆叠数量。当玩家点击背包里的某个武器并装备时WeaponManager会把当前武器对应的weaponPrefab挂到角色的手上骨骼或者直接SetActive一个预设武器模型。如果已经有一把武器就先把旧的卸下放回背包。这个流程其实很简单但很多初学Unity的人会在“数据什么时候更新”上卡住。我看到这段源码里用的是OnValidate配合OnEquip/OnUnequip事件来刷新UI也就是说装备改变后立刻调用InventoryUI.Refresh()而不是在Update里每帧刷新UI这一点非常值得学习。武器攻击时代码是用Physics.OverlapSphere做一个范围检测然后把范围内有Enemy标签的物体取出来扣血同时播放攻击动画和一个简单的特效。这样处理比那些复杂的碰撞矩阵要容易理解得多也够用。你如果想要更好的打击感可以在这个基础上加屏幕震动、受击闪白、击退效果但不要一开始就上复杂系统。3. 行为树AI从节点设计到角色决策的落地细节3.1 为什么选行为树而不是状态机这份源码里最让我惊喜的是AI部分因为它用的是行为树不是常见的有限状态机FSM。很多人会问“做一个简单的敌人AI用FSM不就行了if (playerInRange) 追击else 巡逻这不就完了吗”其实对于只有一两个敌人的小游戏FSM确实够用。但当敌人行为多了以后FSM的状态转移会变成一张蜘蛛网每加一个行为你都要检查所有旧状态的边界条件非常容易漏掉。行为树的做法是把AI决策拆成一颗树从根节点向下遍历每个节点只回答自己负责的那个小问题比如“玩家在视野范围内吗”“如果在就追击如果不在就巡逻”。要调试AI当前在干什么只需要看它走到树的哪个分支非常直观。这份源码用的是一套自研的轻量行为树不是Behavior Designer或者NodeCanvas这类插件。好处是代码量少、容易读懂、没有任何licence问题坏处是没有可视化编辑器调整行为树参数要靠Inspector拖节点。不过对于小游戏来说完全够了。3.2 自研行为树的节点逻辑我读了一下AI相关的几个核心类基本结构是public enum NodeState { Running, Success, Failure } public abstract class BTNode { public abstract NodeState Evaluate(); } public class Selector : BTNode { private ListBTNode children new ListBTNode(); public override NodeState Evaluate() { foreach (var child in children) { var result child.Evaluate(); if (result ! NodeState.Failure) return result; } return NodeState.Failure; } } public class Sequence : BTNode { private ListBTNode children new ListBTNode(); public override NodeState Evaluate() { foreach (var child in children) { var result child.Evaluate(); if (result ! NodeState.Success) return result; } return NodeState.Success; } }Selector选择节点会依次尝试子节点找到一个能执行的就不再往下走Sequence顺序节点则要求所有子节点都成功才算成功。再加上Conditional节点比如IsPlayerInRange和Action节点比如MoveToPlayer、PlayAttackAnimation就能组合出一棵比较完整的AI决策树。这个敌人的行为树配置大概是这样的RootSelectorSequence攻击分支IsPlayerInAttackRange?AttackPlayerSequence追击分支IsPlayerInViewRange?MoveToPlayerSequence巡逻分支RandomMoveToPointWaitForSeconds这个结构很简单但读起来非常清晰。敌人在每一帧都会从根节点开始重新评估所以当玩家进入视野范围后它能在下一帧作出反应不会像FSM那样有状态切换延迟感。3.3 我在真实项目里调AI的两个血泪经验看到这份源码的行为树实现我忍不住想起自己在真实项目里踩过的两个坑顺便分享出来。第一个坑是“死循环”。在行为树中如果一个Action节点在满足条件时总是返回Running而它上面的父节点又是Selector一旦这个子节点在可执行条件下没走完整个行为树会卡在这一层反复执行看起来就像AI抽搐。解决办法是在Action节点内部加一个耗时判断比如MoveToPlayer只有在到达目标点或超时以后才返回Success否则返回Running并且将Running状态限制在一个时间窗口内。千万不要让无条件的Running持续超过几帧。第二个坑是“视野范围”的计算。源码里如果要实现敌人发现玩家通常会用Vector3.Distance或Physics.OverlapSphere判断距离但这样有一个问题敌人隔着墙也能看见你。我建议在判断距离之后再加一条射线检测从敌人眼睛位置到玩家胸腔位置做Physics.Linecast如果中间有遮挡就返回False。这个做法在大多数RPG和动作游戏里都适用能显著提升AI的真实感。我自己在调行为树的时候还会在敌人的OnDrawGizmos里画出攻击范围和视野范围这比纯调数字直观太多了。4. 存档读档你一定会踩的路径与序列化坑4.1 存档到底存在哪persistentDataPath vs StreamingAssets打开存档模块能看到作者使用了Application.persistentDataPath拼接文件名然后调用File.WriteAllText或StreamWriter写入JSON。这个路径是Unity用来保存玩家数据的标准位置不同平台上各不相同Windows上一般在C:\Users\用户名\AppData\LocalLow\公司名\游戏名Mac上在~/Library/Application Support/公司名/游戏名Android上是/data/data/包名/files。使用这个路径的好处是可写、存档不会因为更新游戏而被覆盖、也不会因为权限问题被拒绝。我看到网络热词里有一条“unity3d filepath path.combine(application.persistentdatapath, filename);”说明这个问题大家确实都很关心。我特别强调一下不要用StreamingAssets来做存档至少不要用它来写玩家自己产生的数据。StreamingAssets在PC上虽然是可写的但在手机和WebGL平台上是只读的而且路径处理方式特别麻烦。存档这类动态数据就该放到persistentDataPath。4.2 JsonUtility的局限与绕过方案源码里使用的是JsonUtility.ToJson和JsonUtility.FromJson。这能跑通但有两个明显的坑。第一个坑是JsonUtility不支持Dictionary。如果存档结构里要保存“每个任务的进度状态”你自然想到用Dictionarystring, int但JsonUtility会直接抛异常。解决方式有两种一种是把它拆成ListKeyValuePair再存另一种是干脆用Newtonsoft.Json支持字典和很多复杂类型而且性能也不错。我个人在后来的项目里直接用Newtonsoft.Json彻底告别这类问题。第二个坑是JsonUtility不支持多态。假如你的背包里有武器和消耗品它们的ItemData类型不同字段也不同直接用JsonUtility序列化ItemData列表时武器子类里的attackPower字段会被丢掉。解决办法是在存一份“类型名”字段读档时根据类型名创建对应的子类实例重新从JSON里反序列化。这份源码里其实也遇到了类似问题我看它最后选择的方案是把武器和普通物品分开存武器存到weaponSaveData列表普通物品存到itemSaveData列表读档时再合并。这个方案可行但扩展性差一点以后加一个装备类型又要改存储结构。4.3 读档后的场景状态恢复读档比存档还要难处理因为场景里的对象是在游戏加载时创建的而存档里的数据是上一个会话的。你在调用SceneManager.LoadScene之后所有NPC、敌人、道具都会按场景默认状态重新生成这时候必须等场景加载完成再把存档数据应用回去。这份源码的做法是在GameManager里挂了一个LoadSaveData方法当场景加载完成后会延时调用一个RestoreWorldState里面做了三件事恢复玩家位置、血量、朝向利用InventoryManager.LoadFromSave把背包和武器槽位恢复到存档状态遍历所有QuestObjective接口根据任务状态重新激活任务追踪点。这里最容易被忽略的是NPC的对话状态和敌人是否存活。如果存档时你已经杀了某个BOSS读档后BOSS可能又冒出来了。解决思路之一是给每个需要持久化的对象配一个唯一ID存一个HashSetstring destroyedObjects读档时把这些ID对应的对象直接摧毁。虽然暴力但简单可靠。5. 二开实战把这份Demo源码改造成可发布的小游戏5.1 先别急着加新系统梳理已有模块的改动优先级我接触过不少拿到源码就开干结果改到一半代码乱成一锅粥的开发者。如果你想在这个项目上继续加内容我的建议是先画一张模块依赖图搞清楚每个系统之间的调用链然后再动手。比如这份源码里PlayerController依赖InventoryManager和WeaponManagerQuestManager又订阅GameEvents。如果你想加一个经验值系统你不需要动对话系统只需要在敌人死亡事件里挂一个经验奖励逻辑。但如果你要改玩家移动方式从CharacterController改成Rigidbody物理驱动那么PlayerController里的动画、碰撞、输入处理都要同步改动影响面就要大得多。所以在动手前优先处理那些跨系统耦合的入口比如GameEvents、GameManager、PlayerController把这些公共接口稳住后面新加系统就会轻松很多。5.2 从PC到WebGL/微信小游戏的适配要点如果要把这份源码发到网页或者微信小游戏平台有几个绕不开的适配问题。第一个是路径与存档。WebGL环境下Application.persistentDataPath虽然在逻辑上是可用的但它被Unity映射到了浏览器的IndexedDB而且用户清缓存或换浏览器数据可能就没了。微信小游戏的persistentDataPath路径规则和标准Unity又不太一样而且小游戏有独立的用户体系通常更推荐把存档上传到自己的服务器。如果你只是做单机版临时用PlayerPrefs应付一下也行但如果游戏内容多、存档文件大我还是建议做成服务器存档。第二个是资源大小。Unity打包WebGL和微信小游戏时C#代码会被编译成WebAssembly资源压缩率受制于平台。如果你的Art目录里放了一堆高模贴图包体一定会爆炸。二开时建议开启AssetBundle或Addressables把前期用不到的关卡资源拆开按需加载。这一份小游戏Demo体量不大暂时还能撑得过去但你要继续加场景就一定要提前规划。第三个是输入事件差异。鼠标左右键、键盘事件在WebGL上问题不大但在微信小游戏里很多设备是触摸屏没有Hover事件。这份源码如果有一些用鼠标悬停触发的UI交互需要改成点击或者触摸触发。5.3 数据配置从Inspector到Excel导表的进阶这份源码虽然已经用ScriptableObject做了数据配置但你在新增几十个武器、任务以后会发现逐个在Inspector里新建资源再填字段的效率很低。更常见的工作流是策划维护一个Excel表格然后用一个编辑器工具把表格导出成ScriptableObject资源。这里我推荐一个简化的做法写一个[MenuItem(Tools/Import Item Data)]的编辑器脚本读取CSV或者Excel导出的JSON然后用AssetDatabase.CreateAsset生成对应的ItemData资源。这样游戏内的数值和招式配置就可以批量更新不需要每次手动改。这个项目的基础数据字段比较规整很适合做这样的工具改造。5.4 我能想到的三个典型扩展方向如果让我在这个Demo基础上继续做我会优先考虑这三个方向第一个是对话系统的“剧情引擎化”。现在的对话已经支持分支了但还没有像角色状态、好感度、全局变量这些概念。你可以给DialogueNode加一个SetVariable操作让对话能够改变世界状态避免“剧情杀”后NPC还在机械重复上一句话。第二个是行为树的动态可视化调试。自研行为树跑起来没问题但调试很痛苦。你可以写一个简单的运行时窗口把每个节点的状态实时绘制成树状图用颜色区分Success/Failure/Running这样AI在想什么一眼就能看懂。很多商业AI插件的核心卖点就是这个。第三个是背包系统的物品堆叠和右键快捷使用。现在背包更偏装备栏缺少消耗品的使用逻辑。你可以把ItemData加一个Use()虚方法然后在ConsumableItem里实现回血、加Buff、传送等效果。这样背包系统就能支持更多玩法而不是只能装武器和任务道具。最后补一个实际踩坑后的建议如果你准备把这份源码当成毕业设计或者面试作品不要只把功能跑通就完事要能回答出“为什么选行为树而不是状态机”“存档用JsonUtility有什么局限”这类问题。源码只是一个起点你真正学到的是这些系统在彼此协作时如何取舍、如何解耦、如何在简单和完整之间找到平衡。我每次拆解完一份优秀源码都会用笔记把里面的设计亮点和踩坑点记下来时间久了这就是你自己的一套技术决策工具箱。本文还有配套的精品资源点击获取