"""
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''
# 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'''