From 2479b47f006cc4d058a3ff1bf91d67b8cfc76518 Mon Sep 17 00:00:00 2001 From: LjingZhijoin Group <107012527+ljzloser@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:20:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=92=E4=BB=B6=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * doc:modify readme and license * feat(plugin): 新增插件系统支持 添加插件管理功能,包括插件进程管理、事件处理、上下文共享和配置管理。主要变更包括: - 新增插件管理器PluginManager - 实现插件进程间通信 - 添加基础插件框架类BasePlugin - 支持事件订阅和分发机制 - 提供插件配置管理功能 --------- Co-authored-by: 👑➕🍋 <50390200+eee555@users.noreply.github.com> --- .gitignore | 5 +- LICENSE | 88 +++++++ README.md | 98 ++++---- README_EN.md | 304 +++++++++++++++++++++++++ requirements.txt | 5 +- src/main.py | 110 ++++++--- src/mineSweeperGUI.py | 53 ++--- src/mp_plugins/__init__.py | 13 ++ src/mp_plugins/base/__init__.py | 33 +++ src/mp_plugins/base/_data.py | 36 +++ src/mp_plugins/base/config.py | 52 +++++ src/mp_plugins/base/context.py | 31 +++ src/mp_plugins/base/error.py | 10 + src/mp_plugins/base/event.py | 11 + src/mp_plugins/base/message.py | 32 +++ src/mp_plugins/base/mode.py | 26 +++ src/mp_plugins/base/plugin.py | 188 +++++++++++++++ src/mp_plugins/context.py | 5 + src/mp_plugins/events.py | 5 + src/mp_plugins/plugin_manager.py | 255 +++++++++++++++++++++ src/mp_plugins/plugin_process.py | 58 +++++ src/pluginDialog.py | 204 +++++++++++++++++ src/plugins/UpLoadVideo/UpLoadVideo.py | 70 ++++++ src/ui/ui_main_board.py | 44 +++- uiFiles/main_board.ui | 62 ++++- "\345\215\217\350\256\256" | 55 +++++ 26 files changed, 1740 insertions(+), 113 deletions(-) create mode 100644 README_EN.md create mode 100644 src/mp_plugins/__init__.py create mode 100644 src/mp_plugins/base/__init__.py create mode 100644 src/mp_plugins/base/_data.py create mode 100644 src/mp_plugins/base/config.py create mode 100644 src/mp_plugins/base/context.py create mode 100644 src/mp_plugins/base/error.py create mode 100644 src/mp_plugins/base/event.py create mode 100644 src/mp_plugins/base/message.py create mode 100644 src/mp_plugins/base/mode.py create mode 100644 src/mp_plugins/base/plugin.py create mode 100644 src/mp_plugins/context.py create mode 100644 src/mp_plugins/events.py create mode 100644 src/mp_plugins/plugin_manager.py create mode 100644 src/mp_plugins/plugin_process.py create mode 100644 src/pluginDialog.py create mode 100644 src/plugins/UpLoadVideo/UpLoadVideo.py create mode 100644 "\345\215\217\350\256\256" diff --git a/.gitignore b/.gitignore index b0bfc9b..380d827 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ __pycache__/ # C extensions *.so - +*.exe # Distribution / packaging .Python build/ @@ -165,4 +165,5 @@ toollib/frame.csv *.evf *.mvf old/ -发布流程.txt \ No newline at end of file +发布流程.txt +src/plugins/*/*.json diff --git a/LICENSE b/LICENSE index f288702..f2bc7fe 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,15 @@ +# 📄 **LICENSE** + +**GNU General Public License Version 3 (GPLv3)** +**with Additional Commercial Restriction and Contributor Revenue Sharing Terms** + +--- + +# **Part I — GNU GPLv3 (Unmodified)** + +**The full text of the GNU General Public License version 3 (GPLv3) follows below, unmodified.** + + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 @@ -672,3 +684,79 @@ may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . + +--- + +# **Part II — Additional Terms under GPLv3 Section 7** + +## **A. Commercial Use Restriction** + +The following additional restriction is applied to the Program and all derivative works, as permitted under GPLv3 Section 7: + +1. **Commercial use of the Program, whether in its unmodified form or in modified or derivative forms, is prohibited without explicit written permission from the original copyright holder (“Owner”).** + Commercial use includes, but is not limited to: + + * selling copies; + * paid distribution; + * paid licensing; + * offering the Program as part of a monetized service; + * any activity in which the Program directly or indirectly generates revenue. + +2. **Commercial use is permitted only after obtaining explicit written authorization from the Owner.** + Such authorization will be issued through a separate commercial license agreement. + +3. Non-commercial use, modification, and redistribution remain fully governed by the GPLv3 without additional restrictions. + +--- + +## **B. Revenue Sharing for Donations, Commercial Licenses, and Related Income** + +The following additional term governs the distribution of revenue derived from donations, sponsorships, commercial licenses, or any other income related to the Program: + +1. **All monetary income generated from the Program — including but not limited to donations, sponsorships, paid support, and commercial licensing — shall be distributed among all contributors according to their proportional number of commits to the project's primary repository.** + +2. The project Owner is responsible for: + + * calculating commit proportions; + * collecting income; + * distributing shares to contributors in a fair and transparent manner; + * publishing the distribution records. + +3. Contributors are defined as individuals who have authored commits that have been merged into the main branch of the project repository. + +4. Commit counts may be weighted or normalized to remove trivial commits (e.g., whitespace-only changes), if the Owner and contributors collectively agree on such rules. + +5. Contributors acknowledge that distribution depends on practical factors such as payment processing fees and contributor identity verification. + +--- + +# **Part III — Clarifications (Non-binding but Recommended)** + +These clarifications help ensure common understanding: + +* “Commercial use” refers to any activity where the Program or its derivatives contribute to revenue, regardless of platform (e.g., Steam, app stores, SaaS, paid bundles). +* Non-commercial redistribution is always permitted under GPLv3. +* Contributors who wish to engage in commercial use must also obtain authorization. +* The Owner may refuse commercial authorization if it violates project ethics or harms contributors. +* Revenue-sharing obligations apply only to income associated with this project, not the contributor's unrelated work. + +--- + +# **Part IV — Informal Summary (Not Legally Binding)** + +**You may:** + +* Use, study, modify, and redistribute the Program for non-commercial purposes. +* Create derivative works (non-commercial). +* Request commercial licensing from the Owner. +* Receive proportional revenue if you contribute commits. + +**You may NOT:** + +* Sell or commercially distribute any version without explicit written permission. +* Bypass the revenue-sharing terms for commercial income. + + + + + diff --git a/README.md b/README.md index c9c2b37..4c0f9ea 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,81 @@ # 元扫雷(Metasweeper) + +**[English version is here.](README_EN.md)** + - 包含8种模式的专业扫雷版本、第三代扫雷录像播放器及高性能算法工具箱 -- project with 8 modes of minesweeper, third generation minesweeper video player and high performance algorithm toolbox [![MetaSweeper](https://img.shields.io/badge/MetaSweeper-v3.2.1-brightgreen.svg)](https://github.com/eee555/Solvable-Minesweeper) [![stars](https://img.shields.io/github/stars/eee555/Solvable-Minesweeper)](https://github.com/eee555/Solvable-Minesweeper/stargazers) [![forks](https://img.shields.io/github/forks/eee555/Solvable-Minesweeper)](https://github.com/eee555/Solvable-Minesweeper/forks) - ## 简介 -**元扫雷**是由专业玩家开发的扫雷游戏。这个项目并非简单重复已有的工作,而是集中了一批扫雷游戏的现代化设计。元扫雷所生成的游戏录像得到[开源扫雷网](https://openms.top)的承认并参与世界排名。 - -优势: - -+ 内部集成了**三大判雷引擎+集成的局面状态机+概率计算引擎+光学局面识别(Optical Board Recognition,OBR)引擎**,具备性能优势。 - -+ 采用Python/PyQt5及Rust编写,模块间相互配合、融为一体,兼顾**开发效率、内存安全与执行速度**。游戏界面与算法高度分离,自研的工具箱同样开源,且遵循更为宽松的MIT协议,通过`pip install ms_toollib`命令即可安装。 - -+ 游戏模式方面,具有**全部6种无猜扫雷模式+标准+win7**,弱可猜、强可猜的模式都是绝无仅有的。 - -+ 可以按住ctrl并滚动滚轮任意**调整大小**,能调整窗口的**透明度**。这是罕见的。 +**元扫雷(Meta Minesweeper)**由资深扫雷专业玩家与软件工程师共同打造——不是对传统扫雷的简单重复,而是在**算法、性能、可扩展性与工具链层面**的全面现代化。 -+ 按下“空格”计算局面中每一格是雷的概率。这是罕见的。 +元扫雷生成的录像格式已获得[开源扫雷网](https://openms.top)官方认可,并参与国际排行榜。 -+ 按下“ctrl+空格”能**截屏识别**计算其他扫雷中每一格是雷的概率。这是绝无仅有的。 -+ 其装载的录像播放器可以分析录像的高层抽象特征,并实时展示游戏局面中每一格是雷的概率。这是绝无仅有的。能够播放avf、rmv、mvf、evf四种主流格式的录像。这是罕见的。 +## 项目优势与技术亮点 -+ 能够计算3BV/s、STNB、RQP等指标并展示,能够自定义公式。这是罕见的。 +### (1)算法与引擎体系 -+ 完备的局面筛选功能,按用户配置来筛选。这是罕见的。 +元扫雷由`ms_toollib`工具箱赋能,核心竞争力来自后者高度优化的算法组件,构成完整的扫雷智能算法系统。 -+ 对变速齿轮等多种作弊手段的防御能力。 +* **三大判雷引擎**:提供多层次策略推理,覆盖从简单集合到枚举法求解。 +* **统一局面状态机**:将游戏局面抽象为自动状态机,提升算法集成度与可扩展性。 +* **概率推断引擎**:支持计算局面中任意一格是雷的概率,求解速度仅次于JSMinesweeper。 +* **光学局面识别(OBR)引擎**:可从任意扫雷应用的截屏中重建局面,实现跨游戏智能分析。 -+ 国际化,包括中、英、德、波兰等语言。 +--- -目前属于漫长的开发阶段中,约1~3月更新一个版本,欢迎提issue、star、pull request、fork。 +### (2)架构与技术栈 -## Introduction +项目在性能、安全性、工具链友好度之间取得扎实平衡。 -Minesweeper is a mineswering game developed by players who are passionate about the game. This project is not a simple repetition of existing work, but a concentration of modern design elements for minesweeper games. +* **Python / PyQt5 + Rust 复合架构**: -Advantages: + * Python 负责 UI、生态扩展; + * Rust 提供核心算法计算的高性能与内存安全。 +* **界面与算法完全解耦**,使 UI和工具链可独立推进。 +* 完全开源的工具链 **`ms_toollib`(MIT License)**,可通过 `pip install ms_toollib` 直接安装并在其他项目中复用。 -+ It **internally integrates three major mine-judging engines, an integrated board state machine, a probability calculation engine, and an Optical Board Recognition (OBR) engine**, offering performance advantages. +--- -+ Written using Python/PyQt5 and Rust, the modules cooperate with each other and are integrated into one, balancing **development efficiency, memory safety, and execution speed**. The game interface is highly separated from the algorithms, and the self-developed toolbox is also open-source, following a more permissive MIT license, which can be installed via the command `pip install ms_toollib`. +### (3)游戏模式与交互能力 -+ In terms of game modes, it features **all six no-guess minesweeper modes, standard, and win7**, with unique modes for both weak and strong guesswork. +具备目前扫雷软件生态中覆盖度最广、交互方式最现代化的功能。 -+ Aesthetically, it appears as a standard minesweeper, but it can **adjust its size** by holding down the ctrl key and scrolling the wheel, and adjust the **transparency** of the window. This is rare. +* 支持 **全部 6 种无猜模式 + 标准 + Win7 模式**;弱可猜 / 强可猜模式均为独家实现。 +* **Ctrl + 滚轮** 自由缩放界面尺寸,提供罕见的 UI 灵活度。 +* **Space**:即时计算当前盘面每一格的雷概率。 +* **Ctrl + Space**:截屏识别并对任何外部扫雷应用执行概率计算(OBR)。 +* **局面筛选器**:基于自定义策略的复杂条件过滤。 +* **性能指标系统**:内置 3BV/s、STNB、RQP 等指标,并支持自定义公式。 -+ Pressing "space" calculates the probability of each cell being a mine in the board. This is rare. +--- -+ Pressing "ctrl+space" enables **screenshot recognition** to calculate the probability of each cell being a mine in other minesweeper games. This is unique. +### (4)录像系统与生态兼容 -+ Its built-in video player can analyze high-level abstract features of recordings and display the probability of each cell being a mine in real-time. This is unique. It can play four mainstream formats of recordings: avf, rmv, mvf, and evf. This is rare. +元扫雷不仅是游戏本体,也是一套专业分析平台。 -+ It can calculate and display metrics such as 3BV/s, STNB, RQP, and allows for custom formulas. This is rare. +* 高级录像播放器:支持高层抽象分析,并实时呈现格子概率。 +* 兼容 **avf / rmv / mvf / [evf](https://github.com/eee555/ms_toollib/blob/main/evf%E6%A0%87%E5%87%86.md)** 四大主流录像格式。 +* 兼容[**evfs**](https://github.com/eee555/ms_toollib/blob/main/evfs%E6%A0%87%E5%87%86.md)录像集格式。 +* 对常见作弊手段(如变速齿轮)具备对抗能力。 +* 国际化支持:中文、英文、德文、波兰文等语言。 -+ Comprehensive board filtering capabilities, configurable by the user. This is rare. +元扫雷正处于持续演进阶段,通常 **3~12 个月发布一个版本**。 +欢迎提交 **Issue / PR / Star / Fork** ——您的参与将决定一个开源扫雷生态的未来走向。 -+ Defense capabilities against various cheating methods, including speed changers. - -+ Internationalization, including languages such as Chinese, English, German, Polish, and more. - -Currently in the lengthy development phase, with updates approximately every 1 to 3 months, we welcome issues, stars, pull requests, and forks. - -### 开发计划 +### 参考连接 + 使用教程:[https://openms.top/#/guide/[80.%E6%95%99%E7%A8%8B.%E8%BD%AF%E4%BB%B6]%E5%85%83%E6%89%AB%E9%9B%B7%E4%BD%BF%E7%94%A8%E6%95%99%E7%A8%8B](https://openms.top/#/guide/[80.%E6%95%99%E7%A8%8B.%E8%BD%AF%E4%BB%B6]%E5%85%83%E6%89%AB%E9%9B%B7%E4%BD%BF%E7%94%A8%E6%95%99%E7%A8%8B) + 算法工具箱地址:[https://github.com/eee555/ms_toollib](https://github.com/eee555/ms_toollib) + 算法工具箱文档:[https://docs.rs/ms_toollib](https://docs.rs/ms_toollib) ## 安装 -建议在`Windows 10`或`Windows 11`下运行本游戏,其它操作系统未经测试,可能出现意想不到的问题。 + +操作系统:仅支持`Windows 10`或`Windows 11`。 ### 方案1:通过官方下载链接安装(推荐) 在下面的[下载链接](#下载链接)中找到最新的版本,然后下载,解压,直接运行`main.exe`文件(如果警告请点击“仍然运行”),开箱即用。通过此方法安装的软件,是`正版`的软件,能够对录像文件进行官方的签名(签名功能打包在“metaminesweeper_checksum.pyd”中,占比很小,且是闭源的)。 @@ -120,14 +121,13 @@ Currently in the lengthy development phase, with updates approximately every 1 t python3 src/main.py # *nix ``` -## 实现原理 - -(还没写,计划弄出3.5以后回头来写) - ## 贡献 [CONTRIBUTING.md](https://github.com/eee555/Solvable-Minesweeper/blob/master/CONTRIBUTING.md) +# 协议须知 +项目使用了附带额外条款的GPLv3协议,尤其禁止了项目未经授权的商用行为,也规定了项目的收益分配方式。细节参见`LICENSE`。 + ## 荣誉 收录于Awesome Rust Repositories: [https://twitter.com/RustRepos/status/1636837781765799940](https://twitter.com/RustRepos/status/1636837781765799940) @@ -140,19 +140,19 @@ Currently in the lengthy development phase, with updates approximately every 1 t [![Star History Chart](https://api.star-history.com/svg?repos=eee555/Metasweeper&type=Date)](https://star-history.com/?repos=eee555/Metasweeper#repos=eee555/Metasweeper&eee555/Metasweeper&Date) ## 赞助 -感谢您考虑支持我们的开源项目,赞助时请备注5+您的昵称,例如“5张先生”。您的赞助将有助于项目的持续发展和改进,使我们能够继续提高软件的质量(owner许诺向所有contributor按获得赞助时commit数量的比例分配赞助得到的收入)。 +感谢您考虑支持我们的开源项目,赞助时请备注**项目名称+您的昵称+其他要求**,例如`元扫雷+张先生+建议添加**功能`。您的赞助将有助于项目的持续发展和改进,使我们能够继续提高软件的质量。此外,按照本项目协议协议,赞助得到的收入将由贡献者按commit数量的比例进行分配。 ### 一般赞助者 -- 一次性捐款¥3及以上 +- 一次性捐款 **¥3** 及以上 - 您的名字将永久出现在项目的贡献者列表中(按照您要求的形式) ### 重要赞助者 -- 一次性捐款¥50及以上 +- 一次性捐款 **¥50** 及以上 - 一般赞助者的所有的权益 - 独家定期报告项目进展 ### 核心赞助者 -- 累计捐款¥1000及以上 +- 累计捐款 **¥1000** 及以上 - 重要赞助者的所有的权益 - 可行的前提下,按照您的要求来制定开发计划 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..108576a --- /dev/null +++ b/README_EN.md @@ -0,0 +1,304 @@ +# Meta Minesweeper (Metasweeper) + +**[中文版本在此](README.md)** + +* A professional Minesweeper suite featuring 8 gameplay modes, a third-generation replay player, and a high-performance algorithm toolbox. + +[![MetaSweeper](https://img.shields.io/badge/MetaSweeper-v3.2.1-brightgreen.svg)](https://github.com/eee555/Solvable-Minesweeper) +[![stars](https://img.shields.io/github/stars/eee555/Solvable-Minesweeper)](https://github.com/eee555/Solvable-Minesweeper/stargazers) +[![forks](https://img.shields.io/github/forks/eee555/Solvable-Minesweeper)](https://github.com/eee555/Solvable-Minesweeper/forks) + +## Introduction + +**Meta Minesweeper** is developed by experienced professional Minesweeper players and software engineers. It is not a simple clone of traditional Minesweeper, but a complete modernization in **algorithms, performance, extensibility, and tooling**. + +Its replay formats are officially recognized by the [Open Minesweeper Network](https://openms.top) and included in international leaderboards. + +## Key Advantages & Technical Highlights + +### (1) Algorithm & Engine System + +Powered by the `ms_toollib` toolbox, Meta Minesweeper’s core strength comes from its highly optimized algorithm components that form a complete intelligent Minesweeper system. + +* **Three inference engines**: multi-layered solving strategies from set-based deduction to full enumeration. +* **Unified board state machine**: abstracts the game board into a formal automaton, improving algorithm composability and extensibility. +* **Probability inference engine**: computes the probability of any tile containing a mine, with speed second only to JSMinesweeper. +* **Optical Board Recognition (OBR)**: reconstructs board states from screenshots of *any* Minesweeper application for cross-software intelligent analysis. + +--- + +### (2) Architecture & Tech Stack + +Designed for strong performance, safety, and tooling friendliness. + +* **Python / PyQt5 + Rust hybrid architecture** + + * Python handles UI and ecosystem extensions. + * Rust provides high-performance, memory-safe core computation. +* **Complete UI–algorithm separation**, enabling independent iteration. +* Fully open-source toolbox **`ms_toollib` (MIT License)**, installable via `pip install ms_toollib` for use in external projects. + +--- + +### (3) Gameplay Modes & Interaction + +One of the most feature-complete and modernized Minesweeper implementations available. + +* Supports **all 6 guess-free modes + Standard + Win7 mode**; weak/strong semi-guessable modes are unique implementations. +* **Ctrl + mouse wheel**: freely scale UI size. +* **Space**: compute mine probability for every tile. +* **Ctrl + Space**: screenshot + OBR to compute probabilities for external Minesweeper applications. +* **Board filter**: complex filtering based on custom strategies. +* **Performance metrics**: built-in 3BV/s, STNB, RQP, and custom formulas. + +--- + +### (4) Replay System & Ecosystem Compatibility + +Meta Minesweeper is not just a game but a full analysis platform. + +* Advanced replay player with high-level analysis and real-time probabilities. +* Supports **avf / rmv / mvf / [evf](https://github.com/eee555/ms_toollib/blob/main/evf标准.md)** formats. +* Supports **[evfs](https://github.com/eee555/ms_toollib/blob/main/evfs标准.md)** replay-set format. +* Resistant to common cheating methods (e.g., speed-gear tools). +* Internationalization: Chinese, English, German, Polish, etc. + +Meta Minesweeper is actively developed and typically releases **every 3–12 months**. +Issues, PRs, stars, and forks are all welcome. + +### Reference Links + +* User Guide: [https://openms.top/#/guide/[80.%E6%95%99%E7%A8%8B.%E8%BD%AF%E4%BB%B6]%E5%85%83%E6%89%AB%E9%9B%B7%E4%BD%BF%E7%94%A8%E6%95%99%E7%A8%8B](https://openms.top/#/guide/[80.%E6%95%99%E7%A8%8B.%E8%BD%AF%E4%BB%B6]%E5%85%83%E6%89%AB%E9%9B%B7%E4%BD%BF%E7%94%A8%E6%95%99%E7%A8%8B) +* Algorithm Toolbox: [https://github.com/eee555/ms_toollib](https://github.com/eee555/ms_toollib) +* Toolbox Documentation: [https://docs.rs/ms_toollib](https://docs.rs/ms_toollib) + +## Installation + +Supported OS: **Windows 10 / Windows 11 only** + +### Option 1: Install via Official Download (Recommended) + +Find the latest version in the [download section](#下载链接), unzip it, and run `main.exe` directly (click “Run anyway” if prompted). +Software installed this way is the **official, fully signed version**, capable of generating valid replay signatures (`metaminesweeper_checksum.pyd` is the small closed-source signing module). + +### Option 2: Install via GitHub Actions (Safest) + +**Note:** this version **cannot** generate valid replay signatures. Replays created by self-built versions cannot pass validation by the official build. All other features are identical. + +Go to [GitHub Actions](https://github.com/eee555/Solvable-Minesweeper/actions), find the latest successful build, download the Artifacts, and run as above. +Provides the newest features and guaranteed clean/no-virus builds, but unreleased builds may be unstable. + +### Option 3: Build from Source (Not Recommended) + +**Note:** this version also **cannot** produce valid replay signatures. +Users may create custom builds and implement their own secret signature logic if desired. + +Requirements: + +* Python ≥3.10, ≤3.12 (3.12 recommended) +* Ability to use PowerShell or any CLI + +Steps: + +```sh +git clone https://github.com/eee555/Solvable-Minesweeper.git +``` + +Option A: Install Python deps from PyPI (simple, may fail if API changed) + +```sh +pip install -r requirements.txt # Windows +pip3 install -r requirements.txt # *nix +``` + +Option B: Install deps from GitHub (nightly ms_toollib; guaranteed to work; requires Rust) + +```sh +git clone https://github.com/eee555/ms_toollib.git +cd ms_toollib/python_package +cargo build --release +# Rename ms_toollib.dll → ms_toollib.pyd and copy to Solvable-Minesweeper/src +# Install all remaining requirements except ms_toollib +``` + +Additional required files (copy from any earlier release): + +* `en_US.qm`, `de_DE.qm`, `pl_PL.qm` etc. → Solvable-Minesweeper/ +* `params.onnx` model → Solvable-Minesweeper/src/ + +Run: + +```sh +py -3 src/main.py # Windows +python3 src/main.py # *nix +``` + +## Contributing + +See [CONTRIBUTING.md](https://github.com/eee555/Solvable-Minesweeper/blob/master/CONTRIBUTING.md) + +## License Notice + +This project uses **GPLv3 with additional terms**, explicitly prohibiting unauthorized commercial use and defining revenue distribution rules. +See `LICENSE` for details. + +## Honors + +Featured in Awesome Rust Repositories: +[https://twitter.com/RustRepos/status/1636837781765799940](https://twitter.com/RustRepos/status/1636837781765799940) + +Featured on llamasweeper.com (4.5 stars): +[https://llamasweeper.com/#/others](https://llamasweeper.com/#/others) + +Official Minesweeper software of OpenMS: [https://openms.top](https://openms.top) + +[![Star History Chart](https://api.star-history.com/svg?repos=eee555/Metasweeper\&type=Date)](https://star-history.com/?repos=eee555/Metasweeper#repos=eee555/Metasweeper&Date) + +## Sponsorship + +Thank you for considering support. Please note in your donation: +**Project name + your nickname + any message**, e.g. +`Meta Minesweeper + Mr. Zhang + please add feature X`. + +Per project rules, donations are distributed among contributors proportionally to commit count. + +### General Supporter + +* One-time donation **¥3+** +* Your name is permanently listed in the contributor table + +### Important Supporter + +* One-time donation **¥50+** +* All rights of General Supporter +* Regular project progress reports + +### Core Supporter + +* Total donation **¥1000+** +* All rights of Important Supporter +* Development priorities may be adjusted per your reasonable requests + +![](readme_pic/微信收款码.png) ![](readme_pic/支付宝收款码.png) + +## Contributor List + +| Sponsor | Amount | Date | Channel | Distribution | +| :-----: | :----: | :--------: | :-----: | :----------: | +| *Song | ¥72.60 | 2024-04-04 | WeChat | Pending | +| *Chang | ¥55.00 | 2024-07-27 | Alipay | Pending | + +## Download Links + +### v3.2.1 + +Supports saving evfs replay sets, selecting any replay for playback, multi-select → export as evf, new *pluck* metric for luck evaluation, new *lag mode* (`[lag]` prefix), movable sub-windows, improved player with tab switching, updated to evf4, updated landmine algorithms, Enter = OK, precision to 3 decimal places, drag-and-drop replay loading, new log/sin/tan/cos/row/column/minenum functions in counter, improved country dropdown, removed transparency setting, and many bug fixes. +Links: +[https://gitee.com/ee55/Metasweeper/releases/download/3.2.1/Metaminesweeper-3.2.1.exe](https://gitee.com/ee55/Metasweeper/releases/download/3.2.1/Metaminesweeper-3.2.1.exe) +[https://github.com/eee555/Metasweeper/releases/download/3.2.1/Metaminesweeper-3.2.1.exe](https://github.com/eee555/Metasweeper/releases/download/3.2.1/Metaminesweeper-3.2.1.exe) + +### v3.2.0 + +Installer introduced; “Speedrun Guess-free” renamed to “Classic Guess-free”; numerous bug fixes; only one taskbar window; proper blind/flag handling; auto-update module added. +Links: +[https://gitee.com/ee55/Metasweeper/releases/download/3.2.0/Metaminesweeper-3.2.0.exe](https://gitee.com/ee55/Metasweeper/releases/download/3.2.0/Metaminesweeper-3.2.0.exe) +[https://github.com/eee555/Metasweeper/releases/download/3.2.0/Metaminesweeper-3.2.0.exe](https://github.com/eee555/Metasweeper/releases/download/3.2.0/Metaminesweeper-3.2.0.exe) + +### v3.1.11 + +Bug fixes; translatable counter titles; HiDPI support. +Link: [https://openms.top/download/Metaminesweeper-v3.1.11.zip](https://openms.top/download/Metaminesweeper-v3.1.11.zip) + +### v3.1.10 + +Fixes for mode switching constraints, freeze in research mode, mouse settings blocking process, crash on difficulty switch during replay, incorrect probability after mis-flag, incorrect timer behavior, etc. Supports flag display during replay, unique identifier, replay saving, per-difficulty settings, double-click guessing, mouse-range restrictions. +(No safe download available; removed) + +### v3.1.9 + +Fixes for weak guessable mines, layout issues, exception during mode switching, added “is_official” and “is_fair”, improved anti-cheat, evf3 introduced. +Link: [https://openms.top/download/Metaminesweeper-v3.1.9.zip](https://openms.top/download/Metaminesweeper-v3.1.9.zip) + +### v3.1.7 + +Precision-related fixes, new icons, evf2 introduced. +Link: [https://eee555.lanzn.com/iQ4C11p34mqh](https://eee555.lanzn.com/iQ4C11p34mqh) + +### v3.1.6 + +Fix for counter not updating during replay, added German/Polish, improved anti-cheat. +Link: [https://eee555.lanzouw.com/iCNsT1a7qiqj](https://eee555.lanzouw.com/iCNsT1a7qiqj) + +### v3.1.5 + +Many bug fixes; popup system; unique PB popup; Arbiter-like mouse settings; selectable flags; 8 languages; improved screenshot probability calculation; adjustable tile pointer; dynamic constraints; counter UI improvements. +Link: [https://eee555.lanzouw.com/imY6g0w9qfha](https://eee555.lanzouw.com/imY6g0w9qfha) + +### v3.1.3 + +6 bug fixes; internationalization (CN/EN); improved anti-cheat; reorganized directory structure; replay checksum support. +Link: [https://wwwl.lanzouw.com/i36LJ0upglmf](https://wwwl.lanzouw.com/i36LJ0upglmf) + +### v3.1.1 + +8 bug fixes; mvf playback supported; improved anti speed-gear defenses. +Link: [https://wwwl.lanzouw.com/itjCR0p24hdc](https://wwwl.lanzouw.com/itjCR0p24hdc) + +### v3.1.0_beta + +Bug fixes; in-game counter with full Python syntax; auto save .evf; playback of avf/rmv/evf; guess-free supports arbitrary mine count. +Link: [https://wwwl.lanzouw.com/imdWO0joyzra](https://wwwl.lanzouw.com/imdWO0joyzra) + +### v3.0.2 + +Fixes for 3 major game-breaking bugs. +Link: [https://wwb.lanzouw.com/iuhs904cfj0b](https://wwb.lanzouw.com/iuhs904cfj0b) + +### v3.0.1 + +Bug fixes; Arbiter-compatible avf default-open behavior. +Link: [https://wwb.lanzouw.com/iHaNm02ane7c](https://wwb.lanzouw.com/iHaNm02ane7c) + +### v3.0 + +Bug fixes; renamed from BlackCat Minesweeper to MetaSweeper; first third-generation replay player; avf playback; high-level event extraction; spacebar probability display during replay. +Link: [https://wwb.lanzouw.com/i8ypL026p1za](https://wwb.lanzouw.com/i8ypL026p1za) + +### v2.4.2 + +Major refactor; bug fixes; vector UI; pre-game Ctrl+scroll to zoom; scroll to adjust mine count; preview of 3.0 rename. +Link: [https://wwb.lanzouw.com/i3Bpc01vfsab](https://wwb.lanzouw.com/i3Bpc01vfsab) + +### v2.4.1 + +Bug fixes; UI improvements; OBR support for custom boards. +Link: [https://wwe.lanzoui.com/i5Sswsq0uva](https://wwe.lanzoui.com/i5Sswsq0uva) + +### v2.3.1 + +Bug fixes. +Link: [https://wwe.lanzoui.com/ifH4Cryp3aj](https://wwe.lanzoui.com/ifH4Cryp3aj) + +### v2.3 + +Bug fixes; auto restart; auto popups; post-game flagging; probability via Space; probability via Ctrl+Space + screenshot OBR. +Link: [https://wwe.lanzoui.com/i2axoq686kb](https://wwe.lanzoui.com/i2axoq686kb) + +### v2.2.6-alpha + +Bug fixes; algorithm improvements (200% faster guess-free 16×16×72); custom mode shortcuts (4/5/6); improved stability and board refreshing. +Links: [https://wwe.lanzoui.com/igPFFo7mwxi](https://wwe.lanzoui.com/igPFFo7mwxi) +[https://wwe.lanzous.com/igPFFo7mwxi](https://wwe.lanzous.com/igPFFo7mwxi) + +### v2.2.5 + +Algorithm improvements (252 boards/s in advanced guess-free); major bug fixes. +Links: [https://wws.lanzoui.com/iS3wImv2y5e](https://wws.lanzoui.com/iS3wImv2y5e) +[https://wws.lanzous.com/iS3wImv2y5e](https://wws.lanzous.com/iS3wImv2y5e) + +### v2.2 + +Algorithm improvements: 37,525 boards/s in advanced mode (~3× Arbiter), 15.7 boards/s guess-free; polar chart for skill metrics; feature cleanup. +Links: [https://wws.lanzoui.com/iq9Ocm8zdtc](https://wws.lanzoui.com/iq9Ocm8zdtc) +[https://wws.lanzous.com/iq9Ocm8zdtc](https://wws.lanzous.com/iq9Ocm8zdtc) + diff --git a/requirements.txt b/requirements.txt index f436824..b3a8370 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ pyQt5==5.15.11 ms-toollib==1.5.1 -setuptools==78.1.1 +setuptools==80.9.0 pyinstaller==6.16.0 - +msgspec>=0.20.0 +zmq>=0.0.0 diff --git a/src/main.py b/src/main.py index 424b349..5ffb0d7 100644 --- a/src/main.py +++ b/src/main.py @@ -12,9 +12,50 @@ import ms_toollib as ms import ctypes from ctypes import wintypes +from mp_plugins.context import AppContext +from mp_plugins.events import * +from mp_plugins import PluginManager +from pathlib import Path +import os + os.environ["QT_FONT_DPI"] = "96" +# def patch_env(): +# import os + + +# env = os.environ.copy() +# root = os.path.dirname(os.path.abspath(__file__)) # 你的项目根目录 +# env["PYTHONPATH"] = root +# return env +def get_paths(): + if getattr(sys, "frozen", False): + # 打包成 exe + dir = os.path.dirname(sys.executable) # exe 所在目录 + else: + dir = os.path.dirname(os.path.abspath(__file__)) + + return dir + + +def patch_env(): + import os + import sys + + env = os.environ.copy() + + if getattr(sys, "frozen", False): + # 打包成 exe,库解压到 _MEIPASS + root = getattr(sys, "_MEIPASS", None) + else: + # 调试模式,库在项目目录 + root = os.path.dirname(os.path.abspath(__file__)) + + env["PYTHONPATH"] = root + return env + + def on_new_connection(localServer: QLocalServer): """当新连接进来时,接受连接并将文件路径传递给主窗口""" socket = localServer.nextPendingConnection() @@ -53,7 +94,7 @@ def find_window(class_name, window_name): """ - user32 = ctypes.WinDLL('user32', use_last_error=True) + user32 = ctypes.WinDLL("user32", use_last_error=True) user32.FindWindowW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR] user32.FindWindowW.restype = wintypes.HWND @@ -63,36 +104,39 @@ def find_window(class_name, window_name): return hwnd - def cli_check_file(file_path: str) -> int: if not os.path.exists(file_path): print("ERROR: file not found") return 2 - + # 搜集目录或文件下的所有evf和evfs文件 evf_evfs_files = [] - if os.path.isfile(file_path) and (file_path.endswith('.evf') or file_path.endswith('.evfs')): + if os.path.isfile(file_path) and ( + file_path.endswith(".evf") or file_path.endswith(".evfs") + ): evf_evfs_files = [os.path.abspath(file_path)] elif os.path.isdir(file_path): - evf_evfs_files = [os.path.abspath(os.path.join(root, file)) - for root, dirs, files in os.walk(file_path) - for file in files if file.endswith('.evf') or file.endswith('.evfs')] + evf_evfs_files = [ + os.path.abspath(os.path.join(root, file)) + for root, dirs, files in os.walk(file_path) + for file in files + if file.endswith(".evf") or file.endswith(".evfs") + ] if not evf_evfs_files: print("ERROR: must be evf or evfs files or directory") return 2 - - + # 实例化一个MineSweeperGUI出来 app = QtWidgets.QApplication(sys.argv) mainWindow = mainWindowGUI.MainWindow() ui = mineSweeperGUI.MineSweeperGUI(mainWindow, sys.argv) - + for ide, e in enumerate(evf_evfs_files): if not ui.checksum_module_ok(): print("ERROR: ???") return 2 - if e.endswith('.evf'): + if e.endswith(".evf"): # 检验evf文件是否合法 video = ms.EvfVideo(e) try: @@ -101,12 +145,13 @@ def cli_check_file(file_path: str) -> int: evf_evfs_files[ide] = (e, 2) else: checksum = ui.checksum_guard.get_checksum( - video.raw_data[:-(len(video.checksum) + 2)]) + video.raw_data[: -(len(video.checksum) + 2)] + ) if video.checksum == checksum: evf_evfs_files[ide] = (e, 0) else: evf_evfs_files[ide] = (e, 1) - elif e.endswith('.evfs'): + elif e.endswith(".evfs"): # 检验evfs文件是否合法 videos = ms.Evfs(e) try: @@ -116,21 +161,22 @@ def cli_check_file(file_path: str) -> int: else: if videos.len() <= 0: evf_evfs_files[ide] = (e, 2) - checksum = ui.checksum_guard.get_checksum( - videos[0].evf_video.raw_data) + checksum = ui.checksum_guard.get_checksum(videos[0].evf_video.raw_data) if video.checksum != checksum: evf_evfs_files[ide] = (e, 1) continue for idcell, cell in enumerate(videos[1:]): checksum = ui.checksum_guard.get_checksum( - cell.evf_video.raw_data + videos[idcell - 1].checksum) + cell.evf_video.raw_data + videos[idcell - 1].checksum + ) if cell.evf_file.checksum != checksum: evf_evfs_files[ide] = (e, 1) continue evf_evfs_files[ide] = (e, 0) print(evf_evfs_files) return 0 - + + if __name__ == "__main__": # metaminesweeper.exe -c filename.evf用法,检查文件的合法性 # metaminesweeper.exe -c filename.evfs @@ -142,7 +188,11 @@ def cli_check_file(file_path: str) -> int: if args.check: exit_code = cli_check_file(args.check) sys.exit(exit_code) - + env = patch_env() + context = AppContext("Metasweeper", "1.0.0", "元扫雷") + PluginManager.instance().context = context + + PluginManager.instance().start(Path(get_paths()) / "plugins", env) app = QtWidgets.QApplication(sys.argv) serverName = "MineSweeperServer" @@ -159,7 +209,8 @@ def cli_check_file(file_path: str) -> int: localServer = QLocalServer() localServer.listen(serverName) localServer.newConnection.connect( - lambda: on_new_connection(localServer=localServer)) + lambda: on_new_connection(localServer=localServer) + ) mainWindow = mainWindowGUI.MainWindow() ui = mineSweeperGUI.MineSweeperGUI(mainWindow, sys.argv) ui.mainWindow.show() @@ -169,16 +220,21 @@ def cli_check_file(file_path: str) -> int: hwnd = find_window(None, _translate("MainWindow", "元扫雷")) SetWindowDisplayAffinity = ctypes.windll.user32.SetWindowDisplayAffinity - ui.disable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000011) else 1/0 - ui.enable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000000) else 1/0 - ui.disable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000011) else 1/0 - ui.enable_screenshot = lambda: ... if SetWindowDisplayAffinity( - hwnd, 0x00000000) else 1/0 + ui.disable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000011) else 1 / 0 + ) + ui.enable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000000) else 1 / 0 + ) + ui.disable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000011) else 1 / 0 + ) + ui.enable_screenshot = lambda: ( + ... if SetWindowDisplayAffinity(hwnd, 0x00000000) else 1 / 0 + ) sys.exit(app.exec_()) + PluginManager.instance().stop() ... # except: # pass diff --git a/src/mineSweeperGUI.py b/src/mineSweeperGUI.py index 816eaf2..4357a91 100644 --- a/src/mineSweeperGUI.py +++ b/src/mineSweeperGUI.py @@ -26,7 +26,9 @@ from mainWindowGUI import MainWindow from datetime import datetime from mineSweeperVideoPlayer import MineSweeperVideoPlayer - +from pluginDialog import PluginManagerUI +from mp_plugins import PluginManager, PluginContext +from mp_plugins.events import GameEndEvent class MineSweeperGUI(MineSweeperVideoPlayer): def __init__(self, MainWindow: MainWindow, args): @@ -37,7 +39,6 @@ def __init__(self, MainWindow: MainWindow, args): self.time_10ms: int = 0 # 已毫秒为单位的游戏时间,全局统一的 self.showTime(self.time_10ms // 100) - self.timer_10ms = QTimer() self.timer_10ms.setInterval(10) # 10毫秒回调一次的定时器 self.timer_10ms.timeout.connect(self.timeCount) @@ -69,6 +70,7 @@ def save_evf_file_integrated(): self.actiongaun_yv.triggered.connect(self.action_AEvent) self.actionauto_update.triggered.connect(self.auto_Update) self.actionopen.triggered.connect(self.action_OpenFile) + self.actionchajian.triggered.connect(self.action_OpenPluginDialog) self.english_action.triggered.connect( lambda: self.trans_language("en_US")) self.chinese_action.triggered.connect( @@ -134,7 +136,7 @@ def save_evf_file_integrated(): self.mainWindow.closeEvent_.connect(self.closeEvent_) self.mainWindow.dropFileSignal.connect(self.action_OpenFile) - + # 播放录像时,记录上一个鼠标状态用。 # 这是一个补丁,因为工具箱里只有UpDown和UpDownNotFlag, # 也有DownUpAfterChording,但是没有UpDownAfterChording @@ -247,8 +249,7 @@ def game_state(self, game_state: str): case "study": self.num_bar_ui.QWidget.close() self._game_state = game_state - - + @property def row(self): return self._row @@ -259,7 +260,7 @@ def row(self, row): "row": row, }) self._row = row - + @property def column(self): return self._column @@ -270,7 +271,7 @@ def column(self, column): "column": column, }) self._column = column - + @property def minenum(self): return self._minenum @@ -281,7 +282,6 @@ def minenum(self, minenum): "minenum": minenum, }) self._minenum = minenum - def layMine(self, i, j): xx = self.row @@ -452,8 +452,6 @@ def chording_ai(self, i, j): not_mine_round + is_mine_round) self.label.ms_board.board = board - - def mineNumWheel(self, i): ''' 在雷上滚轮,调雷数 @@ -564,6 +562,8 @@ def gameFinished(self): self.score_board_manager.show(self.label.ms_board, index_type=2) self.enable_screenshot() self.unlimit_cursor() + event = GameEndEvent() + PluginManager.instance().send_event(event, response_count=0) def gameWin(self): # 成功后改脸和状态变量,停时间 self.timer_10ms.stop() @@ -580,7 +580,7 @@ def gameWin(self): # 成功后改脸和状态变量,停时间 if self.autosave_video and self.checksum_module_ok(): self.dump_evf_file_data() self.save_evf_file() - + self.gameFinished() # 尝试弹窗,没有破纪录则不弹 @@ -601,10 +601,10 @@ def dump_evf_file_data(self): self.label.ms_board.use_question = False # 禁用问号是共识 self.label.ms_board.use_cursor_pos_lim = self.cursor_limit self.label.ms_board.use_auto_replay = self.auto_replay > 0 - + self.label.ms_board.is_fair = self.is_fair() self.label.ms_board.is_official = self.is_official() - + self.label.ms_board.software = superGUI.version self.label.ms_board.mode = self.gameMode self.label.ms_board.player_identifier = self.player_identifier @@ -614,7 +614,7 @@ def dump_evf_file_data(self): country_name[self.country].upper() self.label.ms_board.device_uuid = hashlib.md5( bytes(str(uuid.getnode()).encode())).hexdigest().encode("UTF-8") - + self.label.ms_board.generate_evf_v4_raw_data() # 补上校验值 checksum = self.checksum_guard.get_checksum( @@ -641,8 +641,7 @@ def save_evf_file(self): os.mkdir(self.replay_path) self.label.ms_board.save_to_evf_file(self.cal_evf_filename()) - - + # 拼接evf录像的文件名,无后缀 def cal_evf_filename(self, absolute=True) -> str: if (self.label.ms_board.row, self.label.ms_board.column, self.label.ms_board.mine_num) == (8, 8, 10): @@ -676,8 +675,6 @@ def cal_evf_filename(self, absolute=True) -> str: file_name += "_fake" return file_name - - # 保存evfs文件。先保存后一个文件,再删除前一个文件。 def save_evfs_file(self): # 文件名包含秒为单位的时间戳,理论上不会重复 @@ -698,7 +695,6 @@ def save_evfs_file(self): self.evfs.save_evfs_file(file_name + "1") self.old_evfs_filename = file_name - def gameFailed(self): # 失败后改脸和状态变量 self.timer_10ms.stop() self.score_board_manager.editing_row = -1 @@ -876,8 +872,7 @@ def try_record_pop(self): ui.label_16.setText(mode_text) ui.Dialog.show() ui.Dialog.exec_() - - + # 根据条件是否满足,尝试追加evfs文件 # 当且仅当game_state发生变化,且旧状态为"playing"时调用(即使点一下就获胜也会经过"playing") # 加入evfs是空的,且当前游戏状态不是"win",则不追加 @@ -901,10 +896,10 @@ def try_append_evfs(self, new_game_state): self.label.ms_board.use_question = False # 禁用问号是共识 self.label.ms_board.use_cursor_pos_lim = self.cursor_limit self.label.ms_board.use_auto_replay = self.auto_replay > 0 - + self.label.ms_board.is_fair = self.is_fair() self.label.ms_board.is_official = self.is_official() - + self.label.ms_board.software = superGUI.version self.label.ms_board.mode = self.gameMode self.label.ms_board.player_identifier = self.player_identifier @@ -914,7 +909,7 @@ def try_append_evfs(self, new_game_state): country_name[self.country].upper() self.label.ms_board.device_uuid = hashlib.md5( bytes(str(uuid.getnode()).encode())).hexdigest().encode("UTF-8") - + self.label.ms_board.generate_evf_v4_raw_data() # 补上校验值 checksum = self.checksum_guard.get_checksum( @@ -937,7 +932,6 @@ def try_append_evfs(self, new_game_state): self.cal_evf_filename(absolute=False), checksum) self.evfs.generate_evfs_v0_raw_data() self.save_evfs_file() - def showMineNum(self, n): # 显示剩余雷数,雷数大于等于0,小于等于999,整数 @@ -1005,7 +999,6 @@ def predefined_Board(self, k): self.board_constraint = self.predefinedBoardPara[k]['board_constraint'] self.attempt_times_limit = self.predefinedBoardPara[k]['attempt_times_limit'] - # 菜单回放的回调 def replay_game(self): if not isinstance(self.label.ms_board, ms.BaseVideo): @@ -1374,8 +1367,6 @@ def refreshSettingsDefault(self): self.game_setting.set_value("DEFAULT/minenum", str(self.minenum)) self.game_setting.sync() - - def is_official(self) -> bool: # 局面开始时,判断一下局面是设置是否正式。 # 极端小的3BV依然是合法的,而网站是否认同不关软件的事。 @@ -1423,7 +1414,6 @@ def limit_cursor(self): rect = QRect(widget_pos, widget_size) self._clip_mouse(rect) - def unlimit_cursor(self): ''' 取消将鼠标区域限制在游戏界面中。 @@ -1473,3 +1463,8 @@ def closeEvent_(self): self.game_setting.sync() self.record_setting.sync() + + def action_OpenPluginDialog(self): + contexts = list(PluginManager.instance().plugin_contexts) + dialog = PluginManagerUI(contexts) + dialog.exec() diff --git a/src/mp_plugins/__init__.py b/src/mp_plugins/__init__.py new file mode 100644 index 0000000..80e2a28 --- /dev/null +++ b/src/mp_plugins/__init__.py @@ -0,0 +1,13 @@ +from .plugin_process import PluginProcess +from .plugin_manager import PluginManager +from .base import BaseContext, BaseEvent, BasePlugin, BaseConfig +from .base.context import PluginContext + +__all__ = [ + "PluginManager", + "BaseContext", + "BaseEvent", + "BasePlugin", + "BaseConfig", + "PluginContext", +] diff --git a/src/mp_plugins/base/__init__.py b/src/mp_plugins/base/__init__.py new file mode 100644 index 0000000..50f7b84 --- /dev/null +++ b/src/mp_plugins/base/__init__.py @@ -0,0 +1,33 @@ +from .context import BaseContext, PluginContext +from .error import Error +from .mode import PluginStatus, MessageMode, ValueEnum +from .event import BaseEvent +from .plugin import BasePlugin +from .message import Message +from ._data import get_subclass_by_name +from .config import ( + BaseConfig, + BaseSetting, + BoolSetting, + NumberSetting, + SelectSetting, + TextSetting, +) + +__all__ = [ + "BaseContext", + "PluginContext", + "Error", + "PluginStatus", + "MessageMode", + "BaseEvent", + "BasePlugin", + "Message", + "ValueEnum", + "BaseConfig", + "BaseSetting", + "BoolSetting", + "NumberSetting", + "SelectSetting", + "TextSetting", +] diff --git a/src/mp_plugins/base/_data.py b/src/mp_plugins/base/_data.py new file mode 100644 index 0000000..a3386c8 --- /dev/null +++ b/src/mp_plugins/base/_data.py @@ -0,0 +1,36 @@ +from typing import Type +from msgspec import Struct, json + + +class _BaseData(Struct): + """ + 数据基类 + """ + + def copy(self): + new_data = json.decode(json.encode(self), type=type(self)) + return new_data + + +_subclass_cache = {} + + +def get_subclass_by_name(name: str) -> Type[_BaseData] | None: + """ + 根据类名获取 BaseEvent 的派生类(支持多级继承),带缓存 + """ + global _subclass_cache + if name in _subclass_cache: + return _subclass_cache[name] + + def _iter_subclasses(cls): + for sub in cls.__subclasses__(): + yield sub + yield from _iter_subclasses(sub) + + for subcls in _iter_subclasses(_BaseData): + _subclass_cache[subcls.__name__] = subcls + if subcls.__name__ == name: + return subcls + + return None diff --git a/src/mp_plugins/base/config.py b/src/mp_plugins/base/config.py new file mode 100644 index 0000000..9fbd271 --- /dev/null +++ b/src/mp_plugins/base/config.py @@ -0,0 +1,52 @@ +from typing import Any, Dict, List, Sequence +from ._data import _BaseData, get_subclass_by_name +import msgspec + + +class BaseSetting(_BaseData): + name: str = "" + value: Any = None + setting_type: str = "BaseSetting" + + +class TextSetting(BaseSetting): + value: str = "" + placeholder: str = "" + setting_type: str = "TextSetting" + + +class NumberSetting(BaseSetting): + value: float = 0.0 + min_value: float = 0.0 + max_value: float = 100.0 + step: float = 1.0 + setting_type: str = "NumberSetting" + + +class BoolSetting(BaseSetting): + value: bool = False + description: str = "" + setting_type: str = "BoolSetting" + + +class SelectSetting(BaseSetting): + value: str = "" + options: List[str] = [] + setting_type: str = "SelectSetting" + + +class BaseConfig(_BaseData): + """ """ + + pass + + +def Get_settings(data: Dict[str, Dict[str, Any]]) -> Dict[str, BaseSetting]: + settings = {} + for key, value in data.items(): + if settings_type := value.get("setting_type"): + setting: BaseSetting = msgspec.json.decode( + msgspec.json.encode(value), type=get_subclass_by_name(settings_type) + ) + settings[key] = setting + return settings diff --git a/src/mp_plugins/base/context.py b/src/mp_plugins/base/context.py new file mode 100644 index 0000000..178305c --- /dev/null +++ b/src/mp_plugins/base/context.py @@ -0,0 +1,31 @@ +from ._data import _BaseData +from .mode import PluginStatus +from datetime import datetime +from typing import List + + +class BaseContext(_BaseData): + """ + 上下文基类 + """ + + name: str = "" + version: str = "" + + +class PluginContext(BaseContext): + """ + 插件上下文 + """ + + pid: int = 0 + name: str = "" + display_name: str = "" + description: str = "" + version: str = "" + author: str = "" + author_email: str = "" + url: str = "" + status: PluginStatus = PluginStatus.Stopped + heartbeat: float = datetime.now().timestamp() + subscribers: List[str] = [] diff --git a/src/mp_plugins/base/error.py b/src/mp_plugins/base/error.py new file mode 100644 index 0000000..1e94acb --- /dev/null +++ b/src/mp_plugins/base/error.py @@ -0,0 +1,10 @@ +from ._data import _BaseData + + +class Error(_BaseData): + """ + 错误信息 + """ + + type: str + message: str diff --git a/src/mp_plugins/base/event.py b/src/mp_plugins/base/event.py new file mode 100644 index 0000000..55565df --- /dev/null +++ b/src/mp_plugins/base/event.py @@ -0,0 +1,11 @@ +from ._data import _BaseData +from datetime import datetime +import uuid + + +class BaseEvent(_BaseData): + """ + 事件基类 + """ + + timestamp: float = datetime.now().timestamp() diff --git a/src/mp_plugins/base/message.py b/src/mp_plugins/base/message.py new file mode 100644 index 0000000..43daa5c --- /dev/null +++ b/src/mp_plugins/base/message.py @@ -0,0 +1,32 @@ +import uuid +from msgspec import Struct, json +from typing import Any, Union, Type, TypeVar, Generic, Optional +from ._data import _BaseData, get_subclass_by_name +from datetime import datetime +from .mode import MessageMode + +BaseData = TypeVar("BaseData", bound=_BaseData) + + +class Message(_BaseData): + """ + 一个消息类用于包装事件及消息的基本信息 + """ + + id = str(uuid.uuid4()) + data: Any = None + timestamp: datetime = datetime.now() + mode: MessageMode = MessageMode.Unknown + Source: str = "main" # 来源,也就是消息的发送者 + class_name: str = "" + + def copy(self): + new_message = json.decode(json.encode(self), type=Message) + return new_message + + def __post_init__(self): + cls = get_subclass_by_name(self.class_name) + if cls: + # 将原始 dict 解析成对应的 Struct + if isinstance(self.data, dict): + self.data = cls(**self.data) diff --git a/src/mp_plugins/base/mode.py b/src/mp_plugins/base/mode.py new file mode 100644 index 0000000..0e92a59 --- /dev/null +++ b/src/mp_plugins/base/mode.py @@ -0,0 +1,26 @@ +from enum import Enum + + +class ValueEnum(Enum): + def __eq__(self, value: object) -> bool: + if isinstance(value, Enum): + return self.value == value.value + return self.value == value # 支持直接比较 value + + +class PluginStatus(ValueEnum): + """ + 插件状态 + """ + + Running = "running" + Stopped = "stopped" + Dead = "dead" + + +class MessageMode(ValueEnum): + Event = "event" + Context = "context" + Error = "error" + Unknown = "unknown" + Heartbeat = "heartbeat" diff --git a/src/mp_plugins/base/plugin.py b/src/mp_plugins/base/plugin.py new file mode 100644 index 0000000..a7f6edc --- /dev/null +++ b/src/mp_plugins/base/plugin.py @@ -0,0 +1,188 @@ +from abc import ABC, abstractmethod +import inspect +from msgspec import json +from datetime import datetime, timedelta +from .error import Error +import os +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Type, + Union, + TypeVar, + Generic, + ParamSpec, +) +from .message import Message, BaseData, MessageMode +from ._data import _BaseData, get_subclass_by_name +from .event import BaseEvent +from .context import BaseContext, PluginContext +from queue import Queue +import zmq +from .mode import PluginStatus +from .config import BaseConfig, BaseSetting + +P = ParamSpec("P") # 捕获参数 +R = TypeVar("R") # 捕获返回值 + + +class BasePlugin(ABC): + """ + 插件基类 + """ + + _context: BaseContext + _plugin_context: PluginContext = PluginContext() + _config: BaseConfig + + @abstractmethod + def build_plugin_context(self) -> None: ... + + @staticmethod + def event_handler( + event: Type[BaseEvent], + ) -> Callable[[Callable[P, BaseEvent]], Callable[P, BaseEvent]]: + """ + 装饰器:标记方法为事件 handler + """ + + def decorator(func: Callable[P, BaseEvent]) -> Callable[P, BaseEvent]: + func.__event_handler__ = event + return func + + return decorator + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + cls._event_handlers: Dict[ + Type[BaseEvent], List[Callable[[BasePlugin, BaseEvent], BaseEvent]] + ] = {} + event_set: set[str] = set() + for _, value in cls.__dict__.items(): + if hasattr(value, "__event_handler__"): + if value.__event_handler__ not in cls._event_handlers: + cls._event_handlers[value.__event_handler__] = [] + event_set.add(value.__event_handler__.__name__) + cls._event_handlers[value.__event_handler__].append(value) + cls._plugin_context.subscribers = list(event_set) + cls._plugin_context.pid = os.getpid() + + def __init__(self) -> None: + super().__init__() + context = zmq.Context() + self.dealer = context.socket(zmq.DEALER) + self.__message_queue: Queue[Message] = Queue() + self.__heartbeat_time = datetime.now() + self.build_plugin_context() + + @abstractmethod + def initialize(self) -> None: + self._plugin_context.status = PluginStatus.Running + self.refresh_context() + + @abstractmethod + def shutdown(self) -> None: + self._plugin_context.status = PluginStatus.Stopped + self.refresh_context() + + def run(self, host: str, port: int) -> None: + self.initialize() + self.dealer.setsockopt_string(zmq.IDENTITY, str(self._plugin_context.pid)) + self.dealer.connect(f"tcp://{host}:{port}") + self.refresh_context() + while datetime.now() - self.__heartbeat_time < timedelta(seconds=30): + while not self.__message_queue.empty(): + message = self.__message_queue.get() + self.dealer.send_multipart([b"", json.encode(message)]) + try: + data = self.dealer.recv(flags=zmq.NOBLOCK) + except zmq.Again: + continue + if data: + message = json.decode(data, type=Message) + self.__message_dispatching(message) + self.shutdown() + self.dealer.close() + + def __message_dispatching(self, message: Message) -> None: + """ + 消息分发 + """ + new_message = message.copy() + new_message.Source = self.__class__.__name__ + self.__heartbeat_time = datetime.now() + if message.mode == MessageMode.Event: + if isinstance(message.data, BaseEvent) and message.data is not None: + if message.data.__class__ not in self._event_handlers: + self.send_error( + type="Event Subscribe", + error=f"{self.__class__.__name__} not Subscribe {message.data.__class__.__name__}", + ) + return + for handler in self._event_handlers[message.data.__class__]: + event = handler(self, message.data) + new_message.data = event + self.__message_queue.put(new_message) + else: + self.send_error( + type="Event Validation", + error=f"{message.data} is not a valid event", + ) + elif message.mode == MessageMode.Context: + if isinstance(message.data, BaseContext) and message.data is not None: + self._context = message.data + elif message.mode == MessageMode.Error: + pass + elif message.mode == MessageMode.Unknown: + pass + elif message.mode == MessageMode.Heartbeat: + self.__message_queue.put(new_message) + + def refresh_context(self): + self._plugin_context.heartbeat = datetime.now().timestamp() + self.__message_queue.put( + Message( + data=self._plugin_context, + mode=MessageMode.Context, + Source=self.__class__.__name__, + class_name=self._plugin_context.__class__.__name__, + ) + ) + + @property + def context(self): + return self._context + + def send_error(self, type: str, error: str): + self.__message_queue.put( + Message( + data=Error(type=type, message=error), + mode=MessageMode.Error, + Source=self.__class__.__name__, + ) + ) + + def init_config(self, config: BaseConfig): + self._config = config + old_config = self.config + dir = os.path.dirname(inspect.getfile(self.__class__)) + config_path = os.path.join(dir, f"{self.__class__.__name__}.json") + if old_config is None: + with open(config_path, "w", encoding="utf-8") as f: + b = json.encode(config) + f.write(b.decode("utf-8")) + + @property + def config(self): + dir = os.path.dirname(inspect.getfile(self.__class__)) + config_path = os.path.join(dir, f"{self.__class__.__name__}.json") + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.decode(f.read(), type=self._config.__class__) + return config + except: + return None diff --git a/src/mp_plugins/context.py b/src/mp_plugins/context.py new file mode 100644 index 0000000..88ffd8b --- /dev/null +++ b/src/mp_plugins/context.py @@ -0,0 +1,5 @@ +from .base import BaseContext + + +class AppContext(BaseContext): + display_name: str = "元扫雷" diff --git a/src/mp_plugins/events.py b/src/mp_plugins/events.py new file mode 100644 index 0000000..0a23bcf --- /dev/null +++ b/src/mp_plugins/events.py @@ -0,0 +1,5 @@ +from .base import BaseEvent + + +class GameEndEvent(BaseEvent): + pass diff --git a/src/mp_plugins/plugin_manager.py b/src/mp_plugins/plugin_manager.py new file mode 100644 index 0000000..f4595eb --- /dev/null +++ b/src/mp_plugins/plugin_manager.py @@ -0,0 +1,255 @@ +from datetime import datetime, timedelta +import sys +import threading +from typing import Callable, Dict, List, Optional, TypeVar, Generic, Sequence +from threading import RLock +from .base import ( + PluginContext, + BaseContext, + BaseEvent, + MessageMode, + Message, + Error, + PluginStatus, +) +import zmq +from .base._data import _BaseData +from queue import Queue +import time +import msgspec +from .plugin_process import PluginProcess +from .base.config import BaseConfig, BaseSetting, Get_settings +import pathlib +import json + +_Event = TypeVar("_Event", bound=BaseEvent) + + +class PluginManager(object): + + __instance: Optional["PluginManager"] = None + __lock = RLock() + __plugins_context: Dict[str, PluginContext] = {} + __plugins_process: Dict[str, PluginProcess] = {} + __plugins_config_path: Dict[str, pathlib.Path] = {} + __context: BaseContext + __event_dict: Dict[str, List[BaseEvent]] = {} + __message_queue: Queue[Message] = Queue() + __running: bool = False + + def __init__(self) -> None: + context = zmq.Context() + + self.__router = context.socket(zmq.ROUTER) + self.__port = self.__router.bind_to_random_port("tcp://127.0.0.1") + self.__error_callback = None + + @property + def port(self): + return self.__port + + @classmethod + def instance(cls) -> "PluginManager": + if cls.__instance is None: + with cls.__lock: + if cls.__instance is None: + cls.__instance = cls() + return cls.__instance + + def start(self, plugin_dir: pathlib.Path, env: Dict[str, str]): + with self.__lock: + if self.__running: + return + self.__running = True + plugin_dir.mkdir(parents=True, exist_ok=True) + for plugin_path in plugin_dir.iterdir(): + if plugin_path.is_dir(): + if plugin_path.name.startswith("__"): + continue + self.__plugins_config_path[plugin_path.name] = plugin_path / ( + plugin_path.name + ".json" + ) + # 先判断是否有exe + if (plugin_path / (plugin_path.name + ".exe")).exists(): + plugin_path = plugin_path / (plugin_path.name + ".exe") + else: + plugin_path = plugin_path / (plugin_path.name + ".py") + process = PluginProcess(plugin_path) + process.start("127.0.0.1", self.__port, env=env) + self.__plugins_process[str(process.pid)] = process + + t = threading.Thread(target=self.__message_dispatching, daemon=True) + self.__request_thread = t + t2 = threading.Thread(target=self.__async_send_message, daemon=True) + self.__response_thread = t2 + t3 = threading.Thread(target=self.__send_heartbeat, daemon=True) + self.__heartbeat_thread = t3 + t3.start() + t.start() + t2.start() + print("Plugin manager started") + + def stop(self): + with self.__lock: + if not self.__running: + return + self.__running = False + for process in self.__plugins_process.values(): + process.stop() + self.__request_thread.join(10) + self.__response_thread.join(10) + self.__heartbeat_thread.join(10) + self.__router.close() + print("Plugin manager stopped") + + @property + def plugin_contexts(self): + for context in self.__plugins_context.values(): + yield context.copy() + + @property + def context(self) -> BaseContext: + return self.__context + + @context.setter + def context(self, context: BaseContext): + self.__context = context + + def send_event( + self, event: _Event, timeout: int = 10, response_count: int = 1 + ) -> Sequence[_Event]: + message = Message( + data=event, mode=MessageMode.Event, class_name=event.__class__.__name__ + ) + self.__event_dict[message.id] = [] + self.__send_message(message) + current_time = datetime.now() + while datetime.now() - current_time < timedelta(seconds=timeout) and ( + len(self.__event_dict.get(message.id, [])) < response_count + ): + time.sleep(0.1) + result = [] + if message.id in self.__event_dict: + for response in self.__event_dict[message.id]: + result.append(response) + with self.__lock: + del self.__event_dict[message.id] + return result + + def __send_heartbeat(self): + while self.__running: + time.sleep(5) + for context in self.__plugins_context.values(): + if datetime.now().timestamp() - context.heartbeat > 3000: + context.status = PluginStatus.Dead + if context.status == PluginStatus.Running: + self.__send_message(Message(mode=MessageMode.Heartbeat)) + + def __send_message(self, message: Message): + self.__message_queue.put(message) + + def bind_error(self, func: Callable[[Error], None]): + self.__error_callback = func + + def __message_dispatching(self) -> None: + while self.__running: + time.sleep(0.1) + try: + identity, _, data = self.__router.recv_multipart(flags=zmq.NOBLOCK) + except zmq.Again: + continue + if not data: + continue + if identity.decode() in self.__plugins_context: + self.__plugins_context[identity.decode()].heartbeat = ( + datetime.now().timestamp() + ) + message = msgspec.json.decode(data, type=Message) + if message.mode == MessageMode.Event: + with self.__lock: + if message.id in self.__event_dict and isinstance( + message.data, BaseEvent + ): + self.__event_dict[message.id].append(message.data) + elif message.mode == MessageMode.Error: + if self.__error_callback is not None and isinstance( + message.data, Error + ): + self.__error_callback(message.data) + elif message.mode == MessageMode.Heartbeat: + pass + elif message.mode == MessageMode.Context: + if isinstance(message.data, PluginContext): + with self.__lock: + self.__plugins_context[identity.decode()] = message.data + self.__send_message( + Message( + data=self.context, + mode=MessageMode.Context, + class_name=self.context.__class__.__name__, + ) + ) + + def __async_send_message(self): + while self.__running: + time.sleep(0.1) + if self.__message_queue.empty(): + continue + message = self.__message_queue.get() + if message.mode == MessageMode.Event: + for context in self.__plugins_context.values(): + event_name = message.data.__class__.__name__ + if ( + event_name in context.subscribers + and context.status == PluginStatus.Running + ): + self.__router.send_multipart( + [ + str(context.pid).encode(), + b"", + msgspec.json.encode(message), + ] + ) + elif message.mode == MessageMode.Heartbeat: + for context in self.__plugins_context.values(): + if context.status == PluginStatus.Running: + self.__router.send_multipart( + [ + str(context.pid).encode(), + b"", + msgspec.json.encode(message), + ] + ) + elif message.mode == MessageMode.Context: + for context in self.__plugins_context.values(): + if context.status == PluginStatus.Running: + self.__router.send_multipart( + [ + str(context.pid).encode(), + b"", + msgspec.json.encode(message), + ] + ) + + def Get_Settings(self, plugin_name: str): + if plugin_name not in self.__plugins_config_path: + return {} + if not self.__plugins_config_path[plugin_name].exists(): + return {} + + data = json.loads(self.__plugins_config_path[plugin_name].read_text("utf-8")) + + return Get_settings(data) + + def Set_Settings(self, plugin_name: str, name: str, setting: BaseSetting): + data = self.Get_Settings(plugin_name) + + new_data = {} + data[name] = setting + for key, value in data.items(): + if isinstance(value, BaseSetting): + new_data[key] = msgspec.structs.asdict(value) + + self.__plugins_config_path[plugin_name].write_text( + json.dumps(new_data, indent=4) + ) diff --git a/src/mp_plugins/plugin_process.py b/src/mp_plugins/plugin_process.py new file mode 100644 index 0000000..287798f --- /dev/null +++ b/src/mp_plugins/plugin_process.py @@ -0,0 +1,58 @@ +import sys +import subprocess +import os +from pathlib import Path +from typing import List, Optional, Dict, Any + + +class PluginProcess(object): + + def __init__(self, plugin_path: Path) -> None: + self.__plugin_path = plugin_path + self._process: Optional[subprocess.Popen] = None + self.__pid: int + self.plugin_dir_name = plugin_path.parent.parent.name + self.plugin_name = plugin_path.name.split(".")[0] + + @property + def pid(self): + return self.__pid + + @property + def plugin_path(self): + return self.__plugin_path + + def start(self, host: str, port: int, env: Dict[str, str]): + if self._process is not None: + return + module = f"{self.plugin_dir_name}.{self.plugin_name}.{self.plugin_name}" + if self.__plugin_path.suffix == ".py": + self._process = subprocess.Popen( + [ + sys.executable, + "-m", + module, + host, + str(port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + else: + self._process = subprocess.Popen( + [ + self.__plugin_path, + host, + str(port), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.__pid = self._process.pid + + def stop(self): + if self._process is None: + return + if self._process.poll() is None: + self._process.terminate() diff --git a/src/pluginDialog.py b/src/pluginDialog.py new file mode 100644 index 0000000..e2250aa --- /dev/null +++ b/src/pluginDialog.py @@ -0,0 +1,204 @@ +from PyQt5.QtWidgets import ( + QApplication, + QDialog, + QListWidget, + QFormLayout, + QLabel, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QGroupBox, + QMessageBox, + QScrollArea, + QWidget, + QLineEdit, + QDoubleSpinBox, + QCheckBox, + QComboBox, +) +from PyQt5.QtCore import Qt +import sys +from typing import Dict +from mp_plugins import PluginContext + +# 你的 Setting 类 +from mp_plugins.base import ( + BaseSetting, + TextSetting, + NumberSetting, + BoolSetting, + SelectSetting, +) +from mp_plugins import PluginManager + + +class PluginManagerUI(QDialog): + + def __init__(self, plugin_contexts: list[PluginContext]): + """ + plugin_contexts: 你传入的 plugin 列表 + get_settings_func(plugin_ctx) -> Dict[str, BaseSetting] + 你自己的函数,用来根据 PluginContext 拿到 settings + """ + super().__init__() + + self.plugin_contexts = plugin_contexts + self.current_settings_widgets: Dict[str, QWidget] = {} + + self.setWindowTitle("插件管理") + self.resize(1000, 650) + + root_layout = QHBoxLayout(self) + + # ================= 左侧插件列表 ================= + self.list_widget = QListWidget() + for ctx in self.plugin_contexts: + self.list_widget.addItem(ctx.display_name or ctx.name) + + self.list_widget.currentRowChanged.connect(self.on_plugin_selected) + root_layout.addWidget(self.list_widget, 1) + + # ================= 右侧 ================= + right_layout = QVBoxLayout() + + # -------- 插件详情 -------- + details_group = QGroupBox("插件详情") + self.details_layout = QFormLayout() + + self.detail_labels = { + "pid": QLabel(), + "name": QLabel(), + "display_name": QLabel(), + "description": QLabel(), + "version": QLabel(), + "author": QLabel(), + "author_email": QLabel(), + "url": QLabel(), + "status": QLabel(), + "heartbeat": QLabel(), + "subscribers": QLabel(), + } + + for key, widget in self.detail_labels.items(): + self.details_layout.addRow(key.replace("_", " ").title() + ":", widget) + + details_group.setLayout(self.details_layout) + right_layout.addWidget(details_group, 1) + + # -------- 设置面板(滚动) -------- + settings_group = QGroupBox("设置") + vbox = QVBoxLayout() + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + + self.scroll_content = QWidget() + self.scroll_layout = QFormLayout(self.scroll_content) + self.scroll_area.setWidget(self.scroll_content) + + vbox.addWidget(self.scroll_area, 1) + + # 保存按钮 + self.btn_save = QPushButton("保存") + self.btn_save.clicked.connect(self.on_save_clicked) + vbox.addWidget(self.btn_save) + + settings_group.setLayout(vbox) + right_layout.addWidget(settings_group, 2) + + root_layout.addLayout(right_layout, 2) + + if plugin_contexts: + self.list_widget.setCurrentRow(0) + + # =================================================================== + # 左侧切换插件 + # =================================================================== + def on_plugin_selected(self, index: int): + if index < 0: + return + + ctx = self.plugin_contexts[index] + + # --- PluginContext 原样填充 --- + for key, label in self.detail_labels.items(): + value = getattr(ctx, key) + if isinstance(value, list): + value = ", ".join(value) + label.setText(str(value)) + + # --- 你自己的获取 settings 的函数 --- + settings_dict = PluginManager.instance().Get_Settings(ctx.name) + + # --- 动态加载设置控件 --- + self.load_settings(settings_dict) + + # =================================================================== + # 加载 settings + # =================================================================== + def load_settings(self, settings: Dict[str, BaseSetting]): + # 清空原控件 + while self.scroll_layout.count(): + item = self.scroll_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + self.current_settings_widgets.clear() + + for key, setting in settings.items(): + widget = None + + if isinstance(setting, TextSetting): + widget = QLineEdit() + widget.setText(setting.value) + + elif isinstance(setting, NumberSetting): + widget = QDoubleSpinBox() + widget.setRange(setting.min_value, setting.max_value) + widget.setSingleStep(setting.step) + widget.setValue(setting.value) + + elif isinstance(setting, BoolSetting): + widget = QCheckBox(setting.description) + widget.setChecked(setting.value) + + elif isinstance(setting, SelectSetting): + widget = QComboBox() + widget.addItems(setting.options) + widget.setCurrentText(setting.value) + + else: + continue + + self.current_settings_widgets[key] = widget + self.scroll_layout.addRow(setting.name + ":", widget) + + # =================================================================== + # 保存按钮 + # =================================================================== + def on_save_clicked(self): + ctx = self.plugin_contexts[self.list_widget.currentRow()] + + # --- PluginContext 原样填充 --- + for key, label in self.detail_labels.items(): + value = getattr(ctx, key) + if isinstance(value, list): + value = ", ".join(value) + label.setText(str(value)) + + # --- 你自己的获取 settings 的函数 --- + settings_dict = PluginManager.instance().Get_Settings(ctx.name) + for key, widget in self.current_settings_widgets.items(): + setting = settings_dict[key] + if isinstance(widget, QLineEdit) and isinstance(setting, TextSetting): + setting.value = widget.text() + elif isinstance(widget, QDoubleSpinBox) and isinstance( + setting, NumberSetting + ): + setting.value = widget.value() + elif isinstance(widget, QCheckBox) and isinstance(setting, BoolSetting): + setting.value = widget.isChecked() + elif isinstance(widget, QComboBox) and isinstance(setting, SelectSetting): + setting.value = widget.currentText() + PluginManager.instance().Set_Settings(ctx.name, key, setting) + QMessageBox.information(self, "保存成功", "设置已保存") diff --git a/src/plugins/UpLoadVideo/UpLoadVideo.py b/src/plugins/UpLoadVideo/UpLoadVideo.py new file mode 100644 index 0000000..9808c89 --- /dev/null +++ b/src/plugins/UpLoadVideo/UpLoadVideo.py @@ -0,0 +1,70 @@ +import sys +import os +import msgspec +import zmq + +if getattr(sys, "frozen", False): # 检查是否为pyInstaller生成的EXE + application_path = os.path.dirname(sys.executable) + sys.path.append(application_path + "/../../") + print(application_path + "/../../") +else: + sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/../../") +from mp_plugins import BasePlugin, BaseConfig +from mp_plugins.base.config import * +from mp_plugins.context import AppContext +from mp_plugins.events import GameEndEvent + + +class UpLoadVideoConfig(BaseConfig): + user: TextSetting + passwd: TextSetting + upload_circle: NumberSetting + auto_upload: BoolSetting + upload_type: SelectSetting + + +class UpLoadVideo(BasePlugin): + def __init__( + self, + ) -> None: + super().__init__() + self._context: AppContext + self._config = UpLoadVideoConfig( + TextSetting("用户名", "user"), + TextSetting("密码", "passwd"), + NumberSetting(name="上传周期", value=0, min_value=1, max_value=10, step=1), + BoolSetting("自动上传", True), + SelectSetting("上传类型", "自动上传", options=["自动上传", "手动上传"]), + ) + self.init_config(self._config) + + def build_plugin_context(self) -> None: + self._plugin_context.name = "UpLoadVideo" + self._plugin_context.display_name = "上传录像" + self._plugin_context.version = "1.0.0" + self._plugin_context.description = "上传录像" + self._plugin_context.author = "LjzLoser" + + def initialize(self) -> None: + return super().initialize() + + def shutdown(self) -> None: + return super().shutdown() + + @BasePlugin.event_handler(GameEndEvent) + def on_game_end(self, event: GameEndEvent) -> GameEndEvent: + return event + + +if __name__ == "__main__": + try: + import sys + + args = sys.argv[1:] + host = args[0] + port = int(args[1]) + plugin = UpLoadVideo() + plugin.run(host, port) + except Exception as e: + with open("UpLoadVideo_error.log", "w", encoding="utf-8") as f: + f.write(str(e)) diff --git a/src/ui/ui_main_board.py b/src/ui/ui_main_board.py index a5b5e48..7ce9f5b 100644 --- a/src/ui/ui_main_board.py +++ b/src/ui/ui_main_board.py @@ -46,7 +46,10 @@ def setupUi(self, MainWindow): font.setFamily("微软雅黑") MainWindow.setFont(font) MainWindow.setAutoFillBackground(False) - MainWindow.setStyleSheet("") + MainWindow.setStyleSheet("QMenu::indicator {\n" +" width: 0px;\n" +" height: 0px;\n" +"}") MainWindow.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) MainWindow.setDocumentMode(False) MainWindow.setTabShape(QtWidgets.QTabWidget.Rounded) @@ -226,7 +229,7 @@ def setupUi(self, MainWindow): self.verticalLayout_2.addLayout(self.horizontalLayout_6) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 968, 33)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 968, 27)) font = QtGui.QFont() font.setFamily("微软雅黑") font.setPointSize(12) @@ -252,6 +255,10 @@ def setupUi(self, MainWindow): self.language_menu.setObjectName("language_menu") self.menu_3 = QtWidgets.QMenu(self.menubar) self.menu_3.setObjectName("menu_3") + self.menu_4 = QtWidgets.QMenu(self.menubar) + self.menu_4.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) + self.menu_4.setTearOffEnabled(False) + self.menu_4.setObjectName("menu_4") MainWindow.setMenuBar(self.menubar) self.actionnew_game = QtWidgets.QAction(MainWindow) self.actionnew_game.setWhatsThis("") @@ -393,6 +400,29 @@ def setupUi(self, MainWindow): self.actionauto_update.setFont(font) self.actionauto_update.setShortcut("") self.actionauto_update.setObjectName("actionauto_update") + self.action_open_folder = QtWidgets.QAction(MainWindow) + self.action_open_folder.setObjectName("action_open_folder") + self.actionchajian = QtWidgets.QAction(MainWindow) + font = QtGui.QFont() + font.setFamily("微软雅黑") + font.setPointSize(12) + font.setBold(False) + font.setWeight(50) + self.actionchajian.setFont(font) + self.actionchajian.setObjectName("actionchajian") + self.action_open_replay = QtWidgets.QAction(MainWindow) + font = QtGui.QFont() + font.setFamily("微软雅黑") + font.setPointSize(12) + self.action_open_replay.setFont(font) + self.action_open_replay.setMenuRole(QtWidgets.QAction.TextHeuristicRole) + self.action_open_replay.setObjectName("action_open_replay") + self.actionddd = QtWidgets.QAction(MainWindow) + font = QtGui.QFont() + font.setFamily("微软雅黑") + font.setPointSize(12) + self.actionddd.setFont(font) + self.actionddd.setObjectName("actionddd") self.menu.addAction(self.actionopen) self.menu.addSeparator() self.menu.addAction(self.actionnew_game) @@ -416,8 +446,12 @@ def setupUi(self, MainWindow): self.menu_2.addAction(self.language_menu.menuAction()) self.menu_3.addAction(self.actiongaun_yv) self.menu_3.addAction(self.actionauto_update) + self.menu_3.addAction(self.actionchajian) + self.menu_4.addAction(self.action_open_replay) + self.menu_4.addAction(self.actionddd) self.menubar.addAction(self.menu.menuAction()) self.menubar.addAction(self.menu_2.menuAction()) + self.menubar.addAction(self.menu_4.menuAction()) self.menubar.addAction(self.menu_3.menuAction()) self.retranslateUi(MainWindow) @@ -430,6 +464,7 @@ def retranslateUi(self, MainWindow): self.menu_2.setTitle(_translate("MainWindow", "设置")) self.language_menu.setTitle(_translate("MainWindow", "语言设置")) self.menu_3.setTitle(_translate("MainWindow", "帮助")) + self.menu_4.setTitle(_translate("MainWindow", "查看")) self.actionnew_game.setText(_translate("MainWindow", "新游戏")) self.actionchu_ji.setText(_translate("MainWindow", "初级")) self.actionzhogn_ji.setText(_translate("MainWindow", "中级")) @@ -444,6 +479,11 @@ def retranslateUi(self, MainWindow): self.action_save.setText(_translate("MainWindow", "保存")) self.action_replay.setText(_translate("MainWindow", "回放")) self.actionauto_update.setText(_translate("MainWindow", "检查更新")) + self.action_open_folder.setText(_translate("MainWindow", "录像位置")) + self.action_open_folder.setShortcut(_translate("MainWindow", "W")) + self.actionchajian.setText(_translate("MainWindow", "插件")) + self.action_open_replay.setText(_translate("MainWindow", "录像所在位置")) + self.actionddd.setText(_translate("MainWindow", "设置所在位置")) from ui.mineLabel import mineLabel from ui.mineNumLabel import mineNumLabel from ui.uiComponents import StatusLabel diff --git a/uiFiles/main_board.ui b/uiFiles/main_board.ui index 8a27880..acf491c 100644 --- a/uiFiles/main_board.ui +++ b/uiFiles/main_board.ui @@ -104,7 +104,10 @@ false - + QMenu::indicator { + width: 0px; + height: 0px; +} Qt::ToolButtonIconOnly @@ -606,7 +609,7 @@ 0 0 968 - 33 + 27 @@ -675,9 +678,24 @@ + + + + + Qt::DefaultContextMenu + + + false + + + 查看 + + + + @@ -982,6 +1000,46 @@ W + + + 插件 + + + + 微软雅黑 + 12 + 50 + false + + + + + + + 录像所在位置 + + + + 微软雅黑 + 12 + + + + QAction::TextHeuristicRole + + + + + + 设置所在位置 + + + + 微软雅黑 + 12 + + + diff --git "a/\345\215\217\350\256\256" "b/\345\215\217\350\256\256" new file mode 100644 index 0000000..c50f3b8 --- /dev/null +++ "b/\345\215\217\350\256\256" @@ -0,0 +1,55 @@ +# 🇨🇳 **LICENSE — 中文版(非官方翻译,仅供参考)** + +**⚠️ 本中文版仅为参考译文,若与英文版冲突,以英文版为准。** + +--- + +## ⭐ **附加协议(依据 GPLv3 第 7 条添加)** + +以下附加条款修改并补充 GNU GPLv3 对本项目的适用方式。 + +--- + +## **1. 商业使用限制** + +1.1 **禁止未授权的商用行为。** +任何个人或组织在未取得项目 Owner 明确书面授权的情况下,**不得销售、收费分发、付费许可、或以任何方式商业利用本项目或其衍生作品。** +LICENSE (Chinese)LICENSE (Chinese) +1.2 **授权后可商用。** +取得书面授权后,可按照授权协议中的约定进行商业使用。 + +--- + +## **2. 授权收益与项目收入分配** + +2.1 +本项目产生的所有收益(包括但不限于捐赠、销售收入、许可收入、赞助、或其它与项目相关的收入)**必须在贡献者之间进行分配**。 + +2.2 **分配规则:** +收入按贡献者 **commit 数量比例** 进行分配。 + +2.3 **执行者:** +项目 **Owner 负责执行收益分配**,确保公平与透明。 + +2.4 +贡献者同意在分成需要时向 Owner 提供必要的付款信息。 + +--- + +## **3. 与 GPLv3 的关系** + +3.1 +除上述附加协议另有规定外,GNU GPLv3 的所有条款完全适用。 + +3.2 +本附加协议依据 **GNU GPLv3 第 7 条** 制定,对所有复制、修改与分发行为均具有法律效力。 + +--- + +## **4. 接受条款** + +使用、复制、修改或分发本项目,即表示你接受: + +* GNU GPLv3 +* 本附加协议 +