benmoran56 / esper

An ECS (Entity Component System) for Python
MIT License
537 stars 67 forks source link

Event System does not preserve event handlers upon switching context #97

Closed sb3rg closed 6 months ago

sb3rg commented 6 months ago

Describe the bug Event System does not preserve event handlers cache upon switching context

To Reproduce

def test_ecs_switch_registry():
    class DummyHandler():
        def __init__( self, world ):
            self.WORLD = world
        def handle( self, state ):
            pass

    handler = DummyHandler( 'default' )
    # assign a handler for using the event handling system
    esper.set_handler( "Handle", handler.handle )
    print( esper.event_registry )
    ## >>> {'Handle': {<weakref at 0x7fe2b5828900; to 'DummyHandler' at 0x7fe2b5b0f580>}}
    print( esper._current_context )
    ## >>> default
    esper.switch_world( 'test_world' )
    print( esper.event_registry )
    ## >>> {}
    print( esper._current_context )
    ## >>> test_world

Expected behavior If the event_registry is a part of the module, why would it clear the event_registry upon switching worlds? Expect event_registry to preserve the reference to the handlers regardless of which world is the current_context (Don't you think?)

Development environment:

Additional context Perhaps this is what is supposed to happen in v3? But in the versions less than 3, switching worlds didn't affect the event_registry because you were explicitly passing around world references vs now having to manage the implicit context.

benmoran56 commented 6 months ago

The shared event system in v2.x was a limitation, not a feature. The current behavior mentioned in the README:

Registered events and handlers are part of the current World context.

The event system in esper is provided to allow inter-Process communications. This is a farily common feature in ECS libraries, and has some specific use cases. Keeping events contained to the active context is the common case.

For games with multiple contexts, you often have several instances of the same Processors across multiple World contexts, dispatching events to each other. Processors in inactive contexts generally shouldn't be receiving events from the active context. For example, common events would be things such as the current player picked up a coin, or the map should changed, etc. This is a limited scope event framework. If you are looking for something higher level, such as handling application wide mouse events, it will not fill that need.

sb3rg commented 6 months ago

Got it. Thank you for clarifying. I suppose that makes sense. However, I will say that this can make it quite hairy to debug for anyone else who makes extensive use of different worlds and has to switch context in novel ways--i.e. I don't use worlds at all for switching scenes as I'm not building a game. Tracing your own code, it may not always be clear whether you're in the right context to get access the right set of handlers.

For me, this meant wrapping your entire library with an adapter then putting guards in place to protect myself from accidentally being in the wrong context. It works for me and protects me from any more breaking changes in this library. But, I imagine I'm going to be taking a performance hit and now I have to make checks that I didn't have to in v2.

def add_comp_to( world, v_id, component_class ):
    # world.add_component( v_id, component_class() )
    switch_cx( world )
    esper.add_component( v_id, component_class() )

def has_comp( world, v_id, component_class ):
    # return world.has_component( v_id, component_class )
    switch_cx( world )
    return esper.has_component( v_id, component_class )

def get_ent_comp( world, v_id, component_class ):
    # return world.component_for_entity( v_id, component_class )
    switch_cx( world )
    return esper.component_for_entity( v_id, component_class )

I'll mark this closed.

benmoran56 commented 6 months ago

There shouldn't be all that much overhead to do that. The context switch itself is essentially nothing more than the cost of a function call and a dozen attribute assignments. For example, switching between two contexts a million times:

>>> timeit.timeit("esper.switch_world('new'); esper.switch_world('old')", setup="import esper", number=1_000_000)
0.3097645400002875

For games in particular, this is only done once or twice per frame (or not at all), so it's pretty much negligible. For your case, I suppose it depends on how many times you're switching contexts.

You might also consider making a simple Python context manager to do this. I haven't tested this code, but it should work:

class WorldContext:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        esper.switch_world(self.name)

    def __exit__(self, exc_type, exc_val, exc_tb):
        pass

Then just make an instance of each World you need, and use them with the with operator:


worlda = WorldContext('a')
worldb = WorldContext('b')

with worlda:
    esper.get_components(......)
sb3rg commented 6 months ago

Thank you! This is very helpful. I will incorporate this into my wrapper. I appreciate the time and attention you give to your project. I think esper is quite the special little library. It's small, easy to understand and performant and I hope to contribute to it down the road.