summaryrefslogtreecommitdiff
path: root/core/src/main/java/coffee/liz/ecs
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-02-28 14:08:49 -0800
committerElizabeth Alexander Hunt <me@liz.coffee>2026-02-28 14:08:49 -0800
commit8412efda977c1c76885eae1d0b4a721cf71162f2 (patch)
tree4ff20bc346fd24aeb5881ea06855d7bea5f5d162 /core/src/main/java/coffee/liz/ecs
parent87c8a1e15e399d29f42b41a4ccb66a84c5f6bb9a (diff)
downloaddyl-8412efda977c1c76885eae1d0b4a721cf71162f2.tar.gz
dyl-8412efda977c1c76885eae1d0b4a721cf71162f2.zip
Upgrading JDK and adding Observable interface
Diffstat (limited to 'core/src/main/java/coffee/liz/ecs')
-rw-r--r--core/src/main/java/coffee/liz/ecs/ComponentCache.java124
-rw-r--r--core/src/main/java/coffee/liz/ecs/DAGWorld.java63
-rw-r--r--core/src/main/java/coffee/liz/ecs/events/ComponentAdded.java16
-rw-r--r--core/src/main/java/coffee/liz/ecs/events/ComponentRemoved.java21
-rw-r--r--core/src/main/java/coffee/liz/ecs/events/EntityEvent.java13
-rw-r--r--core/src/main/java/coffee/liz/ecs/events/Hook.java13
-rw-r--r--core/src/main/java/coffee/liz/ecs/events/Observable.java34
-rw-r--r--core/src/main/java/coffee/liz/ecs/model/Entity.java41
8 files changed, 287 insertions, 38 deletions
diff --git a/core/src/main/java/coffee/liz/ecs/ComponentCache.java b/core/src/main/java/coffee/liz/ecs/ComponentCache.java
new file mode 100644
index 0000000..70487d6
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/ComponentCache.java
@@ -0,0 +1,124 @@
+package coffee.liz.ecs;
+
+import coffee.liz.ecs.events.ComponentAdded;
+import coffee.liz.ecs.events.ComponentRemoved;
+import coffee.liz.ecs.events.EntityEvent;
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/** Incremental cache for component-to-entity lookups. */
+public final class ComponentCache {
+ private final Map<Class<? extends Component>, Set<Entity>> entitiesByComponent = new HashMap<>();
+
+ /**
+ * Index all current components on a newly tracked entity.
+ *
+ * @param entity
+ * entity to index
+ */
+ public void addEntity(final Entity entity) {
+ entity.componentTypes().forEach(componentType -> addComponentEntry(componentType, entity));
+ }
+
+ /**
+ * Remove all current components for a no-longer-tracked entity.
+ *
+ * @param entity
+ * entity to remove from the cache
+ */
+ public void removeEntity(final Entity entity) {
+ entity.componentTypes().forEach(componentType -> removeComponentEntry(componentType, entity));
+ }
+
+ /**
+ * Apply a mutation event to the cache.
+ *
+ * @param event
+ * mutation event emitted by an entity
+ */
+ public void onEntityEvent(final EntityEvent event) {
+ switch (event) {
+ case ComponentAdded componentAdded:
+ addComponentEntry(componentAdded.getComponentClazz(), componentAdded.getEntity());
+ break;
+ case ComponentRemoved componentRemoved:
+ removeComponentEntry(componentRemoved.getComponentClazz(), componentRemoved.getEntity());
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Get all entities that contain a component type.
+ *
+ * @param componentType
+ * component class to query
+ * @return entities containing that component
+ */
+ public Set<Entity> entitiesWith(final Class<? extends Component> componentType) {
+ return entitiesByComponent.getOrDefault(componentType, Collections.emptySet());
+ }
+
+ /**
+ * Resolve entities that contain every queried component type.
+ *
+ * @param componentTypes
+ * required component classes
+ * @return entities that have all queried component types
+ */
+ public Set<Entity> entitiesMatchingAllOf(final Collection<Class<? extends Component>> componentTypes) {
+ if (componentTypes.isEmpty()) {
+ return Collections.emptySet();
+ }
+
+ Set<Entity> smallestBucket = null;
+ for (final Class<? extends Component> componentType : componentTypes) {
+ final Set<Entity> entities = entitiesWith(componentType);
+ if (entities.isEmpty()) {
+ return Collections.emptySet();
+ }
+ if (smallestBucket == null || entities.size() < smallestBucket.size()) {
+ smallestBucket = entities;
+ }
+ }
+
+ final Set<Entity> matches = new HashSet<>();
+ if (smallestBucket == null) {
+ return matches;
+ }
+
+ for (final Entity entity : smallestBucket) {
+ if (entity.hasAll(componentTypes)) {
+ matches.add(entity);
+ }
+ }
+ return matches;
+ }
+
+ /**
+ * Remove all cached component indexes.
+ */
+ public void clear() {
+ entitiesByComponent.clear();
+ }
+
+ private void addComponentEntry(final Class<? extends Component> componentType, final Entity entity) {
+ entitiesByComponent.computeIfAbsent(componentType, _unused -> new HashSet<>()).add(entity);
+ }
+
+ private void removeComponentEntry(final Class<? extends Component> componentType, final Entity entity) {
+ final Set<Entity> entities = entitiesByComponent.get(componentType);
+ entities.remove(entity);
+ if (entities.isEmpty()) {
+ entitiesByComponent.remove(componentType);
+ }
+ }
+}
diff --git a/core/src/main/java/coffee/liz/ecs/DAGWorld.java b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
index 1075e5e..a6a11db 100644
--- a/core/src/main/java/coffee/liz/ecs/DAGWorld.java
+++ b/core/src/main/java/coffee/liz/ecs/DAGWorld.java
@@ -1,5 +1,7 @@
package coffee.liz.ecs;
+import coffee.liz.ecs.events.EntityEvent;
+import coffee.liz.ecs.events.Hook;
import coffee.liz.ecs.model.Component;
import coffee.liz.ecs.model.Entity;
import coffee.liz.ecs.model.Query;
@@ -13,7 +15,6 @@ 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;
@@ -31,9 +32,11 @@ 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<>());
+ /** Incremental cache mapping component types to entities having that component. */
+ private final ComponentCache componentCache = new ComponentCache();
+
+ /** Shared world listener to keep cache synchronized with entity component changes. */
+ private final Hook<EntityEvent> cacheUpdateHook = componentCache::onEntityEvent;
/** Deterministic ID's for spawned entities. */
private final AtomicInteger nextEntityId = new AtomicInteger(0);
@@ -48,24 +51,22 @@ public class DAGWorld<T> implements World<T> {
public DAGWorld(final System<T>... systems) {
this.systems = singletonClazzMap(systems);
this.systemExecutionOrder = buildExecutionOrder(Arrays.asList(systems));
- log.debug("Executing in order: {}", systemExecutionOrder);
+ log.info("Executing in order: {}", systemExecutionOrder);
}
@Override
public Entity createEntity() {
final Entity entity = Entity.builder().id(nextEntityId.incrementAndGet()).build();
+ entity.subscribe(cacheUpdateHook);
entities.add(entity);
+ componentCache.addEntity(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);
- }
- });
+ entity.unsubscribe(cacheUpdateHook);
+ componentCache.removeEntity(entity);
entities.remove(entity);
}
@@ -81,11 +82,7 @@ public class DAGWorld<T> implements World<T> {
@Override
public void update(final T state, final float deltaSeconds) {
- systemExecutionOrder.forEach(system -> {
- refreshComponentCache();
- system.update(this, state, deltaSeconds);
- });
- refreshComponentCache();
+ systemExecutionOrder.forEach(system -> system.update(this, state, deltaSeconds));
}
@SuppressWarnings("unchecked")
@@ -94,25 +91,11 @@ public class DAGWorld<T> implements World<T> {
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());
+ return componentCache.entitiesMatchingAllOf(components);
}
private Set<Entity> resolveAnyOf(final Set<Class<? extends Component>> components) {
@@ -120,8 +103,9 @@ public class DAGWorld<T> implements World<T> {
return Collections.emptySet();
}
- return entities.stream().filter(entity -> components.stream().anyMatch(entity::has))
- .collect(Collectors.toSet());
+ final Set<Entity> matches = new HashSet<>();
+ components.forEach(componentType -> matches.addAll(componentCache.entitiesWith(componentType)));
+ return matches;
}
private Set<Entity> resolveNoneOf(final Set<Class<? extends Component>> components) {
@@ -129,8 +113,16 @@ public class DAGWorld<T> implements World<T> {
return Set.copyOf(entities);
}
- return entities.stream().filter(entity -> components.stream().noneMatch(entity::has))
- .collect(Collectors.toSet());
+ final Set<Entity> excluded = new HashSet<>();
+ components.forEach(componentType -> excluded.addAll(componentCache.entitiesWith(componentType)));
+
+ final Set<Entity> result = new HashSet<>();
+ entities.forEach(entity -> {
+ if (!excluded.contains(entity)) {
+ result.add(entity);
+ }
+ });
+ return result;
}
private List<System<T>> buildExecutionOrder(final Collection<System<T>> systems) {
@@ -189,6 +181,7 @@ public class DAGWorld<T> implements World<T> {
for (final System<T> system : systemExecutionOrder) {
system.dispose();
}
+ entities.forEach(entity -> entity.unsubscribe(cacheUpdateHook));
componentCache.clear();
entities.clear();
}
diff --git a/core/src/main/java/coffee/liz/ecs/events/ComponentAdded.java b/core/src/main/java/coffee/liz/ecs/events/ComponentAdded.java
new file mode 100644
index 0000000..6837bfc
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/events/ComponentAdded.java
@@ -0,0 +1,16 @@
+package coffee.liz.ecs.events;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Emitted when an entity receives a component.
+ */
+@RequiredArgsConstructor
+@Getter
+public class ComponentAdded implements EntityEvent {
+ private final Entity entity;
+ private final Class<? extends Component> componentClazz;
+}
diff --git a/core/src/main/java/coffee/liz/ecs/events/ComponentRemoved.java b/core/src/main/java/coffee/liz/ecs/events/ComponentRemoved.java
new file mode 100644
index 0000000..25047b6
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/events/ComponentRemoved.java
@@ -0,0 +1,21 @@
+package coffee.liz.ecs.events;
+
+import coffee.liz.ecs.model.Component;
+import coffee.liz.ecs.model.Entity;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * Emitted when an entity loses a component.
+ *
+ * @param entity
+ * entity that lost a component
+ * @param componentClazz
+ * class key of the component that was removed
+ */
+@RequiredArgsConstructor
+@Getter
+public class ComponentRemoved implements EntityEvent {
+ private final Entity entity;
+ private final Class<? extends Component> componentClazz;
+}
diff --git a/core/src/main/java/coffee/liz/ecs/events/EntityEvent.java b/core/src/main/java/coffee/liz/ecs/events/EntityEvent.java
new file mode 100644
index 0000000..cae20e6
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/events/EntityEvent.java
@@ -0,0 +1,13 @@
+package coffee.liz.ecs.events;
+
+import coffee.liz.ecs.model.Entity;
+
+/** Marker interface for events emitted by {@link Entity}. */
+public interface EntityEvent {
+ /**
+ * Get the entity that emitted this event.
+ *
+ * @return source {@link Entity}
+ */
+ Entity getEntity();
+}
diff --git a/core/src/main/java/coffee/liz/ecs/events/Hook.java b/core/src/main/java/coffee/liz/ecs/events/Hook.java
new file mode 100644
index 0000000..8f91aad
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/events/Hook.java
@@ -0,0 +1,13 @@
+package coffee.liz.ecs.events;
+
+import java.util.function.Consumer;
+
+/**
+ * Event callback used by {@link Observable}.
+ *
+ * @param <E>
+ * consumed event type
+ */
+@FunctionalInterface
+public interface Hook<E> extends Consumer<E> {
+}
diff --git a/core/src/main/java/coffee/liz/ecs/events/Observable.java b/core/src/main/java/coffee/liz/ecs/events/Observable.java
new file mode 100644
index 0000000..d849407
--- /dev/null
+++ b/core/src/main/java/coffee/liz/ecs/events/Observable.java
@@ -0,0 +1,34 @@
+package coffee.liz.ecs.events;
+
+/**
+ * Minimal observable contract for event emission.
+ *
+ * @param <E>
+ * emitted event type
+ */
+public interface Observable<E> {
+ /**
+ * Register a hook to receive future events.
+ *
+ * @param hook
+ * callback to invoke for each emitted event
+ * @return the registered hook
+ */
+ Hook<E> subscribe(Hook<E> hook);
+
+ /**
+ * Remove a previously registered hook.
+ *
+ * @param hook
+ * callback to remove
+ */
+ void unsubscribe(Hook<E> hook);
+
+ /**
+ * Publish an event to all currently subscribed hooks.
+ *
+ * @param event
+ * event to publish
+ */
+ void emit(E event);
+}
diff --git a/core/src/main/java/coffee/liz/ecs/model/Entity.java b/core/src/main/java/coffee/liz/ecs/model/Entity.java
index 7dab667..4984d5d 100644
--- a/core/src/main/java/coffee/liz/ecs/model/Entity.java
+++ b/core/src/main/java/coffee/liz/ecs/model/Entity.java
@@ -1,14 +1,22 @@
package coffee.liz.ecs.model;
+import coffee.liz.ecs.events.ComponentAdded;
+import coffee.liz.ecs.events.ComponentRemoved;
+import coffee.liz.ecs.events.EntityEvent;
+import coffee.liz.ecs.events.Hook;
+import coffee.liz.ecs.events.Observable;
+
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
+import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
@@ -17,7 +25,7 @@ import java.util.Set;
@RequiredArgsConstructor
@AllArgsConstructor
@Data
-public class Entity {
+public class Entity implements Observable<EntityEvent> {
/** Unique id. */
private final int id;
@@ -25,6 +33,10 @@ public class Entity {
@Builder.Default
private Map<Class<? extends Component>, Component> componentMap = Collections.synchronizedMap(new HashMap<>());
+ @Builder.Default
+ /** Event subscribers for entity mutation events. */
+ private transient Set<Hook<EntityEvent>> hooks = Collections.newSetFromMap(new IdentityHashMap<>());
+
/**
* Check if entity has component type.
*
@@ -76,7 +88,9 @@ public class Entity {
* @return this {@link Entity} for chaining
*/
public <C extends Component> Entity add(final C component) {
- componentMap.put(component.getKey(), component);
+ final Class<? extends Component> componentType = component.getKey();
+ componentMap.put(componentType, component);
+ emit(new ComponentAdded(this, componentType));
return this;
}
@@ -90,7 +104,9 @@ public class Entity {
* @return this {@link Entity} for chaining
*/
public <C extends Component> Entity remove(final Class<C> componentType) {
- componentMap.remove(componentType);
+ if (componentMap.remove(componentType) != null) {
+ emit(new ComponentRemoved(this, componentType));
+ }
return this;
}
@@ -102,4 +118,23 @@ public class Entity {
public Set<Class<? extends Component>> componentTypes() {
return componentMap.keySet();
}
+
+ /** Subscribe to component mutation events emitted by this entity. */
+ @Override
+ public Hook<EntityEvent> subscribe(final Hook<EntityEvent> hook) {
+ hooks.add(hook);
+ return hook;
+ }
+
+ /** Remove a previously subscribed mutation hook. */
+ @Override
+ public void unsubscribe(final Hook<EntityEvent> hook) {
+ hooks.remove(hook);
+ }
+
+ /** Emit a mutation event to all current subscribers. */
+ @Override
+ public void emit(final EntityEvent event) {
+ new ArrayList<>(hooks).forEach(hook -> hook.accept(event));
+ }
}