Game Dev Lab — Day 3

Classes: Clean Architecture

# INSTRUCTOR — Game Dev Lab Day 3: Classes — Clean Architecture
# -------------------------------------------------------
# TEACH: The game from Day 2 works, but everything is in one big pile of globals.
# Today we refactor using classes. Same game — cleaner code.
#
# CORE IDEA — a class bundles data + behavior:
#   class Enemy:
#       def __init__(self, x, y):   — set up the object
#       def update(self):           — move it
#       def draw(self, screen):     — draw it
#       rect                        — its Rect for collision
#
# TEACH: the pattern update() + draw() used here is the standard game-object
# pattern. Every object knows how to move and draw itself. The main loop just
# calls obj.update() and obj.draw() for every object — the loop doesn't care
# what type of object it is.
#
# MILESTONE (show me when done):
#   Game plays the same as Day 2, but code uses Player, Enemy, Bullet classes.
#   Game has a title screen (press Enter to start) and a game-over screen (press R).
#
# EXTENSION challenges:
#   - Add an Explosion class: spawns colored circles that fade out on enemy death
#   - Add a Level system: every 10 kills → new level, enemies faster
#   - Add sound: pygame.mixer.Sound("hit.wav").play() on collision
#     (pygame can generate a simple beep: see pygame.sndarray or use a tiny .wav)

import pygame
import sys
import random

pygame.init()

WIDTH, HEIGHT = 600, 700
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Space Shooter — Day 3")
clock = pygame.time.Clock()
font  = pygame.font.SysFont("courier", 22, bold=True)
big   = pygame.font.SysFont("courier", 36, bold=True)

BLACK  = (0,   0,   0)
WHITE  = (255, 255, 255)
CYAN   = (0,   220, 220)
YELLOW = (255, 255, 0)
RED    = (220, 50,  50)
GRAY   = (120, 120, 120)


# ── Classes ──────────────────────────────────────────────────────────────────

class Player:
    SPEED = 5

    def __init__(self):
        self.rect  = pygame.Rect(WIDTH // 2 - 20, HEIGHT - 70, 40, 30)
        self.lives = 3
        self.score = 0

    def update(self):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]  and self.rect.left  > 0:
            self.rect.x -= self.SPEED
        if keys[pygame.K_RIGHT] and self.rect.right < WIDTH:
            self.rect.x += self.SPEED

    def draw(self, surface):
        cx = self.rect.centerx
        top   = (cx,              self.rect.top)
        left  = (self.rect.left,  self.rect.bottom)
        right = (self.rect.right, self.rect.bottom)
        pygame.draw.polygon(surface, CYAN, [top, left, right])

    def fire(self):
        return Bullet(self.rect.centerx, self.rect.top)


class Bullet:
    SPEED = 9

    def __init__(self, x, y):
        self.rect = pygame.Rect(x - 4, y - 18, 8, 18)

    def update(self):
        self.rect.y -= self.SPEED

    def draw(self, surface):
        pygame.draw.ellipse(surface, YELLOW, self.rect)

    @property
    def off_screen(self):
        return self.rect.bottom < 0


class Enemy:
    SIZE  = 40

    def __init__(self, speed=2):
        x = random.randint(20, WIDTH - self.SIZE - 20)
        self.rect  = pygame.Rect(x, -self.SIZE, self.SIZE, self.SIZE)
        self.speed = speed

    def update(self):
        self.rect.y += self.speed

    def draw(self, surface):
        pygame.draw.rect(surface, RED, self.rect, border_radius=6)

    @property
    def off_screen(self):
        return self.rect.top > HEIGHT


# ── Game states ──────────────────────────────────────────────────────────────

STATE_TITLE    = 'title'
STATE_PLAYING  = 'playing'
STATE_GAMEOVER = 'gameover'


# ── Helpers ───────────────────────────────────────────────────────────────────

def draw_centered(surface, text, fnt, color, y):
    rendered = fnt.render(text, True, color)
    rect = rendered.get_rect(center=(WIDTH // 2, y))
    surface.blit(rendered, rect)


def new_game():
    player  = Player()
    bullets = []
    enemies = [Enemy() for _ in range(5)]
    return player, bullets, enemies


# ── Main ─────────────────────────────────────────────────────────────────────

player, bullets, enemies = new_game()
state = STATE_TITLE

SPAWN_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(SPAWN_EVENT, 1400)

while True:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit(); sys.exit()

        if event.type == pygame.KEYDOWN:
            if state == STATE_TITLE and event.key == pygame.K_RETURN:
                player, bullets, enemies = new_game()
                state = STATE_PLAYING

            elif state == STATE_PLAYING and event.key == pygame.K_SPACE:
                bullets.append(player.fire())

            elif state == STATE_GAMEOVER and event.key == pygame.K_r:
                player, bullets, enemies = new_game()
                state = STATE_PLAYING

        if event.type == SPAWN_EVENT and state == STATE_PLAYING:
            enemies.append(Enemy(speed=2 + player.score // 10))

    # ── Update ────────────────────────────────────────────────────────────────
    if state == STATE_PLAYING:
        player.update()

        for b in bullets[:]:
            b.update()
            if b.off_screen:
                bullets.remove(b)

        for e in enemies[:]:
            e.update()
            if e.off_screen:
                enemies.remove(e)
                player.lives -= 1
                if player.lives <= 0:
                    state = STATE_GAMEOVER

        # Collision
        for b in bullets[:]:
            for e in enemies[:]:
                if b.rect.colliderect(e.rect):
                    bullets.remove(b)
                    enemies.remove(e)
                    player.score += 1
                    break

    # ── Draw ──────────────────────────────────────────────────────────────────
    screen.fill(BLACK)

    if state == STATE_TITLE:
        draw_centered(screen, "SPACE SHOOTER", big,  CYAN,  HEIGHT // 2 - 60)
        draw_centered(screen, "Press ENTER to start", font, GRAY, HEIGHT // 2)
        draw_centered(screen, "Arrow keys: move   Space: fire", font, WHITE, HEIGHT // 2 + 50)

    elif state in (STATE_PLAYING, STATE_GAMEOVER):
        player.draw(screen)
        for b in bullets:
            b.draw(screen)
        for e in enemies:
            e.draw(screen)

        hud = font.render(f"Score: {player.score}   Lives: {player.lives}", True, WHITE)
        screen.blit(hud, (10, 10))

        if state == STATE_GAMEOVER:
            draw_centered(screen, "GAME OVER", big,  RED,   HEIGHT // 2 - 40)
            draw_centered(screen, f"Final score: {player.score}", font, WHITE, HEIGHT // 2 + 10)
            draw_centered(screen, "Press R to restart", font, GRAY, HEIGHT // 2 + 55)

    pygame.display.flip()
    clock.tick(60)