網(wǎng)站建設(shè)制作設(shè)計(jì)珠海蘇州seo推廣
目錄
前言
全局協(xié)程還是實(shí)例協(xié)程?
存檔!
全局管理類?
UI框架??
Godot中的異步(多線程)加載
Godot中的ScriptableObject? ? ? ? ?
游戲流程思考?
結(jié)語
前言
? ? ? ? 這是一篇雜談,主要內(nèi)容是對(duì)我近期在做的事做一些簡(jiǎn)單的小總結(jié)和探討,包括整理Godot開發(fā)工具和思考Godot開發(fā)核心。
? ? ? ? 因?yàn)樘脹]寫東西了,于是隨性地寫一點(diǎn)吧,有啥說啥。
全局協(xié)程還是實(shí)例協(xié)程?
? ? ? ? 不得不說,在“深入”了一段時(shí)間后,發(fā)現(xiàn)協(xié)程這個(gè)東西對(duì)于游戲而言非常重要。因?yàn)楹芏鄸|西是需要在多幀完成的而非一幀之內(nèi)完成的,所以有必要優(yōu)化一下這方面的體驗(yàn),為此我特意強(qiáng)化了一下常用的協(xié)程系統(tǒng):
? ? ? ? 等等,如果看不懂很正常,因?yàn)槲覊焊鶝]打算細(xì)說,只是為了表示個(gè)協(xié)程系統(tǒng)的大概。對(duì)協(xié)程感興趣可以先看看這里:
C# 游戲引擎中的協(xié)程_c# 協(xié)程-CSDN博客https://blog.csdn.net/m0_73087695/article/details/142462298?spm=1001.2014.3001.5501
我們知道Unity里面的協(xié)程是以MonoBehaviour為單位的,也就是一個(gè)MonoBehaviour負(fù)責(zé)管理它自己的協(xié)程。因?yàn)槲冶容^懶,就索性搞了了全局的協(xié)程“啟動(dòng)器”,以此來滿足快速啟動(dòng)某個(gè)協(xié)程的需求。
? ? ? ? 以目前我對(duì)協(xié)程的理解,我只能膚淺的把它們分為兩類,分別對(duì)應(yīng)Godot的兩種幀處理方法。
增添改查倒不用多說了,這個(gè)類會(huì)作為一個(gè)單例節(jié)點(diǎn)在“Autoload”的加持下加入樹中。以此才能處理協(xié)程。
? ? ? ? 其實(shí)以這個(gè)思路在每個(gè)節(jié)點(diǎn)上都裝載一個(gè)“協(xié)程管理器”倒是不難,不過我對(duì)這樣做的必要性存疑,而且我以前寫Unity的時(shí)候,因?yàn)槊總€(gè)實(shí)例一堆協(xié)程而繞暈過,于是就沒有這么干了(懶)。
? ? ? ? 暫且先將一堆協(xié)程放在一起吧,當(dāng)然想要屬于節(jié)點(diǎn)自己的協(xié)程可以直接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;}
}
? ? ? ? 至于怎么快速啟動(dòng)?
? ? ? ? 那必然是用到拓展方法。值得注意的是因?yàn)橐訡#枚舉器進(jìn)化而來的“協(xié)程”本質(zhì)上是IEnumerator,所以用來辨別協(xié)程的“ID”也應(yīng)當(dāng)是IEnumerator。就像這里的刪除(停止)協(xié)程執(zhí)行傳遞的是IEnumerator而非我們自己封裝的協(xié)程類。
? ? ? ? 話說回來,拓展方法確實(shí)非常的好用,以前很少關(guān)注這個(gè)東西,覺得可有可無,后來發(fā)現(xiàn)有了拓展方法就可以寫得很“糖”氏,很多全局類的功能可以直接由某個(gè)實(shí)例執(zhí)行,就不用寫很長的名字訪問對(duì)應(yīng)的方法。再者還可以加以抽象,針對(duì)接口制作拓展方法,實(shí)現(xiàn)某些框架等等。
#region Coroutinepublic 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
存檔!
? ? ? ? 老早就應(yīng)該寫了,但是太懶了,總不能一直一直用別人的吧。Godot內(nèi)置了很多文件操作API,但是我還是選擇了用C#庫的,因?yàn)槠者m性(萬一以后又跑回Unity了,Copy過來還可以用Doge)。
? ? ? ? 好了因?yàn)榇a又臭又長了,其實(shí)也不用看。簡(jiǎn)單來說,一開始我試著把所謂的“存檔”抽象成一個(gè)類,只針對(duì)這個(gè)類進(jìn)行讀寫以及序列化,后面想了想,覺得如果這樣的話,每次new新的“存檔”又得填一邊路徑和序列化方式,干脆搞個(gè)全局類“存檔系統(tǒng)”,每次new存檔時(shí)候?yàn)椤按鏅n”自動(dòng)賦初值。
? ? ? ? 很好,然后我還需要很多種可用的序列化和加密方法來保證我的游戲存檔是安全可靠的,我應(yīng)該寫在哪呢?難道寫在每個(gè)單獨(dú)的存檔類里嘛?不對(duì),每種序列化方法對(duì)“存檔”的操作方式是不同的,所以要把“存檔”也細(xì)分,不然不能支持多種序列化或加密方式。
? ? ? ? 可是這樣我的全局類又怎么知道我想要new一個(gè)什么樣的“存檔”類呢,在很多時(shí)候,我們往往需要對(duì)不同的“存檔”(這里代指文本文件),使用不同的處理方式,比如游戲數(shù)據(jù)我們需要加密,但是游戲DEBUG日志我們就不需要。那就干脆把它也抽象了吧,搞一個(gè)“子存檔系統(tǒng)”,由不同的子存檔系統(tǒng)負(fù)責(zé)管理不同需求的“存檔”。
? ? ? ? 同時(shí)為了避免混亂,每個(gè)“存檔”都保留對(duì)管理它的子系統(tǒng)的引用,如果一個(gè)存檔沒有子系統(tǒng)引用,說明它是“野存檔”。以此來約束不同種類的“存檔”只能由不同種類的“子系統(tǒng)”創(chuàng)建,其實(shí)就是“工廠模式”或者“抽象工廠模式”。而且在創(chuàng)建存檔時(shí),怕自己寫昏頭了,我不得不再對(duì)子系統(tǒng)抽象,將創(chuàng)建方法抽象到一個(gè)新的泛型抽象類,并借此對(duì)創(chuàng)建方法賦予再一級(jí)的約束。以防用某個(gè)類型的子系統(tǒng)創(chuàng)建了不屬于它的類型的存檔。
? ? ? ? 最終才拉出了下面這坨屎山。
? ? ? ? 有一個(gè)非常有意思(蠢)的點(diǎn):在我想給“存檔”類寫拓展方法時(shí),我發(fā)現(xiàn)底層的序列化得到的對(duì)象一直傳不上來,當(dāng)然了,這是因?yàn)橐妙愋妥鲄?shù)時(shí)還是以值的方式傳遞自身的引用,所以序列化生成的那個(gè)對(duì)象的引用一直“迷失”在了底層的調(diào)用中,我不想給存檔對(duì)象寫深拷貝,于是嘗試用ref解決,結(jié)果拓展方法不能給類用ref,于是果斷放棄為存檔類拓展方法,代碼中的那兩個(gè)[Obsolete]就是這么來的。
? ? ? ? 后面妥協(xié)了,把存檔讀取和加載交由子系統(tǒng)完成(不能爽寫了)。
? ? ? ? 還有就是C#原生庫對(duì)Json序列化的支持感覺確實(shí)不太好,要支持AOT的話還得寫個(gè)什么JsonSerializerContext,我這里為了AOT完備不得以加之到對(duì)應(yīng)子系統(tǒng)的構(gòu)造函數(shù)中。也許XML可能會(huì)好點(diǎn)?但是目前只寫了Json一種序列化方法,因?yàn)閼小?/p>
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 Systempublic 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 Encryptionpublic 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
}
全局管理類?
? ? ? ? 在以前開發(fā)Unity的時(shí)候,總會(huì)寫一些什么全局管理類。一開始接觸Godot的時(shí)候,我嘗試遵循Godot的開發(fā)理念,即不用框架自然地思考游戲流程,但最后還是忍不住寫起了全局管理類。其實(shí)這些全局類僅僅只是為了簡(jiǎn)化開發(fā)流程罷了。
? ? ? ? 比如,一個(gè)全局的對(duì)象池,通過對(duì)文本場(chǎng)景文件(.tscn)的注冊(cè)來自動(dòng)生成對(duì)應(yīng)的對(duì)象池并對(duì)它們進(jìn)行拓展和管理。
? ? ? ? 可以看到很多情況下,像這樣的全局類的方法還是主要以封裝其被管理對(duì)象自己的方法為主。也就是意味著我們只是寫得更爽了而已,把應(yīng)當(dāng)在開發(fā)時(shí)創(chuàng)建的對(duì)象池延時(shí)到了游戲運(yùn)行時(shí)創(chuàng)建。
? ? ? ? 但是這樣的方式有著更多的靈活性,比如可以隨時(shí)創(chuàng)建(注冊(cè))和銷毀(注銷)新的節(jié)點(diǎn),對(duì)于內(nèi)存管理而言會(huì)比較友好,我們?cè)诿總€(gè)“關(guān)卡”都可以靈活地創(chuàng)建需要用到的節(jié)點(diǎn)。
? ? ? ? 再加之以拓展方法,我們就可以直接針對(duì)被管理對(duì)象進(jìn)行操作,比如這里的PackedScene,通過簡(jiǎn)單地為其拓展依賴于管理類的方法,就能方便地對(duì)它自身進(jìn)行管理。
? ? ? ? 看似復(fù)雜,其實(shí)就是做了這樣類似的事:我們創(chuàng)建一個(gè)對(duì)象池節(jié)點(diǎn),把某個(gè)PackedScene賦值給對(duì)象池,在其他代碼中取得該對(duì)象池的引用并使用它。上面三個(gè)事在一個(gè)我所謂的“全局管理類”下三合一,現(xiàn)在我們只需要對(duì)PackedScene本身進(jìn)行引用保留,然后通過拓展方法即可實(shí)現(xiàn)上述過程。
? ? ? ? 這當(dāng)然是有好有壞的,優(yōu)點(diǎn)就是上述的靈活和便捷,缺點(diǎn)就是不能較大程度地操作被管理對(duì)象,所以我理所應(yīng)當(dāng)?shù)匾A粢粋€(gè)與原始被管理對(duì)象的接口,如代碼中的GetPool方法,這樣一來就能淡化缺點(diǎn)。所以就像我一開始說的那樣,這些有的沒的管理類只是為了寫得爽,開發(fā)得爽,而不能讓你寫得好,開發(fā)得好。
? ? ? ? 也許是我誤解了Godot的開發(fā)理念?也許它的意思是“不要過于重視框架”?從而讓我們回到游戲開發(fā)本身,而非游戲開發(fā)框架本身?
? ? ? ? 于是乎現(xiàn)在我對(duì)“框架”的觀念就是能用就行,夠用就行。同時(shí)在每一次開發(fā)經(jīng)歷中對(duì)框架進(jìn)行積累和迭代。
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);}}
}
? ? ? ? ?除了對(duì)對(duì)象池,或者說PackScene進(jìn)行管理之外,我還“東施效顰”地為音頻流作了個(gè)管理類,即AudioStream這一資源類型,不過對(duì)于音頻而言,這一管理類只能管理非空間型音頻(Non-spatial),也就是說那些與位置相關(guān)的2D或3D音頻還得另外設(shè)計(jì),不過也夠用了。
? ? ? ? 說到節(jié)點(diǎn)位置,這里還是要提醒一下,Node是沒有位置信息(xyz坐標(biāo))的,Node2D和Node3D有??紤]一下情況:選喲把一堆節(jié)點(diǎn)塞到一個(gè)父節(jié)點(diǎn)里以方便管理,但是又希望能保持父子節(jié)點(diǎn)之間的相對(duì)位置,那么一定不能選擇Node節(jié)點(diǎn),就是節(jié)點(diǎn)節(jié)點(diǎn),因?yàn)樗鼪]有位置信息,所以它和字節(jié)點(diǎn)之間的相對(duì)位置是不確定的,我猜它的子節(jié)點(diǎn)的位置可能就直接是全局位置了。
? ? ? ? 最后我還是想說,你或許已經(jīng)注意到了,我這里所謂的“管理類”都有一個(gè)共性,即是通過對(duì)某種資源綁定對(duì)應(yīng)的某個(gè)節(jié)點(diǎn),以此簡(jiǎn)化,靈活化該資源的使用流程。比如PackScene是一種Godot資源,全局對(duì)象池建立該資源與對(duì)象池節(jié)點(diǎn)的對(duì)應(yīng)關(guān)系,直接管理對(duì)象池節(jié)點(diǎn)以此簡(jiǎn)化了該資源的使用過程。
? ? ? ? 我個(gè)人認(rèn)為這是一種非常好的游戲框架思路,即簡(jiǎn)化游戲資源(資產(chǎn))的使用流程,而非復(fù)雜化。雖然我同樣感覺這種思路僅僅適用于小型游戲開發(fā),但是我能不能劍走偏鋒將其做到極致呢?
UI框架??
? ? ? ? 我是真心覺得Godot不需要UI框架,因?yàn)槲宜紒硐肴ヒ膊恢缹憘€(gè)框架出來能管到什么東西,因?yàn)楣?jié)點(diǎn)信號(hào)已經(jīng)能很好地實(shí)現(xiàn)UI設(shè)計(jì)了。為此我只是簡(jiǎn)單地為UI寫了個(gè)小腳本,刻意寫一些簡(jiǎn)單的方法留給信號(hào)使用,所以在Godot里面做UI基本上和連連看差不多。
? ? ? ? 比如下面這個(gè)臨時(shí)趕出來進(jìn)行演示的加載場(chǎng)景類:
? ? ? ? 這是一個(gè)用來充當(dāng)“加載界面UI的節(jié)點(diǎn)”,主要任務(wù)是異步加載(多線程加載)指定路徑的場(chǎng)景后,根據(jù)指定行為等待跳轉(zhuǎn)(Skip)。就是我們常見的加載畫面,有個(gè)進(jìn)度條表示進(jìn)度,有時(shí)可能會(huì)有“按下任意鍵繼續(xù)”,就這么個(gè)東西。
? ? ? ? 先不管別的有的沒的,直接看到自定義的ProgressChanged信號(hào),注意到該信號(hào)有一個(gè)double類型的參數(shù),借此我們就可以在制作加載畫面UI時(shí)直接以信號(hào)連接的方式傳遞加載進(jìn)度。
比如以該信號(hào)連接ProgressBar節(jié)點(diǎn)(這是個(gè)Godot內(nèi)置的節(jié)點(diǎn))的set_value方法,并調(diào)整合適的進(jìn)度步數(shù)和值,就可以很輕松的實(shí)現(xiàn)一個(gè)簡(jiǎn)易的加載畫面。
? ? ? ? 在加之以輸入檢測(cè)功能,比如代碼中,我用一個(gè)InputEvent類型的Array來表示可以Skip的輸入類型,這樣就可以在Inspector輕松賦值,同時(shí)只要進(jìn)行相應(yīng)的類型檢查就可以得到那種檢測(cè)某種類型的輸入才會(huì)跳轉(zhuǎn)畫面的效果。
? ? ? ? 這樣看來,只要提供一些范式的功能,方法。便可以通過信號(hào)快速地構(gòu)建高效的UI,甚至整個(gè)游戲,這確實(shí)是Godot的一大優(yōu)勢(shì),相對(duì)于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中的異步(多線程)加載
? ? ? ? 以防你對(duì)上述代碼中的RuntimeLoader感興趣,這個(gè)靜態(tài)類是我封裝起來專門用于異步加載資源的。在Unity中異步加載的操作比較豐富,而且更加完善,但到了Godot中確實(shí)是不如Unity這般豐富。
? ? ? ? 最簡(jiǎn)單的獲取異步任務(wù)的需求在Godot中都會(huì)以比較繁瑣的形式出現(xiàn),索性就把他們?nèi)糠庋b起來,思路還是相當(dāng)簡(jiǎn)單的,只要弄明白那三個(gè)內(nèi)置的多線程加載函數(shù)都有什么用就很容易理解了(請(qǐng)自行查閱手冊(cè))。
? ? ? ? 值得一提的是,那個(gè)GetStatus雖然沒有用C#中的ref之類的關(guān)鍵字,但是還是利用底層C++的優(yōu)勢(shì)把值傳回了實(shí)參。
? ? ? ? 還有就是最后的Load<T>泛型方法必須new的是泛型的LoadTask,而非普通的。否側(cè)會(huì)報(bào)一個(gè)空引用的錯(cuò)誤,我沒有深究原因,不過大概跟強(qiáng)制轉(zhuǎn)換有關(guān)。
? ? ? ? 如此一來就可以暢快地在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? ? ? ? ?
? ? ? ? 我忘記我在之前的文章中有沒有記錄過了,反正現(xiàn)在先記錄一下吧。
? ? ? ? 作為一個(gè)Unity逃兵,寫不到ScriptableObject(以下簡(jiǎn)稱SO)是無法進(jìn)行游戲開發(fā)的,一開始我以為Godot是沒有這種東西的,在加上Godot的Inspector序列化支持得不是很好(TMD根本沒有),想要在Inspector中設(shè)定自己的數(shù)據(jù)類型簡(jiǎn)直不要太絕望。
? ? ? ? 好在我發(fā)現(xiàn)了GlobalClass的存在,在Godot C#中作為一個(gè)屬性??梢詫⒅付ǖ念惐┞督o編輯器,這樣一來如果該類繼承自Resource之類的可以在編輯器中保存的文件類型,就可以實(shí)現(xiàn)近似于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; }}
? ? ? ? 只要像這樣,我們就可以在編輯器中創(chuàng)建,保存和修改該類型。
游戲流程思考?
? ? ? ? 其實(shí)在復(fù)盤的相當(dāng)長的時(shí)間內(nèi),?我很希望能把游戲流程抽象成可以被管理的對(duì)象,但是鑒于那難度之大,和不同游戲類型的流程差異太多,不利于框架復(fù)用。于是短時(shí)間內(nèi)放棄了這一想法。
? ? ? ? 轉(zhuǎn)而研究了很多這種小東西,也算是受益匪淺。
結(jié)語
? ? ? ? 其實(shí)開發(fā)了這么久,對(duì)游戲引擎的共性之間多少有些了解了,做得越久越發(fā)明白“引擎不重要”是什么意思,也越來越覺得清晰的設(shè)計(jì)思路比框架更重要。
? ? ? ? 本來還有很多話但是到此為止吧,我的經(jīng)驗(yàn)已經(jīng)不夠用了,也許下一次“雜談”能更加侃侃而談吧。