Quillraven / Fleks

Fast, lightweight, multi-platform entity component system in Kotlin
MIT License
174 stars 19 forks source link

Ability to add systems to the world after world creation #145

Closed RefuX closed 2 months ago

RefuX commented 2 months ago

I changed world.system to return a List instead on an Array, so while it didn't impact any tests, I guess its possible some users are expecting an Array?

I ran the benchmarks before and after changes and results are basically identical.

I added a constructor to IntervalSystem (to match old signature), as it was breaking some js/wasm tests otherwise.

Please review and give feedback.

Quillraven commented 2 months ago

Thank you! I will try to have a look by tomorrow and give you feedback :)

Quillraven commented 2 months ago

Had a quick check on my phone. In general looks good to me but need to have a more detailed look. Two things that I can already tell:

RefuX commented 2 months ago

Updated with review feedback 👍

Quillraven commented 2 months ago

Updated with review feedback 👍

haha unlucky timing, I just did the detailed review in parallel. Will have another look this afternoon :)

RefuX commented 2 months ago

Resolved the own hook conundrum using your first approach. Let me know if you like how it looks.

Quillraven commented 2 months ago

Looks better now but the hook stuff is still not correct. It is a little bit confusing because it is a single hook from a family point of view but actually multiple things can be called. Here is a scenario that describes it better:

The special setUpAggregatedFamilyHooks function is then combining all three hooks into a single function. The new function executes during an add "trigger" in following order:

For removal it is the same but in reversed order.

If you now remove system A of the world then the hook of the family remains but the new execution will be:

systemAHook logic needs to be removed. When adding system A again, it needs to become part of the single hook function again depending on where the system was added.

I understand if this is a little complex for someone who does not know about that special use-case and all the details ;) I think this is also not covered by any of the test cases because otherwise you would already have failing tests.

I will have some time on the weekend and can help you with that. But of course, if you feel motivated to do it on your own then also feel free to do so :)


Everything else looks fine from my side now and can be merged. We are just missing the proper logic for this special aggregate hook stuff.

Quillraven commented 2 months ago

@RefuX:

had a little bit of time this morning. In theory the code should work but needs to be verified in a proper test case. Please have a look as well: 1) I removed the ownAddHook and ownRemoveHook from the family again 2) WorldConfiguration is then directly setting again the addHook / removeHook 3) I split the setUpAggregatedFamilyHooks method into two methods. The first one is initAggregatedFamilyHooks which gets called after world configuration (it is basically the same as it was before). The second one is a new updateAggregatedFamilyHooks method that only updates the add/remove hook of a single family. It gets called when a system is added/removed and the system has those special interfaces. 4) I introduced two "lazy caches" to remember the original world configuration hooks. I think this should be the best option from a memory consumption point of view.


The two lazy caches. I hope mutableMapOf supports null values:

    /**
     * Map of add [FamilyHook] out of the [WorldConfiguration].
     * Only used if there are also aggregated system hooks for the family to remember
     * its original world configuration hook (see [initAggregatedFamilyHooks] and [updateAggregatedFamilyHooks]).
     */
    private val worldCfgFamilyAddHooks = mutableMapOf<Family, FamilyHook?>()

    /**
     * Map of remove [FamilyHook] out of the [WorldConfiguration].
     * Only used if there are also aggregated system hooks for the family to remember
     * its original world configuration hook (see [initAggregatedFamilyHooks] and [updateAggregatedFamilyHooks]).
     */
    private val worldCfgFamilyRemoveHooks = mutableMapOf<Family, FamilyHook?>()

Updated system add:

    /**
     * Adds a new system to the world.
     *
     * @param index The position at which the system should be inserted in the list of systems. If null, the system is added at the end of the list.
     * This parameter is optional and defaults to null.
     * @param system The system to be added to the world. This should be an instance of a class that extends IntervalSystem.
     *
     * @throws FleksSystemAlreadyAddedException if the system was already added before.
     */
    fun add(index: Int, system: IntervalSystem) {
        if (systems.any { it::class == system::class }) {
            throw FleksSystemAlreadyAddedException(system::class)
        }
        if (system is IteratingSystem && (system is FamilyOnAdd || system is FamilyOnRemove)) {
            updateAggregatedFamilyHooks(system.family)
        }

        mutableSystems.add(index, system)
    }

Updated system remove:

    /**
     * Removes the specified system from the world.
     *
     * @param system The system to be removed from the world. This should be an instance of a class that extends IntervalSystem.
     * @return True if the system was successfully removed, false otherwise.
     */
    fun remove(system: IntervalSystem) {
        mutableSystems.remove(system)
        if (system is IteratingSystem && (system is FamilyOnAdd || system is FamilyOnRemove)) {
            updateAggregatedFamilyHooks(system.family)
        }
    }

The updated init aggregated hooks and update aggregated hooks:

    /**
     * Extend [Family.addHook] and [Family.removeHook] for all
     * [systems][IteratingSystem] that implement [FamilyOnAdd] and/or [FamilyOnRemove].
     */
    internal fun initAggregatedFamilyHooks() {
        // validate systems against illegal interfaces
        systems.forEach { system ->
            // FamilyOnAdd and FamilyOnRemove interfaces are only meant to be used by IteratingSystem
            if (system !is IteratingSystem) {
                if (system is FamilyOnAdd) {
                    throw FleksWrongSystemInterfaceException(system::class, FamilyOnAdd::class)
                }
                if (system is FamilyOnRemove) {
                    throw FleksWrongSystemInterfaceException(system::class, FamilyOnRemove::class)
                }
            }
        }

        // register family hooks for IteratingSystem.FamilyOnAdd containing systems
        systems
            .mapNotNull { if (it is IteratingSystem && it is FamilyOnAdd) it else null }
            .groupBy { it.family }
            .forEach { entry ->
                val (family, systemList) = entry
                val ownHook = worldCfgFamilyAddHooks.getOrPut(family) { family.addHook }
                family.addHook = if (ownHook != null) { entity ->
                    ownHook(this, entity)
                    systemList.forEach { it.onAddEntity(entity) }
                } else { entity ->
                    systemList.forEach { it.onAddEntity(entity) }
                }
            }

        // register family hooks for IteratingSystem.FamilyOnRemove containing systems
        systems
            .mapNotNull { if (it is IteratingSystem && it is FamilyOnRemove) it else null }
            .groupBy { it.family }
            .forEach { entry ->
                val (family, systemList) = entry
                val ownHook = worldCfgFamilyRemoveHooks.getOrPut(family) { family.removeHook as FamilyHook }
                family.removeHook = if (ownHook != null) { entity ->
                    systemList.forEachReverse { it.onRemoveEntity(entity) }
                    ownHook(this, entity)
                } else { entity ->
                    systemList.forEachReverse { it.onRemoveEntity(entity) }
                }
            }
    }

    /**
     * Update [Family.addHook] and [Family.removeHook] for all
     * [systems][IteratingSystem] that implement [FamilyOnAdd] and/or [FamilyOnRemove]
     * and iterate over the given [family].
     */
    private fun updateAggregatedFamilyHooks(family: Family) {
        // system validation like in initAggregatedFamilyHooks is not necessary
        // because it is already validated before (in initAggregatedFamilyHooks and in add/remove system)

        // update family add hook by adding systems' onAddEntity calls after its original world cfg hook
        val ownAddHook = worldCfgFamilyAddHooks.getOrPut(family) { family.addHook }
        val addSystems = systems.filter { it is IteratingSystem && it is FamilyOnAdd && it.family == family }
        family.addHook = if (ownAddHook != null) { entity ->
            ownAddHook(this, entity)
            addSystems.forEach { (it as FamilyOnAdd).onAddEntity(entity) }
        } else { entity ->
            addSystems.forEach { (it as FamilyOnAdd).onAddEntity(entity) }
        }

        // update family remove hook by adding systems' onRemoveEntity calls before its original world cfg hook
        val ownRemoveHook = worldCfgFamilyRemoveHooks.getOrPut(family) { family.removeHook }
        val removeSystems = systems.filter { it is IteratingSystem && it is FamilyOnRemove && it.family == family }
        family.removeHook = if (ownRemoveHook != null) { entity ->
            removeSystems.forEach { (it as FamilyOnRemove).onRemoveEntity(entity) }
            ownRemoveHook(this, entity)
        } else { entity ->
            removeSystems.forEach { (it as FamilyOnRemove).onRemoveEntity(entity) }
        }
    }

Complete family.kt file ```kotlin package com.github.quillraven.fleks import com.github.quillraven.fleks.collection.* /** * Type alias for an optional hook function for a [Family]. * Such a function runs within a [World] and takes the [Entity] as an argument. */ typealias FamilyHook = World.(Entity) -> Unit /** * A class to define the configuration of a [Family]. A [family][Family] contains of three parts: * * - **allOf**: an [entity][Entity] must have all specified [components][Component] to be part of the [family][Family]. * - **noneOf**: an [entity][Entity] must not have any of the specified [components][Component] to be part of the [family][Family]. * - **anyOf**: an [entity][Entity] must have at least one of the specified [components][Component] to be part of the [family][Family]. * * It is not mandatory to specify all three parts but **at least one** part must be provided. */ data class FamilyDefinition( internal var allOf: BitArray? = null, internal var noneOf: BitArray? = null, internal var anyOf: BitArray? = null, ) { /** * Any [entity][Entity] must have all given [types] to be part of the [family][Family]. */ fun all(vararg types: UniqueId<*>): FamilyDefinition { allOf = BitArray(types.size).also { bits -> types.forEach { bits.set(it.id) } } return this } /** * Any [entity][Entity] must not have any of the given [types] to be part of the [family][Family]. */ fun none(vararg types: UniqueId<*>): FamilyDefinition { noneOf = BitArray(types.size).also { bits -> types.forEach { bits.set(it.id) } } return this } /** * Any [entity][Entity] must have at least one of the given [types] to be part of the [family][Family]. */ fun any(vararg types: UniqueId<*>): FamilyDefinition { anyOf = BitArray(types.size).also { bits -> types.forEach { bits.set(it.id) } } return this } /** * Returns true if and only if [allOf], [noneOf] and [anyOf] are either null or empty. */ internal fun isEmpty(): Boolean { return allOf.isNullOrEmpty() && noneOf.isNullOrEmpty() && anyOf.isNullOrEmpty() } } /** * A family of [entities][Entity]. It stores [entities][Entity] that have a specific configuration of components. * A configuration is defined via the a [FamilyDefinition]. * Each [component][Component] is assigned to a unique index via its [ComponentType]. * That index is set in the [allOf], [noneOf] or [anyOf][] [BitArray]. * * A family gets notified when an [entity][Entity] is added, updated or removed of the [world][World]. * * Every [IteratingSystem] is linked to exactly one family but a family can also exist outside of systems. * It gets created via the [World.family] function. */ data class Family( internal val allOf: BitArray? = null, internal val noneOf: BitArray? = null, internal val anyOf: BitArray? = null, private val world: World, @PublishedApi internal val entityService: EntityService = world.entityService, ) : EntityComponentContext(world.componentService) { /** * An optional [FamilyHook] that gets called whenever an [entity][Entity] enters the family. */ internal var addHook: FamilyHook? = null /** * An optional [FamilyHook] that gets called whenever an [entity][Entity] leaves the family. */ internal var removeHook: FamilyHook? = null /** * Returns the [entities][Entity] that belong to this family. */ private val activeEntities = bag(world.capacity) private var countEntities = 0 /** * Returns true if an iteration of this family is currently in process. */ @PublishedApi internal var isIterating = false // This bag is added for better iteration performance. @PublishedApi internal val mutableEntities = MutableEntityBag() get() { if (isDirty && !isIterating) { // no iteration in process -> update entities if necessary isDirty = false field.clearEnsuringCapacity(activeEntities.size) activeEntities.forEach { field += it } } return field } /** * Returns the [entities][Entity] that belong to this family. * Be aware that the underlying [EntityBag] collection is not always up to date. * The collection is not updated while a family iteration is in progress. It * gets automatically updated whenever it is accessed and no iteration is currently * in progress. */ val entities: EntityBag get() = mutableEntities /** * Returns the number of [entities][Entity] that belong to this family. */ val numEntities: Int get() = countEntities /** * Returns true if and only if this [Family] does not contain any entity. */ val isEmpty: Boolean get() = countEntities == 0 /** * Returns true if and only if this [Family] contains at least one entity. */ val isNotEmpty: Boolean get() = countEntities > 0 /** * Flag to indicate if there are changes in the [activeEntities]. * If it is true then the [mutableEntities] will get updated the next time it is accessed. */ private var isDirty = false /** * Returns true if the specified [compMask] matches the family's component configuration. * * @param compMask the component configuration of an [entity][Entity]. */ internal operator fun contains(compMask: BitArray): Boolean { return (allOf == null || compMask.contains(allOf)) && (noneOf == null || !compMask.intersects(noneOf)) && (anyOf == null || compMask.intersects(anyOf)) } /** * Returns true if and only if the given [entity] is part of the family. */ operator fun contains(entity: Entity): Boolean = activeEntities.hasValueAtIndex(entity.id) /** * Updates this family if needed and runs the given [action] for all [entities][Entity]. * * **Important note**: There is a potential risk when iterating over entities and one of those entities * gets removed. Removing the entity immediately and cleaning up its components could * cause problems because if you access a component which is mandatory for the family, you will get * a FleksNoSuchComponentException. To avoid that you could check if an entity really has the component * before accessing it but that is redundant in context of a family. * * To avoid these kinds of issues, entity removals are delayed until the end of the iteration. This also means * that a removed entity of this family will still be part of the [action] for the current iteration. */ inline fun forEach(crossinline action: Family.(Entity) -> Unit) { // Access entities before 'forEach' call to properly update them. // Check mutableEntities getter for more details. val entitiesForIteration = mutableEntities if (!entityService.delayRemoval) { entityService.delayRemoval = true isIterating = true entitiesForIteration.forEach { action(it) } isIterating = false entityService.cleanupDelays() } else { val origIterating = isIterating isIterating = true entitiesForIteration.forEach { this.action(it) } isIterating = origIterating } } /** * Updates this family if needed and returns its first [Entity]. * @throws [NoSuchElementException] if the family has no entities. */ fun first(): Entity = mutableEntities.first() /** * Updates this family if needed and returns its first [Entity] or null if the family has no entities. */ fun firstOrNull(): Entity? = mutableEntities.firstOrNull() /** * Sorts the [entities][Entity] of this family by the given [comparator]. */ fun sort(comparator: EntityComparator) = mutableEntities.sort(comparator) /** * Adds the [entity] to the family and sets the [isDirty] flag if and only * if the entity's [compMask] is matching the family configuration. */ @PublishedApi internal fun onEntityAdded(entity: Entity, compMask: BitArray) { if (compMask in this) { isDirty = true if (activeEntities.hasNoValueAtIndex(entity.id)) countEntities++ activeEntities[entity.id] = entity addHook?.invoke(world, entity) } } /** * Checks if the [entity] is part of the family by analyzing the entity's components. * The [compMask] is a [BitArray] that indicates which components the [entity] currently has. * * The [entity] gets either added to the [activeEntities] or removed and [isDirty] is set when needed. */ @PublishedApi internal fun onEntityCfgChanged(entity: Entity, compMask: BitArray) { val entityInFamily = compMask in this val currentEntity = activeEntities.getOrNull(entity.id) if (entityInFamily && currentEntity == null) { // new entity gets added isDirty = true countEntities++ activeEntities[entity.id] = entity addHook?.invoke(world, entity) } else if (!entityInFamily && currentEntity != null) { // existing entity gets removed isDirty = true countEntities-- activeEntities.removeAt(entity.id) removeHook?.invoke(world, entity) } } /** * Removes the [entity] of the family and sets the [isDirty] flag if and only * if the [entity] is already in the family. */ internal fun onEntityRemoved(entity: Entity) { if (activeEntities.hasValueAtIndex(entity.id)) { // existing entity gets removed isDirty = true activeEntities.removeAt(entity.id) countEntities-- removeHook?.invoke(world, entity) } } override fun toString(): String { return "Family(allOf=$allOf, noneOf=$noneOf, anyOf=$anyOf, numEntities=$numEntities)" } } ```
Complete world.kt file ```kotlin package com.github.quillraven.fleks import com.github.quillraven.fleks.World.Companion.CURRENT_WORLD import com.github.quillraven.fleks.collection.EntityBag import com.github.quillraven.fleks.collection.MutableEntityBag import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import kotlin.native.concurrent.ThreadLocal import kotlin.reflect.KClass /** * DSL marker for the [WorldConfiguration]. */ @DslMarker annotation class WorldCfgMarker /** * Wrapper class for injectables of the [WorldConfiguration]. * It is used to identify unused injectables after [world][World] creation. */ data class Injectable(val injObj: Any, var used: Boolean = false) /** * A DSL class to configure [Injectable] of a [WorldConfiguration]. */ @WorldCfgMarker class InjectableConfiguration(private val world: World) { /** * Adds the specified [dependency] under the given [name] which * can then be injected via [World.inject]. * * @throws [FleksInjectableAlreadyAddedException] if the dependency was already added before. */ fun add(name: String, dependency: T) { if (name in world.injectables) { throw FleksInjectableAlreadyAddedException(name) } world.injectables[name] = Injectable(dependency) } /** * Adds the specified [dependency] via its [simpleName][KClass.simpleName], * or via its [toString][KClass.toString] if it has no name. * It can then be injected via [World.inject]. * * @throws [FleksInjectableAlreadyAddedException] if the dependency was already added before. */ inline fun add(dependency: T) = add(T::class.simpleName ?: T::class.toString(), dependency) } /** * A DSL class to configure [IntervalSystem] of a [WorldConfiguration]. */ @WorldCfgMarker class SystemConfiguration( private val systems: MutableList ) { /** * Adds the [system] to the [world][World]. * The order in which systems are added is the order in which they will be executed when calling [World.update]. * * @throws [FleksSystemAlreadyAddedException] if the system was already added before. */ fun add(system: IntervalSystem) { if (systems.any { it::class == system::class }) { throw FleksSystemAlreadyAddedException(system::class) } systems += system } } /** * A DSL class to configure [FamilyHook] for specific [families][Family]. */ @WorldCfgMarker class FamilyConfiguration( @PublishedApi internal val world: World, ) { /** * Sets the add [hook][Family.addHook] for the given [family]. * This hook gets called whenever an [entity][Entity] enters the [family]. */ fun onAdd( family: Family, hook: FamilyHook ) { if (family.addHook != null) { throw FleksHookAlreadyAddedException("addHook", "Family $family") } family.addHook = hook } /** * Sets the remove [hook][Family.removeHook] for the given [family]. * This hook gets called whenever an [entity][Entity] leaves the [family]. */ fun onRemove( family: Family, hook: FamilyHook ) { if (family.removeHook != null) { throw FleksHookAlreadyAddedException("removeHook", "Family $family") } family.removeHook = hook } } /** * A configuration for an entity [world][World] to define the systems, dependencies to be injected, * [component][Component]- and [family][Family] hooks. * * @param world the [World] to be configured. */ @WorldCfgMarker class WorldConfiguration(@PublishedApi internal val world: World) { private var injectableCfg: (InjectableConfiguration.() -> Unit)? = null private var familyCfg: (FamilyConfiguration.() -> Unit)? = null private var systemCfg: (SystemConfiguration.() -> Unit)? = null fun injectables(cfg: InjectableConfiguration.() -> Unit) { injectableCfg = cfg } fun families(cfg: FamilyConfiguration.() -> Unit) { familyCfg = cfg } fun systems(cfg: SystemConfiguration.() -> Unit) { systemCfg = cfg } /** * Sets the add entity [hook][EntityService.addHook]. * This hook gets called whenever an [entity][Entity] gets created and * after its [components][Component] are assigned and [families][Family] are updated. */ fun onAddEntity(hook: EntityHook) { world.setEntityAddHook(hook) } /** * Sets the remove entity [hook][EntityService.removeHook]. * This hook gets called whenever an [entity][Entity] gets removed and * before its [components][Component] are removed and [families][Family] are updated. */ fun onRemoveEntity(hook: EntityHook) { world.setEntityRemoveHook(hook) } /** * Sets the [EntityProvider] for the [EntityService] by calling the [factory] function * within the context of a [World]. Per default the [DefaultEntityProvider] is used. */ fun entityProvider(factory: World.() -> EntityProvider) { world.entityService.entityProvider = world.run(factory) } /** * Configures the world in following sequence: * - injectables * - family * - system * * The order is important to correctly trigger [FamilyHook]s and [EntityHook]s. */ fun configure() { injectableCfg?.invoke(InjectableConfiguration(world)) familyCfg?.invoke(FamilyConfiguration(world)) SystemConfiguration(world.mutableSystems).also { systemCfg?.invoke(it) } if (world.numEntities > 0) { throw FleksWorldModificationDuringConfigurationException() } world.initAggregatedFamilyHooks() world.systems.forEach { it.onInit() } } } /** * Creates a new [world][World] with the given [cfg][WorldConfiguration]. * * @param entityCapacity initial maximum entity capacity. * Will be used internally when a [world][World] is created to set the initial * size of some collections and to avoid slow resizing calls. * * @param cfg the [configuration][WorldConfiguration] of the world containing the [systems][IntervalSystem], * [injectables][Injectable] and [FamilyHook]s. */ fun configureWorld(entityCapacity: Int = 512, cfg: WorldConfiguration.() -> Unit): World { val newWorld = World(entityCapacity) CURRENT_WORLD = newWorld try { WorldConfiguration(newWorld).apply(cfg).configure() } finally { CURRENT_WORLD = null } return newWorld } /** * Snapshot for an [entity][Entity] that contains its [components][Component] and [tags][EntityTag]. */ @Serializable data class Snapshot( val components: List>, val tags: List>, ) /** * Utility function to manually create a [Snapshot]. */ @Suppress("UNCHECKED_CAST") fun wildcardSnapshotOf(components: List>, tags: List>): Snapshot { return Snapshot(components as List>, tags as List>) } /** * A world to handle [entities][Entity] and [systems][IntervalSystem]. * * @param entityCapacity the initial maximum capacity of entities. */ class World internal constructor( entityCapacity: Int, ) : EntityComponentContext(ComponentService()) { @PublishedApi internal val injectables = mutableMapOf() /** * Returns the time that is passed to [update][World.update]. * It represents the time in seconds between two frames. */ var deltaTime = 0f private set @PublishedApi internal val entityService = EntityService(this, entityCapacity) /** * List of all [families][Family] of the world that are created either via * an [IteratingSystem] or via the world's [family] function to * avoid creating duplicates. */ @PublishedApi internal var allFamilies = emptyArray() /** * Returns the amount of active entities. */ val numEntities: Int get() = entityService.numEntities /** * Returns the maximum capacity of active entities. */ val capacity: Int get() = entityService.capacity /** * Returns the world's systems. */ internal val mutableSystems = arrayListOf() val systems: List get() = mutableSystems /** * Map of add [FamilyHook] out of the [WorldConfiguration]. * Only used if there are also aggregated system hooks for the family to remember * its original world configuration hook (see [initAggregatedFamilyHooks] and [updateAggregatedFamilyHooks]). */ private val worldCfgFamilyAddHooks = mutableMapOf() /** * Map of remove [FamilyHook] out of the [WorldConfiguration]. * Only used if there are also aggregated system hooks for the family to remember * its original world configuration hook (see [initAggregatedFamilyHooks] and [updateAggregatedFamilyHooks]). */ private val worldCfgFamilyRemoveHooks = mutableMapOf() /** * Adds a new system to the world. * * @param index The position at which the system should be inserted in the list of systems. If null, the system is added at the end of the list. * This parameter is optional and defaults to null. * @param system The system to be added to the world. This should be an instance of a class that extends IntervalSystem. * * @throws FleksSystemAlreadyAddedException if the system was already added before. */ fun add(index: Int, system: IntervalSystem) { if (systems.any { it::class == system::class }) { throw FleksSystemAlreadyAddedException(system::class) } if (system is IteratingSystem && (system is FamilyOnAdd || system is FamilyOnRemove)) { updateAggregatedFamilyHooks(system.family) } mutableSystems.add(index, system) } /** * Adds a new system to the world. * * @param system The system to be added to the world. This should be an instance of a class that extends IntervalSystem. */ fun add(system: IntervalSystem) = add(systems.size, system) /** * Removes the specified system from the world. * * @param system The system to be removed from the world. This should be an instance of a class that extends IntervalSystem. * @return True if the system was successfully removed, false otherwise. */ fun remove(system: IntervalSystem) { mutableSystems.remove(system) if (system is IteratingSystem && (system is FamilyOnAdd || system is FamilyOnRemove)) { updateAggregatedFamilyHooks(system.family) } } /** * Adds a new system to the world using the '+=' operator. * * @param system The system to be added to the world. This should be an instance of a class that extends IntervalSystem. * * @throws FleksSystemAlreadyAddedException if the system was already added before. */ operator fun plusAssign(system: IntervalSystem) = add(system) /** * Removes the specified system from the world using the '-=' operator. * * @param system The system to be removed from the world. This should be an instance of a class that extends IntervalSystem. */ operator fun minusAssign(system: IntervalSystem) { remove(system) } /** * Cache of used [EntityTag] instances. Needed for snapshot functionality. */ @PublishedApi internal val tagCache = mutableMapOf>() init { /** * Maybe because of design flaws, the world reference of the ComponentService must be * set in the world's constructor because the parent class (=EntityComponentContext) already * requires a ComponentService, and it is not possible to pass "this" reference directly. * * That's why it is happening here to set it as soon as possible. */ componentService.world = this } /** * Returns an already registered injectable of the given [name] and marks it as used. * * @throws FleksNoSuchInjectableException if there is no injectable registered for [name]. */ inline fun inject(name: String = T::class.simpleName ?: T::class.toString()): T { val injectable = injectables[name] ?: throw FleksNoSuchInjectableException(name) injectable.used = true return injectable.injObj as T } /** * Returns a new map of unused [injectables][Injectable]. An injectable gets set to 'used' * when it gets injected at least once via a call to [inject]. */ fun unusedInjectables(): Map = injectables.filterValues { !it.used }.mapValues { it.value.injObj } /** * Returns a new [EntityBag] instance containing all [entities][Entity] of the world. * * Do not call this operation each frame, as it can be expensive depending on the amount * of entities in your world. * * For frequent entity operations on specific entities, use [families][Family]. */ fun asEntityBag(): EntityBag { val result = MutableEntityBag(numEntities) entityService.forEach { result += it } return result } /** * Adds a new [entity][Entity] to the world using the given [configuration][EntityCreateContext]. * * **Attention** Make sure that you only modify the entity of the current scope. * Otherwise, you will get wrong behavior for families. E.g. don't do this: * * ``` * entity { * // modifying the current entity is allowed ✅ * it += Position() * // don't modify other entities ❌ * someOtherEntity += Position() * } * ``` */ inline fun entity(configuration: EntityCreateContext.(Entity) -> Unit = {}): Entity { return entityService.create(configuration) } /** * Returns true if and only if the [entity] is not removed and is part of the [World]. */ operator fun contains(entity: Entity) = entityService.contains(entity) /** * Removes the given [entity] from the world. The [entity] will be recycled and reused for * future calls to [World.entity]. */ operator fun minusAssign(entity: Entity) { entityService -= entity } /** * Removes all [entities][Entity] from the world. The entities will be recycled and reused for * future calls to [World.entity]. * If [clearRecycled] is true then the recycled entities are cleared and the ids for newly * created entities start at 0 again. */ fun removeAll(clearRecycled: Boolean = false) { entityService.removeAll(clearRecycled) } /** * Performs the given [action] on each active [entity][Entity]. */ fun forEach(action: World.(Entity) -> Unit) { entityService.forEach(action) } /** * Returns the specified [system][IntervalSystem]. * * @throws [FleksNoSuchSystemException] if there is no such system. */ inline fun system(): T { systems.forEach { system -> if (system is T) { return system } } throw FleksNoSuchSystemException(T::class) } /** * Sets the [hook] as an [EntityService.addHook]. * * @throws FleksHookAlreadyAddedException if the [EntityService] already has an add hook set. */ @PublishedApi internal fun setEntityAddHook(hook: EntityHook) { if (entityService.addHook != null) { throw FleksHookAlreadyAddedException("addHook", "Entity") } entityService.addHook = hook } /** * Sets the [hook] as an [EntityService.removeHook]. * * @throws FleksHookAlreadyAddedException if the [EntityService] already has a remove hook set. */ @PublishedApi internal fun setEntityRemoveHook(hook: EntityHook) { if (entityService.removeHook != null) { throw FleksHookAlreadyAddedException("removeHook", "Entity") } entityService.removeHook = hook } /** * Creates a new [Family] for the given [cfg][FamilyDefinition]. * * This function internally either creates or reuses an already existing [family][Family]. * In case a new [family][Family] gets created it will be initialized with any already existing [entity][Entity] * that matches its configuration. * Therefore, this might have a performance impact on the first call if there are a lot of entities in the world. * * As a best practice families should be created as early as possible, ideally during world creation. * Also, store the result of this function instead of calling this function multiple times with the same arguments. * * @throws [FleksFamilyException] if the [FamilyDefinition] is null or empty. */ fun family(cfg: FamilyDefinition.() -> Unit): Family = family(FamilyDefinition().apply(cfg)) /** * Creates a new [Family] for the given [definition][FamilyDefinition]. * * This function internally either creates or reuses an already existing [family][Family]. * In case a new [family][Family] gets created it will be initialized with any already existing [entity][Entity] * that matches its configuration. * Therefore, this might have a performance impact on the first call if there are a lot of entities in the world. * * As a best practice families should be created as early as possible, ideally during world creation. * Also, store the result of this function instead of calling this function multiple times with the same arguments. * * @throws [FleksFamilyException] if the [FamilyDefinition] is null or empty. */ @PublishedApi internal fun family(definition: FamilyDefinition): Family { if (definition.isEmpty()) { throw FleksFamilyException(definition) } val (defAll, defNone, defAny) = definition var family = allFamilies.find { it.allOf == defAll && it.noneOf == defNone && it.anyOf == defAny } if (family == null) { family = Family(defAll, defNone, defAny, this) allFamilies += family // initialize a newly created family by notifying it for any already existing entity // world.allFamilies.forEach { it.onEntityCfgChanged(entity, compMask) } entityService.forEach { family.onEntityCfgChanged(it, entityService.compMasks[it.id]) } } return family } /** * Returns a map that contains all [entities][Entity] and their components of this world. * The keys of the map are the entities. * The values are a list of components that a specific entity has. If the entity * does not have any components then the value is an empty list. */ fun snapshot(): Map { val result = mutableMapOf() entityService.forEach { result[it] = snapshotOf(it) } return result } /** * Returns a list that contains all components of the given [entity] of this world. * If the entity does not have any components then an empty list is returned. */ @Suppress("UNCHECKED_CAST") fun snapshotOf(entity: Entity): Snapshot { val comps = mutableListOf>() val tags = mutableListOf>() if (entity in entityService) { entityService.compMasks[entity.id].forEachSetBit { cmpId -> val holder = componentService.holderByIndexOrNull(cmpId) if (holder == null) { // tag instead of component tags += tagCache[cmpId] ?: throw FleksSnapshotException("Tag with id $cmpId was never assigned") } else { comps += holder[entity] } } } return Snapshot(comps as List>, tags as List>) } /** * Loads the given [snapshot] of the world. This will first clear any existing * entity of the world. After that it will load all provided entities and components. * This will also execute [FamilyHook]. * * @throws FleksSnapshotException if a family iteration is currently in process. */ fun loadSnapshot(snapshot: Map) { if (entityService.delayRemoval) { throw FleksSnapshotException("Snapshots cannot be loaded while a family iteration is in process") } // remove any existing entity and clean up recycled ids removeAll(true) if (snapshot.isEmpty()) { // snapshot is empty -> nothing to load return } val versionLookup = snapshot.keys.associateBy { it.id } // Set next entity id to the maximum provided id + 1. // All ids before that will be either created or added to the recycled // ids to guarantee that the provided snapshot entity ids match the newly created ones. with(entityService) { val maxId = snapshot.keys.maxOf { it.id } repeat(maxId + 1) { val entity = Entity(it, version = (versionLookup[it]?.version ?: 0u) - 1u) this.recycle(entity) val entitySnapshot = snapshot[versionLookup[it]] if (entitySnapshot != null) { // snapshot for entity is provided -> create it // note that the id for the entity will be the recycled id from above this.configure(this.create { }, entitySnapshot) } } } } /** * Loads the given [entity] and its [snapshot][Snapshot]. * If the entity does not exist yet, it will be created. * If the entity already exists it will be updated with the given components. * * @throws FleksSnapshotException if a family iteration is currently in process. */ fun loadSnapshotOf(entity: Entity, snapshot: Snapshot) { if (entityService.delayRemoval) { throw FleksSnapshotException("Snapshots cannot be loaded while a family iteration is in process") } if (entity !in entityService) { // entity not part of service yet -> create it entityService.create(entity.id) { } } // load components for entity entityService.configure(entity, snapshot) } /** * Updates all [enabled][IntervalSystem.enabled] [systems][IntervalSystem] of the world * using the given [deltaTime]. */ fun update(deltaTime: Float) { this.deltaTime = deltaTime systems.forEach { system -> if (system.enabled) { system.onUpdate() } } } /** * Removes all [entities][Entity] of the world and calls the * [onDispose][IntervalSystem.onDispose] function of each system. */ fun dispose() { entityService.removeAll() systems.reversed().forEach { it.onDispose() } } /** * Extend [Family.addHook] and [Family.removeHook] for all * [systems][IteratingSystem] that implement [FamilyOnAdd] and/or [FamilyOnRemove]. */ internal fun initAggregatedFamilyHooks() { // validate systems against illegal interfaces systems.forEach { system -> // FamilyOnAdd and FamilyOnRemove interfaces are only meant to be used by IteratingSystem if (system !is IteratingSystem) { if (system is FamilyOnAdd) { throw FleksWrongSystemInterfaceException(system::class, FamilyOnAdd::class) } if (system is FamilyOnRemove) { throw FleksWrongSystemInterfaceException(system::class, FamilyOnRemove::class) } } } // register family hooks for IteratingSystem.FamilyOnAdd containing systems systems .mapNotNull { if (it is IteratingSystem && it is FamilyOnAdd) it else null } .groupBy { it.family } .forEach { entry -> val (family, systemList) = entry val ownHook = worldCfgFamilyAddHooks.getOrPut(family) { family.addHook } family.addHook = if (ownHook != null) { entity -> ownHook(this, entity) systemList.forEach { it.onAddEntity(entity) } } else { entity -> systemList.forEach { it.onAddEntity(entity) } } } // register family hooks for IteratingSystem.FamilyOnRemove containing systems systems .mapNotNull { if (it is IteratingSystem && it is FamilyOnRemove) it else null } .groupBy { it.family } .forEach { entry -> val (family, systemList) = entry val ownHook = worldCfgFamilyRemoveHooks.getOrPut(family) { family.removeHook as FamilyHook } family.removeHook = if (ownHook != null) { entity -> systemList.forEachReverse { it.onRemoveEntity(entity) } ownHook(this, entity) } else { entity -> systemList.forEachReverse { it.onRemoveEntity(entity) } } } } /** * Update [Family.addHook] and [Family.removeHook] for all * [systems][IteratingSystem] that implement [FamilyOnAdd] and/or [FamilyOnRemove] * and iterate over the given [family]. */ private fun updateAggregatedFamilyHooks(family: Family) { // system validation like in initAggregatedFamilyHooks is not necessary // because it is already validated before (in initAggregatedFamilyHooks and in add/remove system) // update family add hook by adding systems' onAddEntity calls after its original world cfg hook val ownAddHook = worldCfgFamilyAddHooks.getOrPut(family) { family.addHook } val addSystems = systems.filter { it is IteratingSystem && it is FamilyOnAdd && it.family == family } family.addHook = if (ownAddHook != null) { entity -> ownAddHook(this, entity) addSystems.forEach { (it as FamilyOnAdd).onAddEntity(entity) } } else { entity -> addSystems.forEach { (it as FamilyOnAdd).onAddEntity(entity) } } // update family remove hook by adding systems' onRemoveEntity calls before its original world cfg hook val ownRemoveHook = worldCfgFamilyRemoveHooks.getOrPut(family) { family.removeHook } val removeSystems = systems.filter { it is IteratingSystem && it is FamilyOnRemove && it.family == family } family.removeHook = if (ownRemoveHook != null) { entity -> removeSystems.forEach { (it as FamilyOnRemove).onRemoveEntity(entity) } ownRemoveHook(this, entity) } else { entity -> removeSystems.forEach { (it as FamilyOnRemove).onRemoveEntity(entity) } } } @ThreadLocal companion object { @PublishedApi internal var CURRENT_WORLD: World? = null /** * Returns an already registered injectable of the given [name] and marks it as used. * * @throws FleksNoSuchInjectableException if there is no injectable registered for [name]. * @throws FleksWrongConfigurationUsageException if called outside a [WorldConfiguration] scope. */ inline fun inject(name: String = T::class.simpleName ?: T::class.toString()): T = CURRENT_WORLD?.inject(name) ?: throw FleksWrongConfigurationUsageException() /** * Creates a new [Family] for the given [cfg][FamilyDefinition]. * * This function internally either creates or reuses an already existing [family][Family]. * In case a new [family][Family] gets created it will be initialized with any already existing [entity][Entity] * that matches its configuration. * Therefore, this might have a performance impact on the first call if there are a lot of entities in the world. * * As a best practice families should be created as early as possible, ideally during world creation. * Also, store the result of this function instead of calling this function multiple times with the same arguments. * * @throws [FleksFamilyException] if the [FamilyDefinition] is null or empty. * @throws FleksWrongConfigurationUsageException if called outside a [WorldConfiguration] scope. */ fun family(cfg: FamilyDefinition.() -> Unit): Family = CURRENT_WORLD?.family(cfg) ?: throw FleksWrongConfigurationUsageException() } } private inline fun List.forEachReverse(action: (T) -> Unit) { val lastIndex = this.lastIndex for (i in lastIndex downTo 0) { action(this[i]) } } ```
RefuX commented 2 months ago

Great, I was just about to start looking at this and was writing some test cases 👍

Quillraven commented 2 months ago

looks good now - thanks a lot for adding the new test cases to also verify this special hook behavior <3

Btw, is it okay for you if this feature is currently only available in SNAPSHOT version? Or do you need a stable release for it?

RefuX commented 2 months ago

Btw, is it okay for you if this feature is currently only available in SNAPSHOT version? Or do you need a stable release for it?

I'm currently running off a local jar, so I'm not fussed 😎

RefuX commented 2 months ago

It's warning me about unmerged commits, so maybe I can't close this yet.

Quillraven commented 2 months ago

Yeah, I did not merge it yet, just approved it :) Will merge it tomorrow.

One more thing that I noticed now: it seems like systems.forEach is now using an iterator in the generated Bytecode which is not ideal because that floods the garbage collector with useless objects in our scenario.

Can you please update that to use a for loop with indices inside world.update?

Quillraven commented 2 months ago

@RefuX: should be ready in SNAPSHOT version in a few minutes. Thanks again for your contribution, especially for adding the test cases to cover the special hook behavior!

RefuX commented 2 months ago

Great, thanks so much, using the SNAPSHOT 👍