├── 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 | ![image](https://github.com/user-attachments/assets/bc1b8da1-6dc4-43f0-861e-324c517ca0d2) 2 | 3 | ![image](https://github.com/user-attachments/assets/943b62b2-9621-4622-b7ec-2ba449f31d7a) 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 | --------------------------------------------------------------------------------