Resources
Hello, loves!
Resource designation is awkward, and I think we’re loading them multiple times. Let’s see what we can do about this. Arcade helps out!
Here’s the sort of thing we have to put up with:
class ContentFactory:
def lever(self, *, name):
def lever(self, interactor):
self.state = (self.state+1)%len(self.resources)
interactor.publish('control', self.name, state=self.state)
interactor.publish('state_number', self.name, content=self, state=self.state)
return False
resource1 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/1.png'
resource2 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/2.png'
resource3 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/3.png'
resource4 = '/Users/ron/Desktop/DungeonTiles/png/objects/lever/4.png'
resources = [resource1, resource2, resource3, resource4]
Sometimes I’ve resorted to a temp, only slightly less irritating:
main.py
...
resources=':resources:images/items/'
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
factory = ContentFactory()
item = factory.receivable(name ="a red key", resource=resources + 'keyRed.png', scale=0.5) cell = Cell(29, 28)
cell.add_content(item)
...
Now here chez Ron that’s not all that irritating, since I don’t really use a lot of resource strings, but if we were a real team building a real game, there would be ResourceMasters creating and using lots of different files. And even here, if and when I decide to flesh this game out a bit — and very likely I will — it can only get more irritating.
So let’s see what some goals might be for improvement.
- Load a given resource only once;
- Provide a short notation for resources,
perhaps like ‘keyRed’ or ‘/lever/1’; - Possibly allow or require the file type suffix;
- Ideally, require no changes to existing code;
- Failing that, require only minimal changes such as removing the path bits;
- Remember to deal with the floor tile resources, not just content.
We’d better look at the code for processing resources now.
The action will take place in ContentView, where we load all the textures from the provided strings.
class ContentView:
error_texture = ':resources:images/items/star.png'
def __init__(self, cell, content_item, resources, scale=0.5):
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
try:
texture = arcade.load_texture(resource)
except (FileNotFoundError, AttributeError):
texture = arcade.load_texture(self.error_texture)
self.sprite.append_texture(texture)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
There’s yer problem right there! We expect a full path name here, and we unconditionally call arcade.load_texture on it. We even load the error texture every time.
I’ve taken a quick look at flooring, which uses a thing called TextureFinder and while it could use some improvement, I think it is more likely to be a use of our new capability than a part of it. So we’ll try to meet our goals above with some new code.
Design Thinking
So. An object that takes a short name such as ‘lever/1.png’, or even ‘lever/1’ if we can see how to do it, and returns a loaded texture by that name if it exists and a standard error texture if it can’t be found. (We don’t allow our code to crash.)
There are two kinds of paths to resources supported by arcade:
resources=':resources:images/items/'
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
So far, the only way I know to process those colon ones is with arcade.load_texture. We want to cache the textures. I guess we could do that, keyed on the short name and trying all the paths we know. That might be good. It might also be good to know if arcade will resolve that colon thing into an actual path for us.
RTFM! Amazing What You Can Find!
A bit of searching in the arcade documents offers a method that lists all built-in assets. Let’s try it out.
def test_path(self):
p1 = '/Users/ron/Desktop'
assert Path(p1).exists()
p1_r = arcade.resources.resolve(p1)
assert Path(p1_r).exists()
p2 = ':resources:images/items/'
assert not Path(p2).exists()
p2_r = arcade.resources.resolve(p2)
assert Path(p2_r).exists()
So there is the answer to one question, how we convert a colon path to a real one?
Let’s see that list. It is immense. This test limiting to image files with key in their name:
def test_assets(self):
assets = arcade.resources.list_built_in_assets(name='key', extensions=['.png', '.jpg', '.jpeg'])
p_assets = '\n'.join(str(a) for a in assets)
print(p_assets)
assert False
Provides 15 lines that look like this:
/Users/ron/PycharmProjects/dungeon/.venv/lib/python3.12
/site-packages/arcade/resources/assets/images
/items/keyGreen.png
I also learn that there is a way to provide one’s own resource handle. After a bit of messing about this test runs:
def test_resource_handle(self):
base = '/Users/ron/Desktop/DungeonTiles/png/objects/'
resolved = Path(base).resolve()
arcade.resources.add_resource_handle('ron',
resolved)
path = arcade.resources.resolve(':ron:lever/1.png')
assert str(path) == base + 'lever/1.png'
I think that means that if I were to add the right resource handles, we could use shorter names. That would be nice.
After a bit more reading I find that I don’t have to resolve the path. This works:
def test_resource_handle(self):
base = '/Users/ron/Desktop/DungeonTiles/png/objects/'
arcade.resources.add_resource_handle('ron',
base)
path = arcade.resources.resolve(':ron:lever/1.png')
assert str(path) == base + 'lever/1.png'
I had a test fail that made me think I needed to do that but it must have been due to a typo or something.
I wonder if it will now resolve without providing the handle. No. Apparently resolve doesn’t try all the handles. Is there some way to do that? We can add multiple directories under one handle, and it will search all of them.
We only really use two paths currently:
resources=':resources:images/items/'
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
Can’t we make a single resource handle for both those? Let’s try another test.
def test_combined_handle(self):
resources = ':resources:images/items/'
arcade_resources = arcade.resources.resolve(resources)
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
arcade.resources.add_resource_handle('ron',arcade_resources)
arcade.resources.add_resource_handle('ron',my_resources)
lever = arcade.resources.resolve(':ron:lever/1.png')
keyRed = arcade.resources.resolve(':ron:keyRed.png')
This test fails if it can’t resolve those resources, and it passes. So I’ve created a single handle ‘:ron:’’ that can point to all our resources.
That, alone, resolves one of our goals, a more convenient notation. Let’s change main to assume that it can use ‘:ron:’ and make it work. We’ll start with a test.
My plan is that DungeonView, which it starts up, will register the handle and paths. Then when a ContentView goes for a resource, it should always be able to find it. I’m not sure what it’ll take. We’ll make the test work, learning how along the way.
def test_content_view_uses_ron_handle(self):
layout = DungeonLayout()
dungeon = Dungeon(layout)
dv = DungeonView(dungeon, testing=True) # inits handle
resource = ':ron:Skeleton1.png'
content = ContentFactory().decor(name='test', resource=resource, scale=0.5)
cv = ContentView(Cell(5,5), content, content.resources, content.scale)
This fails because the handle isn’t defined yet, by DungeonView, which needs to be enhanced:
def setup_assets(self):
arcade_resources = ':resources:images/items/'
arcade_resources = arcade.resources.resolve(arcade_resources)
my_resources = '/Users/ron/Desktop/DungeonTiles/png/objects/'
arcade.resources.add_resource_handle('ron',arcade_resources)
arcade.resources.add_resource_handle('ron',my_resources)
I call that in the init. Test passes, which tells us that the ContentView’s load_texture accepts the handle.
But let’s make it harder. Let’s not mention the handle, so that our users can just name the short path and file that they want.
I am mistaken. ContentView, because we cannot crash, recovers if it cannot find the file specified.
class ContentView:
error_texture = ':resources:images/items/star.png'
def __`init`__(self, cell, content_item, resources, scale=0.5):
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
try:
texture = arcade.load_texture(resource)
except (FileNotFoundError, AttributeError):
texture = arcade.load_texture(self.error_texture)
self.sprite.append_texture(texture)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
I could just make this work: I think I know exactly what needs to be done. Let’s add an error flag to CntentView:
class ContentView:
error_texture = ':resources:images/items/star.png'
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
try:
texture = arcade.load_texture(resource)
except (FileNotFoundError, AttributeError):
self.error = True
texture = arcade.load_texture(self.error_texture)
self.sprite.append_texture(texture)
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
Now my test can check the flag.
def test_content_view_uses_ron_handle(self):
layout = DungeonLayout()
dungeon = Dungeon(layout)
dv = DungeonView(dungeon, testing=True) # inits handle
resource = 'Skeleton1.png'
content = ContentFactory().decor(name='test', resource=resource, scale=0.5)
cv = ContentView(Cell(5,5), content, content.resources, content.scale)
assert not cv.error
Test fails as anticipated. Fix ContentView to add the ‘:ron:’ internally.
for resource in resources:
try:
texture = arcade.load_texture(':ron:'+resource)
except (FileNotFoundError, AttributeError):
self.error = True
texture = arcade.load_texture(self.error_texture)
Now I think the entire game will break until we fix up main. It does:
ValueError: Invalid resource handle 'ron::resources:images/items/keyRed.png'
In main we need to change things like this:
appearing = factory.appearing(name='a brilliant torch', resource=my_resources + 'torch/1.png', scale=1)
cell9 = Cell(36, 29)
cell9.add_content(appearing)
To this
appearing = factory.appearing(name='a brilliant torch', resource='torch/1.png', scale=1)
cell9 = Cell(36, 29)
cell9.add_content(appearing)
And similarly in the ContentFactory. The game works. But there are tests that are using the long form. Let’s make them break:
class ContentView:
error_texture = ':resources:images/items/star.png'
def __init__(self, cell, content_item, resources, scale=0.5):
self.error = False
self.cell = cell
self.item = content_item
self.sprite = Sprite()
for resource in resources:
try:
texture = arcade.load_texture(':ron:'+resource)
except (FileNotFoundError, AttributeError):
self.error = True
texture = arcade.load_texture(self.error_texture)
self.sprite.append_texture(texture)
assert not self.error
self.sprite.set_texture(0)
self.sprite.visible = False
self.sprite.scale = scale_texture(self.sprite.textures[-1], scale)
I am somewhat surprised. I expected tests to be providing parameters that couldn’t resolve. Find all the users of ContentView and look. There was one referring to the old ‘:resources:’ path. We will probably discover, in the future, that assuming everything can be found under ‘:ron:’ isn’t quite right. If we do discover that, we’ll deal with it. Dealing with it now would be possible but speculative.
I’ll remove the assert from ContentView but retain the error flag. Commit: ContentView assumes resources are in the handle :ron:
Let’s sum up.
Summary
I wasn’t aware of how the :resource: things worked in arcade, but a bit of Reading of The Fine Manual (RTFM) taught me enough so that I could fumble my way to a single resource handle, :ron:, that points to my desktop folders, which are good enough for now, and the built-in assets that we use. So now any short-path file name will search both those folders, my desktop first, and all the file names in the system are now simple file names, or minimally prefixed like ‘lever/1.png’.
We changed DungeonView, as the first and topmost class that cares about images, to define our new handle to arcade. Then ContentView helpfully prefixes incoming resource short-path names with :ron: and resolves and loads the texture.
In our next phase, we’ll build a little cache of some kind so as to load textures only once. And at some future date, we should look at doing something similar for the flooring, which has a lot of reuse of the same tiles over and over.
A little learning, a little code, and how the references to resources are much more convenient. And we’re in a perfect position to cache them so as only to load them once.
Nice. Not bad at all. See you next time!