From 8412efda977c1c76885eae1d0b4a721cf71162f2 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Sat, 28 Feb 2026 14:08:49 -0800 Subject: Upgrading JDK and adding Observable interface --- .../coffee/liz/dyl/components/StressComponent.java | 16 +++ .../java/coffee/liz/dyl/systems/StressSystem.java | 31 ++++++ .../java/coffee/liz/dyl/world/DylGameWorld.java | 6 +- .../main/java/coffee/liz/ecs/ComponentCache.java | 124 +++++++++++++++++++++ core/src/main/java/coffee/liz/ecs/DAGWorld.java | 63 +++++------ .../java/coffee/liz/ecs/events/ComponentAdded.java | 16 +++ .../coffee/liz/ecs/events/ComponentRemoved.java | 21 ++++ .../java/coffee/liz/ecs/events/EntityEvent.java | 13 +++ core/src/main/java/coffee/liz/ecs/events/Hook.java | 13 +++ .../java/coffee/liz/ecs/events/Observable.java | 34 ++++++ .../src/main/java/coffee/liz/ecs/model/Entity.java | 41 ++++++- 11 files changed, 338 insertions(+), 40 deletions(-) create mode 100644 core/src/main/java/coffee/liz/dyl/components/StressComponent.java create mode 100644 core/src/main/java/coffee/liz/dyl/systems/StressSystem.java create mode 100644 core/src/main/java/coffee/liz/ecs/ComponentCache.java create mode 100644 core/src/main/java/coffee/liz/ecs/events/ComponentAdded.java create mode 100644 core/src/main/java/coffee/liz/ecs/events/ComponentRemoved.java create mode 100644 core/src/main/java/coffee/liz/ecs/events/EntityEvent.java create mode 100644 core/src/main/java/coffee/liz/ecs/events/Hook.java create mode 100644 core/src/main/java/coffee/liz/ecs/events/Observable.java (limited to 'core/src/main/java') diff --git a/core/src/main/java/coffee/liz/dyl/components/StressComponent.java b/core/src/main/java/coffee/liz/dyl/components/StressComponent.java new file mode 100644 index 0000000..f22dfaa --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/components/StressComponent.java @@ -0,0 +1,16 @@ +package coffee.liz.dyl.components; + +import coffee.liz.ecs.model.Component; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +@RequiredArgsConstructor +public class StressComponent implements Component { + private final String componentData; + public StressComponent() { + componentData = IntStream.range(0, 10).mapToObj(Integer::toString).collect(Collectors.joining()); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/systems/StressSystem.java b/core/src/main/java/coffee/liz/dyl/systems/StressSystem.java new file mode 100644 index 0000000..850a9f3 --- /dev/null +++ b/core/src/main/java/coffee/liz/dyl/systems/StressSystem.java @@ -0,0 +1,31 @@ +package coffee.liz.dyl.systems; + +import coffee.liz.dyl.FrameState; +import coffee.liz.dyl.components.StressComponent; +import coffee.liz.dyl.components.Velocity; +import coffee.liz.ecs.math.Vec2f; +import coffee.liz.ecs.model.System; +import coffee.liz.ecs.model.World; + +import java.util.Collection; +import java.util.List; + +public class StressSystem implements System { + + @Override + public Collection>> getDependencies() { + return List.of(); + } + + @Override + public void update(World world, FrameState state, float deltaSeconds) { + world.queryable().allOf().forEach(e -> { + if (Math.random() > 0.5) { + e.add(new StressComponent()); + e.add(new Velocity(new Vec2f(0.5f - (float) Math.random(), 0.5f - (float) Math.random()))); + } else if (e.has(StressComponent.class)){ + e.remove(StressComponent.class); + } + }); + } +} diff --git a/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java b/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java index c3e1c2d..475694e 100644 --- a/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java +++ b/core/src/main/java/coffee/liz/dyl/world/DylGameWorld.java @@ -7,6 +7,7 @@ import coffee.liz.dyl.entities.PlayerFactory; import coffee.liz.dyl.systems.InputSystem; import coffee.liz.dyl.systems.IntegrationSystem; import coffee.liz.dyl.systems.RenderSystem; +import coffee.liz.dyl.systems.StressSystem; import coffee.liz.ecs.DAGWorld; import coffee.liz.ecs.math.Vec2f; import coffee.liz.ecs.math.Vec2i; @@ -16,9 +17,10 @@ public class DylGameWorld extends DAGWorld { super( new InputSystem(), new IntegrationSystem(), - new RenderSystem(game.getBatch(), game.getViewport()) + new RenderSystem(game.getBatch(), game.getViewport()), + new StressSystem() ); - for (int i = 0; i < 8000; i++) { + for (int i = 0; i < 16_000; i++) { PlayerFactory.addTo(this) .add(new BoundingBox(new Vec2f(i / 200f, i/ 200f), new Vec2i(1, 1), 1)); } 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, Set> 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 entitiesWith(final Class 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 entitiesMatchingAllOf(final Collection> componentTypes) { + if (componentTypes.isEmpty()) { + return Collections.emptySet(); + } + + Set smallestBucket = null; + for (final Class componentType : componentTypes) { + final Set entities = entitiesWith(componentType); + if (entities.isEmpty()) { + return Collections.emptySet(); + } + if (smallestBucket == null || entities.size() < smallestBucket.size()) { + smallestBucket = entities; + } + } + + final Set 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 componentType, final Entity entity) { + entitiesByComponent.computeIfAbsent(componentType, _unused -> new HashSet<>()).add(entity); + } + + private void removeComponentEntry(final Class componentType, final Entity entity) { + final Set 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 implements World { /** All entities in the world. */ protected final Set entities = Collections.synchronizedSet(new HashSet<>()); - /** Cache mapping component types to entities having that component. */ - private final Map, Set> 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 cacheUpdateHook = componentCache::onEntityEvent; /** Deterministic ID's for spawned entities. */ private final AtomicInteger nextEntityId = new AtomicInteger(0); @@ -48,24 +51,22 @@ public class DAGWorld implements World { public DAGWorld(final System... 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 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 implements World { @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 implements World { 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 resolveAllOf(final Set> components) { if (components.isEmpty()) { return Set.copyOf(entities); } - - final Class firstType = components.iterator().next(); - final Set 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 resolveAnyOf(final Set> components) { @@ -120,8 +103,9 @@ public class DAGWorld implements World { return Collections.emptySet(); } - return entities.stream().filter(entity -> components.stream().anyMatch(entity::has)) - .collect(Collectors.toSet()); + final Set matches = new HashSet<>(); + components.forEach(componentType -> matches.addAll(componentCache.entitiesWith(componentType))); + return matches; } private Set resolveNoneOf(final Set> components) { @@ -129,8 +113,16 @@ public class DAGWorld implements World { return Set.copyOf(entities); } - return entities.stream().filter(entity -> components.stream().noneMatch(entity::has)) - .collect(Collectors.toSet()); + final Set excluded = new HashSet<>(); + components.forEach(componentType -> excluded.addAll(componentCache.entitiesWith(componentType))); + + final Set result = new HashSet<>(); + entities.forEach(entity -> { + if (!excluded.contains(entity)) { + result.add(entity); + } + }); + return result; } private List> buildExecutionOrder(final Collection> systems) { @@ -189,6 +181,7 @@ public class DAGWorld implements World { for (final System 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 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 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 + * consumed event type + */ +@FunctionalInterface +public interface Hook extends Consumer { +} 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 + * emitted event type + */ +public interface Observable { + /** + * Register a hook to receive future events. + * + * @param hook + * callback to invoke for each emitted event + * @return the registered hook + */ + Hook subscribe(Hook hook); + + /** + * Remove a previously registered hook. + * + * @param hook + * callback to remove + */ + void unsubscribe(Hook 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 { /** Unique id. */ private final int id; @@ -25,6 +33,10 @@ public class Entity { @Builder.Default private Map, Component> componentMap = Collections.synchronizedMap(new HashMap<>()); + @Builder.Default + /** Event subscribers for entity mutation events. */ + private transient Set> 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 Entity add(final C component) { - componentMap.put(component.getKey(), component); + final Class 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 Entity remove(final Class 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> componentTypes() { return componentMap.keySet(); } + + /** Subscribe to component mutation events emitted by this entity. */ + @Override + public Hook subscribe(final Hook hook) { + hooks.add(hook); + return hook; + } + + /** Remove a previously subscribed mutation hook. */ + @Override + public void unsubscribe(final Hook 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)); + } } -- cgit v1.2.3-70-g09d2