How to setup:
$ ./scripts/setup.sh
How to run:
$ python3 simulate.py
This simulation framework features an Entity Component System Framework similar to Specs. We have a nice demo of Bouncy World and you can checkout the output video. In general, ECS allows much nicer design of simulation programs and ensures maintainability, robustness and extensibility.
We divide the whole frameworks to several parts:
In ECS, a Component represents a set of data. In traditional Object Oriented Programming, people needs to put all the fields together. But in ECS, we tend to separate data as much as possible to avoid overhead. For example, a Star might contain Age information, Life information and Position. But their usages are mostly disjoint. So we separate them into three components. As an example:
class SunlikeStarPosition(Component):
def __init__(self, position: Vector3):
self.position = position
Component classes should be inheriting Component
. At the same time, a component should contain only the essential data. No action is required to be associated. (Note: This is also called Data Oriented Program Design, as opposed to Object Oriented).
Another big thing is the System
. System is required to read and mutate the world state. Similar to Component
, we should also separate as much as possible for debuggable, maintainable and extensible simulation. As an example, the star aging system and star movement system can be separated. As the name suggests, a star aging system should only rely on reading and mutating the age component.
class SunlikeStarAgingSystem(System):
DATA = [SunlikeStarAge]
def run(self, ages):
for (entity, age) in ages:
age.step()
In this example we are creating a SunlikeStarAgingSystem
. The first thing to notice is that it inherits System
, which is required for all systems. It has to have a static DATA
field, which is an array or a tuple of Component
classes. In this case, since the system only needs the age component, we declare that we need SunlikeStarAge
.
Every System
also requires a run
method to be implemented. In addition to self
, we will receive the component stores through function arguments. In this case, since we only declare that we need SunlikeStarAge
, then we will only receive a single store, in this case, ages
. If we declare that we want multiple stores, we simply extend the DATA
list. All the stores will be received in the order they appear in DATA
.
It's very natural to want to iterate through all the SunlikeStarAge
components, since for each of them we want the age to step once ahead. So we start by using a for loop. Note that when iterating through a store, or a conjunction of stores, you will always receive an entity
which is a unique id to represent the entity that contains this component. Currently we don't need it. The second element in the tuple will be our age
component. Finally, we just use the step
function implemented in SunlikeStarAge
to advance the age one step forward.
In the above example we covered the iteration process over the component storage. We also have several more use cases:
Let's say you already have the entity id and you want to add a new component to it. You can do the following
class SomeSystem(System):
DATA = [SomeComponent]
def run(self, some_components):
entity_id = ...
some_components.insert(entity_id, SomeComponent())
Similar to above, when you want to remove SomeComponent
from the entity with entity_id
class SomeSystem(System):
DATA = [SomeComponent]
def run(self, some_components):
entity_id = ...
some_components.remove(entity_id)
Creating a new Entity simply means letting the world allocate you a new Entity Id. You can then add a component to that existing entity or do anything else you want. To get a new Entity Id, you can
class SomeSystem(System):
DATA = [...]
def run(self, ...):
new_entity_id = self.world.create_entity()
Removing an entity requires the possession of the entity id. This will remove all the component related to that Entity as well.
class SomeSystem(System):
DATA = [...]
def run(self, ...):
entity_id = ...
self.world.remove_entity(entity_id)
class SomeSystem(System):
DATA = [SomeComponent, ...]
def run(self, some_components, ...):
entity_id = ...
comp = some_components[entity_id]
Sometimes you might want to find all the entities that have two or more components. In that case you want to use Storage.join
to join two or more components. Note that the yield result will also contain the entity id in the first place. And all the rest of the components will remain the order you put into Storage.join
.
class SomeSystem(System):
DATA = [Comp1, Comp2, ...]
def run(self, comp_1s, comp_2s, ...):
for (ent, comp_1, comp_2, ...) in Storage.join(comp_1s, comp_2s, ...):
# Do other things
For example you have a function called test_storage_2
, you can invoke the test using
$ python3 test.py test_storage_2
When writing a new test, you can go to anywhere and add a decorator @test
. For example, you can create a new function in tests/test_storage.py
and type
@test
def hello_world_test():
print("Hello world")
Then in command line you can invoke this test using
$ python3 test.py hello_world_test