2100 字
11 分钟
为 Godot 学 C# 06:集合、泛型、委托事件、LINQ 与异步

教程二 · 第 6 章:集合、泛型、委托/事件、LINQ、异步#

这是语言篇最后一块硬货,也是游戏代码里每天都在用的工具:敌人列表、缓存节点、事件订阅、筛选查询、延迟等待。

6.1 List:最重要的集合#

using System.Collections.Generic; // 命名空间,Godot 脚本常用
var enemies = new List<Enemy>(); // 空列表,装 Enemy
enemies.Add(slime); // 末尾加
enemies.Add(bat);
enemies.Count // 2
enemies[0].TakeDamage(5); // 下标访问
enemies.Remove(slime); // 删指定对象
enemies.RemoveAt(0); // 删指定位置
enemies.Contains(bat); // 是否包含
enemies.Clear(); // 清空

遍历与查询:

foreach (var e in enemies)
e.TakeDamage(1);
for (int i = enemies.Count - 1; i >= 0; i--) // 遍历时删除要倒序
{
if (enemies[i].IsDead)
enemies.RemoveAt(i);
}

直接初始化:

var spawnPoints = new List<Vector2> { new(0,0), new(100,0), new(200,0) };

List vs 数组:List 长度可变、方法丰富,95% 的场景用它;数组用于长度确定不变、或对接 Godot API 返回值。

6.2 Dictionary<K,V>:按键查找#

var bestTimes = new Dictionary<string, float>
{
["level1"] = 42.5f,
["level2"] = 88.0f
};
bestTimes["level3"] = 120f; // 加/改
bestTimes.TryGetValue("level1", out float t); // 安全取:不存在不抛异常
bestTimes.ContainsKey("level2");
foreach (var (level, time) in bestTimes)
GD.Print($"{level}: {time}");

游戏用途:配置表(id → WeaponData)、冷却表(玩家 id → 剩余秒数)、按键映射。不存在的键直接用 [key] 取会抛 KeyNotFoundException,输入不可信时一律 TryGetValue。

Godot 自己的集合(什么时候必须用)#

与引擎交互的边界(信号参数、[Export] 数组、Resource 字段)用 Godot 集合:

// Godot.Collections 命名空间
Godot.Collections.Array<Vector2> points; // [Export] 列表、信号传列表
Godot.Collections.Dictionary<string, int>; // 存档 JSON 序列化常用

规则:内部逻辑用 System.Collections.Generic(List/Dictionary,性能好、生态全);要放进 [Export]/信号/.tres 存档的用 Godot 集合。

其他集合知道名字即可:HashSet<T>(去重集合,Contains 极快:伤害判定去重、已拾取物品 id 集合)、Queue<T>(先进先出:伤害队列)、Stack<T>(后进先出:撤销系统)。

6.3 泛型:List 为什么要写 #

<T> 表示”类型参数”——容器和算法先不指定具体类型,用的时候再定:

var ints = new List<int>(); // 装 int 的列表
var players = new List<Player>(); // 装 Player 的列表
public T GetClosest<T>(List<T> items) where T : Node2D // 自己写泛型方法
{
T best = items[0];
foreach (var item in items)
if (GlobalPosition.DistanceSquaredTo(item.GlobalPosition)
< GlobalPosition.DistanceSquaredTo(best.GlobalPosition))
best = item;
return best;
}

好处:一份逻辑适配所有类型 + 编译期类型检查(不像某些语言的列表什么都能装、取出来全是 object 要转)。Godot API 大量泛型:

GetNode<Player>("Player"); // 直接拿 Player,不用转
ResourceLoader.Load<PackedScene>(path); // 直接拿 PackedScene
Instantiate<Bullet>(); // 直接拿 Bullet

where T : Node2D 是泛型约束:T 必须是 Node2D(或其派生),编译器才允许你访问 .GlobalPosition。

6.4 委托与事件:C# 原生版”信号”#

第 4 章作业留了悬念:纯 C# 类怎么通知外界?答案是 event。

委托(delegate)= 可以装方法的变量类型。

// 声明一个委托类型:"接受 int、无返回的方法"
public delegate void IntAction(int value);
IntAction action = n => GD.Print(n); // Lambda:匿名方法,n => 表达式
action(42); // 像方法一样调用

实际开发几乎不手写 delegate,用内置的 Action / Func:

Action<string> log = msg => GD.Print(msg); // 无返回
Func<int, int, int> add = (a, b) => a + b; // 最后一个泛型是返回类型
Func<bool> isAlive = () => _hp > 0;

event:对外只能订阅/退订,外部不能随便发射或清空——这正是 Godot 信号在 C# 层的样子:

using System;
public class Health
{
public event Action<int, int>? HealthChanged; // (current, max)
public event Action? Died;
private int _current;
public int Max { get; } = 100;
public Health() => _current = Max;
public void Damage(int n)
{
_current = Math.Max(0, _current - n);
HealthChanged?.Invoke(_current, Max); // ?.:没人订阅也安全
if (_current == 0) Died?.Invoke();
}
}

订阅:

_health.HealthChanged += (cur, max) => HpBar.Value = cur;
_health.Died += OnPlayerDied;
// 不再需要时
_health.Died -= OnPlayerDied;

C# event 与 Godot [Signal] 的选择:

  • 挂在场景树里、需要编辑器连接、要跨语言、存档/远程可见 → Godot [Signal]
  • 纯 C# 逻辑类(Health、背包数据、状态机)→ C# event,更轻、可测试、不依赖引擎

订阅纪律和信号一样:对象销毁前 -=,或保证发布者比订阅者短命,否则旧对象被事件引用着继续收通知(第 5 章教程一提的”死了还在收信”)。

Lambda 订阅的取舍:方便但无法 -=,短生命周期订阅长生命周期事件时用具名方法。

6.5 Lambda:随手传一段逻辑#

参数 => 表达式或语句块,到处用:

enemies.ForEach(e => e.TakeDamage(10));
enemies.Sort((a, b) => a.Hp.CompareTo(b.Hp)); // 自定义排序
CreateTween().TweenCallback(Callable.From(() => QueueFree()));
buttons.ForEach(b => b.Pressed += () => PlayClick());

多语句 Lambda:

Func<int, int> clampHp = v =>
{
if (v < 0) return 0;
if (v > 100) return 100;
return v;
};

闭包:Lambda 自动”抓住”它用到的外部变量:

for (int i = 0; i < 3; i++)
{
int captured = i; // 注意闭包捕获
buttons[i].Pressed += () => GotoLevel(captured);
}

(经典坑:直接捕获循环变量 i 会全部变成最终值,复制一份局部变量解决。)

6.6 LINQ:集合查询语法#

using System.Linq; 后,列表查询不再手写循环:

using System.Linq;
// 筛选
var alive = enemies.Where(e => !e.IsDead).ToList();
// 投影:只要坐标
var positions = enemies.Select(e => e.GlobalPosition).ToList();
// 找一个
var closest = enemies
.Where(e => !e.IsDead)
.OrderBy(e => GlobalPosition.DistanceSquaredTo(e.GlobalPosition))
.FirstOrDefault(); // 没有返回 null(引用类型)
// 判断/聚合
bool anyLow = enemies.Any(e => e.Hp < 10);
int totalHp = enemies.Sum(e => e.Hp);
Player? p = players.FirstOrDefault(x => x.IsAlive);
// 分组
var byKind = enemies.GroupBy(e => e.Kind);
foreach (var group in byKind)
GD.Print($"{group.Key}: {group.Count()}");

读成流水线英语即可:Where 过滤 → OrderBy 排序 → Select 变形 → First 取第一个。

Godot 里的注意事项:

  • LINQ 会分配迭代器对象,不要在 _Process/_PhysicsProcess 里每帧对大列表跑复杂 LINQ(偶发逻辑随便用)
  • 每帧热路径用普通 foreach + 手动逻辑
  • GetChildren() 返回 Godot 集合,先 .OfType<Enemy>() 或 cast 后再 LINQ:
    var enemies = GetNode("Enemies").GetChildren().OfType<Enemy>();

6.7 record:为数据而生#

配置、消息体这种”纯数据 + 值相等”的类,用 record 一行写完:

public record DamageInfo(int Amount, DamageType Type, Vector2 SourcePosition);
var d1 = new DamageInfo(10, DamageType.Fire, Vector2.Zero);
var d2 = d1 with { Amount = 20 }; // with 复制改一个字段,第 1 章见过

比 class 少写构造函数,自动获得值相等判断和好看的 ToString。逻辑层消息对象推荐 record;需要继承 Node/Resource 的还是普通 partial class。

6.8 异步 async/await 与 Godot#

C# 异步用 Task:

using System.Threading.Tasks;
public async Task DoSequence()
{
_sprite.Modulate = Colors.Red;
await Task.Delay(200); // 等 200 毫秒(不卡主线程)
_sprite.Modulate = Colors.White;
}

await = “等到这个任务完成,但等待期间游戏继续跑”。

Godot 里等待引擎对象必须用 ToSignal(Task.Delay 也能用但时间会受 Engine.TimeScale 影响差异等):

public override async void _Ready() // 生命周期方法允许 async void
{
await ToSignal(GetTree().CreateTimer(1f), SceneTree.SignalName.Timeout);
GD.Print("1 秒后");
var door = GetNode<Door>("Door");
await ToSignal(door, Door.SignalName.Opened);
GD.Print("门开完了");
}
public async Task PlayDeathAndRespawn() // 自己的异步方法:async Task
{
_anim.Play("die");
await ToSignal(_anim, AnimationPlayer.SignalName.AnimationFinished);
GameManager.Instance.Respawn();
}

规则背下来:

  • async void 只用于重写 Godot 生命周期方法;其余一律 async Task(调用方才能 await、才能捕获异常)
  • await ToSignal(对象, 信号名常量) 是 Godot 异步原语;等一帧用 SceneTree.SignalName.ProcessFrame
  • 节点可能在等待期间被释放:await 回来后先 IsInstanceValid(this) 检查再操作
  • async 不会把代码丢到别的线程:Godot 信号/Timer 的 await 默认在主线程恢复,游戏逻辑不用考虑线程安全。真要用线程(重计算)再学 Task.Run,新手别碰

6.9 命名空间与组织#

类多了用 namespace 分文件夹式管理:

namespace MyGame.Combat; // 文件范围命名空间(C# 10,一行搞定,不带花括号)
public class DamageCalculator { }

使用:using MyGame.Combat; 或全名 MyGame.Combat.DamageCalculator。Godot 脚本不写 namespace 也能全局可见(Godot 源生成器帮忙),项目变大后建议按功能加命名空间。

6.10 综合作业#

  1. 用 List<Vector2> 存 5 个出生点,LINQ 找出离玩家最近的一个刷怪(OrderBy(DistanceSquaredTo).First())。
  2. 用 Dictionary<string, float> 做关卡最佳成绩表:TryGetValue 取、刷新、遍历打印。
  3. 把第 4 章的 Health 改成 C# event 版本,玩家节点订阅后更新血条;验证订阅-退订流程。
  4. 写异步序列:玩家踩到传送门 → 淡出(Tween)→ 换场景 → 淡入(用第 7 章教程一 TransitionLayer 的思路,自己写一遍)。
  5. 性能思考:为什么”每帧 enemies.Where(…).OrderBy(…).ToList()“在 200 个敌人时会掉帧?怎么改成不分配的写法?

第 7 章是最后的 Godot ↔ C# API 对照速查表,写代码时随时翻。


《Godot C# 零基础》系列导航

⬅️ 上一篇:为 Godot 学 C# 05:继承、多态与接口 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:为 Godot 学 C# 07:GDScript 到 C# API 对照速查表

为 Godot 学 C# 06:集合、泛型、委托事件、LINQ 与异步
https://021028.xyz/posts/default/175/
作者
021028
发布于
2026-10-14
许可协议
CC BY-NC-SA 4.0