【Unity百宝箱】游戏中的观察者模式

发布时间 2023-12-28 20:28:56作者: 树梢流年

【Unity百宝箱】游戏中的观察者模式

  • Hi,大家好,我是游戏区“bug主”打工人小棋!

图片

 

今天我想和大家聊一聊游戏中的观察者模式~

近两期视频,都是在为下一期视频做准备,在下期视频中,大家将会看到用户数据存储、以及观察者模式在游戏中的大量应用和实践,希望能让你们彻底学会这些设计思想。

  • • bilibili: Json用户数据存档

  • • bilibili: 观察者模式

介绍

什么是观察者模式?

我给举个简单的例子,当同学们想要第一时间得知小棋更新视频了,最好的办法是什么?

给你们5秒钟思考:5 4 3 2 1

最好的办法当然是点个大大的关注,最好是特别关注,因为这样B站会在第一时间把我更新了的消息通知到你们。

有的同学说,我偏不,我就要守着你的主页,每隔1秒钟刷新下页面,我就不信有人比我快!

这样做,不是说不行,甚至在编程领域,还经常这么做。

比如我之前在 8. 阳光拾取 + 僵尸生成 这期视频中,我们在Update函数中,每一帧都去查看阳光数量是否满足需求,满足时才解锁卡片。

public class Card
{
    public int useSun = 25;

    void Update()
    {
        // 每一帧都检查:太阳数量是否足够
        if (GameManager.instance.sunNum >= useSun)
        {
            darkBg.SetActive(false);
        }
        else
        {
            darkBg.SetActive(true);
        }
    }
}

public class GameManager
{
    // 当前阳光数据量
    public int sunNum = 0;
    // 增加阳光
    public void AddSunNum(int value)
    {
        sunNum += value;
    }
}

这样做同样满足我们的需求,也圆满完成了当时任务。

但是大家想想,如果是一个特大型项目,无数个update 都在执行,那将会是一笔巨大的性能开销。

而且数据发生变化时,往往只有一瞬间,比如小棋发了新视频,这个消息的量级是很小的,而你却需要二十四小时全天候守护,这未免付出太大了。

如果你真的这么做,你真的,我哭死。o(╥﹏╥)o

设计

大家可以把B站的这种特别关注,理解成观察者模式的实现。

他包含两大基本要素:订阅和通知。

粉丝们通过关注提前订阅Up主的更新消息,B站在Up主们更新时通知给订阅的粉丝。

这样做的好处在于双方的耦合性极低,发送方只需要通知下去,订阅方就一定会收到消息,至于收到消息后怎么做,发送方不清楚也不关心。

将来有更多人关注小棋了,也就是无脑通知就完事了,粉丝们想要点进来一键三连,或者评论转发都可以。(不点赞的不许走)

代码

废话少说,直接上代码。

  1. 1. 单例

首先,我们的事件中心作为所有事件的管理器,在游戏中是唯一的,因为我们将其设置为单例模式。

单例的介绍和写法网上教程有很多,我这里就举个最简单的写法。

// 事件中心
public class EventCenter
{
    private static EventCenter instance;

    public static EventCenter Instance
    {
        get
        {
            if(instance == null)
            {
                instance = new EventCenter();
            }
            return instance;
        }
    }

    public void test()
    {
        print("test test test")
    }
}

使用时只需要这么写:

EventCenter().Instance.test()

即可看到运行结果:

输出: test test test

  1. 1. 响应事件

当事件发生时,事件中心会把消息通知给感兴趣的订阅者。

这里的订阅者可以使用Unity为我们封装的UnityAction,大家可以简单将其理解为代码中的一个个方法。

当事件触发时,就调用这些方法。

每个事件可能有多个订阅者,比如小棋就有不少粉丝订阅。

因此这里我们使用字典存储所有的事件,字典的key对应订阅名,字典的value对应一个订阅列表。

下面我们对订阅列表进行下封装:

// 使用接口,方便拓展有参事件和无参事件
public interface IEventInfo
{
}

// 无参数事件响应
public class EventInfo : IEventInfo
{
    public UnityAction actions;
    public EventInfo(UnityAction action)
    {
        actions += action;
    }
}
  1. 1. 框架设计

有了响应事件后,我们就可以搭建起事件中心的整体框架了。

在事件中心里,最重要的两个方法:

  • • 订阅消息

  • • 通知消息

public class EventCenter
{
    // 存储所有事件 
    private Dictionary<string, IEventInfo> _eventDic = new Dictionary<string, IEventInfo>();

    // 订阅消息
    public void AddEventListener(string name, UnityAction action)
    {
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo).actions += action;
        else
            _eventDic.Add(name, new EventInfo(action));
    }

    // 通知消息
    public void EventTrigger(string name)
    {
        if (_eventDic.ContainsKey(name))
            if ((_eventDic[name] as EventInfo).actions != null)
                (_eventDic[name] as EventInfo).actions.Invoke();
    }
}

在某些时候,我们还需要对无用的消息进行清除,比如取消关注。

    // 移除无参数事件的监听
    public void RemoveEventListener(string name, UnityAction action)
    {
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo).actions -= action;
    }

    // 清空事件监听
    public void Clear()
    {
        _eventDic.Clear();
    }

以上所提的方法都是无参方法,相当于up主更新视频后,B站只通知粉丝:

小棋更新视频啦~!

但是更新了什么视频却不得而知。

想要在通知的消息里得到更新的视频内容,还需要添加有参方法,在通知时把视频信息一起发送过去。

这里用到了C#语法中的泛型,具体逻辑和无参方法几乎一样。

// 带参数事件响应
public class EventInfo<T> : IEventInfo
{
    public UnityAction<T> actions;

    public EventInfo(UnityAction<T> action)
    {
        actions += action;
    }
}

public class EventCenter
{
    // 数据结构
    private Dictionary<string, IEventInfo> _eventDic = new Dictionary<string, IEventInfo>();

    // 添加带参数事件的监听
    public void AddEventListener<T>(string name, UnityAction<T> action)
    {
        // 旧事件
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo<T>).actions += action;
        // 新事件
        else
            _eventDic.Add(name, new EventInfo<T>(action));
    }

    // 移除带参数事件的监听
    public void RemoveEventListener<T>(string name, UnityAction<T> action)
    {
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo<T>).actions -= action;
    }

    // 分发带参数的事件
    public void EventTrigger<T>(string name, T info)
    {
        if (_eventDic.ContainsKey(name))
            if ((_eventDic[name] as EventInfo<T>).actions != null)
                (_eventDic[name] as EventInfo<T>).actions.Invoke(info);
    }
}

使用

还是以刚刚卡片的这个逻辑来举例,我们一起思考下:如何以观察者模式重构这段代码。

public class Card
{
    public int useSun = 25;

    void Update()
    {
        // 每一帧都检查:太阳数量是否足够
        if (GameManager.instance.sunNum >= useSun)
        {
            darkBg.SetActive(false);
        }
        else
        {
            darkBg.SetActive(true);
        }
    }
}

public class GameManager
{
    // 当前阳光数据量
    public int sunNum = 0;
    // 增加阳光
    public void AddSunNum(int value)
    {
        sunNum += value;
    }
}

首先分清两件最关键的事情:

  1. 1. 谁订阅消息

  2. 2. 谁触发消息

在上述情境中,不难分析得到:

  1. 1. 卡片需要订阅消息,当阳光改变时,他需要检测下卡片是否解锁。

  2. 2. GameManager需要发布消息,当阳光数量改变时,他需要广播这个消息。

经过上述分析后,我们可以得到重构后的代码:

public class Card
{
    public int useSun = 25;

    void Start()
    {
        // Start中添加消息监听,消息命名为: EventSunNumChange
        EventCenter.Instance.AddEventListener<int>("EventSunNumChange", CheckUnLock);
    }


    public void CheckUnLock(int num)
    {
        print(">>>>> Listen EventSunNumChange");
        // 只在阳光改变时检查:太阳数量是否足够
        if (num >= useSun)
        {
            darkBg.SetActive(false);
        }
        else
        {
            darkBg.SetActive(true);
        }
    }
}

public class GameManager
{
    public int sunNum = 0;
    // 当阳光数量发生改变时,触发消息通知
    public void AddSunNum(int value)
    {
        sunNum += value;
        EventCenter.Instance.EventTrigger<int>("EventSunNumChange", sunNum);
        print(">>>>> Trigger EventSunNumChange");
    }
}

总结

最后来测试下效果,在植物大战僵尸游戏中:拾取阳光会增加阳光的数量,而卡片需要阳光数量满足条件时才会解锁。

经过验证,消息得到了正确的触发和响应。

代码中的Log也打印出来了:

>>>>> Trigger EventSunNumChange
>>>>> Listen EventSunNumChange

最后把框架的主体代码贴出来:

using System.Collections.Generic;
using UnityEngine.Events;

// 事件响应空接口,用于支持可有可无的参数类型
public interface IEventInfo
{
}

// 带参数事件响应
public class EventInfo<T> : IEventInfo
{
    public UnityAction<T> actions;

    public EventInfo(UnityAction<T> action)
    {
        actions += action;
    }
}

// 无参数事件响应
public class EventInfo : IEventInfo
{
    public UnityAction actions;
    public EventInfo(UnityAction action)
    {
        actions += action;
    }
}

// 事件中心
// 负责注册(监听)事件、分发(触发)事件
// 事件支持 带参数 和 无参数 两种
// 带参数事件使用 EventInfo<T> 数据类型
public class EventCenter
{
    // 数据结构
    private Dictionary<string, IEventInfo> _eventDic = new Dictionary<string, IEventInfo>();
    private static EventCenter instance;

    public static EventCenter Instance
    {
        get
        {
            if(instance == null)
            {
                instance = new EventCenter();
            }
            return instance;
        }
    }

    // 添加带参数事件的监听
    public void AddEventListener<T>(string name, UnityAction<T> action)
    {
        // 旧事件
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo<T>).actions += action;
        // 新事件
        else
            _eventDic.Add(name, new EventInfo<T>(action));
    }

    // 添加无参数事件的监听
    public void AddEventListener(string name, UnityAction action)
    {
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo).actions += action;
        else
            _eventDic.Add(name, new EventInfo(action));
    }

    // 移除带参数事件的监听
    public void RemoveEventListener<T>(string name, UnityAction<T> action)
    {
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo<T>).actions -= action;
    }

    // 移除无参数事件的监听
    public void RemoveEventListener(string name, UnityAction action)
    {
        if (_eventDic.ContainsKey(name))
            (_eventDic[name] as EventInfo).actions -= action;
    }

    // 分发带参数的事件
    public void EventTrigger<T>(string name, T info)
    {
        if (_eventDic.ContainsKey(name))
            if ((_eventDic[name] as EventInfo<T>).actions != null)
                (_eventDic[name] as EventInfo<T>).actions.Invoke(info);
    }

    // 分发无参数的事件
    public void EventTrigger(string name)
    {
        if (_eventDic.ContainsKey(name))
            if ((_eventDic[name] as EventInfo).actions != null)
                (_eventDic[name] as EventInfo).actions.Invoke();
    }

    // 清空事件监听
    // 主要用于场景切换时防止内存泄漏
    public void Clear()
    {
        _eventDic.Clear();
    }
}

其他更多平台:

  • • bilibili 打工人小棋

  • • 知乎 打工人小棋

  • • CSDN 打工人小棋


一起加油 :)

 

Unity百宝箱 · 目录
上一篇【Unity百宝箱】游戏中的用户数据存档下一篇【泰裤辣 の Unity百宝箱】分享一套简单易用的游戏UI框架
阅读 184