Skip to content
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

Merged
merged 30 commits into from
Jul 14, 2019
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9b70136
Initial stab
AstraLuma Jun 6, 2019
4c508b5
Add special support for __main__
AstraLuma Jun 6, 2019
3200eef
Update animated_sprite.py
AstraLuma Jun 6, 2019
be1f5f8
Add backport for 3.6
AstraLuma Jun 6, 2019
8b3f9e3
Merge remote-tracking branch 'mine/add-vfs' into resources
AstraLuma Jul 11, 2019
a6b8e50
vfs: Try to document things a little better.
AstraLuma Jul 11, 2019
e7d884f
Try to add a helpful logging message if a resource isn't found.
AstraLuma Jul 11, 2019
515f07b
Add the resource loading spike
AstraLuma Jul 11, 2019
b57cd0c
Add an Asset class
AstraLuma Jul 11, 2019
469dfe7
Rename resources to assets
AstraLuma Jul 11, 2019
46621c8
Add an AssetLoadingSystem
AstraLuma Jul 11, 2019
aa76a5d
ppb: Add ppb.Image
AstraLuma Jul 11, 2019
93bd003
Assets: Add str coercion
AstraLuma Jul 11, 2019
4d5461c
ppb.systems: Rewrite renderer for the new asset system
AstraLuma Jul 11, 2019
cb2034b
ppb.vfs: open() shouldn't return the filename
AstraLuma Jul 11, 2019
8c0a11a
Restore test_sprite_in_main
AstraLuma Jul 11, 2019
ec3058e
ppb.vfs: Wrap ModuleNotFoundError in FileNotFoundError
AstraLuma Jul 11, 2019
85436ea
assets: add tests
AstraLuma Jul 11, 2019
ae2a940
ppb.vfs._main_path: use .absolute instead of .resolve to avoid zip pr…
AstraLuma Jul 11, 2019
69b1dcb
Delete unused property
AstraLuma Jul 11, 2019
4df51eb
Warn if a user referenced an unknown asset but it would go silent oth…
AstraLuma Jul 12, 2019
dd6ab11
assets: Warn if .load() is called before the engine loads up.
AstraLuma Jul 12, 2019
9eb7220
Merge remote-tracking branch 'upstream/master' into resources
AstraLuma Jul 12, 2019
e490703
assets: Add Asset.__repr__
AstraLuma Jul 12, 2019
f114a8d
Start on docs
AstraLuma Jul 13, 2019
1c71eb1
Document sprites
AstraLuma Jul 13, 2019
c7346f3
Merge branch 'docs-sprites' into resources
AstraLuma Jul 13, 2019
981fe00
assets: More doc work
AstraLuma Jul 13, 2019
b2e2a43
Update Sprite
AstraLuma Jul 13, 2019
1165de7
Add stuff about data lifetimes
AstraLuma Jul 13, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions examples/animated_sprite.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


class Blob(ppb.BaseSprite):
image = Animation("./resources/blob_{0..6}.png", 10)
image = Animation("resources/blob_{0..6}.png", 10)
target = ppb.Vector(0, 0)
speed = 1

Expand All @@ -15,8 +15,9 @@ def on_mouse_motion(self, event: events.MouseMotion, signal):

def on_update(self, event: events.Update, signal):
intent_vector = self.target - self.position
self.position += intent_vector.scale(self.speed * event.time_delta)
self.rotation = math.degrees(math.atan2(intent_vector.y, intent_vector.x)) - 90
if intent_vector:
self.position += intent_vector.scale(self.speed * event.time_delta)
self.rotation = math.degrees(math.atan2(intent_vector.y, intent_vector.x)) - 90


def setup(scene):
Expand Down
Empty file added examples/resources/__init__.py
Empty file.
3 changes: 2 additions & 1 deletion ppb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from ppb.engine import GameEngine
from ppb.scenes import BaseScene
from ppb.sprites import BaseSprite
from ppb.systems import Image

__all__ = (
# Shortcuts
'Vector', 'BaseScene', 'BaseSprite',
'Vector', 'BaseScene', 'BaseSprite', 'Image',
# Local stuff
'run', 'make_engine',
)
Expand Down
116 changes: 116 additions & 0 deletions ppb/assets.py
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
3 changes: 2 additions & 1 deletion ppb/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from ppb.systems import PygameEventPoller
from ppb.systems import Renderer
from ppb.systems import Updater
from ppb.assets import AssetLoadingSystem
from ppb.utils import LoggingMixin


Expand All @@ -26,7 +27,7 @@
class GameEngine(EventMixin, LoggingMixin):

def __init__(self, first_scene: Type, *,
basic_systems=(Renderer, Updater, PygameEventPoller),
basic_systems=(Renderer, Updater, PygameEventPoller, AssetLoadingSystem),
systems=(), scene_kwargs=None, **kwargs):

super(GameEngine, self).__init__()
Expand Down
9 changes: 0 additions & 9 deletions ppb/sprites.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,3 @@ def __image__(self):
if self.image is None:
self.image = f"{type(self).__name__.lower()}.png"
return self.image

def __resource_path__(self):
if self.resource_path is None:
try:
file_path = Path(getfile(type(self))).resolve().parent
except TypeError:
file_path = Path.cwd().resolve()
self.resource_path = file_path
return self.resource_path
54 changes: 27 additions & 27 deletions ppb/systems/__init__.py
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


Expand All @@ -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):
Copy link
Collaborator

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.

Copy link
Member Author

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.

Copy link
Collaborator

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.)

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):
Expand Down Expand Up @@ -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}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only thing about this is that it's extremely verbose (every frame).

Copy link
Collaborator

Choose a reason for hiding this comment

The 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 lru_cache wrapped around a print statement make it run once per object passed?

Copy link
Member Author

Choose a reason for hiding this comment

The 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)
Expand All @@ -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,
Expand Down
107 changes: 107 additions & 0 deletions ppb/vfs.py
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)
1 change: 1 addition & 0 deletions requirements.txt
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"
Loading