目录
前言
全局协程还是实例协程?
存档!
全局管理类?
UI框架?
Godot中的异步(多线程)加载
Godot中的ScriptableObject
游戏流程思考
结语
前言
这是一篇杂谈,主要内容是对我近期在做的事做一些简单的小总结和探讨,包括整理Godot开发工具和思考Godot开发核心。
因为太久没写东西了,于是随性地写一点吧,有啥说啥。
全局协程还是实例协程?
不得不说,在“深入”了一段时间后,发现协程这个东西对于游戏而言非常重要。因为很多东西是需要在多帧完成的而非一帧之内完成的,所以有必要优化一下这方面的体验,为此我特意强化了一下常用的协程系统:
等等,如果看不懂很正常,因为我压根没打算细说,只是为了表示个协程系统的大概。对协程感兴趣可以先看看这里:
C# 游戏引擎中的协程_c# 协程-CSDN博客https://blog.csdn.net/m0_73087695/article/details/142462298?spm=1001.2014.3001.5501
我们知道Unity里面的协程是以MonoBehaviour为单位的,也就是一个MonoBehaviour负责管理它自己的协程。因为我比较懒,就索性搞了了全局的协程“启动器”,以此来满足快速启动某个协程的需求。
以目前我对协程的理解,我只能肤浅的把它们分为两类,分别对应Godot的两种帧处理方法。
增添改查倒不用多说了,这个类会作为一个单例节点在“Autoload”的加持下加入树中。以此才能处理协程。
其实以这个思路在每个节点上都装载一个“协程管理器”倒是不难,不过我对这样做的必要性存疑,而且我以前写Unity的时候,因为每个实例一堆协程而绕晕过,于是就没有这么干了(懒)。
暂且先将一堆协程放在一起吧,当然想要属于节点自己的协程可以直接new出来。
using System;
using System.Collections;
using System.Collections.Generic;
namespace GoDogKit
{
/// <summary>
/// In order to simplify coroutine management,
/// this class provides a global singleton that can be used to launch and manage coroutines.
/// It will be autoloaded by GodogKit.
/// </summary>
public partial class GlobalCoroutineLauncher : Singleton<GlobalCoroutineLauncher>
{
private GlobalCoroutineLauncher() { }
private readonly List<Coroutine> m_ProcessCoroutines = [];
private readonly List<Coroutine> m_PhysicsProcessCoroutines = [];
private readonly Dictionary<IEnumerator, List<Coroutine>> m_Coroutine2List = [];
private readonly Queue<Action> m_DeferredRemoveQueue = [];
public override void _Process(double delta)
{
ProcessCoroutines(m_ProcessCoroutines, delta);
}
public override void _PhysicsProcess(double delta)
{
ProcessCoroutines(m_PhysicsProcessCoroutines, delta);
}
public static void AddCoroutine(Coroutine coroutine, CoroutineProcessMode mode)
{
switch (mode)
{
case CoroutineProcessMode.Idle:
Instance.m_ProcessCoroutines.Add(coroutine);
Instance.m_Coroutine2List.Add(coroutine.GetEnumerator(), Instance.m_ProcessCoroutines);
break;
case CoroutineProcessMode.Physics:
Instance.m_PhysicsProcessCoroutines.Add(coroutine);
Instance.m_Coroutine2List.Add(coroutine.GetEnumerator(), Instance.m_PhysicsProcessCoroutines);
break;
}
}
// It batter to use IEnumerator to identify the coroutine instead of Coroutine itself.
public static void RemoveCoroutine(IEnumerator enumerator)
{
if (!Instance.m_Coroutine2List.TryGetValue(enumerator, out var coroutines)) return;
int? index = null;
for (int i = coroutines.Count - 1; i >= 0; i--)
{
if (coroutines[i].GetEnumerator() == enumerator)
{
index = i;
break;
}
}
if (index is not null)
{
Instance.m_DeferredRemoveQueue.Enqueue(() => coroutines.RemoveAt(index.Value));
}
}
private static void ProcessCoroutines(List<Coroutine> coroutines, double delta)
{
foreach (var coroutine in coroutines)
{
coroutine.Process(delta);
}
// Remove action should not be called while procssing.
// So we need to defer it until the end of the frame.
ProcessDeferredRemoves();
}
private static void ProcessDeferredRemoves()
{
if (!Instance.m_DeferredRemoveQueue.TryDequeue(out var action)) return;
action();
}
/// <summary>
/// Do not use if unneccessary.
/// </summary>
public static void Clean()
{
Instance.m_ProcessCoroutines.Clear();
Instance.m_PhysicsProcessCoroutines.Clear();
Instance.m_Coroutine2List.Clear();
Instance.m_DeferredRemoveQueue.Clear();
}
/// <summary>
/// Get the current number of coroutines running globally, both in Idle and Physics process modes.
/// </summary>
/// <returns> The number of coroutines running. </returns>
public static int GetCurrentCoroutineCount()
=> Instance.m_ProcessCoroutines.Count
+ Instance.m_PhysicsProcessCoroutines.Count;
}
}
至于怎么快速启动?
那必然是用到拓展方法。值得注意的是因为以C#枚举器进化而来的“协程”本质上是IEnumerator,所以用来辨别协程的“ID”也应当是IEnumerator。就像这里的删除(停止)协程执行传递的是IEnumerator而非我们自己封装的协程类。
话说回来,拓展方法确实非常的好用,以前很少关注这个东西,觉得可有可无,后来发现有了拓展方法就可以写得很“糖”氏,很多全局类的功能可以直接由某个实例执行,就不用写很长的名字访问对应的方法。再者还可以加以抽象,针对接口制作拓展方法,实现某些框架等等。
#region Coroutine
public static void StartCoroutine(this Node node, Coroutine coroutine, CoroutineProcessMode mode = CoroutineProcessMode.Physics)
{
coroutine.Start();
GlobalCoroutineLauncher.AddCoroutine(coroutine, mode);
}
public static void StartCoroutine(this Node node, IEnumerator enumerator, CoroutineProcessMode mode = CoroutineProcessMode.Physics)
{
StartCoroutine(node, new Coroutine(enumerator), mode);
}
public static void StartCoroutine(this Node node, IEnumerable enumerable, CoroutineProcessMode mode = CoroutineProcessMode.Physics)
{
StartCoroutine(node, enumerable.GetEnumerator(), mode);
}
public static void StopCoroutine(this Node node, IEnumerator enumerator)
{
GlobalCoroutineLauncher.RemoveCoroutine(enumerator);
}
public static void StopCoroutine(this Node node, Coroutine coroutine)
{
StopCoroutine(node, coroutine.GetEnumerator());
}
public static void StopCoroutine(this Node node, IEnumerable enumerable)
{
StopCoroutine(node, enumerable.GetEnumerator());
}
#endregion
存档!
老早就应该写了,但是太懒了,总不能一直一直用别人的吧。Godot内置了很多文件操作API,但是我还是选择了用C#库的,因为普适性(万一以后又跑回Unity了,Copy过来还可以用Doge)。
好了因为代码又臭又长了,其实也不用看。简单来说,一开始我试着把所谓的“存档”抽象成一个类,只针对这个类进行读写以及序列化,后面想了想,觉得如果这样的话,每次new新的“存档”又得填一边路径和序列化方式,干脆搞个全局类“存档系统”,每次new存档时候为“存档”自动赋初值。
很好,然后我还需要很多种可用的序列化和加密方法来保证我的游戏存档是安全可靠的,我应该写在哪呢?难道写在每个单独的存档类里嘛?不对,每种序列化方法对“存档”的操作方式是不同的,所以要把“存档”也细分,不然不能支持多种序列化或加密方式。
可是这样我的全局类又怎么知道我想要new一个什么样的“存档”类呢,在很多时候,我们往往需要对不同的“存档”(这里代指文本文件),使用不同的处理方式,比如游戏数据我们需要加密,但是游戏DEBUG日志我们就不需要。那就干脆把它也抽象了吧,搞一个“子存档系统”,由不同的子存档系统负责管理不同需求的“存档”。
同时为了避免混乱,每个“存档”都保留对管理它的子系统的引用,如果一个存档没有子系统引用,说明它是“野存档”。以此来约束不同种类的“存档”只能由不同种类的“子系统”创建,其实就是“工厂模式”或者“抽象工厂模式”。而且在创建存档时,怕自己写昏头了,我不得不再对子系统抽象,将创建方法抽象到一个新的泛型抽象类,并借此对创建方法赋予再一级的约束。以防用某个类型的子系统创建了不属于它的类型的存档。
最终才拉出了下面这坨屎山。
有一个非常有意思(蠢)的点:在我想给“存档”类写拓展方法时,我发现底层的序列化得到的对象一直传不上来,当然了,这是因为引用类型作参数时还是以值的方式传递自身的引用,所以序列化生成的那个对象的引用一直“迷失”在了底层的调用中,我不想给存档对象写深拷贝,于是尝试用ref解决,结果拓展方法不能给类用ref,于是果断放弃为存档类拓展方法,代码中的那两个[Obsolete]就是这么来的。
后面妥协了,把存档读取和加载交由子系统完成(不能爽写了)。
还有就是C#原生库对Json序列化的支持感觉确实不太好,要支持AOT的话还得写个什么JsonSerializerContext,我这里为了AOT完备不得以加之到对应子系统的构造函数中。也许XML可能会好点?但是目前只写了Json一种序列化方法,因为懒。
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Godot;
namespace GoDogKit
{
#region ISaveable
/// <summary>
/// Fundemental interface for all saveable objects.
/// Contains basical information for saving and loading, such as file name, directory,
/// and the save subsystem which own this.
/// </summary>
public interface ISaveable
{
/// <summary>
/// The file name without extension on save.
/// </summary>
public string FileName { get; set; }
/// <summary>
/// The file name extension on save.
/// </summary>
public string FileNameExtension { get; set; }
/// <summary>
/// The directory where the file is saved.
/// </summary>
public DirectoryInfo Directory { get; set; }
/// <summary>
/// The save subsystem which own this.
/// </summary>
public SaveSubsystem SaveSubsystem { get; set; }
public virtual void Clone(ISaveable saveable)
{
FileName = saveable.FileName;
FileNameExtension = saveable.FileNameExtension;
Directory = saveable.Directory;
SaveSubsystem = saveable.SaveSubsystem;
}
}
public class JsonSaveable : ISaveable
{
[JsonIgnore] public string FileName { get; set; }
[JsonIgnore] public string FileNameExtension { get; set; }
[JsonIgnore] public DirectoryInfo Directory { get; set; }
[JsonIgnore] public SaveSubsystem SaveSubsystem { get; set; }
// /// <summary>
// /// The JsonSerializerContext used to serialize and deserialize this object.
// /// </summary>
// [JsonIgnore] public JsonSerializerContext SerializerContext { get; set; }
}
#endregion
#region System
public static class SaveSystem
{
public static DirectoryInfo DefaultSaveDirectory { get; set; }
public static string DefaultSaveFileName { get; set; } = "sg";
public static string DefaultSaveFileExtension { get; set; } = ".data";
public static SaveEncryption DefaultEncryption { get; set; } = SaveEncryption.Default;
static SaveSystem()
{
if (OS.HasFeature("editor"))
{
// If current save action happens in editor,
// append with "_Editor" in project folder root.
DefaultSaveDirectory = new DirectoryInfo("Save_Editor");
}
else
{
// Else, use the "Save" folder to store the save file,
// at the same path with the game executable in default.
DefaultSaveDirectory = new DirectoryInfo("Save");
}
if (!DefaultSaveDirectory.Exists)
{
DefaultSaveDirectory.Create();
}
}
public static string Encrypt(string data, SaveEncryption encryption)
{
return encryption.Encrypt(data);
}
public static string Decrypt(string data, SaveEncryption encryption)
{
return encryption.Decrypt(data);
}
public static bool Exists(ISaveable saveable)
{
return File.Exists(GetFullPath(saveable));
}
public static string GetFullPath(ISaveable saveable)
{
return Path.Combine(saveable.Directory.FullName, saveable.FileName + saveable.FileNameExtension);
}
public static void Delete(ISaveable saveable)
{
if (Exists(saveable))
{
File.Delete(GetFullPath(saveable));
}
}
/// <summary>
/// Checks if there are any files in the system's save directory.
/// It will count the number of files with the same extension as the system's
/// by default.
/// </summary>
/// <param name="system"> The save subsystem to check. </param>
/// <param name="saveNumber"> The number of files found. </param>
/// <param name="extensionCheck"> Whether to check the file extension or not. </param>
/// <returns></returns>
public static bool HasFiles(SaveSubsystem system, out int saveNumber, bool extensionCheck = true)
{
var fileInfos = system.SaveDirectory.GetFiles();
saveNumber = 0;
if (fileInfos.Length == 0)
{
return false;
}
if (extensionCheck)
{
foreach (var fileInfo in fileInfos)
{
if (fileInfo.Extension == system.SaveFileExtension)
{
saveNumber++;
}
}
if (saveNumber == 0) return false;
}
else
{
saveNumber = fileInfos.Length;
}
return true;
}
}
/// <summary>
/// Base abstract class for all save subsystems.
/// </summary>
public abstract class SaveSubsystem
{
public DirectoryInfo SaveDirectory { get; set; } = SaveSystem.DefaultSaveDirectory;
public string SaveFileName { get; set; } = SaveSystem.DefaultSaveFileName;
public string SaveFileExtension { get; set; } = SaveSystem.DefaultSaveFileExtension;
public SaveEncryption Encryption { get; set; } = SaveSystem.DefaultEncryption;
public abstract string Serialize(ISaveable saveable);
public abstract ISaveable Deserialize(string data, ISaveable saveable);
public virtual void Save(ISaveable saveable)
{
string data = Serialize(saveable);
string encryptedData = SaveSystem.Encrypt(data, Encryption);
File.WriteAllText(SaveSystem.GetFullPath(saveable), encryptedData);
}
public virtual ISaveable Load(ISaveable saveable)
{
if (!SaveSystem.Exists(saveable)) throw new FileNotFoundException("Save file not found!");
string data = File.ReadAllText(SaveSystem.GetFullPath(saveable));
string decryptedData = SaveSystem.Decrypt(data, Encryption);
var newSaveable = Deserialize(decryptedData, saveable);
newSaveable.Clone(saveable);
return newSaveable;
}
public virtual Task SaveAsync(ISaveable saveable)
{
string data = Serialize(saveable);
string encryptedData = SaveSystem.Encrypt(data, Encryption);
return File.WriteAllTextAsync(SaveSystem.GetFullPath(saveable), encryptedData);
}
public virtual Task<ISaveable> LoadAsync(ISaveable saveable)
{
if (!SaveSystem.Exists(saveable)) throw new FileNotFoundException("Save file not found!");
return File.ReadAllTextAsync(SaveSystem.GetFullPath(saveable)).ContinueWith(task =>
{
string data = task.Result;
string decryptedData = SaveSystem.Decrypt(data, Encryption);
var newSaveable = Deserialize(decryptedData, saveable);
newSaveable.Clone(saveable);
return newSaveable;
});
}
}
/// <summary>
/// Abstract class for all functional save subsystems.
/// Restricts the type of ISaveable to a specific type,
/// providing a factory method for creating ISaveables.
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class SaveSubsystem<T> : SaveSubsystem where T : ISaveable, new()
{
public virtual S Create<S>() where S : T, new()
{
var ISaveable = new S()
{
FileName = SaveFileName,
FileNameExtension = SaveFileExtension,
Directory = SaveDirectory,
SaveSubsystem = this
};
return ISaveable;
}
}
/// <summary>
///
/// A Sub save system that uses the JsonSerializer in dotnet core.
/// Notice that a JsonSerializerContext is required to be passed in the constructor,
/// for AOT completeness.
/// <para> So you need to code like this as an example: </para>
/// <sample>
///
/// <para> [JsonSerializable(typeof(SaveData))] </para>
///
/// <para> public partial class DataContext : JsonSerializerContext { } </para>
///
/// <para> public class SaveData : JsonISaveable </para>
/// <para> { </para>
/// <para> public int Health { get; set; } </para>
/// <para> } </para>
///
/// </sample>
/// </summary>
public class JsonSaveSubsystem(JsonSerializerContext serializerContext) : SaveSubsystem<JsonSaveable>
{
public readonly JsonSerializerContext SerializerContext = serializerContext;
public override string Serialize(ISaveable saveable) =>
JsonSerializer.Serialize(saveable, saveable.GetType(), SerializerContext);
public override ISaveable Deserialize(string data, ISaveable saveable) =>
JsonSerializer.Deserialize(data, saveable.GetType(), SerializerContext) as ISaveable;
}
#endregion
#region Extension Methods
/// <summary>
/// All functions used to extend the SaveSystem class. Fully optional, but recommended to use.
/// </summary>
public static class SaveSystemExtensions
{
[Obsolete("Use Subsystem.Save() instead.")]
public static void Save(this ISaveable saveable)
{
saveable.SaveSubsystem.Save(saveable);
}
/// <summary>
/// Unfortuantely, Extension Methods do not support ref classes, so we need to recevive the return value.
/// </summary>
[Obsolete("Use Subsystem.Load() instead.")]
public static T Load<T>(this T saveable) where T : class, ISaveable
{
return saveable.SaveSubsystem.Load(saveable) as T;
}
/// <summary>
/// Save a saveable into local file system depends on its own properties.
/// </summary>
public static void Save<T>(this SaveSubsystem subsystem, T saveable) where T : class, ISaveable
{
subsystem.Save(saveable);
}
/// <summary>
/// Load a saveable from local file system depends on its own properties.
/// This an alternative way to load a saveable object, remember to use a ref parameter.
/// </summary>
public static void Load<T>(this SaveSubsystem subsystem, ref T saveable) where T : class, ISaveable
{
saveable = subsystem.Load(saveable) as T;
}
public static bool Exists(this ISaveable saveable)
{
return SaveSystem.Exists(saveable);
}
public static string GetFullPath(this ISaveable saveable)
{
return SaveSystem.GetFullPath(saveable);
}
public static void Delete(this ISaveable saveable)
{
SaveSystem.Delete(saveable);
}
public static bool HasFiles(this SaveSubsystem system, out int saveNumber, bool extensionCheck = true)
{
return SaveSystem.HasFiles(system, out saveNumber, extensionCheck);
}
}
#endregion
#region Encryption
public abstract class SaveEncryption
{
public abstract string Encrypt(string data);
public abstract string Decrypt(string data);
public static NoneEncryption Default { get; } = new NoneEncryption();
}
public class NoneEncryption : SaveEncryption
{
public override string Encrypt(string data) => data;
public override string Decrypt(string data) => data;
}
/// <summary>
/// Encryption method in negation.
/// </summary>
public class NegationEncryption : SaveEncryption
{
public override string Encrypt(string data)
{
byte[] bytes = Encoding.Unicode.GetBytes(data);
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = (byte)~bytes[i];
}
return Encoding.Unicode.GetString(bytes);
}
public override string Decrypt(string data) => Encrypt(data);
}
#endregion
}
全局管理类?
在以前开发Unity的时候,总会写一些什么全局管理类。一开始接触Godot的时候,我尝试遵循Godot的开发理念,即不用框架自然地思考游戏流程,但最后还是忍不住写起了全局管理类。其实这些全局类仅仅只是为了简化开发流程罢了。
比如,一个全局的对象池,通过对文本场景文件(.tscn)的注册来自动生成对应的对象池并对它们进行拓展和管理。
可以看到很多情况下,像这样的全局类的方法还是主要以封装其被管理对象自己的方法为主。也就是意味着我们只是写得更爽了而已,把应当在开发时创建的对象池延时到了游戏运行时创建。
但是这样的方式有着更多的灵活性,比如可以随时创建(注册)和销毁(注销)新的节点,对于内存管理而言会比较友好,我们在每个“关卡”都可以灵活地创建需要用到的节点。
再加之以拓展方法,我们就可以直接针对被管理对象进行操作,比如这里的PackedScene,通过简单地为其拓展依赖于管理类的方法,就能方便地对它自身进行管理。
看似复杂,其实就是做了这样类似的事:我们创建一个对象池节点,把某个PackedScene赋值给对象池,在其他代码中取得该对象池的引用并使用它。上面三个事在一个我所谓的“全局管理类”下三合一,现在我们只需要对PackedScene本身进行引用保留,然后通过拓展方法即可实现上述过程。
这当然是有好有坏的,优点就是上述的灵活和便捷,缺点就是不能较大程度地操作被管理对象,所以我理所应当地要保留一个与原始被管理对象的接口,如代码中的GetPool方法,这样一来就能淡化缺点。所以就像我一开始说的那样,这些有的没的管理类只是为了写得爽,开发得爽,而不能让你写得好,开发得好。
也许是我误解了Godot的开发理念?也许它的意思是“不要过于重视框架”?从而让我们回到游戏开发本身,而非游戏开发框架本身?
于是乎现在我对“框架”的观念就是能用就行,够用就行。同时在每一次开发经历中对框架进行积累和迭代。
using System.Collections.Generic;
using Godot;
namespace GoDogKit
{
/// <summary>
/// A Global Manager for Object Pools, Maintains links between PackedScenes and their corresponding ObjectPools.
/// Provides methods to register, unregister, get and release objects from object pools.
/// </summary>
public partial class GlobalObjectPool : Singleton<GlobalObjectPool>
{
private readonly Dictionary<PackedScene, ObjectPool> ObjectPools = [];
/// <summary>
/// Registers a PackedScene to the GlobalObjectPool.
/// </summary>
/// <param name="scene"> The PackedScene to register. </param>
/// <param name="poolParent"> The parent node of the ObjectPool. </param>
/// <param name="poolInitialSize"> The initial size of the ObjectPool. </param>
public static void Register(PackedScene scene, Node poolParent = null, int poolInitialSize = 10)
{
if (Instance.ObjectPools.ContainsKey(scene))
{
GD.Print(scene.ResourceName + " already registered to GlobalObjectPool.");
return;
}
ObjectPool pool = new()
{
Scene = scene,
Parent = poolParent,
InitialSize = poolInitialSize
};
Instance.AddChild(pool);
Instance.ObjectPools.Add(scene, pool);
}
/// <summary>
/// Unregisters a PackedScene from the GlobalObjectPool.
/// </summary>
/// <param name="scene"> The PackedScene to unregister. </param>
public static void Unregister(PackedScene scene)
{
if (!Instance.ObjectPools.TryGetValue(scene, out ObjectPool pool))
{
GD.Print(scene.ResourceName + " not registered to GlobalObjectPool.");
return;
}
pool.Destroy();
Instance.ObjectPools.Remove(scene);
}
//Just for simplify coding. Ensure the pool has always been registered.
private static ObjectPool ForceGetPool(PackedScene scene)
{
if (!Instance.ObjectPools.TryGetValue(scene, out ObjectPool pool))
{
Register(scene);
pool = Instance.ObjectPools[scene];
}
return pool;
}
/// <summary>
/// Get a node from the corresponding ObjectPool of the given PackedScene.
/// </summary>
/// <param name="scene"> The PackedScene to get the node from. </param>
/// <returns> The node from the corresponding ObjectPool. </returns>
public static Node Get(PackedScene scene)
{
return ForceGetPool(scene).Get();
}
/// <summary>
/// Get a node from the corresponding ObjectPool of the given PackedScene as a specific type.
/// </summary>
/// <param name="scene"> The PackedScene to get the node from. </param>
/// <typeparam name="T"> The type to cast the node to. </typeparam>
/// <returns> The node from the corresponding ObjectPool. </returns>
public static T Get<T>(PackedScene scene) where T : Node
{
return Get(scene) as T;
}
/// <summary>
/// Releases a node back to the corresponding ObjectPool of the given PackedScene.
/// </summary>
/// <param name="scene"> The PackedScene to release the node to. </param>
/// <param name="node"> The node to release. </param>
public static void Release(PackedScene scene, Node node)
{
ForceGetPool(scene).Release(node);
}
/// <summary>
/// Unregisters all the PackedScenes from the GlobalObjectPool.
/// </summary>
public static void UnregisterAll()
{
foreach (var pool in Instance.ObjectPools.Values)
{
pool.Destroy();
}
Instance.ObjectPools.Clear();
}
/// <summary>
/// Get the ObjectPool of the given PackedScene.
/// If the PackedScene is not registered, it will be registered.
/// </summary>
/// <param name="scene"> The PackedScene to get the ObjectPool of. </param>
/// <returns> The ObjectPool of the given PackedScene. </returns>
public static ObjectPool GetPool(PackedScene scene)
{
return ForceGetPool(scene);
}
}
}
除了对对象池,或者说PackScene进行管理之外,我还“东施效颦”地为音频流作了个管理类,即AudioStream这一资源类型,不过对于音频而言,这一管理类只能管理非空间型音频(Non-spatial),也就是说那些与位置相关的2D或3D音频还得另外设计,不过也够用了。
说到节点位置,这里还是要提醒一下,Node是没有位置信息(xyz坐标)的,Node2D和Node3D有。考虑一下情况:选哟把一堆节点塞到一个父节点里以方便管理,但是又希望能保持父子节点之间的相对位置,那么一定不能选择Node节点,就是节点节点,因为它没有位置信息,所以它和字节点之间的相对位置是不确定的,我猜它的子节点的位置可能就直接是全局位置了。
最后我还是想说,你或许已经注意到了,我这里所谓的“管理类”都有一个共性,即是通过对某种资源绑定对应的某个节点,以此简化,灵活化该资源的使用流程。比如PackScene是一种Godot资源,全局对象池建立该资源与对象池节点的对应关系,直接管理对象池节点以此简化了该资源的使用过程。
我个人认为这是一种非常好的游戏框架思路,即简化游戏资源(资产)的使用流程,而非复杂化。虽然我同样感觉这种思路仅仅适用于小型游戏开发,但是我能不能剑走偏锋将其做到极致呢?
UI框架?
我是真心觉得Godot不需要UI框架,因为我思来想去也不知道写个框架出来能管到什么东西,因为节点信号已经能很好地实现UI设计了。为此我只是简单地为UI写了个小脚本,刻意写一些简单的方法留给信号使用,所以在Godot里面做UI基本上和连连看差不多。
比如下面这个临时赶出来进行演示的加载场景类:
这是一个用来充当“加载界面UI的节点”,主要任务是异步加载(多线程加载)指定路径的场景后,根据指定行为等待跳转(Skip)。就是我们常见的加载画面,有个进度条表示进度,有时可能会有“按下任意键继续”,就这么个东西。
先不管别的有的没的,直接看到自定义的ProgressChanged信号,注意到该信号有一个double类型的参数,借此我们就可以在制作加载画面UI时直接以信号连接的方式传递加载进度。
比如以该信号连接ProgressBar节点(这是个Godot内置的节点)的set_value方法,并调整合适的进度步数和值,就可以很轻松的实现一个简易的加载画面。
在加之以输入检测功能,比如代码中,我用一个InputEvent类型的Array来表示可以Skip的输入类型,这样就可以在Inspector轻松赋值,同时只要进行相应的类型检查就可以得到那种检测某种类型的输入才会跳转画面的效果。
这样看来,只要提供一些范式的功能,方法。便可以通过信号快速地构建高效的UI,甚至整个游戏,这确实是Godot的一大优势,相对于Unity来说。
using Godot;
using Godot.Collections;
namespace GoDogKit
{
public partial class CutScene : Control
{
[Export] public string Path { get; set; }
[Export] public bool AutoSkip { get; set; }
[Export] public bool InputSkip { get; set; }
[Export] public Array<InputEvent> SkipInputs { get; set; }
[Signal] public delegate void LoadedEventHandler();
[Signal] public delegate void ProgressChangedEventHandler(double progress);
private LoadTask<PackedScene> m_LoadTask;
public override void _Ready()
{
m_LoadTask = RuntimeLoader.Load<PackedScene>(Path);
if (AutoSkip)
{
Loaded += Skip;
}
}
public override void _Process(double delta)
{
// GD.Print("progress: " + m_LoadTask.Progress + " status: " + m_LoadTask.Status);
EmitSignal(SignalName.ProgressChanged, m_LoadTask.Progress);
if (m_LoadTask.Status == ResourceLoader.ThreadLoadStatus.Loaded)
EmitSignal(SignalName.Loaded);
}
public override void _Input(InputEvent @event)
{
if (InputSkip && m_LoadTask.Status == ResourceLoader.ThreadLoadStatus.Loaded)
{
foreach (InputEvent skipEvent in SkipInputs)
{
if (@event.GetType() == skipEvent.GetType()) Skip();
}
}
}
public void Skip()
{
GetTree().ChangeSceneToPacked(m_LoadTask.Result);
}
}
}
Godot中的异步(多线程)加载
以防你对上述代码中的RuntimeLoader感兴趣,这个静态类是我封装起来专门用于异步加载资源的。在Unity中异步加载的操作比较丰富,而且更加完善,但到了Godot中确实是不如Unity这般丰富。
最简单的获取异步任务的需求在Godot中都会以比较繁琐的形式出现,索性就把他们全部封装起来,思路还是相当简单的,只要弄明白那三个内置的多线程加载函数都有什么用就很容易理解了(请自行查阅手册)。
值得一提的是,那个GetStatus虽然没有用C#中的ref之类的关键字,但是还是利用底层C++的优势把值传回了实参。
还有就是最后的Load<T>泛型方法必须new的是泛型的LoadTask,而非普通的。否侧会报一个空引用的错误,我没有深究原因,不过大概跟强制转换有关。
如此一来就可以畅快地在Godot异步加载资源了。
using Godot;
using Godot.Collections;
namespace GoDogKit
{
public class LoadTask(string targetPath)
{
public string TargetPath { get; } = targetPath;
/// <summary>
/// Represents the progress of the load operation, ranges from 0 to 1.
/// </summary>
public double Progress
{
get
{
Update();
return (double)m_Progress[0];
}
}
protected Array m_Progress = [];
public ResourceLoader.ThreadLoadStatus Status
{
get
{
Update();
return m_Status;
}
}
private ResourceLoader.ThreadLoadStatus m_Status;
public Resource Result
{
get
{
return ResourceLoader.LoadThreadedGet(TargetPath);
}
}
public LoadTask Load(string typeHint = "", bool useSubThreads = false, ResourceLoader.CacheMode cacheMode = ResourceLoader.CacheMode.Reuse)
{
ResourceLoader.LoadThreadedRequest(TargetPath, typeHint, useSubThreads, cacheMode);
return this;
}
protected void Update()
{
m_Status = ResourceLoader.LoadThreadedGetStatus(TargetPath, m_Progress);
}
}
public class LoadTask<T>(string targetPath) : LoadTask(targetPath) where T : Resource
{
public new T Result
{
get
{
return ResourceLoader.LoadThreadedGet(TargetPath) as T;
}
}
}
/// <summary>
/// Provides some helper methods for loading resources in runtime.
/// Most of them serve as async wrappers of the ResourceLoader class.
/// </summary>
public static class RuntimeLoader
{
/// <summary>
/// Loads a resource from the given path asynchronously and returns a LoadTask object
/// that can be used to track the progress and result of the load operation.
/// </summary>
public static LoadTask Load(string path, string typeHint = "", bool useSubThreads = false, ResourceLoader.CacheMode cacheMode = ResourceLoader.CacheMode.Reuse)
{
return new LoadTask(path).Load(typeHint, useSubThreads, cacheMode);
}
/// <summary>
/// Loads a resource from the given path asynchronously and returns a LoadTask object
/// that can be used to track the progress and result of the load operation.
/// </summary>
public static LoadTask<T> Load<T>(string path, string typeHint = "", bool useSubThreads = false, ResourceLoader.CacheMode cacheMode = ResourceLoader.CacheMode.Reuse) where T : Resource
{
return new LoadTask<T>(path).Load(typeHint, useSubThreads, cacheMode) as LoadTask<T>;
}
}
}
Godot中的ScriptableObject
我忘记我在之前的文章中有没有记录过了,反正现在先记录一下吧。
作为一个Unity逃兵,写不到ScriptableObject(以下简称SO)是无法进行游戏开发的,一开始我以为Godot是没有这种东西的,在加上Godot的Inspector序列化支持得不是很好(TMD根本没有),想要在Inspector中设定自己的数据类型简直不要太绝望。
好在我发现了GlobalClass的存在,在Godot C#中作为一个属性。可以将指定的类暴露给编辑器,这样一来如果该类继承自Resource之类的可以在编辑器中保存的文件类型,就可以实现近似于SO的功能(甚至超越)。
[GlobalClass]
public partial class ItemDropInfo : Resource
{
[Export] public int ID { get; set; }
[Export] public int Amount { get; set; }
[Export] public float Probability { get; set; }
}
只要像这样,我们就可以在编辑器中创建,保存和修改该类型。
游戏流程思考
其实在复盘的相当长的时间内, 我很希望能把游戏流程抽象成可以被管理的对象,但是鉴于那难度之大,和不同游戏类型的流程差异太多,不利于框架复用。于是短时间内放弃了这一想法。
转而研究了很多这种小东西,也算是受益匪浅。
结语
其实开发了这么久,对游戏引擎的共性之间多少有些了解了,做得越久越发明白“引擎不重要”是什么意思,也越来越觉得清晰的设计思路比框架更重要。
本来还有很多话但是到此为止吧,我的经验已经不够用了,也许下一次“杂谈”能更加侃侃而谈吧。