【Unity小技巧】触发器与剧情管理器

前言

在以前开发《名为怪物的游戏》中介绍了「触发器」的概念:事件触发器

原来的触发器是模仿 RPG Maker 的事件页系统。
但是后来我发现这种模式很难适应复杂场景。

例如,玩家同时完成了任务 A 和 任务 B。
接着与发布任务的 NPC 对话,用原来的事件页系统,只能处理其中一个任务,另一个任务会被忽略。
即玩家提交了 A 任务,事件就“翻页”了,翻页的过程直接把事件 B 那页给跳过了,导致事件 B 无法触发。

也就是说,事件页系统每次只能处理一个事件,无法胜任多个事件的情况。
为了解决这个问题,我想到用「队列」来实现。

剧情系统是游戏中最复杂的系统之一,因此本文会用较多篇幅介绍基本技能需求。

队列系统

队列是在 WEB 开发或者其他服务器开发中的概念。
拿一个简单的例子来举例,比如注册账号要发送短信验证码。
发送短信验证码的操作比较费时间(大概要3~5秒)。
但为什么我们注册点了“发送短信”按钮页面却立即响应了?

这是由于系统并不是真正发完短信了,而是先给你一个提示,然后把发送短信的任务延后处理。
不然用户点击发送短信,页面卡顿 3~5 秒,会造成不好的体验。

发送短信的任务会推送到服务端进行处理。
这就存在一个问题了,如果存在很多个用户同时点击发送短信呢?
服务器一次性就要接受 N 个发送短信任务,如何处理?

并行处理的话,一次性处理 N 个任务,这个 N 可能是一个极为庞大的数字。
如果任务太多,服务器处理不过来就崩掉了。

所以,任务的处理需要有一个『调度器』。
调度器也就是“指挥中心”,意思是说,任务先囤在一个“仓库”,然后又指挥中心分配任务。

好比京东的物流,快递员并不是直接把货物从商家那边发给买家。
而是先把货物送到该城市的仓库中心,然后由该仓库分配快递员送到买家手里。
(如果用淘宝比喻的话,就是货物发到菜鸟驿站,然后买家自己上门取件)

这样做的好处是对“货物”可控。
比如一个仓库最多存储 100 件商品,超过 100 件就拒收。
如此一来,这间仓库里的货物永远不会超过最大存储量,换句话说就是不会“爆仓”。

同理,对服务器来说,也不能“爆仓”,爆仓就直接挂掉了。
因此我们需要有一个调度中心来分发任务。

用来存储货物的仓库可以有很多个,每个仓库最多容纳 100 件商品,那么 10 个仓库就能容纳总计 1000 件商品。
在服务端也是同样的,一台服务器可以同时处理 100 个发送短信任务,那如果有 100 台服务器就可以同时处理 1000 个短信任务。

货物发到哪个仓库,有一个调度中心在控制,这就是京东或者淘宝合作的快递的物流网络。
而服务端负责调度的地方,叫做『负载均衡』。

负载均衡就如同字面意思。
『负』可以理解为“负重”,就是一个人能承担的任务繁重程度,比如 100ml 水。
『载』就是承载的意思,比如能装 100ml 水的空瓶子。

负载均衡做的事情就是把 100ml 水装到能装 100ml 水的瓶子里。
并且负载均衡器是“智能”的,例如有一个 250ml 的空瓶子,第一次装瓶会装 100ml,第二次发现它还没装满,就再装 100ml,第三次发现它还是没满,但只能装 50ml 了,那就再装 50 ml,直到瓶子“饱和”为止,就不再装水了。

现在负载均衡的概念也已经十分清楚了,接着回到发送短信的问题上面。
首先,用户点击发送短信按钮,服务端就接收到一个发送短信的任务
这个任务该由谁来完成呢?当然是空闲状态的服务器了。
调度中心会把这个任务转发给空闲的服务器处理。
空闲的服务器接收到任务之后,就开始真正执行发送短信的操作。

空闲状态指的是“未饱和”,即还没达到最大承载的“重量”。比如老板剥削打工人:你那么早下班干嘛?今天结束了吗?你就走了?给我干到 23:59:59 再下班!换句话说,只要没有被 「压榨到极致」 那它就属于“未饱和”状态。

上面涉及到三个概念:

  • 任务创建者:即用户本身,因为用户点击了发送短信,制造了一个新的任务
  • 调度中心:负责分发任务给空闲的服务器
  • 任务消费者:即处理发送短信的服务器

我们由此可以得到基本概念:

任务队列概念

客户执行了某个操作,制造了需求,老板就把任务分配给打工人完成需求。
现在已经捋清楚了,回到文章开头的问题:

如果玩家同时完成多个委托,准备向 NPC 提交任务领取奖励,如何实现每次只领取其中一个,并且其他任务不会被略过?
任务队列本来是为了处理高并发场景流量分发的问题,但是在这里我们利用调度器每次处理一条任务:

委托任务执行远离

未领取奖励的任务依然保存着完成的状态,当其中一个任务被消费之后,玩家再次与 NPC 对话,就从剩下的任务中消费一条,反复这个过程,直到所有任务的奖励都领完为止。

任务队列还有一个“优先级”的处理机制,可以给不同的任务分配优先级。
当一次性完成多个任务之后,如果我们希望优先执行“剧情”任务而不是“支线”任务,那只要让剧情任务的优先级更高即可。

由于我们是单机游戏,因此队列任务的状态需要保存在“存档”里面。

剧情的统一管理

剧情系统极其复杂,如果直接在一个代码脚本实现,那可能超过上万行。
为了方便后期维护,我们需要对剧情单独进行管理,然后用上面调度器的思想来执行调度。

这里可以参考我之前写的:Prefab 的妙用

为避免脚本文件出现太多的无关逻辑,需要把无关部分的代码拆分出去。
例如玩家进入场景就会自动触发剧情,虽然剧情是在场景触发的,但不能把剧情代码写在场景的脚本。
(如果这么做,场景脚本就多出来许多无关场景的代码,显得极其臃肿)

在 WEB 开发中,Controller(控制器)里面一般也不会写业务代码。
而是追加一个 Service(服务)层来处理业务逻辑。
这样做的目的就是把无关的代码拆出来,避免项目长期更新导致代码混乱不堪。

选用 Prefab 再挂载对应的逻辑代码组件就是一个非常不错的方法。

具体的思路如下。

剧情的逻辑处理

首先需要把剧情先用脚本实现,比如控制角色在场景移动、播放动画、播放对话等等。
然后让这个脚本继承 MonoBehaviour,写完就可以了,不需要挂在物体上面,因为我们要动态添加脚本。
脚本取名为 Story_001

1
2
3
4
5
6
7
8
9
10
11
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Story_001 : MonoBehaviour
{
public void Start()
{
// .. 这里执行剧情事件
}
}

空预制体

直接在场景创建一个空的 GameObject,命名为 EmptyPrefab
不需要其他操作,直接拖到 Resources/Prefab 文件夹即可。
或者使用 AssetBundle 加载的方式,可以参考:资源的加载:Resources 和 AssetBundles

如果没有学过 AssetBundle,可以看上面的文章或者直接用 Resources 就可以了。

动态添加组件

我们需要让 Story_001 这个脚本动态挂在 EmptyPrefab 物体上面。
可以用下面的方法:

1
2
3
4
5
6
7
8
9
10
11
12
// 读取预制体
var prefab = Resources.Load("Prefab/EmptyPrefab");

// 实例化游戏对象
var obj = Instantiate(prefab);

// 动态添加脚本
var componentName = "Story_001"; // 将脚本名字以字符串形式即可

// 接着让上面的字符串“转为”组件
var type = Type.GetType(componentName);
var component = obj.AddComponent(type) as Story_001;

上面就实现了动态添加组件的功能。
我们只要实例化预制体,一旦实例化完成就会执行 Start 里面的剧情事件了。

我们现在成功的把剧情的逻辑转移到了 Story_001 这个脚本上。
这个脚本不论你写多少行代码都没关系,因为是完全独立的,它只处理一段独立的剧情。
如果你要进一步的拆分也是可以的,取决于你个人了。

用 Prefab 制作剧情的好处就是可以非常方便的测试某一段剧情。
后期如果对剧情进行改动,也只需要改这个 Story_001 就够了。
剧情片段实现完全的独立,改动它不会影响到其他任何程序,而其他任何程序的改动也不会影响到它。
这是开发的最优解。

剧情调度器

上面已经实现了一个脚本控制一段剧情。
接下来就要实现任务的调度器了。

调度器是基于「队列」的数据结构。
队列是一种先进先出的模式。

在 C# 中就已经实现了队列数据结构:https://www.runoob.com/csharp/csharp-queue.html
任务调度器的作用就是从队列中取出一条任务,然后分发给对应的处理器处理。

因为我们限制了每次只处理一条任务,因此调度器的作用就被弱化了。
(不需要考虑到负载均衡,因为每次就固定处理一条任务)

这里既可以单独把调度器封装成一个脚本,也可以直接在处理器上面写调度代码。
当玩家找到 NPC,并且点击“提交任务”的时候,就执行调度器处理任务。

任务队列

任务队列是一个数据结构,上面我们用 Prefab 来实现剧情。
我们只需要保存对应剧情的组件名称即可,即:Story_001
知道这个组件的名称就可以实例化对应的 Prefab 处理剧情。

可以使用 List<string> tasks 来存储组件名称,并且它是保存在存档的数据上面的。

推送任务

当玩家完成了一个任务,就推送一个任务到队列里面。
比如玩家接受 NPC 委托,要求打死 5 只史莱姆。
玩家打完史莱姆,就把完成任务的剧情脚本 Story_001_Completed 存储在 tasks 变量里。

任务调度

假如这个 NPC 还有第二个委托,打死 5 只哥布林。
玩家同时接受了史莱姆和哥布林任务。
为了少跑一趟,玩家打死了史莱姆和哥布林之后才回到 NPC 这边提交任务。

此时 tasks 里面就有两个脚本 Story_001_Completed(完成史莱姆任务) 和 Story_002_Completed(完成哥布林任务)。
这两个要优先执行哪个呢?

直接存储 List<string> 形式无法判断优先级,因此我们可以再定义一个任务基类。

1
2
3
4
5
6
7
8
public class Task 
{
// 对应的脚本名字
public string component;

// 权重值
public int weight;
}

推送任务的时候可以设置 weight(权重值),然后根据权重值进行排序,优先处理数值较高的任务。
任务处理完成后再把它从 List<Task> 移除即可。

总结

比起原来用事件页判断条件然后一页页执行的方式,本次更新的方法则是将已经判断成功的事件,存储在队列中,然后以实例化预制体、动态添加脚本组件的方式来处理,每次从已完成的事件中取出一个,执行完成后就把它从队列中移除,如此一来,无论有多少个事件,总是能一个个进行处理,并且在任务数量较多时,可以用权重值的方式对任务进行优先级的排序。

文章作者: 火烧兔子
文章链接: http://huotuyouxi.com/2022/01/08/unity-tips-028/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 火兔游戏工作室