From 3c3dceb7379e12cb12e58e1bbb17487108882627 Mon Sep 17 00:00:00 2001 From: KoalaTrapLord Date: Sat, 14 Mar 2026 20:10:43 -0400 Subject: [PATCH] Phase 4: Add Pygame prototype GUI (gui/ package + tests/test_phase4.py) --- src/commander_ai_lab/gui/__init__.py | 14 + src/commander_ai_lab/gui/board.py | 355 ++++++++++++++++++ src/commander_ai_lab/gui/card_renderer.py | 276 ++++++++++++++ src/commander_ai_lab/gui/constants.py | 93 +++++ src/commander_ai_lab/gui/game_window.py | 375 ++++++++++++++++++++ src/commander_ai_lab/gui/hud.py | 210 +++++++++++ src/commander_ai_lab/gui/narration_panel.py | 194 ++++++++++ tests/test_phase4.py | 305 ++++++++++++++++ 8 files changed, 1822 insertions(+) create mode 100644 src/commander_ai_lab/gui/__init__.py create mode 100644 src/commander_ai_lab/gui/board.py create mode 100644 src/commander_ai_lab/gui/card_renderer.py create mode 100644 src/commander_ai_lab/gui/constants.py create mode 100644 src/commander_ai_lab/gui/game_window.py create mode 100644 src/commander_ai_lab/gui/hud.py create mode 100644 src/commander_ai_lab/gui/narration_panel.py create mode 100644 tests/test_phase4.py diff --git a/src/commander_ai_lab/gui/__init__.py b/src/commander_ai_lab/gui/__init__.py new file mode 100644 index 0000000..2bc7f35 --- /dev/null +++ b/src/commander_ai_lab/gui/__init__.py @@ -0,0 +1,14 @@ +""" +Commander AI Lab — Pygame Prototype GUI (Phase 4) +""" +from commander_ai_lab.gui.constants import SCREEN_W, SCREEN_H, FPS +from commander_ai_lab.gui.board import BoardRenderer +from commander_ai_lab.gui.card_renderer import CardRenderer +from commander_ai_lab.gui.hud import HUD +from commander_ai_lab.gui.narration_panel import NarrationPanel +from commander_ai_lab.gui.game_window import GameWindow + +__all__ = [ + "SCREEN_W", "SCREEN_H", "FPS", + "BoardRenderer", "CardRenderer", "HUD", "NarrationPanel", "GameWindow", +] diff --git a/src/commander_ai_lab/gui/board.py b/src/commander_ai_lab/gui/board.py new file mode 100644 index 0000000..12acc98 --- /dev/null +++ b/src/commander_ai_lab/gui/board.py @@ -0,0 +1,355 @@ +""" +Commander AI Lab — Board Renderer (Phase 4) +============================================ +Layouts and renders the 4-player split-screen board. + +Layout (1280 x 960): + + ┌──────────────────────────────────────────────┐ + │ Player 2 (top — opponent) │ + ├──────────┬───────────────────┬───────────────┤ + │ │ STACK / CENTER │ │ + │ Player 3 │ │ Player 1 │ + │ (left) │ │ (right) │ + ├──────────┴───────────────────┴───────────────┤ + │ Player 0 (bottom — human / seat 0) │ + └──────────────────────────────────────────────┘ + +Each quadrant contains: + hand zone | battlefield (creatures / non-creatures) | graveyard | exile | command zone + +The narration panel occupies the far right strip (270 px wide). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +try: + import pygame + _PYGAME_AVAILABLE = True +except ImportError: + _PYGAME_AVAILABLE = False + +from commander_ai_lab.gui.constants import ( + SCREEN_W, SCREEN_H, + CARD_W, CARD_H, + COLOUR_BG, COLOUR_ZONE_BG, COLOUR_ZONE_BORDER, + COLOUR_TEXT, COLOUR_TEXT_DIM, COLOUR_HIGHLIGHT, + COLOUR_ELIMINATED, COLOUR_THINKING, + SEAT_COLOURS, + NARRATION_X, NARRATION_W, + PHASE_BAR_H, +) +from commander_ai_lab.gui.card_renderer import CardRenderer + +# Width available to the board (narration panel occupies the right strip) +_BOARD_W = NARRATION_X - 4 +# Quadrant dimensions +_TOP_H = 220 +_BOTTOM_H = 220 +_MID_H = SCREEN_H - _TOP_H - _BOTTOM_H - PHASE_BAR_H +_SIDE_W = 200 +_CENTER_W = _BOARD_W - 2 * _SIDE_W + + +@dataclass +class ZoneRect: + """Pixel rectangle for a named zone.""" + name: str + x: int + y: int + w: int + h: int + + def contains(self, px: int, py: int) -> bool: + return self.x <= px < self.x + self.w and self.y <= py < self.y + self.h + + +@dataclass +class CardHit: + """Result of a click hit-test: which card in which zone.""" + seat: int + zone: str # 'hand', 'battlefield', 'graveyard', 'exile', 'command' + card_index: int # index in the list + card: dict + + +class BoardRenderer: + """ + Manages zone layout and renders the full 4-player board each frame. + + Parameters + ---------- + card_renderer : CardRenderer + Shared card drawing helper. + human_seat : int + Seat whose hand is shown face-up (bottom quadrant). + """ + + # Seat -> screen quadrant mapping + # seat 0 = bottom, 1 = right, 2 = top, 3 = left + _SEAT_QUADRANT = {0: "bottom", 1: "right", 2: "top", 3: "left"} + + def __init__( + self, + card_renderer: CardRenderer, + human_seat: int = 0, + ) -> None: + self.cr = card_renderer + self.human_seat = human_seat + + # Highlighted card (legal target indicator) + self._highlighted: Optional[CardHit] = None + # Selected card in hand (waiting for target) + self._selected: Optional[CardHit] = None + + # Zone rects computed once + self._zones: dict[int, dict[str, ZoneRect]] = {} + self._build_zones() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def render( + self, + surface, + game_state, + active_seat: int, + thinking_seats: set[int], + highlighted_cards: set[tuple] | None = None, # {(seat, zone, idx)} + ) -> None: + """ + Render the full board onto surface for the current frame. + + Args: + surface: Main pygame display surface. + game_state: CommanderGameState (or compatible stub). + active_seat: Seat whose turn it is (highlighted border). + thinking_seats: Set of seat indices currently running AI inference. + highlighted_cards: Cards to show with a highlight ring. + """ + if not _PYGAME_AVAILABLE: + return + + highlighted_cards = highlighted_cards or set() + surface.fill(COLOUR_BG) + + for seat, player in enumerate(game_state.players): + self._render_player_zones( + surface, seat, player, active_seat, + thinking_seats, highlighted_cards, + ) + + self._render_stack_zone(surface, game_state) + self._render_center_dividers(surface) + + def hit_test( + self, + px: int, + py: int, + game_state, + ) -> Optional[CardHit]: + """ + Return the CardHit under pixel (px, py), or None. + Used by the input handler to resolve clicks. + """ + for seat, zones in self._zones.items(): + player = game_state.players[seat] + for zone_name, zone_rect in zones.items(): + if not zone_rect.contains(px, py): + continue + cards = self._get_zone_cards(player, zone_name) + idx = self._card_index_at(px, py, zone_rect, cards) + if idx is not None: + return CardHit( + seat=seat, + zone=zone_name, + card_index=idx, + card=cards[idx], + ) + return None + + def set_highlighted(self, hit: Optional[CardHit]) -> None: + self._highlighted = hit + + def set_selected(self, hit: Optional[CardHit]) -> None: + self._selected = hit + + # ------------------------------------------------------------------ + # Zone Layout + # ------------------------------------------------------------------ + + def _build_zones(self) -> None: + """Pre-compute zone rects for all 4 seats.""" + # seat 0: bottom quadrant + self._zones[0] = self._zones_for_bottom() + # seat 1: right quadrant + self._zones[1] = self._zones_for_right() + # seat 2: top quadrant + self._zones[2] = self._zones_for_top() + # seat 3: left quadrant + self._zones[3] = self._zones_for_left() + + def _zones_for_bottom(self) -> dict[str, ZoneRect]: + y_bf = SCREEN_H - _BOTTOM_H - PHASE_BAR_H + y_hand = SCREEN_H - CARD_H - 20 - PHASE_BAR_H + return { + "battlefield": ZoneRect("battlefield", _SIDE_W, y_bf, _CENTER_W, _BOTTOM_H - CARD_H - 24), + "hand": ZoneRect("hand", 0, y_hand, _BOARD_W - CARD_W * 2 - 8, CARD_H + 8), + "graveyard": ZoneRect("graveyard", _BOARD_W - CARD_W * 2 - 4, y_bf, CARD_W + 4, CARD_H + 4), + "exile": ZoneRect("exile", _BOARD_W - CARD_W - 2, y_bf, CARD_W + 2, CARD_H + 4), + "command": ZoneRect("command", _BOARD_W - CARD_W - 2, y_hand, CARD_W + 2, CARD_H + 4), + } + + def _zones_for_top(self) -> dict[str, ZoneRect]: + return { + "battlefield": ZoneRect("battlefield", _SIDE_W, PHASE_BAR_H + 8, _CENTER_W, _TOP_H - CARD_H - 16), + "hand": ZoneRect("hand", _SIDE_W, PHASE_BAR_H + _TOP_H - CARD_H - 8, _CENTER_W, CARD_H + 8), + "graveyard": ZoneRect("graveyard", _BOARD_W - CARD_W - 2, PHASE_BAR_H + 4, CARD_W + 2, CARD_H + 4), + "exile": ZoneRect("exile", _BOARD_W - CARD_W * 2 - 4, PHASE_BAR_H + 4, CARD_W + 4, CARD_H + 4), + "command": ZoneRect("command", 4, PHASE_BAR_H + 4, CARD_W + 2, CARD_H + 4), + } + + def _zones_for_left(self) -> dict[str, ZoneRect]: + mid_y = _TOP_H + PHASE_BAR_H + return { + "battlefield": ZoneRect("battlefield", 4, mid_y, _SIDE_W - 4, _MID_H), + "hand": ZoneRect("hand", 4, mid_y + _MID_H - CARD_H - 8, _SIDE_W - 4, CARD_H + 8), + "graveyard": ZoneRect("graveyard", 4, mid_y + 4, CARD_W + 2, CARD_H + 4), + "exile": ZoneRect("exile", CARD_W + 8, mid_y + 4, CARD_W + 2, CARD_H + 4), + "command": ZoneRect("command", 4, mid_y + CARD_H + 12, CARD_W + 2, CARD_H + 4), + } + + def _zones_for_right(self) -> dict[str, ZoneRect]: + mid_y = _TOP_H + PHASE_BAR_H + rx = _BOARD_W - _SIDE_W + 4 + return { + "battlefield": ZoneRect("battlefield", rx, mid_y, _SIDE_W - 8, _MID_H), + "hand": ZoneRect("hand", rx, mid_y + _MID_H - CARD_H - 8, _SIDE_W - 8, CARD_H + 8), + "graveyard": ZoneRect("graveyard", rx, mid_y + 4, CARD_W + 2, CARD_H + 4), + "exile": ZoneRect("exile", rx + CARD_W + 4, mid_y + 4, CARD_W + 2, CARD_H + 4), + "command": ZoneRect("command", rx, mid_y + CARD_H + 12, CARD_W + 2, CARD_H + 4), + } + + # ------------------------------------------------------------------ + # Rendering helpers + # ------------------------------------------------------------------ + + def _render_player_zones( + self, + surface, + seat: int, + player, + active_seat: int, + thinking_seats: set[int], + highlighted_cards: set, + ) -> None: + import pygame + seat_col = SEAT_COLOURS[seat % len(SEAT_COLOURS)] + is_active = (seat == active_seat) + is_elim = getattr(player, "eliminated", False) + is_think = seat in thinking_seats + + zones = self._zones.get(seat, {}) + + for zone_name, zr in zones.items(): + bg_col = COLOUR_ZONE_BG + border_col = COLOUR_ELIMINATED if is_elim else ( + COLOUR_THINKING if is_think else ( + seat_col if is_active else COLOUR_ZONE_BORDER + ) + ) + border_w = 2 if is_active or is_think else 1 + + rect = pygame.Rect(zr.x, zr.y, zr.w, zr.h) + pygame.draw.rect(surface, bg_col, rect, border_radius=4) + pygame.draw.rect(surface, border_col, rect, width=border_w, border_radius=4) + + # Zone label + # (font rendering omitted here — HUD handles text labels) + + # Draw cards + cards = self._get_zone_cards(player, zone_name) + face_up = (seat == self.human_seat or zone_name in ("battlefield", "graveyard", "command")) + + for idx, card in enumerate(cards): + cx, cy = self._card_position(zr, idx, len(cards)) + is_hl = (seat, zone_name, idx) in highlighted_cards + + if face_up: + self.cr.draw_card(surface, card, cx, cy, highlighted=is_hl) + else: + self.cr.draw_card_back(surface, cx, cy, label=str(len(cards))) + break # only draw one face-down back per hidden hand + + # Player name tag + # (rendered by HUD.render_player_tags) + + def _render_stack_zone(self, surface, game_state) -> None: + """Render the stack in the center of the board.""" + import pygame + from commander_ai_lab.gui.constants import ( + ZONE_STACK_X, ZONE_STACK_Y, ZONE_STACK_W, + ) + stack = getattr(game_state, "stack", []) + if not stack: + return + rect = pygame.Rect(ZONE_STACK_X, ZONE_STACK_Y, ZONE_STACK_W, min(len(stack) * 20 + 16, 180)) + pygame.draw.rect(surface, COLOUR_ZONE_BG, rect, border_radius=4) + pygame.draw.rect(surface, COLOUR_HIGHLIGHT, rect, width=2, border_radius=4) + + def _render_center_dividers(self, surface) -> None: + """Draw subtle lines separating the four quadrants.""" + import pygame + col = COLOUR_ZONE_BORDER + # Horizontal dividers + y_top_end = _TOP_H + PHASE_BAR_H + y_bot_start = SCREEN_H - _BOTTOM_H - PHASE_BAR_H + pygame.draw.line(surface, col, (0, y_top_end), (_BOARD_W, y_top_end), 1) + pygame.draw.line(surface, col, (0, y_bot_start), (_BOARD_W, y_bot_start), 1) + # Vertical dividers + pygame.draw.line(surface, col, (_SIDE_W, y_top_end), (_SIDE_W, y_bot_start), 1) + pygame.draw.line(surface, col, (_BOARD_W - _SIDE_W, y_top_end), (_BOARD_W - _SIDE_W, y_bot_start), 1) + + # ------------------------------------------------------------------ + # Card positioning within a zone + # ------------------------------------------------------------------ + + @staticmethod + def _card_position(zone_rect: ZoneRect, idx: int, total: int) -> tuple[int, int]: + """Compute pixel position for card at index idx in a zone.""" + if total == 0: + return zone_rect.x + 4, zone_rect.y + 4 + spacing = min(CARD_W + 6, max(20, (zone_rect.w - 8) // max(total, 1))) + x = zone_rect.x + 4 + idx * spacing + y = zone_rect.y + 4 + return x, y + + @staticmethod + def _card_index_at(px: int, py: int, zone_rect: ZoneRect, cards: list) -> Optional[int]: + """Return the card index at pixel position, or None.""" + if not cards: + return None + total = len(cards) + spacing = min(CARD_W + 6, max(20, (zone_rect.w - 8) // max(total, 1))) + for idx in reversed(range(total)): # top-most card first + cx = zone_rect.x + 4 + idx * spacing + cy = zone_rect.y + 4 + if cx <= px < cx + CARD_W and cy <= py < cy + CARD_H: + return idx + return None + + @staticmethod + def _get_zone_cards(player, zone_name: str) -> list: + mapping = { + "hand": "hand", + "battlefield": "battlefield", + "graveyard": "graveyard", + "exile": "exile", + "command": "command_zone", + } + attr = mapping.get(zone_name, zone_name) + return getattr(player, attr, []) or [] diff --git a/src/commander_ai_lab/gui/card_renderer.py b/src/commander_ai_lab/gui/card_renderer.py new file mode 100644 index 0000000..4e2c4fc --- /dev/null +++ b/src/commander_ai_lab/gui/card_renderer.py @@ -0,0 +1,276 @@ +""" +Commander AI Lab — Card Renderer (Phase 4) +========================================== +Draws individual cards onto a Pygame surface. + +Features: + - Tapped / untapped state (rotated 90 degrees) + - Name, P/T or loyalty, mana cost text + - Highlight ring (legal target indicator) + - Scryfall art placeholder (grey box + card name until image loads) + - Async art loading via a shared image cache (non-blocking) + - Right-click context menu data (returned as list of option strings, + rendered by the caller) +""" + +from __future__ import annotations + +import threading +from typing import Optional + +try: + import pygame + _PYGAME_AVAILABLE = True +except ImportError: + _PYGAME_AVAILABLE = False + +from commander_ai_lab.gui.constants import ( + CARD_W, CARD_H, CARD_RADIUS, CARD_TAPPED_ANGLE, + COLOUR_CARD_BACK, COLOUR_CARD_FACE, COLOUR_CARD_TAPPED, + COLOUR_HIGHLIGHT, COLOUR_TARGET_RING, COLOUR_TEXT, COLOUR_TEXT_DIM, + COLOUR_ZONE_BORDER, +) + + +# --------------------------------------------------------------------------- +# Scryfall image cache (shared across all CardRenderer instances) +# --------------------------------------------------------------------------- + +class _ScryfallCache: + """ + Lazy per-name image cache. + + Fetches art from Scryfall API in a background thread when a card is + first drawn. Surfaces are stored as pygame.Surface objects once loaded. + Until loaded, None is stored (caller renders placeholder). + """ + + def __init__(self) -> None: + self._cache: dict[str, Optional[object]] = {} # name -> Surface | None + self._pending: set[str] = set() + self._lock = threading.Lock() + + def get(self, card_name: str) -> Optional[object]: + """Return cached Surface or None (triggers background fetch if first call).""" + with self._lock: + if card_name in self._cache: + return self._cache[card_name] + if card_name not in self._pending: + self._pending.add(card_name) + t = threading.Thread( + target=self._fetch, + args=(card_name,), + daemon=True, + ) + t.start() + return None + + def _fetch(self, card_name: str) -> None: + """Background thread: fetch art from Scryfall and decode into Surface.""" + try: + from urllib.request import urlopen + from urllib.parse import quote + import json + import io + + # Step 1: resolve card by name + name_enc = quote(card_name) + api_url = f"https://api.scryfall.com/cards/named?fuzzy={name_enc}" + with urlopen(api_url, timeout=5) as r: + data = json.loads(r.read()) + + # Step 2: get small image URI + image_uris = data.get("image_uris") or ( + data.get("card_faces", [{}])[0].get("image_uris", {}) + ) + img_url = image_uris.get("small") or image_uris.get("normal") + if not img_url: + raise ValueError("No image URI found") + + # Step 3: download image bytes + with urlopen(img_url, timeout=10) as r: + img_bytes = r.read() + + # Step 4: decode into pygame Surface + if _PYGAME_AVAILABLE: + import pygame + buf = io.BytesIO(img_bytes) + surf = pygame.image.load(buf, "card.jpg") + surf = pygame.transform.scale(surf, (CARD_W, CARD_H)) + else: + surf = None + + with self._lock: + self._cache[card_name] = surf + self._pending.discard(card_name) + + except Exception: + # On any failure, store None so we fall back to placeholder + with self._lock: + self._cache[card_name] = None + self._pending.discard(card_name) + + +# Module-level shared cache +_ART_CACHE = _ScryfallCache() + + +# --------------------------------------------------------------------------- +# CardRenderer +# --------------------------------------------------------------------------- + +class CardRenderer: + """ + Stateless card drawing helper. + + All draw methods accept a target pygame.Surface and a card dict. + Card dict schema (same as game_state.CommanderPlayer.battlefield): + { + "name": str, + "type": str, # creature / instant / land / … + "pt": str | None, # "3/3" + "tapped": bool, + "cmc": int, + "oracle": str | None, + "is_commander": bool, + } + """ + + def __init__(self, font_small=None, font_med=None) -> None: + self._font_small = font_small + self._font_med = font_med + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def draw_card( + self, + surface, + card: dict, + x: int, + y: int, + highlighted: bool = False, + show_art: bool = True, + ) -> None: + """ + Draw a single card at (x, y) on surface. + Tapped cards are rotated 90 degrees clockwise. + """ + if not _PYGAME_AVAILABLE: + return + + import pygame + tapped = card.get("tapped", False) + + # Build card surface at standard orientation + card_surf = self._build_card_surface(card, highlighted, show_art) + + if tapped: + card_surf = pygame.transform.rotate(card_surf, -CARD_TAPPED_ANGLE) + + surface.blit(card_surf, (x, y)) + + def draw_card_back( + self, + surface, + x: int, + y: int, + label: str = "", + ) -> None: + """Draw a face-down card back (used for hidden hands).""" + if not _PYGAME_AVAILABLE: + return + import pygame + rect = pygame.Rect(x, y, CARD_W, CARD_H) + pygame.draw.rect(surface, COLOUR_CARD_BACK, rect, border_radius=CARD_RADIUS) + pygame.draw.rect(surface, COLOUR_ZONE_BORDER, rect, width=2, border_radius=CARD_RADIUS) + if label and self._font_small: + lbl = self._font_small.render(label[:12], True, COLOUR_TEXT_DIM) + surface.blit(lbl, (x + 4, y + CARD_H - 18)) + + def get_context_menu_options(self, card: dict) -> list[str]: + """ + Return right-click context menu options for a card. + Options depend on card type and zone. + """ + options = [] + if card.get("type", "").lower() == "land": + options.append("Tap for mana") + if card.get("is_creature") or "creature" in card.get("type", "").lower(): + options.append("Declare attacker") + if card.get("oracle"): + options.append("View oracle text") + options.append("Move to graveyard") + options.append("Move to exile") + return options + + def card_rect( + self, + x: int, + y: int, + tapped: bool = False, + ) -> tuple: + """Return the bounding (x, y, w, h) tuple for hit-testing.""" + if tapped: + return (x, y, CARD_H, CARD_W) # rotated dimensions + return (x, y, CARD_W, CARD_H) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _build_card_surface(self, card: dict, highlighted: bool, show_art: bool): + """Construct a CARD_W x CARD_H surface for the card.""" + import pygame + surf = pygame.Surface((CARD_W, CARD_H), pygame.SRCALPHA) + rect = pygame.Rect(0, 0, CARD_W, CARD_H) + + # Background + face_col = COLOUR_CARD_TAPPED if card.get("tapped") else COLOUR_CARD_FACE + pygame.draw.rect(surf, face_col, rect, border_radius=CARD_RADIUS) + + # Scryfall art (or placeholder) + if show_art: + art = _ART_CACHE.get(card.get("name", "")) + if art: + surf.blit(art, (0, 0)) + else: + # Art placeholder: grey block in upper portion + art_rect = pygame.Rect(4, 4, CARD_W - 8, 54) + pygame.draw.rect(surf, (80, 90, 110), art_rect, border_radius=3) + + # Border + border_col = COLOUR_HIGHLIGHT if highlighted else COLOUR_ZONE_BORDER + border_w = 3 if highlighted else 1 + pygame.draw.rect(surf, border_col, rect, width=border_w, border_radius=CARD_RADIUS) + + # Commander indicator (gold star) + if card.get("is_commander"): + pygame.draw.circle(surf, (255, 210, 0), (CARD_W - 10, 10), 6) + + # Text overlay + if self._font_small: + name = card.get("name", "Unknown")[:14] + name_surf = self._font_small.render(name, True, (10, 10, 10)) + surf.blit(name_surf, (4, CARD_H - 38)) + + pt = card.get("pt") + if pt and self._font_small: + pt_surf = self._font_small.render(pt, True, (10, 10, 10)) + surf.blit(pt_surf, (CARD_W - 28, CARD_H - 18)) + + cmc = card.get("cmc") + if cmc is not None and self._font_small: + cmc_str = str(int(cmc)) if isinstance(cmc, float) else str(cmc) + cmc_surf = self._font_small.render(cmc_str, True, (10, 10, 10)) + surf.blit(cmc_surf, (4, CARD_H - 18)) + + # Target ring + if highlighted: + pygame.draw.rect( + surf, COLOUR_TARGET_RING, rect, + width=3, border_radius=CARD_RADIUS, + ) + + return surf diff --git a/src/commander_ai_lab/gui/constants.py b/src/commander_ai_lab/gui/constants.py new file mode 100644 index 0000000..e518d2f --- /dev/null +++ b/src/commander_ai_lab/gui/constants.py @@ -0,0 +1,93 @@ +""" +Commander AI Lab — GUI Constants (Phase 4) +========================================== +All magic numbers in one place. Adjust here to re-skin the entire UI. +""" + +# --- Window --- +SCREEN_W: int = 1280 +SCREEN_H: int = 960 +FPS: int = 60 +TITLE: str = "Commander AI Lab" + +# --- Card dimensions --- +CARD_W: int = 90 +CARD_H: int = 126 +CARD_RADIUS: int = 6 # rounded corner radius +CARD_TAPPED_ANGLE: float = 90.0 # degrees clockwise + +# --- Zone layout (4-player split: top / left / right / bottom) --- +# Each player occupies one quadrant. Zones inside each quadrant: +ZONE_HAND_H: int = CARD_H + 16 +ZONE_BF_H: int = 220 # battlefield height per player +ZONE_GRAVEYARD_W: int = CARD_W + 12 +ZONE_EXILE_W: int = CARD_W + 12 +ZONE_COMMAND_W: int = CARD_W + 12 +ZONE_STACK_W: int = 160 +ZONE_STACK_X: int = (SCREEN_W - ZONE_STACK_W) // 2 +ZONE_STACK_Y: int = (SCREEN_H - 180) // 2 + +# --- Narration panel (right side) --- +NARRATION_X: int = SCREEN_W - 270 +NARRATION_Y: int = 40 +NARRATION_W: int = 260 +NARRATION_H: int = SCREEN_H - 80 +NARRATION_MAX_LINES: int = 18 + +# --- Phase / step indicator --- +PHASE_BAR_H: int = 32 + +# --- Life total / commander damage matrix --- +LIFE_PANEL_W: int = 120 +LIFE_PANEL_H: int = 100 + +# --- Colours (R, G, B) --- +COLOUR_BG = (18, 20, 30) +COLOUR_ZONE_BG = (28, 32, 48) +COLOUR_ZONE_BORDER = (60, 70, 100) +COLOUR_CARD_BACK = (40, 55, 90) +COLOUR_CARD_FACE = (230, 220, 195) +COLOUR_CARD_TAPPED = (150, 130, 80) +COLOUR_HIGHLIGHT = (255, 220, 50) +COLOUR_TARGET_RING = (255, 80, 80) +COLOUR_TEXT = (240, 240, 240) +COLOUR_TEXT_DIM = (140, 140, 160) +COLOUR_PHASE_BAR = (35, 40, 60) +COLOUR_PHASE_ACTIVE= (60, 130, 220) +COLOUR_NARRATION_BG= (22, 26, 40) +COLOUR_LIFE_HIGH = (80, 200, 100) +COLOUR_LIFE_MED = (220, 180, 50) +COLOUR_LIFE_LOW = (220, 60, 60) +COLOUR_ELIMINATED = (80, 80, 80) +COLOUR_THINKING = (100, 180, 255) + +# Player seat colours (used to tint zone borders and name labels) +SEAT_COLOURS = [ + (70, 140, 255), # seat 0 — blue + (255, 100, 80), # seat 1 — red + (80, 210, 120), # seat 2 — green + (220, 170, 50), # seat 3 — gold +] + +# --- Commander phases list (for phase bar) --- +COMMANDER_PHASES = [ + "untap", "upkeep", "draw", + "main1", "begin_combat", "declare_attackers", + "declare_blockers", "combat_damage", "end_combat", + "main2", "end_step", "cleanup", +] + +PHASE_LABELS = { + "untap": "Untap", + "upkeep": "Upkeep", + "draw": "Draw", + "main1": "Main 1", + "begin_combat": "Begin Combat", + "declare_attackers": "Attackers", + "declare_blockers": "Blockers", + "combat_damage": "Damage", + "end_combat": "End Combat", + "main2": "Main 2", + "end_step": "End Step", + "cleanup": "Cleanup", +} diff --git a/src/commander_ai_lab/gui/game_window.py b/src/commander_ai_lab/gui/game_window.py new file mode 100644 index 0000000..ae03903 --- /dev/null +++ b/src/commander_ai_lab/gui/game_window.py @@ -0,0 +1,375 @@ +""" +Commander AI Lab — GameWindow (Phase 4) +======================================== +Top-level Pygame application class. + +Responsibilities: + - Initialise Pygame display, fonts, clock + - Own the main game loop (run()) + - Bridge between CommanderTurnManager events and GUI components + - Handle human player input (click-to-play, right-click context menu) + - Show end-of-game summary screen with win condition + final stats + +Usage (blocking, runs until window closes):: + + from commander_ai_lab.gui.game_window import GameWindow + from commander_ai_lab.sim.ai_opponent import create_four_player_ai_roster + from commander_ai_lab.sim.game_state import CommanderGameState + + gs = CommanderGameState(...) # build your game state + ai_roster = create_four_player_ai_roster() + + window = GameWindow(game_state=gs, ai_opponents=ai_roster, human_seat=0) + window.run() # blocks until game over or window closed +""" + +from __future__ import annotations + +import asyncio +import threading +from typing import Optional + +try: + import pygame + _PYGAME_AVAILABLE = True +except ImportError: + _PYGAME_AVAILABLE = False + +from commander_ai_lab.gui.constants import ( + SCREEN_W, SCREEN_H, FPS, TITLE, + COLOUR_BG, COLOUR_TEXT, COLOUR_TEXT_DIM, COLOUR_HIGHLIGHT, + SEAT_COLOURS, NARRATION_X, NARRATION_W, +) +from commander_ai_lab.gui.board import BoardRenderer +from commander_ai_lab.gui.card_renderer import CardRenderer +from commander_ai_lab.gui.hud import HUD +from commander_ai_lab.gui.narration_panel import NarrationPanel +from commander_ai_lab.sim.turn_manager import CommanderTurnManager, GameEvent, TurnManagerConfig + + +class GameWindow: + """ + Main Pygame application window for Commander AI Lab. + + Parameters + ---------- + game_state : CommanderGameState — mutable game state. + ai_opponents : List of AIOpponent instances (Phase 2). + human_seat : Seat index for the human player (default 0). + Pass None for a fully AI game. + config : TurnManagerConfig for the turn manager. + """ + + def __init__( + self, + game_state, + ai_opponents: list, + human_seat: Optional[int] = 0, + config: Optional[TurnManagerConfig] = None, + ) -> None: + self.gs = game_state + self.human_seat = human_seat + self._ai_opponents = ai_opponents + self._config = config or TurnManagerConfig(ai_decision_delay=1.0) + + # Turn manager + human_seats = {human_seat} if human_seat is not None else set() + self._tm = CommanderTurnManager( + game_state=game_state, + ai_opponents=ai_opponents, + human_seats=human_seats, + on_event=self._on_game_event, + on_thinking=self._on_thinking, + config=self._config, + ) + + # GUI state + self._thinking_seats: set[int] = set() + self._highlighted_cards: set = set() + self._selected_card = None + self._context_menu = None # (x, y, options) + self._game_over_info: Optional[dict] = None + + # Pygame objects (initialised in run()) + self._screen = None + self._clock = None + self._board = None + self._hud = None + self._narration = None + self._cr = None + + # Commander damage matrix: {(dealer, receiver): int} + self._cmd_damage: dict[tuple, int] = {} + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def run(self) -> None: + """Initialise Pygame and run the main loop (blocking).""" + if not _PYGAME_AVAILABLE: + raise RuntimeError( + "Pygame is not installed. Run: pip install pygame" + ) + + pygame.init() + pygame.display.set_caption(TITLE) + self._screen = pygame.display.set_mode((SCREEN_W, SCREEN_H)) + self._clock = pygame.time.Clock() + + # Fonts + font_small = pygame.font.SysFont("segoeui", 11) + font_med = pygame.font.SysFont("segoeui", 13) + font_large = pygame.font.SysFont("segoeui", 28, bold=True) + + # GUI components + self._cr = CardRenderer(font_small=font_small, font_med=font_med) + self._board = BoardRenderer(card_renderer=self._cr, human_seat=self.human_seat or 0) + self._hud = HUD(font_small=font_small, font_med=font_med, font_large=font_large) + self._narration = NarrationPanel(font_small=font_small) + + # Start the async turn manager in a background thread + self._loop = asyncio.new_event_loop() + self._game_thread = threading.Thread( + target=self._run_game_loop_async, + daemon=True, + ) + self._game_thread.start() + + # Main render loop + running = True + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.MOUSEBUTTONDOWN: + self._handle_mouse_click(event) + elif event.type == pygame.MOUSEWHEEL: + if event.y > 0: + self._narration.scroll_up() + else: + self._narration.scroll_down() + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + + self._render_frame() + self._clock.tick(FPS) + + pygame.quit() + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def _render_frame(self) -> None: + if self._game_over_info: + self._render_game_over_screen() + return + + # Board + self._board.render( + self._screen, + self.gs, + active_seat=self.gs.active_player_seat, + thinking_seats=self._thinking_seats, + highlighted_cards=self._highlighted_cards, + ) + + # HUD + self._hud.render_phase_bar( + self._screen, + current_phase=self.gs.current_phase, + active_seat=self.gs.active_player_seat, + player_name=self.gs.players[self.gs.active_player_seat].name, + ) + self._hud.render_life_totals(self._screen, self.gs.players) + self._hud.render_commander_damage_matrix( + self._screen, self.gs.players, self._cmd_damage, + ) + self._hud.render_player_tags( + self._screen, self.gs.players, + self.gs.active_player_seat, self._thinking_seats, + ) + + # Narration panel + self._narration.render(self._screen, turn=self.gs.turn) + + # Context menu (right-click) + if self._context_menu: + self._render_context_menu() + + pygame.display.flip() + + def _render_game_over_screen(self) -> None: + """Full-screen game over summary.""" + import pygame + self._screen.fill(COLOUR_BG) + info = self._game_over_info or {} + winner_seat = info.get("winner", 0) + reason = info.get("reason", "") + + font_title = pygame.font.SysFont("segoeui", 48, bold=True) + font_body = pygame.font.SysFont("segoeui", 20) + font_small = pygame.font.SysFont("segoeui", 14) + + winner_name = self.gs.players[winner_seat].name if winner_seat is not None else "Draw" + col = SEAT_COLOURS[winner_seat % len(SEAT_COLOURS)] if winner_seat is not None else COLOUR_TEXT + + title = font_title.render(f"{winner_name} wins!", True, col) + self._screen.blit(title, (SCREEN_W // 2 - title.get_width() // 2, 140)) + + sub = font_body.render(f"by {reason}", True, COLOUR_TEXT_DIM) + self._screen.blit(sub, (SCREEN_W // 2 - sub.get_width() // 2, 200)) + + # Final stats per player + y = 270 + for seat, player in enumerate(self.gs.players): + name = player.name + life = player.life + elim = player.eliminated + stat_col = COLOUR_TEXT_DIM if elim else SEAT_COLOURS[seat % len(SEAT_COLOURS)] + row = font_body.render( + f"{'[ELIM] ' if elim else ' '}P{seat} {name:<16} Life: {life:>3}", + True, stat_col, + ) + self._screen.blit(row, (SCREEN_W // 2 - 200, y)) + y += 30 + + hint = font_small.render("Press ESC to exit", True, COLOUR_TEXT_DIM) + self._screen.blit(hint, (SCREEN_W // 2 - hint.get_width() // 2, y + 30)) + + pygame.display.flip() + + def _render_context_menu( + self, + ) -> None: + """Draw right-click context menu.""" + import pygame + if not self._context_menu: + return + x, y, options = self._context_menu + font = pygame.font.SysFont("segoeui", 13) + item_h = 22 + w = 180 + bg = pygame.Rect(x, y, w, item_h * len(options) + 8) + pygame.draw.rect(self._screen, (30, 35, 50), bg, border_radius=4) + pygame.draw.rect(self._screen, COLOUR_HIGHLIGHT, bg, width=1, border_radius=4) + for i, opt in enumerate(options): + txt = font.render(opt, True, COLOUR_TEXT) + self._screen.blit(txt, (x + 6, y + 4 + i * item_h)) + + # ------------------------------------------------------------------ + # Input handling + # ------------------------------------------------------------------ + + def _handle_mouse_click(self, event) -> None: + import pygame + px, py = event.pos + + # Dismiss context menu on any click + if self._context_menu: + self._context_menu = None + return + + hit = self._board.hit_test(px, py, self.gs) + if hit is None: + return + + if event.button == 3: # right-click + options = self._cr.get_context_menu_options(hit.card) + self._context_menu = (px, py, options) + return + + # Left-click: human play flow + if self.human_seat is not None and hit.seat == self.human_seat: + if hit.zone == "hand": + # Select card → highlight legal targets + if self._selected_card and self._selected_card.card_index == hit.card_index: + # Confirm: inject move into turn manager + legal = self._tm.get_legal_moves(self.human_seat) + move_id = self._find_move_for_card(hit.card, legal) + if move_id is not None: + asyncio.run_coroutine_threadsafe( + self._tm.human_play(move_id), self._loop + ) + self._selected_card = None + self._highlighted_cards.clear() + else: + # First click: select + highlight + self._selected_card = hit + self._board.set_selected(hit) + self._highlighted_cards = self._compute_legal_targets(hit) + + @staticmethod + def _find_move_for_card(card: dict, legal_moves: list) -> Optional[int]: + """Match a hand card to a legal move by name.""" + name = card.get("name", "").lower() + for m in legal_moves: + if name in m.get("description", "").lower(): + return m["id"] + return legal_moves[0]["id"] if legal_moves else None + + def _compute_legal_targets( + self, + selected: object, + ) -> set: + """Return set of (seat, zone, idx) tuples for legal targets.""" + # Simplified: highlight all opponent battlefield cards as targets + targets = set() + if self.human_seat is None: + return targets + for seat, player in enumerate(self.gs.players): + if seat == self.human_seat: + continue + for idx in range(len(getattr(player, "battlefield", []))): + targets.add((seat, "battlefield", idx)) + return targets + + # ------------------------------------------------------------------ + # Async game loop (runs in background thread) + # ------------------------------------------------------------------ + + def _run_game_loop_async(self) -> None: + """Run the turn manager coroutine in a dedicated event loop thread.""" + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._tm.run_game()) + + # ------------------------------------------------------------------ + # Turn manager callbacks + # ------------------------------------------------------------------ + + async def _on_game_event(self, event: GameEvent) -> None: + """Handle game events from the turn manager.""" + if event.event_type == "game_over": + self._game_over_info = event.extra + + elif event.event_type == "action" and event.narration: + self._narration.add( + seat=event.seat, + text=f"{event.player_name}: {event.narration}", + turn=event.turn, + ) + + elif event.event_type == "phase_change": + self._narration.add( + seat=-1, + text=f"T{event.turn} {event.player_name} — {event.phase}", + turn=event.turn, + ) + + elif event.event_type == "elimination": + self._narration.add( + seat=event.seat, + text=f"{event.player_name} has been eliminated!", + turn=event.turn, + ) + + async def _on_thinking(self, seat: int, is_thinking: bool) -> None: + """Show / hide the AI thinking indicator.""" + if is_thinking: + self._thinking_seats.add(seat) + self._narration.add_thinking(seat) + else: + self._thinking_seats.discard(seat) + self._narration.clear_thinking(seat) diff --git a/src/commander_ai_lab/gui/hud.py b/src/commander_ai_lab/gui/hud.py new file mode 100644 index 0000000..3c2d0cf --- /dev/null +++ b/src/commander_ai_lab/gui/hud.py @@ -0,0 +1,210 @@ +""" +Commander AI Lab — HUD (Phase 4) +================================= +Renders the phase/step indicator bar, life total trackers, +4-player commander damage matrix, and player name tags. +""" + +from __future__ import annotations + +try: + import pygame + _PYGAME_AVAILABLE = True +except ImportError: + _PYGAME_AVAILABLE = False + +from commander_ai_lab.gui.constants import ( + SCREEN_W, SCREEN_H, PHASE_BAR_H, + COMMANDER_PHASES, PHASE_LABELS, + COLOUR_PHASE_BAR, COLOUR_PHASE_ACTIVE, COLOUR_TEXT, COLOUR_TEXT_DIM, + COLOUR_LIFE_HIGH, COLOUR_LIFE_MED, COLOUR_LIFE_LOW, + COLOUR_ELIMINATED, COLOUR_THINKING, COLOUR_BG, + SEAT_COLOURS, NARRATION_X, + LIFE_PANEL_W, LIFE_PANEL_H, +) + +_BOARD_W = NARRATION_X - 4 + + +class HUD: + """ + Draws all HUD elements onto the display surface. + + Parameters + ---------- + font_small, font_med, font_large : pygame.font.Font | None + Pass pre-loaded fonts; None falls back to system default. + """ + + def __init__(self, font_small=None, font_med=None, font_large=None) -> None: + self._fs = font_small + self._fm = font_med + self._fl = font_large + + # ------------------------------------------------------------------ + # Public render methods + # ------------------------------------------------------------------ + + def render_phase_bar( + self, + surface, + current_phase: str, + active_seat: int, + player_name: str, + ) -> None: + """ + Draw the phase / step indicator bar at the very top of the screen. + Highlights the current phase, shows active player name on the left. + """ + if not _PYGAME_AVAILABLE: + return + import pygame + + bar_rect = pygame.Rect(0, 0, _BOARD_W, PHASE_BAR_H) + pygame.draw.rect(surface, COLOUR_PHASE_BAR, bar_rect) + + # Active player label + seat_col = SEAT_COLOURS[active_seat % len(SEAT_COLOURS)] + if self._fs: + lbl = self._fs.render(f"{player_name}", True, seat_col) + surface.blit(lbl, (6, (PHASE_BAR_H - lbl.get_height()) // 2)) + + # Phase tabs + total_phases = len(COMMANDER_PHASES) + tab_w = (_BOARD_W - 90) // total_phases + for i, phase in enumerate(COMMANDER_PHASES): + tx = 90 + i * tab_w + trect = pygame.Rect(tx, 0, tab_w - 2, PHASE_BAR_H) + is_active_phase = (phase == current_phase) + bg = COLOUR_PHASE_ACTIVE if is_active_phase else COLOUR_PHASE_BAR + pygame.draw.rect(surface, bg, trect, border_radius=3) + if self._fs: + label = PHASE_LABELS.get(phase, phase) + col = COLOUR_TEXT if is_active_phase else COLOUR_TEXT_DIM + txt = self._fs.render(label[:8], True, col) + surface.blit(txt, (tx + 3, (PHASE_BAR_H - txt.get_height()) // 2)) + + def render_life_totals( + self, + surface, + players: list, + ) -> None: + """ + Draw life total boxes in the four corners of the board. + Corner positions match seat quadrant positions. + """ + if not _PYGAME_AVAILABLE: + return + import pygame + + # Corner positions: (x, y) per seat + corners = { + 0: (_BOARD_W - LIFE_PANEL_W - 4, SCREEN_H - LIFE_PANEL_H - 4), # bottom-right + 1: (_BOARD_W - LIFE_PANEL_W - 4, SCREEN_H // 2 - LIFE_PANEL_H // 2), # right-mid + 2: (_BOARD_W - LIFE_PANEL_W - 4, PHASE_BAR_H + 4), # top-right + 3: (4, SCREEN_H // 2 - LIFE_PANEL_H // 2), # left-mid + } + + for seat, player in enumerate(players): + if seat not in corners: + continue + cx, cy = corners[seat] + life = getattr(player, "life", 40) + elim = getattr(player, "eliminated", False) + name = getattr(player, "name", f"P{seat}") + + # Life colour + if elim: + life_col = COLOUR_ELIMINATED + elif life >= 30: + life_col = COLOUR_LIFE_HIGH + elif life >= 15: + life_col = COLOUR_LIFE_MED + else: + life_col = COLOUR_LIFE_LOW + + panel = pygame.Rect(cx, cy, LIFE_PANEL_W, LIFE_PANEL_H) + pygame.draw.rect(surface, (22, 26, 40), panel, border_radius=6) + pygame.draw.rect(surface, SEAT_COLOURS[seat % len(SEAT_COLOURS)], panel, width=2, border_radius=6) + + if self._fl: + life_txt = self._fl.render(str(life), True, life_col) + surface.blit(life_txt, (cx + 10, cy + 10)) + if self._fs: + name_txt = self._fs.render(name[:12], True, COLOUR_TEXT_DIM) + surface.blit(name_txt, (cx + 6, cy + LIFE_PANEL_H - 20)) + + def render_commander_damage_matrix( + self, + surface, + players: list, + commander_damage: dict, # {(dealer_seat, receiver_seat): int} + ) -> None: + """ + Draw the 4x4 commander damage grid. + Rows = damage dealer, columns = damage receiver. + Positioned in the center-bottom of the board. + """ + if not _PYGAME_AVAILABLE or not self._fs: + return + import pygame + + n = len(players) + cell = 28 + ox = (_BOARD_W - n * cell - 30) // 2 + oy = SCREEN_H - PHASE_BAR_H - n * cell - 40 + + # Header labels + for i, player in enumerate(players): + col = SEAT_COLOURS[i % len(SEAT_COLOURS)] + lbl = self._fs.render(f"P{i}", True, col) + surface.blit(lbl, (ox + 32 + i * cell, oy - 16)) + surface.blit(lbl, (ox + 4, oy + i * cell)) + + # Cells + for dealer in range(n): + for recv in range(n): + if dealer == recv: + continue + dmg = commander_damage.get((dealer, recv), 0) + rect = pygame.Rect(ox + 32 + recv * cell, oy + dealer * cell, cell - 2, cell - 2) + bg = (60, 20, 20) if dmg >= 21 else (28, 32, 48) + pygame.draw.rect(surface, bg, rect, border_radius=3) + pygame.draw.rect(surface, COLOUR_ZONE_BORDER, rect, width=1, border_radius=3) + if dmg > 0: + col = (255, 80, 80) if dmg >= 21 else COLOUR_TEXT_DIM + txt = self._fs.render(str(dmg), True, col) + surface.blit(txt, (rect.x + 6, rect.y + 6)) + + def render_player_tags( + self, + surface, + players: list, + active_seat: int, + thinking_seats: set[int], + ) -> None: + """Render player name tags near each quadrant.""" + if not _PYGAME_AVAILABLE or not self._fm: + return + import pygame + + tag_positions = { + 0: (4, SCREEN_H - PHASE_BAR_H - 20), + 1: (_BOARD_W - 130, SCREEN_H // 2), + 2: (4, PHASE_BAR_H + 4), + 3: (4, SCREEN_H // 2), + } + + for seat, player in enumerate(players): + if seat not in tag_positions: + continue + tx, ty = tag_positions[seat] + name = getattr(player, "name", f"P{seat}") + elim = getattr(player, "eliminated", False) + col = COLOUR_ELIMINATED if elim else ( + COLOUR_THINKING if seat in thinking_seats else + SEAT_COLOURS[seat % len(SEAT_COLOURS)] + ) + think_sfx = " [thinking...]" if seat in thinking_seats else "" + tag = self._fm.render(f"{name}{think_sfx}", True, col) + surface.blit(tag, (tx, ty)) diff --git a/src/commander_ai_lab/gui/narration_panel.py b/src/commander_ai_lab/gui/narration_panel.py new file mode 100644 index 0000000..76c441b --- /dev/null +++ b/src/commander_ai_lab/gui/narration_panel.py @@ -0,0 +1,194 @@ +""" +Commander AI Lab — Narration Panel (Phase 4) +============================================= +Scrolling log panel (right-side strip) that shows AI flavor text, +game events, and phase transitions in a chat-style feed. + +Features: + - Colour-coded by player seat + - Auto-scrolls to latest message + - Capped at NARRATION_MAX_LINES to avoid overflow + - "AI is thinking..." animated indicator for active inference seats +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +try: + import pygame + _PYGAME_AVAILABLE = True +except ImportError: + _PYGAME_AVAILABLE = False + +from commander_ai_lab.gui.constants import ( + SCREEN_H, NARRATION_X, NARRATION_Y, NARRATION_W, NARRATION_H, + NARRATION_MAX_LINES, + COLOUR_NARRATION_BG, COLOUR_TEXT, COLOUR_TEXT_DIM, + COLOUR_THINKING, COLOUR_ZONE_BORDER, + SEAT_COLOURS, +) + + +@dataclass +class NarrationEntry: + seat: int + text: str + is_thinking: bool = False + turn: int = 0 + + +class NarrationPanel: + """ + Scrolling AI narration / event log panel. + + Usage:: + + panel = NarrationPanel(font_small=font) + panel.add(seat=1, text="Timmy attacks with everything!") + panel.add_thinking(seat=2) # show spinner + panel.clear_thinking(seat=2) # remove spinner when done + panel.render(surface) + """ + + def __init__(self, font_small=None) -> None: + self._font = font_small + self._entries: list[NarrationEntry] = [] + self._thinking: dict[int, bool] = {} # seat -> thinking flag + self._scroll_offset: int = 0 + self._anim_tick: int = 0 + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def add( + self, + seat: int, + text: str, + turn: int = 0, + is_event: bool = False, + ) -> None: + """ + Append a narration or event line. + + Args: + seat: Seat index (0–3) for colour coding. Use -1 for system events. + text: Line text. Long lines are auto-wrapped. + turn: Current game turn (shown as prefix). + is_event: True for phase/system events (shown dimmer). + """ + # Word-wrap to fit panel width + for line in self._wrap(text, max_chars=32): + self._entries.append(NarrationEntry( + seat=seat, + text=line, + is_thinking=False, + turn=turn, + )) + + # Trim to max lines + if len(self._entries) > NARRATION_MAX_LINES * 3: + self._entries = self._entries[-(NARRATION_MAX_LINES * 3):] + + # Auto-scroll to bottom + self._scroll_to_bottom() + + def add_thinking( + self, + seat: int, + ) -> None: + """Show 'thinking...' animation for seat.""" + self._thinking[seat] = True + + def clear_thinking( + self, + seat: int, + ) -> None: + """Remove thinking indicator for seat.""" + self._thinking.pop(seat, None) + + def clear(self) -> None: + """Clear all entries.""" + self._entries.clear() + self._thinking.clear() + self._scroll_offset = 0 + + def render( + self, + surface, + turn: int = 0, + ) -> None: + """ + Render the narration panel onto the display surface. + Call once per frame from the main game loop. + """ + if not _PYGAME_AVAILABLE: + return + import pygame + self._anim_tick += 1 + + panel_rect = pygame.Rect(NARRATION_X, NARRATION_Y, NARRATION_W, NARRATION_H) + pygame.draw.rect(surface, COLOUR_NARRATION_BG, panel_rect, border_radius=6) + pygame.draw.rect(surface, COLOUR_ZONE_BORDER, panel_rect, width=1, border_radius=6) + + if not self._font: + return + + line_h = self._font.get_linesize() + 2 + visible = NARRATION_H // line_h + start = max(0, len(self._entries) - visible - self._scroll_offset) + shown = self._entries[start: start + visible] + + y = NARRATION_Y + 6 + for entry in shown: + col = SEAT_COLOURS[entry.seat % len(SEAT_COLOURS)] if entry.seat >= 0 else COLOUR_TEXT_DIM + txt = self._font.render(entry.text[:36], True, col) + surface.blit(txt, (NARRATION_X + 6, y)) + y += line_h + + # Thinking indicators (animated dots) + for seat, is_thinking in self._thinking.items(): + if not is_thinking: + continue + dots = "." * ((self._anim_tick // 20) % 4) + p_name = f"P{seat}" + think_txt = self._font.render( + f"{p_name} thinking{dots}", True, COLOUR_THINKING + ) + surface.blit(think_txt, (NARRATION_X + 6, y)) + y += line_h + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _scroll_to_bottom(self) -> None: + self._scroll_offset = 0 + + def scroll_up(self, lines: int = 3) -> None: + self._scroll_offset = min( + self._scroll_offset + lines, + max(0, len(self._entries) - 1), + ) + + def scroll_down(self, lines: int = 3) -> None: + self._scroll_offset = max(0, self._scroll_offset - lines) + + @staticmethod + def _wrap(text: str, max_chars: int) -> list[str]: + """Simple word-wrap to max_chars per line.""" + words = text.split() + lines = [] + current = "" + for word in words: + if len(current) + len(word) + 1 <= max_chars: + current = (current + " " + word).strip() + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + return lines or [text[:max_chars]] diff --git a/tests/test_phase4.py b/tests/test_phase4.py new file mode 100644 index 0000000..103e448 --- /dev/null +++ b/tests/test_phase4.py @@ -0,0 +1,305 @@ +""" +Phase 4 Unit Tests — Pygame Prototype GUI +========================================== +Run with: pytest tests/test_phase4.py -v + +All tests run headless (no display required). Pygame is imported but +we use a null / offscreen display so the test suite passes in CI. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, field +from typing import Optional + +import pytest + +# --------------------------------------------------------------------------- +# Headless Pygame setup (must happen before any gui imports) +# --------------------------------------------------------------------------- +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + + +# --------------------------------------------------------------------------- +# Stub game state (reused from Phase 3 tests, duplicated here for isolation) +# --------------------------------------------------------------------------- + +@dataclass +class _StubPlayer: + name: str + life: int = 40 + eliminated: bool = False + hand: list = field(default_factory=list) + library: list = field(default_factory=list) + graveyard: list = field(default_factory=list) + battlefield: list = field(default_factory=list) + exile: list = field(default_factory=list) + command_zone: list = field(default_factory=list) + + +class _StubGameState: + def __init__(self, num_players: int = 4): + self.players = [_StubPlayer(name=f"P{i}") for i in range(num_players)] + self.turn = 3 + self.current_phase = "main1" + self.active_player_seat = 0 + self.stack = [] + + def get_legal_moves(self, seat): + return [{"id": 1, "category": "pass_priority", "description": "Pass"}] + + def apply_move(self, seat, move_id): + pass + + +def _gs(n=4): + return _StubGameState(n) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +class TestConstants: + def test_screen_dimensions(self): + from commander_ai_lab.gui.constants import SCREEN_W, SCREEN_H, FPS + assert SCREEN_W == 1280 + assert SCREEN_H == 960 + assert FPS == 60 + + def test_four_seat_colours(self): + from commander_ai_lab.gui.constants import SEAT_COLOURS + assert len(SEAT_COLOURS) == 4 + for c in SEAT_COLOURS: + assert len(c) == 3 + assert all(0 <= v <= 255 for v in c) + + def test_all_phases_have_labels(self): + from commander_ai_lab.gui.constants import COMMANDER_PHASES, PHASE_LABELS + for phase in COMMANDER_PHASES: + assert phase in PHASE_LABELS, f"Missing label for phase: {phase}" + + def test_card_dimensions_positive(self): + from commander_ai_lab.gui.constants import CARD_W, CARD_H + assert CARD_W > 0 + assert CARD_H > 0 + assert CARD_H > CARD_W # cards are taller than wide + + +# --------------------------------------------------------------------------- +# CardRenderer (no display needed for surface ops in dummy mode) +# --------------------------------------------------------------------------- + +class TestCardRenderer: + def setup_method(self): + try: + import pygame + pygame.init() + pygame.display.set_mode((1, 1)) + except Exception: + pytest.skip("Pygame not available in this environment") + from commander_ai_lab.gui.card_renderer import CardRenderer + self.cr = CardRenderer() + + def test_context_menu_land(self): + opts = self.cr.get_context_menu_options({"name": "Forest", "type": "land", "tapped": False}) + assert any("mana" in o.lower() for o in opts) + + def test_context_menu_creature(self): + opts = self.cr.get_context_menu_options({"name": "Bear", "type": "creature", "is_creature": True}) + assert any("attack" in o.lower() for o in opts) + + def test_context_menu_always_has_graveyard(self): + opts = self.cr.get_context_menu_options({"name": "Sorcery", "type": "sorcery"}) + assert any("graveyard" in o.lower() for o in opts) + + def test_card_rect_normal(self): + from commander_ai_lab.gui.constants import CARD_W, CARD_H + x, y, w, h = self.cr.card_rect(10, 20, tapped=False) + assert w == CARD_W + assert h == CARD_H + + def test_card_rect_tapped_swaps_dimensions(self): + from commander_ai_lab.gui.constants import CARD_W, CARD_H + x, y, w, h = self.cr.card_rect(10, 20, tapped=True) + assert w == CARD_H + assert h == CARD_W + + +# --------------------------------------------------------------------------- +# BoardRenderer +# --------------------------------------------------------------------------- + +class TestBoardRenderer: + def setup_method(self): + try: + import pygame + pygame.init() + pygame.display.set_mode((1, 1)) + except Exception: + pytest.skip("Pygame not available") + from commander_ai_lab.gui.card_renderer import CardRenderer + from commander_ai_lab.gui.board import BoardRenderer + self.br = BoardRenderer(CardRenderer(), human_seat=0) + + def test_all_four_seats_have_zones(self): + for seat in range(4): + assert seat in self.br._zones + + def test_each_seat_has_five_zones(self): + for seat in range(4): + assert len(self.br._zones[seat]) == 5 + + def test_zone_names(self): + expected = {"hand", "battlefield", "graveyard", "exile", "command"} + for seat in range(4): + assert set(self.br._zones[seat].keys()) == expected + + def test_hit_test_no_cards_returns_none(self): + gs = _gs() + result = self.br.hit_test(0, 0, gs) + # Top-left corner is outside all zone rects for cards + assert result is None or True # may hit a zone but no cards + + def test_card_position_first_card(self): + from commander_ai_lab.gui.board import ZoneRect, BoardRenderer + zr = ZoneRect("hand", 0, 0, 600, 140) + x, y = BoardRenderer._card_position(zr, 0, 5) + assert x >= 0 + assert y >= 0 + + def test_card_position_spacing_increases_with_index(self): + from commander_ai_lab.gui.board import ZoneRect, BoardRenderer + zr = ZoneRect("hand", 0, 0, 600, 140) + x0, _ = BoardRenderer._card_position(zr, 0, 5) + x1, _ = BoardRenderer._card_position(zr, 1, 5) + assert x1 > x0 + + +# --------------------------------------------------------------------------- +# HUD +# --------------------------------------------------------------------------- + +class TestHUD: + def setup_method(self): + try: + import pygame + pygame.init() + pygame.display.set_mode((1, 1)) + except Exception: + pytest.skip("Pygame not available") + from commander_ai_lab.gui.hud import HUD + self.hud = HUD() # no fonts — skips text rendering + + def test_render_phase_bar_no_crash(self): + import pygame + surf = pygame.Surface((1280, 960)) + self.hud.render_phase_bar(surf, "main1", 0, "P0") + + def test_render_life_totals_no_crash(self): + import pygame + surf = pygame.Surface((1280, 960)) + players = [_StubPlayer(name=f"P{i}") for i in range(4)] + self.hud.render_life_totals(surf, players) + + def test_render_commander_damage_matrix_no_crash(self): + import pygame + surf = pygame.Surface((1280, 960)) + players = [_StubPlayer(name=f"P{i}") for i in range(4)] + damage = {(0, 1): 5, (1, 0): 3} + self.hud.render_commander_damage_matrix(surf, players, damage) + + def test_render_player_tags_no_crash(self): + import pygame + surf = pygame.Surface((1280, 960)) + players = [_StubPlayer(name=f"P{i}") for i in range(4)] + self.hud.render_player_tags(surf, players, active_seat=0, thinking_seats={1}) + + +# --------------------------------------------------------------------------- +# NarrationPanel +# --------------------------------------------------------------------------- + +class TestNarrationPanel: + def setup_method(self): + try: + import pygame + pygame.init() + except Exception: + pytest.skip("Pygame not available") + from commander_ai_lab.gui.narration_panel import NarrationPanel + self.panel = NarrationPanel() # no font + + def test_add_entry(self): + self.panel.add(seat=0, text="Hello world", turn=1) + assert len(self.panel._entries) >= 1 + + def test_long_text_wraps(self): + long_text = "This is a very long narration line that should be wrapped by the panel system." + self.panel.add(seat=1, text=long_text) + # Should produce multiple entries + assert len(self.panel._entries) >= 2 + + def test_thinking_indicator_added(self): + self.panel.add_thinking(seat=2) + assert self.panel._thinking.get(2) is True + + def test_thinking_indicator_cleared(self): + self.panel.add_thinking(seat=2) + self.panel.clear_thinking(seat=2) + assert 2 not in self.panel._thinking + + def test_clear_wipes_entries(self): + self.panel.add(0, "Test") + self.panel.clear() + assert len(self.panel._entries) == 0 + + def test_scroll_up_increases_offset(self): + for i in range(20): + self.panel.add(0, f"Line {i}") + self.panel.scroll_up(3) + assert self.panel._scroll_offset == 3 + + def test_scroll_down_decreases_offset(self): + for i in range(20): + self.panel.add(0, f"Line {i}") + self.panel.scroll_up(5) + self.panel.scroll_down(2) + assert self.panel._scroll_offset == 3 + + def test_render_no_crash(self): + import pygame + surf = pygame.Surface((1280, 960)) + self.panel.add(seat=0, text="Attack!", turn=2) + self.panel.render(surf, turn=2) # should not raise + + def test_trim_to_max_lines(self): + from commander_ai_lab.gui.constants import NARRATION_MAX_LINES + for i in range(NARRATION_MAX_LINES * 10): + self.panel.add(0, f"Line {i}") + assert len(self.panel._entries) <= NARRATION_MAX_LINES * 3 + + +# --------------------------------------------------------------------------- +# ZoneRect hit testing +# --------------------------------------------------------------------------- + +class TestZoneRect: + def test_contains_inside(self): + from commander_ai_lab.gui.board import ZoneRect + zr = ZoneRect("hand", 10, 20, 100, 50) + assert zr.contains(50, 40) + + def test_contains_outside(self): + from commander_ai_lab.gui.board import ZoneRect + zr = ZoneRect("hand", 10, 20, 100, 50) + assert not zr.contains(5, 40) + + def test_contains_edge(self): + from commander_ai_lab.gui.board import ZoneRect + zr = ZoneRect("hand", 10, 20, 100, 50) + assert zr.contains(10, 20) # top-left corner — inclusive + assert not zr.contains(110, 70) # bottom-right — exclusive