-
Notifications
You must be signed in to change notification settings - Fork 62
Issues with the current design
The current design REGoth is built after is vastly different from how the original game was built. Over the years it grew much larger than I thought, so with most building blocks in place and secrets of the engine demystified, we could go on to refactor a large part of the internal systems.
This is just a proposal, take everything here with a grain of salt. If you have other ideas, feel free to add them!
The Entity-Component-System is nearly unused. Most logic happens inside the
LogicController
. Raw entites are just used for rendering. Furthermore, the
original engine and most (every?) bigger engines we have today use some kind of
tree-structure to handle Entites and Sub-Entites ("attachments").
This has become one complete mess and we should do something about it.
Instead of doing logic and rendering on the same entities, I propose to decouple the logic-part into its own module. This module would then expose an interface, which allows the outside world to interact with it. Such interactions would be "pick up item X", "attack forward" or "start moving forward". With a clear interface description, we would not only make the code cleaner, easier to work with and should open up the possiblility to unit test the logic system. Additionally, Multiplayer should be much easier to implement.
The reason for the ECS (entity-component-system) as it is today was rendering and update-performance. While I don't see the second one to be an issue, rendering-performance is still important. With an Ogre-style approach, we should be able to have quick rendering, decoupled from the logic-system: In Ogre, the rendering-part of the engine has its own world, where it stores all visual representations of the entites in the main world.
At the logic-side, entites can request that such a rendering-entity is created, moved to a specific location, etc. The rendering-module then doesn't need anything more from the logic-side and can just draw everything however we want. In a multiplayer environment we can then just replicate the renderer-state to the clients while collecting their inputs to update the server-state.
On the logic side of things, we wouldn't need an ECS like it currently implemented anymore. We could go for a similar approach like the original game did (full-on OOP) or a relaxed ECS like Unity does (attach "scripts" to entites and call specific functions on that).
The logic-module has to do the following:
- Load the world from a file and hold a representation of all entities inside
- Handle state changes and checks within entities (
Update
-method) - Handle interactions between entities
- Run physics simulation
There are multiple ways of implementing such a representation of the world. We have to discuss on which one we settle. One large aspect which has to be decided early is whether a scripting language shall be used for implementing the game-logic.
In the original games, there exists a world-class zCWorld
, which holds a list of entites (so called "vobs").
This is pure OOP, since all entities share a base class, zCVob
, which implements some of the basic entity
functionality and of which more specialized classes can be derived from.
Example:
zCVob
- zCNPC
- oCNPC
- zCLight
- zCPointLight
- oCMob
- oCMobDoor
...
Within zCVob
, there is then another list of entities, which is how one describes a tree-structure with transforms
building upon each other. (attachments)
This is a design which is easy to implement and which would be closest to the original. However, there are some issues
with this: This design is very cache-unfriendly the way gothic implements it. Each entity is allocated
on its own using new
since they can only store a pointer to it in the world which needs to be of the base-type.
Though, the performance impact should not be too noticable for us because of the low object count.
One could come up with a better allocator strategy like using different buckets for each class-type.
A more serious issue is the bad seperation of concerns. Looking at an ECS, you can split up and reuse functionality between different kind of entities.
The entitiy-tree could also be described of minimalist entities, which do nothing more than basic object functionality. Then, each entity has a list of components which can be attached to them. A "component" is a script which can execute it's own code. Right now, components in REGoth are really dumb, as they only store data with little functionality.
Components can be written in OOP-style again, the key is, that we can attach multiple of those to a single entity.
Which ever design we chose, these kinds of objects need to be thought of (please extend, I probably forgot some):
- Static-Meshes (world-decoration)
- The World-Mesh
- Morph-Meshes (ie. flag at the pirates hideout)
- NPCs/Monsters (same class in the original, is that a good idea?)
- Interactable Objects (Mobs)
- Chests
- Doors
- Levers
- Winches
- Beds
- Shrines
- Pans
- Ovens
- Items (laying in the world, ready to be picked up)
- Particle-Effects (ambient and one-shot-effects)
- Lights (Gothic only has Point-Lights)
Regardless of how the internals of the logic-module look, there needs to be an interface to the outside world. The interface should be as basic as possible. The outside world should not be able to create arbitrary objects without a proper reason.
Scripts should also only ever use this interface!
This is a draft, feel free to add functionality which is not already here! Everything is pseudo-code.
enum MovementError
{
WaypointDoesNotExist,
FreepointDoesNotExist,
PathBlocked,
PositionNotOnWorld,
}
Contains the errors for the upcomming section.
fn move_to_position(npc: NPCHandle, position: Vector3) -> Result<(), MovementError>;
Requests a move to an arbitrary position for the given NPC. Returns an error if the given position can not be reached.
Pushed into the AI-Queue!
fn move_to_waypoint(npc: NPCHandle, waypoint: String) -> Result<(), MovementError>;
Requests a move to the given waypoint for the given NPC. Returns an error if the given position can not be reached or the waypoint does not exist.
Pushed into the AI-Queue!
fn move_to_freepoint(npc: NPCHandle, freepoint: String) -> Result<(), MovementError>;
Requests a move to the given freepoint for the given NPC. Returns an error if the given position can not be reached or the freepoint does not exist.
Pushed into the AI-Queue!
enum InteractionError
{
PathBlocked,
ObjectInUse,
NotInteractingWithAnything,
}
Contains the errors for the upcomming section.
fn start_interaction(npc: NPCHandle, mob: MobHandle) -> Result<(), InteractionError>;
Requests that the given NPC should now go towards a Mob and starts interacting with it. Can issue a move-command if needed. Returns an error if the object cannot be reached or is already in use.
Pushed into the AI-Queue!
fn stop_interaction(npc: NPCHandle) -> Result<(), InteractionError>;
Requests that the given NPC should now stop interacting with the mob it is interacting with. If the NPC is currently not interacting with anything, an error is returned.
Pushed into the AI-Queue!
fn interact_direction_forward(npc: NPCHandle) -> Result<(), InteractionError>;
fn interact_direction_backward(npc: NPCHandle) -> Result<(), InteractionError>;
fn interact_direction_left(npc: NPCHandle) -> Result<(), InteractionError>;
fn interact_direction_right(npc: NPCHandle) -> Result<(), InteractionError>;
Requests that the given NPC should do an interaction into the given direction.
If the NPC is currently not interacting with anything or the direction is invalid for the given object, an error is returned.
Pushed into the AI-Queue!
fn interact_next(npc: NPCHandle) -> Result<(), InteractionError>;
Like the interact_direction_*
-commands, but always goes to the next reasonable state (like opening a chest).
If the NPC is currently not interacting with anything or the direction is invalid for the given object, an error is returned.
Pushed into the AI-Queue!
fn input_move_forward(npc: NPCHandle) -> Result<(), MovementError>;
fn input_move_backward(npc: NPCHandle) -> Result<(), MovementError>;
fn input_move_strafe_left(npc: NPCHandle) -> Result<(), MovementError>;
fn input_move_strafe_right(npc: NPCHandle) -> Result<(), MovementError>;
fn input_move_turn_left(npc: NPCHandle) -> Result<(), MovementError>;
fn input_move_turn_right(npc: NPCHandle) -> Result<(), MovementError>;
fn input_move_stop(npc: NPCHandle) -> Result<(), MovementError>;
Lets the given NPC walk into the given direction. Shall return an error if the AI-Queue is not empty at the moment.
The NPC should run until a input_move_stop
is issued.
This is meant for player-movement!