Godot 游戏客户端 Godot C# 技巧 MeteorCat 2026-08-05 2026-08-25 环境配置
目前我习惯性的开发环境和版本按照以下处理
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 ;public static class GodotTreeExtensions { public static bool IsReleased (this Node ignore ) { 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 ;public class GodotLogEventSink : ILogEventSink { private readonly ITextFormatter _formatter; private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss}] {Message:lj}{NewLine}{Exception}" ; public GodotLogEventSink (ITextFormatter? formatter = null ) { _formatter = formatter ?? new MessageTemplateTextFormatter(DefaultOutputTemplate); } public void Emit (LogEvent logEvent ) { ArgumentNullException.ThrowIfNull(logEvent); using var stringWriter = new StringWriter(); _formatter.Format(logEvent, stringWriter); var content = stringWriter.ToString().TrimEnd(); var (godotTag, logContent) = GetGodotLogContent(logEvent.Level, content); GD.PrintRich($"{godotTag} {logContent} " ); } 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 ;public static class GodotSinkExtensions { public static LoggerConfiguration Godot ( this LoggerSinkConfiguration configuration, ITextFormatter? formatter = null , LogEventLevel level = LevelAlias.Minimum ) { 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;public partial class NGame : Node { public override void _EnterTree() { if (OS.HasFeature("debug" ) || OS.HasFeature("editor" )) { Log.Logger = new LoggerConfiguration() .WriteTo.Godot() .MinimumLevel.Is(LogEventLevel.Debug) .CreateLogger(); Log.Information("Starting Godot, Mode: Debug" ); } else { var filename = ProjectSettings.GetSetting("application/config/name" ).AsString(); var logFilename = ProjectSettings.GlobalizePath($"user://{filename} .log" ); Log.Logger = new LoggerConfiguration() .WriteTo.Console() .WriteTo.File( logFilename, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true , fileSizeLimitBytes: 1024 * 1024 * 5 , retainedFileCountLimit: 7 ) .MinimumLevel.Is(LogEventLevel.Information) .CreateLogger(); Log.Information("Starting Godot, Mode: Release, Log: {LogFilename}" , logFilename); } } 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 ;public static class GodotTreeExtensions { private static int ? _mainThreadId; public static bool IsMainThread (this Node ignore ) { if (_mainThreadId.HasValue) return _mainThreadId == System.Environment.CurrentManagedThreadId; _mainThreadId = System.Environment.CurrentManagedThreadId; return true ; } 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); } 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 ;public static class TaskHelper { public static Task RunSafely (Task task ) { return LogTaskExceptions(task); } 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 ; } } 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 ;public partial class NTransition : ColorRect { public bool InTransition { get ; private set ; } private Tween? _tween; public override void _Ready() { } 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); _tween.TweenProperty(this , "color:a" , 1.0f , duration); await ToSignal(_tween, Tween.SignalName.Finished); } 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); _tween.TweenProperty(this , "color:a" , 0.0f , duration); await ToSignal(_tween, Tween.SignalName.Finished); Visible = false ; MouseFilter = MouseFilterEnum.Ignore; InTransition = false ; } public async Task Transition (Task sceneTask, float fadeOutDuration = 0.8f , float fadeInDuration = 0.8f ) { await FadeOut(fadeOutDuration); await sceneTask; await FadeIn(fadeInDuration); } }
然后需要手动创建 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 public partial class NGame : Node { public NTransition? Transition { get ; private set ; } [Export ] public Button? ExitButton { get ; set ; } 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()); } private async Task OnExitButtonPressed () { if (Transition is not null ) { await Transition.FadeOut(0.85f ); } GetTree().Quit(); } private async Task GameStartupWrapper () { try { await GameStartup(); } catch { _ = TaskHelper.RunSafely(GameStartupError()); throw ; } } private async Task GameStartupError () { Log.Error("Encountered error on game startup! Attempting to show error dialog" ); GetTree().Quit(); } private async Task GameStartup () { if (!IsNodeReady()) { await ToSignal(this , Node.SignalName.Ready); } await Transition.FadeIn(0.85f ); } }
最终场景过度效果如下
纯色图片过渡足够简单且性能开销极低, 但是唯一的缺点没办法做复杂的过渡动画, 所以下一步就是复杂着色器过渡效果
着色器过渡
这里就需要 Godot 着色器的知识点, 需要专门生成纹理着色器节点 ShaderMaterial, 这里处理结构如下
1 2 3 GameTransitionRect (ColorRect), 其中挂载两个着色器混合使用 ├── GradientTransition (TextureRect) → 房间切换时的渐变遮罩动画 └── SimpleTransition (ColorRect) → 简单的 alpha 淡入淡出
杀戮尖塔2是这样处理这部分代码初始化
1 2 3 4 5 6 7 8 public override void _Ready(){ _gradientTransition = GetNode<Control>("GradientTransition" ); _simpleTransition = GetNode<Control>("SimpleTransition" ); _initialGradientYPosition = _gradientTransition.Position.Y; _targetGradientYPosition = 0f ; }
虽然这样也能运行, 但是这里要说下我的观点: 应该暴露给编辑器节点选择绑定节点, 而不是直接在代码硬编码
类似样例应该如下编写和开发
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 public partial class NTransition : ColorRect { [Export ] public TextureRect? GradientTransition { get ; set ; } [Export ] public ColorRect? SimpleTransition { get ; set ; } private float _initialGradientYPosition; private float _targetGradientYPosition; public override void _Ready() { _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 ;public partial class NTransition : ColorRect { private static readonly StringName Threshold = new ("threshold" ); public bool InTransition { get ; private set ; } private Tween? _tween; [Export ] public TextureRect? GradientTransition { get ; set ; } [Export ] public ColorRect? SimpleTransition { get ; set ; } [Export ] public Material? TransitionMaterial { get ; set ; } private float _initialGradientYPosition; private float _targetGradientYPosition; public override void _Ready() { _initialGradientYPosition = GradientTransition?.Position.Y ?? 0f ; _targetGradientYPosition = 0f ; if (GradientTransition is not null ) GradientTransition.MouseFilter = MouseFilterEnum.Ignore; if (SimpleTransition is not null ) SimpleTransition.MouseFilter = MouseFilterEnum.Ignore; } 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; InTransition = true ; modulate.A = 0f ; simpleTransition.Modulate = modulate; _tween?.Kill(); _tween = CreateTween().SetParallel(); _tween.TweenProperty(SimpleTransition, "modulate:a" , 1f , duration) .SetEase(Tween.EaseType.In) .SetTrans(Tween.TransitionType.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 }) { _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 ); } 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.A = 0f ; simpleTransition.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 ; } shaderMaterial.SetShaderParameter(Threshold, 1 ); base .MouseFilter = MouseFilterEnum.Stop; var t = 0.0 ; while (t < duration) { if (cancelToken.HasValue && cancelToken.GetValueOrDefault().IsCancellationRequested) { _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 节点之外, 需要编写着色器文件
一般游戏要做出很好看的特效, 那么就需要学习怎么处理对应的游戏着色器
这里的 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)’ 命名即可
最后对应创建的效果如下
杀戮尖塔2 的 NTransition 场景过渡写得真是太垃圾了, 他的整体过渡逻辑是有很大问题
不建议看原版代码效果, 可以参考我这部分代码直接用, NTransition.cs 这个文件写得都这么多问题
而且阅读代码之后, 这里面仅仅是做简单纯色过渡效果; 直接纯色过渡不好吗? 整体代码给我整个人都看无语了, 总之最后效果还是一样的
自定义弹出消息窗口
大部分情况很少用到系统弹窗, 但是部分关键环境需要调用到系统弹窗功能, 这里罗列常见的需要系统弹窗情况
异常报错的时候需要弹出系统窗口提示启动失败
点击窗口关闭的时候拦截等待确认时候关闭
网络游戏交互的时候网络异常中断抛出错误
这里用的是杀戮尖塔2当中的 NGenericPopup.cs 讲解, 这个文件其实不怎么值得去看, 整体都是过度包装和功能耦合到极致
对于 NGenericPopup 功能, 大部分情况适用于系统异常之类的窗口, 基本涉及到以下功能使用
这部分不应该和游戏内部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 ;public static class WindowPopupHelper { public static async Task Alert (string message, string title, string []? buttons = null ) { TaskCompletionSource<int > tcs = new (); buttons ??= ["OK" ]; DisplayServer.DialogShow(title, message, buttons, Callable.From<int >(index => tcs.TrySetResult(index))); await tcs.Task; } public static async Task<int > Confirm (string message, string title, string []? buttons = null ) { TaskCompletionSource<int > tcs = new (); buttons ??= ["OK" ]; DisplayServer.DialogShow(title, message, buttons, Callable.From<int >(index => tcs.TrySetResult(index))); return await tcs.Task; } }
千万别学杀戮尖塔2的初始化失败都要调用自己写的 UI 业务弹窗
谨记关键点: 启动初始化未完成, 千万不要动任何涉及游戏内部的业务组件!
这里顺路提供功能: 点击窗口右上角关闭按钮的拦截询问是否退出游戏
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 public partial class NGame : Node { public override void _Ready() { GetTree().AutoAcceptQuit = false ; } public override void _Notification(int what) { if (what == NotificationWMCloseRequest) { _ = NotificationExitConfirm(); } } 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(); } } }
这几行代码就可以实现点击游戏窗口右上角关闭询问功能
编辑器点击运行退出窗口提示选择否的时候, 游戏窗口边框消失是正常现象, 正式环境不会出现(编辑器启动本身模拟环境)
资源管理
这里就是最核心的 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) { await (await LoadAssetSets("MainMenuEssentials" , AssetSets.MainMenuEssentials)).WaitForCompletion(); } } }
这里面不用管它内部代码怎么编写, 只需要知道最终产生的效果就是后台加载对应资源并设置为指定资源组名称
杀戮尖塔2这部分代码只能参考加载和运行流程, 其他还不如自己重写架构, 资源管理文件和大量其他 Manger 依赖交叉
这里我这边重写以下工具类
异常类没什么好说, 直接继承 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 ;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 ;public abstract record AssetEntry { public string Path { get ; init ; } public string TypeHint { get ; init ; } = "" ; public bool UseSubThreads { get ; set ; } = false ; public ResourceLoader.CacheMode CacheMode { get ; init ; } = ResourceLoader.CacheMode.Reuse; public Resource? Resource { get ; set ; } = null ; public bool Loaded => Resource != null ; private protected AssetEntry (string path ) => Path = path; #region 资源声明类对象 public sealed record ResourceEntry (string Path ) : AssetEntry (Path ) ; public sealed record PackedSceneEntry (string Path ) : AssetEntry (Path ) ; public sealed record Texture2DEntry (string Path ) : AssetEntry (Path ) ; public sealed record MaterialEntry (string Path ) : AssetEntry (Path ) ; public sealed record CompressedTexture2DEntry (string Path ) : AssetEntry (Path ) ; public sealed record VfxEntry (string Path ) : AssetEntry (Path ) ; #endregion }
开发者必须要明确你的游戏资源是什么类型, 才能方便对某些特定资源做优化加载处理
比如常见的粒子特效类型(vfx), 这类资源加载时间长需要留到场景和贴图资源加载之后再延迟加载
杀戮尖塔2的资源是直接 Resource 对象管理一切, 这部分代码如下
1 2 3 4 5 6 7 8 9 10 11 12 public class AssetCache { 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 ;public class AssetGroup { private const int MaxConcurrentLoads = 128 ; private readonly string _name; private readonly ConcurrentDictionary<string , AssetEntry> _cache; private readonly AssetCache? _assetCache; private readonly Queue<string > _waiting = new (); private readonly Queue<string > _loading = new (); private readonly Queue<string > _finalizing = new (); private readonly TaskCompletionSource<bool > _completionSource = new (); public Task<bool > Task => _completionSource.Task; public bool IsCompleted => _completionSource.Task.IsCompleted; private readonly Stopwatch _stopwatch = new (); private int _totalLoaded; private string ? _currentVfxPath; private readonly Queue<string > _vfx = new (); private bool _vfxLoading; public AssetGroup (string name, IEnumerable<AssetEntry> entries, ConcurrentDictionary<string , AssetEntry> cache, AssetCache assetCache ) { _name = name; _cache = cache; _assetCache = assetCache; foreach (var entry in entries) { _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); } private AssetGroup () { _name = "EMPTY" ; _cache = []; _waiting = []; _loading = []; _finalizing = []; _vfx = []; _assetCache = null ; _completionSource.SetResult(result: true ); } public static AssetGroup Empty () { return new AssetGroup(); } 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; } 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); } } } 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); } } } 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 ; } } } 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); } } 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 ); } 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 ;public static class AssetManager { public static bool Enabled { get ; set ; } = true ; public static AssetCache Cache { get ; } = new (); public static event Action<string , AssetGroup>? OnAssetGroupCreated; 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); } private static AssetGroup LoadAssets (string name, IEnumerable<AssetEntry> assetPaths ) { var assetGroup = Cache.CreateGroup(name, assetPaths); OnAssetGroupCreated?.Invoke(name, assetGroup); return assetGroup; } 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 public partial class GameRoot : Node { private readonly ConcurrentQueue<AssetGroup> _groups = new (); private AssetGroup? _current; public override void _Ready() { 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(); } } } 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" ), new AssetEntry.ResourceEntry("res://shaders/outline.gdshader" ), new AssetEntry.VfxEntry("res://vfx/hit_spark.tscn" ), new AssetEntry.VfxEntry("res://vfx/death_explosion.tscn" ), })).WaitForCompletion(); } 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; var vfx = AssetManager.Cache.GetVfx("res://vfx/hit_spark.tscn" ); if (vfx is not null && vfx.Loaded) { PlayEffect((PackedScene)vfx.Resource); } 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 ;public static class InputDebugHotkey { public static readonly StringName HideCombatUi = "debug_hide_combat_ui" ; public static readonly StringName HideEventUi = "debug_hide_event_ui" ; public static readonly StringName SpeedUp = "debug_speed_up" ; public static readonly StringName SpeedDown = "debug_speed_down" ; 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 ;public static class InputMegaKey { public static readonly StringName Up = "ui_up" ; public static readonly StringName Down = "ui_down" ; public static readonly StringName Left = "ui_left" ; public static readonly StringName Right = "ui_right" ; public static readonly StringName Accept = "ui_accept" ; public static readonly StringName Cancel = "ui_cancel" ; public static readonly StringName Select = "ui_select" ; public static readonly StringName Backspace = "ui_backspace" ; 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 ;public partial class NInputManager : Node { private static NInputManager? _instance; public static NInputManager Instance => _instance ??= new NInputManager(); [Signal ] public delegate void InputReboundEventHandler () ; #region 按键相关定义 private readonly Dictionary<Key, StringName> _debugInputs = new () { { Key.Key3, InputDebugHotkey.HideCombatUi }, { Key.Minus, InputDebugHotkey.SpeedDown }, { Key.Equal, InputDebugHotkey.SpeedUp }, { Key.F3, InputDebugHotkey.HideEventUi }, { Key.U, InputDebugHotkey.UnlockCharacters } }; 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 }; 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 }; private Dictionary<StringName, Key> _keyboardInputs = new (); private Dictionary<StringName, StringName> _controllerInputs = new (); 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 外部监听事件 public Task? OnInitInputMapping { get ; set ; } public Func<Dictionary<string , string >?>? OnLoadKeyboardInputMapping { get ; set ; } public Action<Dictionary<string , string >>? OnSaveKeyboardInputMapping { get ; set ; } public Func<Dictionary<string , string >>? OnInitControllerInputMapping { get ; set ; } public Func<Dictionary<string , string >>? OnLoadControllerInputMapping { get ; set ; } public Action<Dictionary<string , string >>? OnSaveControllerInputMapping { get ; set ; } #endregion public override void _Ready() { _instance ??= this ; TaskHelper.RunSafely(Init()); } 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(); } } 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); } 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); } 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); } 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); } 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); } public override void _UnhandledKeyInput(InputEvent inputEvent) { } 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 public partial class NInputManager : Node { #region 输入设备变动 public enum MappingType { Default, Playstation, Xbox, NintendoSwitch } [Export ] public MappingType ControllerMappingType { get ;set ; } = MappingType.Default; 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); } #endregion public bool Initialized { get ; set ; } public override void _UnhandledKeyInput(InputEvent inputEvent) { ProcessKeyboardInput(inputEvent); ProcessDebugKeyInput(inputEvent); } 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); } } private void ProcessDebugKeyInput (InputEvent inputEvent ) { 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); } } 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 脚本文件任何代码了, 全部工作都是由外部自行绑定事件和信号处理
后面编写派生不同平台手柄按键等功能, 主要 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 ;public partial class NGame : Node { [Export ] public NInputManager? InputManagerNode { get ; set ; } 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()); } private Dictionary<string , string >? LoadKeyboardFromDisk() { Log.Debug("Loading keyboard from disk" ); return null ; } private void SaveKeyboardToDisk (Dictionary<string , string > settings ) { Log.Debug("Saving keyboard settings to disk..." ); } 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 ;public interface IPoolable { void OnInstantiated () ; void OnReturnedFromPool () ; 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 ;public interface INodePool { IPoolable Get () ; void Free (IPoolable poolable ) ; }
这两个文件是没有问题, 问题是在 NodePool.cs 的实现类上, 源码版本就能品鉴到这种依赖乱飞的情况
杀戮尖塔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 ;[SuppressMessage("ReSharper" , "StaticMemberInGenericType" ) ] public class NodePool <T > : INodePool where T : Node , IPoolable { private static readonly Variant NameVariant = Variant.CreateFrom("name" ); private static readonly Variant CallableVariant = Variant.CreateFrom("callable" ); private static readonly Variant SignalVariant = Variant.CreateFrom("signal" ); private readonly List<T> _freeObjects = []; private readonly HashSet<T> _usedObjects = []; public static Func<T>? Factory { get ; set ; } public NodePool (int capacity = 0 ) { for (var i = 0 ; i < capacity; i++) { _freeObjects.Add(Instantiate()); } } private static T Instantiate () { var val = Factory?.Invoke() ?? throw new InvalidOperationException($"Factory not set: {typeof (T).Name} " ); val.OnInstantiated(); return val; } IPoolable INodePool.Get() => Get(); 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; } 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>)); if (obj.IsMainThread()) { obj.QueueFree(); } else { obj.CallDeferred(Node.MethodName.QueueFree); } } } else { DisconnectIncomingAndOutgoingSignals(obj); _usedObjects.Remove(obj); _freeObjects.Add(obj); obj.OnFreedToPool(); } } 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)); } } 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); } } } public class NodePool { private static readonly Dictionary<Type, INodePool> Pools = new (); 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; } 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(); } 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); } public static T Get <T >() where T : Node, IPoolable { return (T)Get(typeof (T)); } 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 ;public partial class NSceneContainer : Control { private Control? _currentScene; 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 ; } public void SetCurrentScene (Control scene ) { foreach (var node in GetChildren ()) { this .RemoveChildSafely(node); ReleaseScene(node); } CurrentScene = scene; if (scene.GetParent() is null ) { this .AddChildSafely(scene); } else { scene.Reparent(this ); } } private static void ReleaseScene (Node node ) { if (!IsInstanceValid(node)) return ; 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 ;public partial class NGame : Node { private static NGame? _instance; public static NGame Instance => _instance!; private static Window? _window; private NTransition? _transition; public NTransition? Transition { get { if (_transition is not null ) { return _transition; } _transition = GetNode<NTransition>("%GameTransitionRect" ); return _transition; } private set => _transition = value ; } private NSceneContainer? _rootSceneContainer; public NSceneContainer? RootSceneContainer { get { if (_rootSceneContainer is not null ) { return _rootSceneContainer; } _rootSceneContainer = GetNode<NSceneContainer>("%RootSceneContainer" ); return _rootSceneContainer; } private set => _rootSceneContainer = value ; } [Export ] public NInputManager? InputManagerNode { get ; set ; } 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.Godot() .MinimumLevel.Is(LogEventLevel.Debug) .CreateLogger(); Log.Information("Starting Godot, Mode: Debug" ); } else { var filename = ProjectSettings.GetSetting("application/config/name" ).AsString(); var logFilename = ProjectSettings.GlobalizePath($"user://{filename} .log" ); Log.Logger = new LoggerConfiguration() .WriteTo.Console() .WriteTo.File( logFilename, rollingInterval: RollingInterval.Day, rollOnFileSizeLimit: true , fileSizeLimitBytes: 1024 * 1024 * 5 , retainedFileCountLimit: 7 ) .MinimumLevel.Is(LogEventLevel.Information) .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 ; } } public override void _ExitTree() { if (_instance == this ) { _instance = null ; _window = null ; Log.CloseAndFlush(); } } 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 加载读取输入系统 private static Dictionary<string , string >? LoadKeyboardFromDisk() { Log.Debug("Loading keyboard from disk" ); return null ; } private static void SaveKeyboardToDisk (Dictionary<string , string > settings ) { Log.Debug("Saving keyboard settings to disk..." ); } #endregion #region 拦截窗口事件 public override void _Notification(int what) { if (what == NotificationWMCloseRequest) { _ = NotificationExitConfirm(); } } 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(); } } private void OnWindowChange () { Log.Information("Window changed! New size: {WindowGetSize}" , DisplayServer.WindowGetSize()); } #endregion private async Task GameStartupWrapper () { try { await GameStartup(); } catch { _ = TaskHelper.RunSafely(GameStartupError()); throw ; } } 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(); } private async Task TryErrorInit () { if (!IsNodeReady()) { await ToSignal(this , Node.SignalName.Ready); } if (Transition is not null ) Transition.Visible = false ; } 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(); await LaunchGameMain(); } private static void InitPools () { } private async Task LaunchGameMain () { await AssetManager.LoadGameMainEssentials(); await LoadGameMain(); Log.Information("[Startup] Time to main menu: {GetTicksMSec:N0}ms" , Time.GetTicksMsec()); } private async Task LoadGameMain () { } #region 游戏系统设置 private static void InitializeGraphicsPreferences () { if (!DisplayServer.GetName().Equals("headless" , StringComparison.OrdinalIgnoreCase)) { ApplyDisplaySettings(); ApplySyncSetting(); } Engine.MaxFps = 60 ; } public static void ApplyDisplaySettings () { } public static void ApplySyncSetting () { } #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 出品的商业化骨骼动画编辑器, 虽然支持免费试用版, 但需要注意以下问题
引入 spine-godot 扩展支持就要转向付费手段买断编辑器给美术使用(这种在中小型公司当中比较实惠, 将美术和程序职责拆分)
用 Godot 原生 Skeleton2D + Bone2D 虽然免费, 但工具链远不如 Spine 成熟, 并且如果有美术同事可能并不能够接受 Godot 开发流
这部分更偏美术设计方向, 这里侧重点还是程序方向, 所以不会去深入说明