DungeonMaster-AI / ui /components /combat_tracker.py
bhupesh-sf's picture
first commit
f8ba6bf verified
"""
DungeonMaster AI - Combat Tracker Component
Turn order display with HP tracking and condition management.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import gradio as gr
from ui import styles
from ui.utils.portrait_mapper import get_combatant_portrait
if TYPE_CHECKING:
pass
# =============================================================================
# Portrait Constants
# =============================================================================
MONSTERS_PATH = Path(__file__).parent.parent / "assets" / "images" / "portraits" / "monsters"
IMAGE_EXTENSIONS = (".webp", ".jpg", ".jpeg", ".png")
def get_default_monster_portrait() -> str | None:
"""Get default monster portrait path."""
for ext in IMAGE_EXTENSIONS:
default_path = MONSTERS_PATH / f"default{ext}"
if default_path.exists():
return str(default_path)
return None
# =============================================================================
# Data Models
# =============================================================================
@dataclass
class Combatant:
"""A participant in combat."""
id: str
name: str
initiative: int
hp_current: int
hp_max: int
armor_class: int
conditions: list[str]
is_player: bool
is_current_turn: bool = False
portrait_path: str | None = None
@classmethod
def from_dict(cls, data: dict[str, object], is_current: bool = False) -> "Combatant":
"""Create from dictionary."""
# Get portrait for this combatant
portrait = get_combatant_portrait(data)
if not portrait:
portrait = get_default_monster_portrait()
return cls(
id=str(data.get("id", data.get("character_id", ""))),
name=str(data.get("name", "Unknown")),
initiative=int(data.get("initiative", 0)),
hp_current=int(data.get("hp_current", data.get("current_hp", 0))),
hp_max=int(data.get("hp_max", data.get("max_hp", 1))),
armor_class=int(data.get("armor_class", data.get("ac", 10))),
conditions=list(data.get("conditions", [])),
is_player=bool(data.get("is_player", False)),
is_current_turn=is_current,
portrait_path=portrait,
)
@dataclass
class CombatState:
"""Current combat state."""
round_number: int
turn_order: list[Combatant]
current_turn_index: int
is_active: bool
@classmethod
def from_dict(cls, data: dict[str, object] | None) -> "CombatState | None":
"""Create from dictionary."""
if not data:
return None
turn_order_data = data.get("turn_order", [])
current_idx = int(data.get("current_turn_index", data.get("current_turn", 0)))
combatants = []
for i, c_data in enumerate(turn_order_data):
combatant = Combatant.from_dict(c_data, is_current=i == current_idx)
combatants.append(combatant)
return cls(
round_number=int(data.get("round", data.get("round_number", 1))),
turn_order=combatants,
current_turn_index=current_idx,
is_active=bool(data.get("is_active", True)),
)
# =============================================================================
# Formatting Helpers
# =============================================================================
def get_hp_percentage(current: int, maximum: int) -> float:
"""Calculate HP percentage."""
if maximum <= 0:
return 100.0
return (current / maximum) * 100
def get_hp_color_class(percentage: float) -> str:
"""Get HP bar color class based on percentage."""
if percentage > 50:
return "hp-bar-healthy"
elif percentage > 25:
return "hp-bar-wounded"
else:
return "hp-bar-critical"
def format_combatant_row(combatant: Combatant) -> str:
"""Format a single combatant row with inline styles."""
hp_pct = get_hp_percentage(combatant.hp_current, combatant.hp_max)
hp_color_class = get_hp_color_class(hp_pct)
# Base row style
row_style = (
"display: flex; "
"align-items: center; "
"gap: 8px; "
"padding: 8px 12px; "
"margin: 4px 0; "
"border-radius: 6px; "
"background: rgba(30, 26, 18, 0.6); "
)
# Add type indicator (ally=blue, enemy=red border)
if combatant.is_player:
row_style += "border-left: 3px solid #4a9eff; "
else:
row_style += f"border-left: 3px solid {styles.BLOOD_RED_LIGHT}; "
# Highlight current turn
if combatant.is_current_turn:
row_style += styles.current_turn_style()
# Initiative style
init_style = f"min-width: 24px; text-align: center; font-weight: bold; color: {styles.GOLD};"
# Name style
name_style = f"flex: 1; color: {styles.PARCHMENT}; font-weight: {'bold' if combatant.is_current_turn else 'normal'};"
turn_indicator = "▶ " if combatant.is_current_turn else ""
# Portrait HTML
portrait_html = ""
if combatant.portrait_path:
border_color = "#4a9eff" if combatant.is_player else "#ff4a4a"
portrait_style = (
f"width: 32px; height: 32px; border-radius: 50%; object-fit: cover; "
f"border: 2px solid {border_color}; "
f"box-shadow: 0 2px 4px rgba(0,0,0,0.3);"
)
portrait_html = f'<img src="file={combatant.portrait_path}" alt="{combatant.name}" style="{portrait_style}">'
# HP bar with inline styles
hp_bar_container = (
"position: relative; "
"width: 70px; "
"height: 16px; "
"background: rgba(0, 0, 0, 0.4); "
"border-radius: 8px; "
"overflow: hidden; "
)
# HP fill color based on status
if hp_color_class == "hp-bar-healthy":
fill_color = f"linear-gradient(90deg, {styles.EMERALD_DARK}, {styles.EMERALD})"
elif hp_color_class == "hp-bar-wounded":
fill_color = f"linear-gradient(90deg, #d68910, {styles.STATUS_YELLOW})"
else:
fill_color = f"linear-gradient(90deg, #922b21, {styles.STATUS_RED})"
hp_fill_style = f"height: 100%; width: {hp_pct}%; background: {fill_color}; border-radius: 6px;"
hp_text_style = (
"position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); "
"font-size: 10px; font-weight: bold; color: white; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);"
)
hp_bar_html = f'''
<div style="{hp_bar_container}">
<div style="{hp_fill_style}"></div>
<span style="{hp_text_style}">{combatant.hp_current}/{combatant.hp_max}</span>
</div>
'''
# Conditions badges with inline styles
conditions_html = ""
if combatant.conditions:
badges = []
for condition in combatant.conditions[:3]:
badge_css = styles.condition_badge_style(condition)
badges.append(f'<span style="{badge_css}">{condition}</span>')
conditions_html = f'<div style="display: flex; gap: 4px; flex-wrap: wrap;">{"".join(badges)}</div>'
return f'''
<div style="{row_style}">
<span style="{init_style}">{combatant.initiative}</span>
{portrait_html}
<span style="{name_style}">{turn_indicator}{combatant.name}</span>
{hp_bar_html}
{conditions_html}
</div>
'''
def format_combat_display(combat_state: CombatState | None) -> str:
"""Format the full combat tracker display with inline styles."""
inactive_style = (
"text-align: center; "
"padding: 1.5rem; "
f"color: {styles.PARCHMENT_MUTED}; "
"font-style: italic;"
)
if not combat_state or not combat_state.is_active:
return f'''
<div style="{inactive_style}">
<div>Not in combat</div>
<div style="font-size: 0.85rem; margin-top: 0.5rem;">Combat tracker will appear here when initiative is rolled.</div>
</div>
'''
# Container style
container_style = (
f"background: linear-gradient(180deg, rgba(42, 26, 26, 0.8) 0%, rgba(26, 16, 16, 0.8) 100%); "
f"border: 2px solid {styles.BLOOD_RED}; "
"border-radius: 10px; "
"padding: 1rem; "
f"box-shadow: 0 4px 12px rgba(139, 35, 50, 0.3);"
)
# Header style
header_style = (
f"color: {styles.BLOOD_RED_LIGHT}; "
"font-size: 1.1rem; "
"font-weight: bold; "
f"border-bottom: 1px solid {styles.BLOOD_RED}; "
"padding-bottom: 0.5rem; "
"margin-bottom: 0.75rem;"
)
html_parts = [
f'<div style="{container_style}">',
f'<div style="{header_style}">Combat - Round {combat_state.round_number}</div>',
]
for combatant in combat_state.turn_order:
html_parts.append(format_combatant_row(combatant))
html_parts.append("</div>")
return "".join(html_parts)
def format_combat_summary(combat_state: CombatState | None) -> str:
"""Format a text summary of combat state."""
if not combat_state or not combat_state.is_active:
return "*Not in combat*"
current = None
if 0 <= combat_state.current_turn_index < len(combat_state.turn_order):
current = combat_state.turn_order[combat_state.current_turn_index]
summary_lines = [
f"**Round {combat_state.round_number}**",
]
if current:
summary_lines.append(f"Current Turn: **{current.name}**")
# Count allies and enemies
allies = sum(1 for c in combat_state.turn_order if c.is_player and c.hp_current > 0)
enemies = sum(1 for c in combat_state.turn_order if not c.is_player and c.hp_current > 0)
summary_lines.append(f"Allies: {allies} | Enemies: {enemies}")
return "\n".join(summary_lines)
def format_turn_order_table(combat_state: CombatState | None) -> list[list[str]]:
"""Format combat state as table data for gr.Dataframe."""
if not combat_state or not combat_state.is_active:
return []
rows = []
for combatant in combat_state.turn_order:
current_marker = "▶" if combatant.is_current_turn else ""
type_marker = "🛡️" if combatant.is_player else "👹"
hp_display = f"{combatant.hp_current}/{combatant.hp_max}"
conditions = ", ".join(combatant.conditions) if combatant.conditions else "-"
rows.append([
current_marker,
str(combatant.initiative),
f"{type_marker} {combatant.name}",
hp_display,
conditions,
])
return rows
# =============================================================================
# Component Creation
# =============================================================================
def create_combat_panel() -> tuple[
gr.Group, # combat_group (for visibility toggle)
gr.Markdown, # combat_header
gr.HTML, # combat_display (changed from Markdown)
gr.Markdown, # combat_summary
gr.Button, # next_turn_btn
gr.Button, # end_combat_btn
]:
"""
Create the combat tracker panel component.
Returns:
Tuple of all Gradio components for event wiring.
"""
with gr.Group(visible=True, elem_classes=["combat-tracker-container"]) as combat_group:
# Header
combat_header = gr.Markdown("## ⚔️ Combat Tracker")
# Combat display (HTML format for styling)
combat_display = gr.HTML(
value=format_combat_display(None),
)
# Combat summary (text format)
combat_summary = gr.Markdown(
value=format_combat_summary(None),
)
# Action buttons
with gr.Row():
next_turn_btn = gr.Button(
"Next Turn",
variant="primary",
size="sm",
interactive=False, # Disabled when not in combat
)
end_combat_btn = gr.Button(
"End Combat",
variant="stop",
size="sm",
interactive=False, # Disabled when not in combat
)
return (
combat_display,
combat_summary,
next_turn_btn,
end_combat_btn,
combat_group, # combat_container
)
# =============================================================================
# Update Functions
# =============================================================================
def update_combat_tracker(
combat_state_dict: dict[str, object] | None,
) -> tuple[gr.update, str, str, gr.update, gr.update]:
"""
Update combat tracker from game state.
Args:
combat_state_dict: Combat state dictionary from GameState
Returns:
Tuple of component updates (visibility, display, summary, btn states)
"""
combat_state = CombatState.from_dict(combat_state_dict)
is_active = combat_state is not None and combat_state.is_active
return (
gr.update(visible=True), # Always show group, just change content
format_combat_display(combat_state),
format_combat_summary(combat_state),
gr.update(interactive=is_active), # Next turn button
gr.update(interactive=is_active), # End combat button
)
def set_combat_visibility(in_combat: bool) -> gr.update:
"""
Set combat tracker visibility.
Args:
in_combat: Whether currently in combat
Returns:
gr.update with visibility setting
"""
# We always show the tracker but display different content
return gr.update(visible=True)
async def handle_next_turn(
toolkit_client: object,
game_state: object,
) -> tuple[str, str]:
"""
Handle next turn button click.
Args:
toolkit_client: TTRPGToolkitClient instance
game_state: Current GameState
Returns:
Tuple of (combat_display, combat_summary)
"""
try:
# Call MCP next_turn tool
result = await toolkit_client.call_tool("next_turn", {})
# Parse result and update display
combat_state = CombatState.from_dict(result)
return (
format_combat_display(combat_state),
format_combat_summary(combat_state),
)
except Exception as e:
return (
f"Error advancing turn: {e!s}",
"*Combat state unknown*",
)
async def handle_end_combat(
toolkit_client: object,
game_state: object,
) -> tuple[str, str, gr.update, gr.update]:
"""
Handle end combat button click.
Args:
toolkit_client: TTRPGToolkitClient instance
game_state: Current GameState
Returns:
Tuple of (combat_display, combat_summary, next_btn, end_btn)
"""
try:
# Call MCP end_combat tool
await toolkit_client.call_tool("end_combat", {})
return (
format_combat_display(None),
format_combat_summary(None),
gr.update(interactive=False),
gr.update(interactive=False),
)
except Exception as e:
return (
f"Error ending combat: {e!s}",
"*Combat state unknown*",
gr.update(interactive=True),
gr.update(interactive=True),
)