While trying to write a game engine, I came up with this ECS / Component Based Architecture for its physics and drawing logic. I want to get some feedback on its architecture. I am using reflection inside the querying code and to get a class's superclass. Is this bad design in this case?
Note that I left out most documentation as most of the code should be self explanatory.
My solution is not strictly an ECS as Entities are not just pointers to blocks of components (in this version I added a name
field just to clarify that). I decided to go that route as Component Arrays would not be stored contiguously anyways, which is a limitation imposed by Java (Looking forward to Project Valhalla).
I took inspiration from Unity's GameObjects, difference being that you have to pass a Class as an argument instead of giving the type as a type parameter (again, this is a limitation imposed by Java). In the Scene class I have a method called forEach(...)
which gets a type of a query (just a record) and a function consuming the query and its matching GameObject as parameters.
In the example I use Position
and Velocity
to form a very basic Physics System operating on them (via PhysicsQuery
). The system simply prints the matching GameObject and updates its position afterwards. In this example you can also see that inheritance of components works (PhysicsQuery
queries Position
but also matches with GameObjects having a Position3D
component).
Example.java
public class Example {
public static void main(String[] args) {
Scene scene = new Scene();
scene.addObject(new GameObject("Object A",
new Position3D(0, 0, 0), new Velocity(1, 0)));
scene.addObject(
new GameObject("Object B", new Position(0, 1)));
record PhysicsQuery(Position position,
Velocity velocity) {
}
for (int i = 0; i < 10; ++i)
scene.forEach(PhysicsQuery.class,
(query, gameObject) -> {
System.out.println(gameObject);
query.position.x += query.velocity.x();
query.position.y += query.velocity.y();
});
}
}
class Position {
public float x;
public float y;
public Position(float x, float y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "Position{" + "x=" + x + ", y=" + y + "}";
}
}
class Position3D extends Position {
public float z;
public Position3D(float x, float y, float z) {
super(x, y);
this.z = z;
}
@Override
public String toString() {
return "Position3D{" + "x=" + x + ", y=" + y + ", z="
+ z + "}";
}
}
record Velocity(float x, float y) {
}
Scene.java
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
public class Scene {
public List<GameObject> getGameObjects() {
return gameObjects;
}
private static <T extends Record> Function<Object[], T> getRecordConstructor(
Class<T> type) {
var recordComponents = type.getRecordComponents();
try {
Constructor<T> constructor = type
.getDeclaredConstructor(
Arrays.stream(recordComponents)
.map(RecordComponent::getType)
.toArray(Class<?>[]::new));
constructor.setAccessible(true);
return (arguments) -> {
try {
return constructor.newInstance(arguments);
} catch (IllegalAccessException
| InvocationTargetException
| InstantiationException e) {
throw new RuntimeException(
"Could not construct record", e);
}
};
} catch (NoSuchMethodException e) {
throw new RuntimeException(
"Canonical constructor not found", e);
}
}
private final List<GameObject> gameObjects = new ArrayList<>();
public <T extends GameObject> T addObject(T gameObject) {
gameObjects.add(gameObject);
return gameObject;
}
public <T extends GameObject> boolean removeObject(
T gameObject) {
return gameObjects.remove(gameObject);
}
/**
* Call a function on all objects that have all components
* specified by {@code type}'s fields
*
* @param type
* Type of a query
* @param consumer
* Called on matching Objects
*/
public <T extends Record> void forEach(Class<T> type,
BiConsumer<T, GameObject> consumer) {
var constructor = getRecordConstructor(type);
for (var gameObject : gameObjects) {
T query = gameObject.toQuery(type, constructor);
if (query != null)
consumer.accept(query, gameObject);
}
}
}
GameObject.java
import java.util.*;
import java.util.function.Function;
public class GameObject {
private final Map<Class<?>, Deque<Object>> components = new HashMap<>();
private String name;
public GameObject(String name) {
this.name = name;
}
public GameObject(String name, Object... components) {
this.name = name;
addComponents(components);
}
private void addComponent(Class<?> type,
Object component) {
components
.computeIfAbsent(type, k -> new ArrayDeque<>())
.add(component);
}
private boolean removeComponent(Class<?> type,
Object component) {
var componentsOfType = components.get(type);
if (componentsOfType == null)
return false;
boolean result = componentsOfType.remove(component);
if (componentsOfType.size() == 0) {
components.remove(type);
}
return result;
}
private boolean isUserDefined(Class<?> type) {
return type != Object.class && type != Record.class
&& type != Enum.class;
}
public GameObject addComponent(Object component) {
Class<?> type = component.getClass();
do {
addComponent(type, component);
type = type.getSuperclass();
} while (type != null && isUserDefined(type));
return this;
}
public GameObject addComponents(Object... components) {
for (var component : components) {
addComponent(component);
}
return this;
}
public <T> T removeComponent(T component) {
Class<?> type = component.getClass();
do {
removeComponent(type, component);
type = type.getSuperclass();
} while (type != null && isUserDefined(type));
return component;
}
public <T> T getComponent(Class<T> type) {
var componentsOfType = components.get(type);
if (componentsOfType == null)
return null;
return type.cast(componentsOfType.peekFirst());
}
@SuppressWarnings("unchecked")
public <T> Collection<T> getComponents(Class<T> type) {
return (Collection<T>) components.get(type);
}
/**
* Process this object to a query of components.
*
* @param query
* Class of a record containing the wanted
* components
* @param generator
* a function that gets all the components in the
* order defined by the {@code query} and results
* in an instance of {@code query}
* @return an instance of the {@code query} iff this
* object has all components wanted by it, null
* otherwise
*/
public <T extends Record> T toQuery(Class<T> query,
Function<Object[], T> generator) {
var recordComponents = query.getRecordComponents();
var components = new Object[recordComponents.length];
for (int i = 0,
length = recordComponents.length; i < length; ++i) {
var componentType = recordComponents[i].getType();
var component = getComponent(componentType);
if (component == null)
return null; // component not found -> return null
components[i] = component;
}
// All components were found -> call generator with them
return generator.apply(components);
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "GameObject{" + "name=" + "'" + name + "'"
+ ", components=" + components + "}";
}
}