Godot C# 技巧

环境配置

目前我习惯性的开发环境和版本按照以下处理

  • IDE: Rider 2026.1.3

  • 引擎版本: Godot 4.7.1

  • 代码管理: bitbucket

源代码地址: https://bitbucket.org/meteorgxx/p26/src/sts2-main

目前仅提取杀戮尖塔2的游戏基本骨架, 并且重写一部分功能脚本和业务逻辑

  • 原生杀戮尖塔2当中是采用远程日志上报, 我这里采用 C# 的第三方 Serilog 日志库替代掉

  • 不沿用原来杀戮尖塔2自定义本地化, 采用 Godot 本身的 i18n 处理全球化翻译的问题

  • 官方采用 FMod 音频中心用于对接高级音频设计, 改写由 Godot 内部驱动(杀戮尖塔2团队才是对, 将负责音频设计和程序分离)

  • 内部涉及到 SpineSprite 都没有去解析, 游戏大量采用业界成熟的商业化骨骼动画方案, 商业化部分不会触碰(可能涉及到法律纠纷)

调试环境

Godot 内部已经集成通用的静态方法来识别游戏处于调试环境, 推荐将其通过 C# 的静态扩展写到顶级 Node 对象之中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;
using System.Collections.Generic;
using System.Text;
using Godot;

namespace P26.Core.Extensions;

/// <summary>
/// Godot 树节点扩展方法
/// </summary>
public static class GodotTreeExtensions
{
/// <summary>
/// 确认是否运行在非编辑器模式下, 也就是正式环境
/// </summary>
/// <param name="ignore"></param>
/// <returns></returns>
public static bool IsReleased(this Node ignore)
{
// OS.HasFeature("debug") → 部分 Visual Studio 和 Rider 的 IDE 开发只带的调试标识
// OS.HasFeature("editor") → Godot 内部运行在编辑器的环境
return !(OS.HasFeature("debug") || OS.HasFeature("editor"));
}

}

这样的好处就是只要是继承 Node 节点都自带了 this.IsReleased() 的方法, 可以方便识别出当前是否处于调试环境

日志库

依托 C# 环境可以不需要自己封装日志库, 直接引用 nuget 的第三方包即可, 在项目之中输入以下命令

1
2
3
dotnet add package Serilog  # 引入 Serilog 日志库
dotnet add package Serilog.Sinks.Console # 引入命令行打印输出
dotnet add package Serilog.Sinks.File # 引入文件输出

之后在内部编写 Godot 关联的 LogEventSink 扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using System;
using System.IO;
using Godot;
using Serilog.Core;
using Serilog.Events;
using Serilog.Formatting;
using Serilog.Formatting.Display;

// 这里的命名空间按照自身项目处理
namespace P26.Core.Extensions;

/// <summary>
/// Serilog-Godot 日志扩展
/// </summary>
public class GodotLogEventSink : ILogEventSink
{
// 日志格式化器(使用 Serilog 默认的消息格式, 兼容 {变量} 占位符)
private readonly ITextFormatter _formatter;

// 默认日志格式: [时间] 消息 (可自定义)
private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss}] {Message:lj}{NewLine}{Exception}";


/// <summary>
/// 构造方法
/// </summary>
/// <param name="formatter">日志格式化器</param>
public GodotLogEventSink(ITextFormatter? formatter = null)
{
_formatter = formatter ?? new MessageTemplateTextFormatter(DefaultOutputTemplate);
}


/// <summary>
/// 核心拦截方法
/// </summary>
/// <param name="logEvent">日志事件</param>
public void Emit(LogEvent logEvent)
{
// 非空确认
ArgumentNullException.ThrowIfNull(logEvent);

// 将日志事件转化为字符串
using var stringWriter = new StringWriter();
_formatter.Format(logEvent, stringWriter);
var content = stringWriter.ToString().TrimEnd();

// 根据 Serilog 级别匹配 Godot 日志标记 + 调用 PrintRich
// GD.PrintRich 方法支持富文本格式和日志级别标记, 能让日志在 Output 面板显示对应颜色
// GD.PrintRich 的日志内容前添加特定标记, Godot 会自动识别并赋予对应颜色和日志级别
var (godotTag, logContent) = GetGodotLogContent(logEvent.Level, content);
GD.PrintRich($"{godotTag} {logContent}");
}

/// <summary>
/// 映射 Serilog 级别到 Godot 富文本标记
/// </summary>
private static (string Tag, string Content) GetGodotLogContent(LogEventLevel level, string logText)
{
return level switch
{
LogEventLevel.Fatal => ("[FATAL]", logText), // 亮红色: 致命错误
LogEventLevel.Error => ("[ERROR]", logText), // 红色: 普通错误
LogEventLevel.Warning => ("[WARN] ", logText), // 黄色: 警告
LogEventLevel.Information => ("[INFO] ", logText), // 白色: 普通信息
LogEventLevel.Debug => ("[INFO] ", logText), // 调试信息: 归为普通白色
LogEventLevel.Verbose => ("[INFO] ", logText), // 详细信息: 归为普通白色
_ => ("[INFO] ", logText) // 默认: 普通白色
};
}
}

之后封装 Godot 输出方式处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;
using Serilog;
using Serilog.Configuration;
using Serilog.Events;
using Serilog.Formatting;


namespace P26.Core.Extensions;

/// <summary>
/// Serilog 日志扩展方法注入, 让外部的 Serilog 对象支持扩展 *.WriteTo.Godot() 功能
/// </summary>
public static class GodotSinkExtensions
{
/// <summary>
/// 全局注入对象功能
/// </summary>
/// <param name="configuration">日志配置</param>
/// <param name="formatter">日志格式化</param>
/// <param name="level">日志事件等级</param>
/// <returns>全局配置</returns>
public static LoggerConfiguration Godot(
this LoggerSinkConfiguration configuration,
ITextFormatter? formatter = null,
LogEventLevel level = LevelAlias.Minimum
)
{
// 注册自定义 Sink 到 Serilog
ArgumentNullException.ThrowIfNull(configuration);
return configuration.Sink(
new GodotLogEventSink(formatter),
level);
}

}

之后根脚本启动的时候可以用于注册全局日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
using Godot;
using Serilog;
using Serilog.Events;
using P26.Core.Extensions;

/// <summary>
/// 游戏启动节点
/// </summary>
public partial class NGame : Node
{
/// <summary>
/// 节点唤醒
/// </summary>
public override void _EnterTree()
{

// 初始化日志配置
if (OS.HasFeature("debug") || OS.HasFeature("editor"))
{
// 编辑器运行不需要写入文件
Log.Logger = new LoggerConfiguration()
//.WriteTo.Console() // 测试环境需要在编辑器窗口打印, 所以不需要命令行打印
.WriteTo.Godot() // 注入我们自己扩展的 Godot 输出
.MinimumLevel.Is(LogEventLevel.Debug) // 设置最小等级为 Debug 模式
.CreateLogger();
Log.Information("Starting Godot, Mode: Debug");
}
else
{
// 正式版本运行需要写入文件日志, 一般都是写入用户目录/{游戏命名}.log
var filename = ProjectSettings.GetSetting("application/config/name").AsString();
var logFilename = ProjectSettings.GlobalizePath($"user://{filename}.log");
Log.Logger = new LoggerConfiguration()
.WriteTo.Console() // 设置写入命令行打印
// .WriteTo.Godot() // 正式环境已经不需要 Godot 编辑器输出, 所以可以直接屏蔽
.WriteTo.File(
// 设置写入本地日志文件, 这里日志落地目录要结合 Godot 的本地
// 默认会生成 {logFilename}{日期}{.log:自定义后缀} 日志文件
logFilename,
// 日志文件压缩规则, 按照每日做最大限制压缩
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 1024 * 1024 * 5, // 单个日志文件最大5MB
retainedFileCountLimit: 7 // 仅保留7天的日志文件
)
.MinimumLevel.Is(LogEventLevel.Information) // 设置最小等级为 Info 模式
.CreateLogger();
Log.Information("Starting Godot, Mode: Release, Log: {LogFilename}", logFilename);
}

}

/// <summary>
/// 节点休眠
/// </summary>
public override void _ExitTree()
{


Log.CloseAndFlush(); // 关闭并刷新日志
}
}

后续调用就直接使用 Log 对象即可, 支持以下等级日志

  • Log.Verbose()

  • Log.Debug()

  • Log.Information():

  • Log.Warning()

  • Log.Error()

  • Log.Fatal()

注意: Information 之后(含 Info 自身)的日志应尽可能简短, 避免出现日志过多让玩家硬盘空间直接消耗殆尽

而 杀戮尖塔2 除了自己编写日志库之外, 还是用 Sentry 搭建实时异常上报系统(小成本游戏的作品不推荐这种方式)

节点树扩展

用于扩展 Godot 节点树操作, 这部分源于杀戮尖塔2的 Godot 源码, 但内部其实是有内存泄露问题的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
using System;
using System.Collections.Generic;
using Godot;

// 这里的命名空间按照自身项目处理
namespace P26.Core.Extensions;

/// <summary>
/// Godot 树节点扩展方法
/// </summary>
public static class GodotTreeExtensions
{

/// <summary>
/// 私有的主线程ID标识
/// </summary>
private static int? _mainThreadId;

/// <summary>
/// 确认是否运行在主线程
/// </summary>
/// <returns>布尔值</returns>
public static bool IsMainThread(this Node ignore)
{
if (_mainThreadId.HasValue) return _mainThreadId == System.Environment.CurrentManagedThreadId;
_mainThreadId = System.Environment.CurrentManagedThreadId;
return true;
}

/// <summary>
/// 安全地将子节点添加到父节点
/// </summary>
/// <param name="parent">父节点对象</param>
/// <param name="child">子节点对象</param>
public static void AddChildSafely(this Node parent, Node? child)
{
if (child == null) return;
if (parent.IsMainThread())
{
parent.AddChild(child, forceReadableName: false, Node.InternalMode.Disabled);
return;
}
parent.CallDeferred(Node.MethodName.AddChild, child);
}


/// <summary>
/// 安全地从父节点移除子节点
/// </summary>
/// <param name="parent">父节点对象</param>
/// <param name="child">子节点对象</param>
public static void RemoveChildSafely(this Node parent, Node? child)
{
if (child == null) return;
if (parent.IsMainThread())
{
parent.RemoveChild(child);
return;
}
parent.CallDeferred(Node.MethodName.RemoveChild, child);
}
}

注意, 虽然这段代码是由杀戮尖塔2内部提取, 并且在他们的 NGame.cs 文件这样调用

1
2
3
4
5
// 杀戮尖塔2内部关于这部分调用方法
public void DeactivateWorldEnvironment()
{
this.RemoveChildSafely(WorldEnvironment);
}

但是这里会引发内存泄露: 1 RID allocations of type 'N26RendererEnvironmentStorage11EnvironmentE' were leaked at exit.

虽然这里命名为 Safely , 如果频繁调用删除其实还没有释放资源, 真正处理删除方法需要释放和置空

1
2
3
this.RemoveChildSafely(WorldEnvironment);
WorldEnvironment.QueueFree(); // 移除后必须释放节点, 否则会导致 ObjectDB 及 Resource 泄漏
WorldEnvironment = null;

该静态方法如果频繁切换场景节点释放的话, 可能会导致内存泄露更严重

后续会学习怎么编写自己的资源管理池(Poolable), 方便更好的去管理自身游戏场景资源

异步运行

现代游戏内部已经大量应用异步任务处理, 在其中我个人感觉 C# 异步功能是最简单的(封装起来调用也简单)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using System;
using System.Threading.Tasks;
using Serilog;

// 这里的命名空间按照自身项目处理
namespace P26.Core.Helpers;

/// <summary>
/// 异步任务扩展工具
/// </summary>
public static class TaskHelper
{
/// <summary>
/// 安全运行任务, 异常将被记录
/// </summary>
/// <param name="task">异步任务</param>
/// <returns></returns>
public static Task RunSafely(Task task)
{
return LogTaskExceptions(task);
}

/// <summary>
/// 安全运行任务, 异常将被记录
/// </summary>
/// <param name="task">调用任务对象</param>
private static async Task LogTaskExceptions(Task task)
{
try
{
await task;
}
catch (Exception e)
{
if (e is not TaskCanceledException)
{
Log.Error(e, "Task exception, Message={Message}", e.Message);
}

throw;
}
}

/// <summary>
/// 等待多个任务中的任意一个完成, 实际上就是批量调度任务运行
/// </summary>
/// <param name="tasks">任务列表</param>
public static async Task WhenAny(params Task[] tasks)
{
await await Task.WhenAny(tasks);
}
}

在 杀戮尖塔2 之中会大量用到异步调用, 内部游戏启动就是这样启动运行

1
2
3
4
5
6
7
public override void _EnterTree()
{
// 其他略

// 在项目启动之后直接异步调用游戏启动流程
TaskHelper.RunSafely(GameStartupWrapper());
}

场景过渡

场景过渡的处理方法其实很多很杂, 相对而言目前大部分场景过渡方案有以下几种

  • 纯色(黑色)过渡透明值, 也就是用贴图场景(ColorRect)将 alpha 值从 0 → 1 的过程, 适用于简单场景过渡

  • 纹理着色器(ShaderMaterial)做节点混合动画, 让场景过渡时可以并行叠加多种动画效果, 适用于复杂动画加载过渡

杀戮尖塔2采用 ShaderMaterial 做叠加处理, 内部会着色器转场对象都放置在 {根目录}/materials/transitions 之中

不过这里先说下简单的 ColorRect 场景过渡, 这是最简单常规的过渡效果, 直接创建 NTransition 脚本即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
using System.Threading.Tasks;
using Godot;

namespace P26.Core.Nodes;

/// <summary>
/// 过渡效果功能类
/// </summary>
public partial class NTransition : ColorRect
{
/// <summary>
/// 是否处于正在过渡状态
/// </summary>
public bool InTransition { get; private set; }

/// <summary>
/// 过渡动画定时器
/// </summary>
private Tween? _tween;

/// <summary>
/// 初始化回调
/// </summary>
public override void _Ready()
{
// todo: 初始化
}

/// <summary>
/// 淡出效果: 画面逐渐被黑色遮罩覆盖
/// </summary>
public async Task FadeOut(float duration = 0.8f)
{
_tween?.Kill();// 强制停止目前的动画定时器
Color = new Color(Color, 0.0f); // 将透明值归零
InTransition = true; // 切换正在过渡中
Visible = true;
MouseFilter = MouseFilterEnum.Stop; // 过渡过程当中拦截所有鼠标点击事件

// 定时器设置
_tween = CreateTween().SetParallel(); // 设置并行定时器
_tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Quad); // 设置缓动类型为 Quad, 先快后慢
_tween.TweenProperty(this, "color:a", 1.0f, duration); // alpha 0 → 1
await ToSignal(_tween, Tween.SignalName.Finished); // 直接等待执行到异步任务完成
}

/// <summary>
/// 淡入效果: 黑色遮罩逐渐变为透明, 露出下方场景
/// </summary>
public async Task FadeIn(float duration = 0.8f)
{
_tween?.Kill();// 强制停止目前的动画定时器
Color = new Color(Color, 1f); // 将透明值归一
_tween = CreateTween().SetParallel();
_tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad); // 设置缓动类型为 Quad, 先慢后快
_tween.TweenProperty(this, "color:a", 0.0f, duration); // alpha 1 → 0
await ToSignal(_tween, Tween.SignalName.Finished);

Visible = false;
MouseFilter = MouseFilterEnum.Ignore; // 恢复鼠标穿透
InTransition = false;
}


/// <summary>
/// 直接过场调用的组合用法: FadeOut → 调用者切换场景 → FadeIn
/// </summary>
public async Task Transition(Task sceneTask, float fadeOutDuration = 0.8f, float fadeInDuration = 0.8f)
{
await FadeOut(fadeOutDuration); // 淡出当前场景
await sceneTask; // 等待场景切换完成
await FadeIn(fadeInDuration); // 淡入新场景
}
}

然后需要手动创建 ColorRect 节点并且附加该节点脚本挂载

Godot ColorRect

在 NGame.cs 的启动脚本之中简单编写下测试效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/// <summary>
/// 游戏启动节点
/// </summary>
public partial class NGame : Node
{
/// <summary>
/// 场景过渡层
/// </summary>
public NTransition? Transition { get; private set; }

/// <summary>
/// 退出按钮
/// </summary>
[Export] public Button? ExitButton { get; set; }

/// <summary>
/// 节点唤醒
/// </summary>
public override void _EnterTree()
{
// 其他略

// 获取游戏场景过渡层, 这里采用 % 全局唯一节点语法
Transition = GetNode<NTransition>("%GameTransitionRect");
if (Transition is null)
{
Log.Error("Transition is null!");
QueueFree();
return;
}


// 点击退出
if (ExitButton is not null)
{
ExitButton.Pressed += () => TaskHelper.RunSafely(OnExitButtonPressed());
}


// 异步启动游戏
TaskHelper.RunSafely(GameStartupWrapper());
}


/// <summary>
/// 触发退出
/// </summary>
private async Task OnExitButtonPressed()
{
if (Transition is not null)
{
await Transition.FadeOut(0.85f);
}
GetTree().Quit();
}


/// <summary>
/// 游戏异步启动检测
/// </summary>
private async Task GameStartupWrapper()
{
// todo: 游戏初始化启动, 可以在这里初始化 Steam 等平台信息
// 尝试启动游戏
try
{
await GameStartup();
}
catch
{
_ = TaskHelper.RunSafely(GameStartupError());
throw;
}
}

/// <summary>
/// 游戏异步启动错误处理
/// </summary>
private async Task GameStartupError()
{
Log.Error("Encountered error on game startup! Attempting to show error dialog");
// 生成通用的错误窗口

// 退出游戏
GetTree().Quit();
}


/// <summary>
/// 游戏异步正式启动
/// </summary>
private async Task GameStartup()
{
// 等待节点准备好
if (!IsNodeReady())
{
await ToSignal(this, Node.SignalName.Ready);
}

// 效果淡出
await Transition.FadeIn(0.85f);

// todo: 已经进入游戏主界面
}
}

最终场景过度效果如下

Godot Transition

纯色图片过渡足够简单且性能开销极低, 但是唯一的缺点没办法做复杂的过渡动画, 所以下一步就是复杂着色器过渡效果

着色器过渡

这里就需要 Godot 着色器的知识点, 需要专门生成纹理着色器节点 ShaderMaterial, 这里处理结构如下

1
2
3
GameTransitionRect (ColorRect), 其中挂载两个着色器混合使用
├── GradientTransition (TextureRect) → 房间切换时的渐变遮罩动画
└── SimpleTransition (ColorRect) → 简单的 alpha 淡入淡出

杀戮尖塔2是这样处理这部分代码初始化

1
2
3
4
5
6
7
8
// 在 GameTransitionRect 的脚本 NTransition.cs 初始化直接硬编码
public override void _Ready()
{
_gradientTransition = GetNode<Control>("GradientTransition");
_simpleTransition = GetNode<Control>("SimpleTransition");
_initialGradientYPosition = _gradientTransition.Position.Y;
_targetGradientYPosition = 0f;
}

虽然这样也能运行, 但是这里要说下我的观点: 应该暴露给编辑器节点选择绑定节点, 而不是直接在代码硬编码

  • GetNode 启动的时候本身就带有查找开销

  • 必须要深入看代码才知道具体的节点依赖

  • 不能对资源随意改名, 如果随便改名会导致资源路径错误而崩溃

类似样例应该如下编写和开发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/// <summary>
/// 过渡效果功能类
/// </summary>
public partial class NTransition : ColorRect
{
/// <summary>
/// 贴图材质过渡层 - 通过编辑器绑定节点
/// </summary>
[Export]
public TextureRect? GradientTransition { get; set; }


/// <summary>
/// 简单纯色过渡层 - 通过编辑器绑定节点
/// </summary>
[Export]
public ColorRect? SimpleTransition { get; set; }


/// <summary>
/// 过渡材质起始 Y 轴初始化位置
/// </summary>
private float _initialGradientYPosition;

/// <summary>
/// 过渡材质结束 Y 轴初始化位置
/// </summary>
private float _targetGradientYPosition;

/// <summary>
/// 初始化回调
/// </summary>
public override void _Ready()
{
// 利用 C# 语法糖, 直接可以简短判空缩写
_initialGradientYPosition = GradientTransition?.Position.Y ?? 0f;
_targetGradientYPosition = 0f;
}
}

这里面的核心用途如下

  • SimpleTransition 负责辅助过渡, 让 shader 图案过渡的基础上追加全局 alpha 渐变, 让过渡看起来更平滑

  • GradientTransition 负责核心过渡, 通过纹理渐变达成特定视觉效果切换特效

这里改写之前简单纯色过渡 NTransition 脚本, 我这里用的是和 杀戮尖塔2 完全不一样的处理方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
using System.Threading;
using System.Threading.Tasks;
using Godot;
using Serilog;

namespace P26.Core.Nodes;

/// <summary>
/// 过渡效果功能类
/// </summary>
public partial class NTransition : ColorRect
{
/// <summary>
/// gdshader 参数名称, 也就是着色器当中内置的变量
/// </summary>
private static readonly StringName Threshold = new("threshold");

/// <summary>
/// 是否处于正在过渡状态
/// </summary>
public bool InTransition { get; private set; }

/// <summary>
/// 过渡动画定时器
/// </summary>
private Tween? _tween;

/// <summary>
/// 贴图材质过渡层 - 通过编辑器绑定节点
/// </summary>
[Export]
public TextureRect? GradientTransition { get; set; }


/// <summary>
/// 简单纯色过渡层 - 通过编辑器绑定节点
/// </summary>
[Export]
public ColorRect? SimpleTransition { get; set; }


/// <summary>
/// 过渡材质 - 通过编辑器绑定节点
/// </summary>
[Export]
public Material? TransitionMaterial { get; set; }


/// <summary>
/// 过渡材质起始 Y 轴初始化位置
/// </summary>
private float _initialGradientYPosition;

/// <summary>
/// 过渡材质结束 Y 轴初始化位置
/// </summary>
private float _targetGradientYPosition;


/// <summary>
/// 初始化回调
/// </summary>
public override void _Ready()
{
_initialGradientYPosition = GradientTransition?.Position.Y ?? 0f;
_targetGradientYPosition = 0f;

// 子节点默认为 Stop 会拦截鼠标事件, 设成 Ignore, 由父节点统一控制事件穿透
if (GradientTransition is not null)
GradientTransition.MouseFilter = MouseFilterEnum.Ignore;
if (SimpleTransition is not null)
SimpleTransition.MouseFilter = MouseFilterEnum.Ignore;
}


/// <summary>
/// 利用着色器实现的淡出效果
/// </summary>
/// <param name="duration"></param>
/// <param name="overrideMaterial"></param>
/// <param name="cancelToken"></param>
public async Task SimpleFadeOut(
float duration = 0.8f, ShaderMaterial? overrideMaterial = null, CancellationToken? cancelToken = null
)
{
// 初始化过渡层
var simpleTransition = SimpleTransition; // 获取简单过渡层
if (simpleTransition is null || TransitionMaterial is not ShaderMaterial)
{
InTransition = false;
Log.Warning("NTransition.Material failed to load from resource. Skipping transition.");
return;
}

var modulate = simpleTransition.Modulate; // 获取当前过渡层的 Modulate 值
InTransition = true; // 切换正在过渡中
modulate.A = 0f; // 设置过渡层的透明度为 0 (Alpha)
simpleTransition.Modulate = modulate; // 设置过渡层的 Modulate 值
_tween?.Kill(); // 强制停止目前的动画定时器


// 定时器设置
_tween = CreateTween().SetParallel(); // 设置并行定时器
_tween.TweenProperty(SimpleTransition, "modulate:a", 1f, duration)
.SetEase(Tween.EaseType.In)
.SetTrans(Tween.TransitionType.Quad); // 设置缓动类型为 Quad, 先快后慢


// 确认过渡材质
base.Material = overrideMaterial ?? TransitionMaterial;
if (base.Material is not ShaderMaterial shaderMaterial)
{
Log.Warning(
"{NTransitionName}.Material is null or not a ShaderMaterial (actual: {Name}. Skipping transition.",
nameof(NTransition), base.Material?.GetType().Name ?? "null");
return;
}

// 确定目前的着色器材质属性值
if (shaderMaterial.GetShaderParameter(Threshold).AsDouble() >= 1.0)
{
return;
}

// 给着色器材质传递属性
shaderMaterial.SetShaderParameter(Threshold, 0);

// 过渡期间阻挡鼠标事件
base.MouseFilter = MouseFilterEnum.Stop;

// 开始播放混合动画
var t = 0.0;
while (t < duration)
{
if (cancelToken is { IsCancellationRequested: true })
{
// 注入一个超大的 delta 让 Tween 瞬间跑到终点
_tween?.CustomStep(999.0);
break;
}

shaderMaterial.SetShaderParameter(Threshold, t / duration);
t += GetProcessDeltaTime();
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
}

base.MouseFilter = MouseFilterEnum.Stop;
shaderMaterial.SetShaderParameter(Threshold, 1);
}


/// <summary>
/// 利用着色器实现的淡入效果
/// </summary>
/// <param name="duration"></param>
/// <param name="overrideMaterial"></param>
/// <param name="cancelToken"></param>
public async Task SimpleFadeIn(
float duration = 0.8f, ShaderMaterial? overrideMaterial = null, CancellationToken? cancelToken = null
)
{
// 初始化过渡层
var simpleTransition = SimpleTransition; // 获取简单过渡层
if (simpleTransition is null || TransitionMaterial is not ShaderMaterial)
{
Log.Warning("NTransition.Material failed to load from resource. Skipping transition.");
InTransition = false;
return;
}

var modulate = simpleTransition.Modulate; // 获取当前过渡层的 Modulate 值
modulate.A = 0f; // 设置过渡层的透明度为 0 (Alpha)
simpleTransition.Modulate = modulate; // 设置过渡层的 Modulate 值
_tween?.Kill(); // 强制停止目前的动画定时器


// 确认过渡材质
base.Material = overrideMaterial ?? TransitionMaterial;
if (base.Material is not ShaderMaterial shaderMaterial)
{
Log.Warning(
"{NTransitionName}.Material is null or not a ShaderMaterial (actual: {Name}. Skipping transition.",
nameof(NTransition), base.Material?.GetType().Name ?? "null");
InTransition = false;
return;
}

// 确定目前的着色器材质属性值 — 移除首次调用的错误跳过逻辑
// 注意: 不再检查 threshold == 0 来跳过, 因为首次调用时默认值就是 0


// 给着色器材质传递属性
shaderMaterial.SetShaderParameter(Threshold, 1);

// 过渡期间阻挡鼠标事件
base.MouseFilter = MouseFilterEnum.Stop;

var t = 0.0;
while (t < duration)
{
if (cancelToken.HasValue && cancelToken.GetValueOrDefault().IsCancellationRequested)
{
// 注入一个超大的 delta 让 Tween 瞬间跑到终点
_tween?.CustomStep(999.0);
break;
}

var progress = 1.0 - t / duration;
shaderMaterial.SetShaderParameter(Threshold, progress * progress * progress);
t += GetProcessDeltaTime();
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
if (t / duration > 0.75)
{
InTransition = false;
}
}

InTransition = false;
shaderMaterial.SetShaderParameter(Threshold, 0);
base.MouseFilter = MouseFilterEnum.Ignore;
}
}

这里除了挂载 GradientTransition 和 SimpleTransition 节点之外, 需要编写着色器文件

  • res://shaders/fade_transition.gdshader: 主要着色器文件

  • res://materials/transitions/fade_transition_mat.tres: 着色器材质文件

一般游戏要做出很好看的特效, 那么就需要学习怎么处理对应的游戏着色器

这里的 fade_transition.gdshader 着色器代码其实很简单, 直接捕获外部传递 threshold 变量设置着色器阿尔法值

1
2
3
4
5
6
7
shader_type canvas_item;

uniform float threshold : hint_range(0,1);

void fragment() {
COLOR.a = threshold;
}

Godot 创建着色器只要在文件夹右键 ‘新建(New)’ → ‘资源(Resource)’ → ‘着色器(Shader)’ 命名即可

最后对应创建的效果如下

Godot Shader

杀戮尖塔2 的 NTransition 场景过渡写得真是太垃圾了, 他的整体过渡逻辑是有很大问题

不建议看原版代码效果, 可以参考我这部分代码直接用, NTransition.cs 这个文件写得都这么多问题

而且阅读代码之后, 这里面仅仅是做简单纯色过渡效果; 直接纯色过渡不好吗? 整体代码给我整个人都看无语了, 总之最后效果还是一样的

Shader Transition

自定义弹出消息窗口

大部分情况很少用到系统弹窗, 但是部分关键环境需要调用到系统弹窗功能, 这里罗列常见的需要系统弹窗情况

  • 异常报错的时候需要弹出系统窗口提示启动失败

  • 点击窗口关闭的时候拦截等待确认时候关闭

  • 网络游戏交互的时候网络异常中断抛出错误

这里用的是杀戮尖塔2当中的 NGenericPopup.cs 讲解, 这个文件其实不怎么值得去看, 整体都是过度包装和功能耦合到极致

对于 NGenericPopup 功能, 大部分情况适用于系统异常之类的窗口, 基本涉及到以下功能使用

  • NGame 游戏启动时候初始化报错

  • NMultiplayerLoadGameScreen 多人游戏读取游戏错误

  • SteamJoinCallbackHandler Steam 调用功能异常

这部分不应该和游戏内部UI绑定, 可能会导致引发整体业务崩溃, 而是直接采用 Godot 内部的系统窗口功能

按照原来游戏工程风格重新写了个 WindowPopupHelper.cs 功能类, 封装系统弹窗集合功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using System.Threading.Tasks;
using Godot;

namespace P26.Core.Helpers;

/// <summary>
/// 系统窗口工具
/// </summary>
public static class WindowPopupHelper
{
/// <summary>
/// 系统窗口工具, 提供类似 OS.alert/OS.confirm 风格的全局静态方法
/// 优先使用原生系统弹窗, 不支持时回退到 DisplayServer.DialogShow
/// 返回 Task 方便配合过渡层系统使用 await。
/// </summary>
public static async Task Alert(string message, string title, string[]? buttons = null)
{
// DisplayServer 系统弹窗
TaskCompletionSource<int> tcs = new();
buttons ??= ["OK"];
DisplayServer.DialogShow(title, message, buttons,
Callable.From<int>(index => tcs.TrySetResult(index)));
await tcs.Task;
}

/// <summary>
/// Confirm 弹窗, 异步等待用户选择
/// 用户点击确定返回 true, 点击取消或关闭返回 false
/// 用法: bool ok = await WindowPopupHelper.Confirm("确定要删除吗?", "确认")
/// </summary>
public static async Task<int> Confirm(string message, string title, string[]? buttons = null)
{
// DisplayServer 系统弹窗
TaskCompletionSource<int> tcs = new();
buttons ??= ["OK"];
DisplayServer.DialogShow(title, message, buttons,
Callable.From<int>(index => tcs.TrySetResult(index)));
return await tcs.Task;
}
}

千万别学杀戮尖塔2的初始化失败都要调用自己写的 UI 业务弹窗

sts2 Popup

谨记关键点: 启动初始化未完成, 千万不要动任何涉及游戏内部的业务组件!

这里顺路提供功能: 点击窗口右上角关闭按钮的拦截询问是否退出游戏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/// <summary>
/// 游戏启动节点
/// </summary>
public partial class NGame : Node
{

/// <summary>
/// 节点初始化
/// </summary>
public override void _Ready()
{
// 其他略

// 关闭自动退出拦截, 核心
GetTree().AutoAcceptQuit = false;
}


/// <summary>
/// 拦截窗口事件
/// </summary>
/// <param name="what"></param>
public override void _Notification(int what)
{
// 玩家点击窗口右上角关闭游戏
if (what == NotificationWMCloseRequest)
{
_ = NotificationExitConfirm();
}
}

/// <summary>
/// 拦截关闭请求
/// </summary>
private async Task NotificationExitConfirm()
{
var res = await WindowPopupHelper.Confirm(
"Are you sure you want to exit?",
"Are you sure?",
["Yes", "No"]
);

// 按钮点击触发的位置, 以 0 开始
if (res == 0)
{
GetTree().Quit();
}
}
}

这几行代码就可以实现点击游戏窗口右上角关闭询问功能

Run Popup

编辑器点击运行退出窗口提示选择否的时候, 游戏窗口边框消失是正常现象, 正式环境不会出现(编辑器启动本身模拟环境)

资源管理

这里就是最核心的 PreloadManager.cs 资源生命周期管理文件, Godot 大部分游戏资源可以抽离以下分类

  • Resource: Godot 的基础资源类, 可存取任意 Resource 派生类型

  • PackedScene: Godot 的场景文件节点树, 保存 UI/角色/房间节点树

  • Texture2D: Godot 标准 2D 纹理, 保存 Sprite 和 UI 贴图等资源

  • Material: Godot 纹理材质, 负责保存渲染效果和角色材质

  • CompressedTexture2D: VRAM 压缩纹理(DDS/Basis), 高清图 GPU 友好格式的贴图纹理

  • VFX: 游戏内部的粒子特效数据

其实我也感觉这个资源类写得也挺烂的, 这部分涉及到以下脚本文件

  • PreloadManager.cs

  • AssetCache.cs

  • AssetLoadingSession.cs

  • NAssetLoader.cs

这里从游戏启动的 PreloadManager.LoadMainMenuEssentials() 加载主界面功能说起, 具体启动代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static class PreloadManager
{
public static async Task LoadMainMenuEssentials()
{
if (!TestMode.IsOn)
{
// AssetSets.MainMenuEssentials 是静态常量类
// 内部的很多资源都是穿插在不同脚本的硬编码常量组合, 我感觉整体代码都很混乱
// AssetSets.MainMenuEssentials = [
// "res://scenes/screens/main_menu.tscn",
// "res://materials/transitions/fight_transition_mat.tres",
// "res://materials/transitions/fade_transition_mat.tres",
// "res://images/ui/language_warning.png",
// "res://shaders/hsv.gdshader",
// "res://shaders/dark_blur.gdshader"
// ]
await (await LoadAssetSets("MainMenuEssentials", AssetSets.MainMenuEssentials)).WaitForCompletion();
}
}
}

这里面不用管它内部代码怎么编写, 只需要知道最终产生的效果就是后台加载对应资源并设置为指定资源组名称

杀戮尖塔2这部分代码只能参考加载和运行流程, 其他还不如自己重写架构, 资源管理文件和大量其他 Manger 依赖交叉

这里我这边重写以下工具类

  • AssetCache: 资源缓存类

  • AssetEntry: 资源实体类

  • AssetGroup: 资源分组类

  • AssetLoadException: 资源加载异常类

  • AssetManager: 资源对象管理器

异常类没什么好说, 直接继承 Exception 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System;

namespace P26.Core.Assets;

/// <summary>
/// 资源加载异常
/// </summary>
public class AssetLoadException : Exception
{
public AssetLoadException(string message)
: base(message)
{
}
public AssetLoadException(string message, Exception innerException)
: base(message, innerException)
{

}
}

主要的是资源实体类, 我将 Godot 部分资源抽象成项目类资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
using Godot;

namespace P26.Core.Assets;

/// <summary>
/// 高度封装的资源实体对象
/// 主要是让开发者必须清楚了解到资源管理的类型, 常规的资源类型
/// - ResourceEntry → Godot Resource 最底层资源
/// - PackedSceneEntry → 常规场景(UI、角色、房间)
/// - Texture2DEntry → 2D 纹理
/// - MaterialEntry → 渲染材质
/// - CompressedTexture2DEntry → VRAM 压缩纹理
/// - VfxEntry → 视觉效果场景(粒子、特效, 需要串行延后加载)
/// </summary>
public abstract record AssetEntry
{
/// <summary>
/// 资源路径
/// </summary>
public string Path { get; init; }

/// <summary>
/// 资源类型提示
/// </summary>
public string TypeHint { get; init; } = "";

/// <summary>
/// 是否使用子线程加载资源
/// </summary>
public bool UseSubThreads { get; set; } = false;


/// <summary>
/// 资源缓存模式
/// </summary>
public ResourceLoader.CacheMode CacheMode { get; init; } = ResourceLoader.CacheMode.Reuse;


/// <summary>
/// 资源对象
/// </summary>
public Resource? Resource { get; set; } = null;

/// <summary>
/// 是否已加载资源
/// </summary>
public bool Loaded => Resource != null;

/// <summary>
/// 私有化资源实体对象
/// </summary>
/// <param name="path"></param>
private protected AssetEntry(string path) => Path = path;



#region 资源声明类对象

/// <summary>
/// Resource 类型资产
/// </summary>
/// <param name="Path"></param>
public sealed record ResourceEntry(string Path) : AssetEntry(Path);


/// <summary>
/// PackedScene 类型资产
/// </summary>
/// <param name="Path"></param>
public sealed record PackedSceneEntry(string Path) : AssetEntry(Path);

/// <summary>
/// Texture2D 类型资产
/// </summary>
/// <param name="Path"></param>
public sealed record Texture2DEntry(string Path) : AssetEntry(Path);

/// <summary>
/// Material 类型资产
/// </summary>
/// <param name="Path"></param>
public sealed record MaterialEntry(string Path) : AssetEntry(Path);

/// <summary>
/// CompressedTexture2D 类型资产
/// </summary>
/// <param name="Path"></param>
public sealed record CompressedTexture2DEntry(string Path) : AssetEntry(Path);


/// <summary>
/// Vfx 类型资产
/// </summary>
/// <param name="Path"></param>
public sealed record VfxEntry(string Path) : AssetEntry(Path);

#endregion

}

开发者必须要明确你的游戏资源是什么类型, 才能方便对某些特定资源做优化加载处理

比如常见的粒子特效类型(vfx), 这类资源加载时间长需要留到场景和贴图资源加载之后再延迟加载

杀戮尖塔2的资源是直接 Resource 对象管理一切, 这部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
// 以下是杀戮尖塔2正式代码 AssetCache.cs 文件
public class AssetCache
{
// 直接资源用 Resource 包装
private readonly ConcurrentDictionary<string, Resource> _cache = new ConcurrentDictionary<string, Resource>();

// 用到的是否直接强制转化
public PackedScene GetScene(string path)
{
return (PackedScene)GetAsset(path);
}
}

这种方式管理资源是很不可控的, 所以我这边抽象成 AssetEntry 实体数据对象来管理, 并且追加对于底层设置的内容

然后游戏一般是采用批量资源加载(比如游戏会加载着色器/场景文件/地图资源等等), 需要用资源组(Group)来批量加载, 资源组功能类如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;
using Godot;
using Serilog;

namespace P26.Core.Assets;

/// <summary>
/// Godot 资源加载任务组
/// </summary>
public class AssetGroup
{
/// <summary>
/// 每帧最大并发加载数
/// </summary>
private const int MaxConcurrentLoads = 128;

/// <summary>
/// 资源加载任务组名称
/// </summary>
private readonly string _name;

/// <summary>
/// 资源缓存
/// </summary>
private readonly ConcurrentDictionary<string, AssetEntry> _cache;

/// <summary>
/// 资源缓存对象
/// </summary>
private readonly AssetCache? _assetCache;

/// <summary>
/// 待加载资源队列
/// </summary>
private readonly Queue<string> _waiting = new();

/// <summary>
/// 加载中的资源队列
/// </summary>
private readonly Queue<string> _loading = new();

/// <summary>
/// 完成加载的资源队列
/// </summary>
private readonly Queue<string> _finalizing = new();


/// <summary>
/// 资源加载完成任务源
/// </summary>
private readonly TaskCompletionSource<bool> _completionSource = new();

/// <summary>
/// 资源加载任务
/// </summary>
public Task<bool> Task => _completionSource.Task;

/// <summary>
/// 资源加载任务是否完成
/// </summary>
public bool IsCompleted => _completionSource.Task.IsCompleted;

/// <summary>
/// 资源加载计时器
/// </summary>
private readonly Stopwatch _stopwatch = new();

/// <summary>
/// 已加载资源总数
/// </summary>
private int _totalLoaded;


/// <summary>
/// 当前正在加载的 VFX 资源路径
/// </summary>
private string? _currentVfxPath;


/// <summary>
/// 特殊的 VFX 资源队列
/// </summary>
private readonly Queue<string> _vfx = new();

/// <summary>
/// VFX 资源加载状态
/// </summary>
private bool _vfxLoading;


/// <summary>
/// 资源加载任务组构造函数
/// </summary>
/// <param name="name"></param>
/// <param name="entries"></param>
/// <param name="cache"></param>
/// <param name="assetCache"></param>
public AssetGroup(string name, IEnumerable<AssetEntry> entries, ConcurrentDictionary<string, AssetEntry> cache,
AssetCache assetCache)
{
_name = name;
_cache = cache;
_assetCache = assetCache;
foreach (var entry in entries)
{
// 注册到缓存(需要保证此时 Resource = null)
_cache[entry.Path] = entry;
if (entry is AssetEntry.VfxEntry)
_vfx.Enqueue(entry.Path);
else
_waiting.Enqueue(entry.Path);
}

_stopwatch.Start();
Log.Information("Preloading '{Name}' asset count={WaitCount}, vfx count={VfxCount}",
name, _waiting.Count, _vfx.Count);
}

/// <summary>
/// 空资源加载任务组
/// </summary>
private AssetGroup()
{
_name = "EMPTY";
_cache = [];
_waiting = [];
_loading = [];
_finalizing = [];
_vfx = [];
_assetCache = null;
_completionSource.SetResult(result: true);
}

/// <summary>
/// 创建一个空的资源加载任务组
/// </summary>
/// <returns></returns>
public static AssetGroup Empty()
{
return new AssetGroup();
}


/// <summary>
/// 将资源添加到缓存
/// </summary>
/// <param name="resource"></param>
/// <param name="path"></param>
private void AddToCache(Resource? resource, string path)
{
if (resource == null || !_cache.TryGetValue(path, out var value))
{
Log.Error("Resource loaded as null for path: {Path}", path);
return;
}

// 写入资源对象
_totalLoaded++;
value.Resource = resource;
}


/// <summary>
/// 完成资源加载
/// </summary>
private void FinalizeLoading()
{
while (_finalizing.Count != 0)
{
if (!_finalizing.TryDequeue(out var result))
{
Log.Error("Failed to dequeue finalizing asset!");
}
else
{
AddToCache(ResourceLoader.LoadThreadedGet(result), result);
}
}
}

/// <summary>
/// 处理加载队列
/// </summary>
private void ProcessLoadingQueue()
{
while (_loading.Count < MaxConcurrentLoads && _waiting.TryDequeue(out var result))
{
// 确认已经设置完成了
if (!_cache.TryGetValue(result, out var value)) continue;
if (value.Resource is not null) continue;

// 请求资源加载
if (ResourceLoader.LoadThreadedRequest(
value.Path, value.TypeHint, useSubThreads: value.UseSubThreads, value.CacheMode) == Error.Ok)
{
_loading.Enqueue(result);
}
else
{
Log.Error("Error requesting load for path: {Path}", result);
}
}
}

/// <summary>
/// 检查资源加载状态
/// </summary>
private void CheckLoadingStatus()
{
var count = _loading.Count;
for (var i = 0; i < count; i++)
{
if (!_loading.TryDequeue(out var result))
{
Log.Error("Failed to dequeue loading asset!");
break;
}

// 确认资源存在
if (!_cache.TryGetValue(result, out var value)) continue;
if (value.Resource is not null) continue;


// 启动加载
var threadLoadStatus = ResourceLoader.LoadThreadedGetStatus(value.Path);
switch (threadLoadStatus)
{
case ResourceLoader.ThreadLoadStatus.Loaded:
_finalizing.Enqueue(value.Path);
continue;
case ResourceLoader.ThreadLoadStatus.Failed:
Log.Error("Failed loading asset: {Path}", value.Path);
_assetCache?.MarkAssetFailed(value.Path);
continue;
case ResourceLoader.ThreadLoadStatus.InvalidResource:
{
Log.Warning("InvalidResource status for {Path}, falling back to sync load", value.Path);
var resource = ResourceLoader.Load<Resource>(value.Path, value.TypeHint, value.CacheMode);
if (resource is not null)
{
AddToCache(resource, value.Path);
}
else
{
Log.Error("Failed to load resource synchronously: {Path}", value.Path);
}

continue;
}
case ResourceLoader.ThreadLoadStatus.InProgress:
_loading.Enqueue(value.Path);
continue;
default:
Log.Error("Unexpected thread load status for path: {Path}", value.Path);
continue;
}
}
}

/// <summary>
/// 处理 VFX 资源加载队列
/// </summary>
public void ProcessVfxQueue()
{
if (_vfxLoading && _currentVfxPath is not null)
{
switch (ResourceLoader.LoadThreadedGetStatus(_currentVfxPath))
{
case ResourceLoader.ThreadLoadStatus.Loaded:
var res = ResourceLoader.LoadThreadedGet(_currentVfxPath);
AddToCache(res, _currentVfxPath);
_vfxLoading = false;
break;
case ResourceLoader.ThreadLoadStatus.InvalidResource:
case ResourceLoader.ThreadLoadStatus.Failed:
Log.Error("Failed to load VFX scene: {Path}", _currentVfxPath);
_vfxLoading = false;
break;
case ResourceLoader.ThreadLoadStatus.InProgress:
break;
default:
throw new ArgumentOutOfRangeException();
}

return;
}

while (_vfx.TryDequeue(out var result))
{
// 确认资源存在
if (!_cache.TryGetValue(result, out var value)) continue;
if (value.Resource is not null) continue;


if (ResourceLoader.LoadThreadedRequest(value.Path, value.TypeHint, useSubThreads: value.UseSubThreads,
value.CacheMode) == Error.Ok)
{
_currentVfxPath = value.Path;
_vfxLoading = true;
break;
}

Log.Error("Error requesting VFX load for path: {Path}", value.Path);
}
}

/// <summary>
/// 处理资源加载任务
/// </summary>
public void Process()
{
// 开始执行加载任务
FinalizeLoading();
ProcessLoadingQueue();
CheckLoadingStatus();
if (_waiting.Count == 0 && _loading.Count == 0 && _finalizing.Count == 0)
{
ProcessVfxQueue();
}

// 日志记录
Log.Debug(
"Preloading '{Name}' Process: toLoad={WaitingCount} loading={LoadingCount} finalizing={FinalizingCount} vfx={VfxCount}",
_name, _waiting.Count, _loading.Count, _finalizing.Count, _vfx.Count);

// 执行确认所有资源加载
if (_waiting.Count != 0 || _loading.Count != 0 || _finalizing.Count != 0 || _vfx.Count != 0 ||
_vfxLoading) return;

// 资源加载完成
Log.Information("Preloading '{Name}' Complete: assets={TotalLoaded} time_elapsed={ElapsedMilliseconds}ms",
_name, _totalLoaded, _stopwatch.ElapsedMilliseconds);
_stopwatch.Stop();
_completionSource.TrySetResult(result: true);
}

/// <summary>
/// 等待资源加载完成
/// </summary>
/// <returns></returns>
public Task WaitForCompletion()
{
return _completionSource.Task;
}
}

这里就是将大量的游戏资源的资源加载任务移交到给 Godot 异步处理, 并且会做资源缓存, 最后就是核心的资源管理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace P26.Core.Assets;

/// <summary>
/// 资源管理器
/// </summary>
public static class AssetManager
{
/// <summary>
/// 是否启用资源管理器
/// </summary>
public static bool Enabled { get; set; } = true;

/// <summary>
/// 资源缓存
/// </summary>
public static AssetCache Cache { get; } = new();


/// <summary>
/// 资源组创建事件
/// </summary>
public static event Action<string, AssetGroup>? OnAssetGroupCreated;


/// <summary>
/// 加载资源集
/// </summary>
/// <param name="name"></param>
/// <param name="assetSets"></param>
/// <returns></returns>
private static async Task<AssetGroup> LoadAssetSets(string name, params IEnumerable<AssetEntry>[] assetSets)
{
// 移除重复的资源
var hashMap = new Dictionary<string, AssetEntry>();
var hashSet = new HashSet<string>();
foreach (var item in assetSets.SelectMany(set => set))
{
hashSet.Add(item.Path);
hashMap[item.Path] = item;
}

// 获取已经加载并缓存的资源并移除重复的资源, 最终获取需要加载的资源
var loadedCacheAssets = Cache.GetLoadedCacheAssets();
var assetsToUnloadSet = loadedCacheAssets.Except(hashSet);
var needLoadedPaths = hashSet.Except(loadedCacheAssets);
var needLoaded = needLoadedPaths.Select(p => hashMap[p]);

// 异步等待执行
Cache.UnloadAssets(assetsToUnloadSet);
await Task.Yield();

// 开始确认加载的资源
return !Enabled ? AssetGroup.Empty() : LoadAssets(name, needLoaded);
}

/// <summary>
/// 加载资源集
/// </summary>
/// <param name="assetPaths"></param>
/// <param name="name"></param>
/// <returns></returns>
private static AssetGroup LoadAssets(string name, IEnumerable<AssetEntry> assetPaths)
{
var assetGroup = Cache.CreateGroup(name, assetPaths);
// 这里考虑 C# 委托机制, 让外部接收回调然后自行使用异步任务
OnAssetGroupCreated?.Invoke(name, assetGroup);
return assetGroup;
}


/// <summary>
/// 异步加载主场景资源
/// </summary>
public static async Task LoadMainMenuEssentials()
{
// 后续可以移交给静态类处理
// 这里仅仅做演示, 展示场景资源应该怎么被读取加载
await (await LoadAssetSets("MainMenuEssentials", [
new AssetEntry.PackedSceneEntry("res://scenes/screens/main_menu.tscn"),
new AssetEntry.ResourceEntry("res://materials/transitions/fight_transition_mat.tres"),
new AssetEntry.ResourceEntry("res://materials/transitions/fade_transition_mat.tres"),
new AssetEntry.ResourceEntry("res://shaders/hsv.gdshader"),
new AssetEntry.ResourceEntry("res://shaders/dark_blur.gdshader"),
])).WaitForCompletion();
}
}

资源使用的时候需要在启动脚本的帧更新方法中不断订阅当前场景的切换事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// ═══ 1. 启动时: 订阅事件, 注册帧驱动 ═══
public partial class GameRoot : Node
{
private readonly ConcurrentQueue<AssetGroup> _groups = new();

private AssetGroup? _current;

public override void _Ready()
{
// 订阅 — AssetManager 创建组时自动入队
AssetManager.OnAssetGroupCreated += OnGroupCreated;
}

private void OnGroupCreated(string name, AssetGroup group)
{
GD.Print($"资源组 [{name}] 已创建, 等待驱动");
_groups.Enqueue(group);
}

public override void _Process(double delta)
{
// 每帧驱动一个资源组
if (_current == null || _current.IsCompleted)
{
if (_groups.TryDequeue(out var next)) _current = next;
}
else
{
_current.Process();
}
}
}

// ═══ 2. 游戏流程: 加载资源 ═══
public async Task StartGame()
{
// 加载主菜单
await AssetManager.LoadMainMenuEssentials();

// 加载关卡资源
await LoadActOneAssets();
}

private async Task LoadActOneAssets()
{
await (await AssetManager.LoadAssetSets("ActOne", new AssetEntry[]
{
// 场景
new AssetEntry.PackedSceneEntry("res://scenes/combat.tscn"),
new AssetEntry.PackedSceneEntry("res://scenes/map.tscn"),

// 纹理
new AssetEntry.Texture2DEntry("res://images/bg_forest.png"),

// 材质
new AssetEntry.MaterialEntry("res://materials/enemy_mat.tres"),

// Shader( 走 ResourceEntry 兜底 )
new AssetEntry.ResourceEntry("res://shaders/outline.gdshader"),

// VFX — 自动延后串行加载
new AssetEntry.VfxEntry("res://vfx/hit_spark.tscn"),
new AssetEntry.VfxEntry("res://vfx/death_explosion.tscn"),
})).WaitForCompletion();
}

// ═══ 3. 消费: 从缓存取用 ═══
private void UseLoadedAssets()
{
// 取场景
var mainMenu = AssetManager.Cache.GetScene("res://scenes/screens/main_menu.tscn");
if (mainMenu is not null)
{
var node = mainMenu.Resource.Instantiate<PackedScene>();
AddChild(node);
}

// 取纹理
var bgTex = AssetManager.Cache.GetTexture2D("res://images/bg_forest.png");
sprite.Texture = bgTex?.Resource as Texture2D;

// 取 VFX
var vfx = AssetManager.Cache.GetVfx("res://vfx/hit_spark.tscn");
if (vfx is not null && vfx.Loaded)
{
PlayEffect((PackedScene)vfx.Resource);
}

// 取原始 entry
var raw = AssetManager.Cache.GetRaw("res://shaders/hsv.gdshader");
if (raw is AssetEntry.ResourceEntry shaderEntry && shaderEntry.Loaded)
{
material.Shader = (Shader)shaderEntry.Resource;
}

// 检查是否存在
if (AssetManager.Cache.ContainsKey("res://scenes/combat.tscn"))
{
// 已注册( 可能还在加载中 )
}
}

这里资源加载流程更加可控, 并且做好各自功能类业务隔离, 不会将大量业务功能耦合在一起

输入管理 - 初始化

杀戮尖塔2输入管理是采用 NInputManager 功能类节点挂载在 NGame 主场景的, 内部很少 Godot 全局挂载功能

可能因为内部互相依赖太严重, 导致系统启动挂载会因为还没完成初始化就调用, 我看到内部代码互相依赖 Manager 的情况太严重

输入控制器算是相对来说比较复杂的情况, 大部分看情况下要有以下方面输入源

  • 鼠标键盘

  • 手柄控制

  • 触屏控制

不过 Godot 底层已经消除了这部分差异, 只需要接收调用执行的回调就行, 而杀戮尖塔2涉及到以下脚本文件

  • NInputManager.cs - 主要核心输入管理器节点, 主要用于被主节点调用和绑定监听

  • DebugHotkey.cs - 测试阶段的调试快捷键常量类, 用于开发作弊指令和隐藏部分UI查看效果

  • MegaInput.cs - 常规的组合快捷键常量类, 比较最常见的场景有 F1~F12 和对应鼠标游戏界面点击按键绑定

  • NControllerManager.cs - 设备控制器底层监听调度, 整套操作界面 UI 在此生成(鼠标键盘点击UI和移动设备的虚拟操作盘等)

比如游戏要加个测试期间作弊 ‘点击一次玩家+1000金币’ 的功能, 直接在 DebugHotkey.cs 绑定特殊按键常量并实现效果即可

这里按照原来的相关功能改进些逻辑, 具体可以直接参考下使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
using Godot;

namespace P26.Core.Inputs;

/// <summary>
/// 测试阶段用于策划的作弊快捷键
/// 常量定义需要 Godot 编辑器上面的 '项目 → 项目设置 → 输入映射' 菜单确定存在
/// 部分系统内置的定义需要打开 '显示内置动作' 就可以看到, 作弊按键需要 Godot 提前声明
/// </summary>
public static class InputDebugHotkey
{

/// <summary>
/// 隐藏战斗UI
/// </summary>
public static readonly StringName HideCombatUi = "debug_hide_combat_ui";

/// <summary>
/// 隐藏事件UI
/// </summary>
public static readonly StringName HideEventUi = "debug_hide_event_ui";


/// <summary>
/// 测试加速, 默认以 1x 倍数基准, 多次点击提高倍数(最高只允许 10x)
/// </summary>
public static readonly StringName SpeedUp = "debug_speed_up";

/// <summary>
/// 测试减速, 默认以 1x 倍数基准, 多次点击降低倍数(最低只允许 1x)
/// </summary>
public static readonly StringName SpeedDown = "debug_speed_down";


/// <summary>
/// 解锁所有角色
/// </summary>
public static readonly StringName UnlockCharacters = "debug_unlock_characters";

}

这里精简原来部分测试快捷作弊键, 只保留可能会用到的作弊按键定义, 之后就是常规组合键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using Godot;

namespace P26.Core.Inputs;

/// <summary>
/// 定义常规游戏按键
/// 常量定义需要 Godot 编辑器上面的 '项目 → 项目设置 → 输入映射' 菜单确定存在
/// 部分系统内置的定义需要打开 '显示内置动作' 就可以看到
/// 这里需要调整的 ui_backspace: 新建 ui_backspace 需要修改成 Backspace 键调度(0.5值)
/// </summary>
public static class InputMegaKey
{
/// <summary>
/// 上
/// </summary>
public static readonly StringName Up = "ui_up";

/// <summary>
/// 下
/// </summary>
public static readonly StringName Down = "ui_down";

/// <summary>
/// 左
/// </summary>
public static readonly StringName Left = "ui_left";

/// <summary>
/// 右
/// </summary>
public static readonly StringName Right = "ui_right";

/// <summary>
/// 触发功能, 键盘E键/Xbox手柄A键/PS的 ○ 键
/// </summary>
public static readonly StringName Accept = "ui_accept";

/// <summary>
/// 取消功能, 键盘Esc键/Xbox手柄B键/PS的 × 键
/// </summary>
public static readonly StringName Cancel = "ui_cancel";


/// <summary>
/// 确认键, 键盘当中的 Enter 键效果, 部分手柄是用于呼出菜单功能
/// </summary>
public static readonly StringName Select = "ui_select";


/// <summary>
/// 后退, 键盘当中的 Backspace 键效果
/// 注意区分 Cancel 不等于 Back
/// - Cancel(ESC): 关闭/取消 — 关弹窗、取消操作、返回上一页
/// - Back(Backspace): 后退/返回 — 返回上一级菜单、后退一步
/// 两者的语义是不一样的
/// </summary>
public static readonly StringName Backspace = "ui_backspace";


/// <summary>
/// 所有输入键
/// </summary>
public static string[] Keys =>
[
Accept, Cancel, Select, Up, Down, Left, Right, Backspace
];
}

游戏初期只需要这些按键操作, 后续按照需求可以追加不同按键功能(比如按下 F10 进入无敌模式/按下 F11 金钱 +999999 等操作)

这里初步编写 ‘输入管理器骨架’ 脚本, 这部分我个人优化杀戮空间2一堆缠绕 Manager, 重写改成利用事件委托机制的调用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Godot;
using P26.Core.Helpers;
using P26.Core.Inputs;

namespace P26.Core.Nodes;

/// <summary>
/// 输入管理器
/// </summary>
public partial class NInputManager : Node
{
/// <summary>
/// 输入管理器实例
/// </summary>
private static NInputManager? _instance;

/// <summary>
/// 输入管理器实例
/// </summary>
public static NInputManager Instance => _instance ??= new NInputManager();


/// <summary>
/// 核心: 内置的按键绑定更新信号, 大部分 EmitSignal(SignalName.InputRebound) 代码都是推送该信号
/// </summary>
[Signal]
public delegate void InputReboundEventHandler();


#region 按键相关定义

/// <summary>
/// 调试输入映射, 这里的 Key.* 是 Godot 内部封装的键值
/// 主要将键值关联自定义 InputDebugHotkey.* 静态类
/// </summary>
private readonly Dictionary<Key, StringName> _debugInputs = new()
{
{
Key.Key3, // 键盘的 3 键
InputDebugHotkey.HideCombatUi
},
{
Key.Minus, // 减号键
InputDebugHotkey.SpeedDown
},
{
Key.Equal, // 加号键
InputDebugHotkey.SpeedUp
},
{
Key.F3,
InputDebugHotkey.HideEventUi
},
{
Key.U, // 直接作弊解锁所有全部角色
InputDebugHotkey.UnlockCharacters
}
};

/// <summary>
/// 允许可重新映射的键盘绑定按键
/// </summary>
public static readonly IReadOnlyList<StringName> RemappableKeyboardInputs = new List<StringName>
{
InputMegaKey.Select,
InputMegaKey.Cancel,
InputMegaKey.Accept,
InputMegaKey.Up,
InputMegaKey.Down,
InputMegaKey.Left,
InputMegaKey.Right,
InputMegaKey.Backspace

// InputMegaKey.F1, // 独有的鼠标键盘按键
};

/// <summary>
/// 允许可重新映射的控制器绑定按键
/// 之所以将这些按键放在一起,是因为它们都是控制器绑定的按键
/// 比如鼠标键盘独有的 F1~F12 键是控制器绑定不了的, 如果后续需要添加其他控制器也需要另外定义
/// 目前初步设定控制器和键盘的按键是一致的
/// </summary>
public static readonly IReadOnlyList<StringName> RemappableControllerInputs = new List<StringName>
{
InputMegaKey.Select,
InputMegaKey.Cancel,
InputMegaKey.Accept,
InputMegaKey.Up,
InputMegaKey.Down,
InputMegaKey.Left,
InputMegaKey.Right,
InputMegaKey.Backspace
};

/// <summary>
/// 目前加载的键盘输入映射 - 可以被玩家修改, 打开设置并保存后生效落地保存
/// </summary>
private Dictionary<StringName, Key> _keyboardInputs = new();

/// <summary>
/// 目前加载的控制器输入映射 - 可以被玩家修改, 打开设置并保存后生效落地保存
/// </summary>
private Dictionary<StringName, StringName> _controllerInputs = new();

/// <summary>
/// 默认键盘输入映射 - 游戏默认设置, 方便直接设置当中直接还原
/// </summary>
private static Dictionary<StringName, Key> DefaultKeyboardInputs => new()
{
{
InputMegaKey.Accept,
Key.E
},
{
InputMegaKey.Select,
Key.Enter
},
{
InputMegaKey.Cancel,
Key.Escape
},
{
InputMegaKey.Up,
Key.Up
},
{
InputMegaKey.Down,
Key.Down
},
{
InputMegaKey.Left,
Key.Left
},
{
InputMegaKey.Right,
Key.Right
},
{
InputMegaKey.Backspace,
Key.Backspace
}
};

#endregion


#region 外部监听事件

/// <summary>
/// 初始化输入映射事件
/// </summary>
public Task? OnInitInputMapping { get; set; }

/// <summary>
/// 加载键盘输入映射事件
/// </summary>
public Func<Dictionary<string, string>?>? OnLoadKeyboardInputMapping { get; set; }

/// <summary>
/// 保存键盘输入映射事件
/// </summary>
public Action<Dictionary<string, string>>? OnSaveKeyboardInputMapping { get; set; }

/// <summary>
/// 初始化控制器输入映射事件, 控制器独有需要按照不同设备做不同初始化
/// </summary>
public Func<Dictionary<string, string>>? OnInitControllerInputMapping { get; set; }

/// <summary>
/// 加载控制器输入映射事件
/// </summary>
public Func<Dictionary<string, string>>? OnLoadControllerInputMapping { get; set; }


/// <summary>
/// 保存控制器输入映射事件
/// </summary>
public Action<Dictionary<string, string>>? OnSaveControllerInputMapping { get; set; }

#endregion


/// <summary>
/// 当节点进入场景树时调用
/// </summary>
public override void _Ready()
{
_instance ??= this;
TaskHelper.RunSafely(Init());
}


/// <summary>
/// 初始化输入管理器
/// </summary>
private async Task Init()
{
// 需要监听异步初始化可用的控制器
if (OnInitInputMapping is not null)
{
await OnInitInputMapping;
}

// 键盘按键管理: 如果不存在配置就加载默认的按键
var keyboards = OnLoadKeyboardInputMapping?.Invoke();
if (keyboards is not null)
{
// 将加载的配置同步到游戏
_keyboardInputs = new Dictionary<StringName, Key>();
foreach (var item in keyboards)
{
_keyboardInputs.Add(item.Key, Enum.Parse<Key>(item.Value));
}
}
else
{
_keyboardInputs = DefaultKeyboardInputs; // 加载默认的键盘输入映射
SaveKeyboardInputMapping(); // 保存默认的键盘输入映射
}

// 控制器按键管理: 如果不存在配置就加载默认的按键
var controllers = OnLoadControllerInputMapping?.Invoke();
if (controllers is not null)
{
// 将加载的配置同步到游戏
_controllerInputs = new Dictionary<StringName, StringName>();
foreach (var item in controllers)
{
_controllerInputs.Add(item.Key, item.Value);
}
}
else
{
// 这里是特殊的控制器输入映射, 需要按照不同控制器加载不同的输入映射
if (OnInitControllerInputMapping is not null)
{
var settings = OnInitControllerInputMapping.Invoke();
foreach (var item in settings)
{
_controllerInputs.Add(item.Key, item.Value);
}
}

SaveControllerInputMapping(); // 保存默认的控制器输入映射
}
}

/// <summary>
/// 重置输入映射为默认值
/// </summary>
public void ResetToDefault()
{
_keyboardInputs = DefaultKeyboardInputs;
if (OnInitControllerInputMapping is not null)
{
_controllerInputs = new Dictionary<StringName, StringName>();
var settings = OnInitControllerInputMapping.Invoke();
foreach (var item in settings)
{
_controllerInputs.Add(item.Key, item.Value);
}
}


// 保存并通知信号
SaveControllerInputMapping();
SaveKeyboardInputMapping();
EmitSignal(SignalName.InputRebound); // 广播推送给 InputReboundEventHandler 信号
}


/// <summary>
/// 修改键盘按键绑定
/// </summary>
public void ModifyKeyboardButton(StringName input, Key keyboardInput)
{
// 没有变动不需要处理
if (_keyboardInputs.TryGetValue(input, out var current) && current == keyboardInput)
return;

// 确定存在绑定的按键
var setting = _keyboardInputs.FirstOrDefault((k) =>
k.Value == keyboardInput && RemappableKeyboardInputs.Contains(k.Key));

// 重写按键
if (setting.Key is not null && _keyboardInputs.TryGetValue(input, out var oldKey))
{
_keyboardInputs[setting.Key] = oldKey;
}

// 保存并重写
_keyboardInputs[input] = keyboardInput;
SaveKeyboardInputMapping();
EmitSignal(SignalName.InputRebound); // 广播推送给 InputReboundEventHandler 信号
}

/// <summary>
/// 修改控制器按键绑定
/// </summary>
public void ModifyControllerButton(StringName input, StringName controllerInput)
{
// 没有变动不需要处理
if (_controllerInputs.TryGetValue(input, out var current) && current == controllerInput)
return;

// 确定存在绑定的按键
var setting = _controllerInputs.FirstOrDefault((k) =>
k.Value == controllerInput && RemappableControllerInputs.Contains(k.Key));

// 重写按键
if (setting.Key is not null && _controllerInputs.TryGetValue(input, out var oldKey))
{
_controllerInputs[setting.Key] = oldKey;
}

// 保存并重写
_controllerInputs[input] = controllerInput;
SaveControllerInputMapping();
EmitSignal(SignalName.InputRebound); // 广播推送给 InputReboundEventHandler 信号
}


/// <summary>
/// 保存键盘输入映射
/// 这里不要学杀戮尖塔直接 SaveManager.Instance.SaveSettings() 直接调用另外 Manager
/// 而是要暴露外部委托 Action 提供监听写入
/// </summary>
private void SaveKeyboardInputMapping()
{
var settings = new Dictionary<string, string>();
foreach (var item in _keyboardInputs)
{
settings.Add(item.Key.ToString(), item.Value.ToString());
}

OnSaveKeyboardInputMapping?.Invoke(settings); // 调用外部委托
}


/// <summary>
/// 保存控制器输入映射
/// 这里也是直接采用委托来暴露监听
/// </summary>
private void SaveControllerInputMapping()
{
var settings = new Dictionary<string, string>();
foreach (var item in _controllerInputs)
{
settings.Add(item.Key.ToString(), item.Value.ToString());
}

OnSaveControllerInputMapping?.Invoke(settings);
}


/// <summary>
/// 键盘/按键控制核心
/// </summary>
public override void _UnhandledKeyInput(InputEvent inputEvent)
{
// 等待编写
}

/// <summary>
/// 手柄/方向控制核心
/// </summary>
public override void _UnhandledInput(InputEvent inputEvent)
{
// 等待编写
}
}

杀戮尖塔2 控制器直接在 InputManager 当中调用大量 ControllerManager 和 SaveSettingManager, 整体代码真的太混乱了

输入管理器骨架搭建完成之后, 就需要针对 Godot 的 _UnhandledKeyInput 和 _UnhandledInput 回调方法做转化调用处理

后续这里需要添加 Godot 所需的回调代码, 并且暴露编辑器设定默认的控制器类型(键鼠?xbox手柄?ns手柄?xbox手柄?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
/// <summary>
/// 输入管理器
/// </summary>
public partial class NInputManager : Node
{
// 其他代码略

#region 输入设备变动

/// <summary>
/// 输入映射类型
/// </summary>
public enum MappingType
{
Default,
Playstation,
Xbox,
NintendoSwitch
}

/// <summary>
/// 控制器映射类型
/// </summary>
[Export]
public MappingType ControllerMappingType { get;set; } = MappingType.Default;


/// <summary>
/// 设置控制器映射类型
/// </summary>
public void SetControllerMappingType(MappingType type)
{
// 确认变动
if (ControllerMappingType == type) return;
ControllerMappingType = type;

// 尝试加载该类型的已有保存配置
var saved = OnLoadControllerInputMapping?.Invoke();
if (saved is not null && saved.Count > 0)
{
_controllerInputs = new Dictionary<StringName, StringName>();
foreach (var item in saved)
_controllerInputs.Add(item.Key, item.Value);
}
else
{
// 没有则走初始化委托拿默认映射
var defaultMapping = OnInitControllerInputMapping?.Invoke();
_controllerInputs = new Dictionary<StringName, StringName>();
if (defaultMapping is not null)
{
foreach (var item in defaultMapping)
_controllerInputs.Add(item.Key, item.Value);
}
}
SaveControllerInputMapping();
EmitSignal(SignalName.InputRebound); // 广播推送给 InputReboundEventHandler 信号
}


#endregion


/// <summary>
/// 由外部确认通知初始化完成, 外部必须确定初始化完成才能开始接收按键输入
/// </summary>
public bool Initialized { get; set; }


/// <summary>
/// 键盘/按键控制核心
/// </summary>
public override void _UnhandledKeyInput(InputEvent inputEvent)
{
ProcessKeyboardInput(inputEvent); // 处理键盘输入
ProcessDebugKeyInput(inputEvent); // 处理调试输入
}

/// <summary>
/// 键盘/按键控制核心
/// </summary>
private void ProcessKeyboardInput(InputEvent inputEvent)
{
// 如果没有初始化或者输入事件不是按键事件则返回
if (!Initialized || inputEvent is not InputEventKey keyEvent) return;

// 处理按键事件并转发事件
foreach (var item in _keyboardInputs)
{
if (keyEvent.Keycode != item.Value || inputEvent.IsEcho()) continue;
// 如果按键事件匹配则转发事件
var e = new InputEventAction
{
Action = item.Key,
Pressed = keyEvent.Pressed
};
Input.ParseInputEvent(e);
}
}

/// <summary>
/// 调试按键控制核心
/// </summary>
private void ProcessDebugKeyInput(InputEvent inputEvent)
{
// 如果正式环境或者输入事件不是按键事件则返回
// this.IsReleased() 扩展方法可以查看其他章节, 有说明怎么实现全局测试环境判断
if (this.IsReleased() || !Initialized || inputEvent is not InputEventKey keyEvent) return;

// 处理按键事件并转发事件
foreach (var item in _debugInputs)
{
if (keyEvent.Keycode != item.Key) continue;
// 如果按键事件匹配则转发事件
var e = new InputEventAction
{
Action = item.Value,
Pressed = keyEvent.Pressed
};
Input.ParseInputEvent(e);
}
}

/// <summary>
/// 手柄/方向控制核心
/// </summary>
public override void _UnhandledInput(InputEvent inputEvent)
{
// 未初始化就跳过执行
if (!Initialized) return;

// 处理按键事件并转发事件
foreach (var item in _controllerInputs)
{
// 如果按键事件匹配则转发事件
if (inputEvent.IsActionPressed(item.Value))
{
var e = new InputEventAction
{
Action = item.Key,
Pressed = true
};
Input.ParseInputEvent(e);
}
else if (inputEvent.IsActionReleased(item.Value))
{
var e = new InputEventAction
{
Action = item.Key,
Pressed = false
};
Input.ParseInputEvent(e);
}
}
}

}

至此 NInputManager 输入管理器已经完成全部代码, 剩下都是其他自行绑定信号或者调用的功能, 整体做到功能解耦合的处理方式

后续已经不需要动到 NInputManager.cs 脚本文件任何代码了, 全部工作都是由外部自行绑定事件和信号处理

Input Mapping

后面编写派生不同平台手柄按键等功能, 主要 Steam 手柄调度(感谢 Steam 有针对多种手柄按键映射, 否则要自行处理Xbox/PS等手柄)

输入管理 - 鼠标键盘

注意: 因为涉及到手柄控制器太多扩展适配问题, 该篇章只从鼠标和键盘操作说明(适配整体游戏手柄是很费事)

杀戮空间2的 NControllerManager 能看到底层大量代码交叉依赖, 上手的时候也是吓我一跳, 所以我这里就是采用精简功能来解析代码

对于鼠标按键的控制, 目前就可以直接调用处理, 这里以 NGame.cs 启动根节点为例初始化来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
namespace P26.Core.Nodes;

/// <summary>
/// 游戏启动节点
/// </summary>
public partial class NGame : Node
{
// 其他代码略

/// <summary>
/// 输入管理器, 由编辑器来提供手动绑定
/// </summary>
[Export]
public NInputManager? InputManagerNode { get; set; }

/// <summary>
/// 节点初始化
/// </summary>
public override void _Ready()
{
// 其他略

// 注入委托
if (InputManagerNode is not null)
{
// 纯键鼠模式只接键盘输入
InputManagerNode.OnInitInputMapping = Task.Run(() =>
{
Log.Debug("ControllerManager Ready");
});
InputManagerNode.OnLoadKeyboardInputMapping = LoadKeyboardFromDisk;
InputManagerNode.OnSaveKeyboardInputMapping = SaveKeyboardToDisk;
}


// 异步启动系统
TaskHelper.RunSafely(GameStartupWrapper());
}


/// <summary>
/// 从保存配置中加载键盘输入映射
/// </summary>
private Dictionary<string, string>? LoadKeyboardFromDisk()
{
// 从存档读取,没有就返回 null → 走默认映射
Log.Debug("Loading keyboard from disk");
return null;
}

/// <summary>
/// 保存键盘输入映射到保存配置
/// </summary>
private void SaveKeyboardToDisk(Dictionary<string, string> settings)
{
Log.Debug("Saving keyboard settings to disk...");
}

/// <summary>
/// 游戏异步启动检测
/// </summary>
private async Task GameStartupWrapper()
{
// 其他略

// 等待节点准备好
if (!IsNodeReady())
{
await ToSignal(this, Node.SignalName.Ready);
}

// 设置初始化完成
if (InputManagerNode is not null)
{
InputManagerNode.Initialized = true;
}
}
}

这里的 LoadKeyboardFromDisk 和 SaveKeyboardToDisk 就是保存和读取本地系统的输入快捷键配置, 后续代码调用也简单

1
2
3
4
5
6
7
8
9
10
11
12
13
// 所有地方统一用 InputMegaKey, 不裸写字符串且不区分输入源
if (Input.IsActionPressed(InputMegaKey.Accept))
ConfirmAction();

if (Input.IsActionJustPressed(InputMegaKey.Cancel))
ClosePanel();

if (Input.IsActionPressed(InputMegaKey.Up))
MoveSelection(-1);

// 调试键同理
if (Input.IsActionJustPressed(InputDebugHotkey.SpeedUp))
TimeScale *= 2;

不过你会看到其实你裸写 Godot 的 Input.* 方法也能实现这些效果, 为什么要搞得这么复杂?

其实原因就是最开始说的, 方便为了多平台扩展必须适配 PC/主机/移动端, 也就是键鼠/手柄/触屏都要做好输入适配

实例管理

说完资源系统(AssetManager)负责的是 素材加载, 还有另外核心的概念的实例对象管理(Instantiate)

这里举例 打飞机 类型的游戏, 在飞机点击射击的时候就会生成子弹, 而子弹都会抽象成单独的节点资源, 按照面向对象的说法伪代码如下

1
2
3
4
5
// 玩家点击开始生成子弹
var bullet = new Bullet();
bullet.start = new Vec2({起点坐标});
bullet.end = new Vec2({终点坐标});
bullet.run(); // 开始执行子弹运动和碰撞逻辑

这样看起来整体流程是能够跑通的, 但是动态实时生成资源会带来一系列性能问题, 首先需要知道子弹资源构成

  • 节点(Node)

  • 特效(Shader)

  • 脚本(Script)

如果更加复杂的情况下, 动态创建子弹成本会指数性上升, 而且维护管理也非常麻烦

每秒 60 帧 × 每帧 10 颗子弹 = 每秒 600 次以上的构建/销毁, 低性能设备游玩可能会直接崩溃

所以也就衍生出资源池的概念, 杀戮尖塔2的资源池管理就是在 src/Core/Nodes/Pooling 目录之中, 以下是关键文件

  • INodePool.cs - 节点池的抽象管理器接口

  • IPoolable.cs - 节点的生命周期抽象接口

  • NodePool.cs - 节点池的管理池对象具体实现

首先是核心的 IPoolable.cs 资源周期抽象接口, 接口对象非常简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace P26.Core.Pooling;

/// <summary>
/// 资源池可复用对象
/// </summary>
public interface IPoolable
{
/// <summary>
/// 对象实例化时调用
/// </summary>
void OnInstantiated();

/// <summary>
/// 对象从池中返回时调用
/// </summary>
void OnReturnedFromPool();

/// <summary>
/// 对象被释放回池中时调用
/// </summary>
void OnFreedToPool();
}

还有资源池抽象接口 INodePool.cs 的脚本文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace P26.Core.Pooling;

/// <summary>
/// Node 节点管理器抽象
/// </summary>
public interface INodePool
{

/// <summary>
/// 从池中获取一个节点
/// </summary>
IPoolable Get();

/// <summary>
/// 将节点释放回池中
/// </summary>
void Free(IPoolable poolable);
}

这两个文件是没有问题, 问题是在 NodePool.cs 的实现类上, 源码版本就能品鉴到这种依赖乱飞的情况

Call Manager

杀戮尖塔2的游戏项目好几次看到这种依赖乱飞的情况, 每次看到这些代码很糟心(甚至还不如 AI 写出来的正确)

不过杀戮尖塔2源码调用确实足够简单, 只需要像下面调用资源池就可以了

1
2
3
4
5
6
7
// 原来的源码调用方式, 内部已经帮你做好 PreloadManager.Cache 底层资源缓存
// 但是带来的代价就是内部功能类被 PreloadManager 功能强侵入
// 这里就是杀戮尖塔2的具体卡牌池初始化功能
// 战斗中手牌最多 10 张, 加上抽牌堆、弃牌堆、预览等各种同时存在的卡牌节点, 一局游戏保守预制 30 个 NCard 实例化对象已经足够用
// 如果池子空了, 内部 Get() 会动态创建新的资源数据来扩容
// 一般就是从池子抽取资源之后替换掉内部可变属性, 这样就是新的卡牌对象, 玩家只是需要卡牌数据来使用而已
NodePool.Init<NCard>("res://scenes/cards/card.tscn", 30);

但是我很不喜欢这种方式, 没有关联性的功能类都不应该交叉调用, 而是应该采用委托和回调方式暴露引用

另外源码这部分功能不要无脑照抄, 因为他们开发评估过游戏 30 张卡已经是极限, 但并不是所有游戏资源都是这个数值, 这里列举以下情况

  • FPS 游戏是会出现每秒发射几十发数量级的子弹, 这种情况下就要考虑如果设置值过小会频繁扩容, 如果值过大会占用系统资源

  • RPG 游戏是会带有攻击出现伤害数字(这也是种场景资源), 攻击频率越高展示的伤害数据信息也就越多, 所以也需要避免值太小频繁扩容

我这里重写部分 NodePool.cs 功能, 通过委托将缓存功能暴露出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Godot;
using P26.Core.Extensions;
using Serilog;

namespace P26.Core.Pooling;

/// <summary>
/// Node 对象池泛型实现
/// </summary>
[SuppressMessage("ReSharper", "StaticMemberInGenericType")]
public class NodePool<T> : INodePool where T : Node, IPoolable
{
/// <summary>
/// 名称变体
/// </summary>
private static readonly Variant NameVariant = Variant.CreateFrom("name");

/// <summary>
/// 可调用变体
/// </summary>
private static readonly Variant CallableVariant = Variant.CreateFrom("callable");

/// <summary>
/// 信号变体
/// </summary>
private static readonly Variant SignalVariant = Variant.CreateFrom("signal");


/// <summary>
/// 未使用的对象列表
/// </summary>
private readonly List<T> _freeObjects = [];

/// <summary>
/// 已使用的对象集合
/// </summary>
private readonly HashSet<T> _usedObjects = [];


/// <summary>
/// 场景加载委托 — 外部注入, 替代杀戮尖塔2原来 PreloadManager 狗屎一样的硬依赖
/// </summary>
public static Func<T>? Factory { get; set; }


/// <summary>
/// 构造函数
/// </summary>
public NodePool(int capacity = 0)
{
for (var i = 0; i < capacity; i++)
{
_freeObjects.Add(Instantiate());
}
}


/// <summary>
/// 实例化资源对象
/// </summary>
private static T Instantiate()
{
var val = Factory?.Invoke()
?? throw new InvalidOperationException($"Factory not set: {typeof(T).Name}");
val.OnInstantiated();
return val;
}

/// <summary>
/// 从对象池中获取一个对象
/// </summary>
/// <returns></returns>
IPoolable INodePool.Get() => Get();

/// <summary>
/// 从对象池中获取一个对象
/// </summary>
public T Get()
{
T val;
if (_freeObjects.Count > 0)
{
val = _freeObjects[_freeObjects.Count - 1];
_freeObjects.RemoveAt(_freeObjects.Count - 1);
}
else
{
val = Instantiate();
}

_usedObjects.Add(val);
val.OnReturnedFromPool();
return val;
}


/// <summary>
/// 归还对象到对象池
/// </summary>
void INodePool.Free(IPoolable poolable)
{
Free((T)poolable);
}

public void Free(T obj)
{
if (!_usedObjects.Contains(obj))
{
if (_freeObjects.Contains(obj))
{
Log.Error(
"Tried to free object {Poolable} ({GetType}) back to pool {Type} but it's already been freed!", obj,
obj.GetType(), typeof(NodePool<T>));
}
else
{
Log.Error(
"Tried to free object {Poolable} ({GetType}) back to pool {Type} but it's not part of the pool!",
obj, obj.GetType(), typeof(NodePool<T>));

// 确认是否在主线程释放
// 如果非主线程就使用 CallDeferred 释放
if (obj.IsMainThread())
{
obj.QueueFree();
}
else
{
obj.CallDeferred(Node.MethodName.QueueFree);
}
}
}
else
{
DisconnectIncomingAndOutgoingSignals(obj);
_usedObjects.Remove(obj);
_freeObjects.Add(obj);
obj.OnFreedToPool();
}
}


/// <summary>
/// 断开对象的信号连接
/// </summary>
private static void DisconnectIncomingAndOutgoingSignals(Node obj)
{
foreach (var signal4 in obj.GetSignalList())
{
var signal = signal4[NameVariant].AsStringName();
foreach (var signalConnection in obj.GetSignalConnectionList(signal))
{
var callable = signalConnection[CallableVariant].AsCallable();
var signal2 = signalConnection[SignalVariant].AsSignal();
DisconnectSignal(callable, signal2);
}
}

foreach (var incomingConnection in obj.GetIncomingConnections())
{
var callable2 = incomingConnection[CallableVariant].AsCallable();
var signal3 = incomingConnection[SignalVariant].AsSignal();
DisconnectSignal(callable2, signal3);
}

for (var i = 0; i < obj.GetChildCount(); i++)
{
DisconnectIncomingAndOutgoingSignals(obj.GetChild(i));
}
}


/// <summary>
/// 断开信号连接
/// </summary>
private static void DisconnectSignal(Callable callable, Signal signal)
{
var target = callable.Target;
if (target == null && callable.Method == null)
{
return;
}

var name = signal.Name;
var node = target as Node;
if (node != null && !node.IsInsideTree()) return;

// 确认信号所有者
var owner = signal.Owner;
var node2 = owner as Node;
if (node != null && node.HasSignal(name) && node.IsConnected(name, callable))
{
node.Disconnect(name, callable);
}
else if (node2 != null && node2.HasSignal(name) && node2.IsConnected(name, callable))
{
node2.Disconnect(name, callable);
}
}
}

/// <summary>
/// Node 节点管理器
/// </summary>
public class NodePool
{
/// <summary>
/// 节点池字典
/// </summary>
private static readonly Dictionary<Type, INodePool> Pools = new();

/// <summary>
/// 初始化对象池
/// </summary>
public static NodePool<T> Init<T>(int capacity) where T : Node, IPoolable
{
// 确认工厂方法已设置
if (NodePool<T>.Factory is null)
throw new InvalidOperationException($"NodePool<{typeof(T).Name}>.Factory must be set before Init");

var tyHandle = typeof(T);
if (Pools.TryGetValue(tyHandle, out _))
{
throw new InvalidOperationException(
$"Tried to init NodePool for type {tyHandle} but it's already initialized!");
}

var nodePool = new NodePool<T>(capacity);
Pools[tyHandle] = nodePool;
return nodePool;
}

/// <summary>
/// 从对象池中获取一个对象
/// </summary>
public static IPoolable Get(Type type)
{
return !Pools.TryGetValue(type, out var value)
? throw new InvalidOperationException($"Tried to get pool for type {type} before it was initialized!")
: value.Get();
}

/// <summary>
/// 归还对象到对象池
/// </summary>
public static void Free(IPoolable poolable)
{
var type = poolable.GetType();
if (!Pools.TryGetValue(type, out var value))
{
throw new InvalidOperationException($"Tried to get pool for type {type} before it was initialized!");
}

value.Free(poolable);
}

/// <summary>
/// 从对象池中获取一个对象
/// </summary>
public static T Get<T>() where T : Node, IPoolable
{
return (T)Get(typeof(T));
}

/// <summary>
/// 归还对象到对象池
/// </summary>
public static void Free<T>(T obj) where T : Node, IPoolable
{
Free((IPoolable)obj);
}
}

这里的调用方式可能相比官方就代码比较冗长, 但是提供更多可以定制的空间, 调用代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 重构后: Init 前必须设 Factory 工厂回调
// 每次调用 Init 方法都会回调到 Factory 内部确认加载缓存的节点数据
NodePool<NCard>.Factory = () => PreloadManager.Cache.GetScene("res://scenes/cards/card.tscn").Instantiate<NCard>(...);
NodePool.Init<NCard>(30);

// 而在之前将 PreloadManager 重构成 AssetManager, 所以调用方式也可以修改成以下代码
// 现在调用这部分资源就需要按照以下流程来处理

// 1. 构建缓存底层回调工厂
NodePool<NCard>.Factory = () =>
{
// 确认节点场景是否有缓存, 不存在缓存就构建缓存, 存在则直接获取原来缓存数据
var scene = AssetManager.Cache.GetScene("res://scenes/cards/card.tscn");
return scene?.Resource is PackedScene packed
? packed.Instantiate<NCard>(PackedScene.GenEditState.Disabled)
: throw new InvalidOperationException("Factory failed : Could not instantiate scene");
};

NodePool.Init<NCard>(30); // 2. 初始化节点池
var node = NodePool.Get<NCard>(); // 3. 获取一个节点
NodePool.Free<NCard>(node); // 4. 释放一个节点

这样调用虽然比较麻烦, 但是从根本上规避了底层功能互相侵入的问题; 至此资源池已经完成, 这些代码可以参考来开发游戏的资源管理

场景容器

说完大部分上面的基础功能就是为了给后续场景容器(RootSceneContainer)来铺路

音频(AudioManager)可以后面说明, 需要让人直观看到游戏运行效果, 从而避免枯燥讲解代码

杀戮尖塔2的设计容器嵌套如下

1
2
3
4
5
6
7
8
NGame (根节点)
└── RootSceneContainer ← 顶级: 装载整个游戏流程
├── NLogoAnimation ← 启动动画
├── NMainMenu ← 游戏启动主菜单
└── NRun ← 游戏主体运行节点
└── RoomContainer ← 二级: 装载房间
└── NEventRoom
└── EventContainer ← 三级: 装载事件

这里可以忽视启动动画的节点, 先整合游戏主界面功能: NMainMenu

其实我觉得应该直接命名 NMain 就行了, 不知道为什么要命名为 NMainMenu, 搞得像游戏菜单栏命名一样

我修改之后的游戏节点关系如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
NGame (根节点, Control)
│
├── [系统服务层] ── 全局存活, 不随场景切换销毁
│ ├── NInputManager ← 输入路由 (_UnhandledKeyInput → 虚拟按键)
│ ├── NControllerManager ← 输入模式侦测 (鼠标/键盘 切换)
│ ├── NCursorManager ← 光标样式管理
│ ├── NDebugHotkeyManager ← 调试热键 (加速/隐藏UI)
│ ├── NAudioManager ← 音频 (后续单独说明)
│ ├── NModalContainer ← 全局弹窗容器 (确认框/错误弹窗)
│ ├── NTransition ← 全屏转场遮罩 (淡入淡出)
│ ├── NScreenShake ← 震屏
│ └── NHitStop ← 顿帧 (打击感)
│
├── RootSceneContainer ← 顶级容器(Control): 装载整个游戏流程
│ ├── NLogoAnimation ← 启动动画
│ ├── NMain ← 游戏主界面 (杀戮尖塔2源码当中原名 NMainMenu, 接下来要实现的功能)
│ │ └── NSubmenuStack ← 栈式子菜单中枢
│ │ ├── NSettingsScreen
│ │ ├── NCharacterSelectScreen
│ │ └── ... ← Push/Pop 动态进出
│ └── NRun ← 游戏主体运行节点
│ └── RoomContainer ← 二级容器: 装载房间
│ └── NEventRoom
│ └── EventContainer ← 三级容器: 装载事件
│
└── HoverTipsContainer ← 悬浮提示层 (渲染在一切之上)

场景容器十分的简单, 基本一眼就能看出具体的作用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
using Godot;
using P26.Core.Extensions;
using P26.Core.Pooling;

namespace P26.Core.Nodes;

/// <summary>
/// 场景容器
/// </summary>
public partial class NSceneContainer: Control
{
/// <summary>
/// 当前场景
/// </summary>
private Control? _currentScene;

/// <summary>
/// 获取当前场景
/// </summary>
public Control? CurrentScene
{
get
{
if (_currentScene is null)
{
return null;
}

if (!IsInstanceValid(_currentScene))
{
return null;
}

return _currentScene.IsQueuedForDeletion() ? null : _currentScene;
}
protected internal set => _currentScene = value;
}

/// <summary>
/// 设置当前场景
/// </summary>
public void SetCurrentScene(Control scene)
{
foreach (var node in GetChildren())
{
// 移除目前场景所有子节点
// 这里使用的之前扩展的 GodotTreeExtensions::RemoveChildSafely 功能方法
this.RemoveChildSafely(node);
ReleaseScene(node);
}
CurrentScene = scene;
if (scene.GetParent() is null)
{
// 这里使用的之前扩展的 GodotTreeExtensions::AddChildSafely 功能方法
this.AddChildSafely(scene);
}
else
{
scene.Reparent(this);
}
}

/// <summary>
/// 清除节点
/// </summary>
private static void ReleaseScene(Node node)
{
if (!IsInstanceValid(node)) return;

// 确认是否为缓存对象
// ReSharper disable once SuspiciousTypeConversion.Global
// ReSharper disable once UsePatternMatching
var poolable = node as IPoolable;
if (poolable is not null)
{
// 池化对象: 延迟到帧末归还节点池, 确保树操作全部完成
Callable.From(delegate
{
NodePool.Free(poolable);
}).CallDeferred();
}
else
{
// 非缓存对象清除
if (node.IsMainThread())
{
node.QueueFree();
}
else
{
node.CallDeferred(Node.MethodName.QueueFree);
}
}
}
}

NSceneContainer 主要责任就是切换场景同时挂载成自己的子节点, 之后就是主界面场景加载流程, 可以完善之前根节点脚本(NGame.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Godot;
using P26.Core.Assets;
using P26.Core.Extensions;
using P26.Core.Helpers;
using Serilog;
using Serilog.Events;

namespace P26.Core.Nodes;

/// <summary>
/// 游戏启动节点
/// </summary>
public partial class NGame : Node
{
/// <summary>
/// 资源加载器实例
/// </summary>
private static NGame? _instance;

/// <summary>
/// 启动实例
/// </summary>
public static NGame Instance => _instance!;


/// <summary>
/// 游戏窗口对象
/// </summary>
private static Window? _window;


/// <summary>
/// 场景过渡层实例
/// </summary>
private NTransition? _transition;

/// <summary>
/// 场景过渡层
/// </summary>
public NTransition? Transition
{
get
{
if (_transition is not null)
{
return _transition;
}

// 获取全局节点名称
_transition = GetNode<NTransition>("%GameTransitionRect");
return _transition;
}
private set => _transition = value;
}

/// <summary>
/// 根场景容器
/// </summary>
private NSceneContainer? _rootSceneContainer;


/// <summary>
/// 获取根场景容器
/// </summary>
public NSceneContainer? RootSceneContainer
{
get
{
if (_rootSceneContainer is not null)
{
return _rootSceneContainer;
}
_rootSceneContainer = GetNode<NSceneContainer>("%RootSceneContainer");
return _rootSceneContainer;
}
private set => _rootSceneContainer = value;
}


/// <summary>
/// 输入管理器
/// </summary>
[Export]
public NInputManager? InputManagerNode { get; set; }


/// <summary>
/// 节点唤醒
/// </summary>
public override void _EnterTree()
{
// 初始化实例
if (_instance is not null && _instance != this)
{
Log.Error("NGame already exists!");
QueueFree();
return;
}
_instance = this;

// 初始化日志配置
if (OS.HasFeature("debug") || OS.HasFeature("editor"))
{
// 编辑器运行不需要写入文件
Log.Logger = new LoggerConfiguration()
//.WriteTo.Console() // 测试环境需要在编辑器窗口打印, 所以不需要命令行打印
.WriteTo.Godot() // 注入我们自己扩展的 Godot 输出
.MinimumLevel.Is(LogEventLevel.Debug) // 设置最小等级为 Debug 模式
.CreateLogger();
Log.Information("Starting Godot, Mode: Debug");
}
else
{
// 正式版本运行需要写入文件日志, 一般都是写入用户目录/{游戏命名}.log
var filename = ProjectSettings.GetSetting("application/config/name").AsString();
var logFilename = ProjectSettings.GlobalizePath($"user://{filename}.log");
Log.Logger = new LoggerConfiguration()
.WriteTo.Console() // 设置写入命令行打印
// .WriteTo.Godot() // 正式环境已经不需要 Godot 编辑器输出, 所以可以直接屏蔽
.WriteTo.File(
// 设置写入本地日志文件, 这里日志落地目录要结合 Godot 的本地
// 默认会生成 {logFilename}{日期}{.log:自定义后缀} 日志文件
logFilename,
// 日志文件压缩规则, 按照每日做最大限制压缩
rollingInterval: RollingInterval.Day,
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 1024 * 1024 * 5, // 单个日志文件最大5MB
retainedFileCountLimit: 7 // 仅保留7天的日志文件
)
.MinimumLevel.Is(LogEventLevel.Information) // 设置最小等级为 Info 模式
.CreateLogger();
Log.Information("Starting Godot, Mode: Release, Log: {LogFilename}", logFilename);
}


// 获取游戏场景过渡层, 这里采用 % 全局唯一节点语法
if (Transition is null)
{
Log.Error("Transition is null!");
QueueFree();
return;
}

// 获取根场景容器
if (RootSceneContainer is null)
{
Log.Error("RootSceneContainer is null!");
QueueFree();
return;
}
}

/// <summary>
/// 节点休眠
/// </summary>
public override void _ExitTree()
{
if (_instance == this)
{
_instance = null;
_window = null;
Log.CloseAndFlush(); // 关闭并刷新日志
}
}


/// <summary>
/// 节点初始化
/// </summary>
public override void _Ready()
{
// 获取游戏窗口对象
_window = GetTree().Root;
_window.Connect(Viewport.SignalName.SizeChanged, Callable.From(OnWindowChange));

// 注入委托
if (InputManagerNode is not null)
{
// 纯键鼠模式只接键盘输入
InputManagerNode.OnInitInputMapping = Task.Run(() => { Log.Debug("ControllerManager Ready"); });
InputManagerNode.OnLoadKeyboardInputMapping = LoadKeyboardFromDisk;
InputManagerNode.OnSaveKeyboardInputMapping = SaveKeyboardToDisk;
}

// 关闭自动退出拦截, 核心
GetTree().AutoAcceptQuit = false;

// 异步启动系统
TaskHelper.RunSafely(GameStartupWrapper());
}

#region 加载读取输入系统

/// <summary>
/// 从保存配置中加载键盘输入映射
/// </summary>
private static Dictionary<string, string>? LoadKeyboardFromDisk()
{
// 从存档读取,没有就返回 null → 走默认映射
Log.Debug("Loading keyboard from disk");
return null;
}

/// <summary>
/// 保存键盘输入映射到保存配置
/// </summary>
private static void SaveKeyboardToDisk(Dictionary<string, string> settings)
{
Log.Debug("Saving keyboard settings to disk...");
}

#endregion

#region 拦截窗口事件

/// <summary>
/// 拦截窗口事件
/// </summary>
/// <param name="what"></param>
public override void _Notification(int what)
{
// 玩家点击窗口右上角关闭游戏
if (what == NotificationWMCloseRequest)
{
_ = NotificationExitConfirm();
}
}

/// <summary>
/// 拦截关闭请求
/// </summary>
private async Task NotificationExitConfirm()
{
var res = await WindowPopupHelper.Confirm(
"Are you sure you want to exit?",
"Are you sure?",
["Yes", "No"]
);

if (res == 0)
{
GetTree().Quit();
}
}

/// <summary>
/// 窗口大小改变事件监听
/// </summary>
private void OnWindowChange()
{
Log.Information("Window changed! New size: {WindowGetSize}", DisplayServer.WindowGetSize());
// EmitSignal(SignalName.WindowChange, SaveManager.Instance.SettingsSave.AspectRatioSetting == AspectRatioSetting.Auto);
}

#endregion


/// <summary>
/// 游戏异步启动检测
/// </summary>
private async Task GameStartupWrapper()
{
// todo: 游戏初始化启动, 可以在这里初始化 Steam 等平台信息

// 尝试启动游戏
try
{
await GameStartup();
}
catch
{
_ = TaskHelper.RunSafely(GameStartupError());
throw;
}
}

/// <summary>
/// 游戏异步启动错误处理
/// </summary>
private async Task GameStartupError()
{
// 异常尝试错误初始化
Log.Error("Encountered error on game startup! Attempting to show error dialog");
await TryErrorInit();

// 调用系统错误弹出
await WindowPopupHelper.Alert("Game Startup Error", "Error", ["Quit"]);
GetTree().Quit();
}


/// <summary>
/// 尝试错误初始化
/// </summary>
private async Task TryErrorInit()
{
if (!IsNodeReady())
{
await ToSignal(this, Node.SignalName.Ready);
}

// 隐藏过渡层
if (Transition is not null) Transition.Visible = false;
}


/// <summary>
/// 游戏异步正式启动
/// </summary>
private async Task GameStartup()
{
// 等待节点准备好
if (!IsNodeReady())
{
await ToSignal(this, Node.SignalName.Ready);
}

// 初始化系统所需配置
InitPools();
if (InputManagerNode is not null) InputManagerNode.Initialized = true;
Callable.From(InitializeGraphicsPreferences).CallDeferred(); // 延迟调用图形设置

// todo: 系统配置加载和初始化

// 启动游戏主界面
await LaunchGameMain();
}


/// <summary>
/// 初始化对象池
/// </summary>
private static void InitPools()
{
// todo: 初始化资源池
}

/// <summary>
/// 启动游戏主界面
/// </summary>
private async Task LaunchGameMain()
{
await AssetManager.LoadGameMainEssentials(); // 加载主界面资源


// 加载游戏界面
await LoadGameMain();
Log.Information("[Startup] Time to main menu: {GetTicksMSec:N0}ms", Time.GetTicksMsec());
}

/// <summary>
/// 加载游戏主界面
/// </summary>
private async Task LoadGameMain()
{
//NMain currentScene = NMain.Create();
//RootSceneContainer.SetCurrentScene(currentScene);
}

#region 游戏系统设置

/// <summary>
/// 初始化图形偏好设置
/// </summary>
private static void InitializeGraphicsPreferences()
{
if (!DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase))
{
ApplyDisplaySettings();
ApplySyncSetting();
}
Engine.MaxFps = 60; // 默认60帧, 一般需要加载本地系统配置
}

/// <summary>
/// 应用显示设置
/// </summary>
public static void ApplyDisplaySettings()
{
// todo: 设置全屏, 窗口化, 无边框窗口化等设置
// 设置定义窗口化分辨率
// 1680x1260: _window.ContentScaleSize = new Vector2I(1680, 1260);
// 1920x1200: _window.ContentScaleSize = new Vector2I(1920, 1200);
// 1920x1080: _window.ContentScaleSize = new Vector2I(1920, 1080);
// 2580x1080: _window.ContentScaleSize = new Vector2I(2580, 1080);
//
// 设置全屏窗口化
// DisplayServer.WindowSetMode(DisplayServer.WindowMode.Fullscreen);
//
// 设置窗口化大小
// DisplayServer.WindowSetSize(windowSize);
// DisplayServer.WindowSetPosition(vector2I5 + vector2I4); // 居中
}

/// <summary>
/// 应用同步设置
/// </summary>
public static void ApplySyncSetting()
{
// todo: 设置 vsync, 无 vsync, 手动 vsync 等设置
// 垂直同步关闭: DisplayServer.WindowSetVsyncMode(DisplayServer.VSyncMode.Disabled);
// 垂直同步开启: DisplayServer.WindowSetVsyncMode(DisplayServer.VSyncMode.Enabled);
// 手动 vsync: DisplayServer.WindowSetVsyncMode(DisplayServer.VSyncMode.Adaptive);
}

#endregion

}

这样就是基础启动入口节点的骨架, 后面就是准备围绕这个入口脚本来做初始化启动, 需要做简单的主界面入口场景

杀戮尖塔2当中的游戏主界面场景相关信息, 内部还设计其他附属节点

  • res://scenes/screens/main_menu.tscn - 场景节点

  • res://src/Core/Nodes/Screens/MainMenu/NMainMenu.cs - 脚本文件

  • res://scenes/screens/main_menu_bg.tscn - 主界面背景节点

  • res://src/Core/Nodes/Screens/MainMenu/NMainMenuBg.cs - 主界面背景脚本

在审阅 NMainMenuBg 相关代码之后发现整体项目都是依赖 SpineSprite 驱动

项目里凡是骨骼动画(角色、怪物、Boss、特效)都是 SpineSprite, 只有纯粒子、纯贴图、UI 控件才用 Godot 内置节点

SpineSprite 是 esoteric-software 出品的商业化骨骼动画编辑器, 虽然支持免费试用版, 但需要注意以下问题

  • 免费试用版无法保存和导出任何数据

  • 不能保存项目文件, 不能打包输出贴Atlas和动画数据, 不能导出图片或视频

引入 spine-godot 扩展支持就要转向付费手段买断编辑器给美术使用(这种在中小型公司当中比较实惠, 将美术和程序职责拆分)

用 Godot 原生 Skeleton2D + Bone2D 虽然免费, 但工具链远不如 Spine 成熟, 并且如果有美术同事可能并不能够接受 Godot 开发流

这部分更偏美术设计方向, 这里侧重点还是程序方向, 所以不会去深入说明