HexDecimal / python-tcod-ec

Entity/Component containers for implementing composition over inheritance. Works well with type hinting.
MIT License
1 stars 0 forks source link

what is the purpose of this module? #1

Closed rumly111 closed 1 year ago

rumly111 commented 1 year ago

The examples don't really explain why I should use this module, only how.

So this is a simplified version of ECS pattern, correct? The way I understand it, Entity represents a lot of things that have visual representation on the screen, like player, mobs, items. Component is some sort of property those entities have, and System is the "game logic" that operates the other two. Am I correct assuming that the principle is the same as the CMS in web-developing, it's purpose is to divide the code that does what it's supposed to do?

Anyway, we have tcod-ec. There are some examples, but I still don't get the gist of WHY. Why should I use entity[Something] = blah when I could just have this Something in my entity class directly (entity.Something = blah)? What do I gain?

If we look at the python roguelike tutorial, does tcod-ec help solving the cross-referencing of classes?

HexDecimal commented 1 year ago

So this is a simplified version of ECS pattern, correct?

Almost, This is some of my personal experiments with an entity/component framework until I'm comfortable enough to implement full ECS. This is related to but distinct from ECS (no systems, and no global registry, yet). Although depending on how I handle this module the lines might end up blurred.

Am I correct assuming that the principle is the same as the CMS in web-developing, it's purpose is to divide the code that does what it's supposed to do?

I'm not familiar with CMS. ECS or Unity components might make better comparisons. ECS is supposed to be more strict with where behaviors and data are allowed to be, but Python tends to allow you do anything with any object anywhere and this module tries to synergize with Python rather than fighting it. So far almost all Python ECS modules allow behaviors in components including this one. Also most other modules including this one do not account for static types and memory locality, but I have this planned for later. These behaviors encouraged by how Python works makes sense for an entity/component framework than for performant ECS.

The way I understand it, Entity represents a lot of things that have visual representation on the screen, like player, mobs, items.

It's a false assumption that an entity is required to represent anything specific. An entity is defined by its components in isolation. In particular, it does not have to be displayable or tangible, but those concepts make for good examples.

Anyway, we have tcod-ec. There are some examples, but I still don't get the gist of WHY. Why should I use entity[Something] = blah when I could just have this Something in my entity class directly (entity.Something = blah)? What do I gain?

I do have the answer for this. This module follows the open–closed principle. Your example, while looking similar, has already violated the open–closed principle by requiring modifications to its entity class to add support for any new components.

To follow the open–closed principle, a class has to be:

The syntax entity[Something] allows for strong type-checking (with Type[T] -> T) while allowing for arbitrary components while also following the open–closed principle.

entity.Something only allows for one specific type defined in the class. This can only follow the open–closed principle if you intend to ignore type-checking and treat Python objects fully dynamically (this will make refactoring very difficult). This module tries to have it all: type-checking, extendability, stability, and simplicity.

If we look at the python roguelike tutorial, does tcod-ec help solving the cross-referencing of classes?

Yes. By being closed for modification entity classes from the tutorial no longer need to reference the component they expect to use. This kind of standalone module isn't even possible following the tutorials standards. By being open for extension new components can be freely made for entities and used on the spot without having to make every other module aware of those new components. Components will need to be made simpler and if they don't hold behaviors involving outside components then they won't need first party imports at all. Objects get passed around as entities and are checked for their components, so components no longer need a back-reference to their owner. If any hierarchy is included then it follows the standard of ECS with parent and children components.

I plan to demonstrate this in the near future.

rumly111 commented 1 year ago

I can't tell I'm convinced. I will wait for more examples. For me fundamentally the difference between using this module and not is Player.hp = 3 and Player[hp] = 3 . Python is a dynamic language, so you can modify any object as you wish. If you don't want to do it, then don't do it. Why put constrains on yourself?

HexDecimal commented 1 year ago

I'd like to put in the effort to convince you. Although if you're not interested in type-hinting I'll likely have a much harder time.

Player[hp] = 3 is not a valid example. The key used has to be a class and what's returned is always an instance of that class. You'd need to make an HP or Health class for this or keep these all in a collective stats-block class.

class Armor:
    value: int

class Equipment:
    equipped: Dict[str, ComponentDict]  # body_location: item
    # Notice this will contain Armor but this never references the Armor class directly.

class Stats:
    hp: int
    hp_max: int

# Notice: The above have no first party dependencies.
# No class depends on any others, only on ComponentDict.

def get_defense(entity: ComponentDict) -> int:
    """Return the combined value of all armor worn by this entity."""
    if Equipment not in entity:
        return 0
    return sum(item[Armor].value for item in entity[Equipment].equipped.values() if Armor in item)

def damage_entity(entity: ComponentDict, damage: int) -> None:
    """Apply normal damage to an entity."""
    assert damage >= 0
    damage = max(0, damage - get_defense(entity))
    entity[Stats].hp -= damage
    if entity[Stats].hp < 0:
        on_death(entity)

Consider the above compared to:

class Actor:
   hp: int
   hp_max: int
   equipped: Dict[str, Item]

Whatever Item is, you now have to import it to hint this class correctly.

class Item:
    name: str  # Names would be another component with ComponentDict.

class Armor(Item):  # Items as a hierarchy.
    value: int

# Getting armor values can still be done with isinstance, so far this isn't bad.
# If you think this might be good to add as a method to Actor, that will likely introduce dependency cycles with Armor and make things much worse.
def get_defense(actor: Actor) -> int:
    """Return the combined value of all armor worn by this actor."""
    return sum(item.value for item in actor.equipped.values() if isinstance(item, Armor))

Now for a more complex example. What if the armor can be used to do non-armor things like cast spells. You might have a reusable charm item for spells so you'd need to combine them. Back to the ComponentDict example:

class Charm:
    spell: ComponentDict  # This does not define or depend on the definition of spells.
    recharge_time: int  # The cool-down time between casts.
    ready_on: int = 0  # The turn this spell will be ready.

# Assume I've been using attrs.define for classes.  As components these can be easily combined.
magic_armor = ComponentDict(
    [
        Name(name="spell armor"),
        Armor(value=3),
        Charm(spell=ComponentDict(), recharge_time=50),
    ]
)  # Can be worn like armor and cast like a charm.

With Item as a class hierarchy things get ugly at this point. Multiple inheritance is required so that different items can pass different isinstance checks. You also can't easily change from one combination of types to another like you can with EC/ECS/ComponentDict so no wild effects such as adding random charms to existing items.

ComponentDict can continue to accumulate features forever, a new component is a new feature and that feature is active where it's used and ignored where it isn't. A normal class with normal class attributes will be crushed under its own complexity as it has to know about every feature it has and if that feature is active. This is one of the main examples to show off composition over inheritance.

rumly111 commented 1 year ago

Hmm, I can see the disadvantages of multiple inheritances from your example. It can really turn ugly fast.

When class A inherits B and C, we basically say "class A is also B and C", but when using composition, we say "class A has the qualities of B and C".

I will tell you why I have subconscious resistance not to the idea "composition over inheritance", but maybe to your implementation.

First of all when using brackets for accessing some inner property of a class (__getitem__), I'm used to either player[0], player[13], or player['hp'], player['inventory'] . So I expect int or str, I could maybe live with player[hp], a class instance with lowercase name. But when I see player[Armor], I feel like a class should not be a key for __getitem__, because Armor is a type of something by logic. ( But of course in python Armor is also an instance of type class, if I'm not wrong. )

I could see something like player[armor] (lowercase) more natural. It is even more awkward for me to see something like item[Armor].value = ..., for 3 reasons: mixing upper- and lowercase, mixing of __getitem__ and __getattribute__, and also that Armor is a class, not instance. While there is nothing wrong with player[Armor].defense, it is not very pleasing to the eye. One of these could work: player.armor.defense, player['armor']['defense'], or even skipping armor and use player.defense . You could achieve this not by inheritance, but by manipulating __getattribute__ method of the basic ComponentDict class, but of course it would not be a "component dict" anymore.

HexDecimal commented 1 year ago

I'll try to be more clear on the internals then:

    def __getitem__(self, key: Type[T]) -> T:
        """Return a component of type, raises KeyError if it doesn't exist."""

This is the type-hinted __getitem__ method. It takes Type[T] and returns T. In other words it takes a class and returns an instance of that class. If the class is missing then it raises an error to enforce a strict return value. Compared to the .get method which returns Optional[T] instead and will return None on a missing class. __getitem__ could also have been called get_component with the same type hints without issues, but this is the signature for __getitem__ due to how it's being used to look up the container like a dict.

Something to note: If one types out entity[Someclass]. into an IDE then the IDE will auto-complete the attributes belonging to Someclass because the static tools are aware of how __getitem__ works. This is not an unusual usage of type hints in Python. This complex usage of hints comes from other languages with rich generics. Things such as support for auto-completion are the main benefit of this ComponentDict over a plain dict, the other being static type checking while following the open–closed principle.

Your last post complains about the aesthetics of the syntax. Using the class as a key is not an aesthetic choice but a practical one. This is required to associate an entities component as being an instance of the class used as a key, and there is no alternative without breaking static type checking or violating the open–closed principle. It is important to show that the keys being used here really are a class/type. Any attempt to hide that obfuscates the code and should be discouraged. I won't so easily violate Python naming conventions just because something looks better lower-case.

Your suggestions only work if one dismisses static typing. I have a lot of experience with and without type-hints and I'll never be writing code without hinting ever again, and I'd really prefer to not read code without hinting either.

rumly111 commented 1 year ago

The idea of using classes as dictionary keys needs some getting used to, but it's not impossible.

I prefer making a few dirty hacks if it allows me to write good looking code in most places. For example:

class CoolConteinerDict(ContainerDict):
    def __getattribute__(self, name: str) -> T:
        components = super().__getattribute__('_components')
        for ck, cv in components.items():
            if cv.__class__.__name__.lower() == name.lower():
                return self[ck]
        return super().__getattribute__(name)

    def __setattr__(self, name: str, value: T) -> None:
        if name == '_components':
            components = dict()
        else:
            components = super().__getattribute__('_components')
        for ck, cv in components.items():
            if cv.__class__.__name__.lower() == name.lower():
                self[ck] = value
                return
        super().__setattr__(name, value)

It will allow me to use both player[Armor].defense and player.armor.defense syntax. It will probably break my IDE auto-completion somewhat, but for the small projects I write I don't care much.

rumly111 commented 1 year ago

This is another topic, but in debian 11 the package name is python3-attr, which means test_ec.py needs to import attr, not attrs.

I also noticed that the abstract_component decorator doesn't have to be used, but it's still somewhat useful. For example:


@attr.define
class Stats:
    hp: int
    hp_max: int

@abstract_component
@attr.define
class Armor:
    defense: int

@abstract_component
@attr.define
class MagicArmor(Armor):
    m_defense: int

def main():
    player = ComponentDict([Stats(hp=3,hp_max=10), MagicArmor(defense=5,m_defense=7)])

    print(f'{player[Stats]=}')
    print(f'{Armor in player=}')
    print(f'{MagicArmor in player=}')
    # print(f'{player[Armor]=}')
    # print(f'{player[MagicArmor]=}')

Depending on usage of decorator the code can work differently. It would be nice if we could use both player[Armor] and player[MagicArmor], but I guess that is pipe dream.

HexDecimal commented 1 year ago
class CoolConteinerDict(ContainerDict):
    def __getattribute__(self, name: str) -> T:

You can't have T on one side unfortunately. This ends up being equivalent to (self, name: str) -> Any and you might as well use Dict[str, Any] at that point. You've mostly just invented one of the many dotted dict libraries on PyPI which you could be using instead.

To keep the type association you'd have to at least add a property for each attribute:

class CoolConteinerDict(ContainerDict):
    @property
    def armor(self) -> Armor:
        return self[Armor]

    @armor.setter
    def armor(self, value: Armor) -> None:
        self[Armor] = value

Or if I remember descriptors correctly one could do this:

class ComponentAccess(Generic[T]):
    __slots__ = ("key",)

    def __init__(self, key: Type[T]) -> None:
        self.key = key

    def __get__(self, obj: ContainerDict, objtype: Any = None) -> T:
        return obj[self.key]

    def __set__(self, obj: ContainerDict, value: T) -> None:
        obj[self.key] = value

    def __delete__(self, obj: ContainerDict) -> None:
        del obj[self.key]

class CoolConteinerDict(ContainerDict):
    armor = ComponentAccess(Armor)

The above classes violate the open–closed principle because you have to manually add attributes to the class to extend it. Using plain Dict[str, Any] does not violate the open–closed principle but doesn't have strong typing.

I plan on removing abstract_component since this can be done by adding a new class with the abstract component as an attribute, and I have something else planned which can handle this case better.

I plan on adding a new container which can store multiple instances of the same class and can access all contained objects derived from a given base class. While this fixes the obvious limitations with ContainerDict I feel that this new class might have a lot of anti-patterns. It feels like there's nothing wrong with the new container itself, it's just very easy to use incorrectly.