I’ve added in Dot and it’s clear we have work to do. Result: 1000X faster.

I was just messing about while in an online chat and put in Dot:

    def on_draw(self):
        self.clear()
        self.draw_rooms()
        self.draw_contents()

    def on_key_press(self, symbol: int, modifiers: int) -> bool | None:
        if self.key_lock is not None:
            return
        self.key_lock = symbol
        if symbol == arcade.key.RIGHT:
            self.dungeon.move_adventurer_east()
        elif symbol == arcade.key.LEFT:
            self.dungeon.move_adventurer_west()
        elif symbol == arcade.key.UP:
            self.dungeon.move_adventurer_north()
        elif symbol == arcade.key.DOWN:
            self.dungeon.move_adventurer_south()
        else:
            self.key_lock = None

    def on_key_release(self, symbol: int, modifiers: int) -> bool | None:
        if symbol == self.key_lock:
            self.key_lock = None

    def draw_contents(self):
        adventurer_cell = self.dungeon.player_cell
        cx = adventurer_cell.x*cell_size + cell_size//2
        cy = adventurer_cell.y*cell_size + cell_size//2
        arcade.draw_circle_filled(cx, cy, cell_size//2, arcade.color.RED)
        # pygame.draw.circle(self.screen, "red", [cx, cy], cell_size//2)
        x,y = adventurer_cell.x, adventurer_cell.y
        txt = f'({x},{y})'
        # font = pygame.font.SysFont("monospace", 20)
        # surf = font.render(txt, True, 'white')
        # self.screen.blit(surf, (cx, cy - cell_size))

As you can see, I just commented out the pygame stuff and put in a circle in arcade style. Since arcade has the origin where God intended it to be, in the lower left, I had to change move_adventurer_north and ...south, inverting the sign of the movement.

It was no surprise when Dot showed up just where she belonged, and she moves as intended. However, she moves V_E_R_Y__ S_L_O_W_L_Y. I assume that is because we are computing and drawing our rectangles and lines on every draw cycle. Let’s put in a timer such as they had in the samples.

    def on_draw(self):
        draw_start_time = timeit.default_timer()
        self.clear()
        self.draw_rooms()
        self.draw_contents()
        draw_time = timeit.default_timer() - draw_start_time
        output = f"Drawing time: {draw_time:.3f} seconds per frame."
        arcade.draw_text(output, 20, screen_height - 40, arcade.color.WHITE, 18)

This reports about 1/8 second per frame. Not 1/60 which is what we’re supposed to get.

Our mission will be to save all the rectangles and lines in a sprite list or whatever it’s called. It’s a shape list. And it goes like this:

main.py
    ...
    view = DungeonView(dungeon)
    view.setup() # <=== added
    window.show_view(view)
    arcade.run()

class DungeonView:
    def setup(self):
        self.shape_list = arcade.shape_list.ShapeElementList()
        self.create_rooms(self.shape_list)

    def on_draw(self):
        draw_start_time = timeit.default_timer()
        self.clear()
        # self.draw_rooms()    # removed
        self.shape_list.draw() # added
        self.draw_contents()
        draw_time = timeit.default_timer() - draw_start_time
        output = f"Drawing time: {draw_time:.6f} seconds per frame."
        arcade.draw_text(output, 20, screen_height - 40, arcade.color.WHITE, 18)

class RoomView:
    def create(self, shape_list):
        cells = set(self.room.cells)
        for cell in cells:
            self.create_colored_cell(cell, shape_list)
            self.create_border(cell, shape_list)

    def create_colored_cell(self, cell, shape_list: ShapeElementList):
        dx = cell.x * cell_size + cell_size //2
        dy = cell.y * cell_size + cell_size //2
        shape = arcade.shape_list.create_rectangle_filled(dx, dy, cell_size, cell_size, arcade.color.GRAY)
        shape_list.append(shape)

    def create_border(self, cell, shape_list):
        borders: BorderList = self.layout.get_borders(cell)
        for border in borders:
            start, finish = border.line_coordinates(cell.xy, cell_size)
            color_name = border.select_boundary(self.boundary_colors)
            if color_name == "black":
                color = arcade.color.BLACK
            elif color_name == "green":
                color = arcade.color.GREEN
            elif color_name == "white":
                color = arcade.color.WHITE
            else:
                color = arcade.color.RED
            shape = arcade.shape_list.create_line(*start, *finish, color)
            shape_list.append(shape)

Everything looks the same, Dot moves freely, and the elapsed time per draw is about 0.0001 or 0.0002 seconds, well inside the 0.01667 we need for 60 frames per second.

I think we’re about done here but let’s move those colors into the color dictionary.

class RoomView:
    boundary_colors = {"open":arcade.color.BLACK, 
                       "partition":arcade.color.GREEN, 
                       "door":arcade.color.WHITE, 
                       "wall":arcade.color.RED}

    def create_border(self, cell, shape_list):
        borders: BorderList = self.layout.get_borders(cell)
        for border in borders:
            start, finish = border.line_coordinates(cell.xy, cell_size)
            color = border.select_boundary(self.boundary_colors)
            shape = arcade.shape_list.create_line(*start, *finish, color)
            shape_list.append(shape)

Works. Commit: convert to shape list.

Remove unneeded draw methods. I temporarily skipped the key tests because they were wired to pygame’s handlers. We’ll update those shortly.

Summary

So in just a little time, we have converted from calculating the dungeon layout on every cycle, at an expense of over 1/8th of a second, to storing the rectangles and lines in a shape list and drawing the list all at once, reducing the time by three orders of magnitude, which is fairly good for an afternoon’s jaunt.

Arcade seems easy to use. I did have to change the rectangle calls to use the rectangle center, as there is no equivalent to LBWH for shape lists, but center orientation is perhaps better anyway. We’ll look at that to see if there is any consistency or simplicity to be gained.

I think we are fully converted from pygame to Arcade. Took a couple of hours including writing the articles and making the pictures.

I am satisfied with the day. See you next time!



Just for completeness, here are the new DungeonView and RoomView files.

class DungeonView(arcade.View):
    def __init__(self, dungeon, testing=False):
        super().__init__()
        self.dungeon = dungeon
        self.key_lock = None
        self.shape_list = None
        if testing:
            return

    def setup(self):
        self.shape_list = arcade.shape_list.ShapeElementList()
        self.create_rooms(self.shape_list)

    def create_rooms(self, shape_list):
        for room in self.dungeon.rooms:
            RoomView(self.dungeon, room).create(shape_list)

    def on_draw(self):
        draw_start_time = timeit.default_timer()
        self.clear()
        # self.draw_rooms()
        self.shape_list.draw()
        self.draw_contents()


    def on_key_press(self, symbol: int, modifiers: int) -> bool | None:
        if self.key_lock is not None:
            return
        self.key_lock = symbol
        if symbol == arcade.key.RIGHT:
            self.dungeon.move_adventurer_east()
        elif symbol == arcade.key.LEFT:
            self.dungeon.move_adventurer_west()
        elif symbol == arcade.key.UP:
            self.dungeon.move_adventurer_north()
        elif symbol == arcade.key.DOWN:
            self.dungeon.move_adventurer_south()
        else:
            self.key_lock = None

    def on_key_release(self, symbol: int, modifiers: int) -> bool | None:
        if symbol == self.key_lock:
            self.key_lock = None

    def draw_contents(self):
        adventurer_cell = self.dungeon.player_cell
        cx = adventurer_cell.x*cell_size + cell_size//2
        cy = adventurer_cell.y*cell_size + cell_size//2
        arcade.draw_circle_filled(cx, cy, cell_size//2, arcade.color.RED)


class RoomView:
    boundary_colors = {"open":arcade.color.BLACK,
                       "partition":arcade.color.GREEN,
                       "door":arcade.color.WHITE,
                       "wall":arcade.color.RED}

    def __init__(self, dungeon, room):
        self.dungeon = dungeon
        self.layout = dungeon.layout
        self.room = room

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

    def create_border(self, cell, shape_list):
        borders: BorderList = self.layout.get_borders(cell)
        for border in borders:
            start, finish = border.line_coordinates(cell.xy, cell_size)
            color = border.select_boundary(self.boundary_colors)
            shape = arcade.shape_list.create_line(*start, *finish, color)
            shape_list.append(shape)

    def create_colored_cell(self, cell, shape_list: ShapeElementList):
        dx = cell.x * cell_size + cell_size //2
        dy = cell.y * cell_size + cell_size //2
        shape = arcade.shape_list.create_rectangle_filled(dx, dy, cell_size, cell_size, arcade.color.GRAY)
        shape_list.append(shape)