Sprites, yesterday and today. Refactoring the Content design, because Sprites.

You may be forgiven for not recalling that dungeon content items used to be drawn by drawing textures in a rectangle at the appropriate coordinates:

class ContentPainter:
    error_texture = ':resources:images/items/star.png'
    def __init__(self, resource, scale=0.5):
        self.scale = scale
        try:
            self.shape = arcade.load_texture(resource)
        except (FileNotFoundError, AttributeError):
            self.shape = arcade.load_texture(self.error_texture)

    def draw(self, cx, cy, cell_size):
        texture = self.shape
        scale = scale_texture(texture, self.scale)
        arcade.draw_texture_rect(
            texture,
            arcade.XYWH(cx, cy, texture.width, texture.height).scale(scale)
        )

ContentPainter is yesterday’s Method Object that allows all the different types of Content to avoid duplicating those two methods, instead relying on a ContentPainter to do the job for them.

But that was the morning. In the afternoon I had a little time and converted the ContentPainter to use Sprites. I did that for two reasons: first, the Arcade documentation seems to recommend always using Sprites over naked textures. Second, I have in mind that we can put all the dungeon content into a single sprite list and draw them as a batch. We don’t really need that speedup in our little program but it’s the right thing to do.

So I changed ContentPainter to look like this:

class ContentPainter:
    error_texture = ':resources:images/items/star.png'
    def __init__(self, resource, scale=0.5):
        self.scale = scale
        try:
            texture = arcade.load_texture(resource)
        except (FileNotFoundError, AttributeError):
            texture = arcade.load_texture(self.error_texture)
        self.sprite = Sprite(texture)
        self.sprite.scale = scale_texture(self.sprite.texture, self.scale)

    def draw(self, cx, cy):
        sprite = self.sprite
        sprite.position = (cx, cy)
        arcade.draw_sprite(sprite)

Not very different. The init goes ahead and builds a sprite and scales it. The draw positions it and draws it.

If we’re going to put all the content sprites into a sprite list, the draw wold not longer call draw, but it would need to update the position of the sprite. I think it’s probable that we should use the on_update event to do that, not on_draw, more for clarity than need.

On the face of it, it would seem that once a ContentItem has been created and placed, we’d have all the information we need to do this, and that all we’ll need to do is have the DungeonView collect up the sprites into a list, let the various content items update, and then draw the list.

There is at least one issue. Invisible objects. Invisible objects and taken objects. There are at least two issues …

I did not expect this. Can we make a sprite invisible while it is in the list? And can we readily remove a sprite from the list? A bit of reading is called for.

A quick search suggests setting the Sprite’s alpha to 0 to be invisible, 255 to be visible. I guess we can deal with that readily enough. What about removing a sprite from its list? Ah, yes, remove_from_sprite_lists() will remove a sprite from all lists. That should work for us.

We can try the alpha trick right now, with the SecretKey object:

class SecretKey:
    def __init__(self, name='a secret key', resource=None):
        resource = '/Users/ron/Desktop/DungeonTiles/png/objects/Key (1).png'
        self.name = name
        self.painter = ContentPainter(resource, scale=0.5)
        self.visible = False

    def draw(self, cx, cy):
        if self.visible:
            self.painter.draw(cx, cy)

    def interact_with_player(self, dungeon):
        if self.visible:
            dungeon.receive_item(self)
            dungeon.announce(f'You have received {self.name}!')

    def placed(self, dungeon):
        self.visible = False
        def callback(event):
            dungeon.announce('A key has appeared!')
            self.visible = True
        dungeon.subscribe_once('button_pressed', callback)

We’ll just tell the painter to set its visibility. Let me try something clever here. No, on second thought, let me try something simple. I’ll call the painter each time I set visible, for now.

F___ LLMs!

I was sucked in by an AI Overview in my search for visibility. Arcade Sprites have a visible property, a boolean. Lying sack of LLM slop. I should know better.

The visible flag only works in sprite lists, so we’ll need to set alpha as well, for now. We have this:

class ContentPainter:
    def set_visibility(self, aBoolean):
        self.sprite.visible = aBoolean
        self.sprite.alpha = 255 if aBoolean else 0

class SecretKey:
    def __init__(self, name='a secret key', resource=None):
        resource = '/Users/ron/Desktop/DungeonTiles/png/objects/Key (1).png'
        self.name = name
        self.painter = ContentPainter(resource, scale=0.5)
        self.visible = False
        self.painter.set_visibility(False)

    def draw(self, cx, cy):
        self.painter.draw(cx, cy)

    def interact_with_player(self, dungeon):
        if self.visible:
            dungeon.receive_item(self)
            dungeon.announce(f'You have received {self.name}!')

    def placed(self, dungeon):
        self.visible = False
        self.painter.set_visibility(False)
        def callback(event):
            dungeon.announce('A key has appeared!')
            self.visible = True
            self.painter.set_visibility(True)
        dungeon.subscribe_once('button_pressed', callback)

The duplication tells us to Extract Method, resulting in:

class SecretKey:
    def __init__(self, name='a secret key', resource=None):
        resource = '/Users/ron/Desktop/DungeonTiles/png/objects/Key (1).png'
        self.name = name
        self.painter = ContentPainter(resource, scale=0.5)
        self.set_visibility(False)

    def draw(self, cx, cy):
        self.painter.draw(cx, cy)

    def interact_with_player(self, dungeon):
        if self.visible:
            dungeon.receive_item(self)
            dungeon.announce(f'You have received {self.name}!')

    def placed(self, dungeon):
        self.set_visibility(False)
        def callback(event):
            dungeon.announce('A key has appeared!')
            self.set_visibility(True)

        dungeon.subscribe_once('button_pressed', callback)

    def set_visibility(self, aBoolean):
        self.visible = aBoolean
        self.painter.set_visibility(aBoolean)

We are green, and the key behaves correctly. let’s commit this: SecretKey sets visibility via ContentPainter.

There is a thing that I don’t like. As shown above, PyCharm observes that the self.visible setter in set_visibility is setting an instance variable that is not defined in __init__. This is OK here, though it can often be a mistake. We could put a direct setting of visible in the init, but that looks odd and one is tempted to remove it. Then one gets the squiggles again.

I think we’ll let that ride for now. We are in search of bigger game, the sprite list for content. Let’s have a look at Dungeon and DungeonView to see how content is handled.

class Dungeon:
    def place_content_at(self, cell, content):
        self.contents[cell].append(content)
        content.placed(self)

I notice that we do not tell the content where it is. We could do, if it turns out to be useful. But it’s probably better for the view to do that, since in principle Dungeon doesn’t know anything about drawing.

class DungeonView:
    def run(self):
        self.setup()
        self.window.show_view(self)
        arcade.run()

    def setup(self):
        self.setup_scroller()
        self.setup_cameras()
        self.shape_list = arcade.SpriteList()
        self.create_rooms(self.shape_list)

    def on_draw(self):
        self.clear()
        self.scroll_dungeon_camera()
        with self.dungeon_camera.activate():
            self.shape_list.draw()
            self.draw_contents()
        with self.scroller_camera.activate():
            self.scroller.draw()

    def draw_contents(self):
        self.draw_content_objects()
        self.draw_adventurer()

    def draw_content_objects(self):
        for cell, content in self.dungeon.contents.items():
            cx, cy = cell.center_position(cell_size)
            for item in content:
                item.draw(cx, cy)

So it seems to me that if we were to create our content shape list in setup, and change ContentPainter draw to skip the drawing but set the position, and then we were to draw the content shape list, it should “just work”.

We’re at a save point, so we’ll just try it. I really don’t see a useful way to test-drive this sort of thing. If you do, please do a little demo and let me know.

OK, this almost works:

class DungeonView:
    def setup(self):
        self.setup_scroller()
        self.setup_cameras()
        self.shape_list = arcade.SpriteList()
        self.create_rooms(self.shape_list)
        self.content_list = arcade.SpriteList()
        self.create_content_list(self.content_list)

    def create_content_list(self, content_list):
        for content in self.dungeon.contents.values():
            for item in content:
                sprite = item.get_sprite()
                content_list.append(sprite)

    def draw_contents(self):
        self.draw_content_objects()
        self.draw_adventurer()

    def draw_content_objects(self):
        for cell, content in self.dungeon.contents.items():
            cx, cy = cell.center_position(cell_size)
            for item in content:
                item.draw(cx, cy)
        self.content_list.draw()

class ContentPainter:
    def draw(self, cx, cy):
        sprite = self.sprite
        sprite.position = (cx, cy)
        # arcade.draw_sprite(sprite)

I added this to all t ContentItems:

    def get_sprite(self):
        return self.painter.sprite

Everything draws as intended, but when we take something, it doesn’t disappear from the screen, although it does stop responding to being stepped on: you can’t pick it up twice. This is, I presume, because we aren’t removing taken items from the sprite list.

Hm. Here’s where we want to do that:

class Dungeon:
    def receive_item(self, item):
        self.player_inventory.append(item)
        content = self.contents_at(self.player_cell)
        if item in content:
            content.remove(item)

I think that right there, we want to remove the item’s sprite from the sprite list. However, Dungeon isn’t supposed to know things like that. Let’s set aside that concern: I have an idea about it, and see if it works:

    def receive_item(self, item):
        self.player_inventory.append(item)
        content = self.contents_at(self.player_cell)
        if item in content:
            content.remove(item)
            item.get_sprite().remove_from_sprite_lists()

It does in fact work. It is not “right”, in that it refers to an object’s innards. But we can have it call the object and tell it is is being moved_to_inventory. Or we could have the objects know to remove themselves from the list.

Let’s see about interaction:

class ReceivableContent(Content):
    def interact_with_player(self, dungeon):
        dungeon.receive_item(self)
        dungeon.announce(f'You have received {self.name}!')


class SecretKey:
    def interact_with_player(self, dungeon):
        if self.visible:
            dungeon.receive_item(self)
            dungeon.announce(f'You have received {self.name}!')

Ewww, duplication. We will resolve that with a Method Object, I expect. But for now, we’ll know that we are removing ourselves.

    def interact_with_player(self, dungeon):
        dungeon.receive_item(self)
        dungeon.announce(f'You have received {self.name}!')
        self.get_sprite().remove_from_sprite_lists()

Now this is happening in a place that makes a bit more sense, because ContentItems do know that they are drawn, and they understand that it involves sprites, and they are vaguely aware of the sprite list, because they don’t actually draw themselves in their draw function.

This is better. Commit: content items are drawn using a shape list.

Long article for a Sunday. Let’s sum up.

Summary

Having moved to drawing individual sprites, we moved to drawing them in a sprite list, which is zillions of times better than drawing them one at a time, if you have lots of them. We have about five, but that will surely double at least. But it’s the principle of the thing, using the Arcade toolkit as it is best used. Maybe the dragon’s treasure hoard will have individual coins and jewels, thousands of them. Who knows, certainly not I.

There are issues with this code. It works, and it’s more nearly right than it was, but, well, there are issues:

  1. The Content items are part game logic, in interaction, and part display logic, in visibility, sprites, sprite lists. An ideal design separates the “model” notions from the “view” notions.
  2. The interact_with_player methods of ReceivableIem and SecretKey are near-duplicates, and duplication tells us that there is a missing abstraction—read “idea”—in the code.
  3. SecretKey is obviously too detailed for what it does. There is probably a better way of doing that. Perhaps a TriggeredItem, which is always invisible, and when, when triggered, drops the item that it contains and destroys itself, resulting in the dropped item suddenly appearing. This might require an update to the sprite list, so it might not be the thing. But something needs to be done: there will surely be more than one thing that appears.
  4. In fact, every time you open a chest, a new treasure might appear. Definitely an issue.
  5. Some of our objects will need to “animate”. Chests can appear closed or open. Creatures might have a sequence of positions as they stand, or as they walk. With everything in sprite lists, how is this done?

And probably more. But it is better, and that’s the point: we can change the design, make it better, as we go.

And that, as I said in an earlier article, is a big part of why I do this.