-
Notifications
You must be signed in to change notification settings - Fork 0
GraphXTheatre File Format
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 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.
This section will go into detail about the dos, donts, cans, and cants when making a Theatre. First thing's first:
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.
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.
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 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 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 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. Theatre reference variables are surrounded by angled brackets.
Warning
If you're using a Theatre reference to copy a variable from another object (i.e: Position <OtherActor>), you can reference any object that will exist in the Theatre. However, if you're referencing the object itself (i.e: Mesh <Cube>), then it must be above the object that's referencing it. For example, Mesh <Cube> will only work if Cube is above the object referencing it.
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.
Sandwich references are probably what I'm most proud of. They are a way to both reference a previously created object and change one or more of its variables without having to manually re-create that object. They are multiple variable names and Theatre references, 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 sandwich references, 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 variables you can change using sandwiches aren't dynamic. They need to be directly implemented (for now). This means that, yes, Mesh:MeshData:Material <Cube>:[GRAPHX_CUBE]:<Material_1> is valid, but only because I've given Actor::youGotACallBack the ability to change a Mesh's MeshData.
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 template functions, this process is not only simple, but also allows for what's essentially like function overloading for but for variable names. Let's start by talking about what happens when an Actor gets a callback.
Actor::youGotACallBack and Device::loadSettings are functions that get called by Theatre::createActor and Theatre::createDevice respectively. They each take a single gSettings argument, called new_settings. Actors and Devices both have an internal gSettings variable called settings; 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, and vice-versa. 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. 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;
setRawData(name, new_settings["Name"]);
}
void Mesh::loadSettings(graphx::gSettings new_settings)
{
Device::loadSettings(new_settings);
gMeshData mesh_data = gMeshData(ERROR_VERTS, ERROR_INDICES, VAO_HANDMADE);
setDevicePointer(material, new_settings["Material"]);
setVariable(mesh_data, new_settings["MeshData"]);
vertices = std::get<0>(mesh_data);
indices = std::get<1>(mesh_data);
vao_index = std::get<2>(mesh_data);
}Keep in mind that if you make a class that derives a 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. This may result in a crash for certain Actors/Devices who set certain pointer variables to nullptr by default.
At the time of writing, I'm working on a way to combine these three template functions into one without sacrificing the convenience they provide, so this section will change soon. The basic idea behind these functions, however, 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!