-
-
Notifications
You must be signed in to change notification settings - Fork 98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Revamp Assets #306
Revamp Assets #306
Changes from 15 commits
9b70136
4c508b5
3200eef
be1f5f8
8b3f9e3
a6b8e50
e7d884f
515f07b
b57cd0c
469dfe7
46621c8
aa76a5d
93bd003
4d5461c
cb2034b
8c0a11a
ec3058e
85436ea
ae2a940
69b1dcb
4df51eb
dd6ab11
9eb7220
e490703
f114a8d
1c71eb1
c7346f3
981fe00
b2e2a43
1165de7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
""" | ||
The asset loading system. | ||
""" | ||
|
||
import concurrent.futures | ||
import threading | ||
|
||
import ppb.vfs as vfs | ||
from ppb.systems import System | ||
|
||
__all__ = 'Asset', 'AssetLoadingSystem', | ||
|
||
|
||
class Asset: | ||
""" | ||
A resource to be loaded from the filesystem and used. | ||
|
||
Meant to be subclassed. | ||
""" | ||
def __init__(self, name): | ||
self.name = str(name) | ||
self._finished = threading.Event() | ||
_hint(self.name, self._finished_background) | ||
|
||
def _finished_background(self, fut): | ||
# Internal | ||
# Called in background thread | ||
try: | ||
try: | ||
raw = fut.result() | ||
except FileNotFoundError: | ||
if hasattr(self, 'file_missing'): | ||
self._data = self.file_missing() | ||
else: | ||
raise | ||
else: | ||
self._data = self.background_parse(raw) | ||
except Exception as exc: | ||
# Save unhandled exceptions to be raised in the main thread | ||
self._raise_error = exc | ||
finally: | ||
# This always needs to happen so the main thread isn't just blocked | ||
self._finished.set() | ||
|
||
def background_parse(self, data): | ||
""" | ||
Takes the bytes from the resource and returns the parsed data. | ||
|
||
Subclasses probably want to override this. | ||
|
||
Called in the background thread. | ||
""" | ||
return data | ||
|
||
def is_loaded(self): | ||
""" | ||
Returns if the data has been loaded and parsed. | ||
""" | ||
return self._finished.is_set() | ||
|
||
def load(self, timeout=None): | ||
""" | ||
Gets the parsed data. | ||
|
||
Will block if not finished. | ||
""" | ||
self._finished.wait(timeout) | ||
if hasattr(self, '_raise_error'): | ||
raise self._raise_error | ||
else: | ||
return self._data | ||
|
||
|
||
class AssetLoadingSystem(System): | ||
def __init__(self, **_): | ||
self._executor = concurrent.futures.ThreadPoolExecutor() | ||
self._queue = {} # maps names to futures | ||
|
||
def __enter__(self): | ||
# 1. Register ourselves as the hint provider | ||
global _hint, _backlog | ||
assert _hint is _default_hint | ||
_hint = self._hint | ||
|
||
# 2. Grab-n-clear the backlog (atomically?) | ||
queue, _backlog = _backlog, [] | ||
|
||
# 3. Process the backlog | ||
for filename, callback in queue: | ||
self._hint(filename, callback) | ||
|
||
def __exit__(self, exc_type, exc_val, exc_tb): | ||
# Reset the hint provider | ||
global _hint | ||
_hint = _default_hint | ||
|
||
def _hint(self, filename, callback=None): | ||
if filename not in self._queue: | ||
self._queue[filename] = self._executor.submit(self._load, filename) | ||
if callback is not None: | ||
self._queue[filename].add_done_callback(callback) | ||
|
||
@staticmethod | ||
def _load(filename): | ||
with vfs.open(filename) as file: | ||
return file.read() | ||
|
||
|
||
_backlog = [] | ||
|
||
|
||
def _default_hint(filename, callback=None): | ||
_backlog.append((filename, callback)) | ||
|
||
|
||
_hint = _default_hint |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,15 @@ | ||
import random | ||
import time | ||
import logging | ||
|
||
import pygame | ||
|
||
import ppb.events as events | ||
import ppb.flags as flags | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
default_resolution = 800, 600 | ||
|
||
|
||
|
@@ -22,6 +26,22 @@ def __exit__(self, exc_type, exc_val, exc_tb): | |
|
||
|
||
from ppb.systems.pg import EventPoller as PygameEventPoller # To not break old imports. | ||
from ppb.assets import Asset | ||
import io | ||
|
||
|
||
class Image(Asset): | ||
def background_parse(self, data): | ||
return pygame.image.load(io.BytesIO(data), self.name).convert_alpha() | ||
|
||
def file_missing(self): | ||
resource = pygame.Surface((70, 70)) | ||
random.seed(str(self.name)) | ||
r = random.randint(65, 255) | ||
g = random.randint(65, 255) | ||
b = random.randint(65, 255) | ||
resource.fill((r, g, b)) | ||
return resource | ||
|
||
|
||
class Renderer(System): | ||
|
@@ -78,14 +98,14 @@ def render_background(self, scene): | |
self.window.fill(scene.background_color) | ||
|
||
def prepare_resource(self, game_object): | ||
image_name = game_object.__image__() | ||
if image_name is flags.DoNotRender: | ||
image = game_object.__image__() | ||
if image is flags.DoNotRender: | ||
return None | ||
image_name = str(image_name) | ||
if image_name not in self.resources: | ||
self.register_renderable(game_object) | ||
if isinstance(image, str): | ||
logger.warn(f"Using string resources is deprecated, use ppb.Image instead. Got {image!r}") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only thing about this is that it's extremely verbose (every frame). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could throw in a tracking variable and only throw it once? (Or once per object?) Would There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, all of those things. It's just annoying, though? |
||
image = Image(image) | ||
|
||
source_image = self.resources[image_name] | ||
source_image = image.load() | ||
if game_object.size <= 0: | ||
return None | ||
resized_image = self.resize_image(source_image, game_object.size) | ||
|
@@ -97,31 +117,11 @@ def prepare_rectangle(self, resource, game_object, camera): | |
rect.center = camera.translate_to_viewport(game_object.position) | ||
return rect | ||
|
||
def register(self, resource_path, name=None): | ||
try: | ||
resource = pygame.image.load(str(resource_path)).convert_alpha(self.window) | ||
except pygame.error: | ||
# Image didn't load, so either the name is bad or the file doesn't | ||
# exist. Instead, we'll render a square with a random color. | ||
resource = pygame.Surface((70, 70)) | ||
random.seed(str(resource_path)) | ||
r = random.randint(65, 255) | ||
g = random.randint(65, 255) | ||
b = random.randint(65, 255) | ||
resource.fill((r, g, b)) | ||
name = name or resource_path | ||
self.resources[name] = resource | ||
|
||
def register_renderable(self, renderable): | ||
image_name = str(renderable.__image__()) | ||
source_path = renderable.__resource_path__() | ||
self.register(source_path / image_name, image_name) | ||
|
||
def resize_image(self, image, game_unit_size): | ||
# TODO: Pygame specific code To be abstracted somehow. | ||
key = (image, game_unit_size) | ||
resized_image = self.old_resized_images.get(key) | ||
if resized_image is None: | ||
if resized_image is None: | ||
height = image.get_height() | ||
width = image.get_width() | ||
target_resolution = self.target_resolution(width, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
""" | ||
Handles opening files from the Python "VFS". | ||
|
||
The VFS is the same file space that Python modules are imported from, so the | ||
module spam.eggs comes from spam/eggs.py, and you can load spam/foo.png that | ||
lives next to it. | ||
""" | ||
import logging | ||
from pathlib import Path | ||
import sys | ||
try: | ||
import importlib.resources as impres | ||
except ImportError: | ||
# Backport for Python 3.6 | ||
import importlib_resources as impres | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def _main_path(): | ||
main = sys.modules['__main__'] | ||
mainpath = getattr(main, '__file__') | ||
if mainpath: | ||
mainpath = Path(mainpath) | ||
return mainpath.resolve().parent | ||
else: | ||
# This primarily happens in REPL-ish situations, where __main__ isn't a | ||
# script but a purely virtual namespace. | ||
return Path.cwd() | ||
|
||
|
||
def _splitpath(filepath): | ||
if '/' in filepath: | ||
slashed, filename = filepath.rsplit('/', 1) | ||
modulename = slashed.replace('/', '.') | ||
else: | ||
modulename = '__main__' | ||
filename = filepath | ||
|
||
return modulename, filename | ||
|
||
|
||
def open(filepath, *, encoding=None, errors='strict'): | ||
""" | ||
Opens the given file, whose name is resolved with the import machinery. | ||
|
||
If you want a text file, pass an encoding argument. | ||
|
||
Returns the open file and the base filename (suitable for filename-based type hinting). | ||
""" | ||
modulename, filename = _splitpath(filepath) | ||
|
||
logger.debug("Opening %s (%s, %s)", filepath, modulename, filename) | ||
|
||
if modulename == '__main__': | ||
# __main__ never has __spec__, so it can't resolve | ||
dirpath = _main_path() | ||
filepath = dirpath / filename | ||
if encoding is None: | ||
return filepath.open('rb') | ||
else: | ||
return filepath.open('rt', encoding=encoding, errors=errors) | ||
else: | ||
try: | ||
if encoding is None: | ||
return impres.open_binary(modulename, filename) | ||
else: | ||
return impres.open_text(modulename, filename, encoding, errors) | ||
except FileNotFoundError: | ||
logger.warning("Did you forget __init__.py?") | ||
raise | ||
|
||
|
||
def exists(filepath): | ||
""" | ||
Checks if the given resource exists and is a resources. | ||
""" | ||
modulename, filename = _splitpath(filepath) | ||
if modulename == '__main__': | ||
# __main__ never has __spec__, so it can't resolve | ||
dirpath = _main_path() | ||
return (dirpath / filename).is_file() | ||
else: | ||
return impres.is_resource(modulename, filepath) | ||
|
||
|
||
def iterdir(modulepath): | ||
modname = modulepath.replace('/', '.') | ||
if modname == '__main__': | ||
dirpath = _main_path() | ||
for item in dirpath.iterdir(): | ||
yield item.name | ||
else: | ||
yield from impres.contents(modname) | ||
|
||
|
||
def walk(modulepath): | ||
""" | ||
Generates all the resources in the given package. | ||
""" | ||
for name in iterdir(modulepath): | ||
fullname = f"{modulepath}/{name}" | ||
if exists(fullname): | ||
yield fullname | ||
elif modulepath != '__main__': | ||
# Don't recurse from __main__, that would be all installed packages. | ||
yield from walk(fullname) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
pygame<2 | ||
ppb-vector ~= 1.0 | ||
dataclasses; python_version < "3.7" | ||
importlib_resources; python_version < "3.7" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dunno on adding new things to
systems.__init__
. If we're making changes to the renderer and adding new things they should move to its own file.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I was wondering if we wanted to move the renderer to its own module.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's one of the places we deal with pygame, so probably needs to go to
systems.pg
. (There's even argument for making pg.py into a subpackage of its own, but uh. . . not right now.)