# Revised Top-Down Dungeon RPG

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 --
for i in range(8):
new_enemy = Enemy()
enemy_list.append(new_enemy)
def clip(value, lower, upper):
return min(upper, max(value, lower))
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]
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():

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:

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')


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

# 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



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']


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



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.

• 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.

## game/resources/__init__.py

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

root = Path(__file__).parent
for path in root.glob('*.png'):


## 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()

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

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


## game/ui.py

import pygame
from pygame import display, font, key

from .logic import Game

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.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.game()
finally:
pygame.quit()

• Why is init.py used twice? Apr 29, 2021 at 1:24
• Because there are two different modules. Apr 29, 2021 at 1:27
• I get this error: ImportError: attempted relative import with no known parent package Apr 29, 2021 at 12:32
• I'm also working in Visual Studio Code Apr 29, 2021 at 12:36
• You need to execute from above the root directory shown. You can't just execute one of these files directly Apr 29, 2021 at 13:05