Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v0.4] Allow removing all components associated with prefab, or add component families #171

Open
soulofmischief opened this issue Jan 21, 2025 · 1 comment

Comments

@soulofmischief
Copy link

soulofmischief commented Jan 21, 2025

I love the new API, thanks for all of the hard work. After implementing it in a test project, I did come across a thorny issue whenever the nature of an entity must change, ex. transitioning from a physics body to an inventory item.

Say we have a scenario where an item is collected and added to a player's inventory. The item initially exists in the scene and has physics components, but after it's added to the inventory, the item should no longer have those components.

index.ts

import {
  IsA,
  addComponent,
  addEntity,
  addPrefab,
  createWorld
} from 'bitecs'

import { Position, Velocity, Acceleration } from '@components/index.js'
import { RigidBody, Player, Item } from '@prefabs.js'
import { CollectedBy } from '@relations.js'
import { collectionSystem } from '@systems/index.js'

const world = createWorld()

const player = addEntity( world )
addComponent( world, player, IsA( Player ))

const item = addEntity( world )
addComponent( world, item, IsA( Item ))

// Later...

addComponent( world, item, CollectedBy( player ))
collectionSystem( world )

collectionSystem.ts

import { getRelationTargets } from 'bitecs'
import { CollectedBy, Has, Targeting } from '@relations.ts'

export function collectionSystem( world ) {
  for ( const collected of query( world, [ CollectedBy( Wildcard )])) {
    const collector = getRelationTargets( world, collected, CollectedBy )[0]
    collect( world, collector, collected )
  }
}

function collect( world, collector, collected ) {
  // Add item to collector's inventory.
  addComponent( world, collector,
    set( Has( collected ), { amount: collectedAmount + 1 })
  )

  // Stop targeting the item.
  removeComponent( world, collector, Targeting( collected ))

  // Remove unneeded components from item.
  removeComponent( world, collected,
    Target,
    CollectedBy,
    Wildcard( Targeting ),
    // Should removing Mesh also remove the inherited RigidBody's 
    // components, or just the ones that Mesh introduces?
    // Tradeoffs either way.
    IsA( Mesh ),
    IsA( RigidBody ),
  )

  // Uh oh... it still has the Position component.
  console.log( hasComponent( world, collected, Position ) // true

  /*
   * Current workaround:
   * removeComponent( world, collected,
   *   IsA( Mesh ),
   *   IsA( RigidBody ),
   *   Target,
   *   CollectedBy,
   *   Wildcard( Targeting ),
   *   Acceleration,
   *   Position,
   *   Velocity,
   *   // ...some other 20 components that need removing,
   *   // with no source of truth on which components need
   *   // to be removed under different conditions.
   * )
   */
}

prefabs.ts

import {
  Acceleration,
  Color,
  Geometry,
  Position,
  Target,
  Velocity,
} from '@components/index.ts'

// RigidBody
const RigidBody = addPrefab( world )
addComponent( world, RigidBody, Acceleration, Position, Velocity )

const Mesh = addPrefab( world )
addComponent( world, Mesh, IsA( RigidBody ), Color, Geometry )

// Item
const Item = addPrefab( world )
addComponent( world, Item, IsA( Mesh ), Target )

// Player
const Player = addPrefab( world )
addComponent( world, Player, IsA( Mesh ), Target )

The current workaround is prone to bugs, as individually tracking the components which need removal can be very tedious for systems with hundreds of components. Additionally, you have to remove all prefabs, because I use prefabs to simplify queries and improve performance (instead of querying for every single needed component for physics, for example, I just query IsA( RigidBody )). Tracking all of that manually sounds like a total mess.

I can think of a few other solutions, such as having a BaseItem prefab, then maybe a PhysicsItem and InventoryItem prefab which inherit them, and then removing the original entity and creating a new one to add to the inventory from the InventoryItem prefab.

const item = addEntity( world )
addComponent( world, item,
  IsA( PhysicsItem ),
  // ...set relevant component data for each kind of item.
)

removeEntity( item )

const newItem = addEntity( world )

addComponent( world, newItem,
  IsA( InventoryItem ),
  // ...set relevant component data for each kind of item.
)

But this would be complicated if I have specific prefabs which inherit from the Item prefab, such as Weapon or something. Would that inherit the BaseItem prefab and require manual, bespoke instantiation of PhysicsItem/InventoryItem each time, or would I need a PhysicsWeapon and InventoryWeapon? I don't think this is the right path.

Either way, while workarounds can be had, there should generally be One Way To Do Something in a framework like an ECS. One of the benefits of an ECS is, after learning how to use one properly, there's rarely a question of how to do something, the Right Way to do it should be immediately obvious.

This doesn't mean sacrificing integral flexibility, a user is still free to use whatever paradigms they want on top of the core ECS, but the ECS semantics should be enough to do anything. Therefore, I think bitECS should provide semantics which make this problem trivial to solve, without needing developers to keep track of individual components in large projects.

Another alternative would be component families, which in the simplest form could just be an array of components, which certainly works with these semantics:

const PhysicsComponents = [ Acceleration, Position, Velocity ]

addComponent( world, RigidBody, ...Components )

// Later...
removeComponent( world, eid, ...Components )

but it doesn't mesh with set semantics, so it wouldn't idomatically work with:

addComponent( world, RigidBody,
  set( Position, { x: 0, y: 0 })
)

You could perhaps instead have an array of sets or something else like:

const PhysicsComponents = [
  set( Acceleration, { x: 0, y: 0 }),
  set( Position, { x: 0, y: 0 }),
  set( Velocity { x: 0, y: 0 }),
]

addComponent( world, RigidBody, ...PhysicsComponents )

// But it would need support for removeComponent
// since it expects components, not set objects.
removeComponent( world, RigidBody, ...PhysicsComponents )
)

A simple wrapper around the set array which behaves differently in addComponent and removeComponent might work, if that isn't too magic. It may look like this:

const PhysicsComponents = addFamily(
  set( Acceleration, { x: 0, y: 0 }),
  set( Position, { x: 0, y: 0 }),
  set( Velocity { x: 0, y: 0 }),
)

const MeshComponents = addFamily(
  set( Color, { r: 0, g: 0, b: 0, a: 0 }),
  set( Geometry, DefaultGeometry()),
)

const RigidBody = addEntity( world )
addComponent( world, RigidBody,
  PhysicsComponents,
  // Setting/including a component after including the family should just work without issues.
  // Removing a family later should still remove Position.
  set( Position, { x: 100, y: 100 })
)

const Mesh = addEntity( world )
addComponent( world, Mesh, IsA( RigidBody ), MeshComponents )

const Item = addPrefab( world )
addComponent( world, Item, IsA( Mesh ))

const Weapon = addPrefab( world )
addComponent( world, Weapon, IsA( Item ))

const item = addEntity( world )
addComponent( world, item, IsA( Weapon ))

// Later...

// But should this automatically remove families?
removeComponent( world, item, IsA( RigidBody ))

/* or... */

// A wrapper component/relation would be more intentional.
removeComponent( world, item, IncludeFamilies( IsA( RigidBody )))

/* or... */

// More verbose, but still significantly reduces cognitive load in complex systems with hundreds of components.
removeComponent( world, item, IsA( RigidBody ), PhysicsComponents, /* ...other families */ ) 

Of course, introducing a new primitive increases the learning curve of bitECS, especially clearly differentiating a family vs prefab. And thought needs to be given to how this all works with inheritance.

And of course, we could just have all of our systems check if an item is inventoried or not, but that is a leaky abstraction which spreads to all affected systems, increases coupling and introduces more chances for code to become stale. Not to mention its impact on query performance for games with a large amount of systems.

BTW, I realize Mesh probably shouldn't inherit RigidBody and they should just live side-by-side, but I wanted a toy example that would illustrate the issues around inheritance.

Anyway, I just wanted to get a discussion rolling. Did you already have a solution in mind for this, or are there existing semantics which accomplish this satisfactorily?

@NateTheGreatt
Copy link
Owner

NateTheGreatt commented Jan 23, 2025

some utils to remove all components is definitely a good idea to add, will get that into 0.4 before release. one for specifically removing components of a prefab is interesting... however, one could also just remove the old entity and create a new one. would that be insufficient for you needs here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants