Skip to content

Instantly share code, notes, and snippets.

@cgbeutler
Last active June 24, 2022 14:45
Show Gist options
  • Save cgbeutler/c4f00b98d744ac438b84e8840bbe1740 to your computer and use it in GitHub Desktop.
Save cgbeutler/c4f00b98d744ac438b84e8840bbe1740 to your computer and use it in GitHub Desktop.
Helper for creating custom resources in Godot in C# until bug #38191 is fixed
using System;
using System.Runtime.CompilerServices;
using Godot.Collections;
using Array = Godot.Collections.Array;
/* Example Usage:
// Declare a class with the attribute
[CSharpScript]
public class CustomResource : Resource { ... }
// Later, create new resources with
CSharpScript<CustomResource>.New()
// Report issues to the gist at: https://gist.github.com/cgbeutler/c4f00b98d744ac438b84e8840bbe1740
*/
namespace Godot
{
[AttributeUsage( AttributeTargets.Class, Inherited = false, AllowMultiple = false )]
public sealed class CSharpScriptAttribute : Attribute
{
public CSharpScriptAttribute( [CallerFilePath] string path = "" )
{
FilePath = path;
}
public string FilePath { get; set; }
};
public static class CSharpScript<T>
where T : class
{
// TODO: Edit this string to be the project-path of THIS file (backlashes and no '.' prefixes)
private const string __THIS_FILE_PROJ_PATH = "path/to/this/CSharpScript.cs";
private static string __projectPath = null;
private static string __ProjectPath => __projectPath ??= __InitProjectPath();
private static string __InitProjectPath( [CallerFilePath] string callerPath = "" )
{
callerPath = callerPath.Replace( System.IO.Path.DirectorySeparatorChar, '/' );
if (!callerPath.EndsWith( "/" + __THIS_FILE_PROJ_PATH ))
{
GD.PushError( "Failed to get project path. Project-path of this file may have changed." );
throw new Exception("Failed to get project path. Project-path of this file may have changed.");
}
return callerPath.Remove( callerPath.Length - __THIS_FILE_PROJ_PATH.Length );
}
public static string FilePath;
public static string Filename;
public static string ResourcePath => FilePath;
static CSharpScript()
{
if (Attribute.GetCustomAttribute( typeof( T ), typeof( CSharpScriptAttribute ) ) is CSharpScriptAttribute attr)
{
var tmpFilePath = attr.FilePath.Replace( System.IO.Path.DirectorySeparatorChar, '/' );
if (!tmpFilePath.BeginsWith( __ProjectPath ))
{
GD.PushError( $"Can't get script 'res' path: Raw path didn't start with project path" );
FilePath = Filename = "";
return;
}
if (!tmpFilePath.EndsWith( ".cs" ))
{
GD.PushError( $"Can't get scritp 'res' path: Raw path didn't end with '.cs'" );
FilePath = Filename = "";
return;
}
FilePath = "res://" + tmpFilePath.Substring( __ProjectPath.Length );
Filename = FilePath.GetFile();
if (Filename.BaseName() != typeof( T ).Name)
{
GD.PushError( $"Class name '{ typeof( T ).Name }' doesn't match filename '{ Filename }'" );
}
}
else
{
FilePath = Filename = typeof(T).Name;
GD.PushError( $"'{nameof( CSharpScriptAttribute )}' missing from the class '{typeof( T ).Name}'." );
}
}
private static WeakRef __csharpScript = null; //<CSharpScript>
/// Get the CSharpScript Godot Resource
public static CSharpScript GetCSharpScript()
{
if (__csharpScript?.GetRef() is CSharpScript scr) { return scr; }
scr = GD.Load<CSharpScript>( ResourcePath );
if (scr != null) { __csharpScript = Godot.Object.WeakRef( scr ); }
else { throw new Exception( "Can't load CSharp Script" ); }
return scr;
}
/// Returns a new instance of the script.
public static T New()
{
var script = GetCSharpScript();
try
{
var o = script.New();
var t = (T) o;
return t;
}
catch (Exception e)
{
GD.PrintErr( "Exception in New(): " + e.ToString() );
GD.PrintStack();
}
return null!;
}
/// Returns the default value of the specified property.
public static object GetPropertyDefaultValue( string property )
{
var script = GetCSharpScript();
return script.GetPropertyDefaultValue( property );
}
/// Returns a dictionary containing constant names and their values.
public static Dictionary GetScriptConstantMap()
{
var script = GetCSharpScript();
return script.GetScriptConstantMap();
}
/// Returns the list of methods in this Godot.Script.
public static Array GetScriptMethodList()
{
var script = GetCSharpScript();
return script.GetScriptMethodList();
}
/// Returns the list of properties in this Godot.Script.
public static Array GetScriptPropertyList()
{
var script = GetCSharpScript();
return script.GetScriptPropertyList();
}
/// Returns the list of user signals defined in this Godot.Script.
public static Array GetScriptSignalList()
{
var script = GetCSharpScript();
return script.GetScriptSignalList();
}
/// Returns true if the script, or a base class, defines a signal with the given
/// name.
public static bool HasScriptSignal( string signalName )
{
var script = GetCSharpScript();
return script?.HasScriptSignal( signalName ) ?? false;
}
/// Returns true if the script is a tool script. A tool script can run in the editor.
public static bool IsTool()
{
var script = GetCSharpScript();
return script?.IsTool() ?? false;
}
};
public static class CSharpScriptExt
{
public static string ResourcePath( this Type t )
{
var sourceInfo = (CSharpScriptAttribute) Attribute.GetCustomAttribute( t, typeof( CSharpScriptAttribute ) );
if (sourceInfo == null)
{
GD.PushError( $"Could not file script info. Did you add '{nameof( CSharpScriptAttribute )}' to the class '{t.Name}'?" );
return "";
}
if (sourceInfo?.FilePath.GetFile().BaseName() != t.Name)
{
GD.PushError( $"Class and script name mismatch. Class name is '{ t.Name }' for script '{ sourceInfo?.FilePath }'" );
return "";
}
return sourceInfo?.FilePath ?? "";
}
public static CSharpScript AsCSharpScript( this Type t )
{
var scriptPath = ResourcePath( t );
if (scriptPath.Empty()) { throw new Exception( "Can't load CSharp Script" ); }
// Don't worry, it will usually be a cached load
// Also, in tool mode it can get scrapped randomly, so we kinda need to use load each time
return GD.Load<CSharpScript>( scriptPath );
}
/// Returns a new instance of the script.
public static object New( this Type t )
{
var script = AsCSharpScript( t );
// if ( Engine.EditorHint && ! script.IsTool() )
// { GD.PushWarning($"Script is not in tool mode: '{ typeof( T ).Name }'"); }
return script.New();
}
/// Returns the default value of the specified property.
public static object GetPropertyDefaultValue( this Type t, string property )
{
var script = AsCSharpScript( t );
return script.GetPropertyDefaultValue( property );
}
/// Returns a dictionary containing constant names and their values.
public static Dictionary GetScriptConstantMap( this Type t )
{
var script = AsCSharpScript( t );
return script.GetScriptConstantMap();
}
/// Returns the list of methods in this Godot.Script.
public static Array GetScriptMethodList( this Type t )
{
var script = AsCSharpScript( t );
return script.GetScriptMethodList();
}
/// Returns the list of properties in this Godot.Script.
public static Array GetScriptPropertyList( this Type t )
{
var script = AsCSharpScript( t );
return script.GetScriptPropertyList();
}
/// Returns the list of user signals defined in this Godot.Script.
public static Array GetScriptSignalList( this Type t )
{
var script = AsCSharpScript( t );
return script.GetScriptSignalList();
}
/// Returns true if the script, or a base class, defines a signal with the given
/// name.
public static bool HasScriptSignal( this Type t, string signalName )
{
var script = AsCSharpScript( t );
return script?.HasScriptSignal( signalName ) ?? false;
}
/// Returns true if the script is a tool script. A tool script can run in the editor.
public static bool IsTool( this Type t )
{
var script = AsCSharpScript( t );
return script?.IsTool() ?? false;
}
};
}
@derkork
Copy link

derkork commented Oct 27, 2021

Will this actually work in exported games where the class files are compiled?

@cgbeutler
Copy link
Author

cgbeutler commented Oct 27, 2021

Will this actually work in exported games where the class files are compiled?

Actually, no. I could write one that does, if you need it. CallerFilePath is filled at compile time, not runtime, so all you'd have to do is replace the project path part of it with "res://", and it would work anywhere.
I was hoping custom resources would just be fixed and not need this, but after a year and a half of waiting...

@derkork
Copy link

derkork commented Oct 27, 2021

I figured as much. The tricky part is to find out the project path part of it and save this to the build. I just analyzed a build to see how a res://path/to/WhatEver.cs would actually be resolved if all CS scripts are compiled to a DLL, and it looks like that the export includes all CS files as part of the build but as empty files. This looks like it's just not ready for primetime yet.

@cgbeutler
Copy link
Author

One trick I use is to make a private func with CallerFilePath that just subtracts the project-relative path of that file. That results in the project path. Then just replace that with "res://" for any paths and you have a path that works in prod.
A touch hackish, but it does work. I have used that method for my custom "Load" that can do relative path loading.

@cgbeutler
Copy link
Author

cgbeutler commented Oct 27, 2021

@derkork Ok, I updated it to work for prod environments. I also added my changes to it that cache stuff, so it should be faster.
Just fill in the 'TODO' item and it should be able to convert paths to be local. If you ever move the file, you WILL get a warning to remind you to update that 'TODO' again!
Let me know if you have any issues. I will be testing it more this afternoon when I have a chance.

@cgbeutler
Copy link
Author

Note that I use the 'Nullable' compile flag, so you may need to remove some nullable refs if you don't use that.

@cgbeutler
Copy link
Author

cgbeutler commented Oct 27, 2021

Ok, I tested it in a sterile environment. Seems to be working fine. You may also need to enable C# v8 by adding <LangVersion>8.0</LangVersion> to the 'PropertyGroup' section of your csproj file, or replace ??= with a long-hand coalesce.

@cgbeutler
Copy link
Author

If folks are interested, I could wrap this and my IO stuff together into an addon. Then the relative path should always be fixed in "addons/..." and wouldn't need this special treatment.

@derkork
Copy link

derkork commented Oct 27, 2021

Nice, I'll give this a spin in my project. The hack isn't super nice, but it's pretty clever and it's the sort of hack you can set and forget, so I can live with that. Now I just need to remember to use the New function for every custom C# script i use instead of just doing new MyClass. Thanks a lot!

@cgbeutler
Copy link
Author

cgbeutler commented Oct 28, 2021

Ok, I wrapped up this attribute and my relative-path file IO stuff into one addon. For those interested, you can try it out or steal whatever code you want from it here: https://github.com/cgbeutler/com.monstervial.io_tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment