Hello, loves!

Some ideas about doors, and with luck, a bit more code.

My new friend on Mastodon, @seedsignal, got me thinking about whether there are “natural” locations for passages between rooms in the Dungeon. The word “passages” just came to mind right there, and it might be a better term. A passage might have a door, or might not? I don’t know. Anyway, I was thinking about where connections between rooms might just feel right. I don’t have a solid theory on that, and I absolutely refuse to read a 1088 page book on physical geology to find out what caves do. (Sorry, seedsignal, but no. Just no.)

I’m far from certain but after looking at some maps, I have an idea, which is to try to place passages where a cell in one of the rooms is adjacent to more than one cell in the other. That will occur where there is a corner or short hallway. Generally the sides of a cell with multiple connections to the other room will be adjacent, but sometimes they’ll be opposing. Think a north-south hallway extending into another room.

I was also thinking, very vaguely, about algorithms for finding out things about borders, and for deciding whether or not Dot can move into a given cell. As things stand, a Cell is really nothing more than an xy pair:

class Cell:
    def __init__(self, x, y):
        self._xy = (x,y)

In particular, a cell does not know what room it is in. Does the DungeonLayout know? It does not:

class DungeonLLayout:
#cell info
    def is_available(self, cell) -> bool:
        return not self.is_in_a_room(cell)

    def is_in_a_room(self, cell) -> bool:
        return self.get_room(cell) is not None

    def get_room(self, cell) -> Room|None:
        for room in self.rooms:
            if cell in room:
                return room
        return None

I think we need to do better than that. One more diversion, how the layout gets wrapped up with a dungeon builder is finished building. Main just stops building rooms, makes a dungeon from the layout, and starts putting things at cell locations. We do not yet have a general way of putting content into rooms: we’ve been concentrating on how content works, and have been placing it at known locations for testing.

I think we’d like to preserve the notion of a Cell instance as nothing but coordinates, and we might do well to rename it that. We have this other notion of a cell as a little square in the dungeon that has contents. How is that trick done, you might ask. I certainly do.

The Dungeon instance has a dictionary from Cell (coordinates) to a list of contents, for all cells with contents. What about rooms? The code I’ve been speculating about requires the ability to find the room a cell is in, given the cell. I’m quite sure that we need that information, and the code above tells me that we already need it.

Can we modify DungeonLayout to maintain that information more efficiently? That seems like a reasonable thing to ask.

I believe that cells are available, from the viewpoint of the DungeonLayout, until it is given a Room to add, at which point the above code will find it to be in a room and thus not available.

Let’s modify DungeonLayout to maintain a mapping from Cell to Room. We write this new test:

    def test_room_presence(self):
        layout = DungeonLayout(5, 5)
        cell = layout.at(3, 3)
        assert layout.is_available(cell) is True
        room = Room([cell], 'room')
        assert layout.is_available(cell) is True
        layout.add_room(room)
        assert layout.is_available(cell) is False

The cell appears to be available. Our rule is that the first room to grab a cell owns it, if I’m not mistaken.

We want now to maintain a mapping from cell to room, in the layout. This will change the code that this test exercises and of course there are other tests and code relying on those methods shown above.

class DungeonLayout:
    def __init__(self, max_x=10, max_y=10):
        self.max_x = max_x
        self.max_y = max_y
        self.border_map = None
        self.cells = dict()
        for x in range(max_x):
            for y in range(max_y):
                self.cells[(x,y)] = Cell(x, y)
        self.rooms = []
        self.room_map = dict()

    def get_room(self, cell) -> Room|None:
        try:
            return self.room_map[cell]
        except KeyError:
            return None

The tests loop. I don’t care, we’re not done yet.

class DungeonLayout:
    def add_room(self, room):
        room.subtract_all(self.rooms)
        for cell in room:
            self.room_map[cell] = room
        self.rooms.append(room)

    def remove_room(self, room):
        for cell in room:
            self.room_map[cell] = None
        self.rooms.remove(room)

We are green. And now we can more reasonably ask the layout what room a cell is in.

Commit: DungeonLayout maintains efficient mapping from cell to room.

Reflection

We didn’t need to do that: we could already find out what room a cell was in. And no performance measurement told us that it needed to be faster. So sue me. I saw the shot and I took it and scored.

let’s think about Dot attempting to cross a border. As thing stand now, we can see the objects in adjacent rooms: I think we’ll fix that sometime soon, making all the rooms outside the current room dark. Currently we allow Dot to move into a new cell if the cell’s contents approve. However, if the cell is not in the same room, she should not be allowed to interact with the contents at all. So I think we need to check the cells themselves to determine whether to attempt the interaction that occurs when Dot tries to enter a cell.

All that takes place here:

class Dungeon:
    def attempt_move_to_offset(self, cell, offset):
        new_cell = self.layout.at_offset(cell.xy, offset)
        if new_cell is None or self.layout.is_available(new_cell):
            return
        self.place_player_at(new_cell)

    def place_player_at(self, cell):
        interactor = Interactor(self, cell)
        if all(interactor.execute(item) for item in list(self.contents_at(cell))):
            self.player_cell = cell

We don’t want to interact if she can’t move in anyway. So up in attempt we need to check whether she can move. Let’s rig it so that she cannot move between rooms at all. We’re assuming there is no passage.

    def attempt_move_to_offset(self, cell, offset):
        new_cell = self.layout.at_offset(cell.xy, offset)
        if new_cell is None or self.layout.is_available(new_cell):
            return
        if self.in_different_rooms(cell, new_cell):
            return
        self.place_player_at(new_cell)

    def in_different_rooms(self, now, proposed):
        return self.layout.get_room(now) != self.layout.get_room(proposed)

We have no test for this but I think that Dot is now trapped in the cross-shaped room. And that’s what happens. I wonder what we could do to allow her passage?

What if the Layout had an additional structure, passages, that lists pairs of cells that are permeable? Or should that be a Dungeon structure? I think the latter, because passages might change based on activities in the dungeon and the layout is supposed to be static most of the time.

Let’s see about another test.

    def test_passage(self):
        layout = DungeonLayout(10, 10)
        room_a_cell = layout.at(5, 5)
        room_a = Room([room_a_cell])
        layout.add_room(room_a)
        room_b_cell = layout.at(6, 5)
        room_b = Room([room_b_cell])
        layout.add_room(room_b)
        dungeon = Dungeon(layout)
        dungeon.place_player_at(room_a_cell)
        dungeon.attempt_move_to_offset(room_a_cell, (1,0))
        assert dungeon.player_cell == room_a_cell
        dungeon.add_passage(room_a_cell, room_b_cell),
        dungeon.attempt_move_to_offset(room_a_cell, (1,0))
        assert dungeon.player_cell == room_b_cell

This fails on add_passage, since there is none. Code:

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

    def add_passage(self, room_1, room_2):
        self.passages[(room_1, room_2)] = True
        self.passages[(room_2, room_1)] = True

I didn’t have a better idea than storing both directions. Now to check:

    def attempt_move_to_offset(self, cell, offset):
        new_cell = self.layout.at_offset(cell.xy, offset)
        if new_cell is None or self.layout.is_available(new_cell):
            return
        if self.in_different_rooms(cell, new_cell):
            if not self.passages.get((cell, new_cell), False):
                return
        self.place_player_at(new_cell)

The test passes. Now I think we can free Dot in the game by adding a passage in main.

main.py
    ,,,
    dungeon.add_passage(Cell(32,30), Cell(32,31))

Voila! In the video below, Dot tries to leave the central room on two sides, then goes up the middle and finds the passage to the other room. We have a proof of concept here.

I’d appreciate a break at this point, if you don’t mind, so let’s commit this: Hard-coded passage at top of central room, and then sum up.

Summary

A quick test gave us a speedup for finding the room a given cell is in. While the optimization wasn’t absolutely necessary, understanding those methods was, since they’re used in the sequel. Then another test drove out a simple notion of passage between cells.

The implementation of that notion is a bit interesting, as it actually represents two passages, one from A to B and one from B to A. We could have a one-way passage quite easily. (I just thought of that: I’m glad we were having this little chat.)

As the saying goes, “this changes everything”. Suddenly the walls actually work like walls. An early future move should be to create passages between all the rooms, and, if we were really nice people, we’d identify them somehow, at least for people in the know. Otherwise Dot would have to bang all along all the borders trying to get through.

All that will surely come, it just makes sense. We’ve taken a nice step forward. Honestly I had no idea that we’d get here this morning. It just made sense, after making all the walls impermeable, to add a passage notion to the Dungeon. Then, while doing it, adding the pair (a,b) to the passages, I realized that we couldn’t be sure which cell to put first, so I added (b,a) as well. Only later did I realize that that was a feature.

To me, one of the joys of programming is the little bits of discovery such as we had here today. We stand somewhere, we look around, we move somewhere else, look around. We see something good: we take it. And the program becomes more and more capable.

Is the program’s design better now, or not? Both DungeonLayout and Dungeon are a bit more complex now. There are probably some objects waiting to be created. A Passages object might be a good one, covering that odd dictionary. There might be some useful little classes inside Layout as well. Maybe Dungeon or Layout or both cold be split into two or more objects. Maybe the responsibilities aren’t divided quite right between them.

Improvements are certainly possible. We’ll discover some, and make them. Some we may never discover, in places where what we have is good enough. What is quite certain, in my mind at least, is that when we see issues with the design, we will readily find ways to improve it.

I can hardly wait to see what I do next. Hoping you are the same, I remain yours etc etc. See you next time!