Skip to content

Instantly share code, notes, and snippets.

@Pinacolada64
Last active March 13, 2025 07:31
Show Gist options
  • Save Pinacolada64/57a11a1c47004ffe1d1aeb473753c947 to your computer and use it in GitHub Desktop.
Save Pinacolada64/57a11a1c47004ffe1d1aeb473753c947 to your computer and use it in GitHub Desktop.
Dungeon crawler with fog-of-war map reveal
import logging
import random
from typing import Optional
from enum import Enum
# Dungeon map configuration
DUNGEON_SIZE = 20
REVEAL_RADIUS = 2
dungeon = [['.' for _ in range(DUNGEON_SIZE)] for _ in range(DUNGEON_SIZE)]
# False if not explored, True if explored (this way you could have "Forget Map" curse)
explored_space = [[False for _ in range(DUNGEON_SIZE)] for _ in range(DUNGEON_SIZE)]
# monster constants:
MONSTER_COUNT = 3
# player name setup:
MALE_FIRST_NAMES = ['Ryan', 'John', 'Bob', 'Wonko', 'Dave', 'Josh', 'Jeff'] # 7
FEMALE_FIRST_NAMES = ['Jane', 'Alice', 'Lisa', 'Susan', 'Martha', 'Madelyn', 'Phedre'] # 7
NEUTRAL_ADJECTIVES = ['Chicken-Hearted', 'Brave', 'Righteous', 'Pious', 'Smelly', 'Insane', 'Sane']
MASCULINE_ADJECTIVES = ['Handsome']
FEMININE_ADJECTIVES = ['Beautiful']
class Wall(str, Enum):
TOP_LEFT_CORNER = "\u250c" # ┌
TOP_RIGHT_CORNER = "\u2510" # ┐
BOTTOM_LEFT_CORNER = "\u2514" # └
BOTTOM_RIGHT_CORNER = "\u2518" # ┘
HORIZONTAL = "\u2500" # ─
VERTICAL = "\u2502" # │
# dungeon obstacles:
obstacles = [item for item in Wall]
class Gender(str, Enum):
MALE = "Male"
FEMALE = "Female"
def generate_random_name(gender: Gender) -> str:
"""Generates a random character name."""
first_names = MALE_FIRST_NAMES if gender == Gender.MALE else FEMALE_FIRST_NAMES
adjectives = NEUTRAL_ADJECTIVES
if gender == Gender.MALE:
adjectives.extend(MASCULINE_ADJECTIVES)
elif gender == Gender.FEMALE:
adjectives.extend(FEMININE_ADJECTIVES)
# logging.debug(f"adjectives: {adjectives}")
return f"{random.choice(first_names)} the {random.choice(adjectives)}"
def get_player_name(gender) -> Optional[str]:
"""
Prompts the player to input a name or accept a randomly generated one.
Returns the chosen name.
"""
while True:
random_name = generate_random_name(gender)
print(f"Type 'r' to choose another random name.")
print(f"Press ENTER to accept '{random_name}'.")
name = input(f"Your character's Name: ").strip()
# TODO: use snarky remark elsewhere:
# if name is None:
# print("Dungeons are littered with enough nameless corpses already. Please enter a name.")
if name.lower() != "r":
return name if name else random_name
class InventoryItem:
def __init__(self, name: str, description: str, cost: int, quantity: int = 1):
self.name = name
self.description = description
self.cost = cost
self.quantity = quantity
def __str__(self):
logging.debug("Using %s __str__ method" % self.__class__.__qualname__)
if self.quantity == 1:
return f"{self.name}"
else:
return f"{self.name} ({self.quantity}x)"
class SpellItem(InventoryItem):
def cast(self):
pass
class Player:
def __init__(self, gender: Gender, name: Optional[str], health: int = 10):
self.name = name
self.gender = gender
self.health = health
self.inventory = [InventoryItem("mace", "A pointy ball on a stick.", 5)]
self.spells = [SpellItem("fireball", "Casts a fireball that hits a monster.", 5,
quantity=5),
SpellItem("heal", "Heals the player for 5 health points.", 10)]
# set_up_monsters() avoids placing monsters in player_pos:
self.player_pos = [random.randrange(0, DUNGEON_SIZE),
random.randrange(0, DUNGEON_SIZE)]
def draw_border():
"""Draws a border around the dungeon."""
for i in range(DUNGEON_SIZE):
dungeon[i][0] = '#'
dungeon[i][DUNGEON_SIZE - 1] = '#'
for i in range(DUNGEON_SIZE):
dungeon[0][i] = '#'
dungeon[DUNGEON_SIZE - 1][i] = '#'
def draw_walls():
room_count = random.randint(1, 5)
for _ in range(room_count):
# Generate room dimensions and position
room_width = random.randint(3, 6)
room_height = random.randint(3, 6)
start_row = random.randint(1, DUNGEON_SIZE - room_height - 1)
start_col = random.randint(1, DUNGEON_SIZE - room_width - 1)
# Draw the room border
for row in range(start_row, start_row + room_height):
for col in range(start_col, start_col + room_width):
if row == start_row or row == start_row + room_height - 1:
dungeon[row][col] = Wall.HORIZONTAL #'-'
elif col == start_col or col == start_col + room_width - 1:
dungeon[row][col] = Wall.VERTICAL # '|'
# connect corners
dungeon[start_row][start_col] = Wall.TOP_LEFT_CORNER
dungeon[start_row][start_col + room_width - 1] = Wall.TOP_RIGHT_CORNER
dungeon[start_row + room_height - 1][start_col] = Wall.BOTTOM_LEFT_CORNER
dungeon[start_row + room_height - 1][start_col + room_width - 1] = Wall.BOTTOM_RIGHT_CORNER
# Add a random doorway
doorway_side = random.choice(['top', 'bottom', 'left', 'right'])
if doorway_side == 'top':
dungeon[start_row][random.randint(start_col + 1, start_col + room_width - 2)] = '.'
elif doorway_side == 'bottom':
dungeon[start_row + room_height - 1][random.randint(start_col + 1, start_col + room_width - 2)] = '.'
elif doorway_side == 'left':
dungeon[random.randint(start_row + 1, start_row + room_height - 2)][start_col] = '.'
elif doorway_side == 'right':
dungeon[random.randint(start_row + 1, start_row + room_height - 2)][start_col + room_width - 1] = '.'
def set_up_monsters() -> list:
# Monster setup: initialized after Player
monsters = []
for _ in range(MONSTER_COUNT):
while True:
monster_pos = [random.randint(0, DUNGEON_SIZE - 1), random.randint(0, DUNGEON_SIZE - 1)]
if monster_pos != player.player_pos and monster_pos not in monsters and monster_pos not in obstacles:
monsters.append(monster_pos)
dungeon[monster_pos[0]][monster_pos[1]] = 'M'
break
return monsters
# Game loop
def update_explored_space(x, y):
# Reveal the dungeon in a certain radius of the player:
for i in range(max(0, x - REVEAL_RADIUS), min(DUNGEON_SIZE, x + REVEAL_RADIUS + 1)):
for j in range(max(0, y - REVEAL_RADIUS), min(DUNGEON_SIZE, y + REVEAL_RADIUS + 1)):
# toggle unexplored spaces to explored spaces:
if not explored_space[i][j]: # Only update unexplored cells
explored_space[i][j] = True
logging.info("Explored space updated")
def display_dungeon():
"""
Generates and displays a visual representation of the dungeon.
This function iterates over the dungeon grid,
revealing explored areas and keeping unexplored
areas hidden under a fog of war. Explored areas
are shown as their respective dungeon contents,
with special markers for the player or monsters
where they are present. The player is represented
as "P," monsters as "M," and unexplored tiles
are displayed as "#". The state of the grid is
printed to the console line by line.
:return: None
"""
for i in range(DUNGEON_SIZE):
row = []
for j in range(DUNGEON_SIZE):
# Reveal explored areas or fog of war for unexplored areas:
if explored_space[i][j]:
# place player & monsters:
if [i, j] == player.player_pos:
row.append("P")
elif [i, j] in monsters:
row.append("M")
else:
# logging.debug(f"{dungeon[i][j]=}")
row.append(dungeon[i][j])
else:
# display unexplored square:
row.append("#")
print(' '.join(row))
print()
def reveal_dungeon():
"""
Generates and displays a visual representation of the dungeon.
This function iterates over the dungeon grid,
revealing the entire dungeon for debugging
purposes. Explored areas
are shown as their respective dungeon contents,
with special markers for the player or monsters
where they are present. The player is represented
as "P," monsters as "M," and unexplored tiles
are displayed as "#". The state of the grid is
printed to the console line by line.
:return: None
"""
for i in range(DUNGEON_SIZE):
row = []
for j in range(DUNGEON_SIZE):
# Reveal explored areas or fog of war for unexplored areas:
if dungeon[i][j]:
# place player & monsters:
if [i, j] == player.player_pos:
row.append("P")
elif [i, j] in monsters:
row.append("M")
else:
row.append(dungeon[i][j])
print(' '.join(row))
print()
def move_player(direction: str) -> None:
"""
Moves the player within the game dungeon based on the specified direction,
updating their position unless blocked by a wall or a monster. The function
manages boundary checks for the dungeon grid to prevent the player from
moving outside the permitted area. If a player attempts to move beyond
the grid limits, an appropriate message indicating collision with the wall
is displayed. Additionally, it avoids any potential collision with monsters
by verifying the desired position before adjusting the player's coordinates.
:param direction: The direction in which the player intends to move.
Valid inputs are 'w', 'a', 's', 'd', representing
up, left, down, and right, respectively.
:type direction: str
:return: None
"""
x, y = player.player_pos
if direction == 'w':
if x > 0:
if not monster_collision(x - 1, y) and not wall_collision(x - 1, y):
x -= 1
elif x == 0:
print("You bump into the north wall!")
elif direction == 's':
if x < DUNGEON_SIZE - 1:
if not monster_collision(x + 1, y) and not wall_collision(x + 1, y):
x += 1
elif x == DUNGEON_SIZE - 1:
print("You bump into the south wall!")
elif direction == 'a':
if y > 0:
if not monster_collision(x, y - 1) and not wall_collision(x, y - 1):
y -= 1
elif y == 0:
print("You bump into the west wall!")
elif direction == 'd':
if y < DUNGEON_SIZE - 1:
if not monster_collision(x, y + 1) and not wall_collision(x, y + 1):
y += 1
elif y == DUNGEON_SIZE - 1:
print("You bump into the east wall!")
player.player_pos = [x, y]
update_explored_space(x, y)
def monster_collision(x: int, y: int, verbose: bool = True) -> bool:
"""
Check whether the player's position is the same as a monster's.
Optionally report the collision if verbose=True.
:param x: player's x position
:param y: player's y position'
:param verbose: bool, default True: whether to report a collision
:return: bool, True if player's position is the same as a monster's, False otherwise.
"""
# move_player() sets verbose=True to report bumping into a monster.
# other functions could set verbose=False to not report bumping into a monster
# when checking something against the player position.
if [x, y] in monsters and verbose:
print("You bump into a monster!")
return [x, y] in monsters
def wall_collision(x: int, y: int, verbose: bool = True) -> bool:
"""Same as monster_collision, but for walls."""
collision = dungeon[x][y] in obstacles
if collision and verbose:
print("You bump into a wall!")
return collision
def choose_spell(player: Player) -> SpellItem | None:
"""
Chooses a spell for the given player from the list of available spells. If the
player has spells, it prompts the user to select one and returns the chosen
spell. If the input is invalid or the selected spell does not exist, the
player is prompted again. If the player has no spells, a message is displayed,
and the function returns None.
:param player: The player object for whom the spell needs to be selected.
The player must possess a list of spells.
:type player: Player
:return: The selected spell from the player's list of spells or None if the
player has no spells or no valid selection is made.
:rtype: SpellItem | None
"""
while True:
if player.spells:
print("Choose a spell:")
for num, spell in enumerate(player.spells, start=1):
print(f"{num}. {spell}")
spell_choice = input("Choose a spell: ")
if spell_choice.isdigit():
spell_choice = int(spell_choice) - 1
if 0 <= spell_choice < len(player.spells):
return player.spells[spell_choice]
else:
print("Invalid spell choice!")
else:
print("You have no spells!")
return None
def cast_spell(player: Player, spell: SpellItem) -> None:
"""
Casts a spell from the player's spell inventory, modifying the game state based
on the selected spell's effect. Handles actions like targeting, applying effects,
and adjusting spell quantities.
:param player: The Player instance that is casting the spell. Contains player-related
attributes such as health and spell inventory.
:type player: Player
:param spell: The SpellItem instance representing the spell to be cast. Includes
spell-related properties such as name and available quantity.
:type spell: SpellItem
:return: Does not return any value. Instead, modifies game state directly, such
as player health, monster locations, and dungeon map.
:rtype: None
"""
# find where spell is in spell list to modify it:
logging.debug(f"spell chosen: {spell}")
spell_number = player.spells.index(spell)
if player.spells[spell_number].quantity <= 0:
print(f"No charges left for {spell.name}!")
return
if spell.name == "fireball":
target = get_target_direction()
if target:
target_x, target_y = target
if [target_x, target_y] in monsters:
monsters.remove([target_x, target_y])
# monster corpse:
dungeon[target_x][target_y] = '*'
print("Fireball hit! Monster defeated!")
else:
print("Fireball missed! No monster there.")
elif spell.name == "heal":
player.health += 5
print(f"You cast heal! Your health is now: {player.health}")
player.spells[spell_number].quantity -= 1
def get_target_direction():
"""
Calculate the target coordinates based on player position and input direction.
:return: [x, y] coordinates of target
"""
target_direction = input("Enter target direction [w/a/s/d]: ")
x, y = player.player_pos
if target_direction == 'w' and x > 0:
return [x - 1, y]
elif target_direction == 's' and x < DUNGEON_SIZE - 1:
return [x + 1, y]
elif target_direction == 'a' and y > 0:
return [x, y - 1]
elif target_direction == 'd' and y < DUNGEON_SIZE - 1:
return [x, y + 1]
else:
print("Invalid direction or target out of bounds!")
return None
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG,
format='%(levelname)-8s %(funcName)15s() %(message)s',
datefmt='%m-%d %H:%M')
print("Dungeon Crawl!")
# player initialization:
while True:
gender = None
option = input("Enter your gender [M/F]: ").lower()[0]
if option == 'm':
gender = Gender.MALE
break
elif option == 'f':
gender = Gender.FEMALE
break
else:
print("Invalid gender!")
okay = input("Are these options okay? [y/n]: ").lower()[0]
if okay == 'y':
break
# assign adjectives based on gender choice:
player = Player(gender=gender, name=get_player_name(gender))
print(f"Welcome to the dungeon, {player.name}!")
# draw walls:
draw_walls()
# set_up_monsters() depends on player.player_pos() being initialized
# so we don't place monsters on top of player:
monsters = set_up_monsters()
# initial fog-of-war reveal:
update_explored_space(*player.player_pos)
while True:
display_dungeon()
if not monsters:
print("You defeated all the monsters! You win!")
break
print("[ move: w / a / s / d, [c]ast spell, [ls] list spells, [i]nventory, [q]uit ]")
action = input("Choose an action: ").lower().strip()
if action == "i":
if player.inventory:
print(f"Inventory:")
for num, item in enumerate(player.inventory, start=1):
print(f"{num}. {item}")
else:
print("Your inventory is empty.")
if action == "ls":
if player.spells:
print("Your spellbook:")
for num, spell in enumerate(player.spells, start=1):
print(f"{num}. {spell}")
else:
print("Your spellbook is empty.")
if action == 'q':
quit_game = input("Quit: are you sure [y/n]: ").lower().strip()
if quit_game == "y":
print("Goodbye!")
break
elif action == 'r':
print("Revealing the dungeon...")
reveal_dungeon()
elif action == 'c':
if player.spells:
spell_to_cast = choose_spell(player)
if spell_to_cast:
cast_spell(player, spell_to_cast)
else:
move_player(action)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment