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 Lists of Scripts 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:

  1. Get the list of currently loaded .NET assemblies within the current App Domain
  2. Get the list of all the types that are within these assemblies from step 1
  3. Get the list of all types that inherit from Script and are not abstract from the list of types from step 2
  4. 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:

  1. Getting all of the function pointers to EngineInterface()‘s functions.
  2. Call the EngineInterface‘s Init() to execute all the initialization code
  3. Use AddScriptViaName() to add a script to an entity
  4. 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.

Categories: Tutorials

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.