|
|
The application below implements an accessible text-based English fighting game featuring state management, a leveling system, inventory, permanent stat increases via a shop, and a persistent online leaderboard (simulated via local JSON storage). |
|
|
|
|
|
The core interaction for the blind user relies on clear text output (suitable for screen readers) and simple text inputs (A, D, I, S) for actions. |
|
|
|
|
|
|
|
|
|
|
|
```python |
|
|
import gradio as gr |
|
|
import dataclasses |
|
|
import json |
|
|
import os |
|
|
import time |
|
|
import random |
|
|
from typing import List, Tuple, Dict, Any, Union |
|
|
|
|
|
|
|
|
|
|
|
@dataclasses.dataclass |
|
|
class Player: |
|
|
name: str = "Player" |
|
|
level: int = 1 |
|
|
xp: int = 0 |
|
|
gold: int = 10 |
|
|
hp: int = 100 |
|
|
max_hp: int = 100 |
|
|
attack: int = 15 |
|
|
defense: int = 5 |
|
|
inventory: Dict[str, int] = dataclasses.field(default_factory=lambda: {"Potion": 1}) |
|
|
wins: int = 0 |
|
|
losses: int = 0 |
|
|
in_combat: bool = False |
|
|
|
|
|
@dataclasses.dataclass |
|
|
class Enemy: |
|
|
name: str |
|
|
hp: int |
|
|
attack: int |
|
|
defense: int |
|
|
|
|
|
|
|
|
|
|
|
LEADERBOARD_FILE = "leaderboard.json" |
|
|
SHOP_ITEMS = { |
|
|
"Potion": {"cost": 5, "effect": "Heals 50 HP", "value": 50, "type": "consumable"}, |
|
|
"Strength_Tonic": {"cost": 15, "effect": "Increases Attack by 5 permanently", "value": 5, "type": "permanent"}, |
|
|
"Armor_Polish": {"cost": 15, "effect": "Increases Defense by 3 permanently", "value": 3, "type": "permanent"}, |
|
|
} |
|
|
|
|
|
|
|
|
SUCCESS_AUDIO = "https://huggingface.co/datasets/gradio/test-files/resolve/main/audio.wav" |
|
|
FAILURE_AUDIO = "https://huggingface.co/datasets/Xenova/be-my-guide-voice/resolve/main/audio.mp3" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def player_to_dict(player: Player) -> Dict[str, Any]: |
|
|
"""Converts a Player object to a dictionary for JSON serialization.""" |
|
|
return dataclasses.asdict(player) |
|
|
|
|
|
def player_from_dict(d: Dict[str, Any]) -> Player: |
|
|
"""Converts a dictionary back to a Player object.""" |
|
|
return Player(**d) |
|
|
|
|
|
|
|
|
|
|
|
def load_leaderboard() -> List[Dict]: |
|
|
"""Loads leaderboard data from JSON file.""" |
|
|
if os.path.exists(LEADERBOARD_FILE): |
|
|
with open(LEADERBOARD_FILE, 'r') as f: |
|
|
try: |
|
|
data = json.load(f) |
|
|
return sorted(data, key=lambda x: x['level'] * 1000 + x['xp'], reverse=True) |
|
|
except json.JSONDecodeError: |
|
|
return [] |
|
|
return [] |
|
|
|
|
|
def save_leaderboard(player_data: Dict): |
|
|
"""Saves/updates player data in the leaderboard.""" |
|
|
leaderboard = load_leaderboard() |
|
|
|
|
|
player_found = False |
|
|
for i, entry in enumerate(leaderboard): |
|
|
if entry['name'] == player_data['name']: |
|
|
leaderboard[i] = player_data |
|
|
player_found = True |
|
|
break |
|
|
|
|
|
if not player_found: |
|
|
leaderboard.append(player_data) |
|
|
|
|
|
leaderboard = sorted(leaderboard, key=lambda x: x['level'] * 1000 + x['xp'], reverse=True) |
|
|
|
|
|
with open(LEADERBOARD_FILE, 'w') as f: |
|
|
json.dump(leaderboard, f, indent=4) |
|
|
|
|
|
|
|
|
|
|
|
def create_enemy(player_level: int) -> Enemy: |
|
|
"""Creates an enemy scaled to the player's level.""" |
|
|
scaling = 1 + (player_level * 0.5) |
|
|
name = random.choice(["Ogre", "Goblin Chief", "Shadow Walker", "Vampire Bat"]) |
|
|
return Enemy( |
|
|
name=name, |
|
|
hp=int(50 * scaling), |
|
|
attack=int(10 * scaling), |
|
|
defense=int(3 * scaling) |
|
|
) |
|
|
|
|
|
def xp_to_next_level(level: int) -> int: |
|
|
return level * 100 |
|
|
|
|
|
def check_level_up(player: Player) -> Tuple[Player, str]: |
|
|
"""Checks if the player leveled up and updates stats.""" |
|
|
message = "" |
|
|
while player.xp >= xp_to_next_level(player.level): |
|
|
player.xp -= xp_to_next_level(player.level) |
|
|
player.level += 1 |
|
|
|
|
|
|
|
|
player.max_hp += 15 |
|
|
player.attack += 3 |
|
|
player.defense += 1 |
|
|
player.hp = player.max_hp |
|
|
message += f"\nLEVEL UP! You are now Level {player.level}! Stats increased and HP restored. You feel stronger." |
|
|
return player, message |
|
|
|
|
|
def get_player_status(player: Player) -> str: |
|
|
"""Generates a detailed player status string for screen readers.""" |
|
|
status = f"--- Player Status ---\n" |
|
|
status += f"Name: {player.name} | Level: {player.level}\n" |
|
|
status += f"HP: {player.hp}/{player.max_hp}\n" |
|
|
status += f"Attack: {player.attack} | Defense: {player.defense}\n" |
|
|
status += f"XP: {player.xp}/{xp_to_next_level(player.level)} | Gold: {player.gold}\n" |
|
|
status += f"Inventory: {', '.join([f'{k} ({v})' for k, v in player.inventory.items() if v > 0])}\n" |
|
|
return status |
|
|
|
|
|
def get_enemy_status(enemy: Enemy) -> str: |
|
|
"""Generates a detailed enemy status string.""" |
|
|
return f"--- Enemy: {enemy.name} ---\nHP: {enemy.hp}\nAttack: {enemy.attack} | Defense: {enemy.defense}\n" |
|
|
|
|
|
def start_adventure(name: str, player_state_json: str) -> Tuple[str, str, str, gr.update, gr.update, str, str, gr.update, Dict]: |
|
|
"""Initializes a new combat session.""" |
|
|
|
|
|
if not name: |
|
|
return "Please enter a name to begin your adventure.", "", player_state_json, gr.update(visible=True), gr.update(visible=False), None, FAILURE_AUDIO, gr.update(placeholder="Enter name to start adventure..."), {"visible": True} |
|
|
|
|
|
|
|
|
leaderboard_data = load_leaderboard() |
|
|
existing_player_data = next((p for p in leaderboard_data if p['name'] == name), None) |
|
|
|
|
|
if existing_player_data: |
|
|
player = player_from_dict(existing_player_data) |
|
|
player.hp = player.max_hp |
|
|
else: |
|
|
player = Player(name=name) |
|
|
|
|
|
enemy = create_enemy(player.level) |
|
|
player.in_combat = True |
|
|
|
|
|
welcome_message = f"Welcome, {name}! Your level is {player.level}. A fearsome {enemy.name} stands in your way!" |
|
|
|
|
|
player_status = get_player_status(player) |
|
|
enemy_status = get_enemy_status(enemy) |
|
|
|
|
|
game_log = f"{welcome_message}\n\n{enemy_status}{player_status}" |
|
|
action_prompt = "What do you do? (A: Attack, D: Defend, I: Use Item, S: Status)" |
|
|
|
|
|
new_player_state_json = json.dumps(player_to_dict(player)) |
|
|
new_enemy_state_json = json.dumps(dataclasses.asdict(enemy)) |
|
|
|
|
|
return ( |
|
|
game_log, |
|
|
action_prompt, |
|
|
new_player_state_json, |
|
|
gr.update(visible=False), |
|
|
gr.update(visible=True, placeholder="A/D/I/S"), |
|
|
new_enemy_state_json, |
|
|
SUCCESS_AUDIO, |
|
|
gr.update(placeholder="A/D/I/S"), |
|
|
{"visible": True, "value": get_player_status(player)} |
|
|
) |
|
|
|
|
|
def process_action(action_text: str, player_state_json: str, enemy_state_json: str) -> Tuple[str, str, str, str, str, str, gr.update]: |
|
|
"""Handles player input and performs one turn of combat.""" |
|
|
|
|
|
player = player_from_dict(json.loads(player_state_json)) |
|
|
enemy = Enemy(**json.loads(enemy_state_json)) |
|
|
|
|
|
action = action_text.strip().upper() |
|
|
log = [] |
|
|
audio_path = SUCCESS_AUDIO |
|
|
action_prompt = "What do you do next? (A: Attack, D: Defend, I: Use Item, S: Status)" |
|
|
|
|
|
if not player.in_combat: |
|
|
log.append("You are not currently in combat. Please start an adventure first.") |
|
|
return "\n".join(log), "A/D/I/S", player_state_json, enemy_state_json, get_player_status(player), FAILURE_AUDIO, gr.update(placeholder="A/D/I/S") |
|
|
|
|
|
|
|
|
player_attacked = False |
|
|
|
|
|
if action == "A": |
|
|
damage = max(1, player.attack - enemy.defense) |
|
|
enemy.hp -= damage |
|
|
log.append(f"You attack the {enemy.name} for {damage} damage!") |
|
|
player_attacked = True |
|
|
|
|
|
elif action == "D": |
|
|
log.append(f"You take a defensive stance, ready to mitigate damage.") |
|
|
player_attacked = True |
|
|
|
|
|
elif action == "I": |
|
|
if player.inventory.get("Potion", 0) > 0: |
|
|
player.inventory["Potion"] -= 1 |
|
|
heal = 50 |
|
|
player.hp = min(player.max_hp, player.hp + heal) |
|
|
log.append(f"You use a Potion and heal for 50 HP. Current HP: {player.hp}") |
|
|
player_attacked = True |
|
|
else: |
|
|
log.append("You have no potions! Choose another action.") |
|
|
audio_path = FAILURE_AUDIO |
|
|
player_attacked = False |
|
|
|
|
|
elif action == "S": |
|
|
log.append("You check your surroundings.") |
|
|
log.append(get_enemy_status(enemy)) |
|
|
log.append(get_player_status(player)) |
|
|
|
|
|
|
|
|
new_player_state_json = json.dumps(player_to_dict(player)) |
|
|
new_enemy_state_json = json.dumps(dataclasses.asdict(enemy)) |
|
|
return "\n".join(log), action_prompt, new_player_state_json, new_enemy_state_json, get_player_status(player), audio_path, gr.update(placeholder="A/D/I/S") |
|
|
|
|
|
else: |
|
|
log.append("Invalid action. Use A (Attack), D (Defend), I (Item), or S (Status).") |
|
|
audio_path = FAILURE_AUDIO |
|
|
player_attacked = False |
|
|
|
|
|
|
|
|
if enemy.hp <= 0 and player_attacked: |
|
|
player.wins += 1 |
|
|
xp_gain = enemy.attack * 5 |
|
|
gold_gain = enemy.defense * 2 |
|
|
|
|
|
player.xp += xp_gain |
|
|
player.gold += gold_gain |
|
|
|
|
|
log.append(f"\nVICTORY! The {enemy.name} is defeated!") |
|
|
log.append(f"You gained {xp_gain} XP and {gold_gain} Gold.") |
|
|
|
|
|
player, level_message = check_level_up(player) |
|
|
log.append(level_message) |
|
|
|
|
|
save_leaderboard(player_to_dict(player)) |
|
|
|
|
|
player.in_combat = False |
|
|
action_prompt = "Combat ended. Go to the Adventure tab to start a new fight." |
|
|
audio_path = SUCCESS_AUDIO |
|
|
|
|
|
new_player_state_json = json.dumps(player_to_dict(player)) |
|
|
new_enemy_state_json = json.dumps(dataclasses.asdict(enemy)) |
|
|
|
|
|
return "\n".join(log), action_prompt, new_player_state_json, new_enemy_state_json, get_player_status(player), audio_path, gr.update(placeholder="Game Over") |
|
|
|
|
|
|
|
|
if player_attacked: |
|
|
|
|
|
|
|
|
enemy_damage = max(1, enemy.attack - player.defense) |
|
|
|
|
|
if action == "D": |
|
|
|
|
|
enemy_damage = max(1, enemy_damage // 2) |
|
|
log.append(f"(Defense successful!)") |
|
|
|
|
|
player.hp -= enemy_damage |
|
|
log.append(f"The {enemy.name} strikes back, dealing {enemy_damage} damage.") |
|
|
|
|
|
|
|
|
if player.hp <= 0: |
|
|
player.losses += 1 |
|
|
log.append("\nDEFEAT! You were overwhelmed and knocked out.") |
|
|
log.append("You lost 5 gold. Your HP is restored for next time.") |
|
|
player.gold = max(0, player.gold - 5) |
|
|
player.hp = player.max_hp |
|
|
|
|
|
save_leaderboard(player_to_dict(player)) |
|
|
|
|
|
player.in_combat = False |
|
|
action_prompt = "Combat ended. Go to the Adventure tab to start a new fight." |
|
|
audio_path = FAILURE_AUDIO |
|
|
|
|
|
new_player_state_json = json.dumps(player_to_dict(player)) |
|
|
new_enemy_state_json = json.dumps(dataclasses.asdict(enemy)) |
|
|
|
|
|
return "\n".join(log), action_prompt, new_player_state_json, new_enemy_state_json, get_player_status(player), audio_path, gr.update(placeholder="Game Over") |
|
|
|
|
|
|
|
|
log.append(f"\n{get_enemy_status(enemy)}{get_player_status(player)}") |
|
|
|
|
|
new_player_state_json = json.dumps(player_to_dict(player)) |
|
|
new_enemy_state_json = json.dumps(dataclasses.asdict(enemy)) |
|
|
|
|
|
return "\n".join(log), action_prompt, new_player_state_json, new_enemy_state_json, get_player_status(player), audio_path, gr.update(placeholder="A/D/I/S") |
|
|
|
|
|
|
|
|
|
|
|
def display_shop(player_state_json: str) -> Tuple[str, str, str]: |
|
|
"""Generates the shop interface content.""" |
|
|
player = player_from_dict(json.loads(player_state_json)) |
|
|
|
|
|
shop_display = "--- Merchant's Wares ---\n" |
|
|
shop_display += f"Your Gold: {player.gold}\n\n" |
|
|
|
|
|
options = [] |
|
|
|
|
|
for i, (item_name, details) in enumerate(SHOP_ITEMS.items()): |
|
|
shop_display += f"[{i+1}] {item_name}: Cost {details['cost']} Gold. Effect: {details['effect']}\n" |
|
|
options.append(str(i+1)) |
|
|
|
|
|
shop_display += "\nEnter the number of the item you wish to buy." |
|
|
|
|
|
return shop_display, json.dumps(options), get_player_status(player) |
|
|
|
|
|
def purchase_item(selection_num: str, player_state_json: str, shop_options_json: str) -> Tuple[str, str, str, str]: |
|
|
"""Handles item purchase logic.""" |
|
|
player = player_from_dict(json.loads(player_state_json)) |
|
|
shop_options = json.loads(shop_options_json) |
|
|
|
|
|
try: |
|
|
selection_index = int(selection_num) - 1 |
|
|
if selection_index < 0 or selection_index >= len(SHOP_ITEMS): |
|
|
raise ValueError |
|
|
except ValueError: |
|
|
return display_shop(player_state_json)[0], "Invalid selection. Please enter a valid item number.", player_state_json, FAILURE_AUDIO |
|
|
|
|
|
item_name = list(SHOP_ITEMS.keys())[selection_index] |
|
|
item_details = SHOP_ITEMS[item_name] |
|
|
cost = item_details["cost"] |
|
|
|
|
|
if player.gold < cost: |
|
|
return display_shop(player_state_json)[0], f"You do not have enough gold to buy {item_name}. Needs {cost} gold.", player_state_json, FAILURE_AUDIO |
|
|
|
|
|
|
|
|
player.gold -= cost |
|
|
|
|
|
item_type = item_details['type'] |
|
|
feedback = "" |
|
|
|
|
|
if item_type == "consumable": |
|
|
player.inventory[item_name] = player.inventory.get(item_name, 0) + 1 |
|
|
feedback = f"You successfully bought one {item_name} for {cost} gold. It is added to your inventory." |
|
|
elif item_type == "permanent": |
|
|
value = item_details['value'] |
|
|
if item_name == "Strength_Tonic": |
|
|
player.attack += value |
|
|
feedback = f"You feel stronger! Attack increased by {value} permanently." |
|
|
elif item_name == "Armor_Polish": |
|
|
player.defense += value |
|
|
feedback = f"Your armor shines! Defense increased by {value} permanently." |
|
|
|
|
|
save_leaderboard(player_to_dict(player)) |
|
|
new_player_state_json = json.dumps(player_to_dict(player)) |
|
|
|
|
|
shop_display, _, _ = display_shop(new_player_state_json) |
|
|
|
|
|
return shop_display, feedback, new_player_state_json, SUCCESS_AUDIO |
|
|
|
|
|
|
|
|
|
|
|
def get_leaderboard_data() -> str: |
|
|
"""Fetches and formats leaderboard data as Markdown.""" |
|
|
leaderboard = load_leaderboard() |
|
|
|
|
|
if not leaderboard: |
|
|
return "The leaderboard is empty. Start an adventure to gain XP!" |
|
|
|
|
|
table_data = [] |
|
|
|
|
|
for i, entry in enumerate(leaderboard[:10]): |
|
|
table_data.append([ |
|
|
i + 1, |
|
|
entry.get('name', 'N/A'), |
|
|
entry.get('level', 1), |
|
|
entry.get('xp', 0), |
|
|
entry.get('wins', 0), |
|
|
entry.get('losses', 0) |
|
|
]) |
|
|
|
|
|
markdown_table = "### Top 10 Adventurers\n\n" |
|
|
|
|
|
header = ["Rank", "Name", "Level", "XP", "Wins", "Losses"] |
|
|
markdown_table += "| " + " | ".join(header) + " |\n" |
|
|
markdown_table += "| " + " | ".join(["---"] * len(header)) + " |\n" |
|
|
|
|
|
for row in table_data: |
|
|
markdown_table += "| " + " | ".join(map(str, row)) + " |\n" |
|
|
|
|
|
return markdown_table |
|
|
|
|
|
|
|
|
|
|
|
initial_player = Player() |
|
|
INITIAL_PLAYER_STATE = json.dumps(player_to_dict(initial_player)) |
|
|
|
|
|
with gr.Blocks(theme=gr.themes.Soft(), title="Accessible Text RPG") as demo: |
|
|
|
|
|
|
|
|
gr.HTML("<h1 style='text-align: center; color: #4B4B4B;'>Accessible English Fighting Game (Text RPG)</h1>") |
|
|
gr.HTML("<p style='text-align: center;'>Built with <a href='https://huggingface.co/spaces/akhaliq/anycoder' target='_blank'>anycoder</a></p>") |
|
|
|
|
|
|
|
|
player_state = gr.State(value=INITIAL_PLAYER_STATE) |
|
|
enemy_state = gr.State(value=json.dumps(dataclasses.asdict(create_enemy(1)))) |
|
|
shop_options = gr.State(value=json.dumps([])) |
|
|
|
|
|
|
|
|
audio_output = gr.Audio(label="Audio Feedback", visible=False, autoplay=True, show_label=False) |
|
|
|
|
|
with gr.Tabs(): |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.TabItem("Adventure"): |
|
|
|
|
|
gr.Markdown( |
|
|
""" |
|
|
## The Arena of Shadows |
|
|
This game is designed for blind accessibility. Interactions are based on simple text inputs and verbose descriptions. |
|
|
Actions: **A** (Attack), **D** (Defend), **I** (Use Item, Potion only for now), **S** (Status Check). |
|
|
Enter your name below and click 'Start Adventure' to begin combat. |
|
|
""" |
|
|
) |
|
|
|
|
|
|
|
|
game_log = gr.Textbox( |
|
|
label="Game Log and Status", |
|
|
lines=15, |
|
|
interactive=False, |
|
|
autoscroll=True, |
|
|
show_copy_button=True |
|
|
) |
|
|
|
|
|
current_player_status = gr.Textbox( |
|
|
label="Player Status (Quick View)", |
|
|
value=get_player_status(initial_player), |
|
|
interactive=False, |
|
|
lines=5, |
|
|
container=True, |
|
|
) |
|
|
|
|
|
with gr.Row(): |
|
|
player_name_input = gr.Textbox( |
|
|
label="Enter Your Name (Existing player stats will be loaded)", |
|
|
placeholder="Enter name to start adventure...", |
|
|
scale=2 |
|
|
) |
|
|
start_btn = gr.Button("Start Adventure!", variant="primary", scale=1) |
|
|
|
|
|
action_input = gr.Textbox( |
|
|
label="Your Action (A/D/I/S)", |
|
|
placeholder="A/D/I/S", |
|
|
visible=False, |
|
|
scale=3 |
|
|
) |
|
|
|
|
|
|
|
|
start_btn.click( |
|
|
fn=start_adventure, |
|
|
inputs=[player_name_input, player_state], |
|
|
outputs=[game_log, action_input, player_state, start_btn, action_input, enemy_state, audio_output, player_name_input, current_player_status] |
|
|
) |
|
|
|
|
|
|
|
|
action_input.submit( |
|
|
fn=process_action, |
|
|
inputs=[action_input, player_state, enemy_state], |
|
|
outputs=[game_log, action_input, player_state, enemy_state, current_player_status, audio_output, action_input] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.TabItem("Shop"): |
|
|
gr.Markdown("## The Merchant - Spend Your Gold!") |
|
|
|
|
|
shop_status_display = gr.Textbox( |
|
|
label="Shop Inventory", |
|
|
lines=8, |
|
|
interactive=False, |
|
|
autoscroll=True |
|
|
) |
|
|
|
|
|
shop_feedback = gr.Textbox( |
|
|
label="Merchant Feedback", |
|
|
lines=1, |
|
|
interactive=False |
|
|
) |
|
|
|
|
|
with gr.Row(): |
|
|
buy_input = gr.Textbox( |
|
|
label="Item Number to Buy", |
|
|
placeholder="Enter item number (e.g., 1)" |
|
|
) |
|
|
buy_btn = gr.Button("Buy Item", variant="secondary") |
|
|
|
|
|
shop_player_status = gr.Textbox( |
|
|
label="Your Current Stats", |
|
|
interactive=False, |
|
|
lines=5 |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
demo.load( |
|
|
fn=display_shop, |
|
|
inputs=[player_state], |
|
|
outputs=[shop_status_display, shop_options, shop_player_status] |
|
|
) |
|
|
|
|
|
|
|
|
buy_btn.click( |
|
|
fn=purchase_item, |
|
|
inputs=[buy_input, player_state, shop_options], |
|
|
outputs=[shop_status_display, shop_feedback, player_state, audio_output] |
|
|
).then( |
|
|
fn=display_shop, |
|
|
inputs=[player_state], |
|
|
outputs=[shop_status_display, shop_options, shop_player_status] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.TabItem("Leaderboard"): |
|
|
gr.Markdown("## Hall of Heroes (Online Leaderboard)") |
|
|
|
|
|
leaderboard_output = gr.Markdown("Loading leaderboard...") |
|
|
|
|
|
leaderboard_refresh_btn = gr.Button("Refresh Leaderboard", variant="secondary") |
|
|
|
|
|
|
|
|
load_leaderboard_event = demo.load( |
|
|
fn=get_leaderboard_data, |
|
|
outputs=leaderboard_output |
|
|
) |
|
|
leaderboard_refresh_btn.click( |