Skip to content

Commit 9c35915

Browse files
committed
feat(history): 实现游戏历史记录功能及GUI界面
新增历史记录数据库存储功能,完善GameEndEvent数据结构,添加历史记录查看GUI界面,支持播放和导出历史记录
1 parent 753db1b commit 9c35915

7 files changed

Lines changed: 653 additions & 120 deletions

File tree

.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: 1 addition & 25 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):

src/mineSweeperGUI.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import base64
12
from PyQt5 import QtCore
23
from PyQt5.QtCore import QTimer, QCoreApplication, Qt, QRect, QUrl
34
from PyQt5.QtGui import QPixmap, QDesktopServices
5+
import msgspec
46
# from PyQt5.QtWidgets import QLineEdit, QInputDialog, QShortcut
57
# from PyQt5.QtWidgets import QApplication, QFileDialog, QWidget
68
import gameDefinedParameter
@@ -91,7 +93,6 @@ def save_evf_file_integrated():
9193
self.action_open_ini.triggered.connect(
9294
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.setting_path))))
9395

94-
9596
self.frameShortcut1.activated.connect(lambda: self.predefined_Board(1))
9697
self.frameShortcut2.activated.connect(lambda: self.predefined_Board(2))
9798
self.frameShortcut3.activated.connect(lambda: self.predefined_Board(3))
@@ -559,8 +560,20 @@ def gameFinished(self):
559560
self.score_board_manager.show(self.label.ms_board, index_type=2)
560561
self.enable_screenshot()
561562
self.unlimit_cursor()
562-
event = GameEndEvent()
563-
PluginManager.instance().send_event(event, response_count=0)
563+
ms_board = self.label.ms_board
564+
status = utils.GameBoardState(ms_board.game_board_state)
565+
if status == utils.GameBoardState.Win:
566+
self.dump_evf_file_data()
567+
event = GameEndEvent()
568+
data = msgspec.structs.asdict(event)
569+
for key in data:
570+
if hasattr(ms_board, key):
571+
if key == "raw_data":
572+
data[key] = base64.b64encode(
573+
ms_board.raw_data).decode("utf-8")
574+
data[key] = getattr(ms_board, key)
575+
event = GameEndEvent(**data)
576+
PluginManager.instance().send_event(event, response_count=0)
564577

565578
def gameWin(self): # 成功后改脸和状态变量,停时间
566579
self.timer_10ms.stop()
@@ -958,7 +971,6 @@ def showTime(self, t):
958971
elif t >= 1000:
959972
return
960973

961-
962974
def predefined_Board(self, k):
963975
# 按快捷键123456时的回调
964976
row = self.predefinedBoardPara[k]['row']

0 commit comments

Comments
 (0)