diff --git a/plugin-dev-tutorial.md b/plugin-dev-tutorial.md new file mode 100644 index 0000000..9087a2f --- /dev/null +++ b/plugin-dev-tutorial.md @@ -0,0 +1,821 @@ +# Meta-Minesweeper 插件开发教程(PyInstaller 打包版 + VS Code) + +> 本教程面向 **PyInstaller 打包后** 的插件管理器,使用 **VS Code** 作为开发/调试工具。 + +--- + +## 目录 + +- [一、系统架构概览](#一系统架构概览) +- [二、环境准备](#二环境准备) +- [三、理解插件发现机制](#三理解插件发现机制) +- [四、编写第一个插件(Hello World)](#四编写第一个插件hello-world) +- [五、核心 API 详解](#五核心-api-详解) +- [六、实战:带 GUI 的完整插件示例](#六实战带-gui-的完整插件示例) +- [七、VS Code 调试指南](#七vs-code-调试指南) +- [八、常见问题与最佳实践](#八常见问题与最佳实践) + +--- + +## 一、系统架构概览 + +Meta-Minesweeper 采用 **ZMQ 多进程插件架构**: + +``` +┌──────────────────────────────────────┐ +│ 主进程 (metaminsweeper.exe) │ +│ GameServerBridge (ZMQ Server :5555)│ +└──────────────┬───────────────────────┘ + │ ZMQ PUB/SUB + REQ/REP +┌──────────────▼───────────────────────┐ +│ 插件管理器进程 (plugin_manager) │ +│ │ +│ PluginLoader ──→ 发现 & 加载 .py │ +│ │ │ +│ EventDispatcher ──→ 事件分发 │ +│ │ │ +│ BasePlugin(QThread) × N │ +│ ├─ HistoryPlugin (内置) │ +│ ├─ 你的插件A (用户) │ +│ └─ 你的插件B (用户) │ +│ │ +│ PluginManagerWindow (Qt GUI) │ +└──────────────────────────────────────┘ +``` + +**关键点:** +- 每个插件运行在**独立的 QThread** 中,互不阻塞 +- 主进程和插件管理器通过 **ZeroMQ** 通信 +- 插件通过**事件订阅**接收游戏数据,通过**指令发送**控制主进程 + +--- + +## 二、环境准备 + +### 2.1 目录结构(打包后) + +打包完成后,目录结构如下: + +``` +<安装目录>/ +├── metaminsweeper.exe # 主程序 +├── plugin_manager.exe # 插件管理器 +├── plugins/ # 👈 用户插件放这里! +│ ├── my_hello.py # 你的插件(单文件) +│ └── my_complex/ # 或包形式插件 +│ ├── __init__.py +│ └── utils.py +├── user_plugins/ # 备用用户插件目录 +├── data/ +│ ├── logs/ # 日志输出(自动创建) +│ │ └── <插件名>.log # 各插件独立日志 +│ └── plugin_data/ # 各插件的独立数据目录(自动创建) +│ ├── HistoryPlugin/ +│ └── MyHelloPlugin/ # 你的插件数据会在这里自动创建 +└── _internal/ # PyInstaller 解压的内部文件(只读) +``` + +### 2.2 用 VS Code 打开项目 + +```bash +# 方式一:直接打开安装目录作为工作区 +code "D:\你的安装目录" + +# 方式二:在其他位置创建插件开发文件夹,写好后复制到安装目录 +mkdir D:\my-plugins +code D:\my-plugins +``` + +### 2.3 推荐 VS Code 扩展 + +| 扩展 | 用途 | +|------|------| +| Python (Microsoft) | 智能补全、调试 | +| Python Debugger | 远程 debugpy 调试 | + +### 2.4 Python 解释器(可选) + +如果需要代码补全,在 VS Code 右下角选择一个装了 PyQt5 / msgspec 的 Python 解释器即可。不配也能正常写插件。 + +--- + +## 三、理解插件发现机制 + +### 3.1 插件加载流程 + +``` +plugin_manager 启动 + → PluginLoader 初始化 + → 扫描以下目录: + ① /plugins/ (内置插件,打包时包含) + ② /plugins/ (👈 用户插件主目录) + ③ /user_plugins/ (备用用户插件目录) + → 对每个 .py 文件(不含 _ 开头)动态导入 + → 查找继承了 BasePlugin 的类 + → 实例化并注册到 PluginManager +``` + +### 3.2 支持两种形式 + +**单文件插件**(推荐新手使用): +``` +plugins/ +└── my_plugin.py # 一个 .py 文件 = 一个插件 +``` + +**包形式插件**(适合复杂插件): +``` +plugins/ +└── my_plugin/ + ├── __init__.py # 插件类定义在此处 + ├── models.py # 数据模型 + └── widgets.py # UI 组件 +``` + +### 3.3 自动发现规则 + +- 文件/目录名以 `_` 开头的会被跳过(如 `_template.py`) +- 单个 `.py` 文件中可以定义多个继承 `BasePlugin` 的类,都会被加载 +- 包形式插件中,只有 `__init__.py` 中导出的 `BasePlugin` 子类会被发现 + +--- + +## 四、编写第一个插件(Hello World) + +### 4.1 创建插件文件 + +在 `<安装目录>/plugins/` 下创建 `hello_world.py`: + +```python +""" +Hello World 示例插件 + +功能:监听每局游戏结束事件,在界面显示统计信息。 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit +from PyQt5.QtCore import Qt, pyqtSignal + +# 导入插件基类和辅助类型 +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode + +# 导入可用的事件类型 +from shared_types.events import VideoSaveEvent + + +class HelloWidget(QWidget): + """简单的 UI 界面""" + + # 自定义信号:用于跨线程安全更新 UI + _update_signal = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._count = 0 + + layout = QVBoxLayout(self) + + self._title = QLabel("👋 Hello World 插件") + self._title.setStyleSheet("font-size: 18px; font-weight: bold; padding: 10px;") + layout.addWidget(self._title) + + self._info = QLabel("等待游戏数据...") + layout.addWidget(self._info) + + self._log = QTextEdit() + self._log.setReadOnly(True) + layout.addWidget(self._log) + + # 连接信号 + self._update_signal.connect(self._append_log) + + def update_game_info(self, text: str): + """线程安全地更新 UI(通过信号槽)""" + self._update_signal.emit(text) + + def _append_log(self, text: str): + """槽函数:在主线程执行 UI 更新""" + self._log.append(text) + self._count += 1 + self._info.setText(f"已收到 {self._count} 条游戏记录") + + +class HelloPlugin(BasePlugin): + """Hello World 示例插件""" + + # ════════════════════════════════════════ + # 1. 定义插件元信息(必须实现) + # ════════════════════════════════════════ + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="hello_world", # 唯一名称(用于日志文件名、数据目录名等) + version="1.0.0", # 版本号 + author="Your Name", # 作者 + description="Hello World 示例插件——演示基本的事件订阅和 UI 显示", + enabled=True, # 是否默认启用 + priority=100, # 优先级(数字越小越先处理事件) + show_window=True, # 初始化时是否显示窗口 + window_mode=WindowMode.TAB, # 窗口模式: TAB=标签页 / DETACHED=独立窗口 / CLOSED=不显示 + icon=make_plugin_icon( # 图标(可选,None 则用默认) + color="#4CAF50", # 绿色背景 + symbol="H", # 显示字母 H + size=64 + ), + ) + + # ════════════════════════════════════════ + # 2. 订阅事件(必须实现) + # ════════════════════════════════════════ + def _setup_subscriptions(self) -> None: + """ + 在此方法中调用 self.subscribe() 订阅你感兴趣的事件。 + + 可用事件类型(定义在 shared_types/events.py): + - VideoSaveEvent: 游戏结束时触发(含完整统计数据 + 录像数据) + - BoardUpdateEvent: 棋盘更新时触发(每步操作都会触发) + """ + self.subscribe(VideoSaveEvent, self._on_video_save) + + # ════════════════════════════════════════ + # 3. 创建 UI 界面(可选覆写,返回 None 表示无界面) + # ════════════════════════════════════════ + def _create_widget(self) -> QWidget | None: + """ + 创建插件的 GUI 组件。 + + 注意: + - 此方法在主线程中调用 + - 可以使用 self.data_dir 获取插件专属的可写数据目录 + - 返回的 widget 会被嵌入标签页或独立窗口 + """ + self._widget = HelloWidget() + return self._widget + + # ════════════════════════════════════════ + # 4. 初始化回调(可选覆写) + # ════════════════════════════════════════ + def on_initialized(self) -> None: + """ + 线程启动后执行此回调。 + + 适用场景: + - 数据库初始化 / 建表 + - 网络连接建立 + - 加载配置文件 + - 任何耗时操作(在此执行不会卡住 UI) + + 注意:此方法在插件工作线程中执行,不要直接操作 GUI 对象! + """ + self.logger.info("HelloPlugin 已初始化!") + + # ════════════════════════════════════════ + # 5. 关闭清理回调(可选覆写) + # ════════════════════════════════════════ + def on_shutdown(self) -> None: + """插件关闭前执行清理""" + self.logger.info("HelloPlugin 正在关闭...") + + # ════════════════════════════════════════ + # 6. 事件处理方法 + # ════════════════════════════════════════ + def _on_video_save(self, event: VideoSaveEvent): + """ + VideoSaveEvent 事件处理器 + + 重要:此方法在插件的工作线程中执行(非主线程), + 所以可以直接做 IO 操作(数据库写入、文件读写等)。 + + 但如果要更新 GUI,必须通过 run_on_gui() 或信号槽机制。 + """ + # 直接使用 loguru logger 记录日志(已为每个插件配置独立的日志文件) + self.logger.info( + f"收到游戏录像: 用时={event.rtime}s, " + f"难度={event.level}, 3BV={event.bbbv}, " + f"左键={event.left}, 右键={event.right}" + ) + + # 构建显示文本 + info_text = ( + f"[{event.rtime:.2f}s] {event.level} | " + f"3BV={event.bbbv} | L={event.left} R={event.right} D={event.double}" + ) + + # ✅ 推荐:直接 emit 信号(自动 QueuedConnection 跨线程到主线程) + self._widget._update_signal.emit(info_text) + + # 备选(一次性调用时可用): + # self.run_on_gui(self._widget.update_game_info, info_text) +``` + +### 4.2 验证插件加载 + +1. 将 `hello_world.py` 放入 `<安装目录>/plugins/` 目录 +2. 启动 `metaminsweeper.exe`(主程序) +3. 启动 `plugin_manager.exe`(插件管理器) +4. 如果一切正常,你应该能在左侧列表看到绿色的 "H" 图标插件 +5. 玩一局游戏结束后,插件界面应显示游戏统计信息 + +--- + +## 五、核心 API 详解 + +### 5.1 BasePlugin 属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `self.info` | `PluginInfo` | 插件的元信息对象 | +| `self.name` | `str` | 插件名称(来自 `info.name`) | +| `self.is_enabled` | `bool` | 当前是否启用 | +| `self.is_ready` | `bool` | 是否已完成初始化 | +| `self.lifecycle` | `PluginLifecycle` | 当前生命周期状态 | +| `self.widget` | `QWidget \| None` | `_create_widget()` 返回的界面组件 | +| `self.client` | `ZMQClient` | ZMQ 客户端(一般不直接使用) | +| `self.data_dir` | `Path` | **插件专属可写数据目录**(重要!) | +| `self.log_level` | `LogLevel` | 当前的日志级别 | +| `self.plugin_icon` | `QIcon` | 插件图标 | +| `self.logger` | `loguru.Logger` | **已绑定插件名称的日志器**(直接用!) | + +### 5.2 事件订阅 API + +```python +# 订阅事件(在 _setup_subscriptions 中调用) +self.subscribe(event_class, handler_function) + +# 取消订阅 +self.unsubscribe(event_class) +``` + +**当前可用的事件类型:** + +| 事件类 | 触发时机 | 关键字段 | +|--------|----------|----------| +| `VideoSaveEvent` | 一局游戏结束时 | `rtime`(用时), `level`(难度), `bbbv`, `left`, `right`, `double`, `mode`, `raw_data`(base64录像), 以及约30+其他字段 | +| `BoardUpdateEvent` | 每步棋盘更新时 | 棋盘状态信息 | + +### 5.3 向主进程发送指令 + +```python +# 异步发送(发完即返回,不等响应) +self.send_command(NewGameCommand(rows=16, cols=30, mines=99)) + +# 同步请求-响应(等待最多 timeout 秒) +result = self.request(some_query_command, timeout=5.0) +``` + +**当前可用的指令类型:** + +| 指令类 | 说明 | 参数 | +|--------|------|------| +| `NewGameCommand` | 开始新游戏 | `rows`, `cols`, `mines` | +| `MouseClickCommand` | 模拟鼠标点击 | `row`, `col`, `button`, `modifiers` | + +### 5.4 线程安全的 GUI 更新(重要!) + +> **为什么需要跨线程机制?** +> +> `BasePlugin` 继承自 `QThread`,它本身就是一个 `QObject`。事件处理器运行在**插件工作线程**中, +> 但 PyQt 的 GUI 操作只能在**主线程**执行。直接跨线程操作 GUI 会导致未定义行为或崩溃。 +> +> **推荐:使用 `pyqtSignal`(信号槽)** + +因为插件类本身就是 `QObject`(QThread 的父类),所以可以直接在 Widget 或 Plugin 类上定义信号: +Qt 会自动用 **QueuedConnection** 跨线程投递,安全且高效。 + +```python +# ════ 推荐方式:pyqtSignal(声明式、类型清晰) ════ + +# Step 1: 在 QWidget 子类上定义信号 +class MyWidget(QWidget): + new_data = pyqtSignal(dict) # 自定义参数类型 + + def __init__(self): + super().__init__() + self.new_data.connect(self._on_new_data) # 连接到槽函数 + + def _on_new_data(self, data: dict): # 槽函数在主线程执行 + self.label.setText(data["text"]) + +# Step 2: 在事件处理器中 emit 信号 +def _on_video_save(self, event): + # 此代码在工作线程执行 → emit 自动跨线程投递到主线程的 _on_new_data + self._widget.new_data.emit({"text": f"用时 {event.rtime}s"}) +``` + +**也可以把信号定义在 Plugin 类上**(因为 BasePlugin 本身就是 QObject): + +```python +class MyPlugin(BasePlugin): + _sig_update = pyqtSignal(str) + + def _create_widget(self): + self._sig_update.connect(self._do_update) # 槽可以是 Plugin 的方法 + return SomeWidget() + + @pyqtSlot(str) + def _do_update(self, text: str): # 主线程执行 + if self.widget: + self.widget.label.setText(text) + + def _handle_event(self, event): + self._sig_update.emit(f"数据: {event.rtime}") # 工作线程 emit → 自动跨线程 +``` + +```python +# ════ 备选方式:self.run_on_gui() ════ +# 适用于一次性调用、不需要重复连接的场景 +self.run_on_gui(some_function, arg1, arg2, keyword_arg=value) +``` + +| 方式 | 适用场景 | 特点 | +|------|----------|------| +| **`pyqtSignal` + 槽** | 有固定 UI 需反复更新 | **推荐**。声明式,类型签名清晰,Qt 原生惯用法 | +| **`self.run_on_gui()`** | 临时/一次性 UI 调用 | 通用封装,无需预先定义信号,灵活但可读性略差 | + +两种方式的底层原理相同 —— 都是通过 QueuedConnection 将调用投递到 Qt 主线程的事件循环。 + +### 5.5 日志记录 + +```python +# 每个 BasePlugin 实例都有绑定好的 logger,直接使用即可 +self.logger.debug("详细调试信息") +self.logger.info("常规信息") +self.logger.warning("警告") +self.logger.error("错误信息") + +# 日志会自动输出到: +# /logs/.log (插件专属日志) +# /logs/plugin_manager.log (主日志) +``` + +### 5.6 PluginInfo 配置项 + +```python +@dataclass +class PluginInfo: + name: str # 必填,唯一标识 + version: str = "1.0.0" # 版本号 + author: str = "" # 作者 + description: str = "" # 描述 + enabled: bool = True # 默认是否启用 + priority: int = 100 # 优先级(越小越先处理事件) + show_window: bool = True # 初始化时显示窗口 + window_mode: WindowMode = "tab" # tab/detached/closed + log_level: LogLevel = "DEBUG" # 默认日志级别 + icon: QIcon | None = None # 图标 + log_config: LogConfig | None = None # 高级日志配置 +``` + +**WindowMode 含义:** + +| 模式 | 行为 | +|------|------| +| `WindowMode.TAB` | 插件 UI 嵌入主窗口的标签页内 | +| `WindowMode.DETACHED` | 插件 UI 以独立窗口弹出(可拖回标签页) | +| `WindowMode.CLOSED` | 不自动创建 UI 窗口(可通过右键菜单手动打开) | + +--- + +## 六、实战:带 GUI 的完整插件示例 + +下面是一个更完整的示例——**实时统计面板插件**,展示计数器、表格等常见 UI 元素的用法: + +```python +""" +实时游戏统计面板 + +功能:统计当前游戏的各种数据,实时显示在界面上。 +""" +from __future__ import annotations + +import json +from pathlib import Path +from collections import defaultdict + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, + QTableWidgetItem, QGroupBox, QHeaderView, QSplitter +) +from PyQt5.QtCore import Qt, pyqtSignal, QTimer +from PyQt5.QtGui import QFont + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent, BoardUpdateEvent + + +class StatsPanel(QWidget): + """统计面板 UI""" + + _signal_update_stats = pyqtSignal(dict) + _signal_add_record = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + self._total_games = 0 + self._stats_by_level = defaultdict(lambda: {"count": 0, "best_time": float('inf')}) + + self._setup_ui() + self._signal_update_stats.connect(self._do_update_stats) + self._signal_add_record.connect(self._do_add_record) + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # === 顶部统计卡片 === + cards_layout = QHBoxLayout() + + self._lbl_total = self._make_stat_card("总局数", "0", "#1976D2") + self._lbl_today = self._make_stat_card("今日", "0", "#388E3C") + self._lbl_best = self._make_stat_card("最佳", "--", "#F57C00") + self._lbl_avg = self._make_stat_card("平均", "--", "#7B1FA2") + + for card in [self._lbl_total, self._lbl_today, self._lbl_best, self._lbl_avg]: + cards_layout.addWidget(card) + + main_layout.addLayout(cards_layout) + + # === 历史记录表格 === + group = QGroupBox("最近对局") + group_layout = QVBoxLayout(group) + + self._table = QTableWidget() + self._table.setColumnCount(4) + self._table.setHorizontalHeaderLabels(["难度", "用时(s)", "3BV", "操作数"]) + self._table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self._table.setAlternatingRowColors(True) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + group_layout.addWidget(self._table) + + main_layout.addWidget(group) + + def _make_stat_card(self, title: str, value: str, color: str) -> QWidget: + """创建统计卡片""" + card = QWidget() + card.setStyleSheet(f""" + background: {color}; border-radius: 8px; padding: 8px; + """) + layout = QVBoxLayout(card) + layout.setContentsMargins(12, 8, 12, 8) + + lbl_title = QLabel(title) + lbl_title.setStyleSheet("color: rgba(255,255,255,0.8); font-size: 12px;") + lbl_value = QLabel(value) + lbl_value.setStyleSheet("color: white; font-size: 24px; font-weight: bold;") + + layout.addWidget(lbl_title) + layout.addWidget(lbl_value) + return card + + def update_from_event(self, event_data: dict): + """线程安全:从事件数据更新统计""" + self._signal_update_stats.emit(event_data) + self._signal_add_record.emit(event_data) + + def _do_update_stats(self, data: dict): + """槽:在主线程更新统计数据""" + level = data.get("level", "?") + rtime = data.get("rtime", 0) + + self._total_games += 1 + self._lbl_total.findChild(QLabel).setText(str(self._total_games)) + + stats = self._stats_by_level[level] + stats["count"] += 1 + if rtime > 0 and rtime < stats["best_time"]: + stats["best_time"] = rtime + self._lbl_best.findChild(QLabel).setText(f"{rtime:.2f}") + + def _do_add_record(self, data: dict): + """槽:在主线程添加表格行""" + row = self._table.rowCount() + self._table.insertRow(row) + self._table.setItem(row, 0, QTableWidgetItem(str(data.get("level", "?")))) + self._table.setItem(row, 1, QTableWidgetItem(f"{data.get('rtime', 0):.2f}")) + self._table.setItem(row, 2, QTableWidgetItem(str(data.get("bbbv", 0)))) + ops = int(data.get("left", 0)) + int(data.get("right", 0)) + self._table.setItem(row, 3, QTableWidgetItem(str(ops))) + + +class StatsPlugin(BasePlugin): + """实时游戏统计插件""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="stats_panel", + version="1.0.0", + author="Developer", + description="实时统计面板——展示游戏数据和历史记录", + icon=make_plugin_icon("#E91E63", "S", 64), + window_mode=WindowMode.TAB, + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + self._panel = StatsPanel() + return self._panel + + def on_initialized(self) -> None: + # 尝试从持久化文件恢复之前的数据 + saved = self.data_dir / "saved_stats.json" + if saved.exists(): + try: + data = json.loads(saved.read_text(encoding='utf-8')) + self.logger.info(f"已恢复 {len(data)} 条历史记录") + except Exception as e: + self.logger.warning(f"无法读取存档: {e}") + + def on_shutdown(self) -> None: + # 退出时保存关键数据 + self.logger.info("StatsPlugin 正在保存数据...") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info( + f"[{event.level}] {event.rtime:.2f}s | 3BV={event.bbbv}" + ) + + # 将事件转为字典传给 UI + event_dict = { + "level": event.level, + "rtime": event.rtime, + "bbbv": event.bbbv, + "left": event.left, + "right": event.right, + } + # ✅ 推荐:直接 emit 信号(自动跨线程) + self._panel._signal_update_stats.emit(event_dict) + self._panel._signal_add_record.emit(event_dict) + + # 备选(一次性调用时可用): + # self.run_on_gui(self._panel.update_from_event, event_dict) +``` +--- + +## 七、VS Code 调试指南 + +### 最简开发方式(推荐) + +只需 5 步,无需配置 launch.json: + +```bash +# 1️⃣ 安装 Python 3.12(如果没有的话) +# 从 python.org 下载安装 + +# 2️⃣ 在扫雷安装目录下创建虚拟环境并安装依赖 +cd <安装目录> +python -m venv .venv +.venv\Scripts\activate +pip install -r requirements.txt + +# 3️⃣ 用 VS Code 打开安装目录 +code <安装目录> +# 右下角选择 .venv 中的 Python 解释器(会自动识别) +``` + +``` +4️⃣ 运行 metaminsweeper.exe (启动主进程) +5️⃣ plugin_manager.exe会跟随主进程启动 (启动插件管理器) +6️⃣ 点击界面上的 🐛 Debug 按钮 (变绿 = debugpy 已监听 5678 端口) +7️⃣ VS Code → 运行和调试 → 附加到进程 → 选 plugin_manager.exe +8️⃣ 在插件代码打断点 → 触发事件 → 命中断点 ✅ +``` + +> 前提条件:`plugin_manager.spec` 的 `hiddenimports` 需包含 `debugpy`。 + +### 开发模式调试 + +有源码时直接 F5 调试 `main.py` 即可,无需额外配置。 + +--- + +## 八、常见问题与最佳实践 + +### Q1: 我的插件为什么没有被加载? + +按顺序排查: + +1. **文件位置**:确认 `.py` 文件在 `plugins/` 或 `user_plugins/` 目录下 +2. **命名规则**:文件名不能以 `_` 开头(如 `_test.py` 会被跳过) +3. **语法错误**:查看 `data/logs/plugin_manager.log` 中有无 `Failed to load module` 错误 +4. **基类继承**:确认类继承了 `BasePlugin` 并实现了 `plugin_info()` 和 `_setup_subscriptions()` +5. **导入错误**:如果使用了第三方库(如 requests),需联系作者添加该库;或临时将同版本的第三方库放到 `_internal/` 目录中 + +### Q2: 如何让插件使用额外的第三方库? + +打包后的环境只有 `requirements.txt` 中的依赖。如果你的插件需要额外的库: + +**方案 A:重新打包**(推荐) +1. 在 `requirements.txt` 中添加依赖 +2. 在 `plugin_manager.spec` 的 `hiddenimports` 中添加模块名 +3. 重新执行 PyInstaller 打包 + +**方案 B:放在插件旁边** +某些纯 Python 库可以直接将源码放入插件的目录中(包形式插件),然后正常 import。但这不是长久之计。 + +### Q3: 如何存储插件的持久化数据? + +使用 `self.data_dir` —— 它指向 `/data/plugin_data//`: + +```python +def on_initialized(self): + db_path = self.data_dir / "my_data.db" + config_path = self.data_dir / "settings.json" + + # 这些目录会自动创建,无需手动 mkdir + # 打包后和开发模式下路径不同,但 self.data_dir 会自动处理 +``` + +### Q4: 插件之间如何通信? + +目前插件间没有直接的通信 API。间接方式: + +- **通过主进程中转**:插件 A 发送 Command → 主进程处理 → 触发 Event → 插件 B 收到 +- **通过文件系统**:插件 A 写文件到公共目录 → 插件 B 定时轮询(不推荐) +- **共享 ZMQ Client**:未来可能会支持插件间自定义频道 + +### Q5: 最佳实践清单 + +| 建议 | 原因 | +|------|------| +| 所有 IO 操作放在 `on_initialized()` 或事件处理器中 | 这些在插件工作线程中执行,不阻塞 UI | +| GUI 操作必须用 `run_on_gui()` 或信号槽 | Qt 的 GUI 只能在主线程操作 | +| 使用 `self.logger` 而非 `print()` | 自动按插件分文件、支持级别过滤、自动轮转 | +| 使用 `msgspec.structs.asdict(event)` 反序列化事件数据 | 事件对象是 msgspec struct,不能直接当 dict 用 | +| 长耗时操作考虑超时和取消 | 插件关闭时只有 2 秒优雅退出时间 | +| `on_shutdown()` 中释放外部资源 | 数据库连接、网络 socket、文件句柄等 | +| 不要在 `_create_widget()` 中做耗时操作 | 此方法在主线程执行,会卡住 UI 加载 | +| 给插件起一个唯一的 name | 用于日志文件、数据目录、UI 标识,避免冲突 | + +### Q6: 插件生命周期图示 + +``` + plugin_class.plugin_info() + │ + ▼ + PluginLoader 发现并实例化 + │ + set_client() ← 注入 ZMQ 客户端 + │ + set_event_dispatcher() ← 注入事件分发器 + │ + initialize() ← 启动 QThread + │ + ┌─► _setup_subscriptions() ← 注册事件订阅 + │ │ + │ _create_widget() ← 创建 UI(主线程) + │ │ + │ start() ← QThread 开始运行 + │ │ + │ on_initialized() ← 【工作线程】初始化回调 + │ │ + │ ═══ 进入事件循环 ═══ + │ │ 等待事件 → 调用 handler → 处理下一个 + │ │ ... + │ │ + │ shutdown() ← 请求停止 + │ │ + │ on_shutdown() ← 【工作线程】清理回调 + │ │ + └───── STOPPED +``` + +--- + +## 附录:快速参考卡片 + +```python +# ═══ 最小可行插件模板 ═══ + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent # 按需导入 + +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="插件描述", + window_mode=WindowMode.TAB, # TAB / DETACHED / CLOSED + icon=make_plugin_icon("#1976D2", "M"), + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._handle_event) + + def _create_widget(self): # 可选:返回 QWidget 或 None + pass + + def on_initialized(self): # 可选:耗时初始化 + pass # self.data_dir 可存放数据 + + def on_shutdown(self): # 可选:资源清理 + pass + + def _handle_event(self, event): + self.logger.info(f"收到事件: {event}") # 用 logger 不用 print + # self.run_on_gui(gui_func, *args) # GUI 更新走这 +``` diff --git a/src/plugin_manager/event_dispatcher.py b/src/plugin_manager/event_dispatcher.py index 3120c6b..b5a0bb2 100644 --- a/src/plugin_manager/event_dispatcher.py +++ b/src/plugin_manager/event_dispatcher.py @@ -2,7 +2,9 @@ 事件分发器 负责将事件分发给订阅了该事件的插件 -支持优先级排序和异常隔离 +- 非阻塞:dispatch() 将事件投递到各插件的独立队列后立即返回 +- 每个插件在自己的线程中串行消费事件(由 BasePlugin.run() 负责) +- 支持优先级排序和异常隔离 """ from __future__ import annotations @@ -29,13 +31,14 @@ class HandlerEntry: class EventDispatcher: """ - 事件分发器 + 事件分发器(非阻塞投递模式) 功能: - 管理事件订阅 - 按优先级分发事件 - - 异常隔离(一个处理函数出错不影响其他) - - 线程安全 + - dispatch() 不阻塞:将事件投递到各插件的队列,立即返回 + - 异常隔离通过各插件线程自行处理 + - 背压控制:队列满时丢弃事件并记录警告 """ def __init__(self): @@ -56,7 +59,7 @@ def subscribe( event_type: 事件类型名称 handler: 事件处理函数 priority: 优先级(数值越小越先执行) - plugin: 所属插件(用于取消订阅) + plugin: 所属插件(用于取消订阅和队列投递) """ with self._lock: entry = HandlerEntry( @@ -104,7 +107,7 @@ def unsubscribe_all(self, plugin: BasePlugin) -> None: def dispatch(self, event_type: str, event: Any) -> None: """ - 分发事件给所有订阅者 + 非阻塞分发事件:将事件投递到各插件的独立队列,立即返回 Args: event_type: 事件类型名称 @@ -126,31 +129,25 @@ def dispatch(self, event_type: str, event: Any) -> None: if entry.plugin and not entry.plugin.is_enabled: continue - try: - entry.handler(event) - except Exception as e: - plugin_name = entry.plugin.name if entry.plugin else "unknown" - logger.error( - f"Handler error in plugin '{plugin_name}' " - f"for event '{event_type}': {e}", - exc_info=True, - ) - - def dispatch_async(self, event_type: str, event: Any) -> None: - """ - 异步分发事件(在新线程中执行) - - Args: - event_type: 事件类型名称 - event: 事件数据 - """ - thread = threading.Thread( - target=self.dispatch, - args=(event_type, event), - daemon=True, - ) - thread.start() - + if entry.plugin is not None: + # 投递到插件队列(非阻塞) + success = entry.plugin._enqueue_event(entry.handler, event) + if not success: + logger.warning( + f"Dropped event '{event_type}' for plugin " + f"'{entry.plugin.name}' (queue full)" + ) + else: + # 无归属插件的 handler(兜底),在调用者线程同步执行 + try: + entry.handler(event) + except Exception as e: + logger.error( + f"Handler error (no-plugin) for event " + f"'{event_type}': {e}", + exc_info=True, + ) + def get_handlers(self, event_type: str) -> list[HandlerEntry]: """获取某事件的所有处理函数""" with self._lock: diff --git a/src/plugin_manager/main_window.py b/src/plugin_manager/main_window.py index 98b6192..78842f6 100644 --- a/src/plugin_manager/main_window.py +++ b/src/plugin_manager/main_window.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QTimer -from PyQt5.QtGui import QMouseEvent, QIcon, QPixmap +from PyQt5.QtGui import QColor, QMouseEvent, QIcon, QPixmap from PyQt5.QtWidgets import ( QApplication, QCheckBox, @@ -39,7 +39,7 @@ ) from .plugin_state import PluginStateManager, PluginState -from .plugin_base import WindowMode, LogLevel +from .plugin_base import PluginLifecycle, WindowMode, LogLevel from .app_paths import get_data_dir if TYPE_CHECKING: @@ -214,6 +214,15 @@ def _detach_tab(self, index: int, name: str, pos: QPoint | None = None) -> None: self.tab_detached.emit(name) + def _cleanup_detached(self, name: str) -> None: + """安全清理 detached 窗口引用""" + if name in self._detached_windows: + w = self._detached_windows[name] + w.blockSignals(True) + w.close() + w.deleteLater() + del self._detached_windows[name] + def _attach_tab(self, name: str) -> None: """将弹出的窗口嵌回标签页""" if name not in self._detached_windows: @@ -444,6 +453,10 @@ def __init__(self, plugin_manager: PluginManager, parent=None): # 应用已保存的状态到插件 self._apply_saved_states() + # 连接所有插件的 ready 信号,就绪后自动刷新列表 + for p in self._manager.plugins.values(): + p.ready.connect(lambda _p=p: self._on_plugin_ready(_p)) + self._refresh_plugin_list() # 定时刷新连接状态 @@ -672,6 +685,13 @@ def _stop_debug(self) -> None: # ── 插件列表 ──────────────────────────────────────── + def _on_plugin_ready(self, plugin) -> None: + """插件初始化完成,刷新列表显示""" + self._refresh_plugin_list() + self.statusBar().showMessage( + self.tr("插件 {name} 就绪").format(name=plugin.name), 2000 + ) + def _refresh_plugin_list(self) -> None: """刷新插件列表和标签页""" t = self._tab_widget @@ -696,12 +716,23 @@ def _refresh_plugin_list(self) -> None: li = QListWidgetItem(p.name) li.setData(Qt.UserRole, name) li.setIcon(p.plugin_icon) - # 已禁用的用灰色 - if not p.is_enabled: - li.setForeground(Qt.gray) + + lc = p.lifecycle + + # 根据生命周期状态显示 + if lc == PluginLifecycle.INITIALIZING: + li.setForeground(QColor("#f57c00")) # 橙色:初始化中 + li.setText(f"{p.name} (⏳ 初始化中...)") + elif not p.is_enabled: + li.setForeground(Qt.gray) # 灰色:已禁用 + elif lc == PluginLifecycle.SHUTTING_DOWN: + li.setForeground(QColor("#c62828")) # 红色:关闭中 + li.setText(f"{p.name} (⏸ 关闭中...)") + lst.addItem(li) - if p.widget and name not in t._detached_windows and name not in self._closed_plugins: + # 只有就绪状态才创建窗口(INITIALIZING/STOPPED 不创建) + if p.lifecycle == PluginLifecycle.READY and p.widget and name not in t._detached_windows and name not in self._closed_plugins: st = self._effective_state(name) if st.window_mode == WindowMode.DETACHED: t.add_detachable_tab(p.widget, name, icon=p.plugin_icon) @@ -748,11 +779,13 @@ def _on_list_context_menu(self, pos) -> None: menu = QMenu(self) t = self._tab_widget - # 启用/禁用 + # 启用/禁用(非就绪状态不可切换,但 STOPPED 可以重新启用) + lc = plugin.lifecycle + can_control = lc in (PluginLifecycle.READY, PluginLifecycle.STOPPED) act_enable = menu.addAction("✅ " + self.tr("启用")) act_disable = menu.addAction("❌ " + self.tr("禁用")) - act_enable.setEnabled(not plugin.is_enabled) - act_disable.setEnabled(plugin.is_enabled) + act_enable.setEnabled(can_control and not plugin.is_enabled) + act_disable.setEnabled(lc == PluginLifecycle.READY and plugin.is_enabled) act_enable.triggered.connect(lambda: self._toggle_plugin(name, True)) act_disable.triggered.connect(lambda: self._toggle_plugin(name, False)) @@ -777,9 +810,10 @@ def _on_list_context_menu(self, pos) -> None: act_open = menu.addAction("🖥 " + self.tr("打开窗口")) act_close = menu.addAction("🚫 " + self.tr("关闭窗口")) - can_open = (has_closed or (not has_tab and plugin.widget is not None)) + # 窗口操作只有就绪状态才可用 + can_open = lc == PluginLifecycle.READY and (has_closed or (not has_tab and plugin.widget is not None)) act_open.setEnabled(can_open) - act_close.setEnabled(has_tab or has_detached) + act_close.setEnabled(lc == PluginLifecycle.READY and (has_tab or has_detached)) # 打开日志文件 act_log = menu.addAction("📋 " + self.tr("打开日志")) @@ -801,6 +835,8 @@ def _toggle_plugin(self, name: str, enable: bool) -> None: if enable: self._manager.enable_plugin(name) else: + # 禁用时先关闭窗口(处理 detached 窗口清理),再 shutdown + self._close_plugin_window(name) self._manager.disable_plugin(name) self._sync_state(name, enabled=enable) self._refresh_plugin_list() @@ -831,15 +867,6 @@ def _open_plugin_window(self, name: str) -> None: self._closed_plugins.discard(name) t.add_detachable_tab(plugin.widget, name, icon=plugin.plugin_icon) - def _cleanup_detached(self, name: str) -> None: - """安全清理 detached 窗口引用""" - if name in self._detached_windows: - w = self._detached_windows[name] - w.blockSignals(True) # 阻止 closeEvent 再次触发 embed_requested - w.close() - w.deleteLater() - del self._detached_windows[name] - def _close_plugin_window(self, name: str) -> None: """关闭插件窗口(不销毁)""" t = self._tab_widget @@ -904,6 +931,7 @@ def _open_plugin_settings(self, name: str) -> None: if new_state.enabled: self._manager.enable_plugin(name) else: + self._close_plugin_window(name) self._manager.disable_plugin(name) # 立即应用日志级别 plugin = self._manager.plugins.get(name) diff --git a/src/plugin_manager/plugin_base.py b/src/plugin_manager/plugin_base.py index e729e64..742fed2 100644 --- a/src/plugin_manager/plugin_base.py +++ b/src/plugin_manager/plugin_base.py @@ -2,15 +2,21 @@ 插件基类定义 每个插件同时具备: -- 后台数据处理能力(订阅事件、处理数据) -- 界面交互能力(可选的 PyQt 界面) +|- 后台数据处理能力(订阅事件、处理数据) +|- 界面交互能力(可选的 PyQt 界面) 注意:插件共享同一个 ZMQClient,事件通过 EventDispatcher 内部分发 +每个插件运行在独立线程中(QThread),通过内部队列串行消费事件,保证线程安全 """ + from __future__ import annotations -from abc import ABC, abstractmethod +from collections import deque +import threading +from abc import abstractmethod +from contextlib import contextmanager from dataclasses import dataclass +from enum import Enum from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast _E = TypeVar("_E", bound="BaseEvent") @@ -18,7 +24,7 @@ if TYPE_CHECKING: from PyQt5.QtGui import QIcon -from PyQt5.QtCore import Qt, QObject +from PyQt5.QtCore import Qt, QThread, QObject, pyqtSignal, pyqtSlot from PyQt5.QtGui import QIcon, QPixmap, QPainter, QPen, QColor, QBrush, QFont from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag @@ -90,6 +96,15 @@ def _values(cls) -> list[str]: } +class PluginLifecycle(str, Enum): + """插件生命周期状态""" + NEW = "NEW" # 刚创建,未初始化 + INITIALIZING = "INITIALIZING" # 线程已启动,on_initialized() 正在执行 + READY = "READY" # on_initialized() 完成,正常运行 + SHUTTING_DOWN = "SHUTTING_DOWN" # shutdown() 调用中 + STOPPED = "STOPPED" # 已停止 + + class LogLevel(str): """日志级别枚举""" TRACE = "TRACE" @@ -128,15 +143,16 @@ class PluginInfo: log_config: "LogConfig | None" = None # 日志轮转配置,None 使用全局默认值 -class BasePlugin(ABC): +class BasePlugin(QThread): """ - 插件基类 - + 插件基类(继承 QThread,每个插件运行在独立线程中) + 每个插件同时具备后台数据处理和界面交互能力: - - 后台部分:订阅事件、处理数据、发送控制指令 - - 界面部分:可选的 PyQt 界面组件 - - 所有插件共享同一个 ZMQClient,事件通过 EventDispatcher 内部分发。 + - 后台部分:订阅事件、处理数据、发送控制指令(在独立线程中执行) + - 界面部分:可选的 PyQt 界面组件(需通过 run_on_gui 安全访问) + + 所有插件共享同一个 ZMQClient,事件通过 EventDispatcher 投递到各插件的队列。 + 每个插件的 handler 在自己的线程中**串行**执行,天然线程安全。 子类必须实现 ``plugin_info()`` 类方法来声明元信息:: @@ -150,25 +166,55 @@ def plugin_info(cls) -> PluginInfo: ) """ + # ── GUI 跨线程信号(类级别,所有实例共享连接到各自 slot)── + gui_call = pyqtSignal(object, object, object) + ready = pyqtSignal(object) # 插件就绪信号(参数:插件实例) + + # 队列最大容量(背压控制) + MAX_QUEUE_SIZE = 4096 + @classmethod @abstractmethod def plugin_info(cls) -> PluginInfo: """返回插件元信息。子类必须重写此方法。""" def __init__(self, info: PluginInfo): + QThread.__init__(self) + + # 抽象检查:确保子类实现了 plugin_info() + if type(self).plugin_info is BasePlugin.plugin_info: + raise TypeError( + f"Can't instantiate abstract class {type(self).__name__} " + f"without implementing 'plugin_info()' classmethod" + ) + + self.setObjectName(f"plugin-{info.name}") + # 保存线程名,在 run() 启动时设到底层 Python 线程 + self._thread_name = f"plugin-{info.name}" + self._info = info self._client: ZMQClient | None = None self._event_dispatcher: EventDispatcher | None = None self._widget: QWidget | None = None - self._initialized = False + self._lifecycle = PluginLifecycle.NEW + + # ── 队列基础设施 ── + self._event_queue: deque[tuple[Callable[[Any], None], Any]] = deque() + self._queue_lock = threading.Lock() + self._queue_event = threading.Event() # 用于通知新事件入队 + self._stop_requested = threading.Event() + self._resource_lock = threading.RLock() # 保护内部共享状态 + + # 连接 gui_call 信号到槽(QueuedConnection 跨线程安全) + self.gui_call.connect(self._on_gui_call, Qt.ConnectionType.QueuedConnection) # 每个插件拥有独立的 loguru logger(日志写入 plugins/.log) from .logging_setup import get_plugin_logger self.logger, self._log_sink_id = get_plugin_logger( info.name, - log_config=info.log_config, # 插件可自定义轮转策略 + log_config=info.log_config, ) - self._log_level: LogLevel = info.log_level # 当前日志级别 + self._log_level: LogLevel = info.log_level # ═══════════════════════════════════════════════════════════════════ # 属性 @@ -186,6 +232,16 @@ def name(self) -> str: def is_enabled(self) -> bool: return self._info.enabled + @property + def is_ready(self) -> bool: + """插件是否真正初始化完成(on_initialized 已执行完毕)""" + return self._lifecycle == PluginLifecycle.READY + + @property + def lifecycle(self) -> PluginLifecycle: + """当前生命周期状态""" + return self._lifecycle + @property def widget(self) -> QWidget | None: return self._widget @@ -210,12 +266,7 @@ def log_level(self) -> LogLevel: return self._log_level def set_log_level(self, level: LogLevel | str) -> None: - """ - 动态设置插件的日志级别 - - Args: - level: 日志级别 (LogLevel 枚举或字符串 "TRACE"/"DEBUG" 等) - """ + """动态设置插件的日志级别""" from .logging_setup import set_plugin_log_level if isinstance(level, str): level = LogLevel(level.upper()) @@ -230,6 +281,124 @@ def plugin_icon(self) -> QIcon: return self._info.icon return make_plugin_icon() + # ═══════════════════════════════════════════════════════════════════ + # 线程安全工具 + # ═══════════════════════════════════════════════════════════════════ + + @contextmanager + def locked(self): + """ + 保护内部状态的上下文管理器 + + 用法:: + + with self.locked(): + self._internal_counter += 1 + self._cache.clear() + """ + with self._resource_lock: + yield + + def run_on_gui(self, func: Callable[..., None], *args, **kwargs) -> None: + """ + 将函数调用安全地投递到 Qt GUI 主线程执行 + + 从插件的工作线程(handler)中调用此方法来更新 GUI。 + 通过 QueuedConnection 保证跨线程安全。 + + Args: + func: 要在主线程执行的函数 + *args: 位置参数 + **kwargs: 关键字参数 + + 用法:: + + def _on_video_save(self, event): + self._save_to_db(event) # IO — 直接做 + self.run_on_gui(self.table.refresh) # GUI — 投递到主线程 + """ + self.gui_call.emit(func, args, kwargs) + + @pyqtSlot(object, object, object) + def _on_gui_call( + self, + func: Callable[..., None], + args: tuple, + kwargs: dict, + ) -> None: + """GUI 主线程执行的槽:接收来自工作线程的回调请求""" + func(*args, **kwargs) + + # ═══════════════════════════════════════════════════════════════════ + # 线程入口(子类不应覆写) + # ═══════════════════════════════════════════════════════════════════ + + def run(self) -> None: + """ + 插件线程主循环:从队列中取出事件并调用对应的 handler + + 此方法由 QThread.start() 调用,子类不应覆写。 + 循环逻辑:等待事件 → 取出 → 执行 handler → 异常隔离 → 继续等待 + """ + # 设置 Python 线程名(调试时在 IDE 线程面板/日志中可见) + threading.current_thread().name = self._thread_name + + self.logger.debug(f"Plugin thread started: {self.name}") + + # 在插件线程中执行初始化回调(可能包含耗时操作:DB、网络等) + try: + self.on_initialized() + self._lifecycle = PluginLifecycle.READY + self.ready.emit(self) # 通知 UI 刷新 + except Exception as e: + self.logger.error( + f"on_initialized error in '{self.name}': {e}", + exc_info=True, + ) + + # 初始化期间可能已收到关闭请求,提前退出 + if self._stop_requested.is_set(): + try: + self.on_shutdown() + except Exception as e: + self.logger.error( + f"on_shutdown error in '{self.name}': {e}", + exc_info=True, + ) + return + + while not self._stop_requested.is_set(): + # 等待新事件入队或停止信号 + self._queue_event.wait(timeout=0.5) + + # 批量处理队列中的所有事件 + while not self._stop_requested.is_set(): + with self._queue_lock: + if not self._event_queue: + break + handler, event = self._event_queue.popleft() + + try: + handler(event) + except Exception as e: + self.logger.error( + f"Handler error in '{self.name}': {e}", + exc_info=True, + ) + + self._queue_event.clear() + + # 在插件线程中执行清理回调(可能包含耗时操作:DB 关闭、保存数据等) + try: + self.on_shutdown() + except Exception as e: + self.logger.error( + f"on_shutdown error in '{self.name}': {e}", + exc_info=True, + ) + + self.logger.debug(f"Plugin thread stopped: {self.name}") + # ═══════════════════════════════════════════════════════════════════ # 生命周期 # ═══════════════════════════════════════════════════════════════════ @@ -241,21 +410,35 @@ def set_event_dispatcher(self, dispatcher: EventDispatcher) -> None: self._event_dispatcher = dispatcher def initialize(self) -> None: - """初始化插件""" - if self._initialized: + """初始化插件并启动事件处理线程(主线程调用,快速返回)""" + if self._lifecycle not in (PluginLifecycle.NEW, PluginLifecycle.STOPPED): return self._setup_subscriptions() self._widget = self._create_widget() - self._initialized = True - self.on_initialized() + self._lifecycle = PluginLifecycle.INITIALIZING + + # 启动插件的事件处理线程(on_initialized 在 run 中执行) + self._stop_requested.clear() + self.start() + self.logger.debug(f"Plugin thread launched: {self.name}") def shutdown(self) -> None: - """关闭插件""" - if not self._initialized: + """关闭插件并停止事件处理线程""" + if self._lifecycle == PluginLifecycle.STOPPED: return - self.on_shutdown() + self._lifecycle = PluginLifecycle.SHUTTING_DOWN + + # 通知线程退出 + self._stop_requested.set() + self._queue_event.set() # 唤醒可能阻塞的 wait() + + # 等待线程结束(最多 2 秒) + # on_shutdown 已在 run() 末尾的插件线程中执行 + if not self.wait(2000): + self.logger.warning(f"Plugin thread did not stop in time: {self.name}") + self.terminate() # 强制终止 if self._event_dispatcher: self._event_dispatcher.unsubscribe_all(self) @@ -264,7 +447,35 @@ def shutdown(self) -> None: self._widget.deleteLater() self._widget = None - self._initialized = False + # 清空队列残留事件 + with self._queue_lock: + self._event_queue.clear() + + self._lifecycle = PluginLifecycle.STOPPED + + # ═══════════════════════════════════════════════════════════════════ + # 内部事件投递(由 EventDispatcher 调用) + # ═══════════════════════════════════════════════════════════════════ + + def _enqueue_event(self, handler: Callable[[Any], None], event: Any) -> bool: + """ + 将事件投递到插件队列(由 EventDispatcher 调用) + + 此方法是非阻塞的,立即返回。 + + Returns: + True 表示成功入队,False 表示队列已满被丢弃 + """ + with self._queue_lock: + if len(self._event_queue) >= self.MAX_QUEUE_SIZE: + self.logger.warning( + f"Event queue full ({self.MAX_QUEUE_SIZE}), dropping event" + ) + return False + self._event_queue.append((handler, event)) + + self._queue_event.set() + return True # ═══════════════════════════════════════════════════════════════════ # 抽象方法 @@ -274,7 +485,7 @@ def shutdown(self) -> None: def _setup_subscriptions(self) -> None: """ 设置事件订阅 - + 子类实现此方法,订阅感兴趣的事件: self.subscribe(GameStartedEvent, self._on_game_started) self.subscribe(BoardUpdateEvent, self._on_board_update) @@ -306,13 +517,7 @@ def subscribe( event_class: type[_E], handler: Callable[[_E], None], ) -> None: - """ - 订阅事件 - - Args: - event_class: 事件类(如 GameStartedEvent) - handler: 事件处理函数,参数类型必须与 event_class 一致 - """ + """订阅事件""" if self._event_dispatcher: tag = get_event_tag(event_class) self._event_dispatcher.subscribe(tag, handler, self._info.priority, self) @@ -345,14 +550,14 @@ def request(self, command: Any, timeout: float = 5.0) -> Any: def enable(self) -> None: """启用插件""" self._info.enabled = True - if not self._initialized: + if self._lifecycle == PluginLifecycle.STOPPED or not self.isRunning(): self.initialize() def disable(self) -> None: """禁用插件""" self._info.enabled = False - if self._initialized: + if self._lifecycle != PluginLifecycle.STOPPED: self.shutdown() def __repr__(self) -> str: - return f"" + return f"" diff --git a/src/plugin_manager/server_bridge.py b/src/plugin_manager/server_bridge.py index 986bd64..441ae2e 100644 --- a/src/plugin_manager/server_bridge.py +++ b/src/plugin_manager/server_bridge.py @@ -13,8 +13,6 @@ from shared_types import EVENT_TYPES, COMMAND_TYPES from shared_types import ( - GameStartedEvent, - GameEndedEvent, BoardUpdateEvent, NewGameCommand, ) @@ -33,7 +31,7 @@ class GameServerSignals(QObject): class GameServerBridge: """游戏服务端桥接器""" - + def __init__( self, game_ui: Any, @@ -42,66 +40,47 @@ def __init__( ): self._game_ui = game_ui self._log = log_handler - + # 信号对象,用于跨线程调用 self._signals = GameServerSignals() - + # 默认端点 if endpoint is None: endpoint = "tcp://127.0.0.1:5555" - + self._endpoint = endpoint self._server = ZMQServer(endpoint=endpoint, log_handler=log_handler) - + # 注册类型 self._server.register_event_types(*EVENT_TYPES) self._server.register_command_types(*COMMAND_TYPES) - + # 注册指令处理器 self._server.register_handler(NewGameCommand, self._handle_new_game) - + @property def endpoint(self) -> str: return self._endpoint - + @property def signals(self) -> GameServerSignals: """获取信号对象,用于连接到主线程的槽函数""" return self._signals - + def start(self) -> None: """启动服务""" self._server.start() logger.info(f"Game server bridge started at {self._endpoint}") - + def stop(self) -> None: """停止服务""" self._server.stop() logger.info("Game server bridge stopped") - - # ═══════════════════════════════════════════════════════════════════ - # 事件发布 - # ═══════════════════════════════════════════════════════════════════ - - def publish_game_started(self, rows: int, cols: int, mines: int) -> None: - """发布游戏开始事件""" - event = GameStartedEvent(rows=rows, cols=cols, mines=mines) - self._server.publish(GameStartedEvent, event) - - def publish_game_ended(self, is_win: bool, time: float) -> None: - """发布游戏结束事件""" - event = GameEndedEvent(is_win=is_win, time=time) - self._server.publish(GameEndedEvent, event) - - def publish_board_update(self, board: list[list[int]]) -> None: - """发布局面刷新事件""" - event = BoardUpdateEvent(board=board) - self._server.publish(BoardUpdateEvent, event) - + # ═══════════════════════════════════════════════════════════════════ # 指令处理 # ═══════════════════════════════════════════════════════════════════ - + def _handle_new_game(self, cmd: NewGameCommand) -> CommandResponse: """处理新游戏指令(在 ZMQ 后台线程中运行)""" try: diff --git a/src/plugins/event_log.py b/src/plugins/event_log.py deleted file mode 100644 index c91fc2c..0000000 --- a/src/plugins/event_log.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -示例插件:事件日志 - -功能: -- 记录所有收到的游戏事件 -- 界面显示事件时间线 -""" - -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon -from shared_types import ( - GameStartedEvent, - GameEndedEvent, - BoardUpdateEvent, -) -from PyQt5.QtWidgets import ( - QWidget, - QVBoxLayout, - QLabel, - QGroupBox, - QTextEdit, - QTableWidget, - QTableWidgetItem, -) -from PyQt5.QtCore import Qt, QDateTime -from PyQt5.QtGui import QColor - - -class EventLogPlugin(BasePlugin): - """事件日志插件""" - - @classmethod - def plugin_info(cls) -> PluginInfo: - return PluginInfo( - name="event_log", - description="事件日志记录器", - icon=make_plugin_icon("#e65100", "📝"), - ) - - def __init__(self, info): - super().__init__(info) - - def _setup_subscriptions(self) -> None: - self.subscribe(GameStartedEvent, self._on_game_started) - self.subscribe(GameEndedEvent, self._on_game_ended) - self.subscribe(BoardUpdateEvent, self._on_board_update) - - def _create_widget(self) -> QWidget: - widget = QWidget() - layout = QVBoxLayout(widget) - - # 事件表格 - group = QGroupBox("事件流") - glayout = QVBoxLayout(group) - - self._table = QTableWidget() - self._table.setColumnCount(3) - self._table.setHorizontalHeaderLabels(["时间", "类型", "详情"]) - self._table.setEditTriggers(QTableWidget.NoEditTriggers) - self._table.setSelectionMode(QTableWidget.NoSelection) - self._table.horizontalHeader().setStretchLastSection(True) - self._table.verticalHeader().setVisible(False) - glayout.addWidget(self._table) - layout.addWidget(group) - - # 统计 - sgroup = QGroupBox("统计") - slayout = QVBoxLayout(sgroup) - - self._stats_label = QLabel("等待事件...") - slayout.addWidget(self._stats_label) - layout.addWidget(sgroup) - - return widget - - def _add_event(self, event_type: str, detail: str, color: str | None = None) -> None: - if not hasattr(self, "_table"): - return - - row = self._table.rowCount() - self._table.insertRow(row) - - time_item = QTableWidgetItem( - QDateTime.currentDateTime().toString("HH:mm:ss.zzz") - ) - type_item = QTableWidgetItem(event_type) - detail_item = QTableWidgetItem(detail) - - if color: - for item in (time_item, type_item, detail_item): - item.setForeground(QColor(color)) - - self._table.setItem(row, 0, time_item) - self._table.setItem(row, 1, type_item) - self._table.setItem(row, 2, detail_item) - - self._table.scrollToBottom() - - def _update_stats(self): - total = self._table.rowCount() - if hasattr(self, "_stats_label"): - self._stats_label.setText(f"已记录 {total} 条事件") - - def _on_game_started(self, event: GameStartedEvent) -> None: - msg = f"{event.rows}x{event.cols}, {event.mines}雷" - self._add_event("GameStarted", msg, "#1976d2") - self.logger.info(f"GameStarted: {msg}") - self._update_stats() - - def _on_game_ended(self, event: GameEndedEvent) -> None: - result = "胜利" if event.is_win else "失败" - color = "#2e7d32" if event.is_win else "#c62828" - msg = f"{result}, 用时 {event.time:.3f}s" - self._add_event("GameEnded", msg, color) - self.logger.info(f"GameEnded: {msg}") - self._update_stats() - - def _on_board_update(self, event: BoardUpdateEvent) -> None: - rows = len(event.board) - cols = len(event.board[0]) if rows else 0 - msg = f"{rows}x{cols}" - self._add_event("BoardUpdate", msg, "#757575") - self.logger.debug(f"BoardUpdate: {msg}") - self._update_stats() - - def on_initialized(self) -> None: - self.logger.info("事件日志插件已初始化") diff --git a/src/plugins/game_monitor.py b/src/plugins/game_monitor.py deleted file mode 100644 index 0d80eed..0000000 --- a/src/plugins/game_monitor.py +++ /dev/null @@ -1,270 +0,0 @@ -""" -示例插件:简单游戏监控插件 - -功能: -- 监听游戏开始、局面刷新、游戏结束事件 -- 界面显示局面网格 -- 按钮控制新游戏 -""" - -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon -from shared_types import ( - GameStartedEvent, - GameEndedEvent, - BoardUpdateEvent, - NewGameCommand, -) -from PyQt5.QtWidgets import ( - QWidget, - QVBoxLayout, - QHBoxLayout, - QLabel, - QPushButton, - QSpinBox, - QGroupBox, - QTextEdit, - QTableWidget, - QTableWidgetItem, - QHeaderView, -) -from PyQt5.QtCore import Qt -from PyQt5.QtGui import QColor, QFont - - -class GameMonitorPlugin(BasePlugin): - """游戏监控插件""" - - @classmethod - def plugin_info(cls) -> PluginInfo: - return PluginInfo( - name="game_monitor", - description="游戏监控插件", - icon=make_plugin_icon("#1976d2", "🎮"), - ) - - # 局面值的颜色映射 - CELL_COLORS = { - 0: "#C0C0C0", # 空 - 1: "#0000FF", # 1 - 蓝 - 2: "#008000", # 2 - 绿 - 3: "#FF0000", # 3 - 红 - 4: "#000080", # 4 - 深蓝 - 5: "#800000", # 5 - 棕 - 6: "#008080", # 6 - 青 - 7: "#000000", # 7 - 黑 - 8: "#808080", # 8 - 灰 - 10: "#C0C0C0", # 未打开 - 11: "#C0C0C0", # 标雷 - 14: "#FF0000", # 踩雷(叉雷) - 15: "#FF0000", # 踩雷(红雷) - 16: "#FFFFFF", # 白雷 - } - - def __init__(self, info): - super().__init__(info) - self._game_rows = 0 - self._game_cols = 0 - self._game_mines = 0 - self._board = [] - - def _setup_subscriptions(self) -> None: - self.subscribe(GameStartedEvent, self._on_game_started) - self.subscribe(GameEndedEvent, self._on_game_ended) - self.subscribe(BoardUpdateEvent, self._on_board_update) - - def _create_widget(self): - """创建界面""" - - widget = QWidget() - layout = QVBoxLayout(widget) - - # 状态显示区 - status_group = QGroupBox("游戏状态") - status_layout = QVBoxLayout(status_group) - - self._status_label = QLabel("等待游戏...") - self._status_label.setStyleSheet("font-size: 14px; padding: 5px;") - status_layout.addWidget(self._status_label) - - self._info_label = QLabel("") - self._info_label.setStyleSheet("color: gray;") - status_layout.addWidget(self._info_label) - - layout.addWidget(status_group) - - # 局面显示区 - board_group = QGroupBox("局面") - board_layout = QVBoxLayout(board_group) - - self._board_table = QTableWidget() - self._board_table.setMinimumHeight(200) - self._board_table.setEditTriggers(QTableWidget.NoEditTriggers) - self._board_table.setSelectionMode(QTableWidget.NoSelection) - self._board_table.setFocusPolicy(Qt.NoFocus) - board_layout.addWidget(self._board_table) - - layout.addWidget(board_group) - - # 控制区 - control_group = QGroupBox("新游戏控制") - control_layout = QVBoxLayout(control_group) - - # 行列雷数设置 - param_layout = QHBoxLayout() - - param_layout.addWidget(QLabel("行:")) - self._rows_spin = QSpinBox() - self._rows_spin.setRange(1, 100) - self._rows_spin.setValue(16) - param_layout.addWidget(self._rows_spin) - - param_layout.addWidget(QLabel("列:")) - self._cols_spin = QSpinBox() - self._cols_spin.setRange(1, 100) - self._cols_spin.setValue(30) - param_layout.addWidget(self._cols_spin) - - param_layout.addWidget(QLabel("雷:")) - self._mines_spin = QSpinBox() - self._mines_spin.setRange(1, 999) - self._mines_spin.setValue(99) - param_layout.addWidget(self._mines_spin) - - control_layout.addLayout(param_layout) - - # 按钮 - self._new_game_btn = QPushButton("开始新游戏") - self._new_game_btn.clicked.connect(self._on_new_game_clicked) - control_layout.addWidget(self._new_game_btn) - - layout.addWidget(control_group) - - # 日志区 - log_group = QGroupBox("事件日志") - log_layout = QVBoxLayout(log_group) - - self._log_text = QTextEdit() - self._log_text.setReadOnly(True) - self._log_text.setMaximumHeight(100) - log_layout.addWidget(self._log_text) - - layout.addWidget(log_group) - - return widget - - def _log(self, msg: str) -> None: - """添加日志并滚动到底部""" - if self._log_text: - self._log_text.append(msg) - # 滚动到最后一行 - scrollbar = self._log_text.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - - def _render_board(self) -> None: - """渲染局面""" - if not self._board_table or not self._board: - return - - rows = len(self._board) - cols = len(self._board[0]) if rows > 0 else 0 - - self._board_table.setRowCount(rows) - self._board_table.setColumnCount(cols) - - # 计算单元格大小 - cell_size = max(12, min(25, 400 // max(rows, cols))) - - for i in range(rows): - self._board_table.setRowHeight(i, cell_size) - for j in range(cols): - val = self._board[i][j] if j < len(self._board[i]) else 10 - item = QTableWidgetItem() - - # 设置文字 - if val == 0: - item.setText("") - elif 1 <= val <= 8: - item.setText(str(val)) - elif val == 10: - item.setText("") - elif val == 11: - item.setText("🚩") - elif val in (14, 15, 16): - item.setText("💣") - else: - item.setText("") - - # 设置颜色 - color = self.CELL_COLORS.get(val, "#C0C0C0") - item.setBackground(QColor(color)) - if val in (1, 4, 7): - item.setForeground(QColor("#0000FF")) - elif val == 2: - item.setForeground(QColor("#008000")) - elif val == 3: - item.setForeground(QColor("#FF0000")) - - item.setTextAlignment(Qt.AlignCenter) - font = QFont() - font.setBold(True) - font.setPointSize(max(6, cell_size // 3)) - item.setFont(font) - - self._board_table.setItem(i, j, item) - - # 设置列宽 - for j in range(cols): - self._board_table.setColumnWidth(j, cell_size) - - # 隐藏表头 - self._board_table.horizontalHeader().hide() - self._board_table.verticalHeader().hide() - - def _on_game_started(self, event: GameStartedEvent) -> None: - self._game_rows = event.rows - self._game_cols = event.cols - self._game_mines = event.mines - self._board = [] - - self._status_label.setText("🎮 游戏进行中") - self._status_label.setStyleSheet("font-size: 14px; padding: 5px; color: green;") - self._info_label.setText(f"局面: {event.rows}x{event.cols}, {event.mines}雷") - msg = f"游戏开始: {event.rows}x{event.cols}, {event.mines}雷" - self._log(f"🎮 {msg}") - self.logger.info(msg) - - def _on_game_ended(self, event: GameEndedEvent) -> None: - result = "🎉 胜利" if event.is_win else "💥 失败" - color = "green" if event.is_win else "red" - - self._status_label.setText(f"{result}! 用时 {event.time:.2f}秒") - self._status_label.setStyleSheet( - f"font-size: 14px; padding: 5px; color: {color};" - ) - msg = f"{result}: 用时 {event.time:.2f}秒" - self._log(msg) - self.logger.info(msg) - - def _on_board_update(self, event: BoardUpdateEvent) -> None: - self._board = event.board - self._render_board() - rows = len(event.board) - msg = f"局面刷新: {rows}行" - self._log(f"📊 {msg}") - self.logger.debug(msg) - - def _on_new_game_clicked(self) -> None: - """新游戏按钮点击""" - rows = self._rows_spin.value() - cols = self._cols_spin.value() - mines = self._mines_spin.value() - - cmd = NewGameCommand(rows=rows, cols=cols, mines=mines) - self.send_command(cmd) - msg = f"发送新游戏指令: {rows}x{cols}, {mines}雷" - self._log(f"📤 {msg}") - self.logger.info(msg) - - def on_initialized(self) -> None: - self._log("✅ 插件已初始化") - self.logger.info("游戏监控插件已初始化") diff --git a/src/plugins/hello_world.py b/src/plugins/hello_world.py new file mode 100644 index 0000000..7901be4 --- /dev/null +++ b/src/plugins/hello_world.py @@ -0,0 +1,82 @@ +""" +Hello World 示例插件 + +演示基本的事件订阅、pyqtSignal 跨线程 GUI 更新。 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit +from PyQt5.QtCore import pyqtSignal + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent + + +class HelloWidget(QWidget): + """简单的 UI 界面""" + + _update_signal = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._count = 0 + + layout = QVBoxLayout(self) + + self._title = QLabel("Hello World Plugin") + self._title.setStyleSheet("font-size: 18px; font-weight: bold; padding: 10px;") + layout.addWidget(self._title) + + self._info = QLabel("Waiting for game data...") + layout.addWidget(self._info) + + self._log = QTextEdit() + self._log.setReadOnly(True) + layout.addWidget(self._log) + + self._update_signal.connect(self._append_log) + + def _append_log(self, text: str): + """Slot: executed on main thread""" + self._log.append(text) + self._count += 1 + self._info.setText(f"Received {self._count} record(s)") + + +class HelloPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="hello_world", + version="1.0.0", + author="Example", + description="Hello World - demonstrates event subscription and pyqtSignal GUI update", + icon=make_plugin_icon("#4CAF50", "H", 64), + window_mode=WindowMode.TAB, + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + self._widget = HelloWidget() + return self._widget + + def on_initialized(self) -> None: + self.logger.info("HelloPlugin initialized") + + def on_shutdown(self) -> None: + self.logger.info("HelloPlugin shutting down") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info( + f"Game: time={event.rtime}s, level={event.level}, " + f"3BV={event.bbbv}, L={event.left} R={event.right}" + ) + info_text = ( + f"[{event.rtime:.2f}s] {event.level} | " + f"3BV={event.bbbv} | L={event.left} R={event.right}" + ) + # pyqtSignal emit -> auto QueuedConnection cross-thread to main thread + self._widget._update_signal.emit(info_text) diff --git a/src/plugins/history.py b/src/plugins/history.py index 2f12562..2173ef0 100644 --- a/src/plugins/history.py +++ b/src/plugins/history.py @@ -18,6 +18,7 @@ import inspect from datetime import datetime from pathlib import Path +import time from typing import Any import msgspec @@ -815,8 +816,6 @@ def closeEvent(self, event: _QCloseEvent): # ═══════════════════════════════════════════════════════════════════ # 插件主体 # ═══════════════════════════════════════════════════════════════════ -class History_Signal(QObject): - video_save_over = pyqtSignal() class HistoryPlugin(BasePlugin): @@ -826,7 +825,7 @@ class HistoryPlugin(BasePlugin): - 后台:监听 VideoSaveEvent,写入 SQLite - 界面:提供筛选、分页、播放/导出功能 """ - + video_save_over = pyqtSignal() @classmethod def plugin_info(cls) -> PluginInfo: return PluginInfo( @@ -840,7 +839,6 @@ def plugin_info(cls) -> PluginInfo: def __init__(self, info): super().__init__(info) - self._signal = History_Signal() def _setup_subscriptions(self) -> None: self.subscribe(VideoSaveEvent, self._on_video_save) @@ -849,11 +847,12 @@ def _create_widget(self) -> QWidget: db_path = self.data_dir / "history.db" config_path = self.data_dir / "history_show_fields.json" self._widget = HistoryMainWidget(db_path, config_path) - self._signal.video_save_over.connect(self._widget.query_button.click) + self.video_save_over.connect(self._widget.query_button.click) return self._widget def on_initialized(self) -> None: self._init_db() + time.sleep(10) self.logger.info("历史记录插件已初始化") # ── 数据库 ────────────────────────────────────────────── @@ -942,4 +941,4 @@ def _on_video_save(self, event: VideoSaveEvent) -> None: ) finally: conn.close() - self._signal.video_save_over.emit() + self.video_save_over.emit() diff --git a/src/plugins/stats.py b/src/plugins/stats.py deleted file mode 100644 index f262875..0000000 --- a/src/plugins/stats.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -示例插件:统计面板 - -功能: -- 统计游戏胜率、平均用时 -- 界面显示统计图表和数字 -""" - -from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon -from shared_types import ( - GameStartedEvent, - GameEndedEvent, - BoardUpdateEvent, -) -from PyQt5.QtWidgets import ( - QWidget, - QVBoxLayout, - QLabel, - QGroupBox, - QTextEdit, - QTableWidget, - QTableWidgetItem, -) - - -class StatsPlugin(BasePlugin): - """游戏统计插件""" - - @classmethod - def plugin_info(cls) -> PluginInfo: - return PluginInfo( - name="stats", - description="游戏统计面板", - icon=make_plugin_icon("#7b1fa2", "📊"), - ) - - def __init__(self, info): - super().__init__(info) - self._total_games = 0 - self._wins = 0 - self._losses = 0 - self._total_time = 0.0 - - def _setup_subscriptions(self) -> None: - self.subscribe(GameStartedEvent, self._on_game_started) - self.subscribe(GameEndedEvent, self._on_game_ended) - - def _create_widget(self) -> QWidget: - widget = QWidget() - layout = QVBoxLayout(widget) - - # 统计概览 - group = QGroupBox("统计概览") - glayout = QVBoxLayout(group) - - self._summary_label = QLabel("暂无数据") - self._summary_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;") - glayout.addWidget(self._summary_label) - layout.addWidget(group) - - # 详细表格 - tgroup = QGroupBox("历史记录") - tlayout = QVBoxLayout(tgroup) - - self._table = QTableWidget() - self._table.setColumnCount(3) - self._table.setHorizontalHeaderLabels(["结果", "用时(秒)", "备注"]) - self._table.setEditTriggers(QTableWidget.NoEditTriggers) - self._table.setSelectionMode(QTableWidget.NoSelection) - self._table.horizontalHeader().setStretchLastSection(True) - tlayout.addWidget(self._table) - layout.addWidget(tgroup) - - # 日志 - lgroup = QGroupBox("事件日志") - llayout = QVBoxLayout(lgroup) - - self._log_text = QTextEdit() - self._log_text.setReadOnly(True) - self._log_text.setMaximumHeight(100) - llayout.addWidget(self._log_text) - layout.addWidget(lgroup) - - return widget - - def _log(self, msg: str) -> None: - if self._log_text: - self._log_text.append(msg) - sb = self._log_text.verticalScrollBar() - sb.setValue(sb.maximum()) - - def _update_summary(self) -> None: - if not hasattr(self, "_summary_label"): - return - win_rate = (self._wins / self._total_games * 100) if self._total_games else 0 - avg_time = (self._total_time / self._total_games) if self._total_games else 0 - self._summary_label.setText( - f"总计: {self._total_games} 场 | " - f"胜: {self._wins} | " - f"负: {self._losses} | " - f"胜率: {win_rate:.1f}% | " - f"均时: {avg_time:.2f}s" - ) - - def _on_game_started(self, event: GameStartedEvent) -> None: - self._log(f"🎮 游戏开始: {event.rows}x{event.cols}, {event.mines}雷") - self.logger.info(f"游戏开始: {event.rows}x{event.cols}, {event.mines}雷") - - def _on_game_ended(self, event: GameEndedEvent) -> None: - self._total_games += 1 - self._total_time += event.time - result = "胜利" if event.is_win else "失败" - if event.is_win: - self._wins += 1 - else: - self._losses += 1 - - # 添加到表格 - if hasattr(self, "_table"): - row = self._table.rowCount() - self._table.insertRow(row) - self._table.setItem(row, 0, QTableWidgetItem(result)) - self._table.setItem(row, 1, QTableWidgetItem(f"{event.time:.2f}")) - self._table.setItem(row, 2, QTableWidgetItem("" if event.is_win else "踩雷")) - - self._update_summary() - msg = f"{'🎉' if event.is_win else '💥'} {result}! 用时 {event.time:.2f}秒" - self._log(msg) - self.logger.info(f"游戏结束: {result}, 用时 {event.time:.2f}s, " - f"总场次={self._total_games}") - - def on_initialized(self) -> None: - self._log("✅ 统计插件已初始化") - self.logger.info("统计插件已初始化") diff --git a/src/plugins/stats_panel.py b/src/plugins/stats_panel.py new file mode 100644 index 0000000..bb0aa7b --- /dev/null +++ b/src/plugins/stats_panel.py @@ -0,0 +1,145 @@ +""" +实时游戏统计面板 + +展示计数器、表格等常见 UI 元素的用法。 +""" +from __future__ import annotations + +import json +from collections import defaultdict + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, + QTableWidgetItem, QGroupBox, QHeaderView, +) +from PyQt5.QtCore import pyqtSignal + +from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent + + +class StatsPanel(QWidget): + """统计面板 UI""" + + _signal_update_stats = pyqtSignal(dict) + _signal_add_record = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + self._total_games = 0 + self._stats_by_level = defaultdict(lambda: {"count": 0, "best_time": float('inf')}) + + self._setup_ui() + self._signal_update_stats.connect(self._do_update_stats) + self._signal_add_record.connect(self._do_add_record) + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + cards_layout = QHBoxLayout() + + self._lbl_total = self._make_stat_card("Total", "0", "#1976D2") + self._lbl_best = self._make_stat_card("Best", "--", "#F57C00") + + for card in [self._lbl_total, self._lbl_best]: + cards_layout.addWidget(card) + + main_layout.addLayout(cards_layout) + + group = QGroupBox("Recent Games") + group_layout = QVBoxLayout(group) + + self._table = QTableWidget() + self._table.setColumnCount(4) + self._table.setHorizontalHeaderLabels(["Level", "Time(s)", "3BV", "Clicks"]) + self._table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self._table.setAlternatingRowColors(True) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + group_layout.addWidget(self._table) + + main_layout.addWidget(group) + + @staticmethod + def _make_stat_card(title: str, value: str, color: str) -> QWidget: + card = QWidget() + card.setStyleSheet(f"background: {color}; border-radius: 8px; padding: 8px;") + layout = QVBoxLayout(card) + layout.setContentsMargins(12, 8, 12, 8) + + lbl_title = QLabel(title) + lbl_title.setStyleSheet("color: rgba(255,255,255,0.8); font-size: 12px;") + lbl_value = QLabel(value) + lbl_value.setStyleSheet("color: white; font-size: 24px; font-weight: bold;") + + layout.addWidget(lbl_title) + layout.addWidget(lbl_value) + return card + + def _do_update_stats(self, data: dict): + level = data.get("level", "?") + rtime = data.get("rtime", 0) + + self._total_games += 1 + self._lbl_total.findChild(QLabel).setText(str(self._total_games)) + + stats = self._stats_by_level[level] + stats["count"] += 1 + if rtime > 0 and rtime < stats["best_time"]: + stats["best_time"] = rtime + self._lbl_best.findChild(QLabel).setText(f"{rtime:.2f}") + + def _do_add_record(self, data: dict): + row = self._table.rowCount() + self._table.insertRow(row) + self._table.setItem(row, 0, QTableWidgetItem(str(data.get("level", "?")))) + self._table.setItem(row, 1, QTableWidgetItem(f"{data.get('rtime', 0):.2f}")) + self._table.setItem(row, 2, QTableWidgetItem(str(data.get("bbbv", 0)))) + ops = int(data.get("left", 0)) + int(data.get("right", 0)) + self._table.setItem(row, 3, QTableWidgetItem(str(ops))) + + +class StatsPlugin(BasePlugin): + """实时游戏统计插件""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="stats_panel", + version="1.0.0", + author="Example", + description="Real-time game statistics panel with table and counters", + icon=make_plugin_icon("#E91E63", "S", 64), + window_mode=WindowMode.TAB, + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget: + self._panel = StatsPanel() + return self._panel + + def on_initialized(self) -> None: + saved = self.data_dir / "saved_stats.json" + if saved.exists(): + try: + data = json.loads(saved.read_text(encoding='utf-8')) + self.logger.info(f"Restored {len(data)} records from disk") + except Exception as e: + self.logger.warning(f"Failed to load saved stats: {e}") + + def on_shutdown(self) -> None: + self.logger.info("StatsPlugin shutting down") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info(f"[{event.level}] {event.rtime:.2f}s | 3BV={event.bbbv}") + + event_dict = { + "level": event.level, + "rtime": event.rtime, + "bbbv": event.bbbv, + "left": event.left, + "right": event.right, + } + self._panel._signal_update_stats.emit(event_dict) + self._panel._signal_add_record.emit(event_dict) diff --git a/src/shared_types/__init__.py b/src/shared_types/__init__.py index e40178c..4016ed4 100644 --- a/src/shared_types/__init__.py +++ b/src/shared_types/__init__.py @@ -4,8 +4,6 @@ 定义主进程和插件管理器共用的类型 """ from .events import ( - GameStartedEvent, - GameEndedEvent, BoardUpdateEvent, EVENT_TYPES, ) @@ -17,11 +15,9 @@ __all__ = [ # 事件 - "GameStartedEvent", - "GameEndedEvent", "BoardUpdateEvent", "EVENT_TYPES", # 指令 "NewGameCommand", "COMMAND_TYPES", -] \ No newline at end of file +] diff --git a/src/shared_types/commands.py b/src/shared_types/commands.py index 6cb3bcb..5cba3ed 100644 --- a/src/shared_types/commands.py +++ b/src/shared_types/commands.py @@ -13,6 +13,13 @@ class NewGameCommand(BaseCommand, tag="new_game"): mines: int = 99 -COMMAND_TYPES = [ - NewGameCommand, -] \ No newline at end of file +class MouseClickCommand(BaseCommand, tag="mouse_click"): + """鼠标点击指令""" + + row: int = 0 + col: int = 0 + button: int = 0 + modifiers: int = 0 + + +COMMAND_TYPES = [NewGameCommand, MouseClickCommand] diff --git a/src/shared_types/events.py b/src/shared_types/events.py index 45e8985..c97ec39 100644 --- a/src/shared_types/events.py +++ b/src/shared_types/events.py @@ -6,22 +6,17 @@ from lib_zmq_plugins.shared.base import BaseEvent -class GameStartedEvent(BaseEvent, tag="game_started"): - """游戏开始事件""" - rows: int = 0 - cols: int = 0 - mines: int = 0 +class GameStatusChange(BaseEvent, tag="game_status"): + last_status: int = 0 + cuurent_status: int = 0 -class GameEndedEvent(BaseEvent, tag="game_ended"): - """游戏结束事件""" - is_win: bool = False - time: float = 0.0 +class BoardUpdateEvent(BaseEvent, tag="board_update"): + pass -class BoardUpdateEvent(BaseEvent, tag="board_update"): - """局面刷新事件""" - board: list[list[int]] = [] +class ConetxtChangeEvent(BaseEvent, tag="context_change"): + pass class VideoSaveEvent(BaseEvent, tag="video_save"): @@ -60,8 +55,8 @@ class VideoSaveEvent(BaseEvent, tag="video_save"): corr: float = 0 thrp: float = 0 ioe: float = 0 - is_official: bool = 0 - is_fair: bool = 0 + is_official: bool = False + is_fair: bool = False op: int = 0 isl: int = 0 pluck: float = 0 @@ -69,8 +64,6 @@ class VideoSaveEvent(BaseEvent, tag="video_save"): EVENT_TYPES = [ - GameStartedEvent, - GameEndedEvent, BoardUpdateEvent, VideoSaveEvent, ]