Table of Contents

Homework 03: Drone Collecting Samples

In this assignment, your task is to complete a simple 2D game using the Pygame module.

In the game, a drone moves through space and collects samples of rare minerals.

The player controls the drone using the keyboard:

The drone is represented by a grey rectangle and changes its color to white while collecting samples.

Sample collection consumes a large amount of energy and therefore cannot be used continuously. It automatically stops after a fixed period of time. After that, the system must recharge, which also takes a fixed amount of time before collection can be activated again.

Samples are represented by blue circles. They are unstable and randomly appear and disappear on the screen.

There are also asteroids moving through space (red circular objects). The player must avoid collisions with them. Any collision immediately ends the game.

The goal of the game is to collect as many samples as possible.

Example of the game can be seen on following picture:

The Task

Your task is to complete the code provided in the Starter Code section. Look for the keyword “TODO” in the code and follow the instructions. You can also use the GIF animation above as a reference. Your finished game should behave in the same way.

Do not use hardcoded values in your code to set dimensions, time delay etc. Use provided constants such as DRONE_SIZE, COLLECTING_TIME etc. instead. You can find those constants find at the beginning of the Starter Code. Constant names are written in uppercase letters.

Note that the classes Asteroid, Sample, and Drone use the self.position attribute to represent the object’s position (both x and y coordinates), stored as a NumPy array. Using NumPy arrays for positions simplifies operations such as computing distances between objects in 2D space. A similar approach is used for velocity.

It is also recommended to check Final Exam Information where you can find general information about PyGame tasks and also the Asteroids game task which shares many similarities with our homework.

Evaluation

There is no automatic evaluation. You can obtain up to 4 points which will be assigned manually based on proper functionality. To obtain 4 points:

Starter Code

Below is a basic code structure you can use. Copy-paste the following code and finish the game according to docstrings. Look for TODO keyword to find what you are supposed to do. Submit your final version as game.py.

import time
import numpy as np
import pygame
import random
import sys
 
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
FPS = 30
 
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
BLUE = (50, 50, 255)
RED = (255, 0, 0)
 
# ------------------- Drone Settings --------------------
DRONE_SIZE = 30
DRONE_COLOR = (154, 155, 156)
DRONE_COLOR_COLLECTING = WHITE
THRUSTER_FLAME_LENGTH = 36
THRUSTER_FLAME_WIDTH = 16
THRUSTER_FLAME_COLOR = (167, 195, 252)
COLLECTING_TIME = 1000 # ms
COLLECTING_COOLDOWN = 5000
ACCELERATION = 1
BOUNDARY_FORCE = 2
 
# ------------------- Sample Settings --------------------
SAMPLE_RADIUS = 8
SAMPLE_LIFETIME = 10000 # ms
SAMPLE_SPAWN_PERIOD_MAX = 10000
SAMPLE_SPAWN_PERIOD_MIN = 1000
 
# -------------------- Asteroid Settings -----------------
ASTEROID_VELOCITY_MAX = 5
ASTEROID_VELOCITY_MIN = 1
ASTEROID_SPAWN_PERIOD = 10000 # ms
ASTEROID_RADIUS_MAX = 120
ASTEROID_RADIUS_MIN = 10
 
# initialization
pygame.init()
clock = pygame.time.Clock()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
 
class Asteroid:
    """A moving circular obstacle that can collide with the drone."""
 
    def __init__(self, position, velocity, radius):
        """Create an asteroid object with a position, velocity, and radius."""
        self.position = position
        self.velocity = velocity
        self.radius = radius
 
    def move(self):
        """Move the asteroid according to its velocity."""
        self.position = self.position + self.velocity
 
    def draw(self):
        """Draw the asteroid on the screen."""
        pygame.draw.circle(screen, RED, self.position.astype(int), self.radius)
 
    @property
    def is_visible(self):
        """TODO: Return True if the asteroid is still visible on the screen, False otherwise.
        An asteroid may be partially visible (its center can be outside the screen).
        In such cases, this method should still return True."""
 
 
 
class Drone:
    """Player-controlled mining drone that moves using directional thrusters."""
    def __init__(self):
        """Create the drone at the center of the screen."""
        self.position = np.array([SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2])
        self.velocity = np.array([0, 0])
        self.collecting = False
        self.collecting_end = pygame.time.get_ticks()
        self.collecting_next = pygame.time.get_ticks()
 
    def draw(self, keys_pressed):
        """TODO: Draw the drone and active thruster flames.
        Notes:
            1. Drone changes its color when collecting samples (use constants DRONE_COLOR, DRONE_COLOR_COLLECTING)
            2. Pressing W or UP arrow key activates UPPER thruster
            3. Pressing S or DOWN arrow key activates LOWER thruster
            4. Pressing A or LEFT arrow key activates LEFT thruster
            5. Pressing D or RIGHT arrow key activates RIGHT thruster
            6. Drone's position is defined by geometric center of rectangle shape representing the drone
        """
        rectangle = pygame.Rect(self.position - np.array([DRONE_SIZE, DRONE_SIZE])//2, (DRONE_SIZE, DRONE_SIZE))
        color = DRONE_COLOR # TODO: change the color if the drone if currently collecting samples
        pygame.draw.rect(screen, color, rectangle)
 
        # TODO: add drawing "flames" from active thrusters
        # use THRUSTER_FLAME_LENGTH, THRUSTER_FLAME_WIDTH, THRUSTER_FLAME_COLOR constants
 
    @property
    def is_collecting(self):
        """Return True if the drone is currently collecting samples."""
        return self.collecting
 
    def start_collecting(self):
        """TODO: Start collecting samples.
        Notes:
            1. Collecting will only start if time longer than COLLECTING_COOLDOWN passed since the end of previous collecting.
            2. When collecting starts, update the state so that:
              * the drone enters collecting mode
              * the time when collecting should stop is set using COLLECTING_TIME
            3. Also update the time when collecting can be started again, taking both collecting duration and cooldown into account.
        """
 
    def update_collecting(self):
        """TODO: Stop collecting when the collecting time has expired."""
 
    def move(self, keys_pressed):
        """TODO: Update drone velocity and position according to active thrusters and drone's position.
        Notes:
            1. The drone is "pushed" in direction which is opposite than the position of activated thruster.
                For example: if thruster on the left side of the drone is active, the drone is "pushed" to the right.
                "Pushing" means changing drone's velocity in particular direction by ACCELERATION.
                In case of active left thruster drone should increase its velocity in direction of X axis.
            2. Update drone's position based on velocity in X and Y directions.
            3. If the drone leaves the screen area (defined by SCREEN_WIDTH and SCREEN_HEIGHT),
                there is additional acceleration that has and effect on the drone.
                This acceleration has value BOUNDARY_FORCE and it always pushes the drone back into screen area.
            4. Drone is considered outside of the screen if its position (geometric center of the rectangle)
                is outside of the screen.
        """
 
 
class Sample:
    """Collectible mineral sample that disappears after a limited time."""
    def __init__(self, position, end_time):
        """Create a sample at the given position with an expiration time."""
        self.position = position
        self.end_time = end_time
 
    def draw(self):
        """Draw the circle representing the sample"""
        pygame.draw.circle(screen, BLUE, self.position, SAMPLE_RADIUS)
 
    @property
    def is_alive(self):
        """Return True if the sample has not expired yet, False otherwise."""
        return pygame.time.get_ticks() < self.end_time
 
class Game:
    """Main game controller that manages objects, updates, drawing, and collisions."""
    def __init__(self):
        """Create a new game state."""
        self.drone = Drone()
        self.samples = []
        self.asteroids = []
        self.running = True
        now = pygame.time.get_ticks()
        self.next_sample_spawn = now + self._sample_spawn_period()
        self.next_asteroid_spawn = now
        self.collected = 0
 
 
    def _sample_spawn_period(self):
        """helper method that returns random delay before the next sample spawn."""
        return random.randrange(SAMPLE_SPAWN_PERIOD_MIN, SAMPLE_SPAWN_PERIOD_MAX)
 
    def spawn_asteroid(self):
        """TODO: Spawn an asteroid at a random screen edge moving toward the drone.
        Notes:
            1. Asteroid should spawn at a random position and at a randomly chosen screen edge
            2. When the asteroid spawns it starts to move towards current drone position and its velocity is constant
                (does not change during asteroid's lifetime)
            3. Length (norm) of asteroid's velocity vector is random value between ASTEROID_VELOCITY_MIN and
                ASTEROID_VELOCITY_MAX (both values included)
            4. Asteroid is represented by circle object with random radius between ASTEROID_RADIUS_MIN and
                ASTEROID_RADIUS_MAX (both values included)
            5. Use class Asteroid to create new asteroid object
            6. Newly created asteroid object should be appended to self.asteroids list
        """
 
    def spawn_sample(self):
        """TODO: Spawn a sample at a random screen position.
        Notes:
            1. Use Sample class to create new sample object
            2. Append new sample object to self.samples list
            3. Update time of next sample spawn accordingly (use _sample_spawn_period helper method)
        """
 
 
    def collect_samples(self):
        """TODO: Collect samples that are fully inside the drone rectangle shape.
        Notes:
            1. print "Sample collected." and update self.collected counter in case of successful sample collection
            2. For successful sample collection the sample represented by circular shape has to be fully
                inside the drone rectangle shape (otherwise it is not collected)
            3. Successfully collected samples are removed from self.samples list
        """
 
    def move(self, keys_pressed):
        """Move the drone and all asteroids."""
        self.drone.move(keys_pressed)
        for asteroid in self.asteroids:
            asteroid.move()
 
    def check_collision(self):
        """TODO: Return True if any asteroid collides with the drone, False otherwise.
        Notes:
            1. Asteroid collides with the drone if there is any overlap between drone's rectangular shape
                and asteroid's circular shape
            2. HINT: For each asteroid, find the point on the drone rectangle closest to the asteroid center.
                Check the distance between this closest point and the asteroid center.
        """
 
 
    def draw(self, keys_pressed):
        """Draw all game objects."""
        for asteroid in self.asteroids:
            asteroid.draw()
        self.drone.draw(keys_pressed)
        for sample in self.samples:
            sample.draw()
 
    def update(self, keys_pressed):
        """Update spawning, collecting, and remove expired or invisible objects."""
        now = pygame.time.get_ticks()
        if now > self.next_sample_spawn:
            self.spawn_sample()
 
        if now > self.next_asteroid_spawn:
            self.spawn_asteroid()
            self.next_asteroid_spawn = now + ASTEROID_SPAWN_PERIOD
 
        self.drone.update_collecting()
        if keys_pressed[pygame.K_SPACE]:
            self.drone.start_collecting()
 
        if self.drone.is_collecting:
            self.collect_samples()
 
        self.samples = [sample for sample in self.samples if sample.is_alive]
        self.asteroids = [asteroid for asteroid in self.asteroids if asteroid.is_visible]
 
 
def main():
    """Main game loop"""
    game = Game()
 
    while game.running:
        clock.tick(FPS)
 
        # Event handling
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                game.running = False
 
        keys_pressed = pygame.key.get_pressed()
        if keys_pressed[pygame.K_q]:  # Q key
            game.running = False
 
        game.move(keys_pressed)
        game.update(keys_pressed)
 
        screen.fill(BLACK)
        game.draw(keys_pressed)
        pygame.display.flip()
 
        if game.check_collision():
            print("Collision detected!")
            game.running = False
            time.sleep(1)
 
    pygame.quit()
    print(f"Game Over. Collected: {game.collected}")
    sys.exit()
 
 
if __name__ == "__main__":
    main()