3D沙盒游戏开发日志10——设计世界中所有物体的工作方式

日志

回顾之前的内容基本都是在处理和人物相关的事情,现在是时候放眼全局来考虑整个游戏世界的物体是如何工作的以及他们之间是如何交互的,这
个部分涉及到的内容很多,有很多都值得单独分析,所以这篇只记录整体的设计思路,至于其中的一些复杂部分会在接下来的日志中会逐个分析,
先了解整体规划后就更容易明白在每个部分中的一些细节处理的原因

代码结构

请添加图片描述
这个图描绘了整个世界的中生物的工作方式,我将从这张图中逐个分析,至于非生物(物体)的工作方式我也会简单介绍因为它们也是设计中的重要考虑部分

现状和期望

世界中的生物分为两类,一类是由AI程序控制的生物,一类是由玩家控制的角色,我们设计的目的就是让这两类生物尽可能的重用相同的代码或者使用同一套系统工作,这样不仅能减少代码量,也能减少bug,debug也轻松的多,最重要的是符合游戏的世界观,我一直在用“生物”这个词而不是敌人,就是说生物之间的行为不应与它和人物之间的行为区别开(除非有意特殊设计某些boss等),所以我们应该尽可能的让人物在代码上也像一个生物
事实上,经过分析我们可以看到角色和生物的唯一区别就是决策层,所以我们应当尽可能的将其它部分从决策层中分离出来并共用。

Input

负责玩家的输入,实际上这部分输入只会影响到角色,对应的类就是之前提到的InputController以及一些ui输入(比如背包),只不过它将不再直接操作ActionController,而是操作一个介于决策层之上的抽象层——事件层,换句话说,每一个输入都是一个事件,与场景中的其它事件是完全一样的,具体处理取决于决策层对事件的处理

EventHandler

整个体系中最重要的结构之一,派生自MonoBehaviour,每个生物都会挂载该脚本,负责事件的处理,事件是生物间最重要的交互方式之一。它的代码非常简单,只负责注册和分派事件

public abstract class BaseEvent{
    public abstract string name { get; }
    //每个事件产生的效果=fn+EventNode(动画)
    //fn为执行实际效果的函数,有些事件可能不会触发动画但仍要产生效果
    //比如某boss在召唤时受到伤害,则不会有受击动画,但仍应当受到实际伤害,对应的代码应放在fn中
    public abstract void fn(GameObject gameObject, params object[] args);
}
public class EventHandler : MonoBehaviour
{
    public delegate void Eventfn(params object[] args);
    private Dictionary<string, Eventfn> events;

    void Awake()
    {
        events = new Dictionary<string, Eventfn>();
    }

    public void ListenEvent(string name, Eventfn fn)
    {
        events[name] = fn;
    }
    public void ListenEvent(BaseEvent e)
    {
        ListenEvent(e.name, (object[] args) => { e.fn(gameObject, args); });
    }
    public void RaiseEvent(string name, params object[] args)
    {
        if(!events.ContainsKey(name)) return;
        events[name](args);
    }
}

事件的注册在两个地方,一个是决策层中,决策层注册的事件基本都是需要执行比较复杂的程序(需要每帧调用)并且有对应动画或音效(决策层与动画层直接相关);另一个是直接在PrefabComponent中注册(PrefabComponent后面会具体介绍),这类事件往往比较简单只有一个简单的函数。
第一类事件较为复杂,放在决策层中具体讨论,
对于第二类简单事件这里举个小例子,石头人会监听它附近是否有人在采矿(攻击采矿者)就可以通过第二类方法注册

EventHandler eventHandler = gameObject.AddComponent<EventHandler>();
eventHandler.ListenEvent("hasmineraround", OnHasMinerAround);

private void OnHasMinerAround(params object[] args)
{
    if(GetComponent<Combat>().IsValidTarget()) return;
    GameObject target = args[0] as GameObject;
    gameObject.GetComponent<Combat>().target = target;
}

事件的引发主要发生在Component中和上面提到的Input层,生物之间的交互只发生在Component之间以及通过Event系统,不同生物直接访问决策层或者动画层是不允许的。对于一些简单行为(只涉及数据或者一些底层的交互)可以通过直接调用对方某个Component中的函数来解决(比如扣血);但复杂行为需要调用对方的EventHandler来引发具体事件,比如收攻击后每个生物都会有不同的表现,就需要引发对方的“gethit”事件,如果对方没有注册对应的处理,则事件就会被忽略。

决策层

这个部分的具体细节非常多,我会在之后单独写一篇日志来分析,这是人物和生物之间唯一区别的地方。Brain或许不难理解,它实际上就是BehaviourTree(行为树),每个Brain都包含一颗bt(可以算是一种封装),并提供一些方便的对bt的操作以及调试,所有生物的Brain都从Brain基类派生并构造自己的bt。但EventController可能让你感到困惑,人物显然不需要行为树,{值得一提的是,虽然我希望让人物和其它生物“浑然一体”,但在代码命名上我保留了以Controller结尾的人物命名方式并有专门的人物命名空间(和之前写的人物一样)},事实上EventController是专门抽象出的一个层,来专门填充“决策层”使人物能拥有与其它生物相同的结构,换句话说,人物所有的事件都要经过EventController的“筛选”才能真正作用于动画层和Components,EventController也提供了简单的机制去确定一个行为是否要被执行

动画层

这一层实际上正是我之前大费篇幅所写的ActionController系统,但之前那个只适用于人物,我对他做了一些修改使它更加适应现在的工作架构

  1. 将原本的人物Action中大量的实质工作性代码移出,转移到相应的Component中,比如现在的LocomotionController只主要负责动画和音效,具体的移动移交到Locomotor(GameComponent)中,这样即便石头人和人物的移动动画完全不同也能共用Locomotor中的功能性代码
  2. 取消ActionController的优先级系统,显然决策层已经提供了更复杂的系统来决定行为的执行顺序,行为树本身就是更复杂的优先级,动画层现在只需要简单的“服从命令”,执行对应的动作
  3. ActionController提供了更多的功能来满足其它生物的需求,比如提供一些事件来通知动作切换,获取当前正在做的动作,更新当前动作的参数等

Component(GameComponent)

在之前的人物代码中就已经涉及过这一部分,这并不是指挂载在游戏物体上的广义Component,具体的来说它是GameComponent,它具有以下特征。

  1. 大部分GameComponent的代码都很少而且不需要监听Unity的事件(update等),它们提供的往往是功能性的函数(如Locomotor)或储存一些状态量(如Health)
  2. 它们也被作为“标签”,之前已经说过这是一种减少耦合的方式,即只关心你所挂载的Component而不关心你具体是谁
  3. 此外这类Component不会直接挂载在物体上,它们只会被PrefabComponent动态加载,并且它们的初始化和数据持久化也都是由PrefabComponent来负责的,换句话说PrefabComponent是所有GameComponent的管理者,也是整个世界中唯一知道每个物体身上有哪些GameComponent的“人”
  4. 通常一个物体上会有很多的GameComponent(几十个)

这部分我之后也会专门有一篇日志来讲解,尤其是PrefabComponent的设计,它不仅作用于生物也作用于非生物物品,是整个世界的基石。

总结

我已经简单分析了图上的全部内容,这一大堆的设计只有一个初心就是为了创造一个低耦合的世界并且尽可能让所有物体遵循同一套规则或至少“有迹可循”,我花了接近两个月的时间来将这套系统付诸实践,期间阅读了很多饥荒的源代码,这套系统中的一部分正是对饥荒中设计的模仿,到今天这套系统已经能如预期般工作,我相信当游戏中的物体越来越多游戏越来越复杂,这套系统就会开始真正发挥它的魅力!
但有些事情还值得在此一提。
任何系统的设计都是理想化的,即在执行过程中会经常有需要违背的情况,这些时候要么调整单个物体,要么在一定程度上模糊规则,我想先简单说说到写本文为止有哪些规则被“模糊”了。

  1. 在最理想情况下,所有的数据都应当保存在Component中,因为确实只有它们的数据会被持久化,无论是决策层还是动画曾都应该访问Component的数据,但实际上有些时候决策层和动画曾会直接访问Constants中的元数据,因为有些数据可能只被使用很少,很难为他们创建专门的Component,它们也不需要持久化(是只读的,但即便如此也应当有一层封装而不是直接访问元数据)
  2. 理想情况下所有的Action都应该只处理动画和音效,或许也应该调用Component(这么说是因为Component也可以由决策层调用,只有有些帧行为才必须要动画层来处理),但实际上有些Action中也涉及到逻辑计算的代码,比如在某个boss召唤小怪的代码我就直接放在了Action中因为我不知道如何把它放到一个单独的Component中并提供配置选项(感觉每个boss的召唤也都大相径庭,无法共用)
  3. 正如第二点中所说,现在在决策层还是动画层中调用Component是很模糊的,好在它们仅模糊与人物与生物之间,即人物主要是在动画层中调用Component,而其它生物主要是在决策层中调用。而且这点差异是完全可以接受的,因为之前已经说过了不同生物之间完全不可能直接访问决策层和动画层,所以这点不同并不会影响到我们让一切生物相同的初衷。
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值