├── CS2.ico
├── CS2StopReflex.py
├── README.md
├── background.png
└── main.py
/CS2.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PuddingTower/CS2StopReflex/ed9986dc274d95c22d53217f90e7deeb73a3cd93/CS2.ico
--------------------------------------------------------------------------------
/CS2StopReflex.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import os
3 | import time
4 | from PyQt5.QtWidgets import (
5 | QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget,
6 | QHBoxLayout, QListWidget, QMessageBox, QListWidgetItem, QPushButton, QSizePolicy, QSpacerItem
7 | )
8 | from PyQt5.QtCore import Qt, pyqtSignal, QUrl
9 | from PyQt5.QtGui import QFont, QColor, QBrush, QIcon, QDesktopServices
10 | from pynput import keyboard
11 | from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
12 | from matplotlib.figure import Figure
13 | import statistics
14 | from matplotlib import rcParams
15 |
16 | rcParams['font.sans-serif'] = ['Microsoft YaHei']
17 | rcParams['axes.unicode_minus'] = False
18 |
19 | def resource_path(relative_path):
20 | try:
21 | base_path = sys._MEIPASS
22 | except Exception:
23 | base_path = os.path.abspath(".")
24 |
25 | return os.path.join(base_path, relative_path)
26 |
27 | class MainWindow(QMainWindow):
28 | feedback_signal = pyqtSignal(str, QColor)
29 | history_signal = pyqtSignal(float, float, dict, QColor)
30 | key_state_signal = pyqtSignal(str, bool)
31 |
32 | def __init__(self):
33 | super().__init__()
34 | self.setWindowTitle("CS2急停评估工具")
35 | self.setGeometry(100, 100, 1400, 900)
36 |
37 | icon_path = resource_path("CS2.ico")
38 | if os.path.exists(icon_path):
39 | self.setWindowIcon(QIcon(icon_path))
40 | else:
41 | print("图标文件 CS2.ico 未找到。")
42 |
43 | central_widget = QWidget()
44 | self.setCentralWidget(central_widget)
45 |
46 | main_layout = QHBoxLayout()
47 | central_widget.setLayout(main_layout)
48 |
49 | left_layout = QVBoxLayout()
50 |
51 | font_large = QFont("Microsoft YaHei", 18)
52 | font_small = QFont("Microsoft YaHei", 12)
53 | font_key = QFont("Microsoft YaHei", 14, QFont.Bold)
54 |
55 | self.feedback_label = QLabel("请模拟自己PEEK时进行AD大拉")
56 | self.feedback_label.setAlignment(Qt.AlignCenter)
57 | self.feedback_label.setFont(font_large)
58 | self.feedback_label.setStyleSheet("""
59 | QLabel {
60 | color: #FFFFFF;
61 | background-color: #2E2E2E;
62 | border-radius: 10px;
63 | padding: 15px;
64 | }
65 | """)
66 | left_layout.addWidget(self.feedback_label)
67 |
68 | key_status_layout = QHBoxLayout()
69 |
70 | self.a_key_label = QLabel("A键:未按下")
71 | self.a_key_label.setFont(font_key)
72 | self.a_key_label.setAlignment(Qt.AlignCenter)
73 | self.a_key_label.setStyleSheet("""
74 | QLabel {
75 | background-color: #D3D3D3;
76 | border: 2px solid #000000;
77 | border-radius: 8px;
78 | }
79 | """)
80 | self.a_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
81 | key_status_layout.addWidget(self.a_key_label)
82 |
83 | self.d_key_label = QLabel("D键:未按下")
84 | self.d_key_label.setFont(font_key)
85 | self.d_key_label.setAlignment(Qt.AlignCenter)
86 | self.d_key_label.setStyleSheet("""
87 | QLabel {
88 | background-color: #D3D3D3;
89 | border: 2px solid #000000;
90 | border-radius: 8px;
91 | }
92 | """)
93 | self.d_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
94 | key_status_layout.addWidget(self.d_key_label)
95 |
96 | left_layout.addLayout(key_status_layout)
97 |
98 | self.history_list = QListWidget()
99 | self.history_list.setFont(font_small)
100 | self.history_list.setStyleSheet("""
101 | QListWidget {
102 | background-color: #FFFFFF;
103 | border: 1px solid #CCCCCC;
104 | border-radius: 5px;
105 | }
106 | QListWidget::item:selected {
107 | background-color: #ADD8E6;
108 | }
109 | """)
110 | self.history_list.itemClicked.connect(self.show_detail_info)
111 | left_layout.addWidget(self.history_list, stretch=1)
112 |
113 | left_layout.addItem(QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding))
114 |
115 | help_button_layout = QHBoxLayout()
116 | help_button_layout.addStretch()
117 |
118 | self.info_button = QPushButton("i")
119 | self.info_button.setFont(QFont("Arial", 16, QFont.Bold))
120 | self.info_button.setFixedSize(40, 40)
121 | self.info_button.setStyleSheet("""
122 | QPushButton {
123 | background-color: #2E2E2E;
124 | color: #FFFFFF;
125 | border: none;
126 | border-radius: 20px;
127 | }
128 | QPushButton:hover {
129 | background-color: #505050;
130 | }
131 | """)
132 | self.info_button.clicked.connect(self.show_recommendations)
133 | self.info_button.hide()
134 | help_button_layout.addWidget(self.info_button)
135 |
136 | self.question_button = QPushButton("❓")
137 | self.question_button.setFont(QFont("Arial", 16, QFont.Bold))
138 | self.question_button.setFixedSize(40, 40)
139 | self.question_button.setStyleSheet("""
140 | QPushButton {
141 | background-color: #2E2E2E;
142 | color: #FFFFFF;
143 | border: none;
144 | border-radius: 20px;
145 | }
146 | QPushButton:hover {
147 | background-color: #505050;
148 | }
149 | """)
150 | self.question_button.clicked.connect(self.open_help_link)
151 | self.question_button.hide()
152 | help_button_layout.addWidget(self.question_button)
153 |
154 | left_layout.addLayout(help_button_layout)
155 |
156 | right_layout = QVBoxLayout()
157 |
158 | self.figure_line = Figure(figsize=(6, 4))
159 | self.canvas_line = FigureCanvas(self.figure_line)
160 | right_layout.addWidget(self.canvas_line)
161 |
162 | self.figure_box = Figure(figsize=(6, 4))
163 | self.canvas_box = FigureCanvas(self.figure_box)
164 | right_layout.addWidget(self.canvas_box)
165 |
166 | main_layout.addLayout(left_layout, 1)
167 | main_layout.addLayout(right_layout, 2)
168 |
169 | self.setStyleSheet("""
170 | QMainWindow {
171 | background-color: #F0F0F0;
172 | }
173 | QLabel {
174 | color: #2E2E2E;
175 | }
176 | QListWidget {
177 | background-color: #FFFFFF;
178 | }
179 | """)
180 |
181 | self.listener = keyboard.Listener(on_press=self.on_press, on_release=self.on_release)
182 | self.listener.start()
183 |
184 | self.key_state = {'A': {'pressed': False, 'time': None}, 'D': {'pressed': False, 'time': None}}
185 | self.waiting_for_opposite_key = None
186 | self.data = []
187 | self.last_record_time = 0
188 | self.min_time_between_records = 0.05
189 |
190 | self.feedback_signal.connect(self.update_feedback)
191 | self.history_signal.connect(self.update_history)
192 | self.key_state_signal.connect(self.update_key_state_display)
193 |
194 | def on_press(self, key):
195 | try:
196 | key_char = key.char.upper()
197 | if key_char in ['A', 'D']:
198 | if not self.key_state[key_char]['pressed']:
199 | self.key_state[key_char]['pressed'] = True
200 | self.key_state[key_char]['time'] = time.perf_counter()
201 | self.key_state_signal.emit(key_char, True)
202 |
203 | if self.waiting_for_opposite_key and self.waiting_for_opposite_key['key'] == key_char:
204 | current_time = time.perf_counter()
205 | if current_time - self.last_record_time < self.min_time_between_records:
206 | return
207 |
208 | press_time = self.key_state[key_char]['time']
209 | release_time = self.waiting_for_opposite_key['release_time']
210 | time_diff = press_time - release_time
211 | if abs(time_diff) > 0.2:
212 | self.waiting_for_opposite_key = None
213 | return
214 |
215 | time_diff_ms = round(time_diff * 1000, 1)
216 |
217 | color = self.get_color(time_diff_ms)
218 |
219 | if abs(time_diff_ms) <= 2:
220 | timing = '完美急停'
221 | elif time_diff_ms < 0:
222 | timing = '按早了'
223 | else:
224 | timing = '按晚了'
225 |
226 | feedback = f"{timing}:松开{self.waiting_for_opposite_key['key_released']}后{abs(time_diff_ms):.1f}ms按下了{key_char}"
227 |
228 | detail_info = {
229 | 'events': self.waiting_for_opposite_key['events'] + [
230 | {'key': key_char, 'event': '按下', 'time': press_time, 'time_str': self.format_time(press_time)}
231 | ]
232 | }
233 |
234 | self.feedback_signal.emit(feedback, color)
235 | self.history_signal.emit(press_time, time_diff, detail_info, color)
236 |
237 | self.data.append({'time': press_time, 'time_diff': time_diff})
238 | if len(self.data) > 50:
239 | self.data.pop(0)
240 |
241 | self.last_record_time = current_time
242 | self.waiting_for_opposite_key = None
243 | except AttributeError:
244 | pass
245 |
246 | def on_release(self, key):
247 | try:
248 | key_char = key.char.upper()
249 | if key_char in ['A', 'D']:
250 | if self.key_state[key_char]['pressed']:
251 | release_time = time.perf_counter()
252 | self.key_state[key_char]['pressed'] = False
253 | self.key_state_signal.emit(key_char, False)
254 | self.process_key_event(key_char, release_time)
255 | except AttributeError:
256 | pass
257 |
258 | def process_key_event(self, key_released, release_time):
259 | opposite_key = 'D' if key_released == 'A' else 'A'
260 | key_state = self.key_state[opposite_key]
261 |
262 | if key_state['pressed']:
263 | time_diff = key_state['time'] - release_time
264 | if abs(time_diff) > 0.2:
265 | return
266 | time_diff_ms = round(time_diff * 1000, 1)
267 |
268 | current_time = time.perf_counter()
269 | if current_time - self.last_record_time < self.min_time_between_records:
270 | return
271 |
272 | color = self.get_color(time_diff_ms)
273 |
274 | if abs(time_diff_ms) <= 2:
275 | timing = '完美急停'
276 | elif time_diff_ms < 0:
277 | timing = '按早了'
278 | else:
279 | timing = '按晚了'
280 |
281 | feedback = f"{timing}:未松开{key_released}就按下了{opposite_key},{abs(time_diff_ms):.1f}ms"
282 |
283 | detail_info = {
284 | 'events': [
285 | {'key': key_released, 'event': '松开', 'time': release_time, 'time_str': self.format_time(release_time)},
286 | {'key': opposite_key, 'event': '按下', 'time': key_state['time'], 'time_str': self.format_time(key_state['time'])}
287 | ]
288 | }
289 |
290 | self.feedback_signal.emit(feedback, color)
291 | self.history_signal.emit(key_state['time'], time_diff, detail_info, color)
292 |
293 | self.data.append({'time': key_state['time'], 'time_diff': time_diff})
294 | if len(self.data) > 50:
295 | self.data.pop(0)
296 |
297 | self.last_record_time = current_time
298 | else:
299 | self.waiting_for_opposite_key = {
300 | 'key': opposite_key,
301 | 'release_time': release_time,
302 | 'key_released': key_released,
303 | 'events': [
304 | {'key': key_released, 'event': '松开', 'time': release_time, 'time_str': self.format_time(release_time)}
305 | ]
306 | }
307 |
308 | def update_feedback(self, feedback, color):
309 | self.feedback_label.setText(feedback)
310 | self.feedback_label.setStyleSheet(f"""
311 | QLabel {{
312 | color: #FFFFFF;
313 | background-color: {color.name()};
314 | border-radius: 10px;
315 | padding: 15px;
316 | }}
317 | """)
318 |
319 | def update_history(self, press_time, time_diff, detail_info, color):
320 | time_diff_ms = round(time_diff * 1000, 1)
321 | time_str = self.format_time(press_time)
322 | item_text = f"{time_str} - 时间差:{time_diff_ms:.1f}ms"
323 | item = QListWidgetItem(item_text)
324 | item.setData(Qt.UserRole, detail_info)
325 | item.setBackground(QBrush(color))
326 |
327 | if self.is_light_color(color):
328 | item.setForeground(QBrush(QColor("#000000")))
329 | else:
330 | item.setForeground(QBrush(QColor("#FFFFFF")))
331 | self.history_list.addItem(item)
332 |
333 | if self.history_list.count() > 25:
334 | self.history_list.takeItem(0)
335 |
336 | if len(self.data) >= 20:
337 | if not self.info_button.isVisible():
338 | self.info_button.show()
339 | if not self.question_button.isVisible():
340 | self.question_button.show()
341 |
342 | self.update_plot()
343 | self.update_boxplot()
344 |
345 | def update_plot(self):
346 | self.figure_line.clear()
347 | ax = self.figure_line.add_subplot(111)
348 |
349 | time_diffs = [round(d['time_diff'] * 1000, 1) for d in self.data[-25:]]
350 | jump_numbers = range(len(self.data) - len(time_diffs) + 1, len(self.data) + 1)
351 |
352 | colors = [self.get_color(diff) for diff in time_diffs]
353 |
354 | scatter = ax.scatter(jump_numbers, time_diffs, c=[color.name() for color in colors], s=100, edgecolors='black')
355 |
356 | if time_diffs:
357 | mean_value = statistics.mean(time_diffs)
358 | ax.axhline(mean_value, color='blue', linestyle='--', linewidth=2, label=f'平均值:{mean_value:.1f}ms')
359 |
360 | ax.axhline(0, color='black', linewidth=1, linestyle='-')
361 |
362 | ax.set_xticks(jump_numbers)
363 | ax.set_xlabel('操作次数', fontproperties="Microsoft YaHei", fontsize=12)
364 | ax.set_ylabel('时间差(ms)', fontproperties="Microsoft YaHei", fontsize=12)
365 | ax.set_title('提前或滞后时间差(最近25次)', fontproperties="Microsoft YaHei", fontsize=14)
366 | ax.legend(prop={'family': 'Microsoft YaHei', 'size': 10})
367 |
368 | ax.spines['top'].set_visible(False)
369 | ax.spines['right'].set_visible(False)
370 | ax.grid(True, linestyle='--', alpha=0.6)
371 |
372 | self.canvas_line.draw()
373 |
374 | def update_boxplot(self):
375 | if len(self.data) >= 20:
376 | time_diffs = [round(d['time_diff'] * 1000, 1) for d in self.data[-50:]]
377 |
378 | self.figure_box.clear()
379 | ax = self.figure_box.add_subplot(111)
380 |
381 | bp = ax.boxplot(time_diffs, vert=False, patch_artist=True, showfliers=False)
382 |
383 | for box in bp['boxes']:
384 | box.set(color='#7570b3', linewidth=2)
385 | box.set(facecolor='#1b9e77')
386 |
387 | for whisker in bp['whiskers']:
388 | whisker.set(color='#7570b3', linewidth=2)
389 |
390 | for cap in bp['caps']:
391 | cap.set(color='#7570b3', linewidth=2)
392 |
393 | for median in bp['medians']:
394 | median.set(color='#b2df8a', linewidth=2)
395 |
396 | ax.set_xlabel('时间差(ms)', fontproperties="Microsoft YaHei", fontsize=12)
397 | ax.set_title('时间差箱线图(最近50次)', fontproperties="Microsoft YaHei", fontsize=14)
398 |
399 | ax.grid(True, linestyle='--', alpha=0.6)
400 |
401 | ax.spines['top'].set_visible(False)
402 | ax.spines['right'].set_visible(False)
403 |
404 | self.canvas_box.draw()
405 | else:
406 | self.figure_box.clear()
407 | self.canvas_box.draw()
408 |
409 | def show_detail_info(self, item):
410 | detail_info = item.data(Qt.UserRole)
411 | if detail_info:
412 | events = detail_info['events']
413 | message = ""
414 | for event in events:
415 | message += f"{event['time_str']} - {event['key']}键 {event['event']}\n"
416 | QMessageBox.information(self, "详细信息", message)
417 |
418 | def format_time(self, timestamp):
419 | return f"{timestamp:.3f}秒"
420 |
421 | def update_key_state_display(self, key_char, is_pressed):
422 | if key_char == 'A':
423 | if is_pressed:
424 | self.a_key_label.setText("A键:按下")
425 | self.a_key_label.setStyleSheet("""
426 | QLabel {
427 | background-color: #90EE90;
428 | border: 2px solid #000000;
429 | border-radius: 8px;
430 | }
431 | """)
432 | else:
433 | self.a_key_label.setText("A键:未按下")
434 | self.a_key_label.setStyleSheet("""
435 | QLabel {
436 | background-color: #D3D3D3;
437 | border: 2px solid #000000;
438 | border-radius: 8px;
439 | }
440 | """)
441 | elif key_char == 'D':
442 | if is_pressed:
443 | self.d_key_label.setText("D键:按下")
444 | self.d_key_label.setStyleSheet("""
445 | QLabel {
446 | background-color: #90EE90;
447 | border: 2px solid #000000;
448 | border-radius: 8px;
449 | }
450 | """)
451 | else:
452 | self.d_key_label.setText("D键:未按下")
453 | self.d_key_label.setStyleSheet("""
454 | QLabel {
455 | background-color: #D3D3D3;
456 | border: 2px solid #000000;
457 | border-radius: 8px;
458 | }
459 | """)
460 |
461 | def get_color(self, time_diff_ms):
462 | max_time_diff = 200
463 | normalized_diff = min(abs(time_diff_ms), max_time_diff) / max_time_diff
464 |
465 | if time_diff_ms < 0:
466 | start_color = QColor(173, 216, 230)
467 | end_color = QColor(0, 0, 139)
468 | elif time_diff_ms > 0:
469 | start_color = QColor(255, 182, 193)
470 | end_color = QColor(139, 0, 0)
471 | else:
472 | return QColor(144, 238, 144)
473 |
474 | r = start_color.red() + (end_color.red() - start_color.red()) * normalized_diff
475 | g = start_color.green() + (end_color.green() - start_color.green()) * normalized_diff
476 | b = start_color.blue() + (end_color.blue() - start_color.blue()) * normalized_diff
477 |
478 | return QColor(int(round(r)), int(round(g)), int(round(b)))
479 |
480 | def is_light_color(self, color):
481 | brightness = (color.red() * 299 + color.green() * 587 + color.blue() * 114) / 1000
482 | return brightness > 128
483 |
484 | def open_help_link(self):
485 | QDesktopServices.openUrl(QUrl("https://space.bilibili.com/13723713"))
486 |
487 | def show_recommendations(self):
488 | if not self.data:
489 | QMessageBox.information(self, "急停建议", "暂无数据可供分析。")
490 | return
491 |
492 | avg_time_diff = statistics.mean([d['time_diff'] for d in self.data])
493 | avg_time_diff_ms = round(avg_time_diff * 1000, 2)
494 |
495 | if avg_time_diff_ms < -5:
496 | recommendation = (
497 | f"您的平均时间差为 {avg_time_diff_ms:.2f}ms,偏早。\n\n"
498 | "建议:\n"
499 | "- 使用更短的反应时间 (RT)。\n"
500 | "- 使用更长的死区(触发键程)。\n"
501 | "- 考虑开启 Snaptap 相关残疾辅助功能。"
502 | )
503 | elif avg_time_diff_ms > 5:
504 | recommendation = (
505 | f"您的平均时间差为 {avg_time_diff_ms:.2f}ms,偏晚。\n\n"
506 | "建议:\n"
507 | "- 使用更长的反应时间 (RT)。\n"
508 | "- 使用更短的死区(触发键程)。"
509 | )
510 | else:
511 | recommendation = (
512 | f"您的平均时间差为 {avg_time_diff_ms:.2f}ms。\n\n"
513 | "您表现出色!继续保持您的急停技巧!"
514 | )
515 |
516 | QMessageBox.information(self, "急停建议", recommendation)
517 |
518 | def main():
519 | if hasattr(Qt, 'AA_EnableHighDpiScaling'):
520 | QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
521 | if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
522 | QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
523 |
524 | try:
525 | app = QApplication(sys.argv)
526 | window = MainWindow()
527 | window.show()
528 | sys.exit(app.exec_())
529 | except Exception as e:
530 | print(f"发生错误: {e}")
531 |
532 | if __name__ == "__main__":
533 | main()
534 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | 
4 |
5 |
6 | # CS2StopReflex
7 | A tool to analyze and improve your strafing and quick-stop timing in CS2. Enhance your in-game movement precision by visualizing your A/D key press timing.
8 | # CS2StrafeStop
9 |
10 | **CS2StrafeStop** is a tool designed to help players of **Counter-Strike 2 (CS2)** improve their strafing and quick-stop timing. By tracking and analyzing the precise timing of A/D key presses during strafing, this tool helps users refine their movement skills for better in-game performance.
11 |
12 | ## Features
13 |
14 | - **Real-time feedback:** Displays your strafing performance with detailed timing information.
15 | - **Keypress analysis:** Tracks the timing between pressing and releasing the A/D keys for quick stops.
16 | - **Visualized data:** Generates line plots and box plots to help visualize recent strafing performance.
17 | - **History tracking:** Records and displays the timing differences between keypresses for up to 50 recent actions.
18 | - **Recommendations:** Provides insights and suggestions based on your strafing performance.
19 | - **Matplotlib Integration:** Displays interactive charts for better understanding of your strafing timing.
20 |
21 | - **实时反馈:** 显示你的闪身操作表现,提供详细的时机信息。
22 | - **按键分析:** 跟踪 A/D 键的按下与松开时间差,分析急停操作时机。
23 | - **数据可视化:** 生成折线图和箱线图,帮助你可视化最近的操作表现。
24 | - **历史记录:** 记录并显示最近 50 次操作的时机差异。
25 | - **个性化建议:** 根据你的操作表现提供改进建议。
26 | - **Matplotlib 集成:** 使用 Matplotlib 生成交互式图表,帮助更好地理解操作时机。
27 |
--------------------------------------------------------------------------------
/background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PuddingTower/CS2StopReflex/ed9986dc274d95c22d53217f90e7deeb73a3cd93/background.png
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import os
3 | import time
4 | from collections import deque
5 | from PyQt5.QtWidgets import (
6 | QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget,
7 | QHBoxLayout, QListWidget, QMessageBox, QListWidgetItem, QPushButton,
8 | QSizePolicy, QSpacerItem, QGridLayout, QGroupBox, QDialog,
9 | QRadioButton, QButtonGroup, QShortcut, QLineEdit, QFormLayout, QTextBrowser
10 | )
11 | from PyQt5.QtCore import Qt, pyqtSignal, QUrl, QSize, QTimer, pyqtSlot
12 | from PyQt5.QtGui import QFont, QColor, QBrush, QIcon, QDesktopServices, QPixmap, QPainter, QKeySequence
13 | from pynput import keyboard
14 | from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
15 | from matplotlib.figure import Figure
16 | import statistics
17 | from matplotlib import rcParams
18 | import matplotlib.colors as mcolors # Import colors module
19 |
20 | # 设置 matplotlib 字体以支持中文
21 | rcParams['font.sans-serif'] = ['Microsoft YaHei']
22 | rcParams['axes.unicode_minus'] = False
23 |
24 | def resource_path(relative_path):
25 | """ 获取资源的绝对路径,支持打包后的应用 """
26 | if getattr(sys, 'frozen', False): # 如果是打包后的可执行文件
27 | current_dir = sys._MEIPASS # 打包后的临时目录
28 | else:
29 | current_dir = os.path.dirname(os.path.abspath(__file__))
30 | return os.path.join(current_dir, relative_path)
31 |
32 | class BackgroundLabel(QLabel):
33 | """ 带透明度和背景模糊效果的背景标签 """
34 | def __init__(self, image_path, parent=None):
35 | super().__init__(parent)
36 | self.pixmap_original = QPixmap(image_path)
37 | self.opacity = 0.8
38 | self.setAttribute(Qt.WA_TransparentForMouseEvents) # 允许鼠标事件穿透
39 | self.setScaledContents(False) # 不自动缩放内容
40 | self.update_pixmap()
41 |
42 | def set_opacity(self, opacity):
43 | """ 设置背景透明度 """
44 | self.opacity = opacity
45 | self.update_pixmap()
46 |
47 | def update_pixmap(self):
48 | """ 更新显示的 Pixmap,应用透明度和模糊效果 """
49 | if self.pixmap_original.isNull():
50 | return
51 | size = self.size()
52 | if size.isEmpty():
53 | return
54 |
55 | # 保持宽高比扩展缩放图像
56 | scaled_pixmap = self.pixmap_original.scaled(
57 | size,
58 | Qt.KeepAspectRatioByExpanding,
59 | Qt.SmoothTransformation
60 | )
61 |
62 | # 创建带透明度的 Pixmap
63 | pixmap_with_opacity = QPixmap(size)
64 | pixmap_with_opacity.fill(Qt.transparent) # 填充透明背景
65 |
66 | painter = QPainter(pixmap_with_opacity)
67 | painter.setOpacity(self.opacity) # 设置图片本身透明度
68 |
69 | # 计算居中绘制的位置
70 | x = (size.width() - scaled_pixmap.width()) // 2
71 | y = (size.height() - scaled_pixmap.height()) // 2
72 | painter.drawPixmap(x, y, scaled_pixmap) # 绘制缩放后的图片
73 |
74 | # 添加一层半透明白色遮罩,模拟模糊效果
75 | painter.setOpacity(0.3)
76 | painter.fillRect(pixmap_with_opacity.rect(), QColor(255, 255, 255))
77 | painter.end()
78 |
79 | super().setPixmap(pixmap_with_opacity) # 设置最终的 Pixmap
80 |
81 | def resizeEvent(self, event):
82 | """ 窗口大小改变时重新计算并更新 Pixmap """
83 | if hasattr(self, 'pixmap_original') and not self.pixmap_original.isNull():
84 | self.update_pixmap()
85 | super().resizeEvent(event)
86 |
87 | class OptionDialog(QDialog):
88 | """ 用于选择选项的通用对话框 """
89 | def __init__(self, title, options, parent=None):
90 | super().__init__(parent)
91 | self.setWindowTitle(title)
92 | self.selected_option = None
93 |
94 | layout = QVBoxLayout()
95 |
96 | self.button_group = QButtonGroup(self)
97 | self.radio_buttons = {}
98 | for option in options:
99 | radio_button = QRadioButton(option)
100 | self.button_group.addButton(radio_button)
101 | layout.addWidget(radio_button)
102 | self.radio_buttons[option] = radio_button
103 | if option == options[0]:
104 | radio_button.setChecked(True)
105 |
106 | button_layout = QHBoxLayout()
107 | self.ok_button = QPushButton("确定")
108 | self.ok_button.clicked.connect(self.accept)
109 | self.cancel_button = QPushButton("取消")
110 | self.cancel_button.clicked.connect(self.reject)
111 | button_layout.addStretch()
112 | button_layout.addWidget(self.ok_button)
113 | button_layout.addWidget(self.cancel_button)
114 |
115 | layout.addLayout(button_layout)
116 | self.setLayout(layout)
117 |
118 | def set_selected_option(self, option_text):
119 | if option_text in self.radio_buttons:
120 | self.radio_buttons[option_text].setChecked(True)
121 |
122 | def get_selected_option(self):
123 | selected_button = self.button_group.checkedButton()
124 | if selected_button:
125 | return selected_button.text()
126 | return None
127 |
128 | class KeyMappingDialog(QDialog):
129 | """ 用于设置按键映射的对话框 """
130 | def __init__(self, current_mappings, parent=None):
131 | super().__init__(parent)
132 | self.setWindowTitle("按键映射设置")
133 | self.current_mappings = current_mappings
134 | self.new_mappings = current_mappings.copy()
135 |
136 | layout = QFormLayout()
137 | self.setMinimumWidth(300)
138 |
139 | self.map_inputs = {}
140 | for target_key in ['W', 'A', 'S', 'D']:
141 | source_key = self.current_mappings.get(target_key, target_key)
142 | line_edit = QLineEdit(source_key)
143 | line_edit.setMaxLength(1)
144 | line_edit.setFont(QFont("Arial", 12))
145 | self.map_inputs[target_key] = line_edit
146 | layout.addRow(f"映射到 {target_key}:", line_edit)
147 |
148 | self.validation_label = QLabel("")
149 | self.validation_label.setStyleSheet("color: red;")
150 | layout.addWidget(self.validation_label)
151 |
152 | button_layout = QHBoxLayout()
153 | self.ok_button = QPushButton("确定")
154 | self.ok_button.clicked.connect(self.validate_and_accept)
155 | self.cancel_button = QPushButton("取消")
156 | self.cancel_button.clicked.connect(self.reject)
157 | self.default_button = QPushButton("恢复默认")
158 | self.default_button.clicked.connect(self.restore_defaults)
159 |
160 | button_layout.addWidget(self.default_button)
161 | button_layout.addStretch()
162 | button_layout.addWidget(self.ok_button)
163 | button_layout.addWidget(self.cancel_button)
164 |
165 | main_v_layout = QVBoxLayout()
166 | main_v_layout.addLayout(layout)
167 | main_v_layout.addLayout(button_layout)
168 | self.setLayout(main_v_layout)
169 |
170 | def validate_and_accept(self):
171 | temp_mappings = {}
172 | pressed_keys = set()
173 | valid = True
174 | for target_key, line_edit in self.map_inputs.items():
175 | val = line_edit.text().upper()
176 | if not val:
177 | self.validation_label.setText(f"错误: {target_key} 的映射不能为空。")
178 | valid = False
179 | break
180 | if not val.isalnum():
181 | self.validation_label.setText(f"错误: {target_key} 的映射 '{val}' 无效 (仅限字母或数字)。")
182 | valid = False
183 | break
184 | if val in pressed_keys:
185 | self.validation_label.setText(f"错误: 按键 '{val}' 被多次映射。")
186 | valid = False
187 | break
188 | pressed_keys.add(val)
189 | temp_mappings[target_key] = val
190 |
191 | if valid:
192 | self.new_mappings = temp_mappings
193 | self.accept()
194 | else:
195 | pass
196 |
197 | def restore_defaults(self):
198 | defaults = {'W': 'W', 'A': 'A', 'S': 'S', 'D': 'D'}
199 | for target_key, line_edit in self.map_inputs.items():
200 | line_edit.setText(defaults[target_key])
201 | self.validation_label.setText("")
202 |
203 | def get_mappings(self):
204 | return self.new_mappings
205 |
206 | class NoSpaceActivateListWidget(QListWidget):
207 | """
208 | 自定义QListWidget,阻止空格键激活项目。
209 | """
210 | def keyPressEvent(self, event):
211 | if event.key() == Qt.Key_Space:
212 | event.ignore() # 忽略空格键事件,不传递给父类处理
213 | return # 直接返回,不执行默认的空格键行为
214 | super().keyPressEvent(event) # 其他按键正常处理
215 |
216 |
217 | class InstructionsDialog(QDialog):
218 | """
219 | 显示使用说明的自定义对话框,包含外部链接按钮。
220 | """
221 | def __init__(self, parent=None):
222 | super().__init__(parent)
223 | self.setWindowTitle("使用说明")
224 | self.setMinimumSize(500, 450) # 稍微调大一点以容纳新文本
225 |
226 | layout = QVBoxLayout(self)
227 |
228 | self.instructions_browser = QTextBrowser(self)
229 | self.instructions_browser.setOpenExternalLinks(True)
230 |
231 | # 新增的说明文本
232 | new_intro_text = """
233 |
此工具主要用于CS2玩家找到自己合适的磁轴键程,请注意,本软件在设计上没有为其他游戏与机械键盘用户开发,请注意使用范围。请详细阅读说明或者去作者B站观看视频。
234 |
235 | """
236 |
237 | existing_instructions_text = """
238 | 欢迎使用 CS2 急停评估工具!
239 |
240 | 基本操作:
241 | 1. 在游戏中或桌面进行 W/A/S/D (或您映射的按键) 急停操作。
242 | - AD 急停: 按住 A 后松开并立即按 D (或反之)。
243 | - WS 急停: 按住 W 后松开并立即按 S (或反之)。
244 | - 反向急停: 按住 A 和 D,然后松开其中一个。
245 | 2. 工具会记录您每次急停的时间差 (毫秒)。
246 | - 负数表示反向键按早了。
247 | - 正数表示反向键按晚了。
248 | - 接近 0 表示完美。
249 | 3. 查看图表和历史记录来分析您的表现。
250 |
251 | 功能键:
252 | - F5 / 刷新按钮: 清空所有记录和图表。
253 | - F6 / 建议按钮: 根据当前数据提供急停建议 (数据充足时显示)。
254 | - F7 / 使用说明按钮: 显示此帮助信息。
255 | - F8 / 按键映射按钮: 设置用其他按键 (如IJKL) 模拟WASD。
256 |
257 | 其他设置:
258 | - 记录次数: 设置图表中显示的最近记录数量。
259 | - 过滤阈值: 忽略时间差绝对值大于此阈值的记录。
260 |
261 | 提示:
262 | - 为了获得准确数据,请确保工具在后台运行时,游戏内没有绑定冲突的按键。
263 | - 建议在练习模式或本地服务器进行测试。
264 | - 如果键盘监听失败,尝试以管理员身份运行本程序。
265 |
266 | 祝您练习愉快,枪法进步!
267 | """
268 | full_instructions_html = new_intro_text + existing_instructions_text.strip().replace("\n", "
")
269 | self.instructions_browser.setHtml(full_instructions_html)
270 | layout.addWidget(self.instructions_browser)
271 |
272 | button_layout = QHBoxLayout()
273 |
274 | self.github_button = QPushButton("作者GitHub主页")
275 | self.github_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://github.com/PuddingTower")))
276 | button_layout.addWidget(self.github_button)
277 |
278 | self.bilibili_button = QPushButton("作者B站主页")
279 | self.bilibili_button.clicked.connect(lambda: QDesktopServices.openUrl(QUrl("https://space.bilibili.com/13723713")))
280 | button_layout.addWidget(self.bilibili_button)
281 |
282 | button_layout.addStretch()
283 |
284 | self.close_button = QPushButton("关闭")
285 | self.close_button.clicked.connect(self.accept)
286 | button_layout.addWidget(self.close_button)
287 |
288 | layout.addLayout(button_layout)
289 | self.setLayout(layout)
290 |
291 |
292 | class MainWindow(QMainWindow):
293 | feedback_signal = pyqtSignal(str, QColor)
294 | history_signal = pyqtSignal(str, float, float, dict, QColor)
295 | key_state_signal = pyqtSignal(str, bool)
296 | start_timer_signal = pyqtSignal(str, int)
297 | stop_timer_signal = pyqtSignal(str)
298 | key_press_signal = pyqtSignal(str, float)
299 | key_release_signal = pyqtSignal(str, float)
300 | log_signal = pyqtSignal(str)
301 | update_key_labels_signal = pyqtSignal()
302 |
303 |
304 | def __init__(self):
305 | super().__init__()
306 | self.setWindowTitle("CS2急停评估工具")
307 | self.setGeometry(100, 100, 1600, 900)
308 |
309 | icon_path = resource_path("CS2.ico")
310 | if os.path.exists(icon_path):
311 | self.setWindowIcon(QIcon(icon_path))
312 | else:
313 | print("图标文件 CS2.ico 未找到。")
314 |
315 | central_widget = QWidget()
316 | self.setCentralWidget(central_widget)
317 |
318 | background_path = resource_path("background.png")
319 | if os.path.exists(background_path):
320 | self.background_label = BackgroundLabel(background_path, central_widget)
321 | self.background_label.setGeometry(central_widget.rect())
322 | self.background_label.lower()
323 | else:
324 | print("背景图片 background.png 未找到。")
325 | self.background_label = None
326 |
327 | main_layout = QHBoxLayout()
328 | central_widget.setLayout(main_layout)
329 |
330 | left_layout = QVBoxLayout()
331 |
332 | font_large = QFont("Microsoft YaHei", 18)
333 | font_small = QFont("Microsoft YaHei", 12)
334 | font_key = QFont("Microsoft YaHei", 14, QFont.Bold)
335 |
336 | self.feedback_label = QLabel("请模拟自己PEEK时进行AD和WS急停")
337 | self.feedback_label.setAlignment(Qt.AlignCenter)
338 | self.feedback_label.setFont(font_large)
339 | self.feedback_label.setStyleSheet("""
340 | QLabel {
341 | color: #FFFFFF; background-color: #2E2E2E;
342 | border-radius: 10px; padding: 15px;
343 | }
344 | """)
345 | left_layout.addWidget(self.feedback_label)
346 |
347 | self.key_mappings = {'W': 'W', 'A': 'A', 'S': 'S', 'D': 'D'}
348 | self.reverse_key_mappings = {v: k for k, v in self.key_mappings.items()}
349 |
350 |
351 | key_status_layout = QGridLayout()
352 | self.w_key_label = self.create_key_label(f"{self.key_mappings['W']}键 (W): 未按下", font_key)
353 | key_status_layout.addWidget(self.w_key_label, 0, 1)
354 | self.a_key_label = self.create_key_label(f"{self.key_mappings['A']}键 (A): 未按下", font_key)
355 | key_status_layout.addWidget(self.a_key_label, 1, 0)
356 | self.s_key_label = self.create_key_label(f"{self.key_mappings['S']}键 (S): 未按下", font_key)
357 | key_status_layout.addWidget(self.s_key_label, 1, 1)
358 | self.d_key_label = self.create_key_label(f"{self.key_mappings['D']}键 (D): 未按下", font_key)
359 | key_status_layout.addWidget(self.d_key_label, 1, 2)
360 | left_layout.addLayout(key_status_layout)
361 |
362 | self.history_list = NoSpaceActivateListWidget()
363 | self.history_list.setFont(font_small)
364 | self.history_list.setStyleSheet("""
365 | QListWidget {
366 | background-color: rgba(255, 255, 255, 180); border: 1px solid #CCCCCC;
367 | border-radius: 5px;
368 | }
369 | QListWidget::item:selected { background-color: #ADD8E6; }
370 | """)
371 | self.history_list.itemClicked.connect(self.show_detail_info)
372 | left_layout.addWidget(self.history_list, stretch=2)
373 |
374 | self.output_list = QListWidget()
375 | self.output_list.setFont(font_small)
376 | self.output_list.setStyleSheet("""
377 | QListWidget {
378 | background-color: rgba(255, 255, 255, 180); border: 1px solid #CCCCCC;
379 | border-radius: 5px;
380 | }
381 | QListWidget::item:selected { background-color: #ADD8E6; }
382 | """)
383 | left_layout.addWidget(self.output_list, stretch=1)
384 |
385 | # --- Controls Button Layout (Record Count, Filter, Key Mapping) ---
386 | controls_button_layout = QHBoxLayout()
387 | controls_button_layout.addStretch(1) # ADDED: Stretch before the first button
388 |
389 | self.record_count_button = QPushButton("记录次数")
390 | self.setup_styled_button(self.record_count_button, "设置图表显示的记录次数", self.set_record_count, fixed_width=100)
391 | controls_button_layout.addWidget(self.record_count_button)
392 |
393 | controls_button_layout.addStretch(1) # Stretch between buttons
394 |
395 | self.filter_threshold_button = QPushButton("过滤阈值")
396 | self.setup_styled_button(self.filter_threshold_button, "设置记录有效急停的时间差阈值", self.set_filter_threshold, fixed_width=100)
397 | controls_button_layout.addWidget(self.filter_threshold_button)
398 |
399 | controls_button_layout.addStretch(1) # Stretch between buttons
400 |
401 | self.key_mapping_button = QPushButton("按键映射 (F8)")
402 | self.setup_styled_button(self.key_mapping_button, "设置自定义按键映射 (F8)", self.show_key_mapping_dialog, fixed_width=140)
403 | controls_button_layout.addWidget(self.key_mapping_button)
404 |
405 | controls_button_layout.addStretch(1) # ADDED: Stretch after the last button
406 |
407 | left_layout.addLayout(controls_button_layout)
408 |
409 | # --- Footer Button Layout (Instructions, Refresh, Recommendations) ---
410 | footer_button_layout = QHBoxLayout()
411 | self.instructions_button = QPushButton("使用说明 (F7)")
412 | self.setup_styled_button(self.instructions_button, "查看使用说明 (F7)", self.show_instructions_dialog, fixed_width=140)
413 | footer_button_layout.addWidget(self.instructions_button)
414 |
415 | footer_button_layout.addStretch()
416 |
417 | self.refresh_button = QPushButton("刷新 (F5)")
418 | self.setup_styled_button(self.refresh_button, "刷新图表和记录 (F5)", self.refresh, fixed_width=100)
419 | footer_button_layout.addWidget(self.refresh_button)
420 |
421 | footer_button_layout.addSpacing(10)
422 |
423 | self.recommendations_button = QPushButton("建议 (F6)")
424 | self.setup_styled_button(self.recommendations_button, "查看急停建议 (F6)", self.show_recommendations, fixed_width=100)
425 | self.recommendations_button.hide()
426 | footer_button_layout.addWidget(self.recommendations_button)
427 |
428 | left_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Minimum, QSizePolicy.Expanding))
429 | left_layout.addLayout(footer_button_layout)
430 |
431 |
432 | right_layout = QVBoxLayout()
433 | ad_group = QGroupBox("AD急停图表")
434 | ad_layout = QVBoxLayout()
435 | self.ad_figure_line = Figure(figsize=(5, 3), facecolor='none')
436 | self.ad_canvas_line = FigureCanvas(self.ad_figure_line)
437 | self.ad_canvas_line.setStyleSheet("background-color: transparent;")
438 | ad_layout.addWidget(self.ad_canvas_line)
439 | self.ad_figure_box = Figure(figsize=(5, 3), facecolor='none')
440 | self.ad_canvas_box = FigureCanvas(self.ad_figure_box)
441 | self.ad_canvas_box.setStyleSheet("background-color: transparent;")
442 | ad_layout.addWidget(self.ad_canvas_box)
443 | ad_group.setLayout(ad_layout)
444 | right_layout.addWidget(ad_group)
445 |
446 | ws_group = QGroupBox("WS急停图表")
447 | ws_layout = QVBoxLayout()
448 | self.ws_figure_line = Figure(figsize=(5, 3), facecolor='none')
449 | self.ws_canvas_line = FigureCanvas(self.ws_figure_line)
450 | self.ws_canvas_line.setStyleSheet("background-color: transparent;")
451 | ws_layout.addWidget(self.ws_canvas_line)
452 | self.ws_figure_box = Figure(figsize=(5, 3), facecolor='none')
453 | self.ws_canvas_box = FigureCanvas(self.ws_figure_box)
454 | self.ws_canvas_box.setStyleSheet("background-color: transparent;")
455 | ws_layout.addWidget(self.ws_canvas_box)
456 | ws_group.setLayout(ws_layout)
457 | right_layout.addWidget(ws_group)
458 |
459 | main_layout.addLayout(left_layout, 1)
460 | main_layout.addLayout(right_layout, 2)
461 |
462 | self.setStyleSheet("""
463 | QMainWindow { background-color: transparent; }
464 | QWidget#MainWindow { background-color: transparent; }
465 | QGroupBox {
466 | color: #E0E0E0; font-size: 14px; font-weight: bold;
467 | border: 1px solid rgba(255, 255, 255, 100); border-radius: 5px;
468 | margin-top: 1ex; background-color: rgba(0, 0, 0, 80);
469 | }
470 | QGroupBox::title {
471 | subcontrol-origin: margin; subcontrol-position: top center;
472 | padding: 0 3px; background-color: rgba(0, 0, 0, 0); color: #E0E0E0;
473 | }
474 | QLabel { color: #2E2E2E; }
475 | QListWidget { background-color: rgba(255, 255, 255, 180); }
476 | """)
477 |
478 | self.key_state = {
479 | 'A': {'pressed': False, 'time': None}, 'D': {'pressed': False, 'time': None},
480 | 'W': {'pressed': False, 'time': None}, 'S': {'pressed': False, 'time': None}
481 | }
482 | self.waiting_for_opposite_key = {}
483 | self.ad_data = deque(maxlen=200)
484 | self.ws_data = deque(maxlen=200)
485 | self.last_record_time = 0
486 | self.min_time_between_records = 0.05
487 | self.in_quick_stop_cooldown = False
488 |
489 | self.feedback_signal.connect(self.update_feedback)
490 | self.history_signal.connect(self.update_history)
491 | self.key_state_signal.connect(self.update_key_state_display)
492 | self.start_timer_signal.connect(self.start_timer)
493 | self.stop_timer_signal.connect(self.stop_timer)
494 | self.key_press_signal.connect(self.on_key_press_main_thread)
495 | self.key_release_signal.connect(self.on_key_release_main_thread)
496 | self.log_signal.connect(self.append_log)
497 | self.update_key_labels_signal.connect(self.update_all_key_labels_text)
498 |
499 |
500 | self.record_count = 20
501 | self.filter_threshold = 120
502 | self.box_plot_multiplier = 2
503 | self.timer_buffer = 20
504 |
505 | self.timers = {'AD': QTimer(self), 'WS': QTimer(self)}
506 | self.timers['AD'].setSingleShot(True)
507 | self.timers['AD'].timeout.connect(lambda: self.reset_quick_stop('AD'))
508 | self.timers['WS'].setSingleShot(True)
509 | self.timers['WS'].timeout.connect(lambda: self.reset_quick_stop('WS'))
510 |
511 | self.f5_shortcut = QShortcut(QKeySequence("F5"), self)
512 | self.f5_shortcut.activated.connect(self.refresh)
513 | self.f6_shortcut = QShortcut(QKeySequence("F6"), self)
514 | self.f6_shortcut.activated.connect(self.show_recommendations)
515 | self.f7_shortcut = QShortcut(QKeySequence("F7"), self)
516 | self.f7_shortcut.activated.connect(self.show_instructions_dialog)
517 | self.f8_shortcut = QShortcut(QKeySequence("F8"), self)
518 | self.f8_shortcut.activated.connect(self.show_key_mapping_dialog)
519 |
520 |
521 | try:
522 | self.listener = keyboard.Listener(on_press=self.on_press, on_release=self.on_release, suppress=False)
523 | self.listener.start()
524 | self.log_message("键盘监听器已启动。")
525 | except Exception as e:
526 | self.log_message(f"启动键盘监听失败: {e}")
527 | QMessageBox.critical(self, "错误", f"无法启动键盘监听器。\n请检查程序权限或是否有其他程序占用了键盘钩子。\n以管理员身份运行可能解决此问题。\n错误信息: {e}")
528 | self.feedback_label.setText("键盘监听启动失败!")
529 | self.listener = None
530 |
531 | self.update_plot()
532 |
533 | def setup_styled_button(self, button, tooltip, on_click_action, fixed_width=100, fixed_height=40, font_size=12):
534 | button.setFont(QFont("Microsoft YaHei", font_size))
535 | button.setFixedSize(fixed_width, fixed_height)
536 | button.setStyleSheet("""
537 | QPushButton {
538 | background-color: #2E2E2E; color: #FFFFFF; border: none;
539 | border-radius: 10px; padding: 5px;
540 | }
541 | QPushButton:hover { background-color: #505050; }
542 | QPushButton:pressed { background-color: #1E1E1E; }
543 | """)
544 | button.setToolTip(tooltip)
545 | button.clicked.connect(on_click_action)
546 |
547 |
548 | def create_key_label(self, text, font_key):
549 | label = QLabel(text)
550 | label.setFont(font_key)
551 | label.setAlignment(Qt.AlignCenter)
552 | label.setStyleSheet("""
553 | QLabel {
554 | background-color: #D3D3D3; border: 2px solid #000000;
555 | border-radius: 8px; min-width: 100px;
556 | padding: 5px; color: #2E2E2E;
557 | }
558 | """)
559 | label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
560 | return label
561 |
562 | @pyqtSlot()
563 | def update_all_key_labels_text(self):
564 | self.w_key_label.setText(f"{self.key_mappings['W']}键 (W): {'按下' if self.key_state['W']['pressed'] else '未按下'}")
565 | self.a_key_label.setText(f"{self.key_mappings['A']}键 (A): {'按下' if self.key_state['A']['pressed'] else '未按下'}")
566 | self.s_key_label.setText(f"{self.key_mappings['S']}键 (S): {'按下' if self.key_state['S']['pressed'] else '未按下'}")
567 | self.d_key_label.setText(f"{self.key_mappings['D']}键 (D): {'按下' if self.key_state['D']['pressed'] else '未按下'}")
568 |
569 |
570 | @pyqtSlot(str)
571 | def append_log(self, message):
572 | timestamp = time.strftime("%H:%M:%S", time.localtime()) + f".{int((time.time() % 1) * 1000):03d}"
573 | self.output_list.addItem(f"{timestamp} - {message}")
574 | self.output_list.scrollToBottom()
575 | if self.output_list.count() > 150:
576 | self.output_list.takeItem(0)
577 |
578 | def log_message(self, msg):
579 | print(msg)
580 | self.log_signal.emit(msg)
581 |
582 | def on_press(self, key):
583 | if not hasattr(self, 'listener') or not self.listener or not self.listener.is_alive():
584 | return
585 | try:
586 | original_key_char = key.char.upper()
587 | self.key_press_signal.emit(original_key_char, time.perf_counter())
588 | except AttributeError:
589 | pass
590 | except Exception as e:
591 | print(f"Error in on_press: {e}")
592 |
593 | def on_release(self, key):
594 | if not hasattr(self, 'listener') or not self.listener or not self.listener.is_alive():
595 | return
596 | try:
597 | original_key_char = key.char.upper()
598 | self.key_release_signal.emit(original_key_char, time.perf_counter())
599 | except AttributeError:
600 | pass
601 | except Exception as e:
602 | print(f"Error in on_release: {e}")
603 |
604 | @pyqtSlot(str, float)
605 | def on_key_press_main_thread(self, original_key_char, press_time):
606 | key_char = self.reverse_key_mappings.get(original_key_char)
607 | if not key_char:
608 | return
609 |
610 | if not self.key_state[key_char]['pressed']:
611 | self.key_state[key_char]['pressed'] = True
612 | self.key_state[key_char]['time'] = press_time
613 | self.key_state_signal.emit(key_char, True)
614 | self.log_message(f"按下: {original_key_char} (映射为 {key_char}) at {press_time:.4f}")
615 |
616 | key_type = self.get_key_type(key_char)
617 |
618 | if key_type in self.waiting_for_opposite_key and key_char == self.waiting_for_opposite_key[key_type]['key']:
619 | current_time = time.perf_counter()
620 | if current_time - self.last_record_time < self.min_time_between_records:
621 | self.log_message(f"操作过于频繁,忽略此次 {key_type} 急停 (松开后按)。")
622 | del self.waiting_for_opposite_key[key_type]
623 | self.stop_timer_signal.emit(key_type)
624 | return
625 |
626 | release_time = self.waiting_for_opposite_key[key_type]['release_time']
627 | key_released_before_orig = self.waiting_for_opposite_key[key_type]['key_released_orig']
628 | key_released_before_mapped = self.waiting_for_opposite_key[key_type]['key_released_mapped']
629 |
630 | original_events = self.waiting_for_opposite_key[key_type]['events']
631 |
632 | time_diff = press_time - release_time
633 | time_diff_ms = round(time_diff * 1000, 1)
634 |
635 | self.log_message(f"检测到 {key_type} 急停 (松开后按): {key_released_before_orig} ({key_released_before_mapped}) -> {original_key_char} ({key_char}), 时间差: {time_diff_ms:.1f}ms")
636 |
637 | if abs(time_diff_ms) > self.filter_threshold:
638 | self.log_message(f"时间差 {time_diff_ms:.1f}ms 超过阈值 {self.filter_threshold}ms,忽略记录。")
639 | else:
640 | color = self.get_color(time_diff_ms)
641 | timing = '完美急停' if abs(time_diff_ms) <= 2 else ('按早了' if time_diff_ms < 0 else '按晚了')
642 |
643 | feedback = f"[{key_type}] {timing}:松开{key_released_before_orig}后 {time_diff_ms:.1f}ms 按下了{original_key_char}"
644 |
645 | detail_info = {
646 | 'events': original_events + [
647 | {'key': original_key_char, 'event': '按下', 'time': press_time, 'time_str': self.format_time(press_time)}
648 | ]
649 | }
650 | self.feedback_signal.emit(feedback, color)
651 | self.history_signal.emit(key_type, press_time, time_diff, detail_info, color)
652 | if key_type == 'AD': self.ad_data.append({'time': press_time, 'time_diff': time_diff})
653 | else: self.ws_data.append({'time': press_time, 'time_diff': time_diff})
654 | self.log_message(f"记录 {key_type} 急停 (松开后按): 时间差 {time_diff_ms:.1f}ms")
655 | self.last_record_time = current_time
656 | self.in_quick_stop_cooldown = True
657 |
658 | if key_type in self.waiting_for_opposite_key:
659 | del self.waiting_for_opposite_key[key_type]
660 | self.stop_timer_signal.emit(key_type)
661 |
662 | other_key_type = 'WS' if key_type == 'AD' else 'AD'
663 | if other_key_type in self.waiting_for_opposite_key:
664 | key_released_in_wait_orig = self.waiting_for_opposite_key[other_key_type]['key_released_orig']
665 | expected_key_in_wait_orig = self.key_mappings[self.waiting_for_opposite_key[other_key_type]['key']]
666 |
667 | self.log_message(f"按下 {original_key_char} ({key_char}) 时取消了等待 {expected_key_in_wait_orig} (原松开 {key_released_in_wait_orig}) 的 {other_key_type} 状态。")
668 | del self.waiting_for_opposite_key[other_key_type]
669 | self.stop_timer_signal.emit(other_key_type)
670 |
671 | @pyqtSlot(str, float)
672 | def on_key_release_main_thread(self, original_key_char, release_time):
673 | key_char = self.reverse_key_mappings.get(original_key_char)
674 | if not key_char:
675 | return
676 |
677 | if self.key_state[key_char]['pressed']:
678 | self.key_state[key_char]['pressed'] = False
679 | self.key_state_signal.emit(key_char, False)
680 | self.log_message(f"松开: {original_key_char} (映射为 {key_char}) at {release_time:.4f}")
681 |
682 | self.process_key_event(original_key_char, key_char, release_time)
683 |
684 | all_keys_released = not any(state['pressed'] for state in self.key_state.values())
685 | if all_keys_released and self.in_quick_stop_cooldown:
686 | self.in_quick_stop_cooldown = False
687 | self.log_message("所有按键已释放,重置急停冷却状态。")
688 |
689 |
690 | def process_key_event(self, key_released_orig, key_released_mapped, release_time):
691 | key_type = self.get_key_type(key_released_mapped)
692 | if key_type is None: return
693 |
694 | if key_type == 'AD':
695 | opposite_key_mapped = 'D' if key_released_mapped == 'A' else 'A'
696 | else:
697 | opposite_key_mapped = 'S' if key_released_mapped == 'W' else 'W'
698 |
699 | opposite_key_orig = self.key_mappings[opposite_key_mapped]
700 | opposite_key_state = self.key_state[opposite_key_mapped]
701 |
702 | if opposite_key_state['pressed']:
703 | current_time = time.perf_counter()
704 | if current_time - self.last_record_time < self.min_time_between_records:
705 | self.log_message(f"操作过于频繁,忽略此次 {key_type} 急停 (按住反向键松开)。")
706 | else:
707 | opposite_key_press_time = opposite_key_state['time']
708 | time_diff = opposite_key_press_time - release_time
709 | time_diff_ms = round(time_diff * 1000, 1)
710 |
711 | self.log_message(f"检测到 {key_type} 急停 (按住反向键松开): {key_released_orig} ({key_released_mapped}) -> {opposite_key_orig} ({opposite_key_mapped}), 时间差: {time_diff_ms:.1f}ms")
712 |
713 | if abs(time_diff_ms) > self.filter_threshold:
714 | self.log_message(f"时间差 {time_diff_ms:.1f}ms 超过阈值 {self.filter_threshold}ms,忽略记录。")
715 | else:
716 | color = self.get_color(time_diff_ms)
717 | timing = '完美急停' if abs(time_diff_ms) <= 2 else ('按早了' if time_diff_ms < 0 else '按晚了')
718 |
719 | feedback = f"[{key_type}] {timing}:按下{opposite_key_orig}后 {-time_diff_ms:.1f}ms 松开了{key_released_orig}"
720 |
721 | detail_info = {
722 | 'events': [
723 | {'key': opposite_key_orig, 'event': '按下', 'time': opposite_key_press_time, 'time_str': self.format_time(opposite_key_press_time)},
724 | {'key': key_released_orig, 'event': '松开', 'time': release_time, 'time_str': self.format_time(release_time)}
725 | ]
726 | }
727 | self.feedback_signal.emit(feedback, color)
728 | self.history_signal.emit(key_type, release_time, time_diff, detail_info, color)
729 | if key_type == 'AD': self.ad_data.append({'time': release_time, 'time_diff': time_diff})
730 | else: self.ws_data.append({'time': release_time, 'time_diff': time_diff})
731 | self.log_message(f"记录 {key_type} 急停 (按住反向键松开): 时间差 {time_diff_ms:.1f}ms")
732 | self.last_record_time = current_time
733 | self.in_quick_stop_cooldown = True
734 | if key_type in self.waiting_for_opposite_key:
735 | del self.waiting_for_opposite_key[key_type]
736 | self.stop_timer_signal.emit(key_type)
737 | return
738 |
739 | if key_type in self.waiting_for_opposite_key:
740 | self.log_message(f"覆盖旧的 {key_type} 等待状态。")
741 | self.stop_timer_signal.emit(key_type)
742 |
743 | self.waiting_for_opposite_key[key_type] = {
744 | 'key': opposite_key_mapped,
745 | 'release_time': release_time,
746 | 'key_released_orig': key_released_orig,
747 | 'key_released_mapped': key_released_mapped,
748 | 'events': [
749 | {'key': key_released_orig, 'event': '松开', 'time': release_time, 'time_str': self.format_time(release_time)}
750 | ]
751 | }
752 | self.log_message(f"开始等待按下 {self.key_mappings[opposite_key_mapped]} (映射为 {opposite_key_mapped}) 以完成 {key_type} 急停。")
753 | timer_interval = self.filter_threshold + self.timer_buffer
754 | self.start_timer_signal.emit(key_type, timer_interval)
755 |
756 |
757 | def get_key_type(self, key_char_mapped):
758 | if key_char_mapped in ['A', 'D']: return 'AD'
759 | elif key_char_mapped in ['W', 'S']: return 'WS'
760 | return None
761 |
762 | def update_feedback(self, feedback, color):
763 | self.feedback_label.setText(feedback)
764 | self.feedback_label.setStyleSheet(f"""
765 | QLabel {{
766 | color: #FFFFFF; background-color: {color.name()};
767 | border-radius: 10px; padding: 15px;
768 | }}
769 | """)
770 |
771 | def update_history(self, key_type, event_time, time_diff, detail_info, color):
772 | time_diff_ms = round(time_diff * 1000, 1)
773 | time_str = time.strftime("%H:%M:%S", time.localtime(event_time)) + f".{int((event_time % 1) * 1000):03d}"
774 | item_text = f"[{key_type}] {time_str} - 时间差: {time_diff_ms:.1f}ms"
775 | item = QListWidgetItem(item_text)
776 | item.setData(Qt.UserRole, detail_info)
777 | item.setBackground(QBrush(color))
778 | item.setForeground(QBrush(QColor("#000000" if self.is_light_color(color) else "#FFFFFF")))
779 | self.history_list.addItem(item)
780 | if self.history_list.count() > 50: self.history_list.takeItem(0)
781 | self.history_list.scrollToBottom()
782 |
783 | if len(self.ad_data) >= 10 or len(self.ws_data) >= 10:
784 | if not self.recommendations_button.isVisible(): self.recommendations_button.show()
785 | self.update_plot()
786 |
787 | def update_plot(self):
788 | try:
789 | self.ad_figure_line.clear()
790 | ax_ad_line = self.ad_figure_line.add_subplot(111)
791 | ax_ad_line.set_facecolor('none')
792 | ad_data_list = list(self.ad_data)
793 | ad_plot_data = ad_data_list[-self.record_count:]
794 | ad_time_diffs_line = [round(d['time_diff'] * 1000, 1) for d in ad_plot_data]
795 | start_index_ad = max(0, len(ad_data_list) - self.record_count)
796 | ad_indices_line = range(start_index_ad + 1, start_index_ad + len(ad_plot_data) + 1)
797 | ad_colors_line = [self.get_color(diff) for diff in ad_time_diffs_line]
798 |
799 | if ad_time_diffs_line:
800 | ax_ad_line.scatter(ad_indices_line, ad_time_diffs_line, c=[color.name() for color in ad_colors_line], s=80, edgecolors='black', alpha=0.8)
801 | mean_value_ad = statistics.mean(ad_time_diffs_line)
802 | ax_ad_line.axhline(mean_value_ad, color='cyan', linestyle='--', linewidth=1.5, label=f'平均值: {mean_value_ad:.1f}ms')
803 | ax_ad_line.axhline(0, color='white', linewidth=0.8, linestyle='-')
804 | ax_ad_line.legend(prop={'family': 'Microsoft YaHei', 'size': 9}, facecolor=(0,0,0,0.5), labelcolor='white')
805 | for spine_pos in ['bottom', 'left']: ax_ad_line.spines[spine_pos].set_color('white')
806 | for spine_pos in ['top', 'right']: ax_ad_line.spines[spine_pos].set_visible(False)
807 | ax_ad_line.tick_params(axis='x', colors='white')
808 | ax_ad_line.tick_params(axis='y', colors='white')
809 | ax_ad_line.xaxis.label.set_color('white')
810 | ax_ad_line.yaxis.label.set_color('white')
811 | ax_ad_line.title.set_color('white')
812 | else:
813 | ax_ad_line.text(0.5, 0.5, '无 AD 数据', ha='center', va='center', transform=ax_ad_line.transAxes, color='white')
814 |
815 | ax_ad_line.set_xlabel('操作次数', fontproperties="Microsoft YaHei", fontsize=10)
816 | ax_ad_line.set_ylabel('时间差 (ms)', fontproperties="Microsoft YaHei", fontsize=10)
817 | ax_ad_line.set_title(f'AD 急停时间差 (最近 {len(ad_plot_data)} 次)', fontproperties="Microsoft YaHei", fontsize=12)
818 | ax_ad_line.grid(True, linestyle='--', alpha=0.3, color='gray')
819 | self.ad_canvas_line.draw()
820 | except Exception as e: self.log_message(f"Error updating AD line plot: {e}")
821 |
822 | try:
823 | self.ad_figure_box.clear()
824 | ax_ad_box = self.ad_figure_box.add_subplot(111)
825 | ax_ad_box.set_facecolor('none')
826 | box_plot_count_ad = min(len(ad_data_list), self.record_count * self.box_plot_multiplier)
827 | ad_box_data = ad_data_list[-box_plot_count_ad:]
828 | ad_time_diffs_box = [round(d['time_diff'] * 1000, 1) for d in ad_box_data]
829 |
830 | if len(ad_time_diffs_box) >= 5:
831 | bp_ad = ax_ad_box.boxplot(ad_time_diffs_box, vert=False, patch_artist=True, showfliers=False, widths=0.6)
832 | for box in bp_ad['boxes']: box.set(color='#7570b3', linewidth=1.5, facecolor='#1b9e77', alpha=0.7)
833 | for whisker in bp_ad['whiskers']: whisker.set(color='#7570b3', linewidth=1.5, linestyle='--')
834 | for cap in bp_ad['caps']: cap.set(color='#7570b3', linewidth=1.5)
835 | for median in bp_ad['medians']: median.set(color='#b2df8a', linewidth=2)
836 | ax_ad_box.set_xlabel('时间差 (ms)', fontproperties="Microsoft YaHei", fontsize=10)
837 | ax_ad_box.set_title(f'AD 时间差分布 (最近 {len(ad_box_data)} 次)', fontproperties="Microsoft YaHei", fontsize=12)
838 | ax_ad_box.grid(True, linestyle='--', alpha=0.3, axis='x', color='gray')
839 | ax_ad_box.tick_params(axis='x', colors='white')
840 | ax_ad_box.tick_params(axis='y', colors='none', length=0)
841 | ax_ad_box.set_yticks([])
842 | ax_ad_box.xaxis.label.set_color('white')
843 | ax_ad_box.title.set_color('white')
844 | for spine_pos in ['top', 'right', 'left']: ax_ad_box.spines[spine_pos].set_visible(False)
845 | ax_ad_box.spines['bottom'].set_color('white')
846 | else:
847 | ax_ad_box.text(0.5, 0.5, 'AD 数据不足', ha='center', va='center', transform=ax_ad_box.transAxes, color='white')
848 | ax_ad_box.set_yticks([])
849 | for spine_pos in ['top', 'right', 'left', 'bottom']: ax_ad_box.spines[spine_pos].set_visible(False)
850 | self.ad_canvas_box.draw()
851 | except Exception as e: self.log_message(f"Error updating AD box plot: {e}")
852 |
853 | try:
854 | self.ws_figure_line.clear()
855 | ax_ws_line = self.ws_figure_line.add_subplot(111)
856 | ax_ws_line.set_facecolor('none')
857 | ws_data_list = list(self.ws_data)
858 | ws_plot_data = ws_data_list[-self.record_count:]
859 | ws_time_diffs_line = [round(d['time_diff'] * 1000, 1) for d in ws_plot_data]
860 | start_index_ws = max(0, len(ws_data_list) - self.record_count)
861 | ws_indices_line = range(start_index_ws + 1, start_index_ws + len(ws_plot_data) + 1)
862 | ws_colors_line = [self.get_color(diff) for diff in ws_time_diffs_line]
863 |
864 | if ws_time_diffs_line:
865 | ax_ws_line.scatter(ws_indices_line, ws_time_diffs_line, c=[color.name() for color in ws_colors_line], s=80, edgecolors='black', alpha=0.8)
866 | mean_value_ws = statistics.mean(ws_time_diffs_line)
867 | ax_ws_line.axhline(mean_value_ws, color='cyan', linestyle='--', linewidth=1.5, label=f'平均值: {mean_value_ws:.1f}ms')
868 | ax_ws_line.axhline(0, color='white', linewidth=0.8, linestyle='-')
869 | ax_ws_line.legend(prop={'family': 'Microsoft YaHei', 'size': 9}, facecolor=(0,0,0,0.5), labelcolor='white')
870 | for spine_pos in ['bottom', 'left']: ax_ws_line.spines[spine_pos].set_color('white')
871 | for spine_pos in ['top', 'right']: ax_ws_line.spines[spine_pos].set_visible(False)
872 | ax_ws_line.tick_params(axis='x', colors='white')
873 | ax_ws_line.tick_params(axis='y', colors='white')
874 | ax_ws_line.xaxis.label.set_color('white')
875 | ax_ws_line.yaxis.label.set_color('white')
876 | ax_ws_line.title.set_color('white')
877 | else:
878 | ax_ws_line.text(0.5, 0.5, '无 WS 数据', ha='center', va='center', transform=ax_ws_line.transAxes, color='white')
879 |
880 | ax_ws_line.set_xlabel('操作次数', fontproperties="Microsoft YaHei", fontsize=10)
881 | ax_ws_line.set_ylabel('时间差 (ms)', fontproperties="Microsoft YaHei", fontsize=10)
882 | ax_ws_line.set_title(f'WS 急停时间差 (最近 {len(ws_plot_data)} 次)', fontproperties="Microsoft YaHei", fontsize=12)
883 | ax_ws_line.grid(True, linestyle='--', alpha=0.3, color='gray')
884 | self.ws_canvas_line.draw()
885 | except Exception as e: self.log_message(f"Error updating WS line plot: {e}")
886 |
887 | try:
888 | self.ws_figure_box.clear()
889 | ax_ws_box = self.ws_figure_box.add_subplot(111)
890 | ax_ws_box.set_facecolor('none')
891 | box_plot_count_ws = min(len(ws_data_list), self.record_count * self.box_plot_multiplier)
892 | ws_box_data = ws_data_list[-box_plot_count_ws:]
893 | ws_time_diffs_box = [round(d['time_diff'] * 1000, 1) for d in ws_box_data]
894 |
895 | if len(ws_time_diffs_box) >= 5:
896 | bp_ws = ax_ws_box.boxplot(ws_time_diffs_box, vert=False, patch_artist=True, showfliers=False, widths=0.6)
897 | for box in bp_ws['boxes']: box.set(color='#D95F02', linewidth=1.5, facecolor='#FF7F0E', alpha=0.7)
898 | for whisker in bp_ws['whiskers']: whisker.set(color='#D95F02', linewidth=1.5, linestyle='--')
899 | for cap in bp_ws['caps']: cap.set(color='#D95F02', linewidth=1.5)
900 | for median in bp_ws['medians']: median.set(color='#ffff99', linewidth=2)
901 | ax_ws_box.set_xlabel('时间差 (ms)', fontproperties="Microsoft YaHei", fontsize=10)
902 | ax_ws_box.set_title(f'WS 时间差分布 (最近 {len(ws_box_data)} 次)', fontproperties="Microsoft YaHei", fontsize=12)
903 | ax_ws_box.grid(True, linestyle='--', alpha=0.3, axis='x', color='gray')
904 | ax_ws_box.tick_params(axis='x', colors='white')
905 | ax_ws_box.tick_params(axis='y', colors='none', length=0)
906 | ax_ws_box.set_yticks([])
907 | ax_ws_box.xaxis.label.set_color('white')
908 | ax_ws_box.title.set_color('white')
909 | for spine_pos in ['top', 'right', 'left']: ax_ws_box.spines[spine_pos].set_visible(False)
910 | ax_ws_box.spines['bottom'].set_color('white')
911 | else:
912 | ax_ws_box.text(0.5, 0.5, 'WS 数据不足', ha='center', va='center', transform=ax_ws_box.transAxes, color='white')
913 | ax_ws_box.set_yticks([])
914 | for spine_pos in ['top', 'right', 'left', 'bottom']: ax_ws_box.spines[spine_pos].set_visible(False)
915 | self.ws_canvas_box.draw()
916 | except Exception as e: self.log_message(f"Error updating WS box plot: {e}")
917 |
918 |
919 | def show_detail_info(self, item):
920 | detail_info = item.data(Qt.UserRole)
921 | if detail_info and 'events' in detail_info:
922 | events = detail_info['events']
923 | message = "按键事件序列:\n" + "-"*20 + "\n"
924 | try:
925 | sorted_events = sorted(events, key=lambda x: x['time'])
926 | for event in sorted_events:
927 | key = event.get('key', '?')
928 | ev_type = event.get('event', '?')
929 | time_str = event.get('time_str', '?')
930 | message += f"{time_str} - {key}键 {ev_type}\n"
931 | except Exception as e:
932 | message += f"\nError formatting events: {e}"
933 | QMessageBox.information(self, "详细信息", message)
934 | else:
935 | QMessageBox.warning(self, "详细信息", "无法加载详细事件信息。")
936 |
937 | def format_time(self, timestamp):
938 | return f"{timestamp:.3f}s"
939 |
940 | @pyqtSlot(str, bool)
941 | def update_key_state_display(self, key_char_mapped, is_pressed):
942 | label_mapping = {
943 | 'A': self.a_key_label, 'D': self.d_key_label,
944 | 'W': self.w_key_label, 'S': self.s_key_label
945 | }
946 | if key_char_mapped in label_mapping:
947 | label = label_mapping[key_char_mapped]
948 | physical_key = self.key_mappings.get(key_char_mapped, key_char_mapped)
949 |
950 | label.setText(f"{physical_key}键 ({key_char_mapped}): {'按下' if is_pressed else '未按下'}")
951 | if is_pressed:
952 | label.setStyleSheet("""
953 | QLabel {
954 | background-color: #90EE90; border: 2px solid #000000; border-radius: 8px;
955 | min-width: 100px; padding: 5px; color: #000000;
956 | }
957 | """)
958 | else:
959 | label.setStyleSheet("""
960 | QLabel {
961 | background-color: #D3D3D3; border: 2px solid #000000; border-radius: 8px;
962 | min-width: 100px; padding: 5px; color: #2E2E2E;
963 | }
964 | """)
965 |
966 | def get_color(self, time_diff_ms):
967 | max_time_diff = self.filter_threshold
968 | normalized_diff = min(abs(time_diff_ms), max_time_diff) / max(max_time_diff, 1e-6)
969 | perfect_color = QColor(144, 238, 144)
970 | early_start_color = QColor(173, 216, 230); early_end_color = QColor(0, 0, 139)
971 | late_start_color = QColor(255, 182, 193); late_end_color = QColor(139, 0, 0)
972 |
973 | if abs(time_diff_ms) <= 2: return perfect_color
974 | elif time_diff_ms < 0: start_color, end_color = early_start_color, early_end_color
975 | else: start_color, end_color = late_start_color, late_end_color
976 |
977 | r = max(0.0, min(1.0, start_color.redF() + (end_color.redF() - start_color.redF()) * normalized_diff))
978 | g = max(0.0, min(1.0, start_color.greenF() + (end_color.greenF() - start_color.greenF()) * normalized_diff))
979 | b = max(0.0, min(1.0, start_color.blueF() + (end_color.blueF() - start_color.blueF()) * normalized_diff))
980 | return QColor.fromRgbF(r, g, b)
981 |
982 | def is_light_color(self, color):
983 | return (color.red() * 299 + color.green() * 587 + color.blue() * 114) / 1000 > 128
984 |
985 | def show_recommendations(self):
986 | if not self.ad_data and not self.ws_data:
987 | QMessageBox.information(self, "急停建议", "暂无足够数据可供分析。")
988 | return
989 | recommendations = []
990 | min_data_points = 5
991 | for key_type_label, data_deque in [("AD", self.ad_data), ("WS", self.ws_data)]:
992 | if len(data_deque) >= min_data_points:
993 | filtered_data = [d['time_diff'] * 1000 for d in data_deque if abs(d['time_diff'] * 1000) <= self.filter_threshold]
994 | if len(filtered_data) >= min_data_points:
995 | avg_diff = round(statistics.mean(filtered_data), 1)
996 | stdev = round(statistics.stdev(filtered_data), 1) if len(filtered_data) > 1 else 0
997 | rec = f"--- {key_type_label} 急停分析 (基于 {len(filtered_data)} 次有效记录) ---\n"
998 | rec += f"平均时间差: {avg_diff:.1f}ms\n标准差 (稳定性): {stdev:.1f}ms\n\n"
999 | if avg_diff < -5: rec += "趋势: 偏早 (反向键按得太快)\n建议: 尝试略微延迟按反向键的时机,或检查键盘设置 (如 Rapid Trigger 的触发点)。"
1000 | elif avg_diff > 5: rec += "趋势: 偏晚 (反向键按得太慢)\n建议: 尝试更快地按下反向键,或检查键盘设置 (如缩短触发键程)。"
1001 | else: rec += "趋势: 良好 (接近同步)\n建议: 继续保持!"
1002 | if stdev > 15: rec += "\n稳定性提示: 时间差波动较大,尝试更一致地执行急停操作。"
1003 | recommendations.append(rec)
1004 | else: recommendations.append(f"--- {key_type_label} 急停分析 ---\n有效数据不足 ({len(filtered_data)}/{min_data_points})。")
1005 | else: recommendations.append(f"--- {key_type_label} 急停分析 ---\n数据不足 ({len(data_deque)}/{min_data_points})。")
1006 | QMessageBox.information(self, "急停建议", "\n\n".join(recommendations))
1007 |
1008 | def show_instructions_dialog(self):
1009 | """ Displays the custom instructions dialog. """
1010 | dialog = InstructionsDialog(self)
1011 | dialog.exec_()
1012 |
1013 |
1014 | def show_key_mapping_dialog(self):
1015 | dialog = KeyMappingDialog(self.key_mappings.copy(), self)
1016 | if dialog.exec_() == QDialog.Accepted:
1017 | new_mappings = dialog.get_mappings()
1018 | if len(set(new_mappings.values())) != len(new_mappings.values()):
1019 | QMessageBox.warning(self, "映射错误", "映射的按键必须唯一。")
1020 | return
1021 | if any(not k for k in new_mappings.values()):
1022 | QMessageBox.warning(self, "映射错误", "映射的按键不能为空。")
1023 | return
1024 |
1025 | self.key_mappings = new_mappings
1026 | self.reverse_key_mappings = {v: k for k, v in self.key_mappings.items()}
1027 | self.log_message(f"按键映射已更新: {self.key_mappings}")
1028 | self.update_key_labels_signal.emit()
1029 | QMessageBox.information(self, "按键映射", "按键映射已成功更新!")
1030 |
1031 |
1032 | @pyqtSlot(str, int)
1033 | def start_timer(self, key_type, interval):
1034 | if key_type in self.timers:
1035 | safe_interval = max(1, interval)
1036 | self.timers[key_type].start(safe_interval)
1037 | self.log_message(f"启动 {key_type} 等待计时器 ({safe_interval}ms)")
1038 |
1039 | @pyqtSlot(str)
1040 | def stop_timer(self, key_type):
1041 | if key_type in self.timers and self.timers[key_type].isActive():
1042 | self.timers[key_type].stop()
1043 |
1044 | def reset_quick_stop(self, key_type):
1045 | if key_type in self.waiting_for_opposite_key:
1046 | timer_interval = self.timers[key_type].interval()
1047 |
1048 | key_released_orig = self.waiting_for_opposite_key[key_type]['key_released_orig']
1049 | expected_key_mapped = self.waiting_for_opposite_key[key_type]['key']
1050 | expected_key_orig = self.key_mappings[expected_key_mapped]
1051 |
1052 | self.log_message(f"超时 ({timer_interval}ms): 松开 {key_released_orig} 后未及时按下 {expected_key_orig} (映射为 {expected_key_mapped})。取消 {key_type} 等待状态。")
1053 | del self.waiting_for_opposite_key[key_type]
1054 | if not any(state['pressed'] for state in self.key_state.values()):
1055 | if self.in_quick_stop_cooldown:
1056 | self.in_quick_stop_cooldown = False
1057 | self.log_message("所有按键已释放 (超时后检查),重置急停冷却状态。")
1058 |
1059 | def resizeEvent(self, event):
1060 | if hasattr(self, 'background_label') and self.background_label:
1061 | self.background_label.setGeometry(self.centralWidget().rect())
1062 | super().resizeEvent(event)
1063 |
1064 | def refresh(self):
1065 | self.log_message("刷新操作已触发。")
1066 | self.ad_data.clear(); self.ws_data.clear()
1067 | self.history_list.clear(); self.output_list.clear()
1068 | self.key_state = {k: {'pressed': False, 'time': None} for k in self.key_state}
1069 | self.waiting_for_opposite_key.clear()
1070 | self.in_quick_stop_cooldown = False
1071 | self.last_record_time = 0
1072 | for timer in self.timers.values(): timer.stop()
1073 |
1074 | self.update_key_labels_signal.emit()
1075 | for key_char_mapped in ['A', 'D', 'W', 'S']:
1076 | self.key_state_signal.emit(key_char_mapped, False)
1077 |
1078 |
1079 | self.feedback_label.setText("请模拟自己PEEK时进行AD和WS急停")
1080 | self.feedback_label.setStyleSheet("QLabel { color: #FFFFFF; background-color: #2E2E2E; border-radius: 10px; padding: 15px; }")
1081 | self.update_plot()
1082 | self.recommendations_button.hide()
1083 | self.log_message("界面和数据已刷新。")
1084 |
1085 | def set_record_count(self):
1086 | options = ["10次", "20次", "50次", "100次", "200次"]
1087 | current_option = f"{self.record_count}次"
1088 | if current_option not in options: options.insert(0, current_option)
1089 | dialog = OptionDialog("选择图表记录次数", options, self)
1090 | dialog.set_selected_option(current_option)
1091 | if dialog.exec_() == QDialog.Accepted:
1092 | selected = dialog.get_selected_option()
1093 | if selected:
1094 | try:
1095 | new_count = int(selected.replace("次", ""))
1096 | if new_count > 0:
1097 | self.record_count = new_count
1098 | self.log_message(f"图表记录次数已设置为 {self.record_count} 次。")
1099 | self.update_plot()
1100 | else: QMessageBox.warning(self, "无效输入", "记录次数必须大于 0。")
1101 | except ValueError: QMessageBox.warning(self, "无效输入", "无法解析选择的次数。")
1102 |
1103 | def set_filter_threshold(self):
1104 | options = ["20ms", "40ms", "60ms", "80ms", "100ms", "120ms", "150ms", "200ms"]
1105 | current_option = f"{self.filter_threshold}ms"
1106 | if current_option not in options: options.insert(0, current_option)
1107 | dialog = OptionDialog("选择时间差过滤阈值 (ms)", options, self)
1108 | dialog.set_selected_option(current_option)
1109 | if dialog.exec_() == QDialog.Accepted:
1110 | selected = dialog.get_selected_option()
1111 | if selected:
1112 | try:
1113 | new_threshold = int(selected.replace("ms", ""))
1114 | if new_threshold >= 0:
1115 | self.filter_threshold = new_threshold
1116 | self.log_message(f"过滤阈值已设置为 {self.filter_threshold}ms。")
1117 | else: QMessageBox.warning(self, "无效输入", "过滤阈值必须大于或等于 0。")
1118 | except ValueError: QMessageBox.warning(self, "无效输入", "无法解析选择的阈值。")
1119 |
1120 | def closeEvent(self, event):
1121 | self.log_message("关闭应用程序...")
1122 | if hasattr(self, 'listener') and self.listener and self.listener.is_alive():
1123 | try:
1124 | self.listener.stop()
1125 | self.listener.join(timeout=0.5)
1126 | if self.listener.is_alive(): self.log_message("警告:键盘监听器线程未能及时停止。")
1127 | else: self.log_message("键盘监听器已停止。")
1128 | except Exception as e: self.log_message(f"停止监听器时出错: {e}")
1129 | event.accept()
1130 |
1131 | def main():
1132 | if hasattr(Qt, 'AA_EnableHighDpiScaling'): QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
1133 | if hasattr(Qt, 'AA_UseHighDpiPixmaps'): QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
1134 | QApplication.setApplicationName("CS2急停评估工具")
1135 | QApplication.setOrganizationName("CS2ToolDev")
1136 |
1137 | try:
1138 | app = QApplication(sys.argv)
1139 | window = MainWindow()
1140 | window.show()
1141 | sys.exit(app.exec_())
1142 | except Exception as e:
1143 | print(f"发生未处理的错误: {e}")
1144 | import traceback
1145 | traceback.print_exc()
1146 | QMessageBox.critical(None, "严重错误", f"应用程序遇到无法处理的错误并即将退出。\n\n{traceback.format_exc()}")
1147 | sys.exit(1)
1148 |
1149 | if __name__ == "__main__":
1150 | main()
1151 |
--------------------------------------------------------------------------------