Godot 3.5 C#开发避坑指南:信号连接、场景切换与随机生成实战 1. 项目概述如果你正在用 Godot 3.5 和 C# 做项目大概率已经踩过一些坑了。这个版本虽然稳定但 C# 支持还在成熟过程中很多在 GDScript 里顺理成章的操作换到 C# 里就变得有点“微妙”。特别是信号连接、场景切换和随机生成这几个高频操作新手和老手都可能在这里栽跟头。这篇文章就是把我自己趟过的雷、填过的坑结合实战经验手把手地给你讲清楚。我会假设你已经对 Godot 和 C# 有基础了解但可能在具体实现时遇到了些麻烦。咱们不聊虚的直接上代码说清楚“为什么”和“怎么办”。2. 信号连接的坑从“连不上”到“断不掉”信号Signal是 Godot 解耦的核心机制但在 C# 里它的使用方式和 GDScript 有显著区别稍不注意就会导致内存泄漏或者信号根本触发不了。2.1 坑点一委托Delegate与Callable的混淆在 GDScript 里你直接用connect(“signal_name”, target_node, “method_name”)就行。在 C# 里Godot 3.5 提供了更符合 C# 习惯的操作符来连接信号这背后其实是把信号当作一个事件event来处理。错误示范// 假设我们有一个按钮 Button myButton GetNodeButton(MyButton); // 错误试图把一个方法组method group直接赋值给事件 myButton.Pressed OnButtonPressed; // 编译不通过或者行为异常正确做法using Godot; using System; public class MyScene : Node2D { private Button myButton; public override void _Ready() { myButton GetNodeButton(MyButton); // 正确使用 操作符添加事件处理器 myButton.Pressed OnButtonPressed; } private void OnButtonPressed() { GD.Print(Button pressed!); } }核心原理在 Godot 3.5 的 C# 绑定中大多数信号都被暴露为标准的 C# 事件。Pressed就是一个event。你需要用来订阅连接用-来取消订阅断开。直接赋值会覆盖掉所有已有的订阅者这几乎从来都不是你想要的效果。2.2 坑点二忘记断开连接导致的内存泄漏这是 C# 开发者最容易忽视的问题。在 GDScript 中节点释放时其上的信号连接会自动清理。但在 C# 里如果你用连接了一个对象的方法而这个对象比如另一个 Node的引用被你持有那么即使场景切换了这个连接也可能依然存在阻止垃圾回收器GC回收对象造成内存泄漏。问题场景一个Player节点连接了GameHUD上的某个信号。当Player被移除QueueFree()或场景切换时如果没手动断开GameHUD对Player中事件处理方法的引用会阻止Player被正确释放。解决方案在_ExitTree或析构函数中断开连接public class Player : KinematicBody2D { private GameHUD hud; public override void _Ready() { hud GetNodeGameHUD(/root/GameHUD); hud.OnEnemySpawned HandleEnemySpawned; // 连接 } public override void _ExitTree() { // 非常重要在节点退出场景树时断开连接 if (hud ! null hud.IsInstanceValid()) { hud.OnEnemySpawned - HandleEnemySpawned; } base._ExitTree(); } // 或者使用析构函数Finalizer但 _ExitTree 更可靠因为与Godot生命周期同步。 // ~Player() // { // if (hud ! null hud.IsInstanceValid()) // { // hud.OnEnemySpawned - HandleEnemySpawned; // } // } private void HandleEnemySpawned(Vector2 position) { // 处理逻辑 } }实操心得生命周期对齐将信号连接的清理工作放在_ExitTree()中是最佳实践。这个方法在节点被从场景树中移除时调用与 Godot 的节点生命周期完美契合。空引用检查断开连接前务必检查目标对象如hud是否仍然有效IsInstanceValid()。因为有可能hud节点已经被释放了直接访问会引发NullReferenceException。使用弱事件模式高级对于复杂的、生命周期交叉的对象可以考虑使用弱引用WeakReference来包装事件处理器但这在 Godot C# 中需要自己实现复杂度较高。对于大多数游戏场景在_ExitTree中手动管理已经足够。2.3 坑点三自定义信号的定义与发射在 GDScript 中你用signal my_signal定义用emit_signal(“my_signal”, arg1, arg2)发射。C# 里则需要用到特性Attribute和特定的方法。定义自定义信号[Signal] public delegate void HealthChanged(int oldHealth, int newHealth); [Signal] public delegate void PlayerDied();发射自定义信号你不能直接调用EmitSignal而需要通过一个由 Godot 生成的方法。但更常见的做法是使用GD类的静态方法或者从Node继承的方法如果你的脚本继承自Node。public class Player : KinematicBody2D { [Signal] public delegate void HealthChanged(int oldHealth, int newHealth); private int health 100; public void TakeDamage(int damage) { int oldHealth health; health - damage; // 方法一使用 Godot.Object.EmitSignal (继承自 Node) EmitSignal(nameof(HealthChanged), oldHealth, health); // 方法二使用 GD.EmitSignal (需要传入发出信号的对象) // GD.EmitSignal(this, nameof(HealthChanged), oldHealth, health); if (health 0) { // 发射另一个信号 EmitSignal(nameof(PlayerDied)); // 假设也定义了 PlayerDied 信号 } } }连接自定义信号连接方式和内置信号一样使用。但你需要先获取到定义了该信号的节点实例。public class GameHUD : Control { private Player player; public override void _Ready() { player GetNodePlayer(/root/Main/Player); // 连接自定义信号 player.HealthChanged OnPlayerHealthChanged; // 注意nameof 操作符在这里非常有用提供了编译时检查 } private void OnPlayerHealthChanged(int oldHealth, int newHealth) { UpdateHealthBar(newHealth); } public override void _ExitTree() { if (player ! null player.IsInstanceValid()) { player.HealthChanged - OnPlayerHealthChanged; } base._ExitTree(); } }注意事项nameof是好帮手始终使用nameof(HealthChanged)而不是字符串“HealthChanged”。这样可以在重命名信号时由编译器帮你检查避免运行时错误。信号参数必须匹配连接的方法签名参数类型和数量必须与信号委托的定义完全一致否则连接会失败。Godot 3.5 的局限在 Godot 3.5 的 C# 中自定义信号不会像 GDScript 那样自动出现在编辑器的“信号”选项卡中供可视化连接。你必须在代码中完成所有连接。3. 场景切换的坑资源泄露与状态残留场景切换是游戏流程控制的核心。在 C# 中不正确的切换方式会导致前一个场景的资源没有释放内存占用越来越高也就是常说的“内存泄露”。3.1 坑点一使用ChangeScene后的资源清理GetTree().ChangeScene(“res://path/to/next_scene.tscn”)是最直接的切换方法。但很多人不知道当前场景树的所有节点并不会立即被销毁。问题如果你在当前场景的节点比如一个全局的GameManager单例上连接了信号或者持有对其他节点如音效播放器的引用直接ChangeScene后这些节点可能还残留在内存中因为它们还被引用着。解决方案在ChangeScene前做清理public class GameManager : Node { // 假设这是一个单例通过 AutoLoad 加载 public static GameManager Instance { get; private set; } private AudioStreamPlayer bgmPlayer; public override void _Ready() { Instance this; bgmPlayer GetNodeAudioStreamPlayer(BGMPlayer); } public void GoToMainMenu() { // 1. 停止所有可能持有引用的进程或动画 bgmPlayer.Stop(); // 2. 断开所有自定义的信号连接 // (例如如果其他节点连接了 GameManager 的信号) // 这里通常需要在其他节点的 _ExitTree 中处理但 GameManager 自身要确保不持有外部引用。 // 3. 释放非托管资源如果有比如手动加载的 Texture // someTexture?.Dispose(); // 4. 调用 ChangeScene GetTree().ChangeScene(res://scenes/MainMenu.tscn); // 注意ChangeScene 是异步的。当前帧的代码还会继续执行完。 // 真正的场景卸载和加载发生在下一帧或稍后的时间点。 } // 确保 GameManager 本身也能被正确清理如果是 AutoLoad通常不需要 public override void _ExitTree() { // 清理静态引用防止内存泄漏 if (Instance this) { Instance null; } base._ExitTree(); } }更优方案使用SceneTree.ChangeSceneTo和PackedScene// 预加载场景资源避免切换时的卡顿 PackedScene mainMenuScene GD.LoadPackedScene(res://scenes/MainMenu.tscn); public void GoToMainMenuSmoothly() { // 这种方法允许你在切换前进行更多控制 GetTree().ChangeSceneTo(mainMenuScene); }ChangeSceneTo接受一个已经加载的PackedScene对象比传递字符串路径的ChangeScene效率稍高尤其是在场景已预加载的情况下。3.2 坑点二场景实例化与QueueFree的时机有时我们需要动态实例化一个场景比如生成一个敌人并在适当时候删除它。错误示范PackedScene enemyScene GD.LoadPackedScene(res://entities/Enemy.tscn); public void SpawnEnemy() { Enemy newEnemy enemyScene.Instance() as Enemy; // 实例化 AddChild(newEnemy); // 添加到当前场景树 newEnemy.Position spawnPosition; // ... 一段时间后想要删除 // 错误如果直接置空或等待GC节点还在场景树中会造成“孤儿节点”。 // newEnemy null; } public void RemoveEnemy(Enemy enemy) { // 正确调用 QueueFree enemy.QueueFree(); // QueueFree 会将节点标记为待删除在当前帧结束时或稍后安全地移除并释放它。 }关键点在 Godot 中节点必须通过QueueFree()来从场景树中移除并最终销毁。仅仅在 C# 中将引用设为null不会影响 Godot 内部对该节点的管理节点会继续存在占用资源。实例化时的陷阱跨场景引用public class Spawner : Node2D { private Player player; // 引用另一个场景中的节点 public override void _Ready() { // 假设 Player 是另一个场景的根节点通过路径获取 player GetNodePlayer(/root/CurrentScene/Player); } public void SpawnProjectile() { var projectileScene GD.LoadPackedScene(res://projectiles/Projectile.tscn); Projectile proj projectileScene.Instance() as Projectile; AddChild(proj); proj.Initialize(player.Position); // 传递玩家位置 // 危险如果 Player 场景被切换了player 引用可能变成野指针指向已释放对象。 // 下次 SpawnProjectile 被调用时访问 player.Position 会崩溃。 } }解决方案使用弱引用或实时获取// 方案A每次使用时都尝试获取适用于节点路径稳定的情况 public void SpawnProjectile() { var player GetNodeOrNullPlayer(/root/CurrentScene/Player); if (player ! null player.IsInstanceValid()) { var projectileScene GD.LoadPackedScene(res://projectiles/Projectile.tscn); Projectile proj projectileScene.Instance() as Projectile; AddChild(proj); proj.Initialize(player.Position); } else { GD.PushWarning(Player not found for spawning projectile.); } } // 方案B使用 Godot 的信号/事件机制更解耦 // 让 Player 发射一个“位置更新”信号Spawner 订阅该信号来获取最新位置而不是持有直接引用。3.3 坑点三异步加载与进度反馈对于大型场景直接ChangeScene可能会造成卡顿。Godot 提供了ResourceLoader.LoadInteractive用于异步加载。基本异步加载流程using Godot.Collections; // 需要使用 Godot 的 Dictionary public class SceneLoader : Node { private ResourceInteractiveLoader loader; private string nextScenePath; private ProgressBar progressBar; public void LoadSceneAsync(string scenePath) { nextScenePath scenePath; loader ResourceLoader.LoadInteractive(scenePath); if (loader null) { GD.PushError($Failed to start async load for {scenePath}); return; } SetProcess(true); // 启用 _Process 以轮询加载进度 progressBar.Visible true; } public override void _Process(float delta) { if (loader null) return; // 每帧执行一个加载步骤如加载一个资源 Error err loader.Poll(); if (err Error.FileEof) { // 加载完成 PackedScene scene loader.GetResource() as PackedScene; OnSceneLoaded(scene); } else if (err Error.Ok) { // 仍在加载中更新进度 float progress (float)loader.GetStage() / loader.GetStageCount(); progressBar.Value progress * 100; GD.Print($Loading... {progress:P0}); } else { // 加载出错 GD.PushError($Async load failed: {err}); SetProcess(false); loader null; progressBar.Visible false; } } private void OnSceneLoaded(PackedScene scene) { SetProcess(false); loader null; progressBar.Visible false; if (scene ! null) { GetTree().ChangeSceneTo(scene); } else { GD.PushError(Loaded resource is not a PackedScene.); } } }注意事项Poll()方法在 Godot 3.5 的 C# 绑定中返回的是Godot.Error枚举。GetStage()和GetStageCount()用于计算进度但并非所有资源类型都支持分阶段报告有时GetStageCount()可能返回 0。更稳健的做法是结合已用时间或一个估计值来显示进度条。异步加载期间最好能提供一个加载界面并禁用玩家输入防止在资源未就绪时发生状态错误。4. 随机生成的坑分布、性能与种子随机生成是游戏常见需求无论是地牢房间、敌人位置还是道具掉落。在 C# 中你需要特别注意随机数生成器的选择、使用方式以及性能。4.1 坑点一使用错误的随机数源绝对不要用System.Random在_Process或_PhysicsProcess中每帧生成大量随机数问题System.Random的默认种子基于系统时间。如果在同一帧内快速连续创建多个System.Random实例它们可能会获得相同的时间种子导致产生完全相同的随机序列。这在生成地图时会是灾难性的。解决方案使用 Godot 内置的GD.Rand系列函数或RandomNumberGenerator类。// 方法一使用全局 GD 静态类线程安全但可能影响全局状态 float randomFloat GD.Randf(); // [0.0, 1.0) int randomInt GD.Randi(); // 随机整数 int rangedInt GD.RandiRange(0, 10); // [0, 10] 包含两端 float rangedFloat GD.RandRange(0.0f, 100.0f); // [0.0, 100.0) // 方法二使用 RandomNumberGenerator 实例推荐可控制种子 private RandomNumberGenerator rng new RandomNumberGenerator(); public override void _Ready() { // 设置一个固定种子可以重现相同的随机序列对调试和网络同步极其有用。 rng.Seed 12345UL; // 或者 (ulong)DateTime.Now.Ticks 用于非确定性随机 // rng.Randomize(); // 使用系统时间随机化种子 } public void GenerateEnemies() { for (int i 0; i 10; i) { Vector2 spawnPos new Vector2( rng.RandfRange(100.0f, 900.0f), // X 坐标在 [100, 900) 之间 rng.RandfRange(100.0f, 500.0f) // Y 坐标在 [100, 500) 之间 ); SpawnEnemyAt(spawnPos); } }为什么推荐RandomNumberGenerator独立实例每个RandomNumberGenerator对象有自己的状态不会干扰其他部分的随机逻辑比如 AI 决策和地图生成可以用不同的 RNG 实例。种子控制方便设置固定种子实现可重现的随机过程这对测试、录像回放和网络游戏至关重要。性能在紧密循环中生成大量随机数时使用一个实例比反复调用静态方法可能有轻微优势尽管通常可忽略。4.2 坑点二在 400x400 范围内生成 4000 个随机点标题中提到的“在400*400范围内生成服从随机分布的4000个二行一列的目标点集为y”是一个典型需求。这里“二行一列”可能指Vector2数组。我们需要高效且分布均匀地生成这些点。低效且分布可能不佳的方法ListVector2 points new ListVector2(); for (int i 0; i 4000; i) { points.Add(new Vector2(GD.Randf() * 400, GD.Randf() * 400)); } // 简单随机可能导致点聚集分布不均。使用RandomNumberGenerator并考虑性能private Vector2[] GenerateRandomPoints(int count, float width, float height) { Vector2[] points new Vector2[count]; RandomNumberGenerator rng new RandomNumberGenerator(); rng.Randomize(); // 或设置固定种子 for (int i 0; i count; i) { points[i] new Vector2( rng.Randf() * width, rng.Randf() * height ); } return points; } // 调用 Vector2[] enemyPositions GenerateRandomPoints(4000, 400, 400);如果需要更均匀的分布如泊松圆盘采样对于大量点简单随机可能显得“扎堆”。泊松圆盘采样能保证点与点之间的最小距离分布更均匀但计算量更大。Godot 没有内置此算法需要自己实现或寻找第三方库。一个简单的替代方案是使用抖动网格Jittered Grid。private Vector2[] GenerateJitteredGridPoints(int approxCount, float width, float height, float minDistance) { // 估算网格行列数 float cellSize minDistance; int cols Mathf.FloorToInt(width / cellSize); int rows Mathf.FloorToInt(height / cellSize); int total cols * rows; total Mathf.Min(total, approxCount); // 不超过所需数量 Vector2[] points new Vector2[total]; RandomNumberGenerator rng new RandomNumberGenerator(); rng.Randomize(); for (int i 0; i rows; i) { for (int j 0; j cols; j) { int index i * cols j; if (index total) break; // 网格中心 随机抖动 float x (j 0.5f) * cellSize rng.RandfRange(-cellSize * 0.4f, cellSize * 0.4f); float y (i 0.5f) * cellSize rng.RandfRange(-cellSize * 0.4f, cellSize * 0.4f); // 确保点在边界内 x Mathf.Clamp(x, 0, width); y Mathf.Clamp(y, 0, height); points[index] new Vector2(x, y); } } return points; }4.3 坑点三随机种子管理与网络同步在多人游戏或需要确定性重现的场合如录像、AI 训练随机种子必须同步。最佳实践主控 RNG在权威服务器或主机上维护一个主RandomNumberGenerator实例用于所有关键的随机决策如掉落、暴击、地图生成。共享种子游戏开始时将主 RNG 的种子rng.Seed发送给所有客户端。客户端同步每个客户端使用相同的种子初始化自己的 RNG。这样只要随机数调用顺序一致所有客户端将产生完全相同的随机序列。分离 RNG将游戏逻辑 RNG 和视觉效果 RNG如粒子抖动、屏幕震动分开。这样即使为了调试重设了逻辑种子视觉效果也不会变得完全可预测。public class DeterministicGameSystem : Node { private RandomNumberGenerator gameRng new RandomNumberGenerator(); private RandomNumberGenerator visualRng new RandomNumberGenerator(); public void InitializeGame(ulong sharedGameSeed) { gameRng.Seed sharedGameSeed; visualRng.Randomize(); // 视觉效果可以不用同步 } public bool CalculateCriticalHit(float baseChance) { // 使用 gameRng 保证所有客户端结果一致 return gameRng.Randf() baseChance; } public Vector2 CalculateCameraShakeOffset() { // 使用 visualRng不同客户端可以略有不同没关系 return new Vector2( visualRng.RandfRange(-1, 1), visualRng.RandfRange(-1, 1) ); } }5. 常见问题与排查技巧实录即使避开了上述大坑开发过程中还是会遇到各种稀奇古怪的问题。这里记录了一些典型问题的排查思路和解决方法。5.1 信号连接了但没触发检查连接时机确保连接信号的代码被执行了。把GD.Print(“Signal connected!”)放在连接代码后面运行看是否输出。检查目标对象有效性确保接收信号的节点target在连接时已添加到场景树中IsInsideTree()为 true。通常连接操作应放在_Ready()或之后。检查方法签名对于自定义信号确保连接的方法的参数类型、数量和顺序与信号委托的定义完全一致。大小写也要注意。检查发射时机确保发射信号的代码被执行了。在EmitSignal前加打印语句。C# 事件与 Godot 信号如果你连接的是 C# 事件如Button.Pressed确保是用而不是。如果你发射的是 Godot 自定义信号确保使用了正确的信号名用nameof避免拼写错误。5.2 场景切换后黑屏或卡住检查场景路径ChangeScene的参数是相对于res://的路径且需要包含.tscn扩展名。路径错误会导致加载失败场景树可能为空。检查场景根节点确保要切换到的场景有一个有效的根节点通常是Node2D,Spatial,Control等。使用调试器在_Ready或_EnterTree中加入GD.Print(“New scene ready!”)看是否有输出。如果没有说明新场景没加载成功。检查资源依赖新场景可能引用了丢失或格式错误的资源如图片、音频。查看“调试器”面板的“错误”页签。避免在_Ready中进行阻塞操作如果在_Ready中执行了非常耗时的同步操作如大量计算、同步文件读取可能会阻塞主线程导致画面无法更新。考虑将这些操作移到后台线程或分帧进行。5.3 随机结果不可重现种子未固定如果你希望重现必须在每次运行前显式地为RandomNumberGenerator设置相同的Seed值。GD.Rand系列的种子是全局的通过GD.Seed设置。随机数调用顺序不一致确定性随机的核心是调用顺序。确保所有客户端的游戏逻辑流程完全相同任何条件分支如if语句都基于相同的确定性输入如网络指令、固定输入否则随机数序列就会错位。浮点数精度问题在不同平台CPU架构上浮点数运算可能有极其微小的差异经过大量计算后可能放大导致分支条件判断不同。对于要求极端确定性的场景可考虑使用定点数库或仔细设计逻辑以避免对微小浮点差异敏感的分支。5.4 C# 脚本编译错误或无法加载Godot 版本与 .NET SDK 版本不匹配Godot 3.5 稳定版通常对应 .NET Framework 4.7/4.8 或 .NET Core 3.1/.NET 5/6 的某个特定版本。检查项目.csproj文件中的目标框架TargetFramework是否与 Godot 编辑器内置的 Mono 运行时兼容。缺少依赖如果使用了 NuGet 包确保它们已正确恢复并且其目标框架与你的项目兼容。在 Godot 编辑器内尝试点击“项目” - “工具” - “C#” - “生成解决方案”来强制重新生成和恢复。脚本类名与文件名不一致Godot 要求 C# 脚本的主类名必须与文件名不含扩展名完全一致且继承自 Godot 节点类型。检查输出面板在 Godot 编辑器底部“输出”面板会显示详细的编译错误信息。根据错误信息逐行排查。5.5 性能问题随机生成或实例化卡顿避免在_Process/_PhysicsProcess中大量生成每帧实例化成百上千个对象会立即导致卡顿。改为分帧生成或使用对象池Object Pooling。对象池模式对于频繁创建和销毁的对象如子弹、敌人、特效预先创建一批并存入池中需要时激活并设置位置不需要时禁用并放回池中而不是Instance/QueueFree。预加载资源使用GD.Load或ResourceLoader.Load在游戏开始时或加载界面时预加载常用场景和资源避免在游戏过程中因即时加载造成卡顿。使用MultiMeshInstance如果需要渲染大量相同的物体如草地、子弹轨迹点MultiMeshInstance能极大降低绘制调用draw call比实例化大量独立MeshInstance节点高效得多。6. 实战整合示例一个简单的敌人生成系统让我们把信号、场景切换和随机生成结合起来做一个简单的敌人波次生成系统。// GameManager.cs (作为 AutoLoad 单例) using Godot; using System.Collections.Generic; public class GameManager : Node { [Signal] public delegate void WaveChanged(int currentWave, int totalWaves); [Signal] public delegate void GameOver(); public static GameManager Instance { get; private set; } private PackedScene enemyScene; private RandomNumberGenerator rng new RandomNumberGenerator(); private int currentWave 0; private int enemiesAlive 0; private ListNode enemyPool new ListNode(); private const int POOL_SIZE 20; public override void _Ready() { Instance this; enemyScene GD.LoadPackedScene(res://entities/Enemy.tscn); PreloadEnemyPool(); rng.Seed (ulong)OS.GetUnixTime(); // 使用当前时间作为随机种子 } private void PreloadEnemyPool() { for (int i 0; i POOL_SIZE; i) { Node enemy enemyScene.Instance(); enemy.Visible false; // 先隐藏 enemy.SetProcess(false); // 暂停处理 AddChild(enemy); // 添加到场景树但隐藏 enemyPool.Add(enemy); } } private Node GetEnemyFromPool() { foreach (Node enemy in enemyPool) { if (!enemy.Visible) // 找到一个未激活的 { enemy.Visible true; enemy.SetProcess(true); return enemy; } } // 池子不够用动态实例化一个应尽量避免 GD.PushWarning(Enemy pool exhausted, instancing new enemy.); Node newEnemy enemyScene.Instance(); AddChild(newEnemy); enemyPool.Add(newEnemy); // 加入池子以备后用 return newEnemy; } public void StartNextWave() { currentWave; EmitSignal(nameof(WaveChanged), currentWave, 10); // 假设共10波 int enemyCount 5 currentWave * 2; // 每波敌人增多 enemiesAlive enemyCount; for (int i 0; i enemyCount; i) { SpawnEnemy(); } } private void SpawnEnemy() { Node enemy GetEnemyFromPool(); if (enemy is Enemy enemyScript) { // 随机生成位置假设游戏区域为 800x600 Vector2 spawnPos new Vector2( rng.RandfRange(50, 750), rng.RandfRange(50, 550) ); enemyScript.GlobalPosition spawnPos; enemyScript.Initialize(); // 连接敌人的死亡信号 if (!enemyScript.IsConnected(nameof(Enemy.Died), this, nameof(OnEnemyDied))) { enemyScript.Connect(nameof(Enemy.Died), this, nameof(OnEnemyDied)); } } } private void OnEnemyDied() { enemiesAlive--; if (enemiesAlive 0) { // 这波敌人全部消灭可以准备下一波或胜利 GD.Print($Wave {currentWave} cleared!); // 例如延迟2秒后开始下一波 GetTree().CreateTimer(2.0f).Connect(timeout, this, nameof(StartNextWave)); } } public void PlayerDied() { EmitSignal(nameof(GameOver)); // 清理所有敌人 foreach (Node enemy in enemyPool) { if (enemy is Enemy e) { e.QueueFree(); // 实际项目中可能应该调用一个回收方法 } } enemyPool.Clear(); // 切换至游戏结束场景 GetTree().ChangeScene(res://ui/GameOverScreen.tscn); } public override void _ExitTree() { // 清理静态引用 if (Instance this) { Instance null; } // 断开所有连接这里简化处理实际应对每个连接进行管理 foreach (Node enemy in enemyPool) { if (enemy is Enemy e e.IsConnected(nameof(Enemy.Died), this, nameof(OnEnemyDied))) { e.Disconnect(nameof(Enemy.Died), this, nameof(OnEnemyDied)); } } base._ExitTree(); } } // Enemy.cs using Godot; public class Enemy : KinematicBody2D { [Signal] public delegate void Died(); private int health 30; public void Initialize() { // 重置敌人状态 health 30; // ... 其他初始化 } public void TakeDamage(int damage) { health - damage; if (health 0) { Die(); } } private void Die() { EmitSignal(nameof(Died)); // 回收至对象池而不是 QueueFree Visible false; SetProcess(false); // 通知 GameManager 此敌人可重用这里简化了实际可能需要一个更正式的回调 } }这个示例展示了信号连接与断开GameManager连接每个Enemy的Died信号并在自身清理时断开。场景资源管理使用对象池预加载敌人场景避免运行时频繁实例化。随机生成使用RandomNumberGenerator生成敌人出生位置。场景切换玩家死亡后切换到游戏结束场景。最后记住在 Godot 3.5 中使用 C#时刻保持对对象生命周期的警惕善用IsInstanceValid()进行安全检查在_ExitTree中做好清理工作并优先使用 Godot 提供的内置机制如信号、RandomNumberGenerator而非完全依赖 .NET 标准库这样才能写出稳定、高效的游戏代码。

相关新闻

最新新闻

如何彻底移除Windows Defender:5步终极指南释放系统性能 [特殊字符]

如何彻底移除Windows Defender:5步终极指南释放系统性能 [特殊字符]

如何彻底移除Windows Defender:5步终极指南释放系统性能 🚀 【免费下载链接】windows-defender-remover A tool which is uses to remove Windows Defender in Windows 8.x, Windows 10 (every version) and Windows 11. 项目地址: https://gitcode.co…

2026/7/11 21:57:05
Claude Code 安装配置全指南:Node.js 版本、PowerShell 策略与 CCSwitch 配置

Claude Code 安装配置全指南:Node.js 版本、PowerShell 策略与 CCSwitch 配置

1. 项目概述:Claude Code 不是“另一个 Chat 界面”,而是一套可嵌入工作流的编程代理系统Claude Code 是 Anthropic 官方推出的命令行原生 AI 编程代理工具,它的核心定位不是替代你写代码,而是成为你终端里的“第二双手”——能读…

2026/7/11 21:57:05
WeChatFerry微信机器人自动化框架技术架构分析

WeChatFerry微信机器人自动化框架技术架构分析

WeChatFerry微信机器人自动化框架技术架构分析 【免费下载链接】WeChatFerry 微信机器人,可接入DeepSeek、Gemini、ChatGPT、ChatGLM、讯飞星火、Tigerbot等大模型。微信 hook WeChat Robot Hook. 项目地址: https://gitcode.com/GitHub_Trending/we/WeChatFerry …

2026/7/11 21:57:05
2026 酷泮推广者计划|零成本长期增收!

2026 酷泮推广者计划|零成本长期增收!

石化、新材料、制药、工程施工、各类制造工业从业者,大多都有同款难题: 企业找不到匹配专家,技术服务成本居高不下;工程师一身技术,缺少稳定接单渠道;项目对接全靠线下熟人,耗时低效。酷泮 ComP…

2026/7/11 21:57:05
拼多多笔试真题【多多爱学习】

拼多多笔试真题【多多爱学习】

多多爱学习(C++/Py/Java /Js/Go)题解 拼多多技术岗 5月17号笔试 第三题 题目内容 多多是个爱学习的人,但不巧的是最近家里在装修,他只能去附近的自习室学习,自习室有很多房间,每个房间只有一段时间是空的(不必从最早空闲的自习室开始自习)。 为了不让其他人占用,多多必…

2026/7/11 21:57:05
PilotGo-plugin-syscare性能优化指南:大规模热补丁部署的最佳实践

PilotGo-plugin-syscare性能优化指南:大规模热补丁部署的最佳实践

PilotGo-plugin-syscare性能优化指南:大规模热补丁部署的最佳实践 【免费下载链接】PilotGo-plugin-syscare Introduce SysCare to PilotGo platform. 项目地址: https://gitcode.com/openeuler/PilotGo-plugin-syscare 前往项目官网免费下载:htt…

2026/7/11 21:52:05

月新闻