BREAKING: Changed format_stat_bar() to right-aligned format
- Bars now left-aligned, emoji+label on right
- Works with Telegram's proportional font (spaces don't work)
- Format: {bar} {percentage}% ({current}/{max}) {emoji} {label}
Combat improvements:
- Show BOTH HP bars on every turn (player + enemy)
- Eliminates redundant enemy HP display
- Better tactical awareness with complete state info
Files modified:
- bot/utils.py: Right-aligned format_stat_bar()
- bot/combat.py: Both HP bars on player/enemy turns
- bot/action_handlers.py: Fixed emoji handling
- bot/combat_handlers.py: Updated combat status display
- docs/development/UI_UX_IMPROVEMENTS.md: Complete documentation
Example output:
██████████ 100% (100/100) ❤️ Your HP
███░░░░░░░ 30% (15/50) 🐕 Feral Dog
372 lines
14 KiB
Python
372 lines
14 KiB
Python
"""
|
|
Action handlers for button callbacks.
|
|
This module contains organized handler functions for different types of player actions.
|
|
"""
|
|
import logging
|
|
import json
|
|
import random
|
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
|
from telegram.ext import ContextTypes
|
|
from . import database, keyboards, logic
|
|
from .utils import format_stat_bar
|
|
from data.world_loader import game_world
|
|
from data.items import ITEMS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# UTILITY FUNCTIONS
|
|
# ============================================================================
|
|
|
|
async def get_player_status_text(telegram_id: int) -> str:
|
|
"""Generate player status text with location and stats."""
|
|
player = await database.get_player(telegram_id)
|
|
if not player:
|
|
return "Could not find player data."
|
|
|
|
location = game_world.get_location(player["location_id"])
|
|
if not location:
|
|
return "Error: Player is in an unknown location."
|
|
|
|
inventory = await database.get_inventory(telegram_id)
|
|
weight, volume = logic.calculate_inventory_load(inventory)
|
|
max_weight, max_volume = logic.get_player_capacity(inventory, player)
|
|
|
|
# Get equipped items
|
|
equipped_items = []
|
|
for item in inventory:
|
|
if item.get('is_equipped'):
|
|
item_def = ITEMS.get(item['item_id'], {})
|
|
emoji = item_def.get('emoji', '❔')
|
|
equipped_items.append(f"{emoji} {item_def.get('name', 'Unknown')}")
|
|
|
|
# Build status with visual bars
|
|
status = f"<b>📍 Location:</b> {location.name}\n"
|
|
status += f"{format_stat_bar('HP', '❤️', player['hp'], player['max_hp'])}\n"
|
|
status += f"{format_stat_bar('Stamina', '⚡', player['stamina'], player['max_stamina'])}\n"
|
|
status += f"🎒 <b>Load:</b> {weight}/{max_weight} kg | {volume}/{max_volume} vol\n"
|
|
|
|
if equipped_items:
|
|
status += f"⚔️ <b>Equipped:</b> {', '.join(equipped_items)}\n"
|
|
|
|
status += "━━━━━━━━━━━━━━━━━━━━\n"
|
|
status += f"<i>{location.description}</i>"
|
|
return status
|
|
|
|
|
|
# ============================================================================
|
|
# INSPECTION & WORLD INTERACTION HANDLERS
|
|
# ============================================================================
|
|
|
|
async def handle_inspect_area(query, user_id: int, player: dict, data: list = None):
|
|
"""Handle inspect area action - show NPCs and interactables in current location."""
|
|
await query.answer()
|
|
location_id = player['location_id']
|
|
location = game_world.get_location(location_id)
|
|
dropped_items = await database.get_dropped_items_in_location(location_id)
|
|
wandering_enemies = await database.get_wandering_enemies_in_location(location_id)
|
|
|
|
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
|
image_path = location.image_path if location else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text="You scan the area. You notice...",
|
|
reply_markup=keyboard,
|
|
image_path=image_path
|
|
)
|
|
|
|
|
|
async def handle_attack_wandering(query, user_id: int, player: dict, data: list):
|
|
"""Handle attacking a wandering enemy."""
|
|
enemy_db_id = int(data[1])
|
|
await query.answer()
|
|
|
|
# Get the enemy from database
|
|
wandering_enemies = await database.get_wandering_enemies_in_location(player['location_id'])
|
|
enemy_data = next((e for e in wandering_enemies if e['id'] == enemy_db_id), None)
|
|
|
|
if not enemy_data:
|
|
await query.answer("That enemy has already moved on!", show_alert=True)
|
|
# Refresh inspect menu
|
|
location_id = player['location_id']
|
|
location = game_world.get_location(location_id)
|
|
dropped_items = await database.get_dropped_items_in_location(location_id)
|
|
wandering_enemies = await database.get_wandering_enemies_in_location(location_id)
|
|
keyboard = await keyboards.inspect_keyboard(location_id, dropped_items, wandering_enemies)
|
|
image_path = location.image_path if location else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text="You scan the area. You notice...",
|
|
reply_markup=keyboard,
|
|
image_path=image_path
|
|
)
|
|
return
|
|
|
|
npc_id = enemy_data['npc_id']
|
|
|
|
# Remove enemy from wandering table (they're now in combat)
|
|
await database.remove_wandering_enemy(enemy_db_id)
|
|
|
|
from data.npcs import NPCS
|
|
from bot import combat
|
|
|
|
# Initiate combat
|
|
combat_data = await combat.initiate_combat(
|
|
user_id, npc_id, player['location_id'], from_wandering_enemy=True
|
|
)
|
|
|
|
if combat_data:
|
|
npc_def = NPCS.get(npc_id)
|
|
message = f"⚔️ You engage the {npc_def.emoji} {npc_def.name}!\n\n"
|
|
message += f"{npc_def.description}\n\n"
|
|
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
|
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
|
message += "🎯 Your turn! What will you do?"
|
|
|
|
keyboard = await keyboards.combat_keyboard(user_id)
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text=message,
|
|
reply_markup=keyboard,
|
|
image_path=npc_def.image_url if npc_def else None
|
|
)
|
|
else:
|
|
await query.answer("Failed to initiate combat.", show_alert=True)
|
|
|
|
|
|
async def handle_inspect_interactable(query, user_id: int, player: dict, data: list):
|
|
"""Handle inspecting an interactable object."""
|
|
location_id, instance_id = data[1], data[2]
|
|
|
|
location = game_world.get_location(location_id)
|
|
if not location:
|
|
await query.answer("Location not found.", show_alert=True)
|
|
return
|
|
|
|
interactable = location.get_interactable(instance_id)
|
|
if not interactable:
|
|
await query.answer("Object not found.", show_alert=False)
|
|
return
|
|
|
|
# Check if ALL actions are on cooldown
|
|
all_on_cooldown = True
|
|
for action_id in interactable.actions.keys():
|
|
cooldown_key = f"{instance_id}:{action_id}"
|
|
if await database.get_cooldown(cooldown_key) == 0:
|
|
all_on_cooldown = False
|
|
break
|
|
|
|
if all_on_cooldown and len(interactable.actions) > 0:
|
|
await query.answer(
|
|
f"The {interactable.name} has already been searched. Try again later.",
|
|
show_alert=False
|
|
)
|
|
return
|
|
|
|
# Show action menu
|
|
await query.answer()
|
|
image_path = interactable.image_path if interactable else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text=f"You focus on the {interactable.name}. What do you do?",
|
|
reply_markup=await keyboards.actions_keyboard(location_id, instance_id),
|
|
image_path=image_path
|
|
)
|
|
|
|
|
|
async def handle_action(query, user_id: int, player: dict, data: list):
|
|
"""Handle performing an action on an interactable object."""
|
|
location_id, instance_id, action_id = data[1], data[2], data[3]
|
|
cooldown_key = f"{instance_id}:{action_id}"
|
|
cooldown = await database.get_cooldown(cooldown_key)
|
|
|
|
if cooldown > 0:
|
|
await query.answer("Someone got to it just before you!", show_alert=False)
|
|
return
|
|
|
|
location = game_world.get_location(location_id)
|
|
if not location:
|
|
await query.answer("Location not found.", show_alert=True)
|
|
return
|
|
|
|
action_obj = location.get_interactable(instance_id).get_action(action_id)
|
|
|
|
if player['stamina'] < action_obj.stamina_cost:
|
|
await query.answer("You are too tired to do that!", show_alert=False)
|
|
return
|
|
|
|
await query.answer()
|
|
|
|
# Set cooldown
|
|
await database.set_cooldown(cooldown_key)
|
|
|
|
# Resolve action
|
|
outcome = logic.resolve_action(player, action_obj)
|
|
new_stamina = player['stamina'] - action_obj.stamina_cost
|
|
new_hp = player['hp'] - outcome.damage_taken
|
|
await database.update_player(user_id, {"stamina": new_stamina, "hp": new_hp})
|
|
|
|
# Build detailed action result
|
|
result_details = [f"<i>{outcome.text}</i>"]
|
|
|
|
if action_obj.stamina_cost > 0:
|
|
result_details.append(f"⚡️ <b>Stamina:</b> -{action_obj.stamina_cost}")
|
|
|
|
if outcome.damage_taken > 0:
|
|
result_details.append(f"❤️ <b>HP:</b> -{outcome.damage_taken}")
|
|
|
|
# Add items gained
|
|
if outcome.items_reward:
|
|
items_text = []
|
|
items_failed = []
|
|
for item_id, quantity in outcome.items_reward.items():
|
|
can_add, reason = await logic.can_add_item_to_inventory(user_id, item_id, quantity)
|
|
|
|
if can_add:
|
|
await database.add_item_to_inventory(user_id, item_id, quantity)
|
|
item_def = ITEMS.get(item_id, {})
|
|
emoji = item_def.get('emoji', '❔')
|
|
item_name = item_def.get('name', item_id)
|
|
items_text.append(f"{emoji} {item_name} x{quantity}")
|
|
else:
|
|
item_def = ITEMS.get(item_id, {})
|
|
item_name = item_def.get('name', item_id)
|
|
items_failed.append(f"{item_name} ({reason})")
|
|
|
|
if items_text:
|
|
result_details.append(f"🎁 <b>Gained:</b> {', '.join(items_text)}")
|
|
if items_failed:
|
|
result_details.append(f"⚠️ <b>Couldn't take:</b> {', '.join(items_failed)}")
|
|
|
|
final_text = await get_player_status_text(user_id)
|
|
final_text += f"\n\n<b>━━━ Action Result ━━━</b>\n" + "\n".join(result_details)
|
|
|
|
# Get location image for the result screen
|
|
current_location = game_world.get_location(player['location_id'])
|
|
location_image = current_location.image_path if current_location else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text=final_text,
|
|
reply_markup=keyboards.main_menu_keyboard(),
|
|
image_path=location_image
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# NAVIGATION & MOVEMENT HANDLERS
|
|
# ============================================================================
|
|
|
|
async def handle_main_menu(query, user_id: int, player: dict, data: list = None):
|
|
"""Return to main menu."""
|
|
await query.answer()
|
|
status_text = await get_player_status_text(user_id)
|
|
location = game_world.get_location(player['location_id'])
|
|
location_image = location.image_path if location else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text=status_text,
|
|
reply_markup=keyboards.main_menu_keyboard(),
|
|
image_path=location_image
|
|
)
|
|
|
|
|
|
async def handle_move_menu(query, user_id: int, player: dict, data: list = None):
|
|
"""Show movement options menu."""
|
|
await query.answer()
|
|
location = game_world.get_location(player['location_id'])
|
|
location_image = location.image_path if location else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text="Where do you want to go?",
|
|
reply_markup=await keyboards.move_keyboard(player['location_id'], user_id),
|
|
image_path=location_image
|
|
)
|
|
|
|
|
|
async def handle_move(query, user_id: int, player: dict, data: list):
|
|
"""Handle player movement to a new location."""
|
|
destination_id = data[1]
|
|
|
|
from_location = game_world.get_location(player['location_id'])
|
|
to_location = game_world.get_location(destination_id)
|
|
|
|
if not from_location or not to_location:
|
|
await query.answer("Invalid location!", show_alert=True)
|
|
return
|
|
|
|
# Calculate stamina cost
|
|
inventory = await database.get_inventory(user_id)
|
|
stamina_cost = logic.calculate_travel_stamina_cost(player, inventory, from_location, to_location)
|
|
|
|
if player['stamina'] < stamina_cost:
|
|
await query.answer(f"Too tired to travel! Need {stamina_cost} stamina.", show_alert=True)
|
|
return
|
|
|
|
# Deduct stamina and update location
|
|
new_stamina = player['stamina'] - stamina_cost
|
|
await database.update_player(user_id, {"location_id": destination_id, "stamina": new_stamina})
|
|
|
|
await query.answer(f"⚡️ -{stamina_cost} stamina", show_alert=False)
|
|
|
|
# Refresh player data
|
|
player = await database.get_player(user_id)
|
|
|
|
# Check for random NPC encounter
|
|
from data.npcs import NPCS, get_random_npc_for_location, get_location_encounter_rate
|
|
encounter_rate = get_location_encounter_rate(destination_id)
|
|
|
|
if random.random() < encounter_rate:
|
|
from bot import combat
|
|
logger.info(f"Encounter triggered at {destination_id} (rate: {encounter_rate})")
|
|
|
|
npc_id = get_random_npc_for_location(destination_id)
|
|
|
|
if npc_id:
|
|
combat_data = await combat.initiate_combat(user_id, npc_id, destination_id)
|
|
|
|
if combat_data:
|
|
npc_def = NPCS.get(npc_id)
|
|
message = f"⚠️ A {npc_def.emoji} {npc_def.name} appears!\n\n"
|
|
message += f"{npc_def.description}\n\n"
|
|
message += format_stat_bar("Your HP", "❤️", player['hp'], player['max_hp']) + "\n"
|
|
message += format_stat_bar("Enemy HP", npc_def.emoji, combat_data['npc_hp'], combat_data['npc_max_hp']) + "\n\n"
|
|
message += "🎯 Your turn! What will you do?"
|
|
|
|
keyboard = await keyboards.combat_keyboard(user_id)
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text=message,
|
|
reply_markup=keyboard,
|
|
image_path=npc_def.image_url if npc_def else None
|
|
)
|
|
return
|
|
|
|
status_text = await get_player_status_text(user_id)
|
|
new_location = game_world.get_location(destination_id)
|
|
location_image = new_location.image_path if new_location else None
|
|
|
|
from .handlers import send_or_edit_with_image
|
|
await send_or_edit_with_image(
|
|
query,
|
|
text=status_text,
|
|
reply_markup=keyboards.main_menu_keyboard(),
|
|
image_path=location_image
|
|
)
|