Menu
CoddyTech

Bos Memanggil Kelelawar

Pelajaran 17 dari 20 dalam kursus Proyek Pygame: Pertahankan Benteng di Coddy.

Bos yang baik berubah seiring berlangsungnya pertarungan. Saat kesehatannya kurang dari setengah, bos ini meminta bantuan: setiap dua detik, tiga kelelawar muncul di sekitarnya. Pertarungan yang berlangsung dalam beberapa tahap seperti ini disebut fase.

boss = self.boss
if boss and boss.alive() and boss.hp < MONSTERS["boss"]["hp"] // 2:
    if self.time % 120 == 0:
        for _ in range(3):
            offset = (random.randint(-50, 50), random.randint(-50, 50))
            self.monsters.add(Monster("bat", boss.pos + offset))

boss and boss.alive() berarti: ada bos, dan bos itu belum tumbang. and berhenti pada bagian pertama yang bernilai false, jadi boss.alive() tidak pernah dipanggil pada None.

Vector2 ditambah tuple berisi dua angka akan menghasilkan Vector2, jadi boss.pos + offset adalah titik di dekat bos.

challenge icon

Tantangan

Sedang
  1. Di akhir spawn, saat boss masih hidup dengan health kurang dari setengah dari 120, tambahkan tiga kelelawar setiap 120 frame, masing-masing berjarak hingga 50 piksel dari boss pada sumbu x dan y.

Coba sendiri

import random
import pygame

WIDTH, HEIGHT = 720, 480
TILE = 48
NIGHT = 60 * 60 * 4
BOSS_AT = NIGHT - 60 * 60
WAVES = [
    ["slime"],
    ["slime", "bat"],
    ["slime", "bat", "spider"],
    ["bat", "spider"],
    ["slime", "spider", "ghost"],
    ["bat", "spider", "ghost"],
    ["spider", "ghost"],
    ["bat", "ghost"],
]
UPGRADES = {
    "Quick bolts": "Bolts fire more often",
    "Sharp bolts": "Bolts hit harder",
    "Spinning axe": "One more axe circles you",
    "Swift boots": "You walk faster",
    "Gem magnet": "Gems come from further",
    "Hearty meal": "Get back a heart",
}
WHITE = (240, 240, 240)
BOLT = (120, 200, 255)
GOLD = (250, 200, 60)

pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Hold the Keep")
clock = pygame.time.Clock()
font = pygame.font.Font("assets/fonts/pixel.ttf", 24)
small_font = pygame.font.Font("assets/fonts/pixel.ttf", 16)
big_font = pygame.font.Font("assets/fonts/pixel.ttf", 48)


def load(name):
    return pygame.image.load("assets/images/" + name).convert_alpha()


def sound(name):
    return pygame.mixer.Sound("assets/sounds/" + name)


def whiten(image):
    white = image.copy()
    white.fill((255, 255, 255), special_flags=pygame.BLEND_RGB_MAX)
    return white


floor_img = load("floor.png")
wall_img = load("wall.png")
knight_img = load("knight.png")
axe_img = load("axe.png")
gem_img = load("gem.png")
heart_img = load("heart.png")
empty_img = load("heart_empty.png")
MONSTERS = {
    "slime": {"image": load("slime.png"), "speed": 1.0, "hp": 1, "size": 28},
    "bat": {"image": load("dungeon_bat.png"), "speed": 1.6, "hp": 1, "size": 28},
    "spider": {"image": load("spider.png"), "speed": 1.2, "hp": 3, "size": 28},
    "ghost": {"image": load("ghost.png"), "speed": 0.8, "hp": 4, "size": 28},
    "boss": {"image": pygame.transform.scale(load("ghost.png"), (96, 96)), "speed": 0.7, "hp": 120, "size": 64},
}
hit_sound = sound("hit.ogg")
hurt_sound = sound("thud.ogg")
gem_sound = sound("coin.ogg")
level_sound = sound("powerup.ogg")

courtyard = pygame.Rect(TILE, TILE, WIDTH - 2 * TILE, HEIGHT - 2 * TILE)
background = pygame.Surface((WIDTH, HEIGHT))
for row in range(HEIGHT // TILE):
    for col in range(WIDTH // TILE):
        x = col * TILE
        y = row * TILE
        if courtyard.collidepoint(x, y):
            background.blit(floor_img, (x, y))
        else:
            background.blit(wall_img, (x, y))
cover = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
cover.fill((0, 0, 0, 150))


def draw(image, center):
    screen.blit(image, image.get_rect(center=center))


def clock_text(frames):
    seconds = frames // 60
    return f"{seconds // 60}:{seconds % 60:02d}"


def edge_point():
    side = random.randint(0, 3)
    if side == 0:
        return (random.randint(0, WIDTH), -24)
    if side == 1:
        return (random.randint(0, WIDTH), HEIGHT + 24)
    if side == 2:
        return (-24, random.randint(0, HEIGHT))
    return (WIDTH + 24, random.randint(0, HEIGHT))


class Knight(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image = knight_img
        self.rect = pygame.Rect(0, 0, 24, 24)
        self.rect.center = courtyard.center
        self.speed = 3
        self.hearts = 5
        self.max_hearts = 5
        self.hurt = 0
        self.bolt_wait = 30
        self.bolt_damage = 1
        self.axes = 1
        self.magnet = 80

    def move(self, keys):
        if keys[pygame.K_LEFT]:
            self.rect.x -= self.speed
        if keys[pygame.K_RIGHT]:
            self.rect.x += self.speed
        if keys[pygame.K_UP]:
            self.rect.y -= self.speed
        if keys[pygame.K_DOWN]:
            self.rect.y += self.speed
        self.rect.clamp_ip(courtyard)


class Monster(pygame.sprite.Sprite):
    def __init__(self, kind, pos):
        super().__init__()
        self.kind = kind
        self.image = MONSTERS[kind]["image"]
        self.speed = MONSTERS[kind]["speed"]
        self.hp = MONSTERS[kind]["hp"]
        self.axe_wait = 0
        self.flash = 0
        self.pos = pygame.Vector2(pos)
        self.rect = pygame.Rect(0, 0, MONSTERS[kind]["size"], MONSTERS[kind]["size"])
        self.rect.center = self.pos

    def update(self, target):
        step = pygame.Vector2(target) - self.pos
        if step.length() > 0:
            self.pos += step.normalize() * self.speed
        self.rect.center = self.pos
        if self.axe_wait > 0:
            self.axe_wait -= 1
        if self.flash > 0:
            self.flash -= 1

    def hit(self, damage):
        self.hp -= damage
        self.flash = 6
        hit_sound.play()


class Bolt(pygame.sprite.Sprite):
    def __init__(self, start, target, damage):
        super().__init__()
        self.pos = pygame.Vector2(start)
        step = pygame.Vector2(target) - self.pos
        if step.length() == 0:
            step = pygame.Vector2(1, 0)
        self.velocity = step.normalize() * 7
        self.damage = damage
        self.rect = pygame.Rect(0, 0, 10, 10)
        self.rect.center = self.pos

    def update(self):
        self.pos += self.velocity
        self.rect.center = self.pos
        if not screen.get_rect().colliderect(self.rect):
            self.kill()


class Gem(pygame.sprite.Sprite):
    def __init__(self, pos):
        super().__init__()
        self.rect = pygame.Rect(0, 0, 24, 24)
        self.rect.center = pos
        self.rect.clamp_ip(courtyard)
        self.pos = pygame.Vector2(self.rect.center)

    def update(self, knight):
        target = pygame.Vector2(knight.rect.center)
        if self.pos.distance_to(target) < knight.magnet:
            self.pos.move_towards_ip(target, 6)
        self.rect.center = self.pos


class Night:
    def __init__(self):
        self.knight = Knight()
        self.time = 0
        self.monsters = pygame.sprite.Group()
        self.bolts = pygame.sprite.Group()
        self.bolt_timer = 0
        self.spin = 0
        self.gems = pygame.sprite.Group()
        self.xp = 0
        self.level = 1
        self.need = 5
        self.choices = []
        self.pick = 0
        self.boss = None
        self.state = "playing"

    def wave(self):
        return min(len(WAVES), self.time * len(WAVES) // NIGHT + 1)

    def spawn(self):
        wave = self.wave()
        if self.time % max(16, 46 - wave * 5) == 0:
            kind = random.choice(WAVES[wave - 1])
            self.monsters.add(Monster(kind, edge_point()))
        if self.boss is None and self.time >= BOSS_AT:
            self.boss = Monster("boss", (WIDTH // 2, -40))
            self.monsters.add(self.boss)

    def nearest(self):
        point = pygame.Vector2(self.knight.rect.center)
        best = None
        for monster in self.monsters:
            if best is None or point.distance_to(monster.pos) < point.distance_to(best.pos):
                best = monster
        return best

    def shoot(self):
        self.bolt_timer += 1
        target = self.nearest()
        if self.bolt_timer >= self.knight.bolt_wait and target is not None:
            self.bolt_timer = 0
            bolt = Bolt(self.knight.rect.center, target.pos, self.knight.bolt_damage)
            self.bolts.add(bolt)

    def axe_spots(self):
        spots = []
        for i in range(self.knight.axes):
            angle = self.spin + i * 360 / self.knight.axes
            offset = pygame.Vector2(70, 0).rotate(angle)
            spots.append(pygame.Vector2(self.knight.rect.center) + offset)
        return spots

    def hurt_monsters(self):
        for bolt in self.bolts:
            target = pygame.sprite.spritecollideany(bolt, self.monsters)
            if target is not None:
                target.hit(bolt.damage)
                bolt.kill()
        for spot in self.axe_spots():
            blade = pygame.Rect(0, 0, 36, 36)
            blade.center = spot
            for monster in self.monsters:
                if monster.axe_wait == 0 and blade.colliderect(monster.rect):
                    monster.hit(1)
                    monster.axe_wait = 20
        for monster in list(self.monsters):
            if monster.hp <= 0:
                monster.kill()
                drops = 8 if monster.kind == "boss" else 1
                for _ in range(drops):
                    self.gems.add(Gem(monster.pos))

    def collect(self):
        for gem in pygame.sprite.spritecollide(self.knight, self.gems, True):
            self.xp += 1
            gem_sound.play()
        if self.xp >= self.need:
            self.xp -= self.need
            self.level += 1
            self.need += 4
            self.choices = random.sample(list(UPGRADES), 3)
            self.pick = 1
            self.state = "levelup"
            level_sound.play()

    def upgrade(self, name):
        knight = self.knight
        if name == "Quick bolts":
            knight.bolt_wait = max(8, knight.bolt_wait - 8)
        elif name == "Sharp bolts":
            knight.bolt_damage += 1
        elif name == "Spinning axe":
            knight.axes += 1
        elif name == "Swift boots":
            knight.speed += 1
        elif name == "Gem magnet":
            knight.magnet += 40
        elif name == "Hearty meal":
            knight.hearts = min(knight.max_hearts, knight.hearts + 1)
        self.state = "playing"

    def take_hits(self):
        knight = self.knight
        if knight.hurt > 0:
            knight.hurt -= 1
        elif pygame.sprite.spritecollideany(knight, self.monsters):
            knight.hearts -= 1
            knight.hurt = 75
            hurt_sound.play()
            if knight.hearts <= 0:
                self.state = "lost"

    def update(self, keys):
        self.time += 1
        self.spin = (self.spin + 4) % 360
        self.knight.move(keys)
        self.spawn()
        self.monsters.update(self.knight.rect.center)
        self.bolts.update()
        self.gems.update(self.knight)
        self.shoot()
        self.hurt_monsters()
        self.collect()
        self.take_hits()


night = Night()

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if night.state == "levelup":
                if event.key == pygame.K_LEFT:
                    night.pick = (night.pick - 1) % 3
                elif event.key == pygame.K_RIGHT:
                    night.pick = (night.pick + 1) % 3
                elif event.key == pygame.K_SPACE:
                    night.upgrade(night.choices[night.pick])

    if night.state == "playing":
        night.update(pygame.key.get_pressed())

    screen.blit(background, (0, 0))
    knight = night.knight
    for gem in night.gems:
        draw(gem_img, gem.rect.center)
    for monster in night.monsters:
        if monster.flash > 0:
            draw(whiten(monster.image), monster.rect.center)
        else:
            draw(monster.image, monster.rect.center)
    if knight.hurt % 10 < 5:
        draw(knight.image, knight.rect.center)
    for spot in night.axe_spots():
        draw(pygame.transform.rotate(axe_img, -night.spin * 3), spot)
    for bolt in night.bolts:
        pygame.draw.circle(screen, BOLT, bolt.rect.center, 5)
    for i in range(knight.max_hearts):
        if i < knight.hearts:
            screen.blit(heart_img, (8 + i * 34, 6))
        else:
            screen.blit(empty_img, (8 + i * 34, 6))
    left = font.render(clock_text(max(0, NIGHT - night.time)) + " until dawn", True, WHITE)
    screen.blit(left, left.get_rect(center=(WIDTH // 2, 24)))
    label = font.render(f"Level {night.level}", True, WHITE)
    screen.blit(label, label.get_rect(topright=(WIDTH - 10, 4)))
    pygame.draw.rect(screen, (40, 40, 60), (WIDTH - 160, 30, 150, 10))
    pygame.draw.rect(screen, GOLD, (WIDTH - 160, 30, 150 * night.xp // night.need, 10))
    if night.state == "levelup":
        screen.blit(cover, (0, 0))
        title = big_font.render("Level up! Pick one", True, GOLD)
        screen.blit(title, title.get_rect(center=(WIDTH // 2, 130)))
        for i, name in enumerate(night.choices):
            card = pygame.Rect(60 + i * 210, 190, 180, 120)
            pygame.draw.rect(screen, (40, 40, 60), card)
            if i == night.pick:
                pygame.draw.rect(screen, GOLD, card, 4)
            else:
                pygame.draw.rect(screen, WHITE, card, 2)
            text = font.render(name, True, WHITE)
            screen.blit(text, text.get_rect(center=(card.centerx, card.y + 40)))
            about = small_font.render(UPGRADES[name], True, WHITE)
            screen.blit(about, about.get_rect(center=(card.centerx, card.y + 80)))
        hint = font.render("Left and Right to choose, Space to take it", True, WHITE)
        screen.blit(hint, hint.get_rect(center=(WIDTH // 2, 360)))
    if night.state == "lost":
        message = big_font.render("The keep has fallen", True, WHITE)
        screen.blit(message, message.get_rect(center=(WIDTH // 2, 200)))
    pygame.display.flip()
    clock.tick(60)

pygame.quit()

Semua pelajaran di Proyek Pygame: Pertahankan Benteng

Latihan sendiri: Playground Pygame