Welcome back! I’ve been quite busy with school and life so apologies for the long wait. In the previous blog post, we had successfully gotten a C# script to run while attached to a entity in our native code. Now, we will attempt to let our C# script access and manipulate native data that is attached to the entity that owns the script.

This is the fifth 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.

Native Data

For the purposes of this tutorial, we will have a simple class hold the values that our script would access. Recall from the previous tutorial that each entity ID groups together native data and script data.

We will define this at the top of our Application.h and define a static array of these components that correspond to each entity along with a function to retrieve these components.

// Application.h
...
namespace Core
{
    class DLL_API TransformComponent
    {
      public: 
        float x = 0.0f;
        float y = 0.0f;
    };
    class DLL_API Application
    {
      public:
        ...
        static TransformComponent* GetComponent(int entityId);
      private:
        static std::array<TransformComponent, ENTITY_COUNT> nativeData;
        ...
    };
}
// Application.cpp
...
namespace Core
{
    std::array<TransformComponent, Application::ENTITY_COUNT> Application::nativeData;
    ...
}

Now, you may be wondering what’s the reason we made this static and isn’t this kind of unsightly with a mix of static and member data in our Application class? Yes, you’re right. In a real application, you would want to pass pointers from the native to the managed portions of your application. However, we are keeping the implementation simple for this tutorial and so we’ll access the data via global static variables.

Owner-Aware Scripts

For this next part, we want a script to be able to access components that are connected to the same entity. However, to do this, the script needs to know what entity it is attached to. Our current implementation doesn’t notify the script. So we’ll add in a entityId field to our scripts along with a function to set the value of that field:

// Script.hxx
namespace ScriptAPI
{
    public ref class Script abstract
    {
      public:
        void virtual Update() {};

      internal:
        void SetEntityId(int id);

      private:
        int entityId;
    };
}

As our Script class now has functions implemented, we need to create a Script.cxx file too:

#include "Script.hxx"

namespace ScriptAPI
{
    void Script::SetEntityId(int id)
    {
        entityId = id;
    }
}

Notice that we made the SetEntityId() function have the internal access modifier. This is a new modifier in .NET languages that allows access to code within the same assembly (project). This allows us to use this function to set the entity ID when we’re creating the script here:

// EngineInterface.cxx
namespace ScriptAPI
{
    ...
    bool EngineInterface::AddScriptViaName(int entityId, System::String^ scriptName)
    {
        ...
        // Failed to get any script
        if (scriptType == nullptr)
            return false;

        // Create the script
        Script^ script = safe_cast<Script^>(System::Activator::CreateInstance(scriptType));
        script->SetEntityId(entityId);
        
        // Add the script
        scripts[entityId]->Add(script);
        return true;
    }
    ...
}

Mirroring Native Types from Managed Code

Next up, let’s build the managed version of the TransformComponent so that our scripts can access it. We’ll create a TransformComponent.hxx and TransformComponent.cxx file which is a lightweight object that contains function for manipulating our native data. First, our TransformComponent.hxx file:

// TransformComponent.hxx
#pragma once

namespace ScriptAPI
{
    public value struct TransformComponent
    {
      public:
        property float X
        {
            float get();
            void set(float value);
        }
        property float Y
        {
            float get();
            void set(float value);
        }

      internal:
        TransformComponent(int id);

      private:
        int entityId;
    };
}

You should notice something new, a “property”. Properties are essentially getter (accessor) and setter (modifier) functions that are disguised as a normal variable. This allows us to write code that works with the variables easily instead of calling a function to get or change values every time.

We have also added an internal TransformComponent constructor that allows us to automatically set the component’s entityId when we construct it. Do keep in mind in C++/CLI a value struct has a mandatory, uncustomizable default constructor that zero-initializes all variables. This means that users writing scripts can instantiate a TransformComponent and try to use it even if it may have invalid data. To work around this, you might choose to reserve 0 as a invalid ID or use other methods. For the purpose of this tutorial, we will keep things as is.

// TransformComponent.cxx
#include "TransformComponent.hxx"
#include "../Core/Application.h"

namespace ScriptAPI
{
    float TransformComponent::X::get()
    {
        return Core::Application::GetComponent(entityId)->x;
    }
    void TransformComponent::X::set(float value)
    {
        Core::Application::GetComponent(entityId)->x = value;
    }
    float TransformComponent::Y::get()
    {
        return Core::Application::GetComponent(entityId)->y;
    }
    void TransformComponent::Y::set(float value)
    {
        Core::Application::GetComponent(entityId)->y = value;
    }
    TransformComponent::TransformComponent(int id)
    : entityId { id }
    {}
}

Here, take note of the syntax for defining the get() and set() functions of properties. You’ll also see that we invoke a GetComponent() for each function to access the native data. While this is relatively cheap, if you are anticipating use cases where you may be accessing and writing values repeatedly from the same object within the same frame, you might want to provide functions that access or write multiple values at once to minimize the managed-to-native function calls.

Accessing Native Data from Scripts

Finally, we need to allow our scripts to access the TransformComponent that is associated with the same entity. To do this, we’ll add a simple function to the Script class to retrieve the TransformComponent.

// Script.hxx
#include "TransformComponent.hxx"

namespace ScriptAPI
{
    public ref class Script abstract
    {
      public:
        void virtual Update() {};
        TransformComponent GetTransformComponent();
        ...
    };
}
// Script.cxx
#include "../Core/Application.h"

namespace ScriptAPI
{
    TransformComponent Script::GetTransformComponent()
    {
        return TransformComponent(entityId);
    }
    ...
}

Take note that in a real application, with multiple types of components, this might not be the most efficient way. Consider using templates and generic functions to improve the interface and keep your code cleaner.

Testing it Out

Now, let’s test it out in our C# code by repurposing our TestScript.cs file:

// TestScript.cs
using ScriptAPI;

public class TestScript : Script
{
    public override void Update()
    {
        TransformComponent tf = GetTransformComponent();
        tf.X += 0.01f;
        Console.WriteLine($"x: {tf.X}");
    }
}

Run the application and now you should have a console that prints out a gradually increasing number.

Conclusion

This ends the main part of the tutorial as we have something that works. But we’re not done! In a full implementation, aside from adjusting the architecture to fit into a real engine, there’s still a few things missing to make it usable in an actual project:

Hot Reloading
It’s great that we can write C# scripts but what is the point of a scripting engine that does not support hot reloading? The reason why we add a scripting engine into a game engine is to enable faster iterations for gameplay programmers. So hot reloading is extremely important. To do this, we will need to rewrite some of the code we wrote today. We will also need to figure out how to support compiling code at runtime and reloading the script assembly (ManagedScripts).

Exception Handling
What happens if your managed code throws an exception? Currently, it causes a crash. We will need to learn to properly handle the exceptions so that our whole application doesn’t crash if any managed calls fail. Additionally, since we are using C++/CLI, we also have to handle thrown native exceptions in managed code on top of managed exceptions. This adds additional complexity that we will look into.

We will look at this next so stay tuned for it!

The 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.