PY-1.3-BP3-MQ60

Objective

Building the Great Wall

Let's begin by reviewing our objective.

Our goal is to use nested for loops to create a grid of bricks (e.g., 5 rows of 10 bricks). We will calculate the x and y position for each brick based on the loop variables. All brick instances will be stored in a single list, and then we will iterate through that list to draw the entire wall.

Check Spec

Next, we'll run pytest to see the failing tests. This confirms the engineering specification we need to meet.

Test Results:

  • test_wall_grid_creation_and_structure
  • test_wall_drawing_logic

Implement: main.py

Now, let's build the solution by following the TODO comments in main.py.

Step by step checklist:

  1. Use nested loops to iterate through the specified number of wall rows and columns.
  2. Inside the inner loop, calculate the x and y position for the current brick based on the loop indices and brick dimensions.
  3. Create a new Brick instance using the calculated position, width, height, and color.
  4. Append the newly created Brick instance to the all_bricks list.
  5. After the nested loops, iterate through the all_bricks list.
  6. Inside this loop, call the draw() method on each brick instance, passing the screen surface.

The following documentation sections are going to be helpful:

  • Building the Great Wall
  • Grid Positioning with Nested Loops
  • Storing Objects in a List
  • Instantiating Objects in a Loop
  • Managing Multiple Instances
  • Custom Surfaces and Blitting
  • The Brick Class

Validate

With the code in place, let's run the tests again to validate our work.

Test Results:

  • test_wall_grid_creation_and_structure
  • test_wall_drawing_logic All tests passed!

Debrief

Great job! You've successfully used nested loops to build and draw a grid of objects, managing them efficiently using a list.

Now, prepare for the final step: the code review with your mentor.

Documentation

Building the Wall: Managing Multiple Bricks

This lesson blueprint focuses on using Python classes and lists to manage multiple objects in a Pygame application, culminating in building a wall of bricks.

The Brick Class

The foundation of this lesson is the Brick class. This class serves as a blueprint for creating individual brick objects, each with its own properties like position, size, and color.

Defining a class involves using the class keyword followed by the class name. The __init__ method is a special method that runs when you create a new instance of the class. It's used to set up the initial state (attributes) of the object.

# Example: Basic class definition
class GameObject:
    def __init__(self, x, y, color):
        self.x = x
        self.y = y
        self.color = color

    def describe(self):
        print(f"Object at ({self.x}, {self.y}) with color {self.color}")

The Brick class will need attributes to store its position (x, y), dimensions (width, height), and color. It will also need a way to draw itself onto the screen.

Drawing a Single Brick

The first step is to define the Brick class and then create a single instance of it. Drawing this instance involves using Pygame's drawing functions, typically pygame.draw.rect, which requires a pygame.Rect object to define the position and size.

The pygame.Rect object can be created using the x, y, width, and height attributes from the Brick instance.

# Example: Creating a Rect and drawing a rectangle
import pygame

# Assume 'screen' is your Pygame surface
# Assume 'object_color' is an RGB tuple like (255, 0, 0)
# Assume 'object_x', 'object_y', 'object_width', 'object_height' are integers

object_rect = pygame.Rect(object_x, object_y, object_width, object_height)
pygame.draw.rect(screen, object_color, object_rect)

The objective is to create one Brick instance and draw it using its attributes.

[Image/Gif: A single colored rectangle on a black background]

Managing Multiple Instances

Instead of just one brick, a game needs many. This involves creating multiple separate instances of the Brick class. Each instance will have its own unique set of attribute values (e.g., different x positions).

To make drawing easier when dealing with multiple objects, it is common practice to add a draw method directly to the class. This method takes the screen surface as an argument and uses the instance's own attributes (self.color, self.rect) to draw itself.

# Example: Adding a draw method to a class
import pygame

class DrawableObject:
    def __init__(self, x, y, width, height, color):
        self.rect = pygame.Rect(x, y, width, height)
        self.color = color

    def draw(self, surface):
        pygame.draw.rect(surface, self.color, self.rect)

# Later, in your main drawing code:
# object1 = DrawableObject(...)
# object2 = DrawableObject(...)
# object1.draw(screen)
# object2.draw(screen)

The objective is to create two Brick instances with different horizontal positions and call their draw methods.

[Image/Gif: Two colored rectangles side-by-side]

Storing Objects in a List

When you have many objects of the same type, storing them in individual variables becomes impractical. A more efficient approach is to use a list to hold all the object instances.

Once the objects are in a list, you can use a for loop to iterate through the list. Inside the loop, you can access each object instance and call its methods, such as the draw method.

# Example: Creating objects and storing them in a list
object_list = []
# Create object instances...
obj_a = GameObject(10, 10, (255, 0, 0))
obj_b = GameObject(50, 10, (0, 255, 0))
# Add them to the list
object_list.append(obj_a)
object_list.append(obj_b)

# Example: Iterating through a list of objects and calling a method
# Assume 'screen' is your Pygame surface
for item in object_list:
    item.draw(screen) # Assuming the object has a draw method

The objective is to create an empty list, create three Brick instances, append them to the list, and then use a for loop to draw all bricks in the list.

[Image/Gif: Three colored rectangles side-by-side (visually similar to two, but conceptually different)]

Instantiating Objects in a Loop

Manually creating and appending each object instance is tedious for large numbers. A more automated approach is to use a loop to create the instances. A for loop with range() is suitable for creating a fixed number of objects.

Inside the loop, you create a new instance of the class and immediately append it to the list. After the creation loop finishes, you can use a separate loop to iterate through the list and draw them, as done previously.

# Example: Creating multiple objects using a loop
object_list = []
number_of_objects = 5

for i in range(number_of_objects):
    # Create a new object instance inside the loop
    new_obj = GameObject(0, 0, (255, 255, 255)) # Using default or simple values for now
    object_list.append(new_obj)

# Now object_list contains 5 instances

The objective is to use a for loop with range(5) to create five Brick instances and add them to a list. Then, use a separate loop to draw them. Note that without changing their positions, they will all be drawn at the same location.

[Image/Gif: A single colored rectangle (as the five bricks are drawn on top of each other)]

Calculating Positions Dynamically

To arrange objects in a pattern, like a row, you need to calculate their positions dynamically within the creation loop. For a horizontal row, the y position remains constant, while the x position changes based on the object's index in the row, its width, and any desired gap between objects.

The formula for the x position of the i-th object in a row, starting at start_x with width and gap, is typically: start_x + i * (width + gap).

# Example: Calculating position in a loop
start_x = 50
start_y = 100
item_width = 80
item_gap = 10
num_items = 8
item_list = []

for i in range(num_items):
    current_x = start_x + i * (item_width + item_gap)
    current_y = start_y # Y is constant for a row

    # Create object with calculated position
    # new_item = MyObject(current_x, current_y, ...)
    # item_list.append(new_item)

The objective is to use a single for loop to create 8 Brick instances, calculating each brick's x position based on its index, width, and a gap. Store them in a list and then draw the complete row.

[Image/Gif: A horizontal row of colored rectangles with gaps]

Custom Surfaces and Blitting

In Pygame, everything drawn is ultimately placed onto a pygame.Surface. The main game window is one large Surface. You can also create smaller, independent Surface objects. This is useful for creating complex sprites or drawing elements off-screen before placing them.

The pygame.Surface object represents a rectangular area of pixels. You can draw shapes or fill colors onto this surface. To display a Surface onto another Surface (like the main screen), you use the blit() method. blit() copies pixels from one surface (source) onto another (destination) at a specified position.

# Example: Creating a surface, filling it, and blitting
import pygame

# Assume 'screen' is your main Pygame surface
# Assume 'custom_size' is a tuple like (100, 50)
# Assume 'fill_color' is an RGB tuple
# Assume 'blit_position' is a tuple like (200, 150)

# Create a new surface
custom_surface = pygame.Surface(custom_size)

# Fill the new surface with a color
custom_surface.fill(fill_color)

# Blit (copy) the custom surface onto the main screen
screen.blit(custom_surface, blit_position)

# Print the position
print(f"Blitted at {blit_position}")

The objective is to create a small pygame.Surface, fill it with a color, blit it onto the main screen at a chosen position, and print the blit coordinates.

[Image/Gif: A single colored rectangle drawn using blit]

Grid Positioning with Nested Loops

Building a wall requires arranging bricks in both rows and columns, forming a grid. Nested loops are the standard way to iterate through a 2D structure like a grid. The outer loop typically handles the rows, and the inner loop handles the columns within each row.

Inside the inner loop, you have access to both the current row_index and col_index. You can use these indices, along with the object's dimensions and the grid's starting position, to calculate the unique (x, y) coordinate for each object in the grid.

The formula for the top-left (x, y) position of an object at (row_index, col_index) in a grid starting at (start_x, start_y) with object width and height is: x = start_x + col_index * object_width y = start_y + row_index * object_height (Note: This assumes no gap between objects. Gaps would be added similarly to the row calculation).

# Example: Calculating grid positions with nested loops
start_x = 10
start_y = 20
item_width = 50
item_height = 30
num_rows = 3
num_cols = 4

for row_index in range(num_rows):
    for col_index in range(num_cols):
        current_x = start_x + col_index * item_width
        current_y = start_y + row_index * item_height
        print(f"Item at row {row_index}, col {col_index}: ({current_x}, {current_y})")

The objective is to use nested loops to calculate and print the (x, y) coordinates for a 5x3 grid of objects, given object dimensions and a starting position.

[Image/Gif: A conceptual grid with coordinates labeled]

Building the Great Wall

Combining the concepts of classes, lists, nested loops, dynamic positioning, and drawing (using blit within the Brick class's draw method), you can build a complete wall of bricks.

The process involves:

  1. Using nested loops to iterate through the desired number of rows and columns for the wall.
  2. Inside the inner loop, calculating the x and y position for the current brick based on the loop indices and brick dimensions.
  3. Creating a Brick instance using the calculated position and other brick properties.
  4. Appending the newly created Brick instance to a list that stores all the bricks.
  5. After the nested loops finish, iterating through the list of all bricks and calling the draw() method on each one to render the entire wall.
# Example: Structure for building a grid of objects
object_list = []
num_rows = 5
num_cols = 10
item_width = 80
item_height = 30

for row_index in range(num_rows):
    for col_index in range(num_cols):
        # Calculate x and y based on indices, width, height
        pos_x = col_index * item_width
        pos_y = row_index * item_height

        # Create object instance
        # new_object = MyObject(pos_x, pos_y, item_width, item_height, ...)

        # Add to list
        # object_list.append(new_object)

# After loops, draw all objects
# for obj in object_list:
#     obj.draw(screen)

The objective is to use nested for loops to create a grid of bricks (e.g., 5 rows of 10 bricks), calculate their x and y positions dynamically, store them in a list, and then draw the entire wall by iterating through the list.

[Image/Gif: A full wall of colored rectangles arranged in a grid]

Safely Removing Elements from a List

Modifying a list while you are iterating over it using a standard for item in list: loop can lead to unexpected behavior, such as skipping elements. This happens because removing an element shifts the indices of the elements that come after it.

To safely remove elements from a list during iteration, you can:

  1. Iterate backwards through the list using indices (for i in range(len(list) - 1, -1, -1):). When you remove an element at index i, it only affects elements at indices less than i, which you have already processed.
  2. Create a new list containing only the elements you want to keep. Iterate through the original list, check each element, and if it meets the criteria for keeping, append it to the new list. Then, replace the original list with the new one (or return the new list).
# Example: Unsafe removal (DO NOT DO THIS)
# my_list = [1, 2, 3, 4]
# for item in my_list:
#     if item % 2 == 0:
#         my_list.remove(item) # This can skip elements!

# Example: Safe removal by iterating backwards
my_list = [1, 2, 3, 4, 5, 6]
print(f"Original: {my_list}")
for i in range(len(my_list) - 1, -1, -1):
    if my_list[i] % 2 == 0:
        my_list.pop(i) # Remove by index
print(f"Modified: {my_list}")

# Example: Safe removal by creating a new list
original_list = [1, 2, 3, 4, 5, 6]
new_list = []
for item in original_list:
    if item % 2 != 0: # Keep if odd
        new_list.append(item)
print(f"Original: {original_list}")
print(f"Modified: {new_list}")

The objective is to write a Python script that creates a list of numbers, iterates through it, and removes all even numbers safely (either by iterating backwards or creating a new list). Print the original and modified lists.

[Image/Gif: A list visually changing as elements are removed]