教程二 · 第 6 章:集合、泛型、委托/事件、LINQ、异步
这是语言篇最后一块硬货,也是游戏代码里每天都在用的工具:敌人列表、缓存节点、事件订阅、筛选查询、延迟等待。
6.1 List:最重要的集合
using System.Collections.Generic; // 命名空间,Godot 脚本常用
var enemies = new List<Enemy>(); // 空列表,装 Enemy
enemies.Add(slime); // 末尾加enemies.Add(bat);enemies.Count // 2enemies[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); // 直接拿 PackedSceneInstantiate<Bullet>(); // 直接拿 Bulletwhere 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 综合作业
- 用
List<Vector2>存 5 个出生点,LINQ 找出离玩家最近的一个刷怪(OrderBy(DistanceSquaredTo).First())。 - 用
Dictionary<string, float>做关卡最佳成绩表:TryGetValue 取、刷新、遍历打印。 - 把第 4 章的 Health 改成 C# event 版本,玩家节点订阅后更新血条;验证订阅-退订流程。
- 写异步序列:玩家踩到传送门 → 淡出(Tween)→ 换场景 → 淡入(用第 7 章教程一 TransitionLayer 的思路,自己写一遍)。
- 性能思考:为什么”每帧 enemies.Where(…).OrderBy(…).ToList()“在 200 个敌人时会掉帧?怎么改成不分配的写法?
第 7 章是最后的 Godot ↔ C# API 对照速查表,写代码时随时翻。
《Godot C# 零基础》系列导航
⬅️ 上一篇:为 Godot 学 C# 05:继承、多态与接口 📑 返回系列总目录(第 01 篇) ➡️ 下一篇:为 Godot 学 C# 07:GDScript 到 C# API 对照速查表