这是我们完成关卡序列化、场景存档功能后进一步的功能。
现在我们的游戏还是关卡制,关卡结束后场景全部被销毁。如果一个关卡做不长就切场景,然后关卡结算,感觉特别敷衍。把游戏做成一个个关卡,关卡结束后只有奖励等信息保存下来是比较容易的。
我们要允许玩家在一个关卡里在不同地图里传送,传送过去后里面有道具、NPC、敌人,玩家改变其中一些对象的状态,可以保存下来。
箱庭多场景游戏中玩家可以在任意时刻通过传送点到其他场景的对应传送点。需要一直维护所有场景当前的状态,包括里面的道具、NPC、敌人、任务点分布。每个场景一个文件。玩家的操作,比如开启了任务,会对未开启的场景产生影响,可能是放置NPC、敌人,如果是任务目的地在那个场景,先提示玩家传送到该场景,然后标记目的地。
地图系统完善
在此之前需要先升级一下小地图,让它显示锚点位置,而且小地图显示不了全地图,需要一个大地图界面。
小地图应该有一个函数,接收一个大世界位置和使用的图标,把它显示到小地图上。那么小地图控制器维护一个MarkBehavior列表,里面能拿到物体的世界位置,换算后显示到小地图。考虑到被显示对象可能在任何时候出现、消失,应该让对象把自己注册到列表。而不是让小地图控制器去收集。
小地图还要维护一个在显示的Image列表,物体出现、消失,Image也显示、消失。
传送系统
需要先做传送系统,传送点可以设计为通往固定的传送点或者可选通往哪个传送点。对于海岛地图,传送点是码头、船,那么应该可以任意选其他地图的任意锚点。那么还要设计一个界面让玩家选去哪个岛的哪个锚点。
现在大地图已经可以显示锚点,鼠标放上去显示标记的描述。我们设想的传送界面是和锚点交互后出现地图列表,点击后显示该场景大地图,上面有锚点标记,点击后弹出对话框:是否去往该锚点?
现在按M的大地图是鼠标进入标记后显示描述。我们希望复用这个UI,不同的就是标记的行为。那么首先我们把鼠标进入、鼠标离开、鼠标点击里面弄成执行委托。
现在锚点执行交互>弹出地图列表>点击选项>弹出大地图,此时大地图的模式是可选锚点。不对,此时显示的大地图不是当前所在的地图,而是没有加载的,它的锚点物体也没有加载,而是在terrain预制体下,现在的MarkBehavior无法用于显示未加载场景的物体到地图上。
到了这一步,我们不能再把锚点作为terrain的子物体了!锚点数据需要放到地图配置里面。然后我们现在地图配置用的总表,一个地图的配置数据量开始大了,应该拆分成分表了。
锚点管理工作流
还需要指定锚点管理的整条工作流:在场景里摆好锚点,由一个工具,可能是编辑器窗口或MonoBehavior检查器收集锚点数据,找到当前场景的配置,反序列化,把锚点数据写入,保存。
进入场景时根据场景配置生成锚点。
之后我们再做输入一个场景配置,生成大地图和上面的锚点标记功能。
不,在那之前我们先把现在的场景总配表拆成多个独立配置。这里场景总配表也并不是很大,可以用总表,但是编辑器编辑体验很差,所以我想要的是SO编辑器用分立文件,导出时收集所有SO文件生成一个json。
我们发现,永远都是写死、强引用很方便,一遇到复杂功能就不行。
地图界面两种使用情景
现在地图按M打开是显示信息,在锚点打开,可以点击上面的锚点传送过去。那么最终标记的行为,根据模式不同,是不同的。
还有生成锚点的逻辑,不是由维护的场景被显示对象字典,而是从那个地图的配置里读取锚点位置。这里MVC又用上了,视图是复用的,由不同的控制器加载。
锚点控制器,玩家选地图后,去地图配置管理器得到地图图片和锚点位置,然后还要换算位置,得到换算公式的参考点也在地图位置里,那么我们直接给地图配置加一个方法,就换算位置。这里就是这个操作用的数据属于哪个类最多,就作为哪个类的方法。
传送
该写传送函数了,传入场景名字、锚点序号,然后传送过去得机制关卡,现在程序还是只维护当前的关卡状态。那么关卡状态管理器改成字典维护多个场景的关卡状态。
还要解决传送进入场景、初始进入场景、加载存档进入场景设置玩家位置的不同操作。传送进入,有一个锚点索引,去地图配置查到位置,把玩家放那,初始进入,要从多个随机位置中选一个,加载存档进入,放到存档里存的位置。
我们为了验证功能先粗暴的把场景切过去,然后MyGameMgr会查当前的关卡状态,如果不处理还会加载之前场景,敌人、NPC、道具那些全部和地形错位。所以切场景前必须切换关卡状态数据。但是这个数据从哪来?现在游戏还是分立关卡的。那么现在玩家一进入游戏,所有场景都有一个关卡状态,只是除了玩家呆的场景,其他场景是一个默认关卡状态,看起来是个空地图。
然后现在的关卡状态文件我们可以叠加加载,原本那个场景是空地图,现在我们触发一个任务,我们写一个叠加函数,对里面的对象list、dictionary叠加。
我们发现传送时玩家的血量、装备、背包物品都是不变的,只有所处的场景、位置变了,那么我们直接从起点场景状态数据的玩家状态拷贝一份,这里不能是赋值,导致同时修改两个场景的玩家状态。需要深拷贝。懒得写深拷贝,就json序列化然后复原回来。
任系统务的修改:从任务Behavior中心到任务管理器中心
现在传送到目标场景不报错了,但是目标场景是空地图,之前开启的任务也没有了。现在任务是MonoBehavior,直接放在任务目的地。那么任务需要改成非MonoBehavior,它本质是个数据类,目的地位置是它一个字段,且现在目的地如果不在同一个场景也不应该显示目的地标记。
我看了看,移动到目的地的任务还需要触发器,如果任务不是MonoBehavior,那还得再弄一个MonoBehavior放在目的地。然后这个类还得写场景序列化、还原。
我们需要的是传送到另一个场景,任务数据还在,但是任务MonoBehavior不在此场景。那么我们进入场景后开启任务不是靠这个场景里的任务MonoBehavior状态是不是open,而是状态文件记录的。任务管理器记录开启的任务也不是记录任务MonoBehavior,不在当前场景的任务就不存在MonoBehavior,而是就记录任务Id+进度。
现在的开启任务,进入场景后读取玩家状态里已开启的任务,任务MonoBehavior不是由恢复关卡状态时创建,而是知道玩家开启的任务后,如果这个任务的场景和当前场景相同,则创建任务MonoBehavior。
之前MissionBehavior的设计是只要在这个场景,不管没开的、开启的、完成的任务都在场景里,会开启的任务和场景锁死。流程是:读取关卡状态>创建任务Behavior>Behavior在Start发现自己的状态是Open,调用任务管理器Open自己。
修改后的流程:任务管理器从玩家状态存档得到任务列表>拿到任务配置>对比配置里的场景名和当前场景名>一致,则让任务配置管理器创建任务Behavior。
没开启的任务,即使在这个场景,也不生成Behavior。马上想到这样设计导致一个问题就是我们是用事件发布监听开启下一个任务,如果下一个任务不存在,那么没有监听者,就无法触发。那么还是把事件发布监听功能都转移到任务管理器。
那么需要修改: 复原关卡状态不要创建任务Behavior,让任务管理器检查场景一致后创建。任务Behavior在Start不要开启自己,统一让管理器开启。
就是说我们之前以任务Behavior为中心的任务系统本质就和多场景自由传送游戏不兼容,应该以数据为中心,而Behavior就是任务在自己场景的目的地标记+触发器挂点。
我们之前设计成任务Behavior为中心,因为这个游戏里每个任务都有目的地,MonoBehavior方便标记,且移动到目的地任务必须有MonoBehavior挂载触发器。
如果大部分任务是诸如砍5棵树、捡3个苹果这种没有目的地的,我们一开始就不会设计成Behavior中心。
任务系统事件发布监听的修改:无参事件+动态拼接事件名VS字符串参数事件+固定事件名
现在成功开启了第一个任务,和NPC对话后发布的事件无人响应。要让任务管理器响应此事件。要通过事件让任务管理器知道完成哪个任务,需要从事件信息到任务Id建立映射关系,要么直接通过事件名和任务Id建立关系,要么发布string参数的事件,事件名固定,通过参数判断。
因为NPC对话发布事件我只设计了无参事件,所以任务管理器这边拿前缀和已开启任务的Id拼接出一些事件名来监听。我们写由Id构造事件名的函数:
string BuildOpenEventName(string missionId) { return missionId + "_Open"; } string BuildCompleteEventName(string missionId) { return missionId + "_OK"; }添加监听是在任务进入开启列表时,移除监听是在任务离开开启列表时。然后又发现问题,添加的是完成这个任务的函数,但是完成任务是个有参函数,需要lambda表达式封装才能成无参函数,直接用lambda表达式封装就丢失引用,无法移除了。那么再把它引用起来,弄一个字典Dictionary<string,Action>,把lambda表达式放进去,移除的时候移除这个任务Id下的值。
然后发现NPC对话发布的事件名要重新填了,必须包含对应的任务Id且符合格式。而不能就和任务Behavior监听的事件名一致就行。我们发现我们可以手动填保证它符合完成任务的事件名格式,也可以就填任务Id,由同一个函数组装出事件名,问题是这样这个发布事件就变成完成任务专用的了,要想让NPC对话触发其他效果如移动物品,还要再写发布事件,我不想这样,决定让这个字符串就直接发布,以便可以触发各种效果。那我们就手动符合完成任务的事件名格式。
我们越来越发现之前以任务Behavior为中心,让它监听发布事件,而不是管理器为中心,是一种非主流的设计。
总结一下这一块的难点就是多个各种发布者发布事件,但是要任务管理器一个对象响应,并且执行不同方法,这些方法本质是一样的,但是参数不一样。不过我发现如果发布的是string参数事件,任务管理器只要监听一次就够了,在这个函数里对参数判断开启列表里有没有这个任务Id。比上面发布无参事件,从事件名拆解出参数,还要添加好几个监听(也要移除好几个)要简单。
再总结一下,无参事件+动态拼接事件名,接收方要添加N个监听,且把同一个有参函数+N种参数封装成N个无参函数,用一个字典维护,添加、移除N次监听。
字符串参数事件,固定事件名,接收方准备一个字符串参数函数,添加移除一次监听,函数内判断。
肉眼可见字符串参数事件方案简单的多。
而中途讨论的,函数内部构建事件名,只是无参事件+动态拼接事件名方案下的一种选择,手填+检查的工作能少一点,牺牲了此发布事件的泛用性,不太值得使用。
然后又遇到问题,现在开启任务的函数输入的是一份数据,里面包括任务进度,因为要支持玩家完成一半的任务,加载存档时恢复进度。而开启任务现在传一个字符串,就是任务Id。
通过事件开启的任务一定是新任务,进度为0,所以再写一个接收string的方法,里面new一份数据。
玩家状态数据的迁移
之前做存档功能,在存档里存玩家的位置,那时候是不考虑跨场景。现在把玩家状态数据:所在场景名、位置、朝向、HP、装备,开启的任务提到一个场景无关文件。
然后呢,现在游戏还有分立关卡的残余,开启任务是关卡状态文件里是open的任务。其实原来的关卡初始任务不需要了,我们不会再以进入场景为条件触发一个任务,支持场景自由传送后任务都是一个接一个的。
现在我们用新的任务系统,能跑完一个关卡了,但是我们的目标是没有关卡结束的概念,全部由任务串联起来。即使玩家做完了任务,也可以添加配置文件,下次进入游戏添加任务。未来我们还可能把主菜单场景取消。
那么接下来我们就要把之前结束关卡的任务,改成其他场景的任务了。