Menu
CoddyTech

Unity 中的 C# 事件

本课属于 Coddy Unity 学习旅程的“Unity 游戏架构”单元。第 12 课,共 64 课。

分数变化时,有好几处会关注它:分数文本、音效、成就。如果 ScoreKeeper 要逐一调用它们,就需要为每一处都添加一个字段,每增加一项新功能都意味着要修改它。事件则反过来:ScoreKeeper 发布分数变化,关注它的对象自行订阅。

两个 C# 关键字让这一切成为可能。Action<int> 是一种方法类型,它接收一个 int,不返回任何内容。这种类型的 event 是一组这样的方法:其他代码用 += 添加自己的方法,事件所有者用 Invoke 调用所有方法。用简单的 C# 表示:

using System;

class Alarm
{
    public event Action<int> Rang;

    public void Ring(int hour)
    {
        Rang?.Invoke(hour);
    }
}

class Sleeper
{
    public void WakeUp(int hour)
    {
        Console.WriteLine("Up at " + hour);
    }
}

// 在其他地方
Alarm alarm = new Alarm();
Sleeper sleeper = new Sleeper();
alarm.Rang += sleeper.WakeUp;
alarm.Ring(7);   // 7 点起床

在 Unity 中,发布者和监听者都是组件,通常位于不同的 GameObject 上:

using System;
using UnityEngine;

public class ScoreKeeper : MonoBehaviour
{
    public event Action<int> OnScoreChanged;
    private int score;

    public void AddPoints(int points)
    {
        score += points;
        OnScoreChanged?.Invoke(score);
    }
}
// ScoreLabel.cs,挂在 HUD 的文本上
public ScoreKeeper keeper;
public TMP_Text label;

void Start()
{
    keeper.OnScoreChanged += ShowScore;
}

void ShowScore(int score)
{
    label.text = "Score: " + score;
}

Action<int> 表示每个监听者都是一个接收一个 int 的方法,这里接收的是新分数。Invoke 会按订阅顺序调用每个已订阅的方法。如果没有监听者,事件就是 null,因此直接调用 OnScoreChanged(score) 会抛出 NullReferenceException,而 ?. 则会跳过这次调用。event 关键字限制其他脚本只能使用 += 和 -=:在 ScoreLabel 中,keeper.OnScoreChanged(5) 或 keeper.OnScoreChanged = null 都无法通过编译。

ScoreKeeper 不需要用于标签的字段,也不知道标签的存在。连击计数器或音效播放器也能以相同方式订阅,而 ScoreKeeper 无需改变;没有 HUD 的测试场景也能正常运行,不会报错。监听者通过 Inspector 引用来找到发布者,如此处所示,也可以用你熟悉的其他方式,例如 GetComponent 或单例。对象销毁后,使用 -= 移除监听者很重要,这会在下一课介绍。

challenge icon

挑战

简单

让 fighter 宣布它的连击数。

  1. 在 Fighter.cs 中,添加 using System; 和 public event Action<int> OnComboChanged;。
  2. 将使用 comboText 和 flame 的代码行替换为 OnComboChanged?.Invoke(combo);,并删除这两个字段。
  3. 在 ComboText 和 ComboFlame 的 Start() 中,分别写入 fighter.OnComboChanged += Show; 和 += Grow;。

点击“开始”,然后按几次空格键:连击数会增加,然后清零。接着点击“检查”。

亲手试试

using UnityEngine;
using UnityEngine.InputSystem;
using TMPro;

public class Fighter : MonoBehaviour
{
    public float comboWindow = 1f;
    public TMP_Text comboText;
    public Transform flame;
    private int combo;
    private float sinceHit;

    void Update()
    {
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
        {
            combo++;
            sinceHit = 0f;
            comboText.text = "Combo x" + combo;
            flame.localScale = Vector3.one * (0.5f + 0.25f * combo);
        }
        else if (combo > 0)
        {
            sinceHit += Time.deltaTime;
            if (sinceHit > comboWindow)
            {
                combo = 0;
                comboText.text = "";
                flame.localScale = Vector3.one * 0.5f;
            }
        }
    }
}

Unity 游戏架构中的所有课

自己练习: Unity 在线编辑器