In the previous blog post, we managed to host the .NET Runtime and successfully executed managed code from native code and vice versa. However, C# was not involved at all. In this post, we will build up the infrastructure for C# scripts and execute our first C# script. This is the fourth in a series of posts on building a .NET 6.0-based C# scripting engine for a game engine. If you’ve not yet read the previous posts, you can start here.
Limitations of the Managed-Native Interface
Before we get started writing code, we need to first understand how we can tie our managed and native data together.
While our sample application is relatively simple, in a real game engine, pretty much all of the engine logic and data resides in native code and data storage. For instance, information about game entities and their associated components will be stored and updated by native C++ code.
On the other hand, data and gameplay logic will most likely be attached to C# scripts and reside in managed code and data storage. In order to facilitate our managed C# code to operate on native objects, we need some way to reference each other.
Thankfully, we are using C++/CLI, which allows our managed objects to store pointers to native objects. However, if we were to develop this for a non-Windows system where C++/CLI is not available, this becomes significantly more complex. Not to mention, storing native pointers and passing them between managed and native code lines gets messy and potentially dangerous.
Instead, a better alternative is to tie all game entities with a numerical index. There are many ways to do this but they are beyond the scope of this guide. To keep things simple, in this guide, we will simply be using sequential numbers in the native code to represent different game entities.
Entities
To keep this sample simple, we will only have a fixed number of 5 objects in our game numbered from 0 to 4 and any calls from our managed code to access any entities outside of this valid ID range would be an error. To handle this in our sample, we will simply define these constants in our Application class.
// Application.h ... class DLL_API Application { public: static constexpr int ENTITY_COUNT = 5; static constexpr int MIN_ENTITY_ID = 0; static constexpr int MAX_ENTITY_ID = ENTITY_COUNT - 1; ... };
Do note that in a real game engine, you would have a proper Entity Manager to handle creation and destruction of game objects.
Base Script
With our simple native infrastructure out of the way, let’s start building the managed infrastructure. We will begin by first creating a managed class which all scripts will inherit from called Script
. We will have the script class be abstract and implement a virtual Update()
function.
Gameplay programmers can then write any code they would wish to run every frame in an overridden Update()
.
// Script.hxx #pragma once namespace ScriptAPI { public ref class Script abstract { public: void virtual Update() {}; }; }
In a real implementation, you would probably add additional lifecycle functions such as Awake()
, Start()
, LateUpdate()
, OnDestroy(
), OnEnable()
and OnDisable()
. You might also want to add additional functions for getting scripts, components and more.
Script Storage
Next, we need a place to store all of our scripts that’s tied to each game entity. We will do this in our EngineInterface
class.
There are many ways to store our scripts but for simplicity’s sake, we will store a List
of List
s of Script
s and initialize it in a new Init()
function. This way, we have a very easy mapping from our fixed entity IDs (0 to 4) to their list of scripts. Do note that in a real game engine, we may have dynamic set of entities where entity IDs may be recycled so you might want to choose a different solution like a Dictionary
instead.
To prepare for adding scripts to entities later, we will also get the list of types of scripts.
// EngineInterface.hxx #pragma once #include "Script.hxx" namespace ScriptAPI { public ref class EngineInterface { public: static void Init(); private: using ScriptList = System::Collections::Generic::List<Script^>; static System::Collections::Generic::List<ScriptList^>^ scripts; static System::Collections::Generic::IEnumerable<System::Type^>^ scriptTypeList; static void updateScriptTypeList(); }; }
// EngineInterface.cxx #include "EngineInterface.hxx" namespace ScriptAPI { void EngineInterface::Init() { scripts = gcnew System::Collections::Generic::List<ScriptList^>(); for (int i = 0; i < Core::Application::ENTITY_COUNT; ++i) { scripts->Add(gcnew ScriptList()); } } namespace { /* Select Many */ ref struct Pair { System::Reflection::Assembly^ assembly; System::Type^ type; }; System::Collections::Generic::IEnumerable<System::Type^>^ selectorFunc(System::Reflection::Assembly^ assembly) { return assembly->GetExportedTypes(); } Pair^ resultSelectorFunc(System::Reflection::Assembly^ assembly, System::Type^ type) { Pair^ p = gcnew Pair(); p->assembly = assembly; p->type = type; return p; } /* Where */ bool predicateFunc(Pair^ pair) { return pair->type->IsSubclassOf(Script::typeid) && !pair->type->IsAbstract; } /* Select */ System::Type^ selectorFunc(Pair^ pair) { return pair->type; } } void EngineInterface::updateScriptTypeList() { using namespace System; using namespace System::Reflection; using namespace System::Linq; using namespace System::Collections::Generic; /* Select Many: Types in Loaded Assemblies */ IEnumerable<Assembly^>^ assemblies = AppDomain::CurrentDomain->GetAssemblies(); Func<Assembly^, IEnumerable<Type^>^>^ collectionSelector = gcnew Func<Assembly^, IEnumerable<Type^>^>(selectorFunc); Func<Assembly^, Type^, Pair^>^ resultSelector = gcnew Func<Assembly^, Type^, Pair^>(resultSelectorFunc); IEnumerable<Pair^>^ selectManyResult = Enumerable::SelectMany(assemblies, collectionSelector, resultSelector); /* Where: Are concrete Scripts */ Func<Pair^, bool>^ predicate = gcnew Func<Pair^, bool>(predicateFunc); IEnumerable<Pair^>^ whereResult = Enumerable::Where(selectManyResult, predicate); /* Select: Select them all */ Func<Pair^, Type^>^ selector = gcnew Func<Pair^, Type^>(selectorFunc); scriptTypeList = Enumerable::Select(whereResult, selector); } }
Notice the huge chunk of code dedicated to updateScriptTypeList()
along with the type and function definitions above it? It may look confusing and arcane but it’s simply using .NET’s LINQ feature. Usually, it is much less verbose in C# as most of what you see above are replaced with lambda functions and SQL-style query expressions. However, none of that is available in C++/CLI so we had to jump through a couple of hoops.
TL;DR What we did is the following:
- Get the list of currently loaded .NET assemblies within the current App Domain
- Get the list of all the types that are within these assemblies from step 1
- Get the list of all types that inherit from
Script
and are not abstract from the list of types from step 2 - Store this list of types in
scriptTypeList
With that out of the way, let’s add a function to allow the native code to add scripts to different entities via script name. This is important as we currently do not have any managed code that is able to load a scene and their associated game entities. In this simple solution, we will hard code the loading of scripts from native code.
// EngineInterface.hxx ... namespace ScriptAPI { public ref class EngineInterface { public: ... static bool AddScriptViaName(int entityId, System::String^ scriptName); ... }; }
// EngineInterface.cxx ... namespace ScriptAPI { ... bool EngineInterface::AddScriptViaName(int entityId, System::String^ scriptName) { // Check if valid entity if (entityId < Core::Application::MIN_ENTITY_ID || entityId > Core::Application::MAX_ENTITY_ID) return false; // Remove any whitespaces just in case scriptName = scriptName->Trim(); // Look for the correct script System::Type^ scriptType = nullptr; for each (System::Type^ type in scriptTypeList) { if (type->FullName == scriptName || type->Name == scriptName) { scriptType = type; break; } } // Failed to get any script if (scriptType == nullptr) return false; // Create the script Script^ script = safe_cast<Script^>(System::Activator::CreateInstance(scriptType)); // Add the script scripts[entityId]->Add(script); return true; } ... }
AddScriptViaName()
simply searches the list of script types that we obtained earlier for a match and then constructs an instance of the script to add into the specified entity’s script list.
Note the use of System::Activator::CreateInstance()
. It may sound a bit cryptic but what it does is simple: Default construct an object of the specified type. We have to use this as we can’t use the normal gcnew
syntax to create it as scriptType
is a variable that stores a type; it itself is not a type that we can pass to gcnew
.
Script Execution
We have one last part of our managed infrastructure to build. That is to execute our scripts. We can simply do this by iterating through all the scripts that have been created and calling their respective Update()
. We will do this in an ExecuteUpdate()
function in EngineInterface
.
// EngineInterface.hxx ... namespace ScriptAPI { public ref class EngineInterface { public: ... static void ExecuteUpdate(); ... }; }
// EngineInterface.cxx ... namespace ScriptAPI { void EngineInterface::ExecuteUpdate() { for each (ScriptList^ entityScriptList in scripts) { // Update each script for each (Script^ script in entityScriptList) { script->Update(); } } } }
Test Script
Now we will create a simple C# Script
in the C# project.
public class TestScript : ScriptAPI.Script { public override void Update() { Console.WriteLine("Test!"); } }
This should easily compile with no issues and IntelliSense should provide you with the correct auto-suggestions.
External Script Assembly
All that’s left is to load the scripts we wrote in the ManagedScripts assembly so that we can access it from within ScriptAPI. To do this, we will need to load the assembly but this consists of two parts.
Current Working Directory
By default, applications launched from Visual Studio will use the project’s directory as the current working directory. This is a problem since our actual game’s working directory when we distribute our game is the location of the game’s executable by default. To fix this, we’re going to add two lines to our “Application.cpp” file.
// Application.cpp ... #include <filesystem> ... namespace Core { ... void Application::startScriptEngine() { // Get the current executable directory so that we can find the coreclr.dll to load std::string runtimePath(MAX_PATH, '\0'); GetModuleFileNameA(nullptr, runtimePath.data(), MAX_PATH); PathRemoveFileSpecA(runtimePath.data()); // Since PathRemoveFileSpecA() removes from data(), the size is not updated, so we must manually update it runtimePath.resize(std::strlen(runtimePath.data())); // ^ Code from Part 3, which we are reusing "runtimePath" for. // Also, while we're at it, set the current working directory to the current executable directory std::filesystem::current_path(runtimePath); ... } ... }
Some people might ask why I didn’t just set the working directory within Visual Studio’s project settings. The issue with doing that is that it is saved in the “vcxproj.user” file which is commonly added to “.gitignore” files. By doing it this way, we ensure that even in a collaborative repository, the working directory will always be set correctly.
Loading the Assembly
Now, we just need to load the ManagedScripts assembly in our EngineInterface
‘s Init()
.
// EngineInterface.cxx ... namespace ScriptAPI { void EngineInterface::Init() { // Load assembly System::Reflection::Assembly::LoadFrom("ManagedScripts.dll"); ... } }
The Pay Off
Finally, all we have to do is to tie up the ends in our native code. We will do all of this in Application::Run()
. There’s 4 things we will need to do:
- Getting all of the function pointers to
EngineInterface()
‘s functions. - Call the
EngineInterface
‘sInit()
to execute all the initialization code - Use
AddScriptViaName()
to add a script to an entity - Use
ExecuteUpdate()
to actually run our update loop for the scripts
// Application.h ... namespace Core { void Application::Run() { startScriptEngine(); // Step 1: Get Functions auto init = GetFunctionPtr<void(*)(void)> ( "ScriptAPI", "ScriptAPI.EngineInterface", "Init" ); auto addScript = GetFunctionPtr<bool(*)(int, const char*)> ( "ScriptAPI", "ScriptAPI.EngineInterface", "AddScriptViaName" ); auto executeUpdate = GetFunctionPtr<void(*)(void)> ( "ScriptAPI", "ScriptAPI.EngineInterface", "ExecuteUpdate" ); // Step 2: Initialize init(); // Step 3: Add script to an entity addScript(0, "TestScript"); // Load while (true) { if (GetKeyState(VK_ESCAPE) & 0x8000) break; // Step 4: Run the Update loop for our scripts executeUpdate(); } stopScriptEngine(); }
Now, when we run our application, you should see our C# scripts executing!
Conclusion
Finally, after lots and lots of code, we finally have a working demonstration of executing C# code from our C++ engine. However, we still have not demonstrated retrieving or manipulating native data from managed code. We will do this in the next blog post.
Thanks for reading! As always, a repository of a working solution based on this tutorial can be found here.
0 Comments