2.4 Project

Controlling Python’s Flow

We will create a text-based fighting game over the next few chapters. This chapter will focus on creating the basic structure of the game, emphasizing the use of variables and flow control structures to manage the basic logic. As we introduce new Python concepts, we will gradually develop the complexity of the game further. Sometimes, this will include adding new features, while other times it will involve refactoring existing code to improve its structure and readability. We will also explore how we might keep track of game state and player progress to analyze the game behavior.

The basic concept of the game is a text-based fighting game where the player must defeat a series of enemies by making choices that affect the outcome of the battle. In the future, there will be multiple enemies in multiple locations of varying difficulty. The player will choose how to travel the map, which will determine which enemy they encounter. The player will also have a health bar that decreases with each battle, and they can choose to heal themselves or attack the enemy.

For this chapter, we will focus on building the character, creating a location, and associating an enemy with that location. Finally, we will implement a simple battle system that will be improved dramatically in the following chapters.

Creating the Character

First let’s create a character for the player. We will use a dictionary to store the character’s attributes, such as name, health, and attack power. This will allow us to easily access and modify the character’s attributes as needed. We will allow the user to apply points to their character’s attributed dynamically, so they can choose how to build their character. We will create two dictionaries: one for the character’s attributes and another for the strengths and weaknesses of different character types. Every character and enemy will have a strength (and a corresponding weakness) that will affect the outcome of battles. We will predetermine the weaknesses of characters based on the chosen strength. For instance, if a character chooses fire as their strength, they will take double damage from a water enemy, but will do double damage to an earth enemy. Simultaneously, they will suffer half damage from a fire enemy. We will be able to control this behavior using a dictionary that maps strengths to weaknesses.

from typing import TypedDict, List, Dict

class CharacterTypes(TypedDict):
    name: str
    health: int
    attack_power: int
    defense: int
    strength: str

total_points: int = 20

strengths_weaknesses = {
    "fire": "water",
    "water": "earth",
    "earth": "fire",
}

character: CharacterTypes = {
    "name": "Hero",
    "health": 90,
    "attack_power": 10,
    "defense": 5,
    "strength": ""
}

enemies: List[CharacterTypes] = [{
    "name": "Goblin",
    "health": 20,
    "attack_power": 5,
    "defense": 1,
    "strength": "water"
}]

locations: Dict[str, int] = {"forest": 0}

gameplay_data = {
    "enemies_defeated": [],
    "damage_taken": [],
    "damage_dealt": [],
    "locations_visited": [],
    "enemies_encountered": []
}

We now have the basis of our game. We have created the basic state of the game. State is the current condition of the game, including the character’s attributes, the enemy’s attributes, and the locations available in the game. We will use this state to control the flow of the game and determine how the player interacts with the game world. State is an important concept in programming, as it allows us to keep track of the current condition of the game and make decisions based on that condition. In this case, we will use state to determine how the player interacts with the game world, such as which enemy they encounter and how much damage they take in battle.

We have created a TypedDict for the character titled CharacterTypes. It will allow us to define the structure of the character’s attributes and ensure that we access them correctly throughout the game. The CharacterTypes TypedDict includes fields for the character’s name, health, attack power, defense, and strength. This will help us maintain a clear and consistent structure for our character data as well as our enemy data because they will share the same structure. This will make it easier to manage the game state and ensure that we can easily access and modify the character’s attributes as needed.

We created the character dictionary with default values for the character’s attributes. The character starts with a name of “Hero”, 90 health, 10 attack power, 10 defense, and an empty strength. During character creation, these attributes will change. The enemies list contains a single enemy with its own attributes, including a strength of “water”. This will allow us to create a simple battle system where the player can fight against this enemy. We created the enemies state as a list of dictionaries, so we can access each enemy individually off the list using index notation. This will allow us to easily add more enemies in the future. We then create the locations list, which contains dictionaries that represent the locations as keys and the index of the associated enemy index in the enemy list as values. This will allow us to easily access the enemy associated with each location and determine the order of the locations in the game.

Finally, we created a gameplay_data dictionary to keep track of the player’s progress in the game. This dictionary will store information about the enemies defeated, damage taken, damage dealt, locations visited, and enemies encountered. This will allow us to analyze the player’s progress and present some basic statistics about the game.

# character build sequence
name = input("Enter your character's name: ")
character["name"] = name
print(f"""
Welcome, {character['name']}! 
You have {total_points} points to distribute 
among your attributes."""
)
print("""
You can distribute points to 
health, 
attack power, 
defense, 
and choose a strength (fire, water, earth)."""
)

try:
    health_add = int(input(f"""
    How many points do you want to add to health? 
    {total_points} available: 
    """))
    if health_add < 0 or health_add > total_points:
        raise ValueError("Invalid points for health. Please enter a value between 0 and the total points available.")
    character["health"] += health_add
    total_points -= health_add
    print(f"Health is now {character['health']}. {total_points} points remaining.")

    attack_add = int(input(f"""
    How many points do you want to add to attack power? 
    {total_points} available: 
    """))
    if attack_add < 0 or attack_add > total_points:
        raise ValueError("Invalid points for attack power. Please enter a value between 0 and the total points available.")
    character["attack_power"] += attack_add
    total_points -= attack_add
    print(f"""Attack power is now {character['attack_power']}. {total_points} points remaining.""")

    defense_add = int(input(f"""
    How many points do you want to add to defense? 
    {total_points} available: 
    """))
    if defense_add < 0 or defense_add > total_points:
        raise ValueError("Invalid points for defense. Please enter a value between 0 and the total points available.")
    character["defense"] += defense_add
    total_points -= defense_add
    print(f"""Defense is now {character['defense']}. 
    {total_points} points remaining.""")

    strength = input(
        f"""
        Choose your character's strength from the following
        ({', '.join(strengths_weaknesses.keys())}): 
        """).lower()
    if strength not in strengths_weaknesses:
        raise ValueError("Invalid strength. Please choose from fire, water, or earth.")
    character["strength"] = strength
    print(f"Your character's strength is now {character['strength']}.")
    print(f"Character created: {character}")
except Exception as e:
    print(f"Error: {e}. Please start over.")
    exit(1)

We have successfully created a character based on the user’s input. We set the character’s name, health, attack power, defense, and strength according to the user’s choices. We used a try-except block to handle any errors that may occur during the character creation process, such as invalid input or exceeding the total points available. This will help us ensure that the character is created correctly and that the user has a smooth experience when creating their character. In the except block, we print an error message and exit the program if any errors occur. This will prevent the program from crashing and allow the user to start over if they make a mistake. This style of error handling is common in Python programs, as it allows us to gracefully handle unexpected situations and provide feedback to the user. In the future, we will improve this process, so we don’t have to start the program over if the user makes a mistake. Instead, we will allow them to correct their input and continue building their character. But for the moment, we do not yet know how to do that, so we will exit the program if an error occurs.

To add additional health, attack, or defense points, we simply modified the character dictionary by accessing the appropriate key and adding the points to the existing value. We also updated the total_points variable to reflect the remaining points available for distribution. This allows us to keep track of how many points the user has left to distribute among their character’s attributes.

Now we can begin the basic fighting system. We will create a simple battle system that will allow the player to fight against the enemy we created earlier. The player will be able to attack the enemy, and the enemy will attack back. The player will win if they defeat the enemy, and lose if their health reaches zero.

First we need to determine which path the player wants to take. Right now, we only have one location, the forest, but we will add more locations in the future. For now, we will simply ask the player to choose a path. We will use a dictionary to store the locations and their associated enemies.

print("Choose which path you want to take:")
path = input(f"Enter the path you want to take {list(locations.keys())}: ").lower()
if path not in locations:
    print("Invalid path. Please choose a valid path.")
    exit(1)

enemy = enemies[locations[path]]  # Get the first enemy from our list
print(f"{character['name']} encounters {enemy['name']} in the {path}!")

Now we have the enemy associated with the chosen path. But before we complete the battle system, we need to establish the attack power and options for the player and the enemy. Now that we know the enemy we are fighting, we can calculate the damage multipliers based on the strengths and weaknesses of the characters. We will use the strengths_weaknesses dictionary to determine the multipliers for both the player and the enemy based on their strengths. For the enemy we will lower their attack power if their strength is the strengh of the character, but we will not do this for the player.


# Battle System - Using only if statements
print("\n--- BATTLE BEGINS ---")
    
# Calculate damage multipliers based on strengths
player_multiplier = 2.0 if character["strength"] == strengths_weaknesses[enemy["strength"]] else 1.0

if enemy["strength"] == strengths_weaknesses[character["strength"]]:
    enemy_multiplier = 2.0
elif enemy["strength"] == character["strength"]:
    enemy_multiplier = 0.5
else:
    enemy_multiplier = 1.0

actions = ["attack", "wait"]
player_damage = int((character["attack_power"] - enemy["defense"]) * player_multiplier)
if player_damage < 1:
    player_damage = 1

enemy_damage = int((enemy["attack_power"] - character["defense"]) * enemy_multiplier)
if enemy_damage < 1:
    enemy_damage = 1

We can now create a simple battle system that will allow the player to fight against the enemy. The player will be able to attack the enemy, and the enemy will attack back. The player will win if they defeat the enemy, and lose if their health reaches zero. To do this, we will need to import the random module to generate the enemy’s action randomly.

import random
# Round 1: Player attacks first
print(f"\n--- ROUND 1 ---")

action = input(f"Choose your action {", ".join(actions)}: ").lower()
if action == "attack":
    print(f"{character['name']} attacks {enemy['name']}!")
    enemy["health"] = enemy["health"] - player_damage
    gameplay_data["damage_dealt"].append(player_damage)
    print(f"{character['name']} attacks {enemy['name']} for {player_damage} damage!")
    #check if enemy is defeated
    if enemy["health"] <= 0:
        print(f"{enemy['name']} has been defeated!")
        print(f"{character['name']} wins the game!")
        gameplay_data["enemies_defeated"].append(enemy["name"])
        exit(0)
    print(f"{enemy['name']} has {enemy['health']} health remaining.")

    # Enemy's turn
    print(f"{enemy['name']}'s turn")
    enemy_action = random.choice(actions)
    if enemy_action == "attack":
        # Enemy attacks back (Round 1)
        print(f"{enemy['name']} attacks back!")
        character["health"] = character["health"] - enemy_damage
        gameplay_data["damage_taken"].append(enemy_damage)
        print(f"{enemy['name']} attacks {character['name']} for {enemy_damage} damage!")

        # check if player is defeated
        if character["health"] <= 0:
            print(f"{character['name']} has been defeated!")
            print(f"{enemy['name']} wins the game!")
            exit(0)
        
        print(f"{character['name']} has {character['health']} health remaining.")
    else:
        print(f"{enemy['name']} waits and does not attack.")
else:
    print(f"{character['name']} waits and does not attack.")

    # Enemy's turn
    print(f"{enemy['name']}'s turn")
    enemy_action = random.choice(actions)
    if enemy_action == "attack":
        # Enemy attacks back (Round 1)
        print(f"{enemy['name']} attacks back!")
        character["health"] = character["health"] - enemy_damage
        gameplay_data["damage_taken"].append(enemy_damage)
        print(f"{enemy['name']} attacks {character['name']} for {enemy_damage} damage!")

        # check if player is defeated
        if character["health"] <= 0:
            print(f"{character['name']} has been defeated!")
            print(f"{enemy['name']} wins the game!")
            exit(0)
        
        print(f"{character['name']} has {character['health']} health remaining.")
    else:
        print(f"{enemy['name']} waits and does not attack.")
# Round 1 ends here.


# Check if enemy is defeated
if enemy["health"] <= 0:
    print(f"{enemy['name']} has been defeated!")
    print(f"{character['name']} wins the game!")
    gameplay_data["enemies_defeated"].append(enemy["name"])
    exit()
else:
    print(f"{enemy['name']} is still alive with {enemy['health']} health remaining.")



# Display battle statistics
print(f"\n--- BATTLE STATISTICS ---")
print(f"Total damage dealt by {character['name']}: {sum(gameplay_data['damage_dealt'])}")
print(f"Total damage taken by {character['name']}: {sum(gameplay_data['damage_taken'])}")
print(f"Enemies defeated: {len(gameplay_data['enemies_defeated'])}")
if len(gameplay_data['enemies_defeated']) > 0:
    print(f"Defeated enemies: {', '.join(gameplay_data['enemies_defeated'])}")