Phase 4: Add Pygame prototype GUI (gui/ package + tests/test_phase4.py)

This commit is contained in:
KoalaTrapLord committed 2026-03-14 20:10:43 -04:00
1 parent 605ff774ed
commit 3c3dceb737
8 files changed
+1822

No files matched your search

+14
View File
@@ -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",
]
+355
View File
@@ -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 []
+276
View File
@@ -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
+93
View File
@@ -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",
}
+375
View File
@@ -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)
+210
View File
@@ -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))
+194
View File
@@ -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]]
+305
View File
@@ -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