#Arcade shooter written in C
23 messages ยท Page 1 of 1 (latest)
Today I focused on making the level itself configurable through JSON files. It's all based on the scene graph. Very similar to Unity, entities in the game have components attached (just like MonoBehaviours) that define how the entity is rendered and behaving.
Since those components can be (in great parts at least) described using serializable values, I am moving now my C code into a JSON configuration file. The advantage is, that the file can be reloaded while the game is playing, enabling quicker iteration times on the gameplay behavior.
Let's go through this a little:
- The enemy planes are spawned through a list of events:
- The enemy plane configuration defines how they react on hits and destruction and how much health they start out with
Here's the event list:
"events": [
{ "time": 0, "message": "and so it begins", "prefab": "enemy-plane-flyin-1", "position": [9,-35,-6] },
{ "time": 0, "prefab": "enemy-plane-flyin-1", "position": [7,-35,-7.5] },
{ "time": 7, "prefab": "enemy-plane-1", "position": [4,0,12.5], "rotation": [0, 180, 0] },
{ "time": 7, "prefab": "enemy-plane-1", "position": [2.5,0,13.5], "rotation": [0, 180, 0] }
],
It's based on time; the messages are right now just for debugging. The prefab name is a reference into an objects dictionary. Base parameters that are defined in the prefab like position and rotation can be overriden here as well.
The "enemy-plane-flyin-1" prefab is simple and looks like this:
"enemy-plane-flyin-1": {
"components": [
{ "type": "MeshRendererComponent", "mesh": "fighter-plane-2", "litAmount": 0 },
{ "type": "LinearVelocityComponent", "velocity": [0,0,5] },
{ "type": "AutoDestroyComponent", "lifeTime": 7 }
]
},
It's creating a scene object and attaches the components you're seeing here. As this is only displayed on the backgrounds, it's just a velocity and mesh renderer component.
The actual enemy that can be shot are similar but more complex since they define some more components:
"enemy-plane-1": {
"components": [
{ "type": "MeshRendererComponent", "mesh": "fighter-plane-2", "litAmount": 0 },
{ "type": "LinearVelocityComponent", "velocity": [0,0,-5] },
{ "type": "TargetHandlerComponent", "radius": 1.5, "colliderMask": 1,
"onHit": [{
"prefab": "spark",
"spawnCount": 12,
"bulletVelocityFactor": [-0.25, -0.5],
"minRandomVelocityRange": [-2, -2, -2],
"maxRandomVelocityRange": [2, 2, 2]
}],
"onDestroy": [{
"prefab": "spark" ,
"spawnCount": 240,
"minRandomVelocityRange": [-4, -4, -4],
"maxRandomVelocityRange": [4, 4, 4]
}]
},
{ "type": "HealthComponent", "maxHealth": 3, "health": 3 },
{ "type": "AutoDestroyComponent", "lifeTime": 7 }
]
},
new components are the TargetHandlerComponent and the HealthComponent. The target handler reacts on hits by bullets and spawns respective effects - which are similar prefabs as well.
It's a little difficult to figure out how to do this using no scripting language, but in principle, this can handle quite a lot of situations using just this JSON data structure.
The C code is relatively simple as well - here's the AutoDestroyComponent for example:
The registration function creates the type in the scene graph. The scene graph manages the memory allocations (which are one continuous array). I am not using pointers for linking the different components.
void AutoDestroyComponentRegister()
{
psg.autoDestroyComponentId = SceneGraph_registerComponentType(psg.sceneGraph, "AutoDestroyComponent", sizeof(AutoDestroyComponent),
(SceneComponentTypeMethods) {
.updateTick = AutoDestroyComponentUpdate,
.onInitialize = AutoDestroyComponentInitialize,
});
}
The update callback is simple as well and shows what this means:
static void AutoDestroyComponentUpdate(SceneObject* node, SceneComponentId sceneComponentId, float dt, void* component)
{
AutoDestroyComponent* autoDestroy = (AutoDestroyComponent*)component;
autoDestroy->lifeTimeLeft -= psg.deltaTime;
if (autoDestroy->lifeTimeLeft <= 0) {
SceneGraph_destroyObject(psg.sceneGraph, node->id);
}
}
Whenever accessing foreign elements, an id is used to resolve it into a pointer (which needs to be checked if it's null, in case the object isn't found).
Let me know if you are interested in learning more about how this all works - I am never sure if this is of interest for anyone ๐ - even though I find it super interesting, especially since this is fairly simple C code.
Liking the look of this, nice style and colours, please keep us updated.
this looks pretty amazing man
Thanks ๐ - I am crawling only slowly forward - I added a new blog post yesterday: https://quakatoo.com/logs/2024-04-01/index.html
TL;DR: haven't added much, still trying to find / improve the style, using a custom font I made and since the last update here, I dropped the JSON approach because it produced too much code. Instead I have worked out a hot reloading approach that loads / recompiles code into a DLL.
Small update! I am still working on this, though moving to more editor stuff. I originally wanted to make a 3d editor powered game engine tool, but my choice of Lua for everything proved problematic when porting it to WebGL where LuaJIT isn't available.
What I need for an the engine I want:
- Serializable data structs
- Asset system
- Inspector tool to edit game data
When I moved to full C (no C++), I originally struggled a lot with how to approach the serialization and inspector system. Typically this needs reflection and that's not available in C.
Last week I stumbled over an idea that is working quite well and seems (still can't fully believe it) to solve the serialization issue by using macros in a clever way. It's not beautiful, but it allows creating structs that can be serialized and edited WITHOUT having to write anything else than the struct's declaration.
Here's an example of how it works with a simple example:
SERIALIZABLE_STRUCT_START(MeshRendererComponent)
SERIALIZABLE_ANNOTATION(Range, Vector2, (&(Vector2){0.0f, 1.0f}))
SERIALIZABLE_FIELD(float, litAmount)
NONSERIALIZED_FIELD(Material*, material)
NONSERIALIZED_FIELD(Mesh*, mesh)
SERIALIZABLE_STRUCT_END(MeshRendererComponent)
The macros declare how the struct is set up. Additionally, it provides an annotation for the editor (working on it right now, but looks very promising).
The trick here is, that I include this snipped via #include in a list of all my structs that are serializable / editor-enabled. That list is then included (sometimes several times) in different parts of the source code.
Let's say, I want to produce a list of all the structs I have, then I'd write:
const char* structNameList = {
#define SERIALIZABLE_STRUCT_START(name) #name,
#include "shared/serialization/serializable_file_headers.h"
}
For convenience, the serializable_file_headers.h file defines all missing macros and undefines them after inclusion. This way I have some sort of a reflection system by producing all the code in the places I need. For instance, this is how a few of the JSON serializer functions I generate look like:
#define SERIALIZABLE_STRUCT_START(name) \
void SerializeData_##name(const char *key, name* data, cJSON *obj, void *userData) {\
cJSON *element __attribute__((unused)) = obj == NULL \
? cJSON_CreateObject() \
: (key == NULL ? obj : cJSON_AddObjectToObject(obj, key));
#define SERIALIZABLE_FIELD(type, name) SerializeData_##type(#name, &data->name, element, userData);
#define SERIALIZABLE_FIXED_ARRAY(type, name, count) \
cJSON *array##name = cJSON_AddArrayToObject(element, #name); \
for (int i = 0; i < count; i++) { \
SerializeData_##type(NULL, &data->name[i], array##name, userData); \
}
#define SERIALIZABLE_CSTR(name) cJSON_AddStringToObject(element, #name, data->name);
As can be seen, the macros produce the code to create a set of functions that use the struct names as suffixes. It always forward the serialization calls to SerializeData_<TYPENAME>. In order to serialize now the struct data, I have to declare the basic data types like this:
void SerializeData_bool(const char *key, bool* data, cJSON *obj, void *userData) {
cJSON_AddBoolToObject(obj, key, *data);
}
void SerializeData_float(const char *key, float* data, cJSON *obj, void *userData) {
cJSON_AddNumberToObject(obj, key, *data);
}
void SerializeData_uint32_t(const char *key, uint32_t *data, cJSON *obj, void *userData) {
cJSON_AddNumberToObject(obj, key, *data);
}
The code for the editor gui looks quite similar:
void DrawSerializeData_uint8_t(const char* key, uint8_t* data, GUIDrawState* userData)
{
int v = *data;
DrawSerializeData_int(key, &v, userData);
*data = (uint8_t)v;
}
The annotation system exists for the editor system and it's a little more complex, but here's a function where I am using it:
void DrawSerializeData_int(const char* key, int* data, GUIDrawState* state)
{
int min = INT_MIN;
int max = INT_MAX;
int showSlider = 0;
int showNumber = 1;
Vector2 range;
if (GUIDrawState_getAnnotation(state, "Range", "Vector2", sizeof(Vector2), &range)) {
min = (int)range.x;
max = (int)range.y;
showSlider = 1;
}
DrawSerializeData_rangedInt(key, data, state, min, max, showSlider, showNumber);
}
... where the annotation macro pushes values to a stack of active annotations that can be queried in the renderers.
This is quite advanced C stuff, I find this quite magical and feel annoyed I didn't discover this earlier (I started coding in C in ~2001). One reason I blame is that macros are often blamed to be awful. "Don't use macros" is something you can encounter often. And it's true: Macros can entangle source code in terrible ways, making IDEs helpless when it comes to auto completion and debugging.
As for autocompletion: Surprisingly, this system actually works (in VS Code) ๐
. Debugging is as bad as it can get - but that's the price to pay for some of the cooler features:
- no additional system like protobuf / messagepack / etc. necessary. It's all just a a bunch of C files that compile out of the box
- Surprisingly flexible: Macros can be adapted to the individual places as shown above. No need to iterate over data structures at runtime - the compiler does it (though it needs some rethinking of patterns)
I didn't think I could come up with an annotation system that isn't a complete mess, but the solution I found today looks actually fairly useable. Maybe you find use cases in your code as well!
Still need to battle test all this, but I am super happy with the progress I've made this week.
Oh, and because I am masochistic, I am working on my own imgui implementation - the reason being that the UIs become a bit complex and I also want to have something that works in games... so skinning is important for me. Neither raygui or dear imgui satisfy these needs :/. Though I am taking a few looks at raygui for some parts ๐ - so thank you @bitter tinsel for your work, it's a joy to work with your lib.
thanks! glad you like it! ๐
The tilt on the plane is fire ๐ฅ nice clouds also. amazing game
quite interesting stuff you are describing. Do you know approaches which achieve the same goal but in cpp?
I don't have cpp examples. In my experience most game engines are cpp based, which is in part my motivation to try out doing similar things in pure c.
yes, I understand, my point was that maybe this approach will be useful even in cpp)
Ah. I guess the macros would work in cpp as well, at least I don't see a reason why not. But I would guess there are other solutions in cpp to achieve the same? How does serialization work in c++ land?
Ok, my 2 minute research lead me to a stack overflow that there's not such a thing... I'm surprised. How do engines handle this?
Interesting. I guess you could try this macro approach out?
I don't know though which way to do it. You could macrofiy the entire class so it can be parsed similarly to my approach, or you have a file that describes only the fields of the class that you want to serialize?
And sorry if my first answer felt rough, I'm not a native English speaker.
Is this on gh? looks really interesting