当前位置: 首页 > news >正文

Godot的开发框架应当是什么样子的?

目录

前言

全局协程还是实例协程?

存档!

全局管理类?

UI框架? 

Godot中的异步(多线程)加载

Godot中的ScriptableObject         

游戏流程思考 

结语


前言

        这是一篇杂谈,主要内容是对我近期在做的事做一些简单的小总结和探讨,包括整理Godot开发工具和思考Godot开发核心。

        因为太久没写东西了,于是随性地写一点吧,有啥说啥。

全局协程还是实例协程?

        不得不说,在“深入”了一段时间后,发现协程这个东西对于游戏而言非常重要。因为很多东西是需要在多帧完成的而非一帧之内完成的,所以有必要优化一下这方面的体验,为此我特意强化了一下常用的协程系统:

        等等,如果看不懂很正常,因为我压根没打算细说,只是为了表示个协程系统的大概。对协程感兴趣可以先看看这里:

C# 游戏引擎中的协程_c# 协程-CSDN博客icon-default.png?t=O83Ahttps://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 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

存档!

        老早就应该写了,但是太懒了,总不能一直一直用别人的吧。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 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
}

全局管理类?

        在以前开发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; }}

        只要像这样,我们就可以在编辑器中创建,保存和修改该类型。

游戏流程思考 

        其实在复盘的相当长的时间内, 我很希望能把游戏流程抽象成可以被管理的对象,但是鉴于那难度之大,和不同游戏类型的流程差异太多,不利于框架复用。于是短时间内放弃了这一想法。

        转而研究了很多这种小东西,也算是受益匪浅。

结语

        其实开发了这么久,对游戏引擎的共性之间多少有些了解了,做得越久越发明白“引擎不重要”是什么意思,也越来越觉得清晰的设计思路比框架更重要。

        本来还有很多话但是到此为止吧,我的经验已经不够用了,也许下一次“杂谈”能更加侃侃而谈吧。

相关文章:

Godot的开发框架应当是什么样子的?

目录 前言 全局协程还是实例协程&#xff1f; 存档&#xff01; 全局管理类&#xff1f; UI框架&#xff1f; Godot中的异步&#xff08;多线程&#xff09;加载 Godot中的ScriptableObject 游戏流程思考 结语 前言 这是一篇杂谈&#xff0c;主要内容是对我…...

GitHub新手入门 - 从创建仓库到协作管理

GitHub新手入门 - 从创建仓库到协作管理 GitHub 是开发者的社交平台&#xff0c;同时也是代码托管的强大工具。无论是个人项目、开源协作&#xff0c;还是团队开发&#xff0c;GitHub 都能让你轻松管理代码、版本控制和团队协作。今天&#xff0c;我们将从基础开始&#xff0c…...

作业25 深度搜索3

作业&#xff1a; #include <iostream> using namespace std; bool b[100][100]{0}; char map[100][100]{0}; int dx[4]{0,1,0,-1}; int dy[4]{1,0,-1,0}; int n,m; int sx,sy,ex,ey; int mink2147483647; void dfs(int,int,int); int main(){cin>>n>>m;for(…...

ubuntu20.04 colmap 安装2024.11最新

很多教程都很落后了&#xff0c;需要下载压缩包解压编译的很麻烦 现在就只需要apt install就可以了 apt更新 sudo apt update && sudo apt-get upgrade安装依赖 #安装依赖 sudo apt-get install git cmake ninja-build build-essential libboost-program-options-de…...

WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇

WebRTC视频 01 - 视频采集整体架构 WebRTC视频 02 - 视频采集类 VideoCaptureModule [WebRTC视频 03 - 视频采集类 VideoCaptureDS 上篇]&#xff08;本文&#xff09; WebRTC视频 04 - 视频采集类 VideoCaptureDS 中篇 WebRTC视频 05 - 视频采集类 VideoCaptureDS 下篇 一、前…...

python os.path.basename(获取路径中的文件名部分) 详解

os.path.basename 是 Python 的 os 模块中的一个函数&#xff0c;用于获取路径中的文件名部分。它会去掉路径中的目录部分&#xff0c;只返回最后的文件名或目录名。 以下是 os.path.basename 的详细解释和使用示例&#xff1a; 语法 os.path.basename(path) 参数 path&…...

《FreeRTOS任务基础知识以及任务创建相关函数》

目录 1.FreeRTOS多任务系统与传统单片机单任务系统的区别 2.FreeRTOS中的任务&#xff08;Task&#xff09;介绍 2.1 任务特性 2.2 FreeRTOS中的任务状态 2.3 FreeRTOS中的任务优先级 2.4 在任务函数中退出 2.5 任务控制块和任务堆栈 2.5.1 任务控制块 2.5.2 任务堆栈…...

036集——查询CAD图元属性字段信息:窗体显示(CAD—C#二次开发入门)

提取CAD图元所有属性字段&#xff0c;通过窗体显示&#xff0c;效果如下&#xff1a;&#xff08;curve改为entity&#xff09; 代码如下&#xff1a; public void 属性查询() {List<Curve> ents Z.db.SelectEntities<Curve>();if (ents is null ||ents.Cou…...

Swift从0开始学习 函数和闭包 day2

一、函数 1.1函数定义 使用 func 来声明一个函数&#xff0c;使用名字和参数来调用函数。使用 -> 来指定函数返回值的类型。 示例&#xff1a;拼接字符串 //有参数和返回值的函数 func append1(name : String, description : String) -> String {return "\(name)…...

内网、公网(外网)划分

内网、公网&#xff08;外网&#xff09;划分 声明&#xff01; 学习视频来自B站up主 泷羽sec 有兴趣的师傅可以关注一下&#xff0c;如涉及侵权马上删除文章 笔记只是方便各位师傅的学习和探讨&#xff0c;文章所提到的网站以及内容&#xff0c;只做学习交流&#xff0c;其…...

【linux】centos7 换阿里云源

相关文章 【linux】CentOS 的软件源&#xff08;Repository&#xff09;学习-CSDN博客 查看yum配置文件 yum的配置文件通常位于/etc/yum.repos.d/目录下。你可以使用以下命令查看这些文件&#xff1a; ls /etc/yum.repos.d/ # 或者 ll /etc/yum.repos.d/备份当前的yum配置文…...

用OMS进行 OceanBase 租户间数据迁移的测评

基本概念 OceanBase迁移服务&#xff08;&#xff0c;简称OMS&#xff09;&#xff0c;可以让用户在同构或异构 RDBMS 与OceanBase 数据库之间进行数据交互&#xff0c;支持数据的在线迁移&#xff0c;以及实时增量同步的复制功能。 OMS 提供了可视化的集中管控平台&#xff…...

【因果分析方法】MATLAB计算Liang-Kleeman信息流

【因果分析方法】MATLAB计算Liang-Kleeman信息流 1 Liang-Kleeman信息流2 MATLAB代码2.1 函数代码2.2 案例参考Liang-Kleeman 信息流(Liang-Kleeman Information Flow)是由 Liang 和 Kleeman 提出的基于信息论的因果分析方法。该方法用于量化变量之间的因果关系,通过计算信息…...

【Java基础知识系列】之Java类的初始化顺序

前言 类的初始化顺序 简单场景 代码示例 public class Person {private String name initName();private String initName() {System.out.println("【父类】初始化实例变量name");return "【父类】史蒂夫";}private int age;private static int staticVa…...

Swift 宏(Macro)入门趣谈(二)

概述 苹果在去年 WWDC 23 中就为 Swift 语言新增了“其利断金”的重要小伙伴 Swift 宏&#xff08;Swift Macro&#xff09;。为此&#xff0c;苹果特地用 2 段视频&#xff08;入门和进阶&#xff09;颇为隆重的介绍了它。 那么到底 Swift 宏是什么&#xff1f;有什么用&…...

vue elementui el-dropdown-item设置@click无效的解决方案

如图&#xff0c;直接在el-dropdown-item上面设置click&#xff0c;相应的method并没有被触发&#xff0c;查找资料发现需要在它的上级 el-dropdown 处使用 command 方法触发。 【template】 <el-dropdown placement"bottom-end" command"handleCommand&quo…...

如何用re从第1排第2个位置中找到两个数字返回(0,1)

以下是使用 Python 的re模块从第1班第2个位置这样的字符串中提取出数字并返回类似(0, 1)这种形式的示例代码&#xff0c;假设数字都是一位数的情况&#xff08;如果是多位数可以按照后续介绍稍作调整&#xff09;&#xff1a; import redef extract_numbers(text):numbers re.…...

vue中的keep-alive是什么,有哪些使用场景,使用了什么原理,缓存后如何更新数据

<keep-alive> 是 Vue.js 提供的一个内置组件&#xff0c;用于缓存动态组件&#xff0c;避免频繁的销毁和重建。这在某些场景下可以显著提升性能&#xff0c;特别是在组件频繁切换的情况下。以下是对 keep-alive 的详细讲解&#xff0c;包括它的定义、使用场景、原理分析、…...

LeetCode105.从前序与中序遍历构造二叉树

题目要求 给定两个整数数组 preorder 和 inorder &#xff0c;其中 preorder 是二叉树的先序遍历&#xff0c; inorder 是同一棵树的中序遍历&#xff0c;请构造二叉树并返回其根节点。 提示: 1 < preorder.length < 3000inorder.length preorder.length-3000 < pr…...

LeetCode654.最大二叉树

LeetCode刷题记录 文章目录 &#x1f4dc;题目描述&#x1f4a1;解题思路⌨C代码 &#x1f4dc;题目描述 给定一个不重复的整数数组 nums 。 最大二叉树 可以用下面的算法从 nums 递归地构建: 创建一个根节点&#xff0c;其值为 nums 中的最大值。 递归地在最大值 左边 的 子…...

MySQL 隔离级别:脏读、幻读及不可重复读的原理与示例

一、MySQL 隔离级别 MySQL 提供了四种隔离级别,用于控制事务之间的并发访问以及数据的可见性,不同隔离级别对脏读、幻读、不可重复读这几种并发数据问题有着不同的处理方式,具体如下: 隔离级别脏读不可重复读幻读性能特点及锁机制读未提交(READ UNCOMMITTED)允许出现允许…...

django filter 统计数量 按属性去重

在Django中&#xff0c;如果你想要根据某个属性对查询集进行去重并统计数量&#xff0c;你可以使用values()方法配合annotate()方法来实现。这里有两种常见的方法来完成这个需求&#xff1a; 方法1&#xff1a;使用annotate()和Count 假设你有一个模型Item&#xff0c;并且你想…...

前端开发面试题总结-JavaScript篇(一)

文章目录 JavaScript高频问答一、作用域与闭包1.什么是闭包&#xff08;Closure&#xff09;&#xff1f;闭包有什么应用场景和潜在问题&#xff1f;2.解释 JavaScript 的作用域链&#xff08;Scope Chain&#xff09; 二、原型与继承3.原型链是什么&#xff1f;如何实现继承&a…...

分布式增量爬虫实现方案

之前我们在讨论的是分布式爬虫如何实现增量爬取。增量爬虫的目标是只爬取新产生或发生变化的页面&#xff0c;避免重复抓取&#xff0c;以节省资源和时间。 在分布式环境下&#xff0c;增量爬虫的实现需要考虑多个爬虫节点之间的协调和去重。 另一种思路&#xff1a;将增量判…...

sipsak:SIP瑞士军刀!全参数详细教程!Kali Linux教程!

简介 sipsak 是一个面向会话初始协议 (SIP) 应用程序开发人员和管理员的小型命令行工具。它可以用于对 SIP 应用程序和设备进行一些简单的测试。 sipsak 是一款 SIP 压力和诊断实用程序。它通过 sip-uri 向服务器发送 SIP 请求&#xff0c;并检查收到的响应。它以以下模式之一…...

用机器学习破解新能源领域的“弃风”难题

音乐发烧友深有体会&#xff0c;玩音乐的本质就是玩电网。火电声音偏暖&#xff0c;水电偏冷&#xff0c;风电偏空旷。至于太阳能发的电&#xff0c;则略显朦胧和单薄。 不知你是否有感觉&#xff0c;近两年家里的音响声音越来越冷&#xff0c;听起来越来越单薄&#xff1f; —…...

A2A JS SDK 完整教程:快速入门指南

目录 什么是 A2A JS SDK?A2A JS 安装与设置A2A JS 核心概念创建你的第一个 A2A JS 代理A2A JS 服务端开发A2A JS 客户端使用A2A JS 高级特性A2A JS 最佳实践A2A JS 故障排除 什么是 A2A JS SDK? A2A JS SDK 是一个专为 JavaScript/TypeScript 开发者设计的强大库&#xff…...

如何更改默认 Crontab 编辑器 ?

在 Linux 领域中&#xff0c;crontab 是您可能经常遇到的一个术语。这个实用程序在类 unix 操作系统上可用&#xff0c;用于调度在预定义时间和间隔自动执行的任务。这对管理员和高级用户非常有益&#xff0c;允许他们自动执行各种系统任务。 编辑 Crontab 文件通常使用文本编…...

c# 局部函数 定义、功能与示例

C# 局部函数&#xff1a;定义、功能与示例 1. 定义与功能 局部函数&#xff08;Local Function&#xff09;是嵌套在另一个方法内部的私有方法&#xff0c;仅在包含它的方法内可见。 • 作用&#xff1a;封装仅用于当前方法的逻辑&#xff0c;避免污染类作用域&#xff0c;提升…...

ubuntu22.04 安装docker 和docker-compose

首先你要确保没有docker环境或者使用命令删掉docker sudo apt-get remove docker docker-engine docker.io containerd runc安装docker 更新软件环境 sudo apt update sudo apt upgrade下载docker依赖和GPG 密钥 # 依赖 apt-get install ca-certificates curl gnupg lsb-rel…...