Yesterday’s idea for selecting random tiles seems tasty. Let’s try it. Is it a kind of Composite Pattern? Answer: a firm ‘kind of’.

I did make two new tiles last night, and found a couple more, so I”ll install those first. Just hold on a moment, OK?

map nearly without mud

Just a couple more to make and there will be no unintentional mud in the dungeon. I’ve taken to renaming the tile files to match their symbolic names, ‘F_NW’ and ‘F_NW.png’ and so on. It’s easy to do and makes editing the TextureFinder’s dictionary easier and less prone to errors.

Now the Good Part

OK, let’s do the good part. Here’s the idea from yesterday about how to support weighted collections of tiles to be chosen randomly:

I have a vague notion of how we might do that. What if instead of just a file name string, the TextureFinder saved a small object containing the string, returning the string via an accessor? Then we could create another small object, containing several file name strings, and some weights, returning one randomly.

Or, perhaps the second object contains a list of keys (currently called border type or border string) into the TextureFinder, randomly selecting a key and then fetching the actual file name with another call back to the Texture Finder. The first way is simpler, but the second has some appeal. Might be too clever: I’ll have to think about it.

Let me try to firm up the idea a bit now.

The purpose of TextureFinder is to return a full-path file name, given a key. The key could be any string, or probably anything hashable, but is intended to be the symbolic name of a texture. The TextureFinder doesn’t care about the keys, it just looks them up. It also knows a file path name and prepends that to the results it returns, so that the values in the TextureFinder’s dictionary will be short file names and the output will be complete file names.

Presently, the mapping is direct, key in, corresponding path out, as found in an internal dictionary. But we have another common use of the TextureFinder, where we have a list of candidate textures and want to select one randomly. For example, we have several different tiles that can make up the general flooring of the dungeon and we want to use them randomly.

The current implementation of this is outside TextureFinder: the program has a list of file keys, selects one randomly, and then looks up the result in TextureFinder. I think it would be “better” if TextureFinder were to support this capability directly.

The plan is this: We’ll have two small objects that we store in the TextureFinder dictionary, a SimpleName, and a RandomName. (These names may change.)

Both these objects respond to some method, say, get_name. The SimpleName just returns its name. The RandomName contains a list of keys and a list of weights. It responds to get_name by randomly selecting a key and going a get_name against the TextureFinder, to return its path. (This could, in principle, recur more than once but I don’t see a use for that … although … quiet, Satan, get behind me.)

Is it “Composite”?

The answer to the question in the blurb is a firm “Kind of”. Composite lets us use the same protocol on a single “leaf” item and on containers of “leaf” instances. And we’re going to do that exact thing, probably with just one method. The difference is that that method will always return a leaf instance, that is, a single texture in our case. My best guess is that yes, this is Composite, but a very simple almost degenerate case thereof.

That’s about the best description of the plan I can create without doing it. So let’s TDD this baby up.

I think we can do the SimpleName relying on existing tests. Let’s see what the TextureFinder looks like. I think this much is all we need to consider:

class TextureFinder:
    def __init__(self):
        self.path = '/Users/ron/Desktop/DungeonTiles/png/tiles/'
        self.names = {
        '': 'Tile (21).png', # none
        'E': 'Tile (29).png', # east
        'N': 'Tile (36).png', # north
        'EN': 'F_EN.png',
        'W': 'Tile (33).png', # west
        'EW': 'Tile (67).png',
        'NW': 'F_NW.png',
        'ENW': 'F_ENW.png',
        'S': 'Tile (25).png', # south
        'ES': 'F_ES.png',
        'NS': 'Tile (03).png',
        'ENS': 'F_ENS.png',
        'WS': 'F_WS.png',
        'EWS': 'F_EWS.png',
        'NWS': 'F_NWS.png',
        'ENWS': 'Tile (57).png',
    }

    def add_texture(self, name, texture):
        self.names[name] = texture

    def short_name(self, borders):
        try:
            return self.names[borders]
        except KeyError:
            return 'NoNoNoNo'

We could edit the names dictionary to call the SimpleName constructor on every line but instead let’s do this, which will break everything. (It did but I fixed it essentially instantly. The following is working.)

class SimpleName:
    __slots__ = ['file_name']
    def __init__(self, file_name):
        self.file_name = file_name


class TextureFinder:
    def __init__(self):
        self.path = '/Users/ron/Desktop/DungeonTiles/png/tiles/'
        raw_names = {
            '': 'Tile (21).png', # none
            'E': 'Tile (29).png', # east
            'N': 'Tile (36).png', # north
            'EN': 'F_EN.png',
            'W': 'Tile (33).png', # west
            'EW': 'Tile (67).png',
            'NW': 'F_NW.png',
            'ENW': 'F_ENW.png',
            'S': 'Tile (25).png', # south
            'ES': 'F_ES.png',
            'NS': 'Tile (03).png',
            'ENS': 'F_ENS.png',
            'WS': 'F_WS.png',
            'EWS': 'F_EWS.png',
            'NWS': 'F_NWS.png',
            'ENWS': 'Tile (57).png',
        }
        self.names = {key: SimpleName(value) for key, value in raw_names.items()}

    def add_texture(self, name, texture):
        self.names[name] = SimpleName(texture)

    def short_name(self, borders):
        try:
            return self.names[borders].file_name
        except KeyError:
            return 'NoNoNoNo'

Green and main works. Commit: added SimpleName to TextureFinder dictionary.

So that’s nice. Now let’s TDD the RandomName class.

    def test_random_name(self):
        tf = TextureFinder()
        names = ['A', 'B']
        weights = [1, 0]
        random_name = RandomName(names, weights)
        chosen = random_name.choose()
        assert chosen == 'A'
        weights = [0, 1]
        random_name = RandomName(names, weights)
        chosen = random_name.choose()
        assert chosen == 'B'

And:

class RandomName:
    __slots__ = ['file_names', 'weights']
    def __init__(self, file_names, weights):
        self.file_names = file_names
        self.weights = weights

    def choose(self):
        return random.choices(self.file_names, weights=self.weights)[0]

Now we need to TDD out the recursive bit.

    def test_random_lookup(self):
        tf = TextureFinder()
        names = ['EN', 'XXX']
        weights = [1, 0]
        random_name = RandomName(names, weights)
        chosen = random_name.short_name(tf)
        assert chosen == 'F_EN.png'

We need to return a key from our RandomName object, so that it can be looked up.

This is green:

class RandomName:
    __slots__ = ['file_names', 'weights']
    def __init__(self, file_names, weights):
        self.file_names = file_names
        self.weights = weights

    def choose(self):
        return random.choices(self.file_names, weights=self.weights)[0]

    def short_name(self,finder):
        return finder.short_name(self.choose())

However, to make it work in the finder, we’ll need a bit more. Another test:

    def test_random_lookup_using_finder(self):
        tf = TextureFinder()
        names = ['EN', 'XXX']
        weights = [1, 0]
        random_name = RandomName(names, weights)
        tf.add_complex_texture('random', random_name)
        chosen = tf.full_name('random')
        assert chosen == '/Users/ron/Desktop/DungeonTiles/png/tiles/F_EN.png'

To make this work, I changed just these:

class SimpleName:
    def short_name(self, finder):
        return self.file_name

class TextureFinder:
    def add_complex_texture(self, name, texture_object):
        self.names[name] = texture_object

    def short_name(self, borders):
        try:
            return self.names[borders].short_name(self)
        except KeyError:
            return 'NoNoNoNo'

I don’t like the short_name method name in the SimpleName and RandomName objects. It’s too much like short_name in the TextureFinder. Let’s just call it get and see how we feel about that.

We’ll review that in a moment. We’re green, let’s commit: TextureFinder running on SimpleName and RandomName. RandomName not yet in prod use.

We should be able to use our RandomName object in RoomView now, changing this:

class RoomView:
    def __init__(self, dungeon, room):
        self.dungeon = dungeon
        self.layout = dungeon.layout
        self.room = room
        self.texture_finder = TextureFinder()
        F1 = 'Tile (21).png'
        F2 = 'Tile (22).png'
        G1 = 'Tile (13).png'
        G2 = 'Tile (23).png'
        self.texture_finder.add_texture('F1', F1)
        self.texture_finder.add_texture('F2', F2)
        self.texture_finder.add_texture('G1', G1)
        self.texture_finder.add_texture('G2', G2)
        if not self.texture_finder.verify():
            raise Exception('Invalid texture finder')
        self.textures = ['F1', 'F2', 'G1', 'G2']
        self.weights = [25, 5, 1, 1]

To this:

class RoomView:
    def __init__(self, dungeon, room):
        self.dungeon = dungeon
        self.layout = dungeon.layout
        self.room = room
        self.texture_finder = TextureFinder()
        F1 = 'Tile (21).png'
        F2 = 'Tile (22).png'
        G1 = 'Tile (13).png'
        G2 = 'Tile (23).png'
        self.texture_finder.add_texture('F1', F1)
        self.texture_finder.add_texture('F2', F2)
        self.texture_finder.add_texture('G1', G1)
        self.texture_finder.add_texture('G2', G2)
        names = ['F1', 'F2', 'G1', 'G2']
        weights = [25, 5, 1, 1]
        rando = RandomName(names, weights)
        self.texture_finder.add_complex_texture('', rando)
        if not self.texture_finder.verify():
            raise Exception('Invalid texture finder')

And change this:

    def choose_flooring_texture(self, cell):
        borders: BorderList = self.layout.get_borders(cell)
        border_type = borders.border_string()
        if border_type == '':
            border_type = random.choices(self.textures, self.weights)[0]
        name = self.texture_finder.full_name(border_type)
        return arcade.load_texture(name)

To this:

    def choose_flooring_texture(self, cell):
        borders: BorderList = self.layout.get_borders(cell)
        border_type = borders.border_string()
        name = self.texture_finder.full_name(border_type)
        return arcade.load_texture(name)

This works and displays a correct random map:

map with correct random tiles

Commit: RandomName now in use in prod.

OK, lets reflect and sum up.

Reflective Summary

We have added two tiny objects that are used almost only inside TextureFinder. But those two tiny objects reduce the complexity of RoomView, and remove a procedural process of computing random names and then fetching. From the viewpoint of RoomView, random tile selection is just a decision in the data, not a decision in the code. This is a good thing.

We need some kind of “constructor” method if we want to avoid importing RandomName into RoomView, and we do want to avoid that. If those two classes are “private” to TextureFinder, the whole system is a bit less complicated to understand. They are already textually included in the same file.

We’ll want to move the creation of F1, F2 G1, and G2 inside the creation of the current default (and only) TextureFinder. Probably they should get more meaningful names.

And we’ll surely want to create a couple of new TextureFinders: I have in mind a muddy one and a wet one.

A legitimate concern …

The implementation of RandomName is unquestionably “clever”, in that it contains a collection of names, chooses one, and then recursively calls the short_name method on the finder. I don’t think it is “too clever, as it is a fairly well-known pattern. I do think that the get / short_name hand off isn’t quite right and in fact it is possible that short_name, or some common name, should be the same throughout.

I need to think about that … I think what we’re seeing here is that there are really two things going on in TextureFinder. One is to map a convenient key value to a short file name, and the other is to expand that file name to a full path to the file. It feels to me that there is a little bump in the code, in that we call get, and in the case of RandomName we want to recur … but we don’t call get again. It’s working … but it’s not quite right. I think maybe short_name was the right name after all.

We’ll let this sit and reconsider later.

Whatever we call it, our two objects, SimpleName and RandomName, must support the same access method, currently get(finder), and this is not yet required other than by convention. We should probably use AbstractBaseClass to nail that down.

Bottom line, we have a very nice little improvement. It’s not perfect yet, and may never be, but it’s standing in just about the right place, doing just about the right job. It can be improved, and probably will be.

In short, woot! Worked as planned. It’s nice when that happens. See you next time!