diff --git a/ppb/assets.py b/ppb/assets.py index 71841a10..60a03200 100644 --- a/ppb/assets.py +++ b/ppb/assets.py @@ -7,6 +7,7 @@ import threading import ppb.vfs as vfs +import ppb.events as events from ppb.systemslib import System __all__ = 'Asset', 'AssetLoadingSystem', @@ -58,10 +59,14 @@ def _finished_background(self, fut): if hasattr(self, 'file_missing'): logger.warning("File not found: %r", self.name) self._data = self.file_missing() + if _finished is not None: + _finished(self) else: raise else: self._data = self.background_parse(raw) + if _finished is not None: + _finished(self) except Exception as exc: # Save unhandled exceptions to be raised in the main thread self._raise_error = exc @@ -101,15 +106,18 @@ def load(self, timeout: float = None): class AssetLoadingSystem(System): - def __init__(self, **_): + def __init__(self, *, engine, **_): + super().__init__(**_) + self.engine = engine self._executor = concurrent.futures.ThreadPoolExecutor() self._queue = {} # maps names to futures def __enter__(self): # 1. Register ourselves as the hint provider - global _hint, _backlog + global _hint, _finished, _backlog assert _hint is _default_hint _hint = self._hint + _finished = self._finished # 2. Grab-n-clear the backlog (atomically?) queue, _backlog = _backlog, [] @@ -134,6 +142,17 @@ def _load(filename): with vfs.open(filename) as file: return file.read() + def _finished(self, asset): + statuses = [ + fut.running() + for fut in self._queue.values() + ] + self.engine.signal(events.AssetLoaded( + asset=asset, + total_loaded=sum(not s for s in statuses), + total_queued=sum(s for s in statuses), + )) + _backlog = [] @@ -143,3 +162,4 @@ def _default_hint(filename, callback=None): _hint = _default_hint +_finished = None diff --git a/ppb/engine.py b/ppb/engine.py index 6929439f..a9c5f282 100644 --- a/ppb/engine.py +++ b/ppb/engine.py @@ -10,7 +10,7 @@ from typing import Type from typing import Union -import ppb.events as events +from ppb import events from ppb.eventlib import EventMixin from ppb.systems import EventPoller from ppb.systems import Renderer @@ -114,6 +114,11 @@ def activate(self, next_scene: dict): self.scenes.append(scene(*args, **kwargs)) def signal(self, event): + """ + Add an event to the event queue. + + Thread-safe. + """ self.events.append(event) def publish(self): diff --git a/ppb/events.py b/ppb/events.py index 10d0b822..062cd062 100644 --- a/ppb/events.py +++ b/ppb/events.py @@ -22,6 +22,7 @@ 'SceneStopped', 'StopScene', 'Update', + 'AssetLoaded', ) # Remember to define scene at the end so the pargs version of __init__() still works @@ -232,3 +233,16 @@ class Update: """ time_delta: float scene: BaseScene = None + + +import ppb + + +@dataclass +class AssetLoaded: + """ + Fired whenever an asset finished loading. + """ + asset: 'ppb.assets.Asset' + total_loaded: int + total_queued: int diff --git a/ppb/sprites.py b/ppb/sprites.py index 9dd6802d..bf085ab4 100644 --- a/ppb/sprites.py +++ b/ppb/sprites.py @@ -2,13 +2,13 @@ from pathlib import Path from typing import Union +import ppb_vector +from ppb_vector import Vector + import ppb -from ppb import Vector from ppb.eventlib import EventMixin from ppb.utils import FauxFloat -import ppb_vector - TOP = "top" BOTTOM = "bottom" diff --git a/ppb/systems/inputs.py b/ppb/systems/inputs.py index be501a78..49418628 100644 --- a/ppb/systems/inputs.py +++ b/ppb/systems/inputs.py @@ -2,6 +2,7 @@ import pygame +from ppb import buttons import ppb.buttons as buttons from ppb_vector import Vector import ppb.events as events diff --git a/ppb/systemslib.py b/ppb/systemslib.py index e4481211..ef827993 100644 --- a/ppb/systemslib.py +++ b/ppb/systemslib.py @@ -1,4 +1,4 @@ -import ppb.eventlib as eventlib +from ppb import eventlib class System(eventlib.EventMixin): diff --git a/tests/test_assets.py b/tests/test_assets.py index 94860d45..15cc03a9 100644 --- a/tests/test_assets.py +++ b/tests/test_assets.py @@ -1,16 +1,44 @@ import pytest from ppb import GameEngine, BaseScene +import ppb.events +import ppb.assets from ppb.assets import Asset, AssetLoadingSystem +from ppb.testutils import Failer -def test_loading(): +@pytest.fixture +def clean_assets(): + """ + Cleans out the global state of the asset system, so that we start fresh every + test. + """ + ppb.assets._backlog = [] + + +class AssetTestScene(BaseScene): + def on_asset_loaded(self, event, signal): + self.ale = event + signal(ppb.events.Quit()) + + +def test_loading(clean_assets): a = Asset('ppb/engine.py') - engine = GameEngine(BaseScene, basic_systems=[AssetLoadingSystem]) + engine = GameEngine( + AssetTestScene, basic_systems=[AssetLoadingSystem, Failer], + fail=lambda e: False, message=None, run_time=1, + ) with engine: engine.start() + ats = engine.current_scene + + engine.main_loop() assert a.load() + print(vars(ats)) + assert ats.ale.asset is a + assert ats.ale.total_loaded == 1 + assert ats.ale.total_queued == 0 # def test_loading_root(): @@ -22,9 +50,12 @@ def test_loading(): # assert a.load() -def test_missing_package(): +def test_missing_package(clean_assets): a = Asset('does/not/exist') - engine = GameEngine(BaseScene, basic_systems=[AssetLoadingSystem]) + engine = GameEngine( + AssetTestScene, basic_systems=[AssetLoadingSystem, Failer], + fail=lambda e: False, message=None, run_time=1, + ) with engine: engine.start() @@ -32,9 +63,12 @@ def test_missing_package(): assert a.load() -def test_missing_resource(): +def test_missing_resource(clean_assets): a = Asset('ppb/dont.touch.this') - engine = GameEngine(BaseScene, basic_systems=[AssetLoadingSystem]) + engine = GameEngine( + AssetTestScene, basic_systems=[AssetLoadingSystem, Failer], + fail=lambda e: False, message=None, run_time=1, + ) with engine: engine.start() @@ -42,26 +76,32 @@ def test_missing_resource(): assert a.load() -def test_parsing(): +def test_parsing(clean_assets): class Const(Asset): def background_parse(self, data): return "nah" a = Const('ppb/flags.py') - engine = GameEngine(BaseScene, basic_systems=[AssetLoadingSystem]) + engine = GameEngine( + AssetTestScene, basic_systems=[AssetLoadingSystem, Failer], + fail=lambda e: False, message=None, run_time=1, + ) with engine: engine.start() assert a.load() == "nah" -def test_missing_parse(): +def test_missing_parse(clean_assets): class Const(Asset): def file_missing(self): return "igotu" a = Const('spam/eggs') - engine = GameEngine(BaseScene, basic_systems=[AssetLoadingSystem]) + engine = GameEngine( + AssetTestScene, basic_systems=[AssetLoadingSystem, Failer], + fail=lambda e: False, message=None, run_time=1, + ) with engine: engine.start() diff --git a/viztests/README.md b/viztests/README.md new file mode 100644 index 00000000..a781e9fe --- /dev/null +++ b/viztests/README.md @@ -0,0 +1,7 @@ +Visual Tests +============ + +This is a test suite of manual tests, meant to help with the difficulty of +thorough automated testing of graphical software. + +Run with `python -m viztests` diff --git a/viztests/__main__.py b/viztests/__main__.py new file mode 100644 index 00000000..ef3dad70 --- /dev/null +++ b/viztests/__main__.py @@ -0,0 +1,24 @@ +import ast +from pathlib import Path +import subprocess +import sys + + +def get_docstring(path): + tree = ast.parse(path.read_text(), path.name, mode='exec') + + return ast.get_docstring(tree) + + +for script in Path(__file__).resolve().parent.glob('*.py'): + if script.name.startswith('_'): + continue + ds = get_docstring(script) + print("=" * len(script.name)) + print(script.name) + print("=" * len(script.name)) + print("") + if ds is not None: + print(ds) + print("") + subprocess.run([sys.executable, str(script)], check=True) diff --git a/viztests/angles.py b/viztests/angles.py new file mode 100644 index 00000000..a8a21b6a --- /dev/null +++ b/viztests/angles.py @@ -0,0 +1,31 @@ +""" +Tests rotation vs Vector angles + +The center sprite should always face the orbiting sprite +""" +import ppb + +ROTATION_RATE = 90 + + +class CenterSprite(ppb.BaseSprite): + image = ppb.Image('player.png') + + def on_update(self, event, signal): + self.rotation += ROTATION_RATE * event.time_delta + + +class OrbitSprite(ppb.BaseSprite): + position = ppb.Vector(0, -2) + image = ppb.Image('target.png') + + def on_update(self, event, signal): + self.position = self.position.rotate(ROTATION_RATE * event.time_delta) + + +def setup(scene): + scene.add(CenterSprite()) + scene.add(OrbitSprite()) + + +ppb.run(setup) diff --git a/viztests/bullet.png b/viztests/bullet.png new file mode 100755 index 00000000..b5a8a035 Binary files /dev/null and b/viztests/bullet.png differ diff --git a/viztests/player.png b/viztests/player.png new file mode 100755 index 00000000..e168d251 Binary files /dev/null and b/viztests/player.png differ diff --git a/viztests/target.png b/viztests/target.png new file mode 100644 index 00000000..841309db Binary files /dev/null and b/viztests/target.png differ