The ‘art’ that I have for the game comes at various scales. We need some way to accommodate that. Outcome: OK, but there’s a tiny telltale scent of corruption. Squidgy.

The texture files that I recently purchased are nominally 256x256 PNG files. The floor tiles, seem all to be a true 256x256. But some of the ones in the ‘objects’ folder are only approximately that size. A random on that I just opened is 231x296. However, the ones in the ‘resources’ that come with Arcade are differently sized, and, oddly, the Mac Finder will often not tell me the dimensions. Preview says that the gem I just opened was 128x128, and that matches my experience with it.

This is important because I’d like to be able use any image that I might find as part of the dungeon. But if we don’t scale them properly, things look weird. In the following pic, textures from both sources are scaled the same:

map showing different sized room contents

You can see that the gems are very small, and that the keys are of very different sizes. In addition, the chest overlaps the adjacent tile a bit.

There are a few places right now where scaling goes on. In each case it is ad hoc: I just mashed it until things looked right, although not without a bit of understanding of what was needed. Here are a couple of cases:

class ReceivableContent(Content):
    error_texture = ':resources:images/items/star.png'
    def __init__(self, name, resource=None):
        self.name = name
        try:
            self.shape = arcade.load_texture(resource)
            print(f'{self.name} {self.shape.size}')
        except:
            print('invalid resource: ' + name)
            self.name = 'invalid resource: ' + name
            self.shape = arcade.load_texture(self.error_texture)

    def draw(self, cx, cy, size):
        texture = self.shape
        scale = size/256
        arcade.draw_texture_rect(
            texture,
            arcade.XYWH(cx, cy, texture.width, texture.height).scale(scale)
        )

class RoomView:
    def __init__(self, dungeon, room):
        self.dungeon = dungeon
        self.layout = dungeon.layout
        self.room = room
        self.texture_finder = TextureFinder()
        if not self.texture_finder.verify():
            raise Exception('Invalid texture finder')

    def create(self, shape_list):
        cells = set(self.room.cells)
        for cell in cells:
            self.choose_flooring(cell, shape_list)

    def choose_flooring(self, cell, shape_list):
        texture = self.choose_flooring_texture(cell)
        sprite = self.make_adjusted_sprite(cell, texture)
        shape_list.append(sprite)

    def make_adjusted_sprite(self, cell, texture):
        sprite = arcade.Sprite(texture, scale=1 / 16)
        sprite.position = cell.center_position(cell_size)
        return sprite

In the ReceivableContent object, size is the cell size as drawn, currently 16 pixels. In the RoomView, using a scale of 1/16 works because the floor tiles currently in use are 256x256 and 256/16 is 16, which is the value we want.

It turns out that we can get the dimensions of the texture from Arcade. Clearly that should be used to figure out scale.

I always have trouble with scaling, figuring out when to multiply and when to divide. Let’s consider cases:

  • If the PNG is 256x256, the proper scale is 1/16, which is 16/256, or cell_size/256.
  • If the PNG is 128x128, the proper scale would be 1/8, which is 16/128, or cell_size/128
  • Therefore, I conclude, the proper scale is cell_size/texture_size … where we’ll take the maximum of the x and y sizes.

Let’s try that.

I had to put this code in at least four places:

        x,y = texture.size
        texture_size = x if x > y else y
        scale = cell_size / texture_size

The picture looks better now:

map with better-scaled objects

I note that the chest is now inside the tile it’s sitting on. The two keys are of different sizes, but there’s no getting around that automatically, because the small ones, and the gems, are not only drawn at 128 size, but they are actually smaller than 128, not filling the square, while the pictures from my purchased set use, and even overflow, the full 256.

We might want to scale things to be just a bit less than a full cell size. We could do that by multiplying the scale by a small constant, 0.875 or something.

There’s a related issue, that I think we don’t care about, which is that scales that are exact inverse powers of two are best. But with a 307x245 texture, there’s no inverse power of two that we would like.

If we were a large production game shop, we’d probably standardize our art to one or maybe two sizes. If we were a mom and pop small game shop, we might carefully source a standard size or two. Since we are a shop whose purpose is to find and deal with programming situations, we’ll accept our variously-sized PNG files and adjust scale, at least until we truly hate what we’re seeing.

The current situation is that we have scaling code in four separate locations. Part of that is due to the duplication of code in our ContentItems, because we decided not to inherit behavior, and have not yet resolved that duplication. If we did, that would reduce three cases down to one. But we would still have the case in RoomView as well as the Contents.

We need a place for that little patch of scaling code to live. I wonder if we have access to a Cell instance at the time we calculate scale. We already have a convenient method in Cell for getting center position:

class Cell:
    def center_position(self, size):
        cx = self.x*size + size // 2
        cy = self.y*size + size // 2
        return cx, cy

If we had a bloody Vector class, that would be one line but life is tough. So if our scaling objects have a cell in hand, we could put the method there. Fact is, it’d be a static method anyway, wouldn’t it? So it doesn’t really belong on Cell.

If the texture were our own object, we could extend it to know how to scale.

The code for scaling:

    x,y = texture.size
    texture_size = x if x > y else y
    scale = cell_size / texture_size

… only references cell_size, which is presently a well-known constant. We could have a well-known function, I suppose, and put it in the tiny ‘params.py’ file.

RoomView, one of our objects that needs this, has access to the Dungeon, which shouldn’t really be concerned with cell size, which is a view thing. We could change RoomView to receive the DungeonView instead of the Dungeon itself. We only use the Dungeon to get the DungeonLayout anyway.

I think I like this thread of reasoning. It makes perfectly good sense for the DungeonView to know the cell_size, and with a bit of care we might be able to get rid of that params file.

But what do our ContentItems know, and what could they know? They mostly only know their name. They are passed the dungeon when they interact, and when they are placed in the dungeon, they are passed the dungeon, with which they can interact. We have a publish-subscribe experiment in progress: the Button publishes that it has been pressed, which wakes up the SecretKey and has it display itself. The Button doesn’t know who is listening to it, which is good. There will be other buttons that trigger multiple items and so on.

The Content actually does need to interact with the Dungeon, not the View, at least as long as the Dungeon is the central repository of all knowledge.

We could use the pub-sub to do the scaling in the Content … but the RoomView is building up the dungeon image and the Dungeon class isn’t really running yet, so pub-sub might not be ready. Let’s see:

class Dungeon:
    def __init__(self, layout):
        self.layout = layout
        self.player_cell = None
        self.player_inventory = []
        self.contents= defaultdict(list)
        self.announcements = []
        self.pub_sub = PubSub()

A brief exploration tells me that PubSub isn’t ready to deal with arguments yet. That isn’t something I want to just slam in, so it will need to wait.

Putting the scaling on Dungeon isn’t right. We could modify the draw in DungeonView:

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

Are the contents really indexed by coordinates, or is it cells?

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

It’s really cell, which is actually clear because we called center_position on it.

Change that temp name:

    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, size=cell_size)

Wait. We don’t need to reference cell_size here:

class DrawableContent:
    def draw(self, cx, cy, size):
        texture = self.shape
        x,y = texture.size
        texture_size = x if x > y else y
        scale = cell_size / texture_size
        arcade.draw_texture_rect(
            texture,
            arcade.XYWH(cx, cy, texture.width, texture.height).scale(scale)
        )

The size parameter is in fact cell size. Rename it. Slightly better, with no import from params:

    def draw(self, cx, cy, cell_size):
        texture = self.shape
        x,y = texture.size
        texture_size = x if x > y else y
        scale = cell_size / texture_size
        arcade.draw_texture_rect(
            texture,
            arcade.XYWH(cx, cy, texture.width, texture.height).scale(scale)
        )

Better, but we still have the duplication. Let’s extract a function in the ContentItem file, so that it’s down to two places. Or … what if we were to pass the DungeonView to the draw? I think a local function is a smaller change. I have in the back of my mind that we should probably turn all of our content items into sprites and generate them once and for all. One issue with that is that come content items, but Button being an example, have more than one texture. I’m not sure how one handles that. I’ve made a note to prioritize that issue.

For now, a function, in params, just so there is only one, not two.

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

class RoomView:
    def make_adjusted_sprite(self, cell, texture):
        scale = scale_texture(texture)
        sprite = arcade.Sprite(texture, scale=scale)
        sprite.position = cell.center_position(cell_size)
        return sprite

params.py
# dungeon parameters

cell_size = 16

def scale_texture(texture):
    x,y = texture.size
    size = x if x > y else y
    return cell_size / size

Everything still looks good. We’ll call it a morning.

Summary

Somewhat satisfactory but not really tasty. The good news is that we have scaling of textures pretty much sorted out, modulo perhaps a small adjustment later, and the code to do the scaling is only in one place, called from four places. It would be called from two, if we would get rid of the duplication in the ContentItem hierarchy. Ideally, we’d find a way to have it be called only once.

We might accomplish that if we were to convert our textured rectangles in ContentItem into Sprites. Ideally-squared, we would have all those sprites in a single sprite list that could be displayed as a batch, since Arcade provides that capability. At our scale it really doesn’t matter but it would be “best”.

There’s nothing fundamentally wrong with having utility functions in the params file, and we could certainly set that up as a convention. As a card-carrying Object Oriented Person, I think that everything is supposed to be an object and that there must be some class of which such functions are members. And if we had our own texture class, that would be one such place.

The situation feels squidgy to me. I get a slight scent of something unsavory but can’t quite identify what it is. There’s something not right in the class hierarchy, the object hierarchy, or both, I’m sure of that. But I’m not quite sniffing out what it is, or how to improve things.

I do rather like the idea of using PubSub for things like this, but you could argue that PubSub is just a cop-out equivalent to some giant global. It’s not, but you could make a good case. A jury might convict me. In any case, PubSub’s purpose is for dungeon objects to use to call out what is happening to them, and to have other dungeon objects hear them and react. It’s not really there to support our python code when it’s hard to reach someone who can help us. Those situations call for better connections between objects, and better parameter lists.

The tiny telltale scent of corruption notwithstanding, we have better-scaled graphics, with the scaling done in one place despite multiple classes needing scaling. That’s progress.

We are perfect … and we could use a little improvement. (pace Suzuki Roshi)

See you next time!


P. S.

I am reminded of this, one of my favorites. Flowers of Evil.