4
\$\begingroup\$

Introduction:

So far, the game works well, and my only major concerns, are a bullet system (including a function where you click the mouse to shoot the bullets), and a collision system between the zombies and bullets, as I've tried many different ways to go about those two issues, with no luck. Anyways, if someone were to mind explaining how to implement these two concepts, with the code provided, that would be wonderful.

Iterative review from my previous question.

GitHub Repository: https://github.com/Zelda-DM/Dungeon-Minigame

# --- Imports ---
import pygame
import os
import random
# --- Screen Dimensions ---
SCR_WIDTH = 1020
SCR_HEIGHT = 510
# --- Colors ---
WHITE = [240, 240, 240]
# --- Game Constants ---
FPS = 60
score = 0
lives = 3
# --- Fonts ---
pygame.font.init()
TNR_FONT = pygame.font.SysFont('Times_New_Roman', 27)
# --- Player Variables ---
playerX = 120
playerY = 100
# --- Dictionaries/Lists ---
images = {}
enemy_list = []
bullets = []
# --- Classes ---
class Enemy:
    def __init__(self):
        self.x = random.randint(600, 1000)
        self.y = random.randint(8, 440)
        self.moveX = 0
        self.moveY = 0

    def move(self):
        if self.x > playerX:
            self.x -= 0.7
        
        if self.x <= 215:
            self.x = 215
            enemy_list.remove(enemy)
            for i in range(1):
                new_enemy = Enemy()
                enemy_list.append(new_enemy)

    def draw(self):
        screen.blit(images['l_zombie'], (self.x, self.y))
# --- Functions --
def load_zombies():
    for i in range(8):
        new_enemy = Enemy()
        enemy_list.append(new_enemy)
def clip(value, lower, upper):
    return min(upper, max(value, lower))
def load_images():
    path = 'Desktop/Files/Dungeon Minigame/'
    filenames = [f for f in os.listdir(path) if f.endswith('.png')]
    for name in filenames:
        imagename = os.path.splitext(name)[0]
        images[imagename] = pygame.image.load(os.path.join(path, name))
def main_menu():
    screen.blit(images['background'], (0, 0))
    start_button = screen.blit(images['button'], (420, 320))
    onclick = False
    while True:
        mx, my = pygame.mouse.get_pos()
        if start_button.collidepoint((mx, my)):
            if onclick:
                game()
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
            if event.type == pygame.MOUSEBUTTONDOWN:
                onclick = True
        clock.tick(FPS)
        pygame.display.update()
def game(): 

    load_zombies()

    while True:

        global playerX, playerY, score, lives, enemy

        screen.blit(images['background2'], (0, 0))
        score_text = TNR_FONT.render('Score: ' + str(score), True, WHITE)
        lives_text = TNR_FONT.render('Lives: ', True, WHITE)
        screen.blit(score_text, (20, 20))
        screen.blit(lives_text, (840, 20))
        screen.blit(images['r_knight'], (playerX, playerY))

        heart_images = ["triple_empty_heart", "single_heart", "double_heart", "triple_heart"]
        lives = clip(lives, 0, 3)
        screen.blit(images[heart_images[lives]], (920, 0))

        if lives == 0:
            main_menu()

        for enemy in enemy_list:
            enemy.move()
            enemy.draw()
            if enemy.x == 215:
                lives -= 1
    
        onpress = pygame.key.get_pressed()

        Y_change = 0
        if onpress[pygame.K_w]:
            Y_change -= 5
        if onpress[pygame.K_s]:
            Y_change += 5
        playerY += Y_change

        X_change = 0
        if onpress[pygame.K_a]:
            X_change -= 5
        if onpress[pygame.K_d]:
            X_change += 5
        playerX += X_change

        playerX = clip(playerX, -12, 100)
        playerY = clip(playerY, -15, 405)

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                quit()
            
        clock.tick(FPS)
        pygame.display.update()
# --- Main ---
pygame.init()
clock = pygame.time.Clock()
screen = pygame.display.set_mode((SCR_WIDTH, SCR_HEIGHT))
pygame.display.set_caption('Dungeon Minigame')
load_images()
main_menu()

Output

\$\endgroup\$
4
  • \$\begingroup\$ You seem to have skipped some of those recommendations, particularly creating a player class and moving global code into functions. \$\endgroup\$
    – Reinderien
    Commented Apr 27, 2021 at 14:40
  • \$\begingroup\$ I didn't figure out how to incorporate the Player class. \$\endgroup\$
    – Zelda
    Commented Apr 27, 2021 at 14:55
  • 2
    \$\begingroup\$ Do you have a GitHub repository to link your whole project including graphic resources? \$\endgroup\$
    – Reinderien
    Commented Apr 27, 2021 at 14:58
  • \$\begingroup\$ I will probably start a bounty soon \$\endgroup\$
    – Zelda
    Commented Apr 28, 2021 at 15:52

2 Answers 2

2
\$\begingroup\$

Colliding

For now, let's assume that your bullets travel in a straight line from left to right, or from right to left.

Your zombies have a position, self.x and self.y. In addition, they also have an image that you draw on the screen (images['l_zombie'] in the Enemy.draw() method).

What I don't see is any mention of left-over pixels in the image. It could be that your zombie images are all the same size, but that there is a border of 1, or 2, or 50 pixels of "dead space" around the outside of the image.

For now, let's assume that there is no dead space. If dead space exists, you'll just have to do some subtraction.

You draw your zombies with the Enemy.draw() method, which uses (self.x, self.y) to set the position of the image.

You need to detect a collision. Since the bullets are (we assume) traveling horizontally, we can make some radically simplifying assumptions.

  • We assume that there is some "bullet position".
  • We assume that you can compute the "bullet front position" based on the bullet position. (Note: this may vary a bit, since the bullet position might be on one side of the bullet always, but the front will change depending on the direction of fire.)
  • We assume that there is some "zombie position" (self.x, self.y)
  • We assume that you can compute the "zombie front position" based on the zombie position (Note: same issues as above).

Since you are traveling horizontally, you can compute the bullet's dx on a given update.

Compare the "zombie front position" for each zombie, with the old/new "bullet front position" for each bullet. If a bullet moves from one side of the zombie front to the other (or in contact), then there's a hit.

Example

Let zed be a zombie.

zed = Enemy()

Assume your zombie image has 5 pixels of dead space above, and 13 pixels below.

ABOVE = 0
BELOW = 1

Enemy.dead_space = (5, 13)

Now assume that zed is at position 200, 350

zed.x, zed.y = 200, 350

We can compute the hit zone from this information:

zed.image = images['l_zombie']
hit_zone = (zed.y + zed.dead_space[ABOVE],
            zed.y + zed.image.get_height() - zed.dead_space[BELOW])

The hit zone is the Y-axis band where a horizontal bullet can impact the zombie. If the bullet is above or below this band, it will miss.

Of course, your bullets can be more than one pixel high. In which case, we need to check for overlap.

Like zombies, bullets can have dead space in their image. So be sure and check for that:

Bullet.dead_space = (10, 12)
bullet = Bullet()

bullet.image = images['r_bullet']  # left-facing zed gets shot at with right-facing bullets

bullet_zone = (bullet.y + bullet.dead_space[ABOVE], 
               bullet.y + bullet.image.get_height() - bullet.dead_space[BELOW])

So we have a bullet_zone and an impact_zone, and we want to check if they overlap:

if bullet_zone[BELOW] > impact_zone[ABOVE] or bullet_zone[ABOVE] > impact_zone[BELOW]:
    return False  # no impact

If the bullet and zed are in the right range to impact, we now have to ask if an impact occurs right in this moment. Assume that we are doing this check after all the zombies and bullets have moved:

bullet_oldpos_x = bullet.x - bullet.speed_x
bullet_newpos_x = bullet.x

zed_oldpos_x = zed.x - zed.speed_x
zed_newpos_x = zed.x

Now ask which side of the bullet we are on. The images are positioned based on their top, left corner. But we may want to worry about the right edge, instead of the left. Figure it out:

if bullet.speed > 0:   # shooting left->right
    img_width = bullet.image.get_width()
    bullet_oldpos_x += img_width
    bullet_newpos_x += img_width
        
if zed.speed > 0:      # walking left->right
    img_width = zed.image.get_width()
    zed_oldpos_x += img_width
    zed_newpos_x += img_width

Now check if the bullet passed through the edge of zed:

if bullet_oldpos_x < zed_oldpos_x and bullet_newpos_x >= zed_newpos_x:
    return True

For collisions, there are three cases:

  • The bullet crossed the line of newpos.
  • The bullet existed within the step of zed.
  • The bullet crossed the line of oldpos.

In the first two cases, the bullet's oldpos was "before" zed, but the bullet's newpos is "inside or beyond" zed.

In the second two cases, the bullet's newpos is "beyond" zed's oldpos.

You'll have to make some similar-but-backwards checks for the right-to-left direction.

When you start moving in 2 dimensions, you'll have a rectangle to worry about. At that point the "right" thing is to compute the intersection of the line of the bullet with the rectangle or some other shape of zed. But if you can simplify the "edge" of zed to a line segment, it's easier.

\$\endgroup\$
0
2
\$\begingroup\$
  • Your constants (screen width, etc.) are fine as they are, but e.g. lives is not a constant - it should not live in the global namespace
  • Avoid calling pygame initialization methods in the global namespace
  • moveX and moveY are unused, so delete them
  • You need to pry apart your logic from your presentation; they're mixed up right now
  • Consider modularizing your project, which will make for a nicer testing and packaging experience
  • Do not assume that there's a Desktop directory relative to the current one that contains your images; instead look up the image paths via pathlib and glob
  • Your main_menu is deeply troubled. It's currently running a frame loop even though none is needed since there are no UI updates; so instead you want a blocking event loop. Also, your onclick and get_pos misrepresent how proper event handling should be done with pygame - do not call get_pos at all; and instead rely on the positional information from the event itself.
  • I don't think pygame.quit does what you think it does. To be fair, they haven't named it very well. It does not quit. It deallocates all of the pygame resources. You should be doing this as a part of game cleanup, not to trigger an exit.
  • Consider using an f-string for 'Score: ' + str(score)
  • heart_images should be a global constant
  • You currently have a stack explosion loop - main_menu calls game, but game calls main_menu. Do not do this.

The following example code does not attempt to implement your bullet feature, but does preserve your existing functionality, and fixes the up-to-now broken feature that had attempted to loop between the game and the main menu.

Directory structure

directory structure

game/resources/__init__.py

from pathlib import Path
from typing import Iterable, Tuple
from pygame import image, Surface


def load_images() -> Iterable[Tuple[str, Surface]]:
    root = Path(__file__).parent
    for path in root.glob('*.png'):
        yield path.stem, image.load(path)

game/__main__.py

This supports the invocation

python -m game
from .ui import main
main()

game/logic.py

from numbers import Number
from random import randint
from typing import TypeVar, Tuple

N_ENEMIES = 8

ClipT = TypeVar('ClipT', bound=Number)


def clip(value: ClipT, lower: ClipT, upper: ClipT) -> ClipT:
    return min(upper, max(value, lower))


class HasPosition:
    def __init__(self, x: float, y: float):
        self.x, self.y = x, y

    @property
    def pos(self) -> Tuple[float, float]:
        return self.x, self.y


class Player(HasPosition):
    def __init__(self):
        super().__init__(120, 100)

    def up(self):
        self.y -= 5

    def down(self):
        self.y += 5

    def left(self):
        self.x -= 5

    def right(self):
        self.x += 5

    def clip(self):
        self.x = clip(self.x, -12, 100)
        self.y = clip(self.y, -15, 405)


class Enemy(HasPosition):
    def __init__(self):
        super().__init__(randint(600, 1000), randint(8, 440))

    def collide(
        self, left_limit: float,
    ) -> bool:  # Whether the enemy collided
        if self.x > left_limit:
            self.x -= 0.7

        if self.x <= 215:
            self.x = 215
            return True
        return False


class Game:
    def __init__(self):
        self.score = 0
        self.lives = 3
        self.player = Player()
        self.enemies = [Enemy() for _ in range(N_ENEMIES)]
        self.bullets = []

    @property
    def alive(self) -> bool:
        return self.lives > 0

    def update(self) -> None:
        self.player.clip()

        to_remove, to_add = [], []
        for enemy in self.enemies:
            if enemy.collide(left_limit=self.player.x):
                self.lives -= 1
                to_remove.append(enemy)
                enemy = Enemy()
                to_add.append(enemy)

        for enemy in to_remove:
            self.enemies.remove(enemy)
        self.enemies.extend(to_add)

game/ui.py

import pygame
from pygame import display, font, key

from .logic import Game
from .resources import load_images


SCR_WIDTH = 1020
SCR_HEIGHT = 510
WHITE = [240, 240, 240]
FPS = 60

HEART_IMAGES = ["triple_empty_heart", "single_heart", "double_heart",
                "triple_heart"]


class Window:
    def __init__(self):
        self.tnr_font = font.SysFont('Times_New_Roman', 27)
        self.clock = pygame.time.Clock()
        self.screen = display.set_mode((SCR_WIDTH, SCR_HEIGHT))
        display.set_caption('Dungeon Minigame')
        self.images = dict(load_images())

    def main_menu(self) -> None:
        self.screen.blit(self.images['background'], (0, 0))
        start_button = self.screen.blit(self.images['button'], (420, 320))
        display.update()

        while True:
            event = pygame.event.wait()
            if event.type == pygame.QUIT:
                exit()
            if event.type == pygame.MOUSEBUTTONDOWN and start_button.collidepoint(event.pos):
                break

    def draw(self, game: Game) -> None:
        self.screen.blit(self.images['background2'], (0, 0))

        antialias = True
        score_text = self.tnr_font.render(f'Score: {game.score}', antialias, WHITE)
        lives_text = self.tnr_font.render('Lives: ', antialias, WHITE)
        self.screen.blit(score_text, (20, 20))
        self.screen.blit(lives_text, (840, 20))

        self.screen.blit(self.images[HEART_IMAGES[game.lives]], (920, 0))
        self.screen.blit(self.images['r_knight'], game.player.pos)

        for enemy in game.enemies:
            self.screen.blit(self.images['l_zombie'], enemy.pos)

        display.update()

    @staticmethod
    def handle_keys(game: Game) -> None:
        onpress = key.get_pressed()
        if onpress[pygame.K_w]:
            game.player.up()
        if onpress[pygame.K_s]:
            game.player.down()
        if onpress[pygame.K_a]:
            game.player.left()
        if onpress[pygame.K_d]:
            game.player.right()

    @staticmethod
    def maybe_exit() -> None:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                exit()

    def game(self) -> None:
        game = Game()

        while True:
            self.handle_keys(game)
            game.update()
            if not game.alive:
                break

            self.maybe_exit()
            self.draw(game)
            self.clock.tick(FPS)


def main():
    pygame.init()
    font.init()

    window = Window()
    try:
        while True:
            window.main_menu()
            window.game()
    finally:
        pygame.quit()
\$\endgroup\$
8
  • \$\begingroup\$ Why is init.py used twice? \$\endgroup\$
    – Zelda
    Commented Apr 29, 2021 at 1:24
  • \$\begingroup\$ Because there are two different modules. \$\endgroup\$
    – Reinderien
    Commented Apr 29, 2021 at 1:27
  • \$\begingroup\$ I get this error: ImportError: attempted relative import with no known parent package \$\endgroup\$
    – Zelda
    Commented Apr 29, 2021 at 12:32
  • \$\begingroup\$ I'm also working in Visual Studio Code \$\endgroup\$
    – Zelda
    Commented Apr 29, 2021 at 12:36
  • \$\begingroup\$ You need to execute from above the root directory shown. You can't just execute one of these files directly \$\endgroup\$
    – Reinderien
    Commented Apr 29, 2021 at 13:05

Not the answer you're looking for? Browse other questions tagged or ask your own question.