diff options
| author | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-02-28 14:08:49 -0800 |
|---|---|---|
| committer | Elizabeth Alexander Hunt <me@liz.coffee> | 2026-02-28 14:08:49 -0800 |
| commit | 8412efda977c1c76885eae1d0b4a721cf71162f2 (patch) | |
| tree | 4ff20bc346fd24aeb5881ea06855d7bea5f5d162 /core/src/main/java/coffee/liz/ecs | |
| parent | 87c8a1e15e399d29f42b41a4ccb66a84c5f6bb9a (diff) | |
| download | dyl-8412efda977c1c76885eae1d0b4a721cf71162f2.tar.gz dyl-8412efda977c1c76885eae1d0b4a721cf71162f2.zip | |
Upgrading JDK and adding Observable interface
Diffstat (limited to 'core/src/main/java/coffee/liz/ecs')
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)); + } } |
