Phase 4: Add Pygame prototype GUI (gui/ package + tests/test_phase4.py)
This commit is contained in:
1 parent
605ff774ed
commit
3c3dceb737
8 files changed
+1822
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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]]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user