summaryrefslogtreecommitdiff
path: root/core/src/main/java/coffee/liz/ecs
diff options
context:
space:
mode:
Diffstat (limited to 'core/src/main/java/coffee/liz/ecs')
-rw-r--r--core/src/main/java/coffee/liz/ecs/DAGWorld.java207
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Component.java8
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Entity.java105
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Query.java30
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java25
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/System.java32
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/World.java64
-rw-r--r--core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java28
8 files changed, 499 insertions, 0 deletions
diff --git a/core/src/main/java/coffee/liz/ecs/DAGWorld.java b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
new file mode 100644
index 0000000..1075e5e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
@@ -0,0 +1,207 @@
+package coffee.liz.ecs;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import coffee.liz.ecs.model.Query;
+import coffee.liz.ecs.model.System;
+import coffee.liz.ecs.model.World;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.log4j.Log4j2;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+/** World that updates in {@link System#getDependencies()} topological order. */
+@Log4j2
+@RequiredArgsConstructor
+public class DAGWorld<T> implements World<T> {
+ /** All entities in the world. */
+ protected final Set<Entity> entities = Collections.synchronizedSet(new HashSet<>());
+
+ /** Cache mapping component types to entities having that component. */
+ private final Map<Class<? extends Component>, Set<Entity>> componentCache = Collections
+ .synchronizedMap(new HashMap<>());
+
+ /** Deterministic ID's for spawned entities. */
+ private final AtomicInteger nextEntityId = new AtomicInteger(0);
+
+ /** All registered systems. */
+ protected final Map<Class<? extends System<T>>, System<T>> systems;
+
+ /** Ordered list of systems for execution. */
+ private final List<System<T>> systemExecutionOrder;
+
+ @SafeVarargs
+ public DAGWorld(final System<T>... systems) {
+ this.systems = singletonClazzMap(systems);
+ this.systemExecutionOrder = buildExecutionOrder(Arrays.asList(systems));
+ log.debug("Executing in order: {}", systemExecutionOrder);
+ }
+
+ @Override
+ public Entity createEntity() {
+ final Entity entity = Entity.builder().id(nextEntityId.incrementAndGet()).build();
+ entities.add(entity);
+ return entity;
+ }
+
+ @Override
+ public void removeEntity(final Entity entity) {
+ entity.getComponentMap().keySet().forEach(componentType -> {
+ final Set<Entity> cachedEntities = componentCache.get(componentType);
+ if (cachedEntities != null) {
+ cachedEntities.remove(entity);
+ }
+ });
+ entities.remove(entity);
+ }
+
+ @Override
+ public Set<Entity> resolve(final Query query) {
+ final Set<Class<? extends Component>> components = query.queryingComponents();
+ return switch (query.filter()) {
+ case ALL_OF -> resolveAllOf(components);
+ case ANY_OF -> resolveAnyOf(components);
+ case NONE_OF -> resolveNoneOf(components);
+ };
+ }
+
+ @Override
+ public void update(final T state, final float deltaSeconds) {
+ systemExecutionOrder.forEach(system -> {
+ refreshComponentCache();
+ system.update(this, state, deltaSeconds);
+ });
+ refreshComponentCache();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public <S extends System<T>> S getSystem(final Class<S> system) {
+ return (S) systems.get(system);
+ }
+
+ private void refreshComponentCache() {
+ componentCache.clear();
+ entities.forEach(entity -> entity.getComponentMap().keySet().forEach(
+ componentType -> componentCache.computeIfAbsent(componentType, _comp -> new HashSet<>()).add(entity)));
+ }
+
+ private Set<Entity> resolveAllOf(final Set<Class<? extends Component>> components) {
+ if (components.isEmpty()) {
+ return Set.copyOf(entities);
+ }
+
+ final Class<? extends Component> firstType = components.iterator().next();
+ final Set<Entity> candidates = componentCache.get(firstType);
+ if (candidates == null) {
+ return Collections.emptySet();
+ }
+
+ return candidates.stream().filter(entity -> components.stream().allMatch(entity::has))
+ .collect(Collectors.toSet());
+ }
+
+ private Set<Entity> resolveAnyOf(final Set<Class<? extends Component>> components) {
+ if (components.isEmpty()) {
+ return Collections.emptySet();
+ }
+
+ return entities.stream().filter(entity -> components.stream().anyMatch(entity::has))
+ .collect(Collectors.toSet());
+ }
+
+ private Set<Entity> resolveNoneOf(final Set<Class<? extends Component>> components) {
+ if (components.isEmpty()) {
+ return Set.copyOf(entities);
+ }
+
+ return entities.stream().filter(entity -> components.stream().noneMatch(entity::has))
+ .collect(Collectors.toSet());
+ }
+
+ private List<System<T>> buildExecutionOrder(final Collection<System<T>> systems) {
+ if (systems.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final Map<Class<?>, System<T>> systemMap = systems.stream()
+ .collect(Collectors.toMap(System::getClass, system -> system, (_sys, b) -> b, LinkedHashMap::new));
+ final Map<Class<?>, Integer> inDegree = new LinkedHashMap<>();
+ final Map<Class<?>, Set<Class<?>>> adjacencyList = new LinkedHashMap<>();
+
+ systems.forEach(system -> {
+ final Class<?> systemClass = system.getClass();
+ inDegree.put(systemClass, 0);
+ adjacencyList.put(systemClass, new HashSet<>());
+ });
+
+ systems.forEach(system -> {
+ system.getDependencies().forEach(dependency -> {
+ if (systemMap.containsKey(dependency)) {
+ adjacencyList.get(dependency).add(system.getClass());
+ inDegree.merge(system.getClass(), 1, Integer::sum);
+ }
+ });
+ });
+
+ // Kahn's algorithm
+ final List<System<T>> result = new ArrayList<>();
+
+ final Queue<Class<?>> queue = new LinkedList<>(
+ inDegree.entrySet().stream().filter(entry -> entry.getValue() == 0).map(Map.Entry::getKey).toList());
+
+ while (!queue.isEmpty()) {
+ final Class<?> currentClass = queue.poll();
+ result.add(systemMap.get(currentClass));
+
+ adjacencyList.getOrDefault(currentClass, Collections.emptySet()).forEach(dependent -> {
+ final int newInDegree = inDegree.get(dependent) - 1;
+ inDegree.put(dependent, newInDegree);
+ if (newInDegree == 0) {
+ queue.add(dependent);
+ }
+ });
+ }
+
+ if (result.size() != systems.size()) {
+ throw new IllegalStateException("Circular dependency detected in systems");
+ }
+
+ return Collections.unmodifiableList(result);
+ }
+
+ @Override
+ public void dispose() {
+ for (final System<T> system : systemExecutionOrder) {
+ system.dispose();
+ }
+ componentCache.clear();
+ entities.clear();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static <T> Map<Class<? extends T>, T> singletonClazzMap(final T... singletons) {
+ final boolean areSingletons = Arrays.stream(singletons).map(t -> (Class<? extends System<T>>) t.getClass())
+ .distinct().count() == singletons.length;
+ if (!areSingletons) {
+ throw new IllegalArgumentException("Only one instance may be used per clazz");
+ }
+
+ return Arrays.stream(singletons).map(t -> Map.entry((Class<? extends T>) t.getClass(), t))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/Component.java b/core/src/main/java/coffee/liz/ecs/model/Component.java
new file mode 100644
index 0000000..2d3a8e7
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/Component.java
@@ -0,0 +1,8 @@
+package coffee.liz.ecs.model;
+
+/** Component of an {@link Entity}. */
+public interface Component {
+ default Class<? extends Component> getKey() {
+ return getClass();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/Entity.java b/core/src/main/java/coffee/liz/ecs/model/Entity.java
new file mode 100644
index 0000000..7dab667
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/Entity.java
@@ -0,0 +1,105 @@
+package coffee.liz.ecs.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+@Getter
+@Builder
+@RequiredArgsConstructor
+@AllArgsConstructor
+@Data
+public class Entity {
+ /** Unique id. */
+ private final int id;
+
+ /** Instances of {@link Component}s. */
+ @Builder.Default
+ private Map<Class<? extends Component>, Component> componentMap = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * Check if entity has component type.
+ *
+ * @param componentType
+ * the {@link Component} class
+ * @return true if component exists
+ */
+ public boolean has(final Class<? extends Component> componentType) {
+ return componentMap.containsKey(componentType);
+ }
+
+ /**
+ * Check if entity has all component types.
+ *
+ * @param components
+ * collection of {@link Component} classes
+ * @return true if all components exist
+ */
+ public boolean hasAll(final Collection<Class<? extends Component>> components) {
+ return components.stream().allMatch(this::has);
+ }
+
+ /**
+ * Get component by type.
+ *
+ * @param componentType
+ * the {@link Component} class
+ * @param <C>
+ * component type
+ * @return the component or throw {@link IllegalArgumentException}
+ */
+ @SuppressWarnings("unchecked")
+ public <C extends Component> C get(final Class<C> componentType) {
+ final C component = (C) componentMap.get(componentType);
+ if (component == null) {
+ throw new IllegalArgumentException(
+ "Entity with id " + getId() + " does not have required component " + componentType.getSimpleName());
+ }
+ return component;
+ }
+
+ /**
+ * Add component to entity.
+ *
+ * @param component
+ * the {@link Component} to add
+ * @param <C>
+ * component type
+ * @return this {@link Entity} for chaining
+ */
+ public <C extends Component> Entity add(final C component) {
+ componentMap.put(component.getKey(), component);
+ return this;
+ }
+
+ /**
+ * Remove component from entity.
+ *
+ * @param componentType
+ * the {@link Component} class to remove
+ * @param <C>
+ * component type
+ * @return this {@link Entity} for chaining
+ */
+ public <C extends Component> Entity remove(final Class<C> componentType) {
+ componentMap.remove(componentType);
+ return this;
+ }
+
+ /**
+ * Get all component types.
+ *
+ * @return set of {@link Component} classes
+ */
+ public Set<Class<? extends Component>> componentTypes() {
+ return componentMap.keySet();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/Query.java b/core/src/main/java/coffee/liz/ecs/model/Query.java
new file mode 100644
index 0000000..679ea49
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/Query.java
@@ -0,0 +1,30 @@
+package coffee.liz.ecs.model;
+
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public record Query(Set<Class<? extends Component>> queryingComponents, QueryFilter filter) {
+ public Query {
+ queryingComponents = Set.copyOf(queryingComponents);
+ }
+
+ @SafeVarargs
+ public static Query allOf(final Class<? extends Component>... components) {
+ return new Query(Arrays.stream(components).collect(Collectors.toSet()), QueryFilter.ALL_OF);
+ }
+
+ @SafeVarargs
+ public static Query anyOf(final Class<? extends Component>... components) {
+ return new Query(Arrays.stream(components).collect(Collectors.toSet()), QueryFilter.ANY_OF);
+ }
+
+ @SafeVarargs
+ public static Query noneOf(final Class<? extends Component>... components) {
+ return new Query(Arrays.stream(components).collect(Collectors.toSet()), QueryFilter.NONE_OF);
+ }
+
+ public enum QueryFilter {
+ ALL_OF, ANY_OF, NONE_OF
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java b/core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java
new file mode 100644
index 0000000..eba5021
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/QueryBuilder.java
@@ -0,0 +1,25 @@
+package coffee.liz.ecs.model;
+
+import lombok.RequiredArgsConstructor;
+
+import java.util.Set;
+
+@RequiredArgsConstructor
+public class QueryBuilder<T> {
+ private final World<T> world;
+
+ @SafeVarargs
+ public final Set<Entity> allOf(final Class<? extends Component>... components) {
+ return world.resolve(Query.allOf(components));
+ }
+
+ @SafeVarargs
+ public final Set<Entity> anyOf(final Class<? extends Component>... components) {
+ return world.resolve(Query.anyOf(components));
+ }
+
+ @SafeVarargs
+ public final Set<Entity> noneOf(final Class<? extends Component>... components) {
+ return world.resolve(Query.noneOf(components));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/System.java b/core/src/main/java/coffee/liz/ecs/model/System.java
new file mode 100644
index 0000000..c00334a
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/System.java
@@ -0,0 +1,32 @@
+package coffee.liz.ecs.model;
+
+import java.time.Duration;
+import java.util.Collection;
+
+/**
+ * Updates the {@link World} state.
+ *
+ * @param <T>
+ * is the state of the stuff outside the {@link World}.
+ */
+public interface System<T> {
+ /**
+ * {@link System} clazzes that must run before this system.
+ *
+ * @return {@link Collection} of dependencies.
+ */
+ Collection<Class<? extends System<T>>> getDependencies();
+
+ /**
+ * @param world
+ * Is the {@link World}.
+ * @param state
+ * Is the {@link T} state outside the {@param world}.
+ * @param deltaSeconds
+ * Is the timestamp.
+ */
+ void update(final World<T> world, final T state, final float deltaSeconds);
+
+ default void dispose() {
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/World.java b/core/src/main/java/coffee/liz/ecs/model/World.java
new file mode 100644
index 0000000..82e01a7
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/model/World.java
@@ -0,0 +1,64 @@
+package coffee.liz.ecs.model;
+
+import java.time.Duration;
+import java.util.Set;
+
+/**
+ * The game world.
+ *
+ * @param <T>
+ * is the state of the stuff outside the world.
+ */
+public interface World<T> {
+ /**
+ * Create unique {@link Entity} in the {@link World}.
+ *
+ * @return created {@link Entity}.
+ */
+ Entity createEntity();
+
+ /**
+ * Remove an entity from the {@link World}.
+ *
+ * @param entity
+ * to remove.
+ */
+ void removeEntity(final Entity entity);
+
+ /**
+ * Get entities with {@link Component}s.
+ *
+ * @param query
+ * to query.
+ * @return All entities satisfying {@param query}.
+ */
+ Set<Entity> resolve(final Query query);
+
+ default QueryBuilder<T> queryable() {
+ return new QueryBuilder<>(this);
+ }
+
+ /**
+ * Integrate the {@link World}.
+ *
+ * @param state
+ * Is the state outside the world.
+ * @param deltaSeconds
+ * Is the time step.
+ */
+ void update(T state, float deltaSeconds);
+
+ /**
+ * Get world {@link System}.
+ *
+ * @param system
+ * is the Clazz.
+ * @param <S>
+ * is the {@link System} type.
+ * @return {@link System} instance of {@param system}.
+ */
+ <S extends System<T>> S getSystem(final Class<S> system);
+
+ default void dispose() {
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java b/core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java
new file mode 100644
index 0000000..25e81f0
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/utils/FunctionUtils.java
@@ -0,0 +1,28 @@
+package coffee.liz.ecs.utils;
+
+
+public final class FunctionUtils {
+ /**
+ * Runs the provided action and wraps failures into a {@link RuntimeException}.
+ *
+ * @param run
+ * action to execute
+ */
+ public static <E extends Throwable> void wrapCheckedException(final ThrowableRunnable<E> run) {
+ try {
+ run.run();
+ } catch (final Throwable e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @FunctionalInterface
+ public interface ThrowableRunnable<E extends Throwable> {
+ /**
+ * Run.
+ */
+ void run() throws E;
+ }
+
+ private FunctionUtils() { }
+}