"""Lost Cities classic pygame GUI.""" from __future__ import annotations import argparse import json import logging import os import sys from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, Literal from .bots import DEFAULT_BOT, LostCitiesBot, available_bot_names, build_bot from .engines import FastGameState as GameState from .game import Card, LostCitiesConfig, classic_config from .resources import theme_path from .snapshots import Snapshot, snapshot_from_state, snapshot_summary LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.pygame_pvp") ModeName = Literal["pvp", "pvc"] COLOR_PALETTE = [ (255, 78, 83), (83, 141, 244), (116, 174, 82), (232, 181, 59), (164, 99, 202), (67, 205, 196), (255, 139, 66), (220, 92, 170), ] BG = (0, 0, 0) CARD_BG = (3, 4, 5) LINE = (58, 58, 64) LINE_DARK = (35, 35, 39) TEXT = (222, 222, 228) MUTED = (158, 155, 160) GOLD = (232, 178, 0) COLOR_NAME_PALETTE = ["Red", "Blue", "Green", "Gold", "Violet", "Cyan", "Orange", "Rose"] @dataclass(frozen=True) class ActionTarget: rect: Any action_id: int label: str def color_rgb(color: int) -> tuple[int, int, int]: return COLOR_PALETTE[color % len(COLOR_PALETTE)] def color_name(color: int) -> str: if color < len(COLOR_NAME_PALETTE): return COLOR_NAME_PALETTE[color] return f"Color {color}" def card_value_label(card: Card, config: LostCitiesConfig) -> str: if card.is_handshake: return "H" return str(card.numeric_value(config.min_rank)) def card_label(card: Card, config: LostCitiesConfig) -> str: return f"{color_name(card.color)} {card_value_label(card, config)}" def cards_to_json(cards: list[Card]) -> list[dict[str, int]]: return [card.to_snapshot() for card in cards] def snapshot_to_json(snapshot: Snapshot) -> dict[str, Any]: return { "config": snapshot.config.to_snapshot(), "deck": cards_to_json(snapshot.deck), "hands": [cards_to_json(hand) for hand in snapshot.hands], "expeditions": [ [cards_to_json(expedition) for expedition in player_expeditions] for player_expeditions in snapshot.expeditions ], "discards": [cards_to_json(discard) for discard in snapshot.discards], "current_player": snapshot.current_player, "phase": snapshot.phase, "pending_discarded_color": snapshot.pending_discarded_color, "turn_count": snapshot.turn_count, "terminal": snapshot.terminal, "legal_mask": list(snapshot.legal_mask), "scores": [snapshot.total_score(0), snapshot.total_score(1)], "score_diff_player0": snapshot.score_diff(0), } def turn_identity_summary(identity: tuple[int, str, int] | None) -> str: if identity is None: return "없음" player, phase, turn = identity phase_name = "카드" if phase == "card" else "뽑기" return f"플레이어={player} 단계={phase_name} 턴={turn}" def undo_until_player_card_phase( app: LostCitiesGuiApp, *, player: int, ) -> int: undone = 0 while app.can_undo(): changed = app.undo_once() if not changed: break undone += 1 snapshot = app.snapshot() if snapshot.current_player == player and snapshot.phase == "card": break return undone def configure_debug_logging() -> None: root = logging.getLogger() if not root.handlers: logging.basicConfig( level=logging.DEBUG, stream=sys.stderr, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) else: root.setLevel(logging.DEBUG) LOGGER.setLevel(logging.DEBUG) def preferred_font_path() -> Path | None: return None def pygame_display_flags(pygame_module: Any) -> int: flags = pygame_module.RESIZABLE if os.environ.get("COOLRL_X11_SOFTWARE") != "1": flags |= pygame_module.SCALED return flags def build_argparser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Play Lost Cities in one pygame window.") parser.add_argument("--mode", choices=("pvp", "pvc"), default="pvp") parser.add_argument("--bot", choices=available_bot_names(), default=DEFAULT_BOT) parser.add_argument( "--model", dest="bot", choices=available_bot_names(), help=argparse.SUPPRESS, ) parser.add_argument("--seed", type=int, default=None) parser.add_argument("--width", type=int, default=1536) parser.add_argument("--height", type=int, default=964) parser.add_argument("--screenshot-on-start", action="store_true") return parser class LostCitiesGuiApp: def __init__( self, *, mode: ModeName = "pvp", bot_name: str = DEFAULT_BOT, seed: int | None = None, width: int = 1536, height: int = 964, screenshot_on_start: bool = False, ): import pygame import pygame_gui configure_debug_logging() pygame.init() pygame.display.set_caption("COOLRL LOST CITIES") self.pygame = pygame self.pygame_gui = pygame_gui self.window_size = (width, height) self.screen = pygame.display.set_mode( self.window_size, pygame_display_flags(pygame), vsync=0, ) self.font_path = preferred_font_path() self.font_cache: dict[tuple[int, bool], Any] = {} self.manager = pygame_gui.UIManager(self.window_size) self._configure_ui_theme() self.clock = pygame.time.Clock() self.seed = seed self.mode: ModeName = mode self.bot_name = bot_name self.computer_player = 1 self.next_computer_action_at_ms = 0 self.config = classic_config() self.computer_bot, self.computer_bot_label = self._build_computer_bot() self.state = GameState.new_game(self.config, seed=self.seed) self.history: list[GameState] = [] self.ui_elements: list[Any] = [] self.hand_card_rects: dict[int, Any] = {} self.board_targets: list[ActionTarget] = [] self.mode_dropdown: Any = None self.bot_dropdown: Any = None self.new_game_button: Any = None self.undo_button: Any = None self.export_button: Any = None self.selected_card_slot: int | None = None self.error_text: str | None = None self.export_text: str | None = None self.match_trace: list[dict[str, Any]] = [] self.last_turn_identity: tuple[int, str, int] | None = None self.turn_flash_until_ms = 0 self.screenshot_on_start = screenshot_on_start self.pending_startup_screenshot = screenshot_on_start self._reset_match_trace() self.rebuild_ui() LOGGER.debug( "GUI 앱 초기화: 모드=%s 봇=%s 시드=%s 크기=%sx%s 색상수=%s 손패=%s 덱=%s", self.mode, self.computer_bot_label, self.seed, width, height, self.config.n_colors, self.config.hand_size, self.config.deck_size, ) def _bot_seed(self) -> int | None: if self.seed is None: return None return self.seed + 10_001 def _computer_bot_display_name(self) -> str: return self.computer_bot_label def _build_computer_bot(self) -> tuple[LostCitiesBot, str]: return build_bot(self.bot_name, seed=self._bot_seed()), self.bot_name def snapshot(self) -> Snapshot: return snapshot_from_state(self.state) def can_undo(self) -> bool: return bool(self.history) def apply_state_action(self, action_id: int) -> None: self.history.append(self.state.clone()) try: self.state.apply_unified_action(action_id) except Exception: self.history.pop() raise def undo_once(self) -> bool: if not self.history: return False self.state = self.history.pop() return True def _configure_ui_theme(self) -> None: theme = json.loads(theme_path().read_text()) if self.font_path is not None: font_path = str(self.font_path) self.manager.add_font_paths("b612", font_path) self.manager.preload_fonts( [{"name": "b612", "point_size": size} for size in (14, 16, 18, 20)] ) LOGGER.debug("B612 폰트 사용: %s", self.font_path) else: theme.get("defaults", {}).pop("font", None) LOGGER.debug("pygame_gui 기본 대체 폰트 사용") self.manager.get_theme().update_theming(theme) def run(self) -> None: pygame = self.pygame running = True while running: time_delta = self.clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: LOGGER.debug("pygame 종료 이벤트") running = False continue self.manager.process_events(event) self.handle_ui_event(event) self.maybe_apply_computer_action() self.manager.update(time_delta) self.draw() self.manager.draw_ui(self.screen) if self.pending_startup_screenshot: self.pending_startup_screenshot = False self.save_screenshot() pygame.display.flip() pygame.quit() def handle_ui_event(self, event: Any) -> None: pygame_gui = self.pygame_gui if event.type == pygame_gui.UI_BUTTON_PRESSED: if event.ui_element == self.new_game_button: LOGGER.debug("새 게임 버튼 클릭") self.reset_game() return if event.ui_element == self.undo_button: LOGGER.debug("되돌리기 버튼 클릭") self.undo() return if event.ui_element == self.export_button: LOGGER.debug("내보내기 버튼 클릭") self.export_match_trace() return elif event.type == pygame_gui.UI_DROP_DOWN_MENU_CHANGED: if event.ui_element == self.mode_dropdown: LOGGER.debug("모드 변경: %s -> %s", self.mode, event.text) self.mode = event.text self.reset_game() elif event.ui_element == self.bot_dropdown: LOGGER.debug("봇 변경: %s -> %s", self.bot_name, event.text) self.bot_name = event.text self.reset_game() elif event.type == self.pygame.KEYDOWN: if event.key == self.pygame.K_z and (event.mod & self.pygame.KMOD_CTRL): LOGGER.debug("컨트롤+Z 입력") self.undo() elif event.key == self.pygame.K_F12: LOGGER.debug("F12 입력: 전체 스크린샷 저장") self.save_screenshot() elif event.key == self.pygame.K_F11: LOGGER.debug("F11 입력: 헤더 스크린샷 저장") self.save_header_screenshot() elif event.type == self.pygame.MOUSEBUTTONDOWN and event.button == 1: LOGGER.debug("마우스 클릭: 위치=%s", event.pos) self.handle_board_click(event.pos) def is_computer_turn(self, snapshot: Snapshot | None = None) -> bool: snapshot = snapshot or self.snapshot() return ( self.mode == "pvc" and not snapshot.terminal and snapshot.current_player == self.computer_player ) def maybe_apply_computer_action(self) -> None: snapshot = self.snapshot() if not self.is_computer_turn(snapshot): self.next_computer_action_at_ms = 0 return now = self.pygame.time.get_ticks() if self.next_computer_action_at_ms == 0: self.next_computer_action_at_ms = now + 360 return if now < self.next_computer_action_at_ms: return try: bot_input = self._computer_bot_input(snapshot) action_id = self.computer_bot.act(bot_input) if isinstance(bot_input, GameState): action_id = bot_input.to_unified_action(action_id) LOGGER.debug( "컴퓨터 액션 선택: 봇=%s 입력=%s 액션=%s 상태={%s}", self._computer_bot_display_name(), type(bot_input).__name__, action_id, snapshot_summary(snapshot), ) self.apply_action(action_id, rebuild=False) except Exception as exc: self.error_text = str(exc) LOGGER.exception("컴퓨터 액션 실패: 봇=%s", self._computer_bot_display_name()) self.next_computer_action_at_ms = self.pygame.time.get_ticks() + 360 self.rebuild_ui() def _computer_bot_input(self, snapshot: Snapshot) -> Snapshot | GameState: if self.mode == "pvc": return GameState.from_snapshot(snapshot_to_json(snapshot)) return snapshot def handle_board_click(self, pos: tuple[int, int]) -> None: snapshot = self.snapshot() if self.is_computer_turn(snapshot): LOGGER.debug( "보드 클릭 무시: 컴퓨터 턴 위치=%s 상태={%s}", pos, snapshot_summary(snapshot) ) return if snapshot.terminal: LOGGER.debug( "보드 클릭 무시: 종료 상태 위치=%s 상태={%s}", pos, snapshot_summary(snapshot) ) return for target in self.board_targets: if target.rect.collidepoint(pos): LOGGER.debug( "타겟 클릭: 라벨=%s 액션=%s 위치=%s 상태={%s}", target.label, target.action_id, pos, snapshot_summary(snapshot), ) self.apply_action(target.action_id) return if snapshot.phase != "card": LOGGER.debug( "보드 클릭 무시: 카드 단계 아님 위치=%s 상태={%s}", pos, snapshot_summary(snapshot) ) return for slot, rect in self.hand_card_rects.items(): if rect.collidepoint(pos): self.selected_card_slot = slot card = snapshot.hands[snapshot.current_player][slot] LOGGER.debug( "카드 선택: 슬롯=%s 카드=%s 상태={%s}", slot, card_label(card, snapshot.config), snapshot_summary(snapshot), ) return LOGGER.debug("보드 클릭: 대상 없음 위치=%s 상태={%s}", pos, snapshot_summary(snapshot)) def reset_game(self) -> None: self.selected_card_slot = None self.hand_card_rects = {} self.board_targets = [] self.last_turn_identity = None self.turn_flash_until_ms = 0 self.next_computer_action_at_ms = 0 self.export_text = None try: self.computer_bot, self.computer_bot_label = self._build_computer_bot() self.state = GameState.new_game(self.config, seed=self.seed) self.history = [] self._reset_match_trace() self.error_text = None LOGGER.debug( "게임 초기화 완료: 시드=%s 상태={%s}", self.seed, snapshot_summary(self.snapshot()), ) except Exception as exc: self.error_text = str(exc) LOGGER.exception("게임 초기화 실패: 시드=%s", self.seed) self.rebuild_ui() def apply_action(self, action_id: int, *, rebuild: bool = True) -> None: try: before = self.snapshot() LOGGER.debug("액션 적용 요청: 액션=%s 상태={%s}", action_id, snapshot_summary(before)) self.apply_state_action(action_id) after = self.snapshot() self._append_match_trace_step(action_id=action_id, before=before, after=after) self.selected_card_slot = None self.hand_card_rects = {} self.board_targets = [] self.error_text = None self.export_text = None LOGGER.debug( "액션 적용 완료: 액션=%s 상태={%s}", action_id, snapshot_summary(after), ) except Exception as exc: self.error_text = str(exc) LOGGER.exception("액션 적용 실패: 액션=%s", action_id) if rebuild: self.rebuild_ui() def undo(self) -> None: try: before = self.snapshot() if self.mode == "pvc": undo_count = undo_until_player_card_phase( self, player=1 - self.computer_player, ) else: undo_count = 1 if self.undo_once() else 0 changed = undo_count > 0 self.selected_card_slot = None self.hand_card_rects = {} self.board_targets = [] self.next_computer_action_at_ms = 0 self.error_text = None if changed else "되돌릴 수 없음" if changed: for _ in range(undo_count): if len(self.match_trace) <= 1: break self.match_trace.pop() self.export_text = None LOGGER.debug( "되돌리기 요청: 변경됨=%s 횟수=%s 이전={%s} 이후={%s}", changed, undo_count, snapshot_summary(before), snapshot_summary(self.snapshot()), ) except Exception as exc: self.error_text = str(exc) LOGGER.exception("되돌리기 실패") self.rebuild_ui() def _reset_match_trace(self) -> None: snapshot = self.snapshot() self.match_trace = [ self._trace_record( step_index=0, action_id=None, actor=None, phase_before=None, snapshot=snapshot, ) ] def _append_match_trace_step( self, *, action_id: int, before: Snapshot, after: Snapshot, ) -> None: self.match_trace.append( self._trace_record( step_index=len(self.match_trace), action_id=action_id, actor=before.current_player, phase_before=before.phase, snapshot=after, ) ) def _trace_record( self, *, step_index: int, action_id: int | None, actor: int | None, phase_before: str | None, snapshot: Snapshot, ) -> dict[str, Any]: return { "type": "step", "step_index": step_index, "action_id": action_id, "actor": actor, "phase_before": phase_before, "state": snapshot_to_json(snapshot), } def export_match_trace(self) -> None: snapshot = self.snapshot() if not snapshot.terminal: self.error_text = "대국 종료 후 내보낼 수 있음" self.rebuild_ui() return export_dir = Path.cwd() / "exports" timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") seed_label = "none" if self.seed is None else str(self.seed) path = export_dir / ( f"lost-cities-classic-{self.mode}-python-seed-{seed_label}-{timestamp}.jsonl" ) metadata = { "type": "metadata", "format": "coolrl_lost_cities.games.classic.match_trace.v1", "created_at": datetime.now().astimezone().isoformat(), "variant": "classic", "mode": self.mode, "bot": self._computer_bot_display_name() if self.mode == "pvc" else None, "engine": "cython", "seed": self.seed, "config": self.config.to_snapshot(), "step_count": len(self.match_trace), "score": [snapshot.total_score(0), snapshot.total_score(1)], "score_diff_player0": snapshot.score_diff(0), } try: export_dir.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as handle: handle.write(json.dumps(metadata, ensure_ascii=False) + "\n") for record in self.match_trace: handle.write(json.dumps(record, ensure_ascii=False) + "\n") self.error_text = None self.export_text = f"Exported {path}" LOGGER.debug("대국 내보내기 완료: %s", path) except Exception as exc: self.error_text = str(exc) self.export_text = None LOGGER.exception("대국 내보내기 실패: %s", path) self.rebuild_ui() def _hand_layout( self, snapshot: Snapshot, player: int, y: int ) -> tuple[int, int, int, int, int]: width, _ = self.window_size status_x = width - 404 card_x = 340 available = max(360, status_x - card_x - 40) count = max(1, snapshot.config.hand_size) gap = 14 card_w = min(126, max(54, (available - gap * (count - 1)) // count)) card_h = max(70, int(card_w * 1.22)) if count > 1: gap = max(8, min(18, (available - card_w * count) // (count - 1))) card_y = y + (14 if player == 1 else 28) return card_x, card_y, card_w, card_h, gap def rebuild_ui(self) -> None: pygame = self.pygame pygame_gui = self.pygame_gui width, _ = self.window_size for element in self.ui_elements: element.kill() self.ui_elements = [] self.mode_dropdown = pygame_gui.elements.UIDropDownMenu( options_list=["pvp", "pvc"], starting_option=self.mode, relative_rect=pygame.Rect(92, 17, 108, 46), manager=self.manager, ) self.bot_dropdown = pygame_gui.elements.UIDropDownMenu( options_list=available_bot_names(), starting_option=self.bot_name, relative_rect=pygame.Rect(288, 17, 138, 46), manager=self.manager, ) if self.mode != "pvc": self.bot_dropdown.disable() game_label = pygame_gui.elements.UILabel( relative_rect=pygame.Rect(448, 17, 190, 46), text="CLASSIC / PYTHON", manager=self.manager, ) self.new_game_button = pygame_gui.elements.UIButton( relative_rect=pygame.Rect(660, 17, 148, 46), text="NEW GAME", manager=self.manager, ) self.undo_button = pygame_gui.elements.UIButton( relative_rect=pygame.Rect(width - 248, 17, 110, 46), text="UNDO", manager=self.manager, ) if not self.can_undo(): self.undo_button.disable() self.export_button = pygame_gui.elements.UIButton( relative_rect=pygame.Rect(width - 128, 17, 110, 46), text="EXPORT", manager=self.manager, ) if not self.snapshot().terminal: self.export_button.disable() self.ui_elements.extend( [ self.mode_dropdown, self.bot_dropdown, game_label, self.new_game_button, self.undo_button, self.export_button, ] ) def draw(self) -> None: snapshot = self.snapshot() self._sync_turn_identity(snapshot) self.hand_card_rects = {} self.board_targets = [] self.screen.fill(BG) self._draw_header(snapshot) self._draw_player_area(snapshot, player=1, y=110) self._draw_center(snapshot) self._draw_player_area(snapshot, player=0, y=self.window_size[1] - 257) if self.error_text: self._draw_text(self.error_text.upper(), (740, 50), color_rgb(0), 18, bold=True) elif self.export_text: self._draw_text(self.export_text, (740, 50), GOLD, 16, bold=True) def _draw_header(self, snapshot: Snapshot) -> None: pygame = self.pygame width, _ = self.window_size compact = width < 1700 header_left = 1098 header_right = width - 266 show_meta = header_right - header_left >= 280 pygame.draw.line(self.screen, LINE, (0, 0), (width, 0), 1) pygame.draw.line(self.screen, LINE, (0, 90), (width, 90), 1) pygame.draw.line(self.screen, LINE, (1080, 0), (1080, 90), 1) self._draw_text("MODE", (31, 31), MUTED, 18) self._draw_text("BOT", (220, 31), MUTED, 18) self._draw_text("GAME", (448, 31), MUTED, 18) if snapshot.terminal: diff = snapshot.score_diff(0) if diff > 0: status = "P0 WINS" if compact else "PLAYER 0 WINS" elif diff < 0: status = "P1 WINS" if compact else "PLAYER 1 WINS" else: status = "DRAW" detail = "Start a new game." if compact else "START A NEW GAME TO PLAY AGAIN." elif snapshot.phase == "card": card = self._selected_card(snapshot) if self.is_computer_turn(snapshot): status = ( "CPU: CHOOSE CARD" if compact else f"COMPUTER ({self._computer_bot_display_name().upper()}): CHOOSE A CARD" ) detail = "Waiting for bot action." elif card is None: status = ( f"P{snapshot.current_player}: CHOOSE CARD" if compact else f"PLAYER {snapshot.current_player}: CHOOSE A CARD" ) detail = "Click active hand." if compact else "Click a card in the active hand." else: status = ( f"P{snapshot.current_player}: CHOOSE TARGET" if compact else f"PLAYER {snapshot.current_player}: CHOOSE CARD DESTINATION" ) detail = ( f"{card_label(card, snapshot.config)} selected." if compact else f"{card_label(card, snapshot.config)} selected. Click expedition or discard." ) else: if self.is_computer_turn(snapshot): status = ( "CPU: DRAW" if compact else f"COMPUTER ({self._computer_bot_display_name().upper()}): DRAW A CARD" ) detail = "Waiting for bot action." else: status = ( f"P{snapshot.current_player}: DRAW" if compact else f"PLAYER {snapshot.current_player}: DRAW A CARD" ) detail = ( "Click deck/discard." if compact else "Click the deck or a highlighted discard pile." ) title_size = 24 if compact else 30 detail_size = 14 if compact else 18 header_clip = pygame.Rect(header_left, 0, max(0, header_right - header_left), 90) previous_clip = self.screen.get_clip() self.screen.set_clip(header_clip) self._draw_text(status, (header_left, 19), TEXT, title_size, bold=True) self._draw_text(detail, (header_left, 54), MUTED, detail_size) if show_meta: bot_meta = "" if self.mode == "pvc": bot_meta = f" BOT: {self._computer_bot_display_name()}" meta = f"{self.mode.upper()} TURN: {snapshot.turn_count}{bot_meta}" self._draw_text_right(meta, (header_right, 54), MUTED, detail_size) self.screen.set_clip(previous_clip) def _screenshot_dir(self) -> Path: return Path("/tmp") def _screenshot_path(self, suffix: str) -> Path: timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") return self._screenshot_dir() / f"lost-cities-{suffix}-{timestamp}.png" def save_screenshot(self) -> Path: path = self._screenshot_path("full") self._screenshot_dir().mkdir(parents=True, exist_ok=True) self.pygame.image.save(self.screen, str(path)) self.export_text = f"Saved {path}" LOGGER.debug("전체 스크린샷 저장: %s", path) return path def save_header_screenshot(self) -> Path: path = self._screenshot_path("header") self._screenshot_dir().mkdir(parents=True, exist_ok=True) header_surface = self.screen.subsurface( self.pygame.Rect(0, 0, self.window_size[0], 90) ).copy() self.pygame.image.save(header_surface, str(path)) self.export_text = f"Saved {path}" LOGGER.debug("헤더 스크린샷 저장: %s", path) return path def _draw_player_area(self, snapshot: Snapshot, *, player: int, y: int) -> None: pygame = self.pygame width, _ = self.window_size active = not snapshot.terminal and snapshot.current_player == player border = GOLD if active else LINE panel = pygame.Rect(26, y, width - 52, 182 if player == 1 else 210) border_width = 4 if active and self._turn_flash_active(player) else 2 if active else 1 self._draw_panel_rect(panel, border, width=border_width) self._draw_text(f"PLAYER {player}", (48, y + 23), TEXT, 28) self._draw_text(f"SCORE {snapshot.total_score(player)}", (204, y + 29), TEXT, 18) self._draw_text("HAND", (80, y + 76), MUTED, 18) pygame.draw.line(self.screen, LINE, (44, y + 77), (67, y + 77), 1) pygame.draw.line(self.screen, LINE, (49, y + 72), (49, y + 87), 1) card_x, card_y, card_w, card_h, card_gap = self._hand_layout(snapshot, player, y) for slot, card in enumerate(snapshot.hands[player]): selectable = active and snapshot.phase == "card" selected = selectable and slot == self.selected_card_slot rect = self._draw_card( card, (card_x + slot * (card_w + card_gap), card_y), snapshot.config, large=True, size=(card_w, card_h), selected=selected, selectable=selectable, ) if selectable: self.hand_card_rects[slot] = rect if active: status_box = pygame.Rect(width - 404, y + (45 if player == 0 else 42), 330, 92) pygame.draw.rect(self.screen, BG, status_box) pygame.draw.rect(self.screen, LINE, status_box, width=1) active_label = "COMPUTER" if self.is_computer_turn(snapshot) else "ACTIVE" self._draw_text(active_label, (status_box.x + 22, status_box.y + 22), GOLD, 20) if self.is_computer_turn(snapshot): prompt = f"Bot: {self._computer_bot_display_name()}" elif snapshot.phase == "card": prompt = ( "Yellow border: expedition or discard" if self.selected_card_slot is not None else "Select card, then click a target" ) else: prompt = "Click deck or discard" self._draw_text(prompt, (status_box.x + 22, status_box.y + 56), MUTED, 16) def _sync_turn_identity(self, snapshot: Snapshot) -> None: identity = (snapshot.current_player, snapshot.phase, snapshot.turn_count) if identity == self.last_turn_identity: return previous = self.last_turn_identity self.last_turn_identity = identity if snapshot.phase == "card" and not snapshot.terminal: self.turn_flash_until_ms = self.pygame.time.get_ticks() + 1200 LOGGER.debug( "턴 전환 감지: 이전=%s 현재플레이어=%s 단계=%s 턴=%s", turn_identity_summary(previous), snapshot.current_player, "카드" if snapshot.phase == "card" else "뽑기", snapshot.turn_count, ) def _turn_flash_active(self, player: int) -> bool: snapshot = self.snapshot() return ( snapshot.current_player == player and snapshot.phase == "card" and self.pygame.time.get_ticks() < self.turn_flash_until_ms ) def _draw_center(self, snapshot: Snapshot) -> None: pygame = self.pygame width, height = self.window_size bottom_panel_y = height - 257 board_y = 313 board_h = max(220, bottom_panel_y - board_y - 20) board_x = 263 if width >= 1450 else 196 board_rect = pygame.Rect(board_x, board_y, width - board_x - 30, board_h) pygame.draw.rect(self.screen, BG, board_rect) pygame.draw.rect(self.screen, LINE, board_rect, width=1) deck_h = min(264, board_h - 44) deck_rect = pygame.Rect(44, board_y + 44, 173, deck_h) deck_action = snapshot.card_action_size deck_target = snapshot.phase == "draw" and self._is_legal(snapshot, deck_action) self._draw_deck(snapshot, deck_rect, target=deck_target) if deck_target: self._register_target(deck_rect, deck_action, "덱에서 뽑기") selected_card = self._selected_card(snapshot) lane_count = snapshot.config.n_colors lane_gap = 14 lane_x = board_rect.x + 26 lane_y = board_rect.y + 63 lane_w = (board_rect.width - 52 - lane_gap * (lane_count - 1)) // lane_count zone_h = max(42, (board_rect.height - 86 - 24) // 3) for color in range(lane_count): x = lane_x + color * (lane_w + lane_gap) lane_color = color_rgb(color) if color > 0: pygame.draw.line( self.screen, LINE, (x - 8, board_rect.y), (x - 8, board_rect.bottom), 1 ) self._draw_text( color_name(color).upper(), (x + 12, board_rect.y + 24), lane_color, 23, bold=True, ) pygame.draw.line( self.screen, lane_color, (x + 12, board_rect.y + 51), (x + lane_w - 12, board_rect.y + 51), 2, ) p1_rect = pygame.Rect(x + 26, lane_y, lane_w - 52, zone_h) discard_rect = pygame.Rect(x + 26, lane_y + zone_h + 12, lane_w - 52, zone_h) p0_rect = pygame.Rect(x + 26, lane_y + 2 * (zone_h + 12), lane_w - 52, zone_h) play_target_player = None play_action = None discard_action = None if snapshot.phase == "card" and selected_card and selected_card.color == color: play_target_player = snapshot.current_player play_action = 2 * (self.selected_card_slot or 0) discard_action = play_action + 1 for player, rect in ((1, p1_rect), (0, p0_rect)): action = play_action if play_target_player == player else None is_target = action is not None and self._is_legal(snapshot, action) self._draw_zone( rect, f"P{player} expedition", f"Score {snapshot.expedition_score(player, color)}", snapshot.expeditions[player][color], snapshot.config, target=is_target, target_label="Play here" if is_target else None, ) if is_target and action is not None: self._register_target(rect, action, "탐험대에 놓기") draw_discard_action = snapshot.card_action_size + 1 + color discard_target = False discard_label = None target_action = None if ( snapshot.phase == "card" and selected_card and selected_card.color == color and discard_action is not None and self._is_legal(snapshot, discard_action) ): discard_target = True discard_label = "Discard here" target_action = discard_action elif snapshot.phase == "draw" and self._is_legal(snapshot, draw_discard_action): discard_target = True discard_label = "Draw" target_action = draw_discard_action self._draw_zone( discard_rect, "Discard", "Stack", snapshot.discards[color], snapshot.config, target=discard_target, target_label=discard_label, ) if discard_target and target_action is not None: target_label = ( "버린 더미에 버리기" if discard_label == "Discard here" else "버린 더미에서 뽑기" ) self._register_target(discard_rect, target_action, target_label) def _draw_deck(self, snapshot: Snapshot, rect: Any, *, target: bool = False) -> None: pygame = self.pygame border = GOLD if target else LINE pygame.draw.rect(self.screen, BG, rect) pygame.draw.rect(self.screen, border, rect, width=2 if target else 1) compact = rect.height < 230 self._draw_text_center( "DECK", pygame.Rect(rect.x, rect.y + 18, rect.width, 36), TEXT, 20 if compact else 22 ) card_w, card_h = (42, 58) if compact else (52, 72) card_back = pygame.Rect( rect.centerx - card_w // 2 - 4, rect.y + (68 if compact else 72), card_w, card_h ) pygame.draw.rect(self.screen, BG, card_back) pygame.draw.rect(self.screen, TEXT, card_back, width=2) pygame.draw.rect(self.screen, LINE, card_back.move(8, 8), width=2) count_rect = ( pygame.Rect(rect.x, card_back.bottom + 8, rect.width, 40) if compact else pygame.Rect(rect.x, rect.bottom - 92, rect.width, 64) ) self._draw_text_center( str(len(snapshot.deck)), count_rect, TEXT, 36 if compact else 42, bold=True ) if target: self._draw_text_center( "DRAW", pygame.Rect(rect.x, rect.bottom - 34, rect.width, 24), GOLD, 15 if compact else 17, ) def _draw_zone( self, rect: Any, title: str, subtitle: str, cards: list[Card], config: LostCitiesConfig, *, target: bool = False, target_label: str | None = None, ) -> None: pygame = self.pygame border = GOLD if target else LINE pygame.draw.rect(self.screen, BG, rect) pygame.draw.rect(self.screen, border, width=2 if target else 1, rect=rect) if rect.height < 58: if target and target_label: self._draw_target_badge(rect, target_label) if not cards: self._draw_text( self._zone_title(title, rect.width), (rect.x + 18, rect.y + 12), TEXT, 14 ) self._draw_text("EMPTY", (rect.right - 56, rect.y + 13), MUTED, 12) else: self._draw_mini_card_row( cards, pygame.Rect(rect.x + 8, rect.y + 9, rect.width - 16, 24), config, preferred_size=24, min_size=14, ) return title_text = self._zone_title(title, rect.width) self._draw_text( title_text, (rect.x + 20, rect.y + 16), TEXT, 17 if rect.width < 220 else 18 ) if target and target_label: self._draw_target_badge(rect, target_label) if not cards: self._draw_text(subtitle, (rect.x + 20, rect.y + 51), MUTED, 15) self._draw_text("EMPTY", (rect.right - 72, rect.y + 51), MUTED, 15) return # Once cards exist, reserve the bottom band exclusively for cards. # This prevents multi-card expeditions from covering score/top-card text. self._draw_text_right( subtitle, (rect.right - 18, rect.y + 20), MUTED, 12 if rect.width < 220 else 13 ) row_size = min(30, max(18, rect.height - 70)) row_rect = pygame.Rect(rect.x + 12, rect.bottom - row_size - 10, rect.width - 24, row_size) self._draw_mini_card_row(cards, row_rect, config, preferred_size=row_size) def _zone_title(self, title: str, width: int) -> str: text = title.upper() if width >= 220: return text return text.replace(" EXPEDITION", " EXP") def _mini_card_layout( self, width: int, count: int, *, preferred_size: int = 30, min_size: int = 16, ) -> tuple[int, int]: gap = 5 usable = max(32, width) if count <= 1: return min(preferred_size, usable), gap size = min(preferred_size, (usable - gap * (count - 1)) // count) if size >= min_size: return size, gap # Keep cards readable before shrinking too hard: negative gap means overlap. size = min(preferred_size, max(min_size, usable // min(count, 5))) gap = (usable - size * count) // (count - 1) return size, min(5, gap) def _draw_mini_card_row( self, cards: list[Card], row_rect: Any, config: LostCitiesConfig, *, preferred_size: int = 30, min_size: int = 16, ) -> None: if not cards: return size, gap = self._mini_card_layout( row_rect.width, len(cards), preferred_size=preferred_size, min_size=min_size, ) step = size + gap total_width = size + max(0, len(cards) - 1) * step start_x = row_rect.x + max(0, (row_rect.width - total_width) // 2) y = row_rect.y + max(0, (row_rect.height - size) // 2) for index, card in enumerate(cards): self._draw_mini_card(card, (start_x + index * step, y), config, size=size) def _draw_target_badge(self, rect: Any, label: str) -> None: if rect.width < 220: return pygame = self.pygame text = label.upper() font = self._font(12) surface = font.render(text, True, GOLD) badge = surface.get_rect() badge.width += 12 badge.height += 6 badge.topright = (rect.right - 8, rect.y - 11) pygame.draw.rect(self.screen, BG, badge) pygame.draw.rect(self.screen, GOLD, badge, width=1) self.screen.blit(surface, (badge.x + 6, badge.y + 3)) def _draw_card( self, card: Card, pos: tuple[int, int], config: LostCitiesConfig, *, large: bool = False, size: tuple[int, int] | None = None, selected: bool = False, selectable: bool = False, ) -> Any: pygame = self.pygame x, y = pos width, height = size or ((126, 154) if large else (30, 30)) color = color_rgb(card.color) rect = pygame.Rect(x, y, width, height) if selected: pygame.draw.rect( self.screen, GOLD, rect.inflate(10, 10), width=2, ) pygame.draw.rect(self.screen, CARD_BG, rect) pygame.draw.rect(self.screen, color, rect, width=2) if large: label_size = max(10, min(19, width // 6)) value_size = max(26, min(62, int(width * 0.48))) self._draw_text(color_name(card.color).upper(), (x + 10, y + 10), color, label_size) self._draw_text_center( card_value_label(card, config), pygame.Rect(x, y + max(24, height // 4), width, height - max(32, height // 3)), color, value_size, bold=True, ) else: self._draw_text_center( card_value_label(card, config), rect, color, 16, bold=True, ) return rect def _draw_mini_card( self, card: Card, pos: tuple[int, int], config: LostCitiesConfig, *, size: int = 30, ) -> None: pygame = self.pygame x, y = pos rect = pygame.Rect(x, y, size, size) color = color_rgb(card.color) pygame.draw.rect(self.screen, CARD_BG, rect) pygame.draw.rect(self.screen, color, rect, width=1) self._draw_text_center( card_value_label(card, config), rect, color, max(11, size // 2), bold=True ) def _selected_card(self, snapshot: Snapshot) -> Card | None: if snapshot.phase != "card" or self.selected_card_slot is None: return None hand = snapshot.hands[snapshot.current_player] if self.selected_card_slot >= len(hand): self.selected_card_slot = None return None return hand[self.selected_card_slot] def _is_legal(self, snapshot: Snapshot, action_id: int) -> bool: return 0 <= action_id < len(snapshot.legal_mask) and snapshot.legal_mask[action_id] def _register_target(self, rect: Any, action_id: int, label: str) -> None: self.board_targets.append(ActionTarget(rect=rect, action_id=action_id, label=label)) def _draw_panel(self, rect: tuple[int, int, int, int], border: tuple[int, int, int]) -> None: pygame = self.pygame panel = pygame.Rect(*rect) self._draw_panel_rect(panel, border) def _draw_panel_rect( self, panel: Any, border: tuple[int, int, int], *, width: int | None = None, ) -> None: pygame = self.pygame pygame.draw.rect(self.screen, BG, panel) border_width = width if width is not None else 2 if border == GOLD else 1 pygame.draw.rect(self.screen, border, panel, width=border_width) def _draw_text( self, text: str, pos: tuple[int, int], color: tuple[int, int, int], size: int, *, bold: bool = False, ) -> None: font = self._font(size, bold=bold) surface = font.render(text, True, color) self.screen.blit(surface, pos) def _draw_text_center( self, text: str, rect: Any, color: tuple[int, int, int], size: int, *, bold: bool = False, ) -> None: font = self._font(size, bold=bold) surface = font.render(text, True, color) self.screen.blit(surface, surface.get_rect(center=rect.center)) def _draw_text_right( self, text: str, top_right: tuple[int, int], color: tuple[int, int, int], size: int, *, bold: bool = False, ) -> None: font = self._font(size, bold=bold) surface = font.render(text, True, color) self.screen.blit(surface, surface.get_rect(topright=top_right)) def _font(self, size: int, *, bold: bool = False) -> Any: key = (size, bold) if key not in self.font_cache: if self.font_path is not None: font = self.pygame.font.Font(str(self.font_path), size) font.set_bold(bold) else: font = self.pygame.font.SysFont("dejavusansmono", size, bold=bold) self.font_cache[key] = font return self.font_cache[key] PvpApp = LostCitiesGuiApp def main(argv: list[str] | None = None) -> None: args = build_argparser().parse_args(argv) app = LostCitiesGuiApp( mode=args.mode, bot_name=args.bot, seed=args.seed, width=args.width, height=args.height, screenshot_on_start=args.screenshot_on_start, ) app.run() if __name__ == "__main__": main()