# -*- coding: utf-8 -*- """Textual TUI: 棋盘实时渲染 + 对局状态面板 + 快捷键控制。""" import asyncio import logging from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.widgets import Footer, RichLog, Static from .bot import OnlineBot, build_parser RED_NAMES = {'R': '车', 'N': '马', 'B': '相', 'A': '仕', 'K': '帅', 'C': '炮', 'P': '兵'} BLACK_NAMES = {'r': '車', 'n': '馬', 'b': '象', 'a': '士', 'k': '將', 'c': '砲', 'p': '卒'} NAMES = {**RED_NAMES, **BLACK_NAMES} ROW_LABELS_FLIP = ['十', '九', '八', '七', '六', '五', '四', '三', '二', '一'] # 我方红(黑在上) ROW_LABELS_NORM = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'] # 我方黑(红在上) def board_text(fen, flip): """FEN -> 多行棋盘文本(红字黑字用不同样式标记)。""" if not fen: return '(等待对局…)' rows = fen.split()[0].split('/') cells = [] for row in rows: line = [] for ch in row: if ch.isdigit(): line.extend(['・'] * int(ch)) else: line.append(NAMES.get(ch, '?')) cells.append(line) if flip: cells.reverse() row_labels = ROW_LABELS_FLIP else: row_labels = ROW_LABELS_NORM lines = [' ' + ' '.join('123456789' if flip else '123456789')] out = [] for i, line in enumerate(cells): out.append(f'{row_labels[i]} ' + ' '.join(line)) if i == 4: out.append(' ~楚 河 汉 界~') return '\n'.join(out) class XiangqiTUI(App): CSS = ''' Screen { layout: horizontal; } #left { width: 1fr; } #right { width: 44; } #board { border: round #884444; padding: 0 1; content-align: left middle; } #board:focus { border: round #cc6666; } #panel { height: auto; border: round #444488; padding: 0 1; } #log { border: round #448844; height: 1fr; } ''' BINDINGS = [ ('q', 'quit', '退出'), ('p', 'pause', '暂停/继续'), ('c', 'cloud', '云库开/关'), ('n', 'new_match', '换一局'), ] TITLE = '🐴 皮卡鱼在线对弈' def __init__(self, args): super().__init__() self.args = args self.bot = OnlineBot(args) self._task = None def compose(self) -> ComposeResult: with Horizontal(): with Vertical(id='left'): yield Static('', id='board') yield Static('', id='panel') yield RichLog(id='log', markup=False, wrap=True, max_lines=400) yield Footer() def on_mount(self): log_handler = TextualLogHandler(self.query_one('#log', RichLog)) logging.getLogger().addHandler(log_handler) logging.getLogger().setLevel(logging.INFO) self.set_interval(0.3, self.refresh_state) self._task = asyncio.create_task(self._run_bot()) async def _run_bot(self): try: await self.bot.run() except Exception as e: self.bot.status = f'异常退出: {e}' log.error('[bot] %s: %s', type(e).__name__, e) def refresh_state(self): b = self.bot flip = b.my_side != 'black' self.query_one('#board', Static).update(board_text(b.fen, flip)) turn = {'red': '红方', 'black': '黑方', None: '-'}.get(b.my_side) my_turn = '← 轮到我' if b.is_my_turn() else '' panel = '\n'.join([ f'状态 : {b.status}', f'对局 : {b.game_id or "-"} 我方: {turn or "-"} {my_turn}', f'暂停 : {"是" if b.paused else "否"} 云库: {"开" if b.use_cloud else "关"}', f'步数 : {len(b.moves)}', f'评分 : {b.score or "-"} 来源: {b.source or "-"}', f'终局 : {b.end_reason or "-"}', '', '着法: ' + ' '.join(b.site_moves[-40:]), ]) self.query_one('#panel', Static).update(panel) def action_pause(self): self.bot.toggle_pause() def action_cloud(self): self.bot.toggle_cloud() log.info('[bot] 云库 %s', '开' if self.bot.use_cloud else '关') async def action_new_match(self): log.info('[bot] 弃局重匹配…') await self.bot.new_match() async def action_quit(self): await self.bot.close() self.exit() class TextualLogHandler(logging.Handler): def __init__(self, widget): super().__init__() self.widget = widget def emit(self, record): try: msg = self.format(record) self.widget.write(msg) except Exception: pass def run_tui(args): import sys logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(message)s', datefmt='%H:%M:%S') app = XiangqiTUI(args) # 无终端时(重定向/后台)以 headless 运行, 方便观察日志 app.run(headless=not (sys.stdout.isatty() and sys.stdin.isatty())) return 0