Drawing a room looks good. Let’s generate a whole space of them and see how the map looks. We’ll want some design improvement along the way.

The current “design” includes a Dungeon, containing Rooms, containing tuples representing a cell position in a 2d space ranging from zero up to some limit in x and y. I think of those tuples as “cells”, but as yet there is no Cell class. We do have a CellBank, also containing tuples. It starts with all the tuples and hands them over to rooms on request. Rooms grow by randomly selecting an available space surrounding a randomly selected cell of the room. There are details, of course.

We have a DungeonView class that handles the drawing of the dungeon. It contains the pygame init and game loop. Let’s take a look at that:

screen_width = 1024
screen_height = 1024 - 128
cell_size = 16

class DungeonView:
    def __init__(self):
        pygame.init()
        pygame.display.set_caption("Dungeon")
        self.screen = pygame.display.set_mode(
            (screen_width, screen_height))
        self.clock = pygame.time.Clock()
        bank = CellBank(64, 56)
        self.room = Room()
        # random.seed(234)
        self.room.build(bank, 200, (32,28))


    def main_loop(self):
        running = True
        moving = False
        background = "gray33"
        color = "darkblue"
        while running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
            self.screen.fill(background)
            for x in range(screen_width//cell_size):
                dx = (x+1)*cell_size
                for y in range(screen_height//cell_size):
                    dy = (y+1)*cell_size
                    pygame.draw.line(
                        self.screen, color,
                        (dx, 0),
                        (dx, screen_height))
                    pygame.draw.line(
                        self.screen, color,
                        (0, dy),
                        (screen_width, dy)
                    )
            for cell in self.room.cells:
                dx = cell[0]*cell_size
                dy = cell[1]*cell_size
                pygame.draw.rect(self.screen, 'red',
                                 (dx, dy, cell_size, cell_size))
            self.clock.tick(60)
            pygame.display.flip()
        pygame.quit()

That could use a bit of refactoring to make it a bit more clear. Extract a method:

    def main_loop(self):
        running = True
        moving = False
        background = "gray33"
        color = "darkblue"
        while running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
            self.screen.fill(background)
            self.draw_grid(color)
            for cell in self.room.cells:
                dx = cell[0]*cell_size
                dy = cell[1]*cell_size
                pygame.draw.rect(self.screen, 'red',
                                 (dx, dy, cell_size, cell_size))
            self.clock.tick(60)
            pygame.display.flip()
        pygame.quit()


    def draw_grid(self, color: str):
        for x in range(screen_width // cell_size):
            dx = (x + 1) * cell_size
            for y in range(screen_height // cell_size):
                dy = (y + 1) * cell_size
                pygame.draw.line(
                    self.screen, color,
                    (dx, 0),
                    (dx, screen_height))
                pygame.draw.line(
                    self.screen, color,
                    (0, dy),
                    (screen_width, dy)
                )

A bit better. I was going to extract the cell-drawing loop as well but since our mission this morning is to draw all the rooms, I think we’ll proceed differently.

Look at the init:

class DungeonView:
    def __init__(self):
        pygame.init()
        pygame.display.set_caption("Dungeon")
        self.screen = pygame.display.set_mode(
            (screen_width, screen_height))
        self.clock = pygame.time.Clock()

        bank = CellBank(64, 56)
        self.room = Room()
        # random.seed(234)
        self.room.build(bank, 200, (32,28))

We’re just setting up a room to draw, because yesterday our mission was to draw a room. What we should really want, it seems to me, is for the DungeonView to be created with a Dungeon and it should work from there. So, for today’s current purpose, Those final four lines should really be in the main function, and we should be passed a Dungeon from there.

Let’s get a clean commit and then see if we can do this in small steps. Things go better with small steps.

Let’s build a Dungeon here in the DungeonView, and adjust the code to use it. Dungeons don’t currently do much:

class Dungeon:
    def __init__(self):
        self.rooms = []

    @property
    def number_of_rooms(self):
        return len(self.rooms)

    def add_room(self, room):
        self.rooms.append(room)

We’ll see about improving that as part of this series of moves. What do we want (really really want)? We want our DungeonView to draw the grid and then draw all the rooms (and whatever paraphernalia may come along later). It seems likely that a room should actually be drawn by a RoomView, because DungeonView shouldn’t really know how to draw a Room, now, should it? So we’ll work in that direction.

So as our first move, we’ll create a Dungeon and put our room into it.

class DungeonView:
    def __init__(self):
        pygame.init()
        pygame.display.set_caption("Dungeon")
        self.screen = pygame.display.set_mode(
            (screen_width, screen_height))
        self.clock = pygame.time.Clock()

        self.dungeon = Dungeon()
        bank = CellBank(64, 56)
        room = Room()
        random.seed(234)
        room.build(bank, 100, (0, 0))
        self.dungeon.add_room(room)

This won’t quite work yet, because our drawing code refers to self.room. We want to get things back quickly, so we’ll do this:

    room = self.dungeon.rooms[0]
    for cell in room.cells:
        dx = cell[0]*cell_size
        dy = cell[1]*cell_size
        pygame.draw.rect(self.screen, 'red',
                         (dx, dy, cell_size, cell_size))

A hack, yes. And it should draw a room for me. And it does. Commit: moving toward full Dungeon and RoomView.

Let’s extract a room-drawing method now:

    def main_loop(self):
        running = True
        moving = False
        background = "gray33"
        color = "darkblue"
        while running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
            self.screen.fill(background)
            self.draw_grid(color)
            self.draw_rooms()
            self.clock.tick(60)
            pygame.display.flip()
        pygame.quit()

    def draw_rooms(self):
        room = self.dungeon.rooms[0]
        for cell in room.cells:
            dx = cell[0] * cell_size
            dy = cell[1] * cell_size
            pygame.draw.rect(self.screen, 'red',
                             (dx, dy, cell_size, cell_size))

I named it draw_rooms for a reason. First test and commit. Now what I’d like to do is to draw all the rooms. Let’s demand something that we don’t have yet. No, first this:

    def draw_rooms(self):
        for room in self.dungeon.rooms:
            for cell in room.cells:
                dx = cell[0] * cell_size
                dy = cell[1] * cell_size
                pygame.draw.rect(self.screen, 'red',
                                 (dx, dy, cell_size, cell_size))

That will draw all the rooms in the Dungeon, but they will all be red. Let’s make the colors random.

    def draw_rooms(self):
        for room in self.dungeon.rooms:
            for cell in room.cells:
                dx = cell[0] * cell_size
                dy = cell[1] * cell_size
                r = random.randint(0, 255)
                g = random.randint(0, 255)
                b = random.randint(0, 255)
                room_color = pygame.Color(r, g, b)
                pygame.draw.rect(self.screen, room_color,
                                 (dx, dy, cell_size, cell_size))

Now let’s have some more rooms. No, wait, that won’t work. We’ll be giving rooms a random color on every clock tick. Improve Room.

class Room:
    def __init__(self):
        self.cells:list[tuple[int, int]] = []
        r = random.randint(0, 255)
        g = random.randint(0, 255)
        b = random.randint(0, 255)
        self.color = pygame.Color(r, g, b)

After a small mistake, I have this in the DungeonView init:

        start = (0,0)
        for _ in range(5):
            room = Room()
            start = room.build(bank, 100, start)
            self.dungeon.add_room(room)

That adds five new rooms. (My first cut, I forgot to create a new room each time through and got one very large room.) Let me up that number for effect and show the result.

map of many colors

Well, that’s what I asked for. I can’t say that I like it for daily use but it is definitely working. Now that it works, let’s make the code more nearly right.

I think I’d like to have a RoomView object to manage the drawing of a room. One question is whether we should create them dynamically, or whether the DungeonView should have a collection of them. We have no evidence that creating them dynamically is bad, so let’s create them on the fly.

Programming by Wishful Thinking, I’d like to DungeonView to do this:

class DungeonView:
    def draw_rooms(self):
        for room in self.dungeon:
            view = RoomView(room)
            view.draw(self.screen)

PyCharm tells me that I can’t iterate the dungeon and that there is no RoomView. I knew that.

The first is easy:

class Dungeon:
    def __init__(self):
        self.rooms = []
        
    def __iter__(self):
        return iter(self.rooms)

I should have left the code the way it was, then I could commit. Revert a bit:

    def draw_rooms(self):
        for room in self.dungeon:
            # view = RoomView(room)
            # view.draw(self.screen)
            for cell in room.cells:
                dx = cell[0] * cell_size
                dy = cell[1] * cell_size
                pygame.draw.rect(self.screen, room.color,
                                 (dx, dy, cell_size, cell_size))

Let’s commit, same message: moving toward full Dungeon and RoomView.

Extract a method:

    def draw_rooms(self):
        for room in self.dungeon:
            self.draw_room(room)

    def draw_room(self, room):
        # view = RoomView(room)
        # view.draw(self.screen)
        for cell in room.cells:
            dx = cell[0] * cell_size
            dy = cell[1] * cell_size
            pygame.draw.rect(self.screen, room.color,
                             (dx, dy, cell_size, cell_size))

We can and do commit.

Now if I uncomment the RoomView line, PyCharm will offer to make a class for me.

class RoomView:
    def __init__(self, room):
        self.room = room

    def draw_room(self, room):
        view = RoomView(room)
        # view.draw(self.screen)
        for cell in room.cells:
            dx = cell[0] * cell_size
            dy = cell[1] * cell_size
            pygame.draw.rect(self.screen, room.color,
                             (dx, dy, cell_size, cell_size))

Commit. Then a bit of copy-pasta:

class RoomView:
    def __init__(self, room):
        self.room = room

    def draw(self, screen):
        for cell in self.room.cells:
            dx = cell[0] * cell_size
            dy = cell[1] * cell_size
            pygame.draw.rect(screen, self.room.color,
                             (dx, dy, cell_size, cell_size))

Test. Commit. (Arrgh, somewhere along the way I committed a bunch of .idea files. I thought I had avoided that. No real harm done, I think.)

Move the RoomView to its own file. F6 I think. Done, commit.

Now let’s create the dungeon in main and pass it to DungeonView. Here’s main:

if __name__ == '__main__':
    dungeon = DungeonView()
    dungeon.main_loop()

We’ll rename the view and add in the creation code, removing it from DungeonView:

That doesn’t go smoothly. I have a circular import. Need to run an errand, we’ll fix upon my return.

Errand complete, I find quite a few little glitches. My main fix is to prepare a params.py file and import from it judiciously.

# dungeon parameters
screen_width = 1024
screen_height = 1024 - 128
cell_size = 16

Making it all work we have this:

if __name__ == '__main__':
    bank = CellBank(64, 56)
    # random.seed(234)
    dungeon = Dungeon()
    start = (0,0)
    for _ in range(35):
        room = Room()
        start = room.build(bank, 100, start)
        dungeon.add_room(room)
    view = DungeonView(dungeon)
    view.main_loop()


class DungeonView:
    def __init__(self, dungeon):
        self.dungeon = dungeon
        pygame.init()
        pygame.display.set_caption("Dungeon")
        self.screen = pygame.display.set_mode(
            (screen_width, screen_height))
        self.clock = pygame.time.Clock()

    def main_loop(self):
        running = True
        moving = False
        background = "gray33"
        color = "darkblue"
        while running:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
            self.screen.fill(background)
            self.draw_grid(color)
            self.draw_rooms()
            self.clock.tick(60)
            pygame.display.flip()
        pygame.quit()

    def draw_rooms(self):
        for room in self.dungeon:
            self.draw_room(room)

    def draw_room(self, room):
        view = RoomView(room)
        view.draw(self.screen)

    def draw_grid(self, color: str):
        for x in range(screen_width // cell_size):
            dx = (x + 1) * cell_size
            for y in range(screen_height // cell_size):
                dy = (y + 1) * cell_size
                pygame.draw.line(
                    self.screen, color,
                    (dx, 0),
                    (dx, screen_height))
                pygame.draw.line(
                    self.screen, color,
                    (0, dy),
                    (screen_width, dy)
                )

class RoomView:
    def __init__(self, room):
        self.room = room

    def draw(self, screen):
        for cell in self.room.cells:
            dx = cell[0] * cell_size
            dy = cell[1] * cell_size
            pygame.draw.rect(screen, self.room.color,
                             (dx, dy, cell_size, cell_size))

And of course our model classes:

class Dungeon:
    def __init__(self):
        self.rooms = []

    def __iter__(self):
        return iter(self.rooms)

    @property
    def number_of_rooms(self):
        return len(self.rooms)

    def add_room(self, room):
        self.rooms.append(room)

class Room:
    def __init__(self):
        self.cells:list[tuple[int, int]] = []
        r = random.randint(0, 255)
        g = random.randint(0, 255)
        b = random.randint(0, 255)
        self.color = pygame.Color(r, g, b)

    def add_cell(self, x, y):
        self.cells.append((x,y))

    def build(self, bank, length, start_cell):
        new_cell = start_cell
        for _ in range(length):
            bank.take(*new_cell)
            self.add_cell(*new_cell)
            new_cell = self.find_adjacent_cell(bank)
        return new_cell

    def find_adjacent_cell(self, bank):
        for cell in sample(self.cells, len(self.cells)):
            neighbors = self.neighbors(cell)
            for x, y in sample(neighbors, len(neighbors)):
                if bank.has_cell(x, y):
                    return((x,y))
        return bank.available_cell()

    @staticmethod
    def neighbors(pair):
        x, y = pair
        return [(x+dx,y+dy) for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]]

    # used only in old tests

    def grow(self, bank):
        sx,sy = self.cells[0]
        for nx, ny in [(-1,0), (0,1), (1,0), (0,-1)]:
            check = (sx + nx, sy + ny)
            if bank.has_cell(*check):
                bank.take(*check)
                self.add_cell(*check)

    def grow_randomly(self, bank):
        for cell in sample(self.cells, len(self.cells)):
            neighbors = self.neighbors(cell)
            for x, y in sample(neighbors, len(neighbors)):
                if bank.has_cell(x, y):
                    bank.take(x, y)
                    self.add_cell(x, y)
                    return
        return

    def cell_string(self):
        return ", ".join([str(c) for c in sorted(self.cells)])

I’m tired and ready to drink my iced chai and read some trash. We’ll clean up the excess next time. For now …

Summary

Nine commits in about 90 minutes of work. A decent figure for me, given time for coding testing and writing.

I would say that the refactoring to RoomView went fairly well but not ideally. Sometimes I had to change things in more than one place at a time, and I now suspect I could have taken two or more one-place changes. I prefer that when I can see it, but sometimes I don’t see it, and sometimes I just go ahead and do what needs to be done. The ideas I use as guidance are ideas, not some kind of hard and fast rules.

Overall, the smaller my steps, the better things go.

I don’t know quite when they started failing, because I hadn’t run the tests for a while, so I have changed my process slightly:

I added a test to call the main during testing. So now to see the display, I can run the tests. That will tell me whether I’ve broken anything, and will also give me the nice display.

In terms of what I’ve set out to do, I’m pleased with the results, and I have ideas.

The entire grid is packed with rooms (or would be if I ask for enough to fill in all the tiny holes). If this were a real cavern or dungeon, I think there wouldn’t be all those rooms right up against each other, there would be a room of odd shape, then some kind of channel or path to another room, and so on. I can imagine a few different ways we might get something more like that. I’ll think about it. Options include:

  • Allocate a smaller number of rooms, at random locations instead of abutting as they do now;
  • Allocate some rooms as “blank”, making inaccessible empty areas;
  • After a room is finished growing, let it grow further, eating cells from the bank but not allocating them into the room. They’ll stay dark.

Other thoughts for future work:

  • We need to figure out how the rooms connect: we have no doors nor windows yet.
  • Instead of huge tracts of horrid colors, maybe we could just draw the borders of the rooms somehow. If a cell in the room has fewer than 4 neighbors in the room, the sides without neighbors are a boundary between in the room and not.
  • We need some long narrow passages, which make for an interesting dungeon and good places for fights. Relates to smaller number of rooms, of course.

I just tried an experiment: I set half the rooms to color black and then in the view, because I didn’t like the black, I didn’t draw the black ones at all. And we get this layout:

map with holes looks better

Interesting. I think we can find something like this to make things look more like a cavern or dungeon.

I’m having fun. And got a small process improvement as well as a little jolt of pleasure from seeing the picture emerging. That’s the point. See you next time!