Skip to content

Commit 07121ae

Browse files
authored
feat(history): 实现游戏历史记录功能及GUI界面 (#84)
* fix(plugin): 修复事件处理器返回类型检查及上下文处理问题 修复BasePlugin中事件处理器返回类型不一致的问题,添加类型检查 修正PluginManager和PluginManagerUI中的上下文处理逻辑 优化History插件的事件处理器返回 * feat(history): 实现游戏历史记录功能及GUI界面 新增历史记录数据库存储功能,完善GameEndEvent数据结构,添加历史记录查看GUI界面,支持播放和导出历史记录 * feat(History): 添加历史记录保存模式配置选项 * refactor(plugin): 将插件初始化逻辑移到服务器连接之后 将插件管理器和应用上下文的初始化代码从主程序入口移动到本地服务连接建立之后,确保插件加载在正确的上下文中执行
1 parent fbf6e32 commit 07121ae

File tree

7 files changed

+668
-130
lines changed

7 files changed

+668
-130
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,5 @@ old/
169169
src/plugins/*/*.json
170170
src/plugins/*/*.db
171171
.vscode/
172+
src/history.db
173+
history_show_fields.json

src/history_gui.py

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
from enum import Enum
2+
from fileinput import filename
3+
import json
4+
import os
5+
from pathlib import Path
6+
import sqlite3
7+
import subprocess
8+
import sys
9+
from typing import Any
10+
from PyQt5.QtGui import QCloseEvent
11+
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QTableWidget, QMenu, \
12+
QAction, QTableWidgetItem, QHeaderView, QTableView, QMessageBox, QFileDialog
13+
from PyQt5.QtCore import QDateTime, Qt, QCoreApplication
14+
from datetime import datetime
15+
import inspect
16+
from utils import GameBoardState, BaseDiaPlayEnum, get_paths, patch_env
17+
_translate = QCoreApplication.translate
18+
19+
20+
class HistoryData:
21+
replay_id: int = 0
22+
game_board_state: GameBoardState = GameBoardState.Win
23+
rtime: float = 0
24+
left: int = 0
25+
right: int = 0
26+
double: int = 0
27+
left_s: float = 0.0
28+
right_s: float = 0.0
29+
double_s: float = 0.0
30+
level: int = 0
31+
cl: int = 0
32+
cl_s: float = 0.0
33+
ce: int = 0
34+
ce_s: float = 0.0
35+
rce: int = 0
36+
lce: int = 0
37+
dce: int = 0
38+
bbbv: int = 0
39+
bbbv_solved: int = 0
40+
bbbv_s: float = 0.0
41+
flag: int = 0
42+
path: float = 0.0
43+
etime: float = datetime.now()
44+
start_time: datetime = datetime.now()
45+
end_time: datetime = datetime.now()
46+
mode: int = 0
47+
software: str = ""
48+
player_identifier: str = ""
49+
race_identifier: str = ""
50+
uniqueness_identifier: str = ""
51+
stnb: float = 0.0
52+
corr: float = 0.0
53+
thrp: float = 0.0
54+
ioe: float = 0.0
55+
is_official: int = 0
56+
is_fair: int = 0
57+
op: int = 0
58+
isl: int = 0
59+
60+
@classmethod
61+
def fields(cls):
62+
return [name for name, value in inspect.getmembers(cls) if not name.startswith("__") and not callable(value) and not name.startswith("_")]
63+
64+
@classmethod
65+
def query_all(cls):
66+
return f"select {','.join(cls.fields())} from history"
67+
68+
@classmethod
69+
def from_dict(cls, data: dict):
70+
instance = cls()
71+
for name, value in inspect.getmembers(cls):
72+
if not name.startswith("__") and not callable(value) and not name.startswith("_"):
73+
new_value = data.get(name)
74+
if isinstance(value, datetime):
75+
value = datetime.fromtimestamp(new_value / 1_000_000)
76+
elif isinstance(value, float):
77+
value = round(new_value, 4)
78+
elif isinstance(value, BaseDiaPlayEnum):
79+
value = value.__class__(new_value)
80+
else:
81+
value = new_value
82+
setattr(instance, name, value)
83+
return instance
84+
85+
86+
class HistoryTable(QWidget):
87+
def __init__(self, showFields: set[str], parent: QWidget | None = ...) -> None:
88+
super().__init__(parent)
89+
self.layout: QVBoxLayout = QVBoxLayout(self)
90+
self.table = QTableWidget(self)
91+
self.layout.addWidget(self.table)
92+
self.setLayout(self.layout)
93+
# 设置不可编辑
94+
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
95+
# 添加右键菜单
96+
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
97+
self.table.customContextMenuRequested.connect(self.show_context_menu)
98+
self.showFields: set[str] = showFields
99+
self.headers = [
100+
"replay_id",
101+
"game_board_state",
102+
"rtime",
103+
"left",
104+
"right",
105+
"double",
106+
"left_s",
107+
"right_s",
108+
"double_s",
109+
"level",
110+
"cl",
111+
"cl_s",
112+
"ce",
113+
"ce_s",
114+
"rce",
115+
"lce",
116+
"dce",
117+
"bbbv",
118+
"bbbv_solved",
119+
"bbbv_s",
120+
"flag",
121+
"path",
122+
"etime",
123+
"start_time",
124+
"end_time",
125+
"mode",
126+
"software",
127+
"player_identifier",
128+
"race_identifier",
129+
"uniqueness_identifier",
130+
"stnb",
131+
"corr",
132+
"thrp",
133+
"ioe",
134+
"is_official",
135+
"is_fair",
136+
"op",
137+
"isl",
138+
]
139+
self.table.setColumnCount(len(self.showFields))
140+
self.table.setHorizontalHeaderLabels(self.headers)
141+
# 居中显示文字
142+
self.table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter)
143+
# 选中整行
144+
self.table.setSelectionBehavior(QTableView.SelectRows)
145+
146+
# 自适应列宽
147+
self.table.horizontalHeader().setSectionResizeMode(
148+
QHeaderView.ResizeToContents)
149+
# 初始化隐藏列
150+
for i, field in enumerate(self.headers):
151+
self.table.setColumnHidden(i, field not in self.showFields)
152+
153+
def load(self, data: list[HistoryData]):
154+
self.table.setRowCount(len(data))
155+
for i, row in enumerate(data):
156+
for j, field in enumerate(self.headers):
157+
value = getattr(row, field)
158+
159+
self.table.setItem(i, j, self.build_item(value))
160+
161+
def build_item(self, value: Any):
162+
if isinstance(value, datetime):
163+
new_value = value.strftime("%Y-%m-%d %H:%M:%S.%f")
164+
if isinstance(value, BaseDiaPlayEnum):
165+
new_value = value.display_name
166+
else:
167+
new_value = value
168+
item = QTableWidgetItem(str(new_value))
169+
item.setData(Qt.UserRole, value)
170+
item.setTextAlignment(Qt.AlignCenter | Qt.AlignVCenter)
171+
return item
172+
173+
def refresh(self):
174+
parent: 'HistoryGUI' = self.parent()
175+
parent.load_data()
176+
177+
def show_context_menu(self, pos):
178+
menu = QMenu(self)
179+
action1 = menu.addAction(_translate("Form", "播放"), self.play_row)
180+
action2 = menu.addAction(_translate("Form", "导出"), self.export_row)
181+
action3 = menu.addAction(_translate("Form", "刷新"), self.refresh)
182+
# 给action3添加子菜单
183+
submenu = QMenu(_translate("Form", "显示字段"), self)
184+
# 遍历所有字段,添加一个action
185+
for field in self.headers:
186+
action = QAction(field, self)
187+
action.setCheckable(True)
188+
action.setChecked(field in self.showFields)
189+
action.triggered.connect(
190+
lambda checked: self.on_action_triggered(checked))
191+
submenu.addAction(action)
192+
menu.addMenu(submenu)
193+
menu.exec_(self.table.mapToGlobal(pos))
194+
195+
def on_action_triggered(self, checked: bool):
196+
action: QAction = self.sender()
197+
name = action.text()
198+
self.table.setColumnHidden(
199+
self.table.horizontalHeader().logicalIndex(
200+
self.headers.index(name)), not checked)
201+
if checked:
202+
self.showFields.add(name)
203+
else:
204+
self.showFields.remove(name)
205+
206+
def save_evf(self, evf_path: str):
207+
row_index = self.table.currentRow()
208+
if row_index < 0:
209+
return
210+
row = self.table.item(row_index, 0).data(Qt.UserRole)
211+
for filed in self.headers:
212+
if filed == "replay_id":
213+
replay_id = self.table.item(
214+
row_index, self.headers.index(filed)).data(Qt.UserRole)
215+
conn = sqlite3.connect(Path(get_paths()) / "history.db")
216+
conn.row_factory = sqlite3.Row # 设置行工厂
217+
cursor = conn.cursor()
218+
cursor.execute(
219+
"select raw_data from history where replay_id = ?", (replay_id,))
220+
221+
raw_data = cursor.fetchone()[0]
222+
with open(evf_path, "wb") as f:
223+
f.write(raw_data)
224+
conn.close()
225+
226+
def play_row(self):
227+
temp_filename = Path(get_paths())/f"tmp.evf"
228+
self.save_evf(temp_filename)
229+
# 检查当前目录是否存在main.py
230+
if (Path(get_paths()) / "main.py").exists():
231+
subprocess.Popen(
232+
[
233+
sys.executable,
234+
str(Path(get_paths()) / "main.py"),
235+
temp_filename
236+
],
237+
env=patch_env(),
238+
)
239+
elif (Path(get_paths()) / "metaminesweeper.exe").exists():
240+
subprocess.Popen(
241+
[
242+
Path(get_paths()) / "metaminesweeper.exe",
243+
temp_filename
244+
]
245+
)
246+
else:
247+
QMessageBox.warning(
248+
self, "错误", "当前目录下不存在main.py或metaminesweeper.exe")
249+
return
250+
251+
def export_row(self):
252+
file_path, _ = QFileDialog.getSaveFileName(self, _translate(
253+
"Form", "导出evf文件"), get_paths(), "evf文件 (*.evf)")
254+
255+
if not file_path:
256+
return
257+
self.save_evf(file_path)
258+
259+
260+
class HistoryGUI(QWidget):
261+
def __init__(self, parent=None):
262+
super().__init__(parent)
263+
self.setWindowTitle(_translate("Form", "历史记录"))
264+
self.resize(800, 600)
265+
self.layout = QVBoxLayout(self)
266+
self.table = HistoryTable(self.get_show_fields(), self)
267+
self.layout.addWidget(self.table)
268+
self.setLayout(self.layout)
269+
self.load_data()
270+
271+
def load_data(self):
272+
conn = sqlite3.connect(Path(get_paths()) / "history.db")
273+
conn.row_factory = sqlite3.Row # 设置行工厂
274+
cursor = conn.cursor()
275+
cursor.execute(HistoryData.query_all())
276+
datas = cursor.fetchall()
277+
history_data = [HistoryData.from_dict(dict(data)) for data in datas]
278+
self.table.load(history_data)
279+
280+
@property
281+
def config_path(self):
282+
return Path(get_paths()) / "history_show_fields.json"
283+
284+
def get_show_fields(self):
285+
# 先判断是否存在展示列的json文件
286+
if not (self.config_path).exists():
287+
return set(HistoryData.fields())
288+
with open(self.config_path, "r") as f:
289+
return set(json.load(f))
290+
291+
def closeEvent(self, a0: QCloseEvent | None) -> None:
292+
with open(self.config_path, "w") as f:
293+
json.dump(list(self.table.showFields), f)
294+
return super().closeEvent(a0)
295+
296+
297+
if __name__ == "__main__":
298+
299+
app = QApplication(sys.argv)
300+
301+
gui = HistoryGUI()
302+
303+
gui.show()
304+
305+
sys.exit(app.exec_())

src/main.py

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from mp_plugins import PluginManager
1717
from pathlib import Path
1818
# import os
19+
from utils import get_paths, patch_env
1920

2021
os.environ["QT_FONT_DPI"] = "96"
2122

@@ -28,31 +29,6 @@
2829
# root = os.path.dirname(os.path.abspath(__file__)) # 你的项目根目录
2930
# env["PYTHONPATH"] = root
3031
# return env
31-
def get_paths():
32-
if getattr(sys, "frozen", False):
33-
# 打包成 exe
34-
dir = os.path.dirname(sys.executable) # exe 所在目录
35-
else:
36-
dir = os.path.dirname(os.path.abspath(__file__))
37-
38-
return dir
39-
40-
41-
def patch_env():
42-
import os
43-
import sys
44-
45-
env = os.environ.copy()
46-
47-
if getattr(sys, "frozen", False):
48-
# 打包成 exe,库解压到 _MEIPASS
49-
root = getattr(sys, "_MEIPASS", None)
50-
else:
51-
# 调试模式,库在项目目录
52-
root = os.path.dirname(os.path.abspath(__file__))
53-
54-
env["PYTHONPATH"] = root
55-
return env
5632

5733

5834
def on_new_connection(localServer: QLocalServer):
@@ -159,14 +135,6 @@ def cli_check_file(file_path: str) -> int:
159135
if args.check:
160136
exit_code = cli_check_file(args.check)
161137
sys.exit(exit_code)
162-
env = patch_env()
163-
context = AppContext(name="Metasweeper", version="1.0.0", display_name="元扫雷",
164-
plugin_dir=(Path(get_paths()) / "plugins").as_posix(),
165-
app_dir=get_paths()
166-
)
167-
PluginManager.instance().context = context
168-
169-
PluginManager.instance().start(Path(get_paths()) / "plugins", env)
170138

171139
app = QtWidgets.QApplication(sys.argv)
172140
serverName = "MineSweeperServer"
@@ -185,6 +153,15 @@ def cli_check_file(file_path: str) -> int:
185153
localServer.newConnection.connect(
186154
lambda: on_new_connection(localServer=localServer)
187155
)
156+
env = patch_env()
157+
context = AppContext(name="Metasweeper", version="1.0.0", display_name="元扫雷",
158+
plugin_dir=(Path(get_paths()) /
159+
"plugins").as_posix(),
160+
app_dir=get_paths()
161+
)
162+
PluginManager.instance().context = context
163+
164+
PluginManager.instance().start(Path(get_paths()) / "plugins", env)
188165
mainWindow = mainWindowGUI.MainWindow()
189166
ui = mineSweeperGUI.MineSweeperGUI(mainWindow, sys.argv)
190167
ui.mainWindow.show()

0 commit comments

Comments
 (0)