Skip to content

Commit

Permalink
Merge #221
Browse files Browse the repository at this point in the history
221: New subsystem API r=astronouth7303 a=pathunstrom

So, doing what I discussed in the slack: Removing activate, adding a heartbeat event.

Need to decide if we heartbeat between each publish, or we clear the queue between each heartbeat.

If we do the former, we're going to start seeing scheduling silliness, but that just comes from asynchronous design.

If we do the latter, it's easier to make events happen in a sequence, but still going to have some of the asynchronous silliness because, well, we're technically async.

- [x] Add heartbeat, switch the test utilities to use a heartbeat system.
- [x] Remove activate, switch remaining systems to use heartbeat system.
- [x] Clean up the main loop to reduce the processing time of the busy loop.

Co-authored-by: Piper Thunstrom <pathunstrom@gmail.com>
  • Loading branch information
bors[bot] and pathunstrom committed Apr 8, 2019
2 parents 902fc5e + 42d4242 commit f81f327
Show file tree
Hide file tree
Showing 7 changed files with 86 additions and 43 deletions.
12 changes: 7 additions & 5 deletions ppb/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def __init__(self, first_scene: Type, *,
self.event_extensions = defaultdict(dict)
self.running = False
self.entered = False
self._last_idle_time = None

# Systems
self.systems_classes = systems
Expand Down Expand Up @@ -80,17 +81,18 @@ def run(self):

def start(self):
self.running = True
self._last_idle_time = time.monotonic()
self.activate({"scene_class": self.first_scene,
"kwargs": self.scene_kwargs})

def main_loop(self):
while self.running:
time.sleep(0)
for system in self.systems:
for event in system.activate(self):
self.signal(event)
while self.events:
self.publish()
now = time.monotonic()
self.signal(events.Idle(now - self._last_idle_time))
self._last_idle_time = now
while self.events:
self.publish()
self.manage_scene()

def activate(self, next_scene: dict):
Expand Down
9 changes: 9 additions & 0 deletions ppb/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,15 @@ class StopScene:
scene: Scene = None


@dataclass
class Idle:
"""
An engine plumbing event to pump timing information to subsystems.
"""
time_delta: float
scene: Scene = None


@dataclass
class Update:
"""
Expand Down
35 changes: 23 additions & 12 deletions ppb/systems/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,24 @@ def __enter__(self):
def __exit__(self, exc_type, exc_val, exc_tb):
pass

def activate(self, engine):
return []


from ppb.systems.pg import EventPoller as PygameEventPoller # To not break old imports.


class Renderer(System):

def __init__(self, resolution=default_resolution, window_title: str="PursuedPyBear", **kwargs):
def __init__(self, resolution=default_resolution, window_title: str="PursuedPyBear", target_frame_rate: int=30, **kwargs):
self.resolution = resolution
self.resources = {}
self.window = None
self.window_title = window_title
self.pixel_ratio = None
self.resized_images = {}
self.old_resized_images = {}
self.render_clock = 0
self.render_ready = False
self.target_frame_rate = target_frame_rate
self.target_count = 1 / self.target_frame_rate

def __enter__(self):
pygame.init()
Expand All @@ -46,9 +47,18 @@ def __enter__(self):
def __exit__(self, exc_type, exc_val, exc_tb):
pygame.quit()

def activate(self, engine):
yield events.PreRender()
yield events.Render()
def on_idle(self, idle_event: events.Idle, signal):
self.render_clock += idle_event.time_delta
if self.render_ready:
self.render_ready = False
signal(events.Render())
elif self.render_clock >= self.target_count:
self.render_clock = 0
signal(events.PreRender())

def on_pre_render(self, pre_render_event, signal):
# Here to let the system flush responses to PreRender before rendering.
self.render_ready = True

def on_render(self, render_event, signal):
self.render_background(render_event.scene)
Expand Down Expand Up @@ -143,14 +153,15 @@ def __init__(self, time_step=0.016, **kwargs):
self.time_step = time_step

def __enter__(self):
self.start_time = time.time()
self.start_time = time.monotonic()

def activate(self, engine):
def on_idle(self, idle_event: events.Idle, signal):
if self.last_tick is None:
self.last_tick = time.time()
this_tick = time.time()
self.last_tick = time.monotonic()
this_tick = time.monotonic()
self.accumulated_time += this_tick - self.last_tick
self.last_tick = this_tick
while self.accumulated_time >= self.time_step:
# This might need to change for the Idle event system to signal _only_ once per idle event.
self.accumulated_time += -self.time_step
yield events.Update(self.time_step)
signal(events.Update(self.time_step))
4 changes: 2 additions & 2 deletions ppb/systems/pg.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,11 @@ def __enter__(self):
def __exit__(self, exc_type, exc_val, exc_tb):
pygame.quit()

def on_update(self, update, signal):
def on_idle(self, idle: events.Idle, signal):
for pygame_event in pygame.event.get():
methname = self.event_map.get(pygame_event.type)
if methname is not None: # Is there a handler for this pygame event?
ppbevent = getattr(self, methname)(pygame_event, update.scene)
ppbevent = getattr(self, methname)(pygame_event, idle.scene)
if ppbevent: # Did the handler actually produce a ppb event?
signal(ppbevent)

Expand Down
14 changes: 7 additions & 7 deletions ppb/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,27 @@
from typing import Callable

from ppb.engine import GameEngine
from ppb.events import Idle
from ppb.events import Quit
from ppb.systems import System


class Failer(System):

def __init__(self, *, fail: Callable[[GameEngine], bool], message: str,
run_time: float=1, **kwargs):
run_time: float=1, engine, **kwargs):
super().__init__(**kwargs)
self.fail = fail
self.message = message
self.start = time.monotonic()
self.run_time = run_time
self.engine = engine

def activate(self, engine):
def on_idle(self, idle_event: Idle, signal):
if time.monotonic() - self.start > self.run_time:
raise AssertionError("Test ran too long.")
if self.fail(engine):
if self.fail(self.engine):
raise AssertionError(self.message)
return ()



class Quitter(System):
Expand All @@ -35,7 +35,7 @@ def __init__(self, loop_count=1, **kwargs):
self.counter = 0
self.loop_count = loop_count

def activate(self, engine):
def on_idle(self, idle_event: Idle, signal):
self.counter += 1
if self.counter >= self.loop_count:
yield Quit()
signal(Quit())
33 changes: 25 additions & 8 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ def fail(engine):
if parent.count > 0 and engine.current_scene != parent:
return True

failer = Failer(fail=fail, message="ParentScene should not be counting while a child exists.")
engine = GameEngine(ParentScene, systems=[Updater(time_step=0.001), failer])
engine = GameEngine(ParentScene,
systems=[Updater(time_step=0.001), Failer], fail=fail,
message="ParentScene should not be counting while a child exists.")
engine.run()


Expand All @@ -114,8 +115,8 @@ def __init__(self, *args, **kwargs):
def change(self):
return super().change()

failer = Failer(fail=lambda n: False, message="Will only time out.")
with GameEngine(Scene, systems=[Updater, failer]) as ge:
with GameEngine(Scene, systems=[Updater, Failer], fail=lambda n: False,
message="Will only time out.") as ge:
ge.run()


Expand Down Expand Up @@ -175,7 +176,8 @@ def on_scene_started(self, event, signal):
class Tester(System):
listening = False

def activate(self, engine):
def on_idle(self, idle: events.Idle, signal):
engine = idle.engine
if self.listening:
assert isinstance(engine.current_scene, SecondScene)
assert len(engine.scenes) == 2
Expand All @@ -185,6 +187,7 @@ def on_scene_paused(self, event, signal):
self.listening = True

with GameEngine(FirstScene, systems=[Updater, Tester]) as ge:
ge.register(events.Idle, "engine", ge)
ge.run()

pause_was_run.assert_called()
Expand All @@ -209,7 +212,7 @@ def on_scene_started(self, event, signal):
class TestFailer(Failer):

def __init__(self, engine):
super().__init__(fail=self.fail, message="Will not call")
super().__init__(fail=self.fail, message="Will not call", engine=engine)
self.first_scene_ended = False

def on_scene_stopped(self, event, signal):
Expand Down Expand Up @@ -240,8 +243,7 @@ def on_scene_stopped(self, event, signal):
assert event.scene is self
test_function()

failer = Failer(fail=lambda x: False, message="Will only time out.")
with GameEngine(TestScene, systems=[Updater, failer]) as ge:
with GameEngine(TestScene, systems=[Updater, Failer], fail=lambda x: False, message="Will only time out.") as ge:
ge.run()

test_function.assert_called()
Expand All @@ -258,3 +260,18 @@ def test_flush_events():
ge.flush_events()

assert len(ge.events) == 0


def test_idle():
"""This test confirms that Idle events work."""
was_called = False

class TestSystem(System):

def on_idle(self, event: events.Idle, signal):
global was_called
was_called = True
signal(events.Quit())

with GameEngine(BaseScene, systems=[TestSystem, Failer], fail=lambda x: False, message="Can only time out.") as ge:
ge.run()
22 changes: 13 additions & 9 deletions tests/test_testutil.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,41 @@
from time import monotonic
from unittest.mock import Mock

from pytest import mark
from pytest import raises

import ppb.testutils as testutil
from ppb.events import Idle
from ppb.events import Quit


@mark.parametrize("loop_count", range(1, 6))
def test_quitter(loop_count):
quitter = testutil.Quitter(loop_count=loop_count)
for _ in range(loop_count):
for e in quitter.activate(None): # Quitter doesn't need access to the engine, so we can pass None here.
if isinstance(e, Quit):
return
raise AssertionError("Quitter did not raise a quit event.")
signal_mock = Mock()
for i in range(loop_count):
quitter.__event__(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
assert isinstance(signal_mock.call_args[0][0], Quit)


def test_failer_immediate():
failer = testutil.Failer(fail=lambda e: True, message="Expected failure.")
failer = testutil.Failer(fail=lambda e: True, message="Expected failure.", engine=None)

with raises(AssertionError):
failer.activate(None)
failer.__event__(Idle(0.0), lambda x: None)


def test_failer_timed():
failer = testutil.Failer(fail=lambda e: False, message="Should time out", run_time=0.1)
failer = testutil.Failer(fail=lambda e: False, message="Should time out", run_time=0.1, engine=None)

start_time = monotonic()

while True:
try:
failer.activate(None)
failer.__event__(Idle(0.0), lambda x: None)
except AssertionError as e:
if e.args[0] == "Test ran too long.":
end_time = monotonic()
Expand Down

0 comments on commit f81f327

Please sign in to comment.