Skip to content

GraphXTheatre File Format

Electron edited this page Mar 7, 2025 · 20 revisions

Introduction to GraphXTheatre Files

What are GraphXTheatre Files?

GraphXTheatre files are what the engine use to create Theatres, and Theatres are pretty much just GraphX's version of game scenes, levels, maps, etc. I created the GraphXTheatre file format to replace my old method of making and loading Theatres... using header files.

Wait, you made a file format?

Well, kinda. .gt files are pretty much just .txt files that are read, parsed, and interpreted by custom code in the engine. What I really made was a parser/interpreter and a bunch of rules that .gt files need to follow. Speaking of which, that brings us to the next section.

The Filename and Load Order

The first and one of the most important rules to follow involves what you actually name the .gt file itself. Well, ironically, the actual "name" of the file doesn't really matter at all, but what comes before it is pretty important. Every .gt file must begin with a number and a period; that number is the load-order of that Theatre. Under the hood, Theatre files are embedded into the engine as reeeeeally long strings inside of a map called embedded_theatres; the key values in that map are long numbers that are taken from the filename. Because the engine uses a map instead of an unordered map, if a Theatre has the same load-order number as a Theatre that's already been embedded, it will actually replace that other Theatre. This is by design, as it allows users to replace the default Theatres that come pre-embedded into GraphX. In layman's terms: it lets you replace the game's default "levels" with your own.

Well, with all that out of the way, it's time to move on to the meat and potatoes of this page.

GraphXTheatre Syntax

This section will go into detail about the dos, donts, cans, and cants when making a Theatre. First thing's first:

Names, Classes, & Braces (dear god!)

The very first line of a GraphXTheatre file must always be the name of the Theatre and must always start with the @ symbol.

Here's an example of a Theatre named "HelloWorld":

@HelloWorld

Simple, right? Now the fun stuff: class creation. Theatres are basically just holding all the stuff that the engine is currently interacting with; stuff like the player, all the different lights, all the meshes and materials being used, etc. Luckily, creating and adding these to a Theatre using a GraphXTheatre file is actually pretty simple, and tries to at least resemble C++ syntax at a glance.

Creating an Object

The basic syntax looks like this:

Class (name)
{
    Variable1 [code_reference]
    Variable2 <theatre_reference>
    Variable3 (raw_data)
    Variable4 "external/reference.var"
    ...
}

And here's an actual example from one of the default Theatres in the engine:

LightTesterMover (spinny_light_2)
{
    PivotPosition (-11.0, 1.7, -4.3)
    PivotRadius   (2.4)
    PivotSpeed    (0.87)
    Color         (0.10, 1.0, 0.4)
}
RigidBodyActor (Falling_Cube_1)
{
    Mesh:Material <Cube>:<Doom_Shiny>
    Scale         (1.0, 1.0, 1.0)
    Position      (-2.0, 9.0, -6.0)
}

As you can see, there are two objects being created in this example; a LightTesterMover named "spinny_light_2" and a RigidBodyActor named "Falling_Cube_1".

Warning

While there's nothing stopping you from using the same name for different objects, I'd recommend not doing that. The engine has a few functions for interacting with Actors and Devices based on their Unique Identifier (UID), but figuring out which UID an object has is really annoying, so I also added functions that let you search for objects based on their name. If those functions are used to look for an Actor/Device with a name that other Actors/Devices also have, the wrong one could very easily be returned.

Similarly to C++, all the variables for an object are written inside a pair of curly brackets. Every object is required to have these brackets, even if no variables are set, like in the case of this LightFlashlight object:

LightFlashlight (Player_Flashlight)
{}

Now that you know how to make a new Actor/Device, it's time to learn how to set them up properly.

The Four Types of Variables

Variable names are just strings that the C++ classes look for when told to process their "settings" (the parsed data from the .gt file). I'll go into more detail later about variable names, how easy it is to make new ones, and how they don't affect a C++ variable's default value. First, though, you need to know the different types of data that GraphXTheatre variables use, as well as their syntax. Variable data comes in four different types:

(Raw Data)

Raw data variables are the simplest form of variable, but also the most rigid and, admittedly, in need of fleshing out. Raw data variables can be a single number, a vector of two or three numbers, a quaternion (which is just a vector of four numbers), a boolean value, or a single string. Raw data variables are surrounded by parentheses.

Examples of raw data variables include an object's name:

Light (Player_Flashlight)

An Actor's position, rotation, and scale:

StaticBodyActor (Platform_1)
{
    ...
    Scale    (10.0, 50.0, 10.0)
    Position (32.1, 75.0, -29.7)
    Rotation (0.0, 20.0, 0.0)
}

A Material's specular strength and sharpness:

Material (Doom_Dull)
{
    ...
    SpecularSharpness (16)
    SpecularStrength  (0.5)
}

Note

All numerical raw data variables get converted to floats when parsed. In the future, I might instead have them converted to longs, but for now be aware that floating-point imprecision may be a factor in the values you set.

[C++ References]

C++ references are, unsurprisingly, references to variables in the engine's code. They are surrounded by square brackets. The actual references are defined in a map in t_interpreter.cpp called "cpp_definitions". The map's keys are what you would write in the GraphXTheatre file.

As an example, here's that Collider object that I showed you earlier:

Collider (Test_Collider_1)
{
    MotionType  [Dynamic]
    ObjectLayer [Moving]
    Activation  [Activate]
    Shape       [BoxShape]
}

And here's a small section of the "cpp_definitions" map:

std::map<std::string, std::any> cpp_definitions =
{
    ...
    {"Dynamic", JPH::EMotionType::Dynamic},
    {"Moving", Layers::MOVING},
    {"Activate", JPH::EActivation::Activate},
    {"BoxShape", ColliderShapes::BOX},
    ...
};

As you can see, the reference variables are the map's string keys, and the actual C++ variables being referenced are the values. This means that to add new definitions, all you have to do is add another pair to this map. This also means that you can account for different spellings/capitalizations or even give a single reference multiple names by doing something like this:

std::map<std::string, std::any> cpp_definitions =
{
    ...
    {"Dynamic", JPH::EMotionType::Dynamic},
    {"dynamic", JPH::EMotionType::Dynamic},
    {"rigidbody collider", JPH::EMotionType::Dynamic},
    ...
};

<Theatre References>

Theatre reference variables are one of the two things that I am most proud of being able to create and implement correctly; Theatre references will either copy a variable from another object, or copy that object entirely. When used to copy variables, they are a great way to avoid having to manually copy and paste the same thing over and over again and are really nice for things like setting multiple objects to the same position. When used to set a variable to an entire object (which is necessary for certain variables like Mesh and Material), they act almost like a high-level version of a C++ copy constructor. Theatre reference variables are surrounded by angled brackets.

Warning

If a variable is using an object via Theatre reference (i.e: Mesh <Cube>), the referenced object needs to be above the object that's referencing it. However, if a variable is simply copying a referenced object's variable (i.e: Position <OtherActor>), this restriction doesn't apply.

Here's an example of two Actors setting their positions to another Actor's position:

Actor (Actor_1)
{
    Position <Actor_2>
}
Actor (Actor_2)
{
    Position (0.0, 1.0, 0.0)
}
Actor (Actor_3)
{
    Position <Actor_3>
}

As you can see, it doesn't matter where Actor_2 is, so both Actor_1 and Actor_3 are able to copy its position value.

Here's an example of an Actor setting its mesh variable to a previously created Mesh object:

Mesh (Mesh_1)
{
    MeshData [GRAPHX_CUBE]
}
Actor (Actor_1)
{
    Mesh <Mesh_1>
}

There's an important difference here that the warning box above talked about; when a Theatre reference is referencing an entire object and not just a variable in that object, that object must be above the one that's referencing it. In this example, Mesh_1 needs to be above Actor_1 for this Theatre reference to work.

Note

If you want to know the internal C++ code-related reason for the restrictions above: When you reference another object's variable, the interpreter can just find the referenced setting, interpret it, and copy the value. However, when referencing an entire object, there are a lot of places where returning a pointer to the referenced object would cause a lot of problems (and currently no places where that's a better solution), so I need to return a copy; instead of re-interpreting an entire object and all its settings, I just make a new one, load the referenced object's settings, as well as any additional settings if its a sandwiched variable.

<Sandwich>:[Variables]

Sandwiched variables are probably what I'm most proud of. They act as a shorthand for copying an object and changing one or more of its variables. Instead of having to copy the same Mesh object every time you want to use a different Material with it, you can just use a sandwich instead! Sandwiched variables are multiple variable names and values, all separated by colons.

Here's an example of three Actors using the same Mesh with different Materials. The first two directly change the Material using sandwiches, while the third Actor doesn't. This will result in all three Actors using the same cube Mesh, but actor_1 will be using material_1, actor_2 will be using material_2, and actor_3 will be using material_3.

Material (material_1)
{
    ...
}
Material (material_2)
{
    ...
}
Material (material_3)
{
    ...
}
Mesh (cube_mesh)
{
    MeshData [GRAPHX_CUBE]
    Material <material_3>
}
Actor (actor_1)
{
    Mesh:Material <cube_mesh>:<material_1>
}
Actor (actor_2)
{
    Mesh:Material <cube_mesh>:<material_2>
}
Actor (actor_3)
{
    Mesh <cube_mesh>
}

Important

Sandwiches can be any length, and work with any type of variable! However, the first variable (internally called the "Sandwich Bun") must be a Theatre reference; sandwiches really are just a fancy way to make copies of pre-existing objects.

Variable Names

The way Actors and Devices interact with their "settings" (the data from GraphXTheatre files) is what dictates variable names, and is a system I am rather proud of. Instead of trying to make sure the interpreter knows every single variable name and exactly what variable in each class that name corresponds to (which would be fucking awful), the interpreter just doesn't give a fuck! Instead, every class derived from Actor or Device is responsible for their own variables. Thanks to a template function called getSetting, this process is not only simple, but also allows for overloading variable names (meaning, you can check for and "enable" multiple variable names for the same C++ variable). Let's start by talking about what happens when an Actor gets a callback.

Actor::youGotACallBack & Device::loadSettings

Actor::youGotACallBack and Device::loadSettings are effectively the same function (but Actor gets to be quirky because I think its funny). They each take a single gSettings argument, called new_settings. Actors and Devices both have an internal gSettings variable called settings, and when these functions are called, both settings and new_settings are checked to see if they are empty (using a global gSettings variable named empty_settings). If settings is empty, it is replaced with new_settings. This check is only done by the base classes Actor and Device, which means that all derived classes that override youGotACallBack/loadSettings must call the base class version of that function before anything else.

Note

This note is about Actor::youGotACallBack/Device::loadSettings, for anyone who wants to tinker with the code. Due to some spaghetti code, late nights of work, and ADHD, these functions load settings from settings, not new_settings; this means that if settings is not empty, and you attempt to load extra settings by passing some to one of these functions, those new settings won't get used at all. In a future update, I intend to fix this, but for now, if you use the actual codebase, be warned that trying to load some temporary extra settings will (probably) not work.

As an example, here's a comparison of Device::loadSettings and Mesh::loadSettings:

void Device::loadSettings(graphx::gSettings new_settings)
{
        if(settings.contains(empty_settings_identifier))
                settings = new_settings;
        if(new_settings.contains(empty_settings_identifier))
                new_settings = settings;

        getSetting(name, new_settings["Name"]);
}
void Mesh::loadSettings(graphx::gSettings new_settings)
{
        Device::loadSettings(new_settings);

        getSetting(material, settings["Material"]);
        getSetting(mesh_data, settings["MeshData"]);

        processMeshData();

        if(vao_index == VAO_OBJ)
                mesh_scale *= PREEMPTIVE_OBJ_SCALE;
}

Keep in mind that if you make a class that derives from another derived class, you only need to call the function of the immediate parent, not the base class. As an example, the class Sprite derives from Mesh instead of Device, and this is what Sprite::loadSettings looks like:

void Sprite::loadSettings(graphx::gSettings new_settings)
{
	Mesh::loadSettings(new_settings);
}

Warning

GraphX uses a unique gSettings variable called empty_settings as the default for all gSettings variables. empty_settings contains only a single key-value pair, which uses a global string variable called empty_settings_identifier for its key. empty_settings_identifier is equal to the string "FUCKYOU". This allows for a quick way to check if a gSettings variable is unset using a simple if statement. However, if you, for whatever reason, decide to use the variable name FUCKYOU in a GraphXTheatre file, the specific class that uses that variable name will not load any of the settings you give it and will keep all its default C++ values.

Quick Aside: Square Brackets, Maps, and Good Bad Programming

The basic idea behind the getSetting function is something I'm quite happy with. Basically, the interpreter doesn't care what the variable names are in a GraphXTheatre file; it just fills up an unordered map with every pair its given and sends that map to a newly created Actor/Device. It's up to that specific subclass of Actor or Device to account for any valid variable names you want it to be able to take. Any invalid names (like typos) won't cause problems, because they simply aren't asked for. The real star of the show is actually something that I usually try to avoid: using square brackets to look for and access map pairs. If you try to search a key that doesn't exist in a certain map while using square brackets, the square brackets will create a new pair using the key you supplied and an empty value. Here's an example:

#include <map>
#include <iostream>
std::map<int, int> integer_map =
{
    {0, 1},
    {1, 20}
};

int main()
{
    std::cout << integer_map[0] << std::endl; // result: '1'
    std::cout << integer_map[1] << std::endl; // result: '20'
    std::cout << integer_map[2] << std::endl; // result: '0' ({2, 0} was just added to integer_map!)
};

While this would usually be something to avoid, it's the main reason why defining a new variable string and accessing it from new_settings are done in the same function call! For example, if you wanted to make sure that both position and Position are valid ways of setting the position_global variable in Actor, you could do something like this:

void Actor::youGotACallBack(gSettings new_settings)
{
...
    getSetting(position_global, settings["Position"]);
    getSetting(position_global, settings["position"]);
...
}

Doing this won't cause position_global to be reset if position is empty, because getSetting will exit if the setting it's given is empty. This also means you can enable hidden variable names and even "autocorrect" typos (as long as the typo is exactly the same every single time...).

Clone this wiki locally