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

konijn
34.2k5 gold badges70 silver badges267 bronze badges
asked Apr 27, 2021 at 12:39
\$\endgroup\$
4
  • \$\begingroup\$ You seem to have skipped some of those recommendations, particularly creating a player class and moving global code into functions. \$\endgroup\$ Commented Apr 27, 2021 at 14:40
  • \$\begingroup\$ I didn't figure out how to incorporate the Player class. \$\endgroup\$ Commented Apr 27, 2021 at 14:55
  • 2
    \$\begingroup\$ Do you have a GitHub repository to link your whole project including graphic resources? \$\endgroup\$ Commented Apr 27, 2021 at 14:58
  • \$\begingroup\$ I will probably start a bounty soon \$\endgroup\$ 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.

answered Apr 28, 2021 at 1:30
\$\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()
answered Apr 28, 2021 at 22:54
\$\endgroup\$
8
  • \$\begingroup\$ Why is init.py used twice? \$\endgroup\$ Commented Apr 29, 2021 at 1:24
  • \$\begingroup\$ Because there are two different modules. \$\endgroup\$ Commented Apr 29, 2021 at 1:27
  • \$\begingroup\$ I get this error: ImportError: attempted relative import with no known parent package \$\endgroup\$ Commented Apr 29, 2021 at 12:32
  • \$\begingroup\$ I'm also working in Visual Studio Code \$\endgroup\$ 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\$ Commented Apr 29, 2021 at 13:05

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.