| | """ |
| | 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 |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| | 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 |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | @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.""" |
| | |
| | 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)), |
| | ) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | 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) |
| |
|
| | |
| | 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); " |
| | ) |
| |
|
| | |
| | if combatant.is_player: |
| | row_style += "border-left: 3px solid #4a9eff; " |
| | else: |
| | row_style += f"border-left: 3px solid {styles.BLOOD_RED_LIGHT}; " |
| |
|
| | |
| | if combatant.is_current_turn: |
| | row_style += styles.current_turn_style() |
| |
|
| | |
| | init_style = f"min-width: 24px; text-align: center; font-weight: bold; color: {styles.GOLD};" |
| |
|
| | |
| | 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 = "" |
| | 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_container = ( |
| | "position: relative; " |
| | "width: 70px; " |
| | "height: 16px; " |
| | "background: rgba(0, 0, 0, 0.4); " |
| | "border-radius: 8px; " |
| | "overflow: hidden; " |
| | ) |
| |
|
| | |
| | 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_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 = ( |
| | 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 = ( |
| | 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}**") |
| |
|
| | |
| | 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 |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | def create_combat_panel() -> tuple[ |
| | gr.Group, |
| | gr.Markdown, |
| | gr.HTML, |
| | gr.Markdown, |
| | gr.Button, |
| | gr.Button, |
| | ]: |
| | """ |
| | 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: |
| | |
| | combat_header = gr.Markdown("## ⚔️ Combat Tracker") |
| |
|
| | |
| | combat_display = gr.HTML( |
| | value=format_combat_display(None), |
| | ) |
| |
|
| | |
| | combat_summary = gr.Markdown( |
| | value=format_combat_summary(None), |
| | ) |
| |
|
| | |
| | with gr.Row(): |
| | next_turn_btn = gr.Button( |
| | "Next Turn", |
| | variant="primary", |
| | size="sm", |
| | interactive=False, |
| | ) |
| | end_combat_btn = gr.Button( |
| | "End Combat", |
| | variant="stop", |
| | size="sm", |
| | interactive=False, |
| | ) |
| |
|
| | return ( |
| | combat_display, |
| | combat_summary, |
| | next_turn_btn, |
| | end_combat_btn, |
| | combat_group, |
| | ) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | 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), |
| | format_combat_display(combat_state), |
| | format_combat_summary(combat_state), |
| | gr.update(interactive=is_active), |
| | gr.update(interactive=is_active), |
| | ) |
| |
|
| |
|
| | 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 |
| | """ |
| | |
| | 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: |
| | |
| | result = await toolkit_client.call_tool("next_turn", {}) |
| |
|
| | |
| | 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: |
| | |
| | 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), |
| | ) |
| |
|