C# 编程与 Unity 3D:委托、事件与扩展函数的应用
1. 委托与事件基础
在 Unity 3D 中使用 C# 编程时,将大量代码作为整个游戏的中央开关板会变得非常繁琐。委托可以实现很多很酷的技巧,带有泛型类型的委托能实现更多有趣的功能,尤其是事件。事件就像一个容器,可用于存放任意数量的函数。当事件被调用时,所有分配给它的函数都会被调用,这是一种很好的通知机制,能告知另一个函数你所等待的事情已经发生。
1.1 委托与事件的声明
我们通常使用
public void functionName() {//code statements}
这样的声明来定义函数。而委托的声明则根据具体需求,例如
delegate void delegateName();
。下面通过一个基本示例来详细说明。
1.2 基本示例
在 Unity 中启动
Events_Scene
,定位到同名游戏对象上的
Events
脚本。通常,
EventHandler
的命名会基于它要处理的事件类型。在了解其用途后,我们再确定更合适的名称。
声明
public delegate void Handler();
是一种类型声明,创建该类型后,会创建变量
HandleDelegate
。为了等待委托被处理,我们创建一个新的
Listener
类,其中有一个函数会被分配给
HandleDelegate
变量。事件的声明与委托类似,但会添加
event
关键字。
在很多情况下,我们认为只能给变量赋值,但委托可以分配函数。当调用委托时,分配的函数也会被调用,不过分配有一个条件:签名必须匹配,即委托的声明和分配给它的函数的签名必须一致。
创建
Listener
类后,将
OnDelegateHandled()
事件分配给
Dispatcher
的
HandleDelegate
和
HandleEvent
变量。创建调度器和监听器后,有两种方式分配委托:
1. 使用
+=
运算符。
2. 使用新的
Dispatcher.Handler()
函数初始化器。
但使用第二种方式分配时会出现有趣的情况。当调度器调用
CallHandlers()
函数时,只有 Frasier 的委托被处理,Sigmond 的
OnDelegateHandled
函数未被调用。调用
new Dispatcher.Handler()
时,
HandleDelegate
会丢失之前的所有分配,清除委托是被允许的。
我们还可以将
null
分配给
HandleDelegate
处理程序,调用
CallHandlers()
时将不会发生任何事情,因为
HandleDelegate
已清除所有要调用的函数。通过使用
event
关键字标记
HandleEvent
处理程序,可以防止对该变量的误用。错误提示表明,在给事件赋值时只能使用
+=
或
-=
运算符,所以
event
关键字更像是委托的特殊访问器关键字。尝试将
HandleEvent
设置为
null
时也会出现相同的错误。
1.3 事件的保护机制
事件保护委托只能使用
+=
或
-=
来分配函数,这可以保护变量不被误操作或意外清除已分配给事件处理程序的内容。要从处理程序中移除一个函数,可以使用
-=
运算符。例如,使用
-=
从
HandleEvent
中移除
Frasier.OnDelegateHandled
后,只有 Sigmund 的委托处理程序会被调用。委托和事件的操作方式基本相同,主要区别在于它们的保护级别。
1.4 符合规范的事件
.NET 框架有许多规范程序,代码需要遵循这些规范。Microsoft 描述了许多最佳实践,涵盖从变量名到事件的各个方面。为了遵循正确的代码程序,我们需要了解如何编写事件。
public delegate void ProperEventHandler(object sender, EventArgs args);
将上述委托添加到
EventDispatcher
类中,紧接在旧的
EventHandler
之后。这里添加了
object sender
和
EventArgs args
,稍后会详细解释它们的用途。
在类作用域外部声明委托后,在类作用域内添加该委托的处理程序:
public event ProperEventHandler ProperEvent;
为了使事件更有用,我们可以添加
EventArgs
(事件参数),以便新事件具有更多功能。
EventArgs
用于向监听事件的对象传递参数。在创建自定义
EventArg
之前,需要添加新的指令。
1.5 EventArgs 的使用
系统指令让我们可以访问一个名为
EventArg
的新类。
EventArg
类型来自
System
,因此要创建基于它的类,需要在类作用域中添加
using System;
。完成此操作后,在
EventDispatcher
的全局作用域中创建一个新类,例如
MessageEventArgs
。
graph TD;
A[开始] --> B[添加 using System;]
B --> C[创建 MessageEventArgs 类]
C --> D[在构造函数中分配 string message]
D --> E[更改委托签名以发出 MessageEventArgs 类型]
E --> F[确保 OnProperEvent 函数签名匹配]
F --> G[在 EventDispatcher 中添加 ProperEvent 到调用语句]
G --> H[实例化 MessageEventArgs 对象并传递参数]
H --> I[结束]
创建或实例化
MessageEventArgs
类后,应在构造函数中分配
string message
。需要注意的是,自定义事件参数允许将特定的事件信息传递给监听事件的对象。要使用新的
MessageEventArgs
对象调度事件,需要更改委托签名以发出
MessageEventArgs
类型。在
ProperEventArgListener
类中,事件调用的
OnProperEvent
函数的签名必须与
ProperEventHandler
匹配。
现在,调度器的
MyEvent
调用
EventListeners
的
CallMeMaybe
函数,
ProperEvent
调用
CallMePlease
函数。最后,在
EventDispatcher
中,需要将
ProperEvent
添加到进行调用的
if
语句中。
MessageEventArgs()
对象是一个类,使用前需要使用
new
进行实例化。在
ProperEvent
参数列表中,使用
this
作为发送者,
new MessageEventArgs()
作为第二个参数,这两个值会传递给监听器。
采用事件驱动的游戏设计是最佳选择,这样可以避免在条件满足时立即执行任务,而是等待事件发生后再处理,从而避免过多脚本同时更新。在 PC 上这可能不是问题,但在移动设备上,处理器性能会受到很大限制。
1.6 泛型 EventArgs
我们可以让多个事件从单个委托类型派生,为此需要使用泛型类型来声明委托。当创建带有
<TEventArgs>
的
EventHandler
委托时,在参数列表中使用泛型类型代替强类型。这意味着
MessageEvent
和
NumberEvent
都可以从
EventHandler
委托派生,每个事件用自己的特定
EventArg
类型替换
TEventArgs
。
使用
GenericEventArgs
中的泛型值,我们可以通过
GenericEventArg
与委托
EventHandler
进行交互。创建事件时需要指定类型,创建参数时也必须包含匹配的类型。同样,泛型类型的监听器也必须包含具有匹配签名的事件监听器。
使用事件时,通过少量类型定义就能获得很大的灵活性。例如,
sigmund.OnGenericEvent
可以分配给
IntEvent
和
StringEvent
,并为每个事件提供适当的重载函数。
GenericEventArgs<int>
和
GenericEventArgs<string>
能确保在调用不同事件时找到要调用的函数。
综上所述,直观地使用事件很重要,泛型在基于灵活委托声明创建事件时增加了灵活性。使用事件而非委托主要有两个原因:一是事件增加了保护级别,二是允许从单个委托类型派生多个事件。
2. 扩展函数
2.1 传统编程方式的问题
在编写新类时,传统程序员可能会编写一个庞大的单一类,用复杂的算法处理多个不同的任务。但这种方法存在一些问题,例如调试困难、缺乏灵活性以及代码难以解释等。在处理复杂问题时,更好的、更适合 Unity 的方法是从通用解决方案入手。
2.2 扩展方法的引入
当使用 Unity 3D 的内置类型(如
GameObject
)时,我们无法直接扩展这些类。如果尝试使用
MyGameObject
继承
GameObject
,会得到错误信息
'MyGameObject': cannot derive from sealed type 'GameObject'
,这表明
GameObject
是一个密封类,不能被扩展或修改。但这并不意味着我们不能为
GameObject
添加新功能,通过扩展方法可以实现这一点。
2.3 基本示例
在 Unity 中启动
ExtensionFunctions_Scene
,打开同名对象上的
ExtensionFunctions.cs
脚本。在脚本顶部添加
using Tricks;
,以便
ExtensionFunctions: MonoBehaviour
类可以使用命名空间中的内容。通常,命名空间应放在另一个文件中,但为了清晰起见,我们将它们合并在一个文件中。
在命名空间中,创建一个新类,例如
GameObjectExtensions
,这是一个静态类,其中包含一个新的静态函数,用于扩展密封的
GameObject
类。
public static void NewTrick(this GameObject gameObject)
{
Console.WriteLine(gameObject.name + " has a new trick!");
}
上述代码中的
NewTrick()
就是扩展函数,使用
this GameObject gameObject
作为参数,
this
关键字告知 C# 这是一个扩展方法。在函数中,使用
gameObject.name
打印游戏对象的名称和提示信息。
在
ExtensionFunctions_Scene
中,附加到
ExtensionFunctions
游戏对象上的
MonoBehaviour
会显示
(Extension) void NewTrick()
作为
gameObject
类型的可用函数。选择并运行
NewTrick()
函数,控制台将输出
Penn has a new trick!
。
任何额外的参数都必须放在使用
this
关键字的参数之后。根据第一个参数中
this
后面的类型,可以更改函数以扩展不同的密封类。例如,添加
Vector3 position
作为第二个参数,并使用
gameObject.transform.position = position;
可以更新游戏对象的位置。
public static void Move(this GameObject gameObject, Vector3 position)
{
gameObject.transform.position = position;
}
使用
Move()
扩展函数时,虽然第一个参数是游戏对象本身,但在使用时不会显示。通过使用第一个参数引用对象的语法,将函数转换为扩展函数,扩展函数可以应用于任何密封类。在使用第三方库时,为没有源代码的类添加自己的函数非常有用。
static
关键字对于使函数在任何上下文中都可见是必要的,包含扩展函数的类也需要是静态的。扩展函数理论上可以写在任何静态类中,但如果出现在不相关的类中会造成混淆,因此清晰的命名规范非常重要。
由于扩展方法的存在,为密封类添加功能变得很容易。但如果可能的话,最好直接将函数添加到类本身,这样可以避免编写大量扩展函数,并且这种方法不仅适用于
gameObject
。
2.4 扩展函数的重载
除了通过扩展为
Transform
或
gameObject
添加新函数外,还可以为现有函数创建新的重载。例如,新的
Reset()
扩展函数被调用时,游戏对象会移动到场景的原点,即使它的父对象已经移动。
Transform
对象的
SetParent()
函数有几种重载方式。第一种方式设置父对象时会保留子对象的局部位置、旋转和缩放;第二种方式在设置父对象后保留世界位置;而我们自定义的扩展函数在设置父对象后将子对象设置到场景的原点。
graph TD;
A[创建 Child 游戏对象并定位到 1.0 1.0 1.0] --> B[创建 Parent 对象并移动到 0,0,1]
B --> C[使用重载的 SetParent 函数设置父对象]
C --> D[检查 Child 的局部位置]
D --> E[输出结果]
通过以下代码示例可以更清楚地看到重载的效果:
// 创建 Child 游戏对象
GameObject child = new GameObject("Child");
child.transform.position = new Vector3(1.0f, 1.0f, 1.0f);
// 创建 Parent 游戏对象
GameObject parent = new GameObject("Parent");
parent.transform.position = new Vector3(0.0f, 0.0f, 1.0f);
// 使用重载的 SetParent 函数
child.transform.SetParent(parent.transform, resetToWorldPosition: true);
// 检查 Child 的局部位置
Vector3 localPosition = child.transform.localPosition;
Debug.Log("Child local position: " + localPosition);
有两种使用
(Transform, bool)
签名模式的重载方式。内置版本使用
bool
参数指示子对象是否保留其相对世界位置,使用该选项后,子对象的
localPosition
会报告为
1.0, 1.0, 0.0
。通过明确写出
bool
参数名
resetToWorldPosition: true
,我们告诉 C# 使用自定义的扩展函数而不是内置版本。
2.5 魔法镜像扩展函数
程序员经常编写代码来减少代码量,虽然这听起来有些矛盾,但大部分编程时间都花在编写被短函数调用的大函数上。在追求降低复杂度的过程中,复杂度是不可避免的。
这里我们为
GameObject
创建一个名为
Mirror
的扩展函数,它接受一个
Vector3
值作为镜像轴,例如反射 x 轴。
public static void Mirror(this GameObject original, Vector3 axis)
{
// 创建克隆对象
GameObject clone = Instantiate(original);
clone.transform.position = original.transform.position;
clone.transform.localScale = new Vector3(-axis.x * original.transform.localScale.x, axis.y * original.transform.localScale.y, axis.z * original.transform.localScale.z);
// 创建更新系统
UpdateReflection updateReflection = original.AddComponent<UpdateReflection>();
updateReflection.Setup(original, clone, axis);
}
Mirror
函数会创建原始对象的克隆,并设置其大小和位置。计算镜像缩放并应用到克隆对象上,然后创建一个更新系统来跟踪原始对象、镜像对象和反射值。
UpdateReflection
类会跟踪镜像对象和反射向量。创建时,它会为原始对象添加一个名为
UpdatesReflected
的组件。
UpdatesReflected
对象有一个更新事件,会调用
OnReflectionUpdated
函数,该函数会根据反射轴值设置镜像对象的位置和旋转。
graph TD;
A[调用 Mirror 函数] --> B[创建克隆对象]
B --> C[设置克隆对象的大小和位置]
C --> D[计算并应用镜像缩放]
D --> E[创建 UpdateReflection 类实例]
E --> F[添加 UpdatesReflected 组件]
F --> G[设置更新事件]
G --> H[根据反射轴更新镜像对象位置和旋转]
H --> I[结束]
原始对象会附加
MonoBehaviour UpdatesReflected
,其中的
ReflectionUpdateEvent
会调用
UpdateReflection
类中的
OnReflectionUpdated
函数。事件会传递原始对象的变换信息,以便镜像对象了解原始对象的变换更新情况。
通过合理使用委托、事件和扩展函数,我们可以提高代码的可维护性和灵活性,使游戏开发更加高效。在实际应用中,需要根据具体需求选择合适的方法,并遵循良好的编程规范。
3. 总结与应用建议
3.1 核心要点回顾
| 技术概念 | 主要特点 | 应用场景 |
|---|---|---|
| 委托与事件 |
- 委托可存放函数,事件基于委托,增加保护机制
- 泛型委托增加事件灵活性,允许派生多个事件 |
- 游戏中事件通知机制,如角色状态变化通知
- 多模块间的消息传递 |
| 扩展函数 |
- 为密封类添加新功能,可重载现有函数
- 提高代码可维护性,避免庞大单一类 |
- 为 Unity 内置类或第三方库类添加自定义功能
- 简化复杂类的功能扩展 |
3.2 应用建议
-
委托与事件的使用
-
在设计游戏系统时,优先考虑使用事件驱动的架构。例如,当角色的生命值发生变化时,可以触发一个
HealthChanged事件,所有监听该事件的模块(如 UI 显示、技能触发等)都会收到通知并做出相应处理。 -
合理使用泛型委托和事件,根据不同的事件类型传递特定的参数。比如,在处理不同类型的伤害事件时,可以使用泛型
EventArgs传递伤害类型、伤害值等信息。
-
在设计游戏系统时,优先考虑使用事件驱动的架构。例如,当角色的生命值发生变化时,可以触发一个
-
扩展函数的使用
-
对于经常使用的功能,考虑为相关类创建扩展函数。例如,为
GameObject类添加一个IsInRange扩展函数,用于判断该游戏对象是否在某个范围内。 - 在使用扩展函数重载时,要明确每个重载的功能和适用场景,避免造成混淆。同时,使用有意义的参数名,确保代码的可读性。
-
对于经常使用的功能,考虑为相关类创建扩展函数。例如,为
3.3 注意事项
-
委托与事件
- 注意事件的订阅和取消订阅,避免内存泄漏。在对象销毁时,确保取消所有订阅的事件。
-
合理使用
event关键字,避免对事件变量的误用。
-
扩展函数
- 扩展函数应保持功能的单一性和独立性,避免在扩展函数中添加过多复杂逻辑。
- 清晰命名扩展函数和包含它们的类,遵循一致的命名规范。
4. 案例分析:游戏中的具体应用
4.1 委托与事件在角色系统中的应用
在一个角色扮演游戏中,角色的状态变化(如生命值、魔法值、等级等)可以通过事件来通知相关模块。以下是一个简单的示例:
// 定义事件参数类
public class CharacterStatusChangedEventArgs : EventArgs
{
public int Health { get; set; }
public int Mana { get; set; }
public int Level { get; set; }
public CharacterStatusChangedEventArgs(int health, int mana, int level)
{
Health = health;
Mana = mana;
Level = level;
}
}
// 定义委托和事件
public delegate void CharacterStatusChangedEventHandler(object sender, CharacterStatusChangedEventArgs args);
public class Character
{
public event CharacterStatusChangedEventHandler StatusChanged;
private int health;
private int mana;
private int level;
public int Health
{
get { return health; }
set
{
health = value;
OnStatusChanged();
}
}
public int Mana
{
get { return mana; }
set
{
mana = value;
OnStatusChanged();
}
}
public int Level
{
get { return level; }
set
{
level = value;
OnStatusChanged();
}
}
protected virtual void OnStatusChanged()
{
StatusChanged?.Invoke(this, new CharacterStatusChangedEventArgs(Health, Mana, Level));
}
}
// 监听事件的类
public class CharacterStatusListener
{
public void Subscribe(Character character)
{
character.StatusChanged += OnCharacterStatusChanged;
}
public void Unsubscribe(Character character)
{
character.StatusChanged -= OnCharacterStatusChanged;
}
private void OnCharacterStatusChanged(object sender, CharacterStatusChangedEventArgs args)
{
Debug.Log($"Character status changed: Health = {args.Health}, Mana = {args.Mana}, Level = {args.Level}");
}
}
graph TD;
A[角色状态改变] --> B[触发 StatusChanged 事件]
B --> C[调用 OnCharacterStatusChanged 函数]
C --> D[输出状态信息]
4.2 扩展函数在游戏对象操作中的应用
在一个策略游戏中,需要对游戏对象进行一些额外的操作,如移动到指定位置、旋转等。可以为
GameObject
类创建扩展函数来实现这些功能。
using UnityEngine;
public static class GameObjectExtensions
{
public static void MoveTo(this GameObject gameObject, Vector3 position, float speed)
{
gameObject.transform.position = Vector3.MoveTowards(gameObject.transform.position, position, speed * Time.deltaTime);
}
public static void RotateAroundAxis(this GameObject gameObject, Vector3 axis, float angle)
{
gameObject.transform.Rotate(axis, angle);
}
}
在游戏脚本中,可以直接使用这些扩展函数:
using UnityEngine;
public class GameController : MonoBehaviour
{
public GameObject targetObject;
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
targetObject.MoveTo(new Vector3(10, 0, 0), 5f);
targetObject.RotateAroundAxis(Vector3.up, 90f);
}
}
}
5. 未来展望
随着游戏开发的不断发展,委托、事件和扩展函数在 Unity 3D 中的应用将会更加广泛和深入。未来可能会出现以下趋势:
-
更智能的事件管理系统
:自动处理事件的订阅和取消订阅,减少开发者的手动操作,进一步提高开发效率。
-
跨平台的扩展函数库
:为不同平台的 Unity 开发提供统一的扩展函数接口,方便开发者在不同平台上复用代码。
-
与其他技术的融合
:与人工智能、机器学习等技术结合,实现更加复杂和智能的游戏逻辑。
通过不断学习和实践,开发者可以更好地掌握这些技术,为游戏开发带来更多的创新和可能性。
超级会员免费看
16万+

被折叠的 条评论
为什么被折叠?



