1
\$\begingroup\$

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 + "}";
    }
}

\$\endgroup\$
1
  • \$\begingroup\$ While I can't give you any feedback directly, why don't you try to look at the source of Ashley? It's an existing ECS used by libGDX, maybe it'll give you some more ideas or insights for your project. \$\endgroup\$ Jun 22, 2022 at 16:35

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.