事件系统

UGUI是基于事件的GUI系统,无论是内置的UI组件,还是我们自定义的一些交互,都需要借助UGUI的事件系统来表达。对于任何GUI框架来说,事件系统都是相对比较复杂的,不过实际上它们都有着类似的设计。这篇笔记我们详细介绍,如何在Unity编辑器使用UGUI的事件系统,以及如何使用纯C#代码动态指定事件的回调函数。

事件系统相关的组件

我们在场景中放入一个UI组件后,会发现Unity编辑器自动为我们创建了一个EventSystem对象,它相当于UI系统的一个事件管理器。该对象组件上有一些配置项,不过我们一般都不需要修改。

在组件中,有一个Event分类,其中包含了和UGUI事件系统相关的组件。

  • Event System:事件管理器,配置在EventSystem对象上
  • Event Trigger:事件触发器,放置在物体上可以用来监听一种或多种事件(经常使用)
  • Graphic Raycaster:UI组件的射线检测相关设置,配置在Canvas
  • Physics2D Raycaster:2D游戏对象的射线检测相关设置,配置在Canvas
  • Physics Raycaster:游戏对象的射线检测相关设置,配置在Canvas
  • Standalone Input Module:键盘鼠标输入系统,配置在EventSystem对象上
  • Touch Input Module:触屏输入系统,配置在EventSystem对象上

Event Trigger的使用

通过Unity编辑器配置Event Trigger

我们编写UI时,涉及最多的内容就是绑定监听器触发事件,其中最常用的其实就是Event Trigger。下面例子中,我们使用Event Trigger组件,为一个Image控件绑定点击事件的回调(Image默认不响应任何事件)。

如图所示,我们在Image上面,增加了一个Event Trigger组件,然后为其配置了一个回调函数。

public void HandleClick()
{
    Debug.Log("target clicked");
}

注意:回调函数必须为public,至多有一个参数,不能有返回值。

通过C#代码配置Event Trigger

实际开发中,其实更为常见的一种状况是:我们的对象是动态生成的,那么此时如何为其指定事件的回调函数呢?其实Unity编辑器中属性面板的各种操作都完全可以一比一改为C#代码,和编辑器中类似,我们使用代码为对象添加Event Trigger组件,然后绑定函数即可。

// 实例化游戏对象
GameObject skillItem = Instantiate(skill.IconPrefab);
skillItem.transform.SetParent(transform, false);
// 添加Event Trigger组件
EventTrigger eventTrigger = skillItem.AddComponent<EventTrigger>();

// 添加开始拖拽事件
EventTrigger.Entry entry1 = new EventTrigger.Entry();
entry1.eventID = EventTriggerType.BeginDrag;
entry1.callback.AddListener((BaseEventData baseEventData) =>
    {
        HandleSkillItemBeginDrag(skill);
    });
eventTrigger.triggers.Add(entry1);

上面代码节选自之后我们要介绍的例子。代码中,我们实例化了一个名为skillItem的游戏对象,然后为其添加了Event Trigger组件,最后为其绑定了一个响应BeginDrag事件的回调函数。

其实之前编辑器中我们手动点击Add New Event Type按钮,其作用就是给Event Trigger组件添加EventTrigger.Entry对象,该对象的eventID字段对应事件的类型,callback字段则是一个UnityEvent<BaseEventData>类型,其AddListener()方法用于注册UnityAction<BaseEventData>,后者本质上是一个委托(delegate),我们可以直接传入函数或是传入一个lambda表达式。

事件类型

Unity中,内置了如下所示的事件类型:

前面例子中,我们使用的PointerUp,会在鼠标抬起时触发。其他触发类型也都比较好理解,参考文档即可,这里就不多介绍了。

内置UI组件的事件

学习了UGUI事件系统相关知识后,在使用UGUI内置的Button按钮等控件时,其实就轻车熟路了。例如Button等,其事件本质上也是基于Event Trigger实现的,我们可以通过Unity编辑器进行编辑,也可以通过代码进行动态的指定。

有关UGUI内置组件,将在后续章节详细介绍。

综合案例:可拖拽的技能栏

功能简介

这里我们编写一个将技能图标拖拽进技能栏的例子,技能数据从XML文件中加载,我们可以从上方的技能列表中拖动任意技能到下方的技能槽,点击可以实现技能触发和冷却中的特效,最终效果如图:

注:技能图来自网络

创建的UI对象如图所示:

其中,SkillPanel是展示技能列表的容器,我们会读取一个XML文件,然后将所有的技能以GridLayoutGroup的布局加载到SkillPanel中。SkillSlotPanel是我们的技能槽面板,其中包含了3个技能槽SkillSlot0SkillSlot1SkillSlot2,每一个技能槽包含两个子对象,SlotImage用于显示技能的图标,SlotMask是一个遮罩,我们这里使用Filled模式的Image对象实现遮罩的效果,间接实现技能冷却的效果。

我们总共创建了3个C#脚本:

Skill.cs
SkillPanelController.cs
SkillSlotController.cs

Skill.cs是数据模型,其中包含了技能相关的字段,和XML文件对应。SkillPanelController是技能列表的控制器(这里使用了MVC模式),它用于加载技能列表,以及控制渲染技能数据。SkillSlotController附加到了每一个技能槽中,它用于响应当前技能槽的拖拽Drop事件,以及控制技能释放和冷却。

补充说明:技能冷却效果原理

这里首先要说明的是如何实现技能冷却的效果,虽然这个问题和本章节的主题不太相关。其实技能冷却效果是两个重叠的Image控件实现的,上面一层其颜色被设置为了叠加灰色,因此比原图暗。UGUI中的Image控件有一个Image Type属性,其取值:简单模式Simple和九宫格模式Sliced不必多说,大家应该都能理解,比较神奇的是Filled模式,它能实现按一定角度或是某个方向被遮蔽,间接实现上面遮罩的效果。

工程和代码要点

Skills.xml

<skills>
    <skill>
        <id>1</id>
        <name>电刃</name>
        <iconPrefab>Skills_0</iconPrefab>
    </skill>
    <skill>
        <id>2</id>
        <name>冲击</name>
        <iconPrefab>Skills_1</iconPrefab>
    </skill>
    <skill>
        <id>3</id>
        <name>血刃</name>
        <iconPrefab>Skills_2</iconPrefab>
    </skill>
    <skill>
        <id>4</id>
        <name>风刃</name>
        <iconPrefab>Skills_3</iconPrefab>
    </skill>
    <skill>
        <id>5</id>
        <name>地爆天星</name>
        <iconPrefab>Skills_4</iconPrefab>
    </skill>
</skills>

上面代码是我们作为数据的XML文件,其中包括了我们技能的ID、名字和图标预制体名,预制体包含了技能的Sprite,用于我们动态加载和显示技能图标。当然这里我们仅仅是演示UGUI用法的Demo,设置的字段都非常简单,实际开发中「技能」可是个相当复杂的系统。

Skill.cs

using UnityEngine;

public class Skill
{
    public int Id { get; set; }
    public string Name { get; set; }
    public GameObject IconPrefab { get; set; }
}

上面代码是技能的数据模型,字段和XML中对应。

SkillPanelController.cs

using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Xml;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

public class SkillPanelController : MonoBehaviour
{
    private static SkillPanelController instance;

    private List<Skill> skillList;
    public GameObject CurrentFollower { get; private set; }
    public Skill CurrentFollowerSkill { get; private set; }

    public static SkillPanelController GetInstance() { return instance; }

    private void Awake()
    {
        instance = this;
        skillList = new List<Skill>();

        // 读取技能XML定义文件
        TextAsset textAsset = Resources.Load<TextAsset>("Skills");
        string skillDefs = textAsset.text;

        // 解析XML文件生成技能对象
        XmlDocument doc = new XmlDocument();
        doc.LoadXml(skillDefs);
        XmlElement root = (XmlElement)doc.SelectSingleNode("skills");
        XmlNodeList defs = root.ChildNodes;
        foreach (XmlNode node in defs)
        {
            int id = int.Parse(node.SelectSingleNode("id").InnerText);
            string name = node.SelectSingleNode("name").InnerText;
            string iconPrefabName = node.SelectSingleNode("iconPrefab").InnerText;
            GameObject iconPrefab = Resources.Load<GameObject>(iconPrefabName);
            Skill skill = new Skill() { Id = id, Name = name, IconPrefab = iconPrefab };
            skillList.Add(skill);
        }

        // 填充技能列表UI元素
        foreach (Skill skill in skillList)
        {
            // 构造技能可拖拽元素
            GameObject skillItem = Instantiate(skill.IconPrefab);
            skillItem.transform.SetParent(transform, false);
            EventTrigger eventTrigger = skillItem.AddComponent<EventTrigger>();

            // 添加开始拖拽事件
            EventTrigger.Entry entry1 = new EventTrigger.Entry();
            entry1.eventID = EventTriggerType.BeginDrag;
            entry1.callback.AddListener((BaseEventData baseEventData) =>
                {
                    HandleSkillItemBeginDrag(skill);
                });
            eventTrigger.triggers.Add(entry1);

            // 添加拖拽中事件
            EventTrigger.Entry entry2 = new EventTrigger.Entry();
            entry2.eventID = EventTriggerType.Drag;
            entry2.callback.AddListener((BaseEventData baseEventData) =>
            {
                HandleSkillItemDrag();
            });
            eventTrigger.triggers.Add(entry2);

            // 添加拖拽结束事件
            EventTrigger.Entry entry3 = new EventTrigger.Entry();
            entry3.eventID = EventTriggerType.EndDrag;
            entry3.callback.AddListener((BaseEventData baseEventData) =>
            {
                HandleSkillItemEndDrag();
            });
            eventTrigger.triggers.Add(entry3);
        }
    }

    public void HandleSkillItemBeginDrag(Skill skill)
    {
        CurrentFollowerSkill = skill;
        CurrentFollower = Instantiate(skill.IconPrefab);
        CurrentFollower.transform.position = Input.mousePosition;
        CurrentFollower.GetComponent<Image>().raycastTarget = false;
        CurrentFollower.transform.SetParent(GameObject.Find("Canvas").transform);
    }

    public void HandleSkillItemDrag()
    {
        CurrentFollower.transform.position = Input.mousePosition;
    }

    public void HandleSkillItemEndDrag()
    {
        Destroy(CurrentFollower);
        CurrentFollower = null;
        CurrentFollowerSkill = null;
    }
}

上面代码主要包含两个部分,Awake()方法中包含了读取XML文件,加载技能列表GUI的逻辑,其中比较复杂就是动态拼装技能列表中的每一个技能,我们动态的实例化了技能图标对象,并为其绑定了BeginDrag、Drag、EndDrag这3个拖拽事件;后面的拖拽事件回调函数就比较简单了,拖拽开始我们再实例化一个技能图标,并在拖拽中设置其实时跟随鼠标,拖拽结束即将其销毁。

注意:代码中的一个小技巧是设置了CurrentFollower.GetComponent<Image>().raycastTarget = false;,这是因为我们拖拽时可能发生跟随鼠标的图标把Drop事件挡住的问题,此时后面的技能槽就无法收到事件了,因此我们设置raycastTarget属性为false,事件就会穿过跟随鼠标的图标。

SkillSlotController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class SkillSlotController : MonoBehaviour
{
    private Image slotImage;
    private Image maskImage;
    private Skill currentSkill;

    private bool showingMask = false;
    private float showingTime = 0;
    private float maxShowingTime = 1;

    private void Awake()
    {
        slotImage = transform.Find("SlotImage").GetComponent<Image>();
        maskImage = transform.Find("SlotMask").GetComponent<Image>();
        currentSkill = null;
    }

    public void HandleDrop()
    {
        // 将拖拽中的技能设置到当前技能槽的2个图片上
        SkillPanelController skillPanelController = SkillPanelController.GetInstance();
        slotImage.sprite = skillPanelController.CurrentFollower.GetComponent<Image>().sprite;
        maskImage.sprite = skillPanelController.CurrentFollower.GetComponent<Image>().sprite;

        // 设置当前技能槽技能
        currentSkill = skillPanelController.CurrentFollowerSkill;
    }

    public void HandleClick()
    {
        // 当前技能槽不为空且不在冷却中,允许发动技能
        if (currentSkill != null && !showingMask)
        {
            // 显示冷却遮罩
            maskImage.gameObject.SetActive(true);
            maskImage.type = Image.Type.Filled;
            showingMask = true;
            showingTime = 0;
        }
    }

    private void Update()
    {
        // 在1s之内更新遮罩的遮蔽角度,实现技能冷却效果
        if (showingMask)
        {
            showingTime += Time.deltaTime;
        }
        maskImage.fillAmount = (1 - showingTime / maxShowingTime);
        if (showingTime > maxShowingTime)
        {
            showingMask = false;
            maskImage.gameObject.SetActive(false);
        }
    }
}

技能槽脚本响应了Drop事件,它是拖拽目标响应的事件,我们这里收到拖拽事件后,就将当前技能槽的图标、技能数据都设置为被拖拽的技能,此时技能拖拽功能就实现了。HandleClick响应技能槽的点击事件,前面介绍过技能冷却效果的实现原理,这里就是其实现代码。

对于技能槽,由于其是相对静态的对象,我们这里使用的是通过Unity编辑器指定回调事件的方式。如下图所示:

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap