Spaces:
Sleeping
Sleeping
import gradio as gr | |
import random | |
import time | |
# --- Character Classes --- | |
class Character: | |
"""Base class for all characters in the game.""" | |
def __init__(self, name, max_hp, attack, defense, character_type="neutral"): | |
self.name = name | |
self.max_hp = max_hp | |
self.current_hp = max_hp | |
self.attack = attack | |
self.defense = defense | |
self.character_type = character_type # 'player', 'enemy' | |
def is_alive(self): | |
"""Checks if the character is still alive.""" | |
return self.current_hp > 0 | |
def take_damage(self, damage): | |
"""Calculates and applies damage taken.""" | |
actual_damage = max(0, damage - self.defense) # Defense reduces damage | |
self.current_hp = max(0, self.current_hp - actual_damage) | |
return f"{self.name} took {actual_damage} damage!" | |
def attack_target(self, target): | |
"""Performs an attack on a target.""" | |
# Add randomness, critical hits, and misses! | |
miss_chance = 0.05 # 5% chance to miss | |
if random.random() < miss_chance: | |
return f"{self.name} swings and MISSES {target.name}!" | |
damage = random.randint(self.attack - 2, self.attack + 2) | |
critical_hit = random.random() < 0.1 # 10% chance for a crit | |
if critical_hit: | |
damage = int(damage * 1.5) | |
crit_text = " CRITICAL HIT!" | |
else: | |
crit_text = "" | |
message = f"{self.name} attacks {target.name} for {damage} damage!{crit_text}" | |
message += "\n" + target.take_damage(damage) | |
return message | |
class PlayerCharacter(Character): | |
"""Base class for player characters (Scott or Ramona).""" | |
def __init__(self, name, max_hp, attack, defense): | |
super().__init__(name, max_hp, attack, defense, character_type="player") | |
self.xp = 0 | |
self.level = 1 | |
# Start players with a little spending cash | |
self.money = 25 | |
def gain_xp(self, amount): | |
"""Awards experience points and checks for level up.""" | |
self.xp += amount | |
message = f"You gained {amount} XP!" | |
# Simple level up system: gain a level every 50 XP | |
while self.xp >= self.level * 50: | |
message += "\n" + self.level_up() | |
return message | |
def level_up(self): | |
"""Increases player stats upon leveling up.""" | |
self.level += 1 | |
self.max_hp += 20 | |
self.current_hp = self.max_hp # Fully heal on level up | |
self.attack += 3 | |
self.defense += 1 | |
return f"\n--- {self.name} Leveled Up to Level {self.level}! ---\nNew Stats: HP: {self.max_hp}, Attack: {self.attack}, Defense: {self.defense}" | |
class ScottPilgrim(PlayerCharacter): | |
"""Scott Pilgrim character with his specific stats.""" | |
def __init__(self): | |
super().__init__("Scott Pilgrim", 100, 15, 5) | |
class RamonaFlowers(PlayerCharacter): | |
"""Ramona Flowers character with her specific stats.""" | |
def __init__(self): | |
super().__init__("Ramona Flowers", 110, 17, 4) | |
class EvilEx(Character): | |
"""Base class for enemies (Evil Exes).""" | |
def __init__(self, name, max_hp, attack, defense, xp_reward, money_drop): | |
super().__init__(name, max_hp, attack, defense, character_type="enemy") | |
self.xp_reward = xp_reward | |
self.money_drop = money_drop | |
# --- Game State Management --- | |
class ScottPilgrimGame: | |
"""Manages the entire game state and logic.""" | |
def __init__(self): | |
# Initial game state attributes are set to None or default values | |
self.player = None | |
self.current_enemy = None | |
self.game_active = False | |
self.message_log = [] | |
self.ex_index = 0 | |
self.current_ex_list = [] | |
self.current_game_phase = "start" # "start", "combat", "game_over", "win" | |
# Define Evil Exes for Scott's storyline | |
self.scott_exes = [ | |
("The Chaos Theater", EvilEx("Matthew Patel", 50, 10, 2, 30, 10)), | |
("Casa Loma (outside)", EvilEx("The Twins & Roxy Richter", 70, 12, 3, 50, 20)), | |
("Vegan restaurant", EvilEx("Todd Ingram", 90, 15, 4, 70, 30)), | |
("The Score at Lee's Palace", EvilEx("Envy Adams", 110, 17, 5, 90, 40)), | |
("Final Boss Arena", EvilEx("Gideon Graves", 150, 20, 7, 150, 70)) | |
] | |
# Define Evil Exes for Ramona's storyline (a creative twist!) | |
self.ramona_exes = [ | |
("Scott's Band Practice", EvilEx("Kim Pine", 55, 11, 3, 35, 12)), | |
("A Bar on Bloor", EvilEx("Knives Chau", 75, 13, 4, 55, 22)), | |
("Scott's Apartment", EvilEx("Lisa Miller", 95, 16, 5, 75, 32)), | |
("The Club Scene", EvilEx("Stacey Pilgrim", 115, 18, 6, 95, 42)), # Fictional boss | |
("The Subspace Highway", EvilEx("Young Neil", 160, 21, 8, 160, 75)) # Fictional final boss | |
] | |
# --- In-game shop items --- | |
self.shop_items = { | |
"Energy Drink": {"cost": 10, "type": "heal", "amount": 20, "desc": "Restore 20 HP"}, | |
"Protein Bar": {"cost": 15, "type": "attack", "amount": 2, "desc": "+2 Attack"}, | |
"Armor Patch": {"cost": 15, "type": "defense", "amount": 1, "desc": "+1 Defense"}, | |
"Max Potion": {"cost": 20, "type": "heal_full", "amount": 0, "desc": "Fully heal"}, | |
} | |
def reset_game(self): | |
"""Resets the game state for a new playthrough.""" | |
self.__init__() # Re-initialize the game object to its default state | |
def start_new_game(self, character_choice): | |
"""Initializes a new game based on character choice.""" | |
self.reset_game() # Ensure a clean slate | |
if character_choice == "Play as Scott Pilgrim": | |
self.player = ScottPilgrim() | |
self.current_ex_list = list(self.scott_exes) # Use a copy of the list | |
self.add_message("You chose to play as Scott Pilgrim! Get ready to face Ramona's Evil Exes!") | |
# Image assets for Scott | |
self.image_assets = { | |
"idle": ["characters/Scott_idle.webp"], | |
"attack": ["characters/Scott_Attack.webp", "characters/Scott_angry.webp"], | |
"run": ["characters/Scott_angry.webp"] | |
} | |
self.player_image_path = self.image_assets["idle"][0] | |
# Prepare cycling list | |
self.image_cycle = self.image_assets["idle"] + self.image_assets["attack"] + self.image_assets["run"] | |
self.image_cycle_index = 0 | |
elif character_choice == "Play as Ramona Flowers": | |
self.player = RamonaFlowers() | |
self.current_ex_list = list(self.ramona_exes) # Use a copy of the list | |
self.add_message("You chose to play as Ramona Flowers! It's time to deal with Scott's complicated past!") | |
# Image assets for Ramona | |
self.image_assets = { | |
"idle": ["characters/Ramona_Angry.webp"], | |
"attack": ["characters/Ramona_attack.webp", "characters/Ramona_attack2.webp"], | |
"run": ["characters/Ramona_attack2.webp"] | |
} | |
self.player_image_path = self.image_assets["idle"][0] | |
# Prepare cycling list | |
self.image_cycle = self.image_assets["idle"] + self.image_assets["attack"] + self.image_assets["run"] | |
self.image_cycle_index = 0 | |
else: | |
self.add_message("Invalid character choice. Please select Scott or Ramona.") | |
return | |
self.game_active = True | |
self.current_game_phase = "combat" | |
self._set_next_enemy() | |
def _set_next_enemy(self): | |
"""Sets the next enemy from the list for the current battle.""" | |
if self.ex_index < len(self.current_ex_list): | |
location, enemy_data = self.current_ex_list[self.ex_index] | |
# Create a new instance of the enemy so their HP is fresh for each battle | |
self.current_enemy = EvilEx(enemy_data.name, enemy_data.max_hp, enemy_data.attack, enemy_data.defense, enemy_data.xp_reward, enemy_data.money_drop) | |
self.add_message(f"\n--- Confronting {self.current_enemy.name} at {location}! ---") | |
else: | |
self.current_enemy = None # No more enemies | |
def add_message(self, message): | |
"""Adds a message to the game log for display.""" | |
self.message_log.append(message) | |
# Keep the log from getting too long for the display | |
if len(self.message_log) > 20: | |
self.message_log = self.message_log[-20:] | |
def get_status_text(self): | |
"""Returns a formatted string of current player and enemy status.""" | |
if not self.player: | |
return "" | |
status = f"{self.player.name} (Lvl {self.player.level}) | HP: {self.player.current_hp}/{self.player.max_hp} | XP: {self.player.xp}/{self.player.level * 50}" | |
status += f" | $: {self.player.money}" | |
if self.current_enemy: | |
status += f"\n{self.current_enemy.name} | HP: {self.current_enemy.current_hp}/{self.current_enemy.max_hp}" | |
return status | |
def _check_game_over(self): | |
"""Checks for and handles the player's defeat.""" | |
if not self.player.is_alive(): | |
self.game_active = False | |
self.current_game_phase = "game_over" | |
self.add_message(f"\n{self.player.name} has been defeated by {self.current_enemy.name}!") | |
self.add_message("Game Over!") | |
def _handle_victory(self): | |
"""Handles the logic after defeating an enemy.""" | |
self.add_message(f"\n{self.current_enemy.name} has been defeated!") | |
self.add_message(self.player.gain_xp(self.current_enemy.xp_reward)) | |
self.player.money += self.current_enemy.money_drop | |
self.add_message(f"You found ${self.current_enemy.money_drop}!") | |
self.ex_index += 1 | |
# Check for game completion | |
if self.ex_index >= len(self.current_ex_list): | |
self.game_active = False | |
self.current_game_phase = "win" | |
self.add_message("\nCongratulations! You have defeated all Evil Exes!") | |
self.add_message("You have truly earned the power of self-respect!") | |
else: | |
# Set up the next fight | |
self._set_next_enemy() | |
def player_attack_turn(self): | |
"""Handles the full sequence of a player's attack turn.""" | |
if not self.game_active or not self.player.is_alive() or not self.current_enemy or not self.current_enemy.is_alive(): | |
return | |
# Switch to attack animation | |
self.set_player_image("attack") | |
# Player attacks the enemy | |
self.add_message(self.player.attack_target(self.current_enemy)) | |
# Check if enemy was defeated | |
if not self.current_enemy.is_alive(): | |
self._handle_victory() | |
return # Stop the turn here since the battle is over or a new one is starting | |
# If the enemy survived, it's their turn to attack | |
self.enemy_attack_turn() | |
def enemy_attack_turn(self): | |
"""Handles the enemy's attack turn.""" | |
if not self.game_active or not self.player.is_alive() or not self.current_enemy or not self.current_enemy.is_alive(): | |
return | |
# Enemy attacks the player | |
self.add_message(self.current_enemy.attack_target(self.player)) | |
# Check if the player was defeated | |
self._check_game_over() | |
def attempt_run(self): | |
"""Handles the player's attempt to run away from a battle.""" | |
if not self.game_active: | |
return | |
# Switch to run/escape animation | |
self.set_player_image("run") | |
if random.random() < 0.2: # 20% chance to successfully run | |
self.add_message("You managed to escape from the battle!") | |
self.ex_index += 1 # Advance to the next foe after running | |
if self.ex_index < len(self.current_ex_list): | |
self._set_next_enemy() | |
else: # Running from the last boss is still a win condition | |
self.game_active = False | |
self.current_game_phase = "win" | |
self.add_message("\nCongratulations! You ran away from all your problems!") | |
else: | |
self.add_message("You couldn't get away!") | |
# Enemy gets a free hit as a penalty for a failed escape | |
self.enemy_attack_turn() | |
# --------- Shop Mechanics --------- | |
def get_shop_choices(self): | |
"""Return list of item names with price for UI dropdown.""" | |
return [f"{name} - ${data['cost']}" for name, data in self.shop_items.items()] | |
def get_shop_description_html(self): | |
"""Return an HTML string listing shop items with cost and effect.""" | |
lines = ["<b>Shop Items:</b>"] | |
for name, data in self.shop_items.items(): | |
lines.append(f"{name} (${data['cost']}) β {data['desc']}") | |
return "<br>".join(lines) | |
def buy_item(self, item_display_name): | |
"""Process purchasing an item given the dropdown display string.""" | |
if not self.player: | |
return | |
# Extract item key (before ' - $') | |
item_name = item_display_name.split(" - $")[0] | |
if item_name not in self.shop_items: | |
self.add_message("That item doesn't exist!") | |
return | |
item = self.shop_items[item_name] | |
cost = item["cost"] | |
if self.player.money < cost: | |
self.add_message("Not enough money!") | |
return | |
# Deduct money | |
self.player.money -= cost | |
# Apply item effect | |
if item["type"] == "heal": | |
self.player.current_hp = min(self.player.max_hp, self.player.current_hp + item["amount"]) | |
self.add_message(f"You used {item_name} and healed {item['amount']} HP!") | |
elif item["type"] == "heal_full": | |
healed = self.player.max_hp - self.player.current_hp | |
self.player.current_hp = self.player.max_hp | |
self.add_message(f"{item_name} fully restored your HP (+{healed})!") | |
elif item["type"] == "attack": | |
self.player.attack += item["amount"] | |
self.add_message(f"{item_name} consumed! Attack increased by {item['amount']}.") | |
elif item["type"] == "defense": | |
self.player.defense += item["amount"] | |
self.add_message(f"{item_name} equipped! Defense increased by {item['amount']}.") | |
# Confirm purchase | |
self.add_message(f"You bought {item_name} for ${cost}. Remaining money: ${self.player.money}.") | |
def get_player_image(self): | |
"""Returns current player image path if set.""" | |
return getattr(self, "player_image_path", None) | |
# --------- Image switching ---------- | |
def set_player_image(self, action="idle"): | |
"""Set player image based on action type ('idle', 'attack', 'run').""" | |
if hasattr(self, "image_assets") and action in self.image_assets: | |
choices = self.image_assets[action] | |
self.player_image_path = random.choice(choices) | |
def advance_player_image(self): | |
"""Cycle through all available images sequentially.""" | |
if hasattr(self, "image_cycle") and self.image_cycle: | |
self.image_cycle_index = (self.image_cycle_index + 1) % len(self.image_cycle) | |
self.player_image_path = self.image_cycle[self.image_cycle_index] | |
# --- Gradio Interface Logic --- | |
# Create a single game instance to be managed by gr.State | |
game_instance = ScottPilgrimGame() | |
# New helper to format the message log into color-coded HTML | |
def _format_log_html(game_state): | |
"""Returns the message log as HTML with basic color-coding.""" | |
# Early return for fresh page load | |
if not game_state.message_log: | |
return "" | |
player_name = game_state.player.name if game_state.player else "" | |
enemy_name = game_state.current_enemy.name if game_state.current_enemy else "" | |
# Show only the last 2 log messages: latest action and its outcome | |
recent_msgs = game_state.message_log[-2:] | |
html_lines = [] | |
for msg in recent_msgs: | |
color = "#ECF0F1" # default light text for dark mode | |
if player_name and (msg.startswith(player_name) or msg.startswith("You")): | |
color = "#2ECC71" # green for player actions | |
elif enemy_name and msg.startswith(enemy_name): | |
color = "#E74C3C" # red for enemy actions | |
elif msg.startswith("---") or "Congratulations" in msg or "GAME OVER" in msg: | |
color = "#F1C40F" # yellow/gold for system or banner messages | |
html_lines.append(f"<div style='margin-bottom:4px; color:{color}; white-space:pre-wrap;'>{msg}</div>") | |
return "".join(html_lines) | |
# New helper to build a richer status block with progress bars for HP/XP | |
def _format_status_html(game_state): | |
if not game_state.player: | |
return "" | |
player = game_state.player | |
status_html = [ | |
f"<h4 style='margin:4px 0'>{player.name} (Lvl {player.level})</h4>", | |
f"HP: <progress value='{player.current_hp}' max='{player.max_hp}' style='width:160px;height:14px;'></progress> {player.current_hp}/{player.max_hp}<br>", | |
f"XP: <progress value='{player.xp}' max='{player.level * 50}' style='width:160px;height:14px;'></progress> {player.xp}/{player.level * 50}<br>", | |
f"Money: ${player.money}<br><br>" | |
] | |
if game_state.current_enemy: | |
enemy = game_state.current_enemy | |
status_html.extend([ | |
f"<h4 style='margin:4px 0'>{enemy.name}</h4>", | |
f"HP: <progress value='{enemy.current_hp}' max='{enemy.max_hp}' style='width:160px;height:14px;'></progress> {enemy.current_hp}/{enemy.max_hp}" | |
]) | |
return "".join(status_html) | |
# Updated update_ui to output HTML instead of plain text | |
def update_ui(game_state): | |
""" | |
Updates all Gradio UI components based on the current game state. | |
Returns log_html, status_html, and component visibility updates. | |
""" | |
phase = game_state.current_game_phase | |
# Special welcome / end-screen messages | |
if phase == "start" and not game_state.message_log: | |
game_state.message_log = [ | |
"Welcome to Scott Pilgrim vs. The World: The RPG!", | |
"Choose your hero!" | |
] | |
elif phase == "game_over": | |
game_state.message_log.append("\nGAME OVER! Better luck next time!") | |
elif phase == "win": | |
game_state.message_log.append("\nYOU WON! Thanks for playing!") | |
# Cycle hero image each UI update | |
game_state.advance_player_image() | |
log_html = _format_log_html(game_state) | |
status_html = _format_status_html(game_state) | |
hero_img_path = game_state.get_player_image() | |
hero_visible = hero_img_path is not None | |
# Configure visibility for each game phase | |
char_buttons_visible = (phase == "start") | |
action_buttons_visible = (phase == "combat") | |
play_again_visible = (phase in ["game_over", "win"]) | |
shop_visible = (phase == "combat") | |
preview_visible = (phase == "start") | |
return ( | |
log_html, | |
status_html, | |
gr.update(value=hero_img_path, visible=hero_visible), | |
gr.update(visible=preview_visible), # Scott preview | |
gr.update(visible=preview_visible), # Ramona preview | |
gr.update(visible=char_buttons_visible), | |
gr.update(visible=action_buttons_visible), | |
gr.update(visible=play_again_visible), | |
gr.update(visible=shop_visible), | |
game_state | |
) | |
def choose_character(choice, game_state): | |
"""Callback for character selection buttons.""" | |
game_state.start_new_game(choice) | |
return update_ui(game_state) | |
def player_attack(game_state): | |
"""Callback for the 'Attack' button.""" | |
game_state.player_attack_turn() | |
return update_ui(game_state) | |
def player_run(game_state): | |
"""Callback for the 'Run' button.""" | |
game_state.attempt_run() | |
return update_ui(game_state) | |
def play_again(game_state): | |
"""Callback for the 'Play Again' button.""" | |
game_state.reset_game() | |
return update_ui(game_state) | |
# --- Shop callback --- | |
def buy_item_action(item_choice, game_state): | |
"""Callback for Buy button in the shop.""" | |
if item_choice: | |
game_state.buy_item(item_choice) | |
else: | |
game_state.add_message("Select an item first!") | |
return update_ui(game_state) | |
# --- Gradio UI Layout --- | |
with gr.Blocks(theme=gr.themes.Soft()) as demo: | |
gr.Markdown("# Scott Pilgrim vs. The World: The RPG") | |
gr.Markdown("Choose your fighter and battle through the league of Evil Exes!") | |
# gr.State is crucial for persisting the game object across user interactions | |
game_state = gr.State(game_instance) | |
# UI Components | |
# Row containing hero image next to the game log | |
with gr.Row() as log_row: | |
hero_img = gr.Image(label="Hero", width=120, height=120, visible=False) | |
game_output = gr.HTML(label="Game Log") | |
status_output = gr.HTML(label="Current Status") | |
# Preview images shown before character selection | |
with gr.Row(visible=True) as preview_row: | |
scott_preview_img = gr.Image(value="characters/Scott_idle.webp", label="Scott", width=140, height=140, visible=True) | |
ramona_preview_img = gr.Image(value="characters/Ramona_Angry.webp", label="Ramona", width=140, height=140, visible=True) | |
with gr.Row(visible=True) as character_selection_buttons: | |
scott_btn = gr.Button("Play as Scott Pilgrim") | |
ramona_btn = gr.Button("Play as Ramona Flowers") | |
with gr.Row(visible=False) as game_action_buttons: | |
attack_btn = gr.Button("βοΈ Attack") | |
run_btn = gr.Button("π Run") | |
play_again_btn = gr.Button("Play Again?", visible=False) | |
# --- Shop UI Components --- | |
with gr.Row(visible=False) as shop_row: | |
shop_desc = gr.HTML(game_instance.get_shop_description_html()) | |
item_dropdown = gr.Dropdown(choices=game_instance.get_shop_choices(), label="Buy Item") | |
buy_btn = gr.Button("Buy") | |
# --- Event Handlers --- | |
# Define a list of all UI components that need to be updated, including the state | |
outputs = [game_output, status_output, hero_img, scott_preview_img, ramona_preview_img, character_selection_buttons, game_action_buttons, play_again_btn, shop_row, game_state] | |
# Connect buttons to their callback functions | |
scott_btn.click(choose_character, inputs=[scott_btn, game_state], outputs=outputs) | |
ramona_btn.click(choose_character, inputs=[ramona_btn, game_state], outputs=outputs) | |
attack_btn.click(player_attack, inputs=[game_state], outputs=outputs) | |
run_btn.click(player_run, inputs=[game_state], outputs=outputs) | |
play_again_btn.click(play_again, inputs=[game_state], outputs=outputs) | |
buy_btn.click(buy_item_action, inputs=[item_dropdown, game_state], outputs=outputs) | |
# Set the initial UI state when the app loads, ensuring the outputs match the function's return values | |
demo.load(update_ui, inputs=[game_state], outputs=outputs) | |
# Launch the Gradio app | |
if __name__ == "__main__": | |
demo.launch() | |