Files
coorl-lost-cities/src/coolrl_lost_cities/games/classic/pygame_pvp.py
T

1293 lines
48 KiB
Python

"""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()