Unity游戏开发工作流:关于复杂系统的创建流程
当制作一个新的复杂系统、例如角色、活动、状态甚至是游戏生命周期等等需要多脚本协同的系统时在看到这个系统的第一眼可能就是我去这个系统怎么这么复杂“我的天哪这么多脚本和函数”“一个个看下去要花不少时间吧”。但是一个系统之所以复杂主要是因为它的衍生功能太多或者它涉及到很多其他的系统就像一棵树长出了很多个枝桠在它不断生长的过程中它不断地成长最后变成了一棵树叶繁杂的大树第一眼看上去的时候当然会认为它的枝条很多很繁茂。但是我们看树的时候能够一眼看出哪些是主干的枝条哪些是次生的它们的粗壮程度就可以说明。但是代码可不会告诉你那条枝干最粗代码只会将函数和功能以代码的形式展现出来而我们要做的就是自己去判断那条枝桠是主干道。而在之前的实习经历之后指导前辈就告诉过我系统的主干道大部分时间段都是其生命周期即一个系统在其激活之后的运行、结束流程。这些教导让我受益匪浅也分享给大家。系统生命周期分析分析系统行为以Unity的生命周期为例在Unity游戏运行开始后其游戏对象的生命周期大体可以概述为Awake-Start-Update-FixedUpdate-LateUpdate-OnApplicationQuit在其中Unity的游戏对象会经历激活-更新-结束的生命周期主干。而对于一个系统它也会有诸如以下的生命周期现在让我们假设有一个通行证系统和大家熟知的一样在激活后通过完成任务累加等级从而获得等级奖励的系统。我们首先分析以下该系统的行为在一定条件下通常为玩家通过了某个关卡或者达到了某个等级后激活玩家可以点击进入通行证界面查看任务列表领取通行证奖励玩家完成任务后增加通行证经验通过经验增加等级后玩家可以点按按钮领取等级奖励时间到时通行证结束暂时忽略掉通行证的升级、购买经验等等其他机制单独观看这些行为先单独观察这些行为可以看到这一整套行为覆盖了激活-更新-结束的生命周期同时其还和关卡系统或者等级系统、和奖励系统形成了联动等级或关卡激活、领取奖励那么我们就可以通过这些大致画出如下的生命周期流程图关卡或等级提升点击领取通行证未结束的情况下时间到等待下一次开启通行证活动开启激活通行证任务和奖励玩家行为完成任务得到奖励活动激活时间倒计时活动结束那么在得到这张生命周期图后我们需要做的是什么呢首先我们需要做的就是定义生命周期循环函数即在脚本中用哪个函数去作为该脚本的生命周期函数用于支撑这个系统的核心流程。在分析一个已有的系统时也是一样首先判断哪个函数是该系统的生命周期函数之后对该系统的分析就直接以这些函数为核心去分析。生命周期函数设计先用最基本的设计模式单例模式来统一管理该系统的生命周期。首先我们需要使用一个新的泛型单例脚本MonoSingleton用于让继承该脚本的脚本能够使用单例模式具体设计内容不在本文的讨论范围内如有需要请自查。那么我们回过头来看通行证系统——之前分析出了六个行为激活、加入、完成任务获取经验、升级、领奖、到期结束。这六个行为正好对应六个生命周期函数。先把它们一字排开函数名直接反映行为本身Activate() → 激活通行证 JoinPass() → 玩家正式加入 FinishPassMission() → 完成任务获得经验 LevelUp() → 经验达标升级 GetLevelGift() → 领取等级奖励 CheckPassEnd() → 检查是否到期 → EndPass()你可能会想这不就是把需求翻译成了函数名吗对就是这么直接。但关键不是名字而是每个函数的内部结构——它们都遵循同一个三步走的模式检查当前状态 → 修改数据 → 存档。下面把全部六个函数的完整代码列出来你可以逐一对比它们的结构publicvoidActivate(){if(save.StatePass_Statement.InActive)// 状态守卫{save.StatePass_Statement.Active;// 修改数据var_timeTimeContoller.TIME_GetTime();save.PassOpenTime_time.Ticks;SaveData();// 存档}}publicvoidJoinPass(){if(save.StatePass_Statement.Active)// 状态守卫{save.StatePass_Statement.Joined;// 修改数据SaveData();// 存档}}publicvoidFinishPassMission(intMissionExp){if(save.State!Pass_Statement.Joined)return;// 状态守卫save.LeftExpMissionExp;// 修改数据while(save.LeftExpNextLevelExp){LevelUp();// 可能触发连锁升级}SaveData();// 存档}publicvoidLevelUp(){save.LeftExp-NextLevelExp;// 扣除经验save.PassLevel;// 升一级}publicvoidGetLevelGift(){intSpanLevelsave.PassLevel-save.HasGetGiftLevel;// 遍历未领取的等级发放对应奖励奖励逻辑暂略}publicvoidCheckPassEnd(){if(save.StatePass_Statement.Joined)// 状态守卫{if(TimeContoller.TIME_JudgeOverTime(PassEndTime)){EndPass();// 到期 → 结束}}}publicvoidEndPass(){save.StatePass_Statement.End;SaveData();}仔细看每个函数的开头——Activate检查State InActiveJoinPass检查State ActiveFinishPassMission检查State Joined。这就是生命周期函数的门禁通过使用Enum状态机能够保证每个函数只在自己该出现的那一环生效。如果你在错误的时机调用了它们函数要么被 if 挡在外面什么都做不了要么直接 return 忽略。这样一来整个系统的行为就被严格约束在了一条清晰的链条上InActive → Active → Joined → End。辅助用系统结构状态机这个状态机是一个简单的有限枚举状态机内容如下publicenumPass_Statement{InActive,// 未激活Active,// 已激活等待玩家加入Joined,// 玩家已加入正在做任务End,// 已结束ERROR// 异常状态}如果有需要你可以将其改为无限状态机用无限状态机的情况下你就需要在状态脚本被加载的时候更新一次Manager内的状态。Save持久化而save对象类型为Pass_Save就是这些函数共同操作的数据载体也是玩家持久化数据内容它的字段结构如下[Serializable]publicclassPass_Save{publicPass_StatementState;// 当前状态publicintPassLevel;// 玩家当前等级publicintHasGetGiftLevel;// 已领取奖励到第几级publicintLeftExp;// 剩余经验publiclongPassOpenTime;// 通行证激活时间存 ticks}所有生命周期函数读写的都是这个对象的字段没有任何函数直接操作 UI 或者别的系统——它只负责数据变了没有和现在该不该变展示的事情交给面板脚本去做。之后该系统提供了以下几个函数用来实现数据的保存和读取行为SaveData()函数可用于保存将数据保存为PlayerPrefs键值对它的完整实现如下publicvoidSaveData(Pass_SaveNewSavenull){if(NewSave!null){saveNewSave;}// 可选整体替换if(save!null){string_saveJsonUtility.ToJson(save);// 对象 → JSON 字符串PlayerPrefs.SetString(PASS_SAVE_KEY,_save);// 写入本地存储}}LoadData()函数读取PlayerPrefs键值对并写入到Save变量中每次游戏开始时调用protectedoverridevoidAwake(){base.Awake();// 单例初始化if(LoadData()false)// 读不到存档 → 首次运行{savenewPass_Save();save.StatePass_Statement.InActive;save.PassLevel0;save.HasGetGiftLevel0;save.LeftExp0;save.PassOpenTime0;}}publicboolLoadData(){string_prefSavePlayerPrefs.GetString(PASS_SAVE_KEY);if(_prefSave)returnfalse;// 键不存在返回空串saveJsonUtility.FromJsonPass_Save(_prefSave);returntrue;}由于仅做演示这里仅仅是通过PlayerPref将数据保存到注册表里如果可以的话还是尽量制作存档管理器来将Json保存为文件形式并进行读写吧其他需要注意的内容PlayerPrefs.GetString()在键不存在时返回的是空字符串不是null。所以判空时写成 别写成 null——否则永远进不到初始化分支。DateTime类型不能直接用JsonUtility序列化它会变成空对象{}读回来数据就丢了。所以我在这里把PassOpenTime的类型从DateTime改成了long——存盘时把DateTime.Ticks一个长整数写进去读取后要用时再new DateTime(ticks)还原。最后再看一下FinishPassMission里的while循环——为什么要用 while 而不是 if因为一次任务奖励的经验可能够连升好几级。如果只写 if就只能升一级剩余经验白白浪费。用while(save.LeftExp NextLevelExp)就能一口气升完while(save.LeftExpNextLevelExp){LevelUp();}而NextLevelExp用于表示提升到下一级时需要的等级这里因为仅用作演示就使用简单的数学计算了在实际项目中通行证的等级通常会用一张表格或者单独一个数值表示privateintNextLevelExp{get{int_expsave.PassLevel*2;// 当前等级 × 2_exp_exp6?6:save.PassLevel*2;// 最低不低于 6return_exp;}}LevelUp()升一级NextLevelExp就自动跟着涨while 的判断条件也随之变化循环在经验不够下一级时自然结束。总结到这里我们就完成了一个完整的通行证系统核心骨架。整个PassManager的完整结构如下PassManager : MonoSingletonPassManager ├── 字段 │ ├── Pass_Statement statement // 活动状态运行时查看用 │ ├── Pass_Save save // 存档对象核心数据 │ └── DateTime PassEndTime // 活动截止时间 ├── 初始化 │ └── Awake() → LoadData() / 首次初始化 ├── 生命周期函数 │ ├── Activate() // 激活 │ ├── JoinPass() // 加入 │ ├── FinishPassMission(int) // 完成任务 │ ├── LevelUp() // 升级 │ ├── GetLevelGift() // 领奖 │ ├── CheckPassEnd() // 到期检查 → EndPass() │ └── EndPass() // 结束 └── 持久化 ├── SaveData(Pass_Save?) // 存档 └── LoadData() → bool // 读档有了这个骨架要加新功能——比如购买经验、VIP双倍经验、每日任务——只需要在对应的生命周期函数里添几行代码或者新增一个函数插到状态链中主干完全不受影响。之后我们就可以根据这个生命周期函数设计面板的开启和关闭以及其他杂项的东西了