diff options
Diffstat (limited to 'core/src/main/java/coffee/liz/ecs')
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() { } +} |
