diff --git a/ppb/engine.py b/ppb/engine.py index cf4ef20c..fc1fb102 100644 --- a/ppb/engine.py +++ b/ppb/engine.py @@ -1,8 +1,8 @@ +import time from collections import defaultdict from collections import deque from contextlib import ExitStack from itertools import chain -import time from typing import Any from typing import Callable from typing import DefaultDict @@ -11,19 +11,33 @@ from typing import Union from ppb import events -from ppb.eventlib import EventMixin +from ppb.assetlib import AssetLoadingSystem +from ppb.errors import BadEventHandlerException from ppb.systems import EventPoller from ppb.systems import Renderer -from ppb.systems import Updater from ppb.systems import SoundController -from ppb.assetlib import AssetLoadingSystem +from ppb.systems import Updater from ppb.utils import LoggingMixin - +from ppb.utils import camel_to_snake _ellipsis = type(...) +_cached_handler_names = {} + -class GameEngine(EventMixin, LoggingMixin): +def _get_handler_name(txt): + result = _cached_handler_names.get(txt) + if result is None: + result = "on_" + camel_to_snake(txt) + _cached_handler_names[txt] = result + return result + + +for x in events.__all__: + _get_handler_name(x) + + +class GameEngine(LoggingMixin): def __init__(self, first_scene: Type, *, basic_systems=(Renderer, Updater, EventPoller, SoundController, AssetLoadingSystem), @@ -127,17 +141,26 @@ def publish(self): scene = self.current_scene event.scene = scene extensions = chain(self.event_extensions[type(event)], self.event_extensions[...]) + + # Hydrating extensions. for callback in extensions: callback(event) - self.__event__(event, self.signal) - for system in self.systems: - system.__event__(event, self.signal) - # Required for if we publish with no current scene. - # Should only happen when the last scene stops via event. - if scene is not None: - scene.__event__(event, self.signal) - for game_object in scene: - game_object.__event__(event, self.signal) + + event_handler_name = _get_handler_name(type(event).__name__) + for obj in self.walk(): + method = getattr(obj, event_handler_name, None) + if callable(method): + try: + method(event, self.signal) + except TypeError as ex: + from inspect import signature + sig = signature(method) + try: + sig.bind(event, self.signal) + except TypeError: + raise BadEventHandlerException(obj, event_handler_name, event) from ex + else: + raise def on_start_scene(self, event: events.StartScene, signal: Callable[[Any], None]): """ @@ -212,3 +235,10 @@ def flush_events(self): the wrong scene. """ self.events = deque() + + def walk(self): + yield self + yield from self.systems + yield self.current_scene + if self.current_scene is not None: + yield from self.current_scene diff --git a/ppb/errors.py b/ppb/errors.py new file mode 100644 index 00000000..e1766616 --- /dev/null +++ b/ppb/errors.py @@ -0,0 +1,25 @@ +class BadEventHandlerException(TypeError): + + def __init__(self, instance, method, event): + object_type = type(instance) + event_type = type(event) + o_name = object_type.__name__ + e_name = event_type.__name__ + article = ['a', 'an'][int(e_name.lower()[0] in "aeiou")] + + message = f""" +{o_name}.{method}() signature incorrect, it should accept {article} {e_name} object and a signal function. + +{e_name} is a dataclass that represents an event. Its attributes +tell you about the event. + +The signal function is a function you can call that accepts an event instance +as its only parameter. Call it to add an event to the queue. You don't have to +use it, but it is a mandatory argument provided by ppb. + +It should look like this: + +def {method}({e_name.lower()}_event: {e_name}, signal_function): + (Your code goes here.) +""" + super().__init__(message) diff --git a/ppb/eventlib.py b/ppb/eventlib.py deleted file mode 100644 index 4219036e..00000000 --- a/ppb/eventlib.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -The event machinery -""" - -import logging -import re - -__all__ = ( - 'EventMixin', 'BadEventHandlerException', -) - -boundaries_finder = re.compile('(.)([A-Z][a-z]+)') -boundaries_finder_2 = re.compile('([a-z0-9])([A-Z])') - - -def camel_to_snake(txt): - s1 = boundaries_finder.sub(r'\1_\2', txt) - return boundaries_finder_2.sub(r'\1_\2', s1).lower() - - -class BadEventHandlerException(TypeError): - - def __init__(self, instance, method, event): - object_type = type(instance) - event_type = type(event) - o_name = object_type.__name__ - e_name = event_type.__name__ - article = ['a', 'an'][int(e_name.lower()[0] in "aeiou")] - - message = f""" -{o_name}.{method}() signature incorrect, it should accept {article} {e_name} object and a signal function. - -{e_name} is a dataclass that represents an event. Its attributes -tell you about the event. - -The signal function is a function you can call that accepts an event instance -as its only parameter. Call it to add an event to the queue. You don't have to -use it, but it is a mandatory argument provided by ppb. - -It should look like this: - -def {method}({e_name.lower()}_event: {e_name}, signal_function): - (Your code goes here.) -""" - super().__init__(message) - - -class EventMixin: - def __event__(self, bag, fire_event): - elog = logging.getLogger('game.events') - - name = camel_to_snake(type(bag).__name__) - meth_name = 'on_' + name - meth = getattr(self, meth_name, None) - if callable(meth): - try: - elog.debug(f"Calling handler {meth} for {name}") - meth(bag, fire_event) - except TypeError as ex: - from inspect import signature - sig = signature(meth) - try: - sig.bind(bag, fire_event) - except TypeError: - raise BadEventHandlerException(self, meth_name, bag) from ex - else: - raise diff --git a/ppb/features/twophase.py b/ppb/features/twophase.py index eab18e7c..45b05bdb 100644 --- a/ppb/features/twophase.py +++ b/ppb/features/twophase.py @@ -3,7 +3,6 @@ """ from dataclasses import dataclass from ppb.systemslib import System -from ppb.eventlib import EventMixin __all__ = 'Commit', @@ -24,7 +23,7 @@ def on_update(self, event, signal): signal(Commit()) -class TwoPhaseMixin(EventMixin): +class TwoPhaseMixin: """ Mixin to apply to objects to handle two phase updates. """ diff --git a/ppb/scenes.py b/ppb/scenes.py index 9c6614f2..a1e7325f 100644 --- a/ppb/scenes.py +++ b/ppb/scenes.py @@ -9,7 +9,6 @@ from typing import Type from ppb.camera import Camera -from ppb.eventlib import EventMixin class GameObjectCollection(Collection): @@ -96,7 +95,7 @@ def remove(self, game_object: Hashable) -> None: s.discard(game_object) -class BaseScene(EventMixin): +class BaseScene: # Background color, in RGB, each channel is 0-255 background_color: Sequence[int] = (0, 0, 100) container_class: Type = GameObjectCollection diff --git a/ppb/sprites.py b/ppb/sprites.py index 745cf673..1c9cf440 100644 --- a/ppb/sprites.py +++ b/ppb/sprites.py @@ -15,7 +15,6 @@ from ppb_vector import Vector import ppb -from ppb.eventlib import EventMixin from ppb.utils import FauxFloat __all__ = ( @@ -35,7 +34,7 @@ side_attribute_error_message = error_message.format -class BaseSprite(EventMixin): +class BaseSprite: """ The base Sprite class. All sprites should inherit from this (directly or indirectly). diff --git a/ppb/systemslib.py b/ppb/systemslib.py index ef827993..6ad857d8 100644 --- a/ppb/systemslib.py +++ b/ppb/systemslib.py @@ -1,7 +1,4 @@ -from ppb import eventlib - - -class System(eventlib.EventMixin): +class System: def __init__(self, **_): pass diff --git a/ppb/utils.py b/ppb/utils.py index fc01952b..bc3056eb 100644 --- a/ppb/utils.py +++ b/ppb/utils.py @@ -1,9 +1,10 @@ import logging -import sys -import numbers import math +import numbers +import re +import sys -__all__ = 'LoggingMixin', 'FauxFloat', +__all__ = 'LoggingMixin', 'FauxFloat', 'camel_to_snake' # Dictionary mapping file names -> module names @@ -22,6 +23,15 @@ def _build_index(): } +_boundaries_finder = re.compile('(.)([A-Z][a-z]+)') +_boundaries_finder_2 = re.compile('([a-z0-9])([A-Z])') + + +def camel_to_snake(txt): + s1 = _boundaries_finder.sub(r'\1_\2', txt) + return _boundaries_finder_2.sub(r'\1_\2', s1).lower() + + def _get_module(file_name): """ Find the module name for the given file name, or raise KeyError if it's diff --git a/tests/test_events.py b/tests/test_events.py index ba95713a..3071a2c6 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,62 +1,8 @@ from pytest import mark from pytest import raises -from ppb.eventlib import BadEventHandlerException -from ppb.eventlib import camel_to_snake -from ppb.eventlib import EventMixin - - -def test_eventmixin(): - passed_bag = None - passed_fire = None - - class Spam: - pass - - class Eventable(EventMixin): - def on_spam(self, bag, fire_event): - nonlocal passed_bag, passed_fire - passed_fire = fire_event - passed_bag = bag - - bag = Spam() - fire_event = lambda: None - - e = Eventable() - - e.__event__(bag, fire_event) - assert bag is passed_bag - assert fire_event is passed_fire - - -def test_event_mixin_with_bad_signature(): - - class BadSpam: - pass - - - class Spam: - pass - - - class Eventable(EventMixin): - def on_spam(self, spam_event): - pass - - def on_bad_spam(self, bad_spam_event, signal): - raise TypeError - - e = Eventable() - - with raises(BadEventHandlerException): - e.__event__(Spam(), lambda x: None) - - with raises(TypeError) as exception_info: - e.__event__(BadSpam(), lambda x: None) - - exec = exception_info.value - assert not isinstance(exec, BadEventHandlerException) - +from ppb.errors import BadEventHandlerException +from ppb.utils import camel_to_snake @mark.parametrize("text,expected", [ ("CamelCase", "camel_case"), diff --git a/tests/test_testutil.py b/tests/test_testutil.py index f2e5f4bf..fa0310eb 100644 --- a/tests/test_testutil.py +++ b/tests/test_testutil.py @@ -9,12 +9,12 @@ from ppb.events import Quit -@mark.parametrize("loop_count", range(1, 6)) +@mark.parametrize("loop_count", list(range(1, 6))) def test_quitter(loop_count): quitter = testutil.Quitter(loop_count=loop_count) signal_mock = Mock() for i in range(loop_count): - quitter.__event__(Idle(.01), signal_mock) + quitter.on_idle(Idle(.01), signal_mock) signal_mock.assert_called_once() assert len(signal_mock.call_args[0]) == 1 assert len(signal_mock.call_args[1]) == 0 @@ -25,7 +25,7 @@ def test_failer_immediate(): failer = testutil.Failer(fail=lambda e: True, message="Expected failure.", engine=None) with raises(AssertionError): - failer.__event__(Idle(0.0), lambda x: None) + failer.on_idle(Idle(0.0), lambda x: None) def test_failer_timed(): @@ -35,7 +35,7 @@ def test_failer_timed(): while True: try: - failer.__event__(Idle(0.0), lambda x: None) + failer.on_idle(Idle(0.0), lambda x: None) except AssertionError as e: if e.args[0] == "Test ran too long.": end_time = monotonic()